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

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

실습 3 — 가짜 모델로 세션별 기록 확인하기

RunnableWithMessageHistory 가 손님(세션)마다 기록을 나눠 담고, 요청마다 기록을 꺼내 넣는지 가짜 모델로 확인합니다. 모델이 실제로 몇 개의 메시지를 받는지 중간에서 찍어 보고, 세션마다의 기록을 json 파일로 남깁니다. 가짜 모델은 정해 둔 답을 차례로 돌려주므로 API 키가 없어도 됩니다.

1단계. 세션마다 기록 상자 만들기

Python
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단계. 받는 메시지를 찍어 보는 체인 만들기

Python
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단계. 두 손님과 번갈아 대화하기

Python
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단계. 세션마다 쌓인 기록 저장하기

Python
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 는 사라지지만 이 파일은 남습니다.
← 이전 절4장 · 실습 2 — 구조화된 출력으로 요청 글에서 조건 뽑기다음 절 →4장 · 실습 4 — 이전 대화를 기억하는 여행 비서