LangChain 챗봇4장 · 대화형 챗봇과 메모리 전략20 / 22← 이전목차다음 →MetaContents

4장. 대화형 챗봇과 메모리 전략

프로젝트 1 — 메모리 전략 견주기

구름다리여행의 여행 비서에 어떤 메모리 전략을 넣을지 정하려고 합니다. 1장의 열 번 주고받은 대화(chat_log.json)를 처음부터 다시 돌린다고 보고, 모두 보내기·Window·Summary 세 전략의 요청별 입력 토큰과, 마지막 정리 질문("지금까지 말한 일정을 정리해 주세요")에 대한 답을 견주어 보세요. 그리고 표 하나로 정리해 여행 비서에 넣을 전략을 고르세요. 대화는 연습용으로 지어낸 것입니다. Summary 의 요약과 세 전략의 답은 모델을 불러야 하므로 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

항목내용
파일chat_log.json. 시스템 메시지 1개와 사용자·어시스턴트 메시지 열 쌍, 모두 21개
모두 보내기시스템 + 앞의 모든 메시지 + 새 질문
Windowtrim_messages(max_tokens=100, strategy="last", include_system=True, start_on="human")
Summary시스템 + 요약(시스템 메시지) + 최근 4개 + 새 질문. 최근 4개 앞으로 밀려난 메시지가 생길 때마다 요약을 고쳐 쓴다
토큰 세기tiktoken o200k_base 로 메시지의 글만 센 어림값

요구사항은 다섯 가지입니다.

  1. 세 전략마다 "기록과 새 질문을 받아 보낼 메시지를 돌려주는" 함수를 만듭니다.
  2. 열 번의 요청마다 세 전략의 입력 토큰을 구해 표로 만듭니다. Summary 는 요약을 고쳐 쓰는 호출의 입력 토큰도 따로 더합니다.
  3. 마지막 요청에서 세 전략이 보내는 메시지로 각각 답을 받고, 답에 "2박 3일"과 "아이"가 들어 있는지 확인합니다.
  4. 전략마다 열 번의 입력 토큰 합, 요약 호출의 입력 토큰, 지킨 조건을 한 표로 모아 out_ch04_strategy_compare.csv 로 저장합니다.
  5. 표를 보고 여행 비서에 넣을 전략을 고르고, 까닭을 두세 문장으로 적습니다.

단계별 힌트입니다.

  1. 대화는 실습 5처럼 convert_to_messages 로 바꿉니다. t 번째 질문(0 부터)의 자리는 1 + 2 * t 이고, 그 앞의 기록은 msgs[: 1 + 2 * t] 입니다.
  2. Window 함수는 기록과 새 질문을 이어 붙인 목록을 trim_messages 에 넣으면 됩니다.
  3. Summary 는 "이미 요약한 메시지 수"를 변수로 들고 다니다가, 최근 4개 앞으로 새로 밀려난 메시지만 요약 체인에 넘깁니다. 요약 체인에는 지난 요약과 새 메시지를 함께 줍니다.
  4. 요약 호출의 입력 토큰은 update_prompt.invoke({...}).to_messages() 로 실제로 보낼 메시지를 만들어 셉니다.
  5. 답에 글이 들어 있는지는 "2박 3일" in answer 로 확인합니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

채점 기준배점
세 전략의 보낼 메시지를 바르게 만들었다25
요청별 입력 토큰 표가 맞다(요약 호출 포함)25
마지막 질문의 답 세 개를 받아 조건을 확인했다20
견주기 표를 만들고 저장했다15
고른 전략과 까닭이 숫자와 맞다15

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

대화 읽기와 세 전략 함수, 요청마다 토큰 세기, 마지막 질문에 답 받기, 표로 고르기의 네 단계를 이은 코드 흐름도

상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 상자 아래의 글은 그 단계에서 쓰는 핵심 코드입니다. 2단계는 숫자(토큰)를, 3단계는 숫자로 드러나지 않는 것(잊은 조건)을 구하고, 4단계에서 둘을 한 표에 모아 고릅니다.

실제 데이터로 해 보려면

내 챗봇의 실제 대화로 같은 견주기를 해 볼 수 있습니다. 대화를 쓰기 전에 손님의 이름, 연락처, 예약 번호 같은 개인정보를 지우고, 대화를 모으고 쓰는 것에 대해 이용 약관과 개인정보 처리 방침을 확인합니다.

