4장. 대화형 챗봇과 메모리 전략
메모리 전략 고르기
상담원에게 손님 사정을 넘기는 방법은 세 가지입니다. 기록철을 통째로 넘기기, 최근 몇 장만 넘기기, 요약 메모와 최근 몇 장을 함께 넘기기. 통째로 넘기면 빠짐없지만 읽는 데 오래 걸리고, 최근 몇 장은 빠르지만 앞의 일을 모르고, 요약은 둘 사이를 메우지만 요약을 쓰는 품이 듭니다. 챗봇의 쓰임에 따라 골라야 합니다.
세 상자를 위에서 아래로 읽으면 보내는 것, 기억하는 것, 드는 품입니다. 왼쪽 파랑 상자(모두 보내기)는 기억은 가장 좋지만 보내는 양이 대화만큼 늘고, 가운데 보라 상자(Window)는 양이 일정하지만 앞의 것을 잊고, 오른쪽 하늘 상자(Summary)는 요약으로 앞의 것을 지키는 대신 요약 호출이 더 듭니다. 여기서 기록철 통째가 모두 보내기, 최근 몇 장이 Window, 요약 메모와 최근 몇 장이 Summary 입니다. 비유와 다른 점은, 사람의 요약 메모는 한 번 쓰면 그만이지만 챗봇의 요약은 대화가 이어질 때마다 고쳐 써야 한다는 것입니다.
세 전략 견주기
| 견줄 것 | 모두 보내기 | Window | Summary |
|---|---|---|---|
| 보내는 것 | 기록 전부 | 최근 기록(토큰 상한까지) | 요약 + 최근 기록 |
| 보내는 토큰 | 대화만큼 계속 는다 | 상한 안에서 일정 | 요약만큼 더해 거의 일정 |
| 앞의 일을 기억 | 빠짐없이 | 잊는다 | 요약에 남긴 만큼 |
| 더 드는 것 | 없음 | 없음 | 요약할 때마다 모델 호출 |
| 어울리는 챗봇 | 몇 번 주고받고 끝나는 상담 | 앞의 일이 중요하지 않은 FAQ | 오래 이어지며 조건을 쌓는 여행 비서 |
옛 이름과 지금 방식
인터넷의 LangChain 글에는 ConversationBufferMemory 같은 옛 메모리 클래스가 자주 나옵니다. 이 클래스들은 langchain 1.x 본체에는 없고 langchain-classic 이라는 따로 된 패키지로 옮겨졌습니다. 이 과정은 그 패키지를 설치하지 않고, 지금 판의 부품으로 같은 일을 합니다.
| 옛 이름 | 하던 일 | 이 장에서 쓰는 방식 |
|---|---|---|
ConversationBufferMemory | 기록을 모두 담아 보낸다(buffer, 쌓아 두는 곳) | InMemoryChatMessageHistory + RunnableWithMessageHistory |
ConversationBufferWindowMemory | 최근 k 쌍만 보낸다 | trim_messages |
ConversationSummaryMemory | 대화를 요약해 보낸다 | 요약 체인(prompt | model | StrOutputParser())을 직접 만든다 |
짧은 예시
1장의 chat_log.json 으로, 마지막 질문을 보낼 때 세 전략이 보내는 메시지 수와 토큰을 셉니다. 요약 글은 사람이 쓴 것으로 대신합니다.
import json
from pathlib import Path
import tiktoken
from langchain_core.messages import SystemMessage, convert_to_messages, trim_messages
enc = tiktoken.get_encoding("o200k_base")
def count_tokens(messages):
"""메시지 목록의 글을 tiktoken 으로 세어 더한다(역할 표시 토큰은 빼고 센 어림값)."""
return sum(len(enc.encode(m.content)) for m in messages)
# convert_to_messages: {"role", "content"} 사전 목록을 LangChain 메시지로 바꾼다
msgs = convert_to_messages(json.loads(Path("chat_log.json").read_text(encoding="utf-8")))
sent = msgs[:-1] # 마지막 정리 질문까지(마지막 답은 뺀다)
full = sent
window = trim_messages(sent, max_tokens=100, token_counter=count_tokens,
strategy="last", include_system=True, start_on="human")
summary = [msgs[0], SystemMessage("앞의 대화 요약: 제주도 2박 3일, 아이 둘. 오전 출발, 동쪽 해안 숙소.")] + sent[-5:]
for name, m in [("모두 보내기", full), ("Window", window), ("Summary", summary)]:
keeps = any("2박 3일" in x.content for x in m)
print(f"{name}: 메시지 {len(m)}개, {count_tokens(m)} 토큰, 2박 3일 있음 {keeps}")모두 보내기: 메시지 20개, 317 토큰, 2박 3일 있음 True
Window: 메시지 6개, 96 토큰, 2박 3일 있음 False
Summary: 메시지 7개, 126 토큰, 2박 3일 있음 True모두 보내기는 1장 프로젝트의 열째 요청(317 토큰)과 같은 양을 보냅니다. Window 는 토큰이 3분의 1 아래로 줄었지만 "2박 3일"이 없고, Summary 는 Window 보다 조금 더 보내면서 "2박 3일"을 지킵니다. 이 요약 글은 예시로 사람이 쓴 것이라, 모델이 쓴 요약은 실습 6과 프로젝트에서 봅니다.
코드가 의미하는 것
이 코드는 같은 기록철을 세 방법으로 넘길 때 넘기는 장 수와 분량, 빠진 조건을 한 번에 재는 일입니다. 요약 글은 사람이 쓴 것으로 대신해 모델을 부르지 않습니다.
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
count_tokens(messages) | 메시지 글의 토큰을 더한다 | 넘기는 분량, 곧 요금을 재는 자입니다. 역할 표시 토큰은 빼고 세므로 어림값입니다 |
convert_to_messages(json.loads(…)) | 1장의 대화 파일을 메시지로 바꾼다 | 파일에 남긴 기록을 LangChain 부품이 다루는 모양으로 되살립니다 |
trim_messages(sent, max_tokens=100, …) | 뒤에서부터 100 토큰까지 남긴다 | Window 전략입니다 |
[msgs[0], SystemMessage(…)] + sent[-5:] | 시스템, 요약, 최근 5개를 잇는다 | Summary 전략입니다. sent[-5:] 에는 최근 4개와 정리 질문이 들어 있습니다 |
any("2박 3일" in x.content for x in m) | 보낼 메시지에 조건이 있는지 본다 | 토큰 수로는 보이지 않는 "잊은 것"을 글자로 확인합니다 |
결과에서 읽을 것은 세 가지입니다.
- 모두 보내기가 가장 많다. 메시지 20개, 317 토큰입니다. 요약 호출이 없는 대신 대화만큼 늘어납니다.
- Window 는 가장 적게 보내고 조건을 잃었다. 96 토큰으로 줄었지만 "2박 3일 있음"이 False 입니다.
- Summary 는 30 토큰 더 보내고 조건을 지켰다. 126 토큰으로 Window 보다 30 토큰 많고, True 입니다.
주의할 점
- 세 전략은 섞어 쓸 수 있습니다. 이 장의 Summary 도 "요약 + 최근 몇 개"라서 Window 를 품고 있습니다.
- 아무 전략을 써도 모델은 받은 것만 압니다. 챗봇이 엉뚱하게 잊었다면 먼저 "이번 요청에 무엇을 보냈는지"를 찍어 봅니다.
- 손님의 이름, 연락처 같은 개인정보는 요약에 남기지 않도록 요약 프롬프트에 적어 둡니다. 요약은 오래 남는 기록이기 때문입니다.