4장. 대화형 챗봇과 메모리 전략
실습 3 — 가짜 모델로 세션별 기록 확인하기
RunnableWithMessageHistory 가 손님(세션)마다 기록을 나눠 담고, 요청마다 기록을 꺼내 넣는지 가짜 모델로 확인합니다. 모델이 실제로 몇 개의 메시지를 받는지 중간에서 찍어 보고, 세션마다의 기록을 json 파일로 남깁니다. 가짜 모델은 정해 둔 답을 차례로 돌려주므로 API 키가 없어도 됩니다.
1단계. 세션마다 기록 상자 만들기
import warnings
from langchain_core.chat_history import InMemoryChatMessageHistory
# InMemoryChatMessageHistory 와 RunnableWithMessageHistory 는 "사용 중단 예정" 경고를 띄운다
# ("손님마다 기록 상자 나누기" 절의 주의할 점). 경고를 읽었으면 이 줄로 숨긴다
warnings.filterwarnings("ignore", category=DeprecationWarning)
store = {} # {세션 id: 기록 상자}
def get_session_history(session_id):
"""세션 id 로 기록 상자를 찾는다. 처음 보는 손님이면 빈 상자를 새로 만든다"""
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
# 같은 id 로 두 번 부르면 같은 상자가 돌아온다
print(get_session_history("kim") is get_session_history("kim"))
print("만든 상자:", list(store))True
만든 상자: ['kim']코드가 의미하는 것 store 는 손님별 기록철을 꽂아 두는 보관함이고, get_session_history 는 회원번호로 철을 찾아 주는 직원입니다. 출력의 True 는 같은 id 로 두 번 찾아도 새 철을 만들지 않고 같은 철을 내준다는 뜻이라, 손님이 다시 와도 기록이 이어집니다. warnings.filterwarnings 는 "손님마다 기록 상자 나누기" 절에서 읽은 사용 중단 예정 경고를 출력에서 숨기는 줄입니다.
2단계. 받는 메시지를 찍어 보는 체인 만들기
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableLambda
prompt = ChatPromptTemplate.from_messages([
("system", "너는 구름다리여행의 여행 비서다."),
MessagesPlaceholder("history"), # 세션의 기록이 들어갈 자리
("human", "{question}"),
])
def peek(prompt_value):
"""프롬프트와 모델 사이에서, 모델이 받을 메시지의 수와 역할을 찍고 그대로 넘긴다"""
msgs = prompt_value.to_messages()
print(f" 모델이 받는 메시지 {len(msgs)}개:", [m.type for m in msgs])
return prompt_value
# 가짜 모델은 세션과 상관없이 목록의 답을 차례로 돌려준다
model = FakeListChatModel(responses=[
"좋습니다. 며칠 일정인가요?",
"어디로 가고 싶으신가요?",
"2박 3일이면 동쪽 해안 일정을 추천합니다.",
"부산은 1박 2일도 충분합니다.",
])
# RunnableLambda: 보통 함수를 체인의 부품으로 만든다. lambda 는 "이름 없는 작은 함수"를 부르는 말
chain = prompt | RunnableLambda(peek) | model
print(type(chain).__name__)RunnableSequence코드가 의미하는 것 peek 은 프롬프트와 모델 사이에 둔 엿보는 창입니다. RunnableLambda 로 감싸면 보통 함수도 | 로 이을 수 있는 체인 부품이 되고, 받은 것을 그대로 넘기므로 체인의 결과는 바뀌지 않습니다. 챗봇이 엉뚱하게 잊었을 때 "이번 요청에 무엇을 보냈는지" 찍어 보는 방법이 바로 이것입니다.
3단계. 두 손님과 번갈아 대화하기
from langchain_core.runnables.history import RunnableWithMessageHistory
bot = RunnableWithMessageHistory(
chain, # 감쌀 체인
get_session_history, # 세션 id → 기록 상자
input_messages_key="question", # 입력 사전에서 새 질문이 든 열쇠
history_messages_key="history", # 프롬프트의 기록 자리 이름
)
talks = [("kim", "제주도에 가요."), ("lee", "여행 가고 싶어요."), ("kim", "2박 3일이요."), ("lee", "부산이요.")]
for session_id, question in talks:
print(f"[{session_id}] {question}")
# configurable: 부를 때마다 바꿀 수 있는 설정. 여기에 세션 id 를 넣는다
reply = bot.invoke({"question": question}, config={"configurable": {"session_id": session_id}})
print(" 답:", reply.content)[kim] 제주도에 가요.
모델이 받는 메시지 2개: ['system', 'human']
답: 좋습니다. 며칠 일정인가요?
[lee] 여행 가고 싶어요.
모델이 받는 메시지 2개: ['system', 'human']
답: 어디로 가고 싶으신가요?
[kim] 2박 3일이요.
모델이 받는 메시지 4개: ['system', 'human', 'ai', 'human']
답: 2박 3일이면 동쪽 해안 일정을 추천합니다.
[lee] 부산이요.
모델이 받는 메시지 4개: ['system', 'human', 'ai', 'human']
답: 부산은 1박 2일도 충분합니다.코드가 의미하는 것 RunnableWithMessageHistory 로 감싼 bot 은 부를 때마다 config 의 세션 id 로 철을 골라 기록 자리에 넣고, 답이 나오면 질문과 답을 그 철에 덧붙입니다. 두 손님을 번갈아 부르는 것은 웹에서 여러 사람이 함께 쓰는 상황을 흉내 낸 것이고, peek 이 찍은 메시지 수로 기록이 섞이지 않았는지 확인합니다.
4단계. 세션마다 쌓인 기록 저장하기
import json
from pathlib import Path
for session_id, history in store.items():
print(session_id, "기록", len(history.messages), "개:", [m.content for m in history.messages if m.type == "human"])
# 세션마다 {"role", "content"} 사전 목록으로 바꿔 파일로 남긴다
saved = {sid: [{"role": m.type, "content": m.content} for m in h.messages] for sid, h in store.items()}
Path("out_ch04_sessions.json").write_text(json.dumps(saved, ensure_ascii=False, indent=2), encoding="utf-8")
print("저장: out_ch04_sessions.json")kim 기록 4 개: ['제주도에 가요.', '2박 3일이요.']
lee 기록 4 개: ['여행 가고 싶어요.', '부산이요.']
저장: out_ch04_sessions.json코드가 의미하는 것 history.messages 는 기록 상자 안의 메시지 목록이고, 이를 {"role", "content"} 사전으로 바꿔 json 으로 남기는 것은 메모리에만 있던 철을 파일 서랍으로 옮기는 일입니다. 이렇게 남긴 파일은 프로그램을 꺼도 남아서, 프로젝트의 "실제 데이터로 해 보려면"에서처럼 다른 실습의 기록 자리에 넣어 쓸 수 있습니다.
이 실습이 뜻하는 것
이 실습은 손님 두 명이 번갈아 창구에 올 때 비서가 회원번호로 철을 바꿔 펴는지 지켜보는 일과 같습니다. 회원번호마다 철을 만들고(1단계), 상담원이 받는 메모를 중간에서 엿보고(2단계), 두 손님과 번갈아 상담하고(3단계), 철에 쌓인 장을 세어 남깁니다(4단계). 여기서 회원번호가 세션 id, 철이 InMemoryChatMessageHistory, 철을 바꿔 펴는 비서가 RunnableWithMessageHistory, 엿보는 창이 peek 입니다. 비유와 다른 점은, 이 상담원(가짜 모델)은 메모를 읽지 않고 정해 둔 답을 차례로 말해서 답이 손님과 맞지 않을 수 있다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 기록 상자 | True, ['kim'] | 같은 id 로 부르면 같은 상자가 돌아오고, 처음 부를 때 한 번만 만들어집니다 |
| 2. 체인 | RunnableSequence | 프롬프트, peek, 모델의 부품 셋을 이은 체인 하나가 됐습니다 |
| 3. 첫 질문 | kim 2개, lee 2개 | 두 손님 모두 처음이라 시스템과 질문, 메시지 2개만 갔습니다 |
| 3. 둘째 질문 | kim 4개, lee 4개 | 자기 세션의 질문·답 한 쌍이 끼워졌습니다. 다른 손님의 것은 끼지 않았습니다 |
| 4. 저장 | kim 4개, lee 4개 | 세션마다 질문·답 두 쌍이 쌓였고, 사람의 질문도 손님별로 따로 남았습니다 |
한 줄로 줄이면, RunnableWithMessageHistory 가 세션 id 로 기록 상자를 골라 꺼내 넣고 덧붙인다는 것을 중간에서 엿보며 확인한 실습입니다. 번갈아 물었는데도 kim 의 둘째 요청에는 kim 의 첫 질문과 답만, lee 의 둘째 요청에는 lee 의 것만 들어갔습니다. 가짜 모델의 답이 마침 손님과 맞아 보이는 것은 답을 그 순서로 적어 두었기 때문이고, 모델이 기록을 읽은 것은 아닙니다. 실습 4에서 같은 짜임에 진짜 모델을 끼웁니다.
코드 설명
get_session_history는 세션 id 를 받아 기록 상자를 돌려주는 함수입니다.RunnableWithMessageHistory가 부를 때마다 이 함수로 상자를 찾습니다. 사전store가 상자들의 보관함입니다.RunnableLambda(peek)는 보통 함수peek을 체인 부품으로 바꿉니다. 받은 것을 그대로 돌려주므로 체인의 결과는 바뀌지 않고, 중간 모습만 찍힙니다.bot.invoke({"question": …}, config={"configurable": {"session_id": …}})에서 첫째 사전은 프롬프트에 넣을 값,config는 어느 손님의 기록을 쓸지입니다. 세션 id 를 빠뜨리면 오류가 납니다.- 기록 상자에는 사람의 질문(human)과 모델의 답(ai)만 쌓이고 시스템 지시는 쌓이지 않습니다. 시스템 지시는 프롬프트 틀에 있어 요청마다 새로 붙습니다.
out_ch04_sessions.json은 세션 id 를 열쇠로 한 기록입니다. 프로그램을 끄면store는 사라지지만 이 파일은 남습니다.