자료구하는 곳쓰는 법
실습 3·4에서 저장한 세션 기록out_ch04_sessions.json 처럼 내가 남긴 파일세션 하나의 메시지 목록을 msgs 자리에 넣는다
내 챗봇 대화 내보내기ChatGPT 의 설정에서 데이터 내보내기역할과 내용을 꺼내 {"role", "content"} 목록으로 바꾼다

대화가 길수록 세 전략의 차이가 커집니다. 열 번이 아니라 서른 번, 쉰 번짜리 대화로 같은 표를 만들어 보세요.

결과 보기 (예시 풀이)

1단계. 대화 읽고 세 전략 함수 만들기

Python
import json
import os
from pathlib import Path

import pandas as pd
import tiktoken
from dotenv import find_dotenv, load_dotenv
from langchain_core.messages import SystemMessage, convert_to_messages, trim_messages
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

load_dotenv(find_dotenv(usecwd=True))
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
model = ChatOpenAI(model=MODEL, temperature=0)

enc = tiktoken.get_encoding("o200k_base")


def count_tokens(messages):
    return sum(len(enc.encode(m.content)) for m in messages)


msgs = convert_to_messages(json.loads(Path("chat_log.json").read_text(encoding="utf-8")))
RECENT = 4          # Summary 가 그대로 남길 최근 메시지 수


def send_full(history, question):
    """모두 보내기: 기록 전부 + 새 질문"""
    return history + [question]


def send_window(history, question):
    """Window: 뒤에서부터 100 토큰까지만 남긴다"""
    return trim_messages(history + [question], max_tokens=100, token_counter=count_tokens,
                         strategy="last", include_system=True, start_on="human")


def send_summary(history, question, summary):
    """Summary: 시스템 + 요약 + 최근 4개 + 새 질문. 요약이 아직 없으면 모두 보내기와 같다"""
    head = [history[0]] + ([SystemMessage("앞의 대화 요약: " + summary)] if summary else [])
    return head + history[1:][-RECENT:] + [question]


# 요약을 고쳐 쓰는 체인: 지난 요약과 새로 밀려난 메시지를 받아 새 요약을 쓴다
update_prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 여행 상담 기록을 정리하는 직원이다. 지난 요약에 새 대화를 더해 세 문장 이내로 다시 요약한다. "
               "장소, 박수, 인원, 정한 것은 숫자와 함께 빠뜨리지 않는다."),
    ("human", "지난 요약: {summary}\n새 대화:\n{dialog}"),
])
update_chain = update_prompt | model | StrOutputParser()
print("메시지:", len(msgs), "개 / 모델:", MODEL)
출력
메시지: 21 개 / 모델: gpt-4.1-mini

코드가 의미하는 것 send_full, send_window, send_summary 는 기록철을 넘기는 세 방법을 "기록과 새 질문을 받아 보낼 목록을 돌려주는" 같은 모양의 함수로 맞춘 것입니다. 모양이 같아야 2·3단계에서 같은 반복문으로 세 전략을 견줄 수 있습니다. update_chain 은 요약을 처음부터 다시 쓰지 않고 "지난 요약 + 새로 밀려난 메시지"로 고쳐 쓰는 체인이라, 요약 호출의 입력이 대화 전체로 커지지 않습니다.

2단계. 요청마다 세 전략의 입력 토큰 구하기

Python
summary, done, update_tokens, calls = "", 0, 0, 0
rows = []
for t in range(10):
    q = 1 + 2 * t
    history, question = msgs[:q], msgs[q]

    # Summary: 최근 4개 앞으로 밀려난 메시지(older) 가운데 아직 요약하지 않은 것만 요약에 더한다
    older = history[1:-RECENT] if len(history) - 1 > RECENT else []
    new = older[done:]
    if new:
        values = {"summary": summary or "(없음)", "dialog": "\n".join(f"{m.type}: {m.content}" for m in new)}
        update_tokens += count_tokens(update_prompt.invoke(values).to_messages())   # 요약 호출의 입력
        summary = update_chain.invoke(values)
        done, calls = len(older), calls + 1

    rows.append({
        "요청": t + 1,
        "모두 보내기": count_tokens(send_full(history, question)),
        "Window": count_tokens(send_window(history, question)),
        "Summary": count_tokens(send_summary(history, question, summary)),
    })

