4장. 대화형 챗봇과 메모리 전략
실습 4 — 이전 대화를 기억하는 여행 비서
실습 3의 가짜 모델 자리에 진짜 모델을 끼워, 앞의 대화를 기억하는 구름다리여행의 여행 비서를 만듭니다. 손님 kim 과 세 번 주고받은 뒤 지금까지의 조건을 정리하게 하고, 처음 온 손님 lee 에게는 같은 것을 물어 기록이 섞이지 않는지 봅니다. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.
1단계. 여행 비서 만들기
import os
import warnings
from dotenv import find_dotenv, load_dotenv
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_openai import ChatOpenAI
# 실습 3과 같이 "사용 중단 예정" 경고를 숨긴다
warnings.filterwarnings("ignore", category=DeprecationWarning)
# .env 에서 키와 모델 이름을 읽는다
load_dotenv(find_dotenv(usecwd=True))
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
model = ChatOpenAI(model=MODEL, temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", "너는 구름다리여행의 친절한 여행 비서다. 손님이 앞에서 말한 조건(장소, 박수, 인원)을 기억해 두 문장 이내로 답한다. 모르는 것은 모른다고 답한다."),
MessagesPlaceholder("history"),
("human", "{question}"),
])
# 3차시의 체인 끝에 StrOutputParser 를 붙여 답을 글로 받는다
chain = prompt | model | StrOutputParser()
store = {}
def get_session_history(session_id):
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
bot = RunnableWithMessageHistory(chain, get_session_history, input_messages_key="question", history_messages_key="history")
def ask(session_id, question):
"""세션 id 와 질문으로 여행 비서를 부르고, 질문과 답을 찍는다"""
answer = bot.invoke({"question": question}, config={"configurable": {"session_id": session_id}})
print(f"[{session_id}] {question}\n → {answer}")
print("여행 비서 준비:", MODEL)여행 비서 준비: gpt-4.1-mini코드가 의미하는 것 실습 3의 짜임에서 가짜 모델 자리에 ChatOpenAI 를, 엿보는 peek 대신 끝에 StrOutputParser() 를 끼웠습니다. 시스템 지시의 "앞에서 말한 조건을 기억해"는 모델에게 기록을 읽으라고 일러 주는 말일 뿐이고, 실제로 기억하게 만드는 것은 RunnableWithMessageHistory 가 기록을 넣어 보내는 일입니다. ask() 는 세션 id 와 질문만 받도록 config 를 감싼 창구로, 7장의 웹 화면에서는 이 자리에 로그인한 사람마다 다른 세션 id 가 들어갑니다.
2단계. 손님 kim 과 세 번 주고받기
ask("kim", "다음 달에 제주도로 2박 3일 가요. 아이 둘이랑 가요.")
ask("kim", "첫날 저녁은 어디서 먹으면 좋을까요?")
ask("kim", "지금까지 말한 조건을 정리해 주세요.")[kim] 다음 달에 제주도로 2박 3일 가요. 아이 둘이랑 가요.
→ 아이 둘과 함께하는 제주도 2박 3일 여행이군요. 이동이 짧은 동쪽 해안 일정을 추천드립니다.
[kim] 첫날 저녁은 어디서 먹으면 좋을까요?
→ 첫날은 이동으로 피곤하니 숙소 근처의 해산물 식당을 추천드립니다. 아이 메뉴가 있는지 미리 확인해 보세요.
[kim] 지금까지 말한 조건을 정리해 주세요.
→ 다음 달, 제주도 2박 3일, 아이 둘과 함께하는 여행입니다. 첫날 저녁은 숙소 근처 해산물 식당으로 정하셨습니다.코드가 의미하는 것 같은 세션 id "kim" 으로 세 번 부르므로 셋째 요청에는 앞의 두 쌍이 함께 갑니다. 셋째 질문에는 조건이 한 글자도 없어서, 답에 조건이 나오면 그것은 기록에서 왔다고 볼 수 있습니다. 출력은 예상 출력이라 답의 문장은 실제 실행에서 달라집니다.
3단계. 처음 온 손님 lee 에게 같은 것 묻기
ask("lee", "지금까지 말한 조건을 정리해 주세요.")[lee] 지금까지 말한 조건을 정리해 주세요.
→ 아직 말씀해 주신 여행 조건이 없습니다. 가고 싶은 곳과 일정, 함께 가는 인원을 알려 주세요.코드가 의미하는 것 처음 보는 세션 id "lee" 로 같은 질문을 하면 get_session_history 가 빈 철을 새로 만들어 넣습니다. 같은 질문에 kim 과 다른 답이 나와야 손님마다 기록이 나뉘었다는 뜻입니다. 예상 출력처럼 조건을 지어내지 않고 되묻는지는 시스템 지시의 "모르는 것은 모른다고 답한다"에 기대고 있습니다.
4단계. 기록과 보낸 메시지 수 확인하기
for session_id, history in store.items():
print(session_id, "기록 메시지:", len(history.messages))
# kim 의 셋째 요청에는 시스템 1 + 앞의 두 쌍 4 + 새 질문 1 이 갔다
print("kim 셋째 요청에 보낸 메시지:", 1 + 4 + 1)kim 기록 메시지: 6
lee 기록 메시지: 2
kim 셋째 요청에 보낸 메시지: 6코드가 의미하는 것 store 를 훑어 철마다 쌓인 장 수를 세는 것은 답의 글과 상관없이 기록 장치가 제대로 일했는지 숫자로 확인하는 일입니다. 1 + 4 + 1 은 시스템 지시가 철에 쌓이지 않고 요청마다 프롬프트에서 붙는다는 것을 셈으로 보여 줍니다. 이 수가 곧 요청마다 보내는 양이고, 다음 실습의 Window 와 Summary 는 이 양을 줄이는 방법입니다.
이 실습이 뜻하는 것
이 실습은 실습 3에서 확인한 창구에 진짜 상담원을 앉힌 일과 같습니다. 상담원과 철 보관함을 준비하고(1단계), 단골 손님과 이어서 상담하고(2단계), 처음 온 손님에게 같은 질문을 받고(3단계), 철에 쌓인 장을 셉니다(4단계). 여기서 진짜 상담원이 ChatOpenAI, 철 보관함이 store 입니다. 비유와 다른 점은, 이 상담원은 철에 적힌 것만 알고 철 밖의 일(지난주에 다른 창구에서 한 말)은 전혀 모른다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 2. kim 첫 질문 | "아이 둘과 함께하는 제주도 2박 3일" | 질문에 든 조건을 받아 답했습니다 |
| 2. kim 셋째 질문 | 다음 달, 제주도 2박 3일, 아이 둘 | 셋째 질문에는 조건이 없는데도 기록에서 찾아 정리했습니다 |
| 3. lee | "아직 말씀해 주신 조건이 없습니다" | lee 의 기록 상자는 비어 있어 kim 의 조건이 섞이지 않았습니다 |
| 4. 기록 | kim 6, lee 2 | 질문 하나에 메시지 두 개(질문, 답)씩 쌓였습니다 |
한 줄로 줄이면, 3장의 체인을 RunnableWithMessageHistory 로 감싸기만 해도 손님마다 앞의 대화를 기억하는 여행 비서가 된다는 것을 본 실습입니다. kim 의 셋째 질문 "지금까지 말한 조건을 정리해 주세요"에는 조건이 하나도 없지만, 기록이 함께 가서 모델이 정리할 수 있었습니다. 시스템 지시에 "모르는 것은 모른다고 답한다"를 넣어 두어 lee 에게는 조건을 지어내지 않고 되물었다고 보았습니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라지므로 여러분의 답은 문장이 다를 수 있습니다. 다만 기록 메시지 수(6, 2)는 모델과 상관없이 같습니다.
코드 설명
chain = prompt | model | StrOutputParser()는 3장의 체인 그대로입니다.RunnableWithMessageHistory는 체인의 결과가 글이든AIMessage든 받아서 기록에 답으로 넣습니다.ask()는 세션 id 와 질문만 받아config를 꾸며 줍니다. 웹 화면(7장)에서는 로그인한 사람마다 다른 세션 id 를 넘기게 됩니다.- 시스템 지시의 "모르는 것은 모른다고 답한다"는 기록이 빈 손님에게 조건을 지어내지 않게 하려는 줄입니다. 1장에서 본 환각을 줄이는 처방입니다.
store는 프로그램이 도는 동안만 있습니다. 이 셀을 다시 실행하면store = {}로 기록이 모두 지워집니다.