16장. 멀티턴 대화와 세션 관리
따라하기 — 세 턴짜리 멀티턴 상담
목표
고객 한 명과 세 번 이어지는 대화를 실행합니다. 두 번째 문의의 "그거", 세 번째 문의의 "아까 말한 상품"을 상담원이 알아듣는지, 그리고 턴이 지날 때마다 입력 토큰이 얼마나 커지는지 확인합니다.
0. 실습 준비
이 장의 실습 고객 —
C007강예준 고객(실버 등급)으로 로그인한 상태라고 정해 두고 실습합니다.
VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.
$ conda activate myenv
이번 장은 새로 설치할 것이 없습니다. 다만 8장에서 만든 haru_tools.py 가 폴더 맨 위에 있어야 합니다. 이번 장의 코드가 그 파일의 CustomerSession과 make_tools를 불러 씁니다.
$ ls haru_tools.py
파일 이름이 그대로 출력되면 준비된 것입니다. 없다고 나오면 8장으로 돌아가 먼저 만듭니다.
1. 파일 만들기
haru-market 폴더 맨 위에 새 파일을 만듭니다.
lesson16_multiturn.py
아래 코드 전체를 복사해 붙여 넣고 저장합니다.
# -*- coding: utf-8 -*-
"""[16장] 멀티턴 대화와 세션 관리
문제 상황:
고객: "제 최근 주문 상태 알려주세요."
하루: "무선 핸디 청소기가 배송완료 상태입니다."
고객: "그거 환불하면 언제 들어와요?" ← '그거'가 뭔지 모델은 모른다
원인: LLM 은 상태가 없다(stateless). 매 호출이 처음 만남이다.
해결: 대화 이력을 우리가 저장했다가 매번 함께 보낸다.
- 이력 = 세션(스레드)별로 관리
- 이력이 길어지면 토큰이 커진다 → 오래된 턴은 잘라낸다(trim)
- 턴(turn) = 고객 문의 하나와 그 처리 전체(도구 호출·도구 결과·최종 답변)
실행: python lesson16_multiturn.py
"""
from google.genai import types
from config import MODEL, get_client, print_usage
from haru_tools import CustomerSession, make_tools
client = get_client()
session = CustomerSession("C007")
tools = make_tools(session)
SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
대화 맥락을 기억하고, '그거/아까 그 주문' 같은 지시어를 앞선 대화에서 찾아 이해합니다.
반드시 도구로 조회한 데이터로만 답합니다. 존댓말, 3~5문장.
환불 기간·배송비 같은 정책 내용은 이 도구들로 조회할 수 없습니다. 지어내지 않고
"확인 후 안내드리겠습니다"라고 답합니다."""
class ChatSession:
"""세션(스레드) 하나의 대화 이력을 관리한다."""
def __init__(self, thread_id: str, max_turns: int = 10):
self.thread_id = thread_id
self.history: list[types.Content] = [] # 대화 이력이 곧 '기억'
self.max_turns = max_turns # 보관할 최근 턴 수
@staticmethod
def _is_turn_start(content: types.Content) -> bool:
"""고객이 직접 말한 메시지인가 — 턴이 시작되는 자리.
도구 결과(function_response)도 role 이 "user" 인 메시지로 이력에 들어간다.
그래서 role 만 봐서는 고객의 말과 도구 결과를 가릴 수 없고, 글(text)이 있는지까지 본다.
"""
return content.role == "user" and any(p.text for p in (content.parts or []))
def _trim(self):
"""턴 수가 max_turns 를 넘으면 앞(오래된 턴)부터 버린다 — 토큰 관리.
주의: 반드시 턴이 시작되는 자리에서 자른다. 도구 호출(function_call)과
도구 결과(function_response)는 짝이라, 그 사이를 자르면 API 가 400 오류를 낸다.
(17장에서 '버리는 대신 요약'으로 개선한다)
"""
starts = [i for i, c in enumerate(self.history) if self._is_turn_start(c)]
if len(starts) <= self.max_turns:
return
self.history = self.history[starts[-self.max_turns]:]
def send(self, text: str) -> str:
self.history.append(
types.Content(role="user", parts=[types.Part.from_text(text=text)]))
# 도구 왕복(자동)까지 포함해 이력 전체를 보낸다
response = client.models.generate_content(
model=MODEL,
contents=self.history, # 매번 '전체 이력'이 입력으로 나간다
config=types.GenerateContentConfig(
system_instruction=SYSTEM, temperature=1.0,
tools=list(tools.values()),
automatic_function_calling=types.AutomaticFunctionCallingConfig(
maximum_remote_calls=5),
),
)
# 자동 호출 왕복 이력(도구 결정·결과)도 대화 이력에 보존해야
# 다음 턴에 "아까 조회한 그 주문"을 이해할 수 있다.
# 이 이력 안에서 도구 결정은 role="model", 도구 결과는 role="user" 메시지다.
if response.automatic_function_calling_history:
self.history = list(response.automatic_function_calling_history)
if response.candidates:
self.history.append(response.candidates[0].content)
self._trim()
print_usage(response, f"(이력 {len(self.history)}개 메시지)")
return response.text
if __name__ == "__main__":
chat = ChatSession(thread_id="web-C007-0001")
turns = [
"제 최근 주문 상태 알려주세요.",
"그거 혹시 지금 취소하면 환불 언제 들어와요?", # '그거' → 앞 턴의 주문
"아 맞다, 아까 말한 상품 색상 다른 것도 있어요?", # 두 턴 전 문맥 참조
]
for t in turns:
print(f"\n고객: {t}")
print(f"하루: {chat.send(t)}")
print("""
──────────────────────────────────────────────────────────
확인할 것
- 2번째 문의부터 '그거'를 정확히 이해한다 (이력 덕분)
- [토큰] 줄의 '입력'이 턴마다 커진다 ← 이력 전체가 매번 입력으로 나가기 때문
→ 대화가 길어질수록 비용 증가. trim 은 임시방편이다.
버리는 대신 요약하는 방법은 17장에서 다룬다.
──────────────────────────────────────────────────────────""")
2. 코드에서 볼 곳
파일은 세 부분으로 되어 있습니다.
| 부분 | 내용 | 볼 것 |
|---|---|---|
| 준비 | CustomerSession("C007"), make_tools(session), SYSTEM |
로그인한 고객은 C007. 시스템 프롬프트에 지시어를 이력에서 찾으라는 줄과 정책은 지어내지 말라는 줄 |
ChatSession |
history, _trim(), send() |
이력을 어디에 두고, 언제 붙이고, 언제 자르는가 |
| 실행 | 문의 세 개를 차례로 chat.send() |
같은 chat 객체에 계속 보낸다 |
가장 중요한 곳은 send()입니다. 하는 일은 네 단계입니다.
def send(self, text: str) -> str:
self.history.append( # (1) 새 문의를 이력에 붙인다
types.Content(role="user", parts=[types.Part.from_text(text=text)]))
response = client.models.generate_content(
model=MODEL,
contents=self.history, # (2) 이력 전체를 보낸다
config=...,
)
if response.automatic_function_calling_history: # (3) 도구 왕복과 답변을 이력에 남긴다
self.history = list(response.automatic_function_calling_history)
if response.candidates:
self.history.append(response.candidates[0].content)
self._trim() # (4) 너무 길면 오래된 턴을 버린다
print_usage(response, f"(이력 {len(self.history)}개 메시지)")
return response.text
실행 부분에서는 chat을 한 번만 만든다는 점을 봅니다. 문의마다 새로 만들면 이력이 매번 비어 버립니다.
chat = ChatSession(thread_id="web-C007-0001")
for t in turns:
print(f"\n고객: {t}")
print(f"하루: {chat.send(t)}")
3. 실행하기
실행하기 전에 짐작해 보세요.
- 두 번째 문의의 "그거"를 상담원은 무엇으로 이해할까요? 고객 C007의 가장 최근 주문은 7월 22일에 배송이 끝난 무선 핸디 청소기입니다.
- 문의는 매번 한 문장입니다. 입력 토큰은 턴마다 같을까요, 커질까요?
$ python lesson16_multiturn.py
LLM을 여섯 번쯤 호출하므로 10~20초 걸립니다.
실행 결과 (문장은 실행할 때마다 달라집니다)
고객: 제 최근 주문 상태 알려주세요.
[토큰 (이력 4개 메시지)] 입력 1048 + 출력 101 = 총 1149
하루: 고객님의 최근 주문 내역을 확인한 결과, 2026년 7월 19일에 주문하신 무선 핸디 청소기(주문번호: HR20260719073)가 현재 '배송완료' 상태입니다. 이 외에도 손목 보호대와 현미 누룽지 주문 건이 모두 정상적으로 배송 완료되었습니다. 다른 도움이 필요하신 사항이 있으시면 언제든지 말씀해 주세요!
고객: 그거 혹시 지금 취소하면 환불 언제 들어와요?
[토큰 (이력 6개 메시지)] 입력 1166 + 출력 69 = 총 1235
하루: 아까 말씀드린 무선 핸디 청소기(주문번호: HR20260719073)는 이미 배송이 완료된 상태입니다. 환불 기간이나 절차에 대한 정확한 정책 내용은 확인 후 안내해 드리겠습니다. 상담원 연결이 필요하시면 말씀해 주세요.
고객: 아 맞다, 아까 말한 상품 색상 다른 것도 있어요?
[토큰 (이력 10개 메시지)] 입력 1380 + 출력 50 = 총 1430
하루: 고객님께서 주문하셨던 무선 핸디 청소기는 '화이트' 외에도 '그레이' 색상 옵션이 준비되어 있습니다. 추가로 궁금하신 점이 있으시면 언제든지 편하게 말씀해 주세요!
(이하 생략)
[토큰 …] 줄이 "하루:"보다 먼저 나오는 것은 send() 안에서 토큰을 출력한 뒤에 답을 돌려주기 때문입니다.
| 이렇게 나오면 | 원인과 조치 |
|---|---|
ModuleNotFoundError: No module named 'haru_tools' |
8장의 haru_tools.py가 폴더 맨 위에 없습니다. 8장에서 만든 뒤 다시 실행합니다 |
[설정 필요] GOOGLE_API_KEY 가 없습니다 |
.env 파일을 확인합니다(2장) |
| 이력의 메시지 수가 위와 다르다 | 정상입니다. 모델이 도구를 부르지 않고 답한 턴은 메시지가 두 개만 늘어납니다 |
4. 무엇을 관찰했나
"그거"가 주문번호까지 특정되었다
두 번째 턴에서 "그거"가 무선 핸디 청소기(주문번호: HR20260719073) 로 이해되었습니다. 세 번째 턴의 "아까 말한 상품"도 같은 청소기로 이어졌습니다. 고객은 두 번째, 세 번째 문의에서 상품 이름을 한 번도 말하지 않았습니다.
- "그거"를 해석한 것은 모델입니다.
- 해석할 재료(앞 턴의 메시지들)를 넣어 준 것은 우리 코드입니다.
조회한 것은 답하고, 조회할 수 없는 것은 미뤘다
두 번째 턴의 답은 두 부분으로 되어 있습니다.
| 답의 내용 | 어디서 왔나 |
|---|---|
| "이미 배송이 완료된 상태입니다" | 첫 턴의 도구 결과. 이력에 남아 있어서 다시 조회하지 않았다 |
| "환불 기간이나 절차는 확인 후 안내해 드리겠습니다" | 시스템 프롬프트의 경계. 정책은 이 도구들로 조회할 수 없다고 적어 두었다 |
배송 완료라는 사실은 고객이 말한 적이 없습니다. 첫 턴에 get_my_orders가 돌려준 값이 이력에 들어 있었고, 모델이 거기서 꺼내 썼습니다.
환불 기간은 주문 데이터가 아니라 정책 문서에 있습니다. 이 상담원에게는 정책을 찾는 도구가 없으므로, 기간을 말하지 않고 확인 후 안내하겠다고 답했습니다. 프롬프트에 적어 둔 대로입니다.
이력은 문맥을 이어 줍니다. 사실은 도구와 문서에서 옵니다. 주문 도구와 정책 검색(12~15장)을 한 대화 안에서 함께 쓰는 일은 18장 이후에 합니다.
입력 토큰이 턴마다 커졌다
| 턴 | 고객이 보낸 것 | 이력 | 입력 토큰 |
|---|---|---|---|
| 1 | 한 문장 | 4개 메시지 | 1,048 |
| 2 | 한 문장 | 6개 메시지 | 1,166 |
| 3 | 한 문장 | 10개 메시지 | 1,380 |
새 문의는 매번 한 문장인데 입력은 턴마다 120~210토큰씩 커졌습니다. 이력 전체가 매번 다시 나가고 있기 때문입니다. 세 턴에 1.3배가 되었으니, 스무 턴짜리 상담에서 마지막 한 마디가 얼마가 될지 짐작할 수 있습니다.
첫 턴의 1,048토큰 대부분은 시스템 프롬프트와 도구 여섯 개의 설명입니다. 이것도 매 턴 다시 나갑니다.
이력의 메시지 수도 함께 봅니다. 도구를 부른 턴(1, 3번째)은 네 개씩, 부르지 않은 턴(2번째)은 두 개가 늘었습니다. 메시지 수가 늘어난 폭만 봐도 그 턴에 도구를 썼는지 알 수 있습니다.
trim은 아직 일할 차례가 아니었다
max_turns의 기본값이 10인데 대화는 세 턴이었습니다. 그래서 _trim()은 매번 아무것도 하지 않고 돌아갔습니다. trim이 일하는 모습은 실습문제 2에서 봅니다.
5. 지금 폴더의 모습
haru-market/
├── config.py
├── haru_tools.py (8장에서 만든 도구 모음)
├── (lesson02 ~ lesson15 생략)
└── lesson16_multiturn.py ← 이번 장
핵심 정리
- 같은
ChatSession객체에 계속send()하면 "그거", "아까 말한 상품" 이 앞 턴의 주문으로 이어집니다. - 고객이 말한 적 없는 주문번호와 배송 완료 상태가 답에 나온 것은 도구 결과가 이력에 남아 있었기 때문입니다.
- 입력 토큰이 1,048 → 1,166 → 1,380 으로 커졌습니다. 이력 전체가 매번 다시 나갑니다.
- 정책처럼 가진 도구로 조회할 수 없는 것은 지어내지 않고 확인 후 안내한다고 답했습니다.
- 이번 장의 산출물은 대화 이력을 관리하는
ChatSession입니다.