compare = pd.DataFrame(rows).set_index("요청")
print(compare.T.to_string())
print("요약 호출:", calls, "번, 입력", update_tokens, "토큰")
print("마지막 요약:", summary)
출력
요청      1   2    3    4    5    6    7    8    9    10
모두 보내기  38  68  100  133  163  193  228  258  288  317
Window   38  68  100   71   69   99   70  100   98   96
Summary  38  68  100  150  150  160  170  180  190  200
요약 호출: 7 번, 입력 900 토큰
마지막 요약: 손님은 다음 달 아이 둘과 제주도 2박 3일 여행을 간다. 오전 9시 전후 출발, 동쪽 해안 숙소, 둘째 날은 비가 와서 실내 아쿠아리움 일정으로 바꾸고 미리 예매한다. 렌터카는 공항에서 바로 빌린다.

코드가 의미하는 것 열 번의 요청을 차례로 다시 돌리면서, 요청마다 세 전략이 보낼 목록의 토큰을 셉니다. done 은 이미 요약에 담은 메시지 수를 기억하는 책갈피로, 최근 4개 앞으로 새로 밀려난 메시지가 있을 때만 update_chain 을 부르게 합니다. update_prompt.invoke(values).to_messages() 로 요약 호출에 보낼 메시지를 만들어 세는 것은 Summary 만 드는 숨은 비용을 표에 올리기 위해서입니다. Summary 줄과 요약 호출의 숫자는 예상 출력입니다.

3단계. 마지막 질문에 세 전략으로 답 받기

Python
history, question = msgs[:19], msgs[19]
plans = {
    "모두 보내기": send_full(history, question),
    "Window": send_window(history, question),
    "Summary": send_summary(history, question, summary),
}
answers = {}
for name, to_send in plans.items():
    answers[name] = model.invoke(to_send).content
    print(f"[{name}] 메시지 {len(to_send)}개 → {answers[name]}")
출력
[모두 보내기] 메시지 20개 → 다음 달 아이 둘과 함께하는 제주도 2박 3일 일정입니다. 오전 출발, 동쪽 해안 숙소, 첫날 저녁 해산물 식당, 둘째 날 실내 일정(아쿠아리움 예매), 공항 렌터카, 15kg 짐 한 개입니다.
[Window] 메시지 6개 → 공항에 내리자마자 렌터카를 빌리시고, 국내선 상품은 15kg 짐 한 개가 포함됩니다. 여행지와 날짜를 알려 주시면 전체 일정을 정리해 드릴게요.
[Summary] 메시지 7개 → 다음 달 아이 둘과 함께하는 제주도 2박 3일 일정입니다. 오전 출발, 동쪽 해안 숙소, 둘째 날 실내 아쿠아리움(예매), 공항 렌터카, 15kg 짐 한 개입니다.

코드가 의미하는 것 마지막 정리 질문 하나에 세 전략의 목록을 각각 보내, 같은 질문에 답이 어떻게 갈리는지 봅니다. 2단계의 토큰 표가 "얼마나 보냈나"라면 이 답은 "무엇을 잊었나"를 보여 줍니다. 답의 글은 예상 출력이라 실제 실행에서 문장이 달라집니다.

4단계. 표로 모아 고르기

Python
table = pd.DataFrame({
    "열 번 입력 합": compare.sum(),
    "요약 호출 입력": pd.Series({"모두 보내기": 0, "Window": 0, "Summary": update_tokens}),
})
table["모두 더한 입력"] = table["열 번 입력 합"] + table["요약 호출 입력"]
table["2박 3일"] = [("2박 3일" in answers[name]) for name in table.index]
table["아이"] = [("아이" in answers[name]) for name in table.index]
print(table.to_string())

table.to_csv("out_ch04_strategy_compare.csv", encoding="utf-8-sig")
print("저장: out_ch04_strategy_compare.csv")
출력
        열 번 입력 합  요약 호출 입력  모두 더한 입력  2박 3일     아이
모두 보내기     1786         0     1786   True   True
Window       809         0      809  False  False
Summary     1400       900     2300   True   True

