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

16장. 멀티턴 대화와 세션 관리

실습문제와 해답

ChatSession을 가져다 써 보며 16장 내용을 손에 익힙니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다.

lesson16_multiturn.py의 실행 부분은 if __name__ == "__main__": 아래에 있어서, 다른 파일에서 from lesson16_multiturn import ChatSession으로 가져와도 세 턴짜리 대화가 다시 실행되지 않습니다.


문제 1 — 스레드 두 개는 서로의 "그거"를 모른다

새 파일 ex16_threads.py를 만들어 스레드 두 개가 섞이지 않는 것을 확인하세요.

  • 스레드 A: "7월 11일에 주문한 손목 보호대 배송 상태 알려주세요."
  • 스레드 B: "7월 19일에 주문한 무선 핸디 청소기 배송 상태 알려주세요."
  • 그다음 두 스레드에 똑같이 "그거 다른 색상도 있어요?"를 보낸다.
  • 두 스레드의 이력 메시지 수를 출력한다.

실행하기 전에 짐작해 보세요. 같은 문장에 두 스레드는 각각 무엇이라고 답할까요?

해답 보기
from lesson16_multiturn import ChatSession

SECOND = "그거 다른 색상도 있어요?"

a = ChatSession(thread_id="ex-A")
b = ChatSession(thread_id="ex-B")

print("■ 스레드 A")
a.send("7월 11일에 주문한 손목 보호대 배송 상태 알려주세요.")
print("■ 스레드 B")
b.send("7월 19일에 주문한 무선 핸디 청소기 배송 상태 알려주세요.")

print(f"\n■ 스레드 A\n고객: {SECOND}")
print(f"하루: {a.send(SECOND)}")
print(f"\n■ 스레드 B\n고객: {SECOND}")
print(f"하루: {b.send(SECOND)}")

print(f"\n스레드 A 이력: {len(a.history)}개 메시지 / 스레드 B 이력: {len(b.history)}개 메시지")
$ python ex16_threads.py

실행 결과 (문장은 실행마다 다릅니다)

■ 스레드 A
  [토큰 (이력 6개 메시지)] 입력 1263 + 출력 105 = 총 1368
■ 스레드 B
  [토큰 (이력 6개 메시지)] 입력 1264 + 출력 101 = 총 1365

■ 스레드 A
고객: 그거 다른 색상도 있어요?
  [토큰 (이력 10개 메시지)] 입력 1492 + 출력 65 = 총 1557
하루: 고객님께서 문의하신 손목 보호대 2개입 상품은 현재 '블랙/FREE' 옵션으로만 판매되고 있습니다.
다른 색상 옵션은 따로 준비되어 있지 않은 점 이용에 참고 부탁드립니다.
더 궁금한 상품이나 도움이 필요하시면 편하게 말씀해 주세요.

■ 스레드 B
고객: 그거 다른 색상도 있어요?
  [토큰 (이력 10개 메시지)] 입력 1503 + 출력 58 = 총 1561
하루: 무선 핸디 청소기는 현재 화이트 색상 외에도 그레이 색상이 함께 준비되어 있습니다. 2단계 흡입력과 USB-C 충전을 지원하는 동일한 사양의 제품입니다. 더 자세한 내용이 필요하시면 편하게 말씀해 주세요.

스레드 A 이력: 10개 메시지 / 스레드 B 이력: 10개 메시지

해설 — 같은 문장인데 답이 다릅니다. 달라진 것은 모델이 아니라 모델에게 들어간 이력입니다.

  • 스레드 A는 "그거"를 손목 보호대로, 스레드 B는 무선 핸디 청소기로 이해했습니다.
  • 두 스레드는 같은 프로그램 안에서 번갈아 실행되었는데도 서로의 대화를 모릅니다. 격리하는 코드를 따로 쓰지 않았습니다. history가 객체 안에 있으므로 객체가 다르면 이력이 섞이지 않습니다.
  • 두 스레드 모두 로그인한 고객은 C007입니다. 신원 세션은 하나, 대화 스레드는 둘입니다.

문제 2 — trim이 일하는 동안에도 대화는 이어지는가

