실무 Multi-Agent 오케스트레이션 16장 · 멀티턴 대화와 세션 관리 5 / 7 ← 이전목차다음 → TechLead Cro

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 입니다.
← 이전 절기억의 비용과 trim — 이력은 매번 다시 나간다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com