모두 보내기와 Window 의 토큰은 모델과 상관없이 tiktoken 으로 센 값이라 실행해도 같습니다. 모두 보내기는 1장 프로젝트에서 본 그대로 요청마다 30 토큰 안팎씩 늘어 열 번 합이 1,786 토큰입니다. Window(상한 100 토큰)는 넷째 요청부터 70~100 토큰에 머물러 열 번 합이 809 토큰으로 절반 아래지만, 마지막 답에서 "2박 3일"도 "아이"도 잃고 오히려 여행지와 날짜를 되묻습니다. Summary 의 줄과 요약 호출 숫자는 예상 출력이라 둥글게 적었습니다. Summary 는 요청마다 150~200 토큰 안팎을 보내 열 번 합이 1,400 토큰 안팎이고, 조건을 모두 지켰습니다. 그런데 요약을 일곱 번 고쳐 쓰는 데 900 토큰 안팎이 더 들어, 모두 더하면 2,300 토큰 안팎으로 모두 보내기보다 많습니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라지므로 Summary 의 숫자와 답의 문장은 여러분의 출력과 다를 수 있습니다.

열 번짜리 짧은 대화라면 여행 비서에는 모두 보내기가 알맞습니다. 조건을 다 지키면서 더한 입력도 Summary 보다 적기 때문입니다. 대화가 길어지면 이야기가 달라집니다. 모두 보내기는 요청마다 보내는 양이 계속 늘지만, Summary 는 요청마다 200 토큰 안팎에 머물기 때문에 어느 길이부터는 Summary 가 덜 듭니다. 그래서 "기록이 정한 상한을 넘을 때까지는 모두 보내고, 넘으면 요약한다"처럼 둘을 섞는 것이 여행 비서에 가장 알맞은 선택입니다. Window 는 조건을 쌓는 여행 비서에는 맞지 않고, 앞의 일이 중요하지 않은 FAQ 챗봇에 씁니다.

코드가 의미하는 것 compare.sum() 으로 열 번의 입력을 더하고 요약 호출의 입력을 따로 칸에 둔 뒤 둘을 합친 것은, 요청에 보낸 양과 요약에 쓴 양을 모두 비용으로 세기 위해서입니다. 같은 표에 조건을 지켰는지를 True·False 로 붙여, 비용과 기억을 한 줄에서 견주고 전략을 고르게 합니다. 이 표가 여행 비서에 어떤 메모리를 넣을지 정하는 근거가 됩니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 상담 기록을 넘기는 세 방법(통째로, 최근 몇 장만, 요약 메모와 최근 몇 장)을 같은 손님의 열 번 상담에 써 보고 복사비와 빠뜨린 내용을 견주는 일과 같습니다. 세 방법의 규칙을 정하고(1단계), 상담마다 넘긴 분량을 재고(2단계), 마지막 정리 상담의 결과를 보고(3단계), 한 장의 표로 고릅니다(4단계). 여기서 복사비가 입력 토큰, 요약 메모를 쓰는 품이 요약 호출의 토큰, 빠뜨린 내용이 답에서 사라진 조건입니다. 비유와 다른 점은, 요약 메모는 상담이 이어질 때마다 다시 써야 해서 그 품이 상담 수만큼 쌓인다는 것입니다.

단계출력뜻
2. 모두 보내기38 → 317, 합 1,786대화만큼 계속 늘어납니다
2. Window넷째부터 70~100, 합 809상한 안에서 거의 일정합니다
2. Summary넷째부터 150~200 안팎, 합 1,400 안팎요약만큼 더 보내지만 늘어나는 폭이 작습니다
2. 요약 호출7번, 900 토큰 안팎Summary 만 드는 품입니다
3. 마지막 답Window 만 False, False최근 6개(100 토큰)에는 조건이 없어 되물었습니다
4. 모두 더한 입력1,786 / 809 / 2,300 안팎짧은 대화에서는 요약의 품이 아끼는 몫보다 큽니다

한 줄로 줄이면, 메모리 전략은 "얼마나 아끼나"와 "무엇을 잊나"를 함께 보고 대화의 길이에 맞춰 고른다는 것을 숫자로 확인한 프로젝트입니다. 짧은 대화에서는 모두 보내기, 긴 대화에서는 요약, 앞의 일이 필요 없는 안내에서는 Window 가 알맞습니다. 대화는 지어낸 열 번짜리라 숫자가 작지만, 실제 서비스에서는 대화가 길고 손님이 많아서 같은 표를 만들어 보면 Summary 가 아끼는 몫이 훨씬 커집니다.

← 이전 절4장 · 실습 6 — Summary 메모리로 앞부분 요약하기다음 절 →4장 · 정리