새 파일 ex16_trim.py를 만들어, 같은 다섯 턴 대화를 max_turns=10과 max_turns=2로 각각 실행해 비교하세요.

  1. "제 최근 주문 상태 알려주세요."
  2. "제 멤버십 등급이 뭐예요?"
  3. "7월 11일에 주문한 손목 보호대는 다른 색상도 있어요?"
  4. "그거 재고는 얼마나 있어요?"
  5. "그럼 그거 가격은 얼마예요?"
  • 턴마다 이력에 턴이 몇 개 남아 있는지, 그리고 이력의 첫 메시지가 무엇인지 출력합니다. 힌트: chat._is_turn_start(c)가 참인 메시지를 세면 됩니다.
  • 마지막 답만 출력합니다.

실행하기 전에 짐작해 보세요. max_turns=2일 때 다섯 번째 문의의 "그거"는 이어질까요? 다섯 번째 턴의 입력 토큰은 어느 쪽이 클까요?

해답 보기
from lesson16_multiturn import ChatSession

TURNS = [
    "제 최근 주문 상태 알려주세요.",
    "제 멤버십 등급이 뭐예요?",
    "7월 11일에 주문한 손목 보호대는 다른 색상도 있어요?",
    "그거 재고는 얼마나 있어요?",
    "그럼 그거 가격은 얼마예요?",
]

for max_turns in (10, 2):
    print(f"\n■ max_turns={max_turns}")
    chat = ChatSession(thread_id=f"ex-trim-{max_turns}", max_turns=max_turns)
    for t in TURNS:
        answer = chat.send(t)
        starts = sum(1 for c in chat.history if chat._is_turn_start(c))
        first = chat.history[0]
        print(f"    남은 턴 {starts}개 / 이력 첫 메시지: {first.parts[0].text}")
    print(f"고객: {TURNS[-1]}")
    print(f"하루: {answer}")
$ python ex16_trim.py

실행 결과 (문장은 실행마다 다릅니다)

■ max_turns=10
  [토큰 (이력 4개 메시지)] 입력 1048 + 출력 78 = 총 1126
    남은 턴 1개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
  [토큰 (이력 8개 메시지)] 입력 1194 + 출력 60 = 총 1254
    남은 턴 2개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
  [토큰 (이력 12개 메시지)] 입력 1388 + 출력 69 = 총 1457
    남은 턴 3개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
  [토큰 (이력 16개 메시지)] 입력 1540 + 출력 50 = 총 1590
    남은 턴 4개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
  [토큰 (이력 20개 메시지)] 입력 1714 + 출력 43 = 총 1757
    남은 턴 5개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
고객: 그럼 그거 가격은 얼마예요?
하루: 아까 확인하신 '손목 보호대 2개입' 상품의 가격은 12,900원입니다. 
다른 궁금한 점이 있으시면 언제든지 말씀해 주세요.

■ max_turns=2
  [토큰 (이력 4개 메시지)] 입력 1048 + 출력 82 = 총 1130
    남은 턴 1개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
  [토큰 (이력 8개 메시지)] 입력 1198 + 출력 56 = 총 1254
    남은 턴 2개 / 이력 첫 메시지: 제 최근 주문 상태 알려주세요.
  [토큰 (이력 8개 메시지)] 입력 1388 + 출력 59 = 총 1447
    남은 턴 2개 / 이력 첫 메시지: 제 멤버십 등급이 뭐예요?
  [토큰 (이력 8개 메시지)] 입력 1151 + 출력 46 = 총 1197
    남은 턴 2개 / 이력 첫 메시지: 7월 11일에 주문한 손목 보호대는 
  [토큰 (이력 6개 메시지)] 입력 1083 + 출력 47 = 총 1130
    남은 턴 2개 / 이력 첫 메시지: 그거 재고는 얼마나 있어요?
고객: 그럼 그거 가격은 얼마예요?
하루: 아까 보신 '손목 보호대 2개입' 상품의 판매 가격은 12,900원입니다.
배송비나 다른 궁금한 점이 있으시면 언제든지 말씀해 주세요!

해설 — 세 가지를 봅니다.

첫째, max_turns=2에서도 "그거"는 손목 보호대로 이어졌습니다. 다섯 번째 문의를 보낼 때 이력에는 세 번째와 네 번째 턴이 남아 있었고, 손목 보호대는 그 안에 있습니다. 답은 두 쪽 모두 12,900원입니다. 바로 앞의 문맥을 잇는 데는 최근 몇 턴이면 충분합니다.

둘째, 비용이 더 커지지 않았습니다. max_turns=10에서는 입력이 1,048에서 1,757까지 계속 올랐습니다. max_turns=2에서는 세 번째 턴 뒤로 1,151, 1,083으로 내려왔습니다. 이력의 메시지 수도 20개가 아니라 6~8개에 머뭅니다.

