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

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

실습 4 — 이전 대화를 기억하는 여행 비서

실습 3의 가짜 모델 자리에 진짜 모델을 끼워, 앞의 대화를 기억하는 구름다리여행의 여행 비서를 만듭니다. 손님 kim 과 세 번 주고받은 뒤 지금까지의 조건을 정리하게 하고, 처음 온 손님 lee 에게는 같은 것을 물어 기록이 섞이지 않는지 봅니다. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

1단계. 여행 비서 만들기

Python
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 과 세 번 주고받기

Python
ask("kim", "다음 달에 제주도로 2박 3일 가요. 아이 둘이랑 가요.")
ask("kim", "첫날 저녁은 어디서 먹으면 좋을까요?")
ask("kim", "지금까지 말한 조건을 정리해 주세요.")
출력
[kim] 다음 달에 제주도로 2박 3일 가요. 아이 둘이랑 가요.
  → 아이 둘과 함께하는 제주도 2박 3일 여행이군요. 이동이 짧은 동쪽 해안 일정을 추천드립니다.
[kim] 첫날 저녁은 어디서 먹으면 좋을까요?
  → 첫날은 이동으로 피곤하니 숙소 근처의 해산물 식당을 추천드립니다. 아이 메뉴가 있는지 미리 확인해 보세요.
[kim] 지금까지 말한 조건을 정리해 주세요.
  → 다음 달, 제주도 2박 3일, 아이 둘과 함께하는 여행입니다. 첫날 저녁은 숙소 근처 해산물 식당으로 정하셨습니다.

코드가 의미하는 것 같은 세션 id "kim" 으로 세 번 부르므로 셋째 요청에는 앞의 두 쌍이 함께 갑니다. 셋째 질문에는 조건이 한 글자도 없어서, 답에 조건이 나오면 그것은 기록에서 왔다고 볼 수 있습니다. 출력은 예상 출력이라 답의 문장은 실제 실행에서 달라집니다.

3단계. 처음 온 손님 lee 에게 같은 것 묻기

Python
ask("lee", "지금까지 말한 조건을 정리해 주세요.")
출력
[lee] 지금까지 말한 조건을 정리해 주세요.
  → 아직 말씀해 주신 여행 조건이 없습니다. 가고 싶은 곳과 일정, 함께 가는 인원을 알려 주세요.

코드가 의미하는 것 처음 보는 세션 id "lee" 로 같은 질문을 하면 get_session_history 가 빈 철을 새로 만들어 넣습니다. 같은 질문에 kim 과 다른 답이 나와야 손님마다 기록이 나뉘었다는 뜻입니다. 예상 출력처럼 조건을 지어내지 않고 되묻는지는 시스템 지시의 "모르는 것은 모른다고 답한다"에 기대고 있습니다.

4단계. 기록과 보낸 메시지 수 확인하기

Python
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 = {} 로 기록이 모두 지워집니다.
← 이전 절4장 · 실습 3 — 가짜 모델로 세션별 기록 확인하기다음 절 →4장 · 실습 5 — Window 메모리로 최근 대화만 남기기