LeanX

대화 기억 넣기: 챗봇이 이전 말을 기억하게

RunnableWithMessageHistory로 체인에 메모리를 씌우면 세션(session_id)별로 대화 기록이 자동 누적되어 챗봇이 이전 대화를 기억하게 되며, 저장소 함수만 교체하면 인메모리에서 Redis나 DB 기반 영구 저장소로 손쉽게 전환할 수 있습니다.

랭체인으로 챗봇을 만들면 첫 번째 벽에 부딪힙니다. '아까 제 이름이 다비드라고 했잖아요'라고 하면 AI가 '무슨 이름이요?'라고 답하는 것이죠. LLM 자체는 매 호출이 독립적이라 과거를 기억하지 못합니다. 대화 내용을 직접 저장해 두고, 새 질문을 보낼 때마다 함께 담아 보내면 이 문제가 해결됩니다.

왜 기억을 못 할까: LLM의 무상태(stateless) 특성

LLM은 전화를 끊으면 모든 것을 잊어버리는 사람과 같습니다. 다음 전화를 걸면 초면인 상태입니다. 챗봇에 기억을 주려면 우리가 대화 기록을 저장하고, 새 질문을 보낼 때 이전 대화 내역을 함께 첨부해야 합니다. 랭체인의 RunnableWithMessageHistory가 이 반복 작업을 자동화해 줍니다.

  • 기록장(ChatMessageHistory): 대화 내역을 저장하는 객체. 인메모리·Redis·DB 등 교체 가능
  • 세션 ID(session_id): 사용자나 대화방을 구분하는 고유 식별자. 같은 ID면 같은 기록장 사용
  • 플레이스홀더(MessagesPlaceholder): 프롬프트에서 이전 대화가 펼쳐질 빈 슬롯

코드로 구현: 기억하는 챗봇 만들기

InMemoryChatMessageHistory + RunnableWithMessageHistory

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

# 세션 ID를 키로, 기록장 객체를 값으로 담는 딕셔너리
store = {}

def get_session_history(session_id: str) -> InMemoryChatMessageHistory:
    # 처음 방문하면 새 기록장을, 이미 있으면 기존 것을 돌려줍니다
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]

# MessagesPlaceholder = 이전 대화가 끼워질 프롬프트의 빈 슬롯
prompt = ChatPromptTemplate.from_messages([
    ('system', '너는 친절한 한국어 AI 어시스턴트야.'),
    MessagesPlaceholder(variable_name='history'),
    ('human', '{input}'),
])

chain = prompt | init_chat_model('openai:gpt-4o-mini') | StrOutputParser()

# 체인에 메모리 레이어를 씌웁니다
chain_with_memory = RunnableWithMessageHistory(
    chain,
    get_session_history,             # 세션 ID -> 기록장 반환 함수
    input_messages_key='input',      # 사용자 입력의 딕셔너리 키
    history_messages_key='history',  # 프롬프트의 history 슬롯 이름
)

실제 대화: 이름을 기억하는지 확인

같은 session_id로 연속 대화하기

# config에 session_id를 담아 호출합니다
config = {'configurable': {'session_id': 'user-alice'}}

# 첫 번째 대화: 이름을 알려줍니다
reply1 = chain_with_memory.invoke(
    {'input': '안녕, 나는 앨리스야. 파이썬 공부 중이야.'},
    config=config,
)
print('AI:', reply1)

# 두 번째 대화: 이름을 기억하는지 확인합니다
reply2 = chain_with_memory.invoke(
    {'input': '내 이름이 뭔지 기억해?'},
    config=config,
)
print('AI:', reply2)  # 앨리스 라고 답해야 합니다

# 쌓인 메시지 기록 출력
for msg in store['user-alice'].messages:
    print(f'{msg.type}: {msg.content[:40]}')

session_id가 같으면 대화가 이어지고, 다른 session_id를 주면 완전히 새 대화가 시작됩니다. 사용자 A와 사용자 B의 대화 기록이 완전히 분리됩니다.

대화가 길어지면: 토큰 비용을 줄이는 전략

대화가 수십 번 이어지면 기록이 너무 길어져 토큰 비용이 폭발합니다. 두 가지 전략으로 대응하세요. 첫째, 슬라이딩 윈도우: 최근 N개 메시지만 유지하고 오래된 것은 버립니다. 둘째, 요약 압축: LLM으로 오래된 대화를 한 단락으로 압축한 뒤 요약문 하나로 교체합니다. 실서비스에서는 두 방법을 조합해 씁니다.

저장소 종류패키지특징
InMemoryChatMessageHistorylangchain-core개발·테스트용, 재시작 시 초기화
RedisChatMessageHistorylangchain-community빠른 영구 저장, 실서비스 적합
PostgresChatMessageHistorylangchain-community기존 PostgreSQL DB와 통합 가능

session_id는 사용자 인증 후 받는 user_id 또는 대화방 UUID처럼 고유한 값을 쓰세요. get_session_history 함수 내부만 바꾸면 저장소를 교체할 수 있으므로, 개발 단계에서 인메모리로 시작하고 운영 단계에서 Redis로 교체하는 전략이 깔끔합니다.

InMemoryChatMessageHistory는 서버를 재시작하면 모든 기록이 사라집니다. 운영 환경에서는 반드시 Redis나 DB 기반 저장소로 교체하세요. 대화 기록에 민감한 개인정보가 포함될 수 있으므로 저장 시 암호화와 보관 기간 정책도 함께 설계하세요.