셋째, 이력의 첫 메시지는 늘 고객의 말입니다. trim이 일한 뒤의 첫 메시지를 보면 "제 멤버십 등급이 뭐예요?", "7월 11일에 주문한 …", "그거 재고는 …"처럼 매번 턴이 시작되는 자리입니다. 도구 호출과 도구 결과의 짝이 갈라지지 않았다는 뜻입니다. 남은 턴은 2개인데 메시지 수가 8개, 6개로 다른 것은 도구를 부른 턴(4개)과 부르지 않은 턴(2개)이 섞여 있기 때문입니다. 메시지 수가 아니라 턴 수로 세는 이유입니다.

max_turns를 얼마로 둘지는 개발자가 대화의 길이와 비용을 보고 정합니다.


문제 3 — 이력 안을 들여다보기

「대화 이력」 절의 표(고객의 말 → 도구 호출 → 도구 결과 → 최종 답변)를 직접 찍어 보세요. 새 파일 ex16_history_dump.py를 만들어 다음을 합니다.

  • "제 최근 주문 상태 알려주세요."와 "감사합니다."를 차례로 보낸다.
  • 이력의 메시지마다 번호, role, 내용물의 종류(글 / 도구 호출 / 도구 결과)를 한 줄씩 출력한다.
  • _is_turn_start가 참인 메시지에는 "← 턴의 시작"을 붙인다.

힌트: 메시지 하나(types.Content)의 내용물은 parts에 들어 있습니다. 조각마다 function_call, function_response, text 가운데 하나가 채워져 있습니다.

해답 보기
from lesson16_multiturn import ChatSession


def kind(part) -> str:
    """메시지 조각(part)이 글인지, 도구 호출인지, 도구 결과인지 가려낸다."""
    if part.function_call:
        return f"도구 호출: {part.function_call.name}"
    if part.function_response:
        return f"도구 결과: {part.function_response.name}"
    return f"글: {(part.text or '')}"


chat = ChatSession(thread_id="ex-dump")
for t in ["제 최근 주문 상태 알려주세요.", "감사합니다."]:
    chat.send(t)

print()
for i, c in enumerate(chat.history):
    start = "  ← 턴의 시작" if chat._is_turn_start(c) else ""
    print(f"{i} [{c.role:5}] {' / '.join(kind(p) for p in c.parts)}{start}")
$ python ex16_history_dump.py

실행 결과 (문장은 실행마다 다릅니다)

  [토큰 (이력 4개 메시지)] 입력 1048 + 출력 78 = 총 1126
  [토큰 (이력 6개 메시지)] 입력 1132 + 출력 34 = 총 1166

0 [user ] 글: 제 최근 주문 상태 알려주세요.  ← 턴의 시작
1 [model] 도구 호출: get_my_orders
2 [user ] 도구 결과: get_my_orders
3 [model] 글: 고객님의 최근 주문은 2026년 7월 19일에 주문하신
4 [user ] 글: 감사합니다.  ← 턴의 시작
5 [model] 글: 별 말씀을요! 추가로 도움이 필요하신 점이 있다면 언제

해설 — 표에서 본 구조가 그대로 나왔습니다.

  • 첫 턴은 메시지 네 개(0~3번)입니다. 도구를 한 번 불렀기 때문입니다. 둘째 턴은 도구 없이 답해서 두 개(4~5번)입니다.
  • 2번 메시지는 도구 결과인데 role이 user 입니다. 그런데 "← 턴의 시작"은 붙지 않았습니다. _is_turn_start가 role만 보지 않고 글이 들어 있는지까지 보기 때문입니다. 0번과 4번, 고객이 직접 말한 메시지에만 표시가 붙었습니다.
  • 1번과 2번은 이름이 같은 짝(get_my_orders)입니다. trim은 0번이나 4번 같은 자리에서만 자르므로 이 짝은 늘 함께 남거나 함께 빠집니다.

주문번호, 옵션, 배송 완료일 같은 값은 3번의 문장이 아니라 2번의 도구 결과 안에 온전히 들어 있습니다. 다음 턴에 모델이 "아까 조회한 그 주문"의 값을 꺼내 쓸 수 있는 것은 이 메시지를 이력에 남겨 두기 때문입니다.

← 이전 절정리와 체크리스트17장 →왜 장기 기억이 필요한가 — 16장이 남긴 두 가지 숙제
오명운 · macro@prag-ai.com