LLM은 스스로 기억하지 않는다
대화형 애플리케이션을 만들 때 가장 먼저 부딪히는 문제는 "모델이 방금 한 말을 왜 기억하지 못하지?"라는 질문이다. 이유는 단순하다. 대부분의 LLM 호출은 기본적으로 상태가 없다. invoke()를 한 번 실행할 때 모델이 보는 것은 그 순간 전달한 메시지뿐이다. 이전 대화가 필요하다면 개발자가 다시 넣어 주어야 한다.
개발 환경 준비와 첫 번째 랭체인 앱에서 만든 터미널 챗봇은 메시지 리스트에 HumanMessage와 AIMessage를 계속 추가했다. 이것이 가장 단순한 대화 기록이다. 하지만 실제 서비스에서는 단순 리스트만으로 부족하다. 사용자가 여러 명일 수 있고, 브라우저를 닫았다가 다시 들어올 수 있으며, 같은 사용자가 여러 업무를 동시에 진행할 수도 있다.
이 장에서 말하는 상태는 세 가지를 포함한다.
대화 기록: 사용자가 무엇을 물었고, AI가 무엇을 답했는가
세션 정보: 지금 대화가 어떤 사용자, 어떤 작업, 어떤 채널에 속하는가
작업 상태: 현재 선택한 문서, 진행 중인 단계, 확인해야 할 값은 무엇인가
핵심은 모델에게 모든 것을 기억시키는 것이 아니다. 애플리케이션이 필요한 상태를 저장하고, 필요한 만큼만 모델 입력에 넣는 것이다.
AI 생성 이미지
전체 대화 기록을 그대로 넣으면 생기는 문제
가장 쉬운 방법은 이전 메시지를 전부 프롬프트에 넣는 것이다. 짧은 실습에서는 잘 동작한다. 하지만 대화가 길어지면 문제가 빠르게 드러난다.
첫째, 토큰 비용이 계속 증가한다. 대화가 10턴, 30턴, 100턴으로 길어질수록 매번 이전 기록을 다시 보내야 한다. 같은 질문에 답하더라도 입력 토큰이 늘어나고, 응답 속도와 비용이 함께 증가한다.
둘째, 맥락 오염이 생긴다. 오래된 요구사항, 철회된 조건, 지나간 농담, 잘못된 답변까지 프롬프트에 남아 있으면 모델이 현재 질문과 무관한 정보를 끌어올 수 있다. 사용자가 "그 방식 말고 새 방식으로 해줘"라고 했는데 예전 방식이 계속 기록에 남아 있다면 답변이 흔들릴 수 있다.
셋째, 보안과 개인정보 관리가 어려워진다. 대화 기록에는 이름, 이메일, 내부 문서명, 고객 정보가 섞일 수 있다. 모든 기록을 항상 모델에 보내는 구조는 불필요한 노출을 만든다.
그래서 대화 기록 관리는 "많이 넣기"가 아니라 "선별해서 넣기"의 문제다. 일반적으로는 전체 기록, 최근 대화, 요약, 사용자 프로필, 작업 상태를 구분해 관리한다. 최근 대화만 유지하는 방식, 오래된 내용을 요약하는 방식, 둘을 섞는 방식은 토큰 비용과 정보 손실 사이의 균형을 잡기 위한 대표적인 전략이다.1
상태 관리 전략 비교
대화 기록을 다루는 전략은 사용 목적에 따라 달라진다.
| 전략 | 방식 | 장점 | 주의할 점 |
|---|---|---|---|
| 전체 기록 유지 | 모든 메시지를 그대로 프롬프트에 넣음 | 구현이 쉽고 원문을 잃지 않음 | 비용 증가, 컨텍스트 한도 초과, 맥락 오염 |
| 최근 대화 유지 | 마지막 N개 메시지만 사용 | 저렴하고 현재 흐름에 강함 | 초반에 말한 중요한 조건을 잃을 수 있음 |
| 요약 메모리 | 오래된 대화를 짧게 요약 | 긴 대화의 핵심을 유지 | 요약 과정에서 세부사항이 사라질 수 있음 |
| 사용자 프로필 | 선호 언어, 역할, 반복 정보만 별도 저장 | 매번 다시 묻지 않아도 됨 | 사용자의 동의와 수정 기능이 필요 |
| 작업 상태 | 선택 문서, 단계, 임시 값 등을 구조화해 저장 | 업무 자동화에 적합 | 대화문과 분리해 명확한 스키마가 필요 |
예를 들어 고객지원 챗봇이라면 최근 6~10개 메시지와 고객의 현재 문의 유형이 중요하다. 반면 코칭 앱이라면 몇 주 동안의 목표와 진행 요약이 중요할 수 있다. 문서 검색 챗봇이라면 이전 대화 전체보다 "지금 사용자가 어떤 문서를 가리키는지"가 더 중요하다.
좋은 기준은 다음과 같다.
사용자의 최근 의도를 이해하는 데 필요한가?
답변의 사실 근거로 사용할 수 있는가?
오래되어도 계속 유효한 정보인가?
저장해도 되는 정보인가?
모델에 보내지 않고 애플리케이션 코드에서 처리할 수 있는가?
마지막 질문이 중요하다. 모든 상태를 LLM에게 해석시키지 않아도 된다. 로그인 사용자 ID, 권한, 선택된 프로젝트 ID, 결제 상태 같은 값은 모델이 판단할 정보가 아니라 코드가 관리할 정보다.
세션 ID로 대화 기록 분리하기
대화 기록은 사용자별, 세션별로 분리해야 한다. 그렇지 않으면 A 사용자의 대화가 B 사용자에게 섞이는 심각한 문제가 생긴다.
LangChain에서는 RunnableWithMessageHistory를 사용해 LCEL 체인에 메시지 기록을 결합할 수 있다. 최근 방식은 예전의 메모리 헬퍼를 체인에 붙이는 것보다, 세션 ID로 메시지 기록을 불러오고 MessagesPlaceholder 위치에 명시적으로 주입하는 구조에 가깝다. RunnableWithMessageHistory는 기본적으로 config 안의 session_id를 보고 어떤 대화 기록을 사용할지 결정한다.2
아래 코드는 가장 단순한 인메모리 예제다. 서버를 재시작하면 기록은 사라지지만, 흐름을 이해하기에는 충분하다.
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
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
load_dotenv()
model = ChatOpenAI(model='gpt-4o-mini', temperature=0)
prompt = ChatPromptTemplate.from_messages([
('system', '너는 친절한 Python 학습 도우미다. 이전 대화를 참고하되, 모르면 모른다고 말한다.'),
MessagesPlaceholder(variable_name='history'),
('human', '{question}'),
])
chain = prompt | model | StrOutputParser()
store = {}
def get_session_history(session_id: str):
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
chat_chain = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key='question',
history_messages_key='history',
)
config = {
'configurable': {
'session_id': 'user-001-session-001'
}
}
print(chat_chain.invoke({'question': '내가 배우고 싶은 언어는 Python이야.'}, config=config))
print(chat_chain.invoke({'question': '내가 배우고 싶은 언어가 뭐였지?'}, config=config))여기서 중요한 부분은 session_id다. 같은 session_id로 호출하면 이전 대화가 이어진다. 다른 session_id를 쓰면 별도 대화로 취급된다.
config_a = {'configurable': {'session_id': 'user-a-session-1'}}
config_b = {'configurable': {'session_id': 'user-b-session-1'}}
chat_chain.invoke({'question': '나는 마케팅팀이야.'}, config=config_a)
chat_chain.invoke({'question': '나는 데이터팀이야.'}, config=config_b)
print(chat_chain.invoke({'question': '나는 어느 팀이라고 했지?'}, config=config_a))
print(chat_chain.invoke({'question': '나는 어느 팀이라고 했지?'}, config=config_b))실무에서는 session_id를 사용자가 직접 입력하게 두지 않는다. 로그인 정보, 워크스페이스 ID, 대화방 ID를 바탕으로 서버가 생성해야 한다. 또한 사용자가 해당 세션을 볼 권한이 있는지도 항상 확인해야 한다.
인메모리에서 저장소로 옮기기
InMemoryChatMessageHistory는 학습용이다. 운영 환경에서는 서버 재시작, 여러 서버 인스턴스, 장애 복구를 고려해야 한다. 따라서 대화 기록은 데이터베이스나 Redis 같은 외부 저장소에 저장하는 것이 일반적이다. LangChain 생태계에도 Redis 기반 메시지 기록 구현처럼 외부 저장소와 연결할 수 있는 구현이 제공되지만, 패키지와 import 경로는 버전에 따라 달라질 수 있으므로 현재 사용하는 LangChain 문서를 확인하는 습관이 필요하다.3
저장할 때는 최소한 다음 필드를 고려한다.
| 필드 | 설명 |
|---|---|
session_id | 대화를 구분하는 ID |
user_id | 소유자 또는 요청자 |
role | human, ai, system 등 메시지 역할 |
content | 메시지 본문 |
created_at | 생성 시각 |
metadata | 모델명, 토큰 수, 출처, 채널 등 부가 정보 |
중요한 설계 원칙은 저장 기록과 프롬프트 기록을 분리하는 것이다. 데이터베이스에는 감사와 재현을 위해 원본을 저장할 수 있다. 하지만 모델에 넣을 때는 최근 메시지, 요약, 필요한 프로필만 선별해야 한다.
예를 들어 전체 원본 로그는 보관하되, 프롬프트에는 다음처럼 넣을 수 있다.
최근 사용자/AI 메시지 8개
오래된 대화 요약 1개
사용자가 명시적으로 저장한 선호 정보 3개
현재 작업 상태 1개
이 구조를 쓰면 대화가 길어져도 입력 크기를 통제할 수 있다. 또한 잘못 저장된 프로필이나 요약을 사용자가 수정할 수 있게 만들면 장기 사용성이 좋아진다.
RAG와 대화 기록을 함께 사용할 때
RAG 애플리케이션 만들기에서는 사용자 질문을 리트리버에 보내 관련 문서를 검색했다. 단일 질문에서는 충분하다. 하지만 대화형 RAG에서는 다음 질문이 자주 나온다.
"그 문서에서 환불 기간은 어떻게 돼?"
사람은 "그 문서"가 무엇인지 안다. 직전 대화에서 "2026년 고객 환불 정책 문서"를 이야기했기 때문이다. 하지만 리트리버는 보통 현재 입력 문자열만 보고 검색한다. 그 문서에서 환불 기간이라는 검색어는 너무 모호하다.
그래서 대화형 RAG에서는 검색 전에 질문 재작성 단계를 둔다. 목적은 답변을 생성하는 것이 아니라, 검색에 적합한 독립 질문을 만드는 것이다.
예를 들어 다음처럼 바꾼다.
원 질문:
그 문서에서 환불 기간은 어떻게 돼?검색 질문:
2026년 고객 환불 정책 문서에서 환불 가능 기간은 며칠인가?
이때 원 질문과 검색 질문을 구분해야 한다. 검색 질문은 리트리버용이다. 최종 답변은 사용자의 원 질문, 검색된 문서, 필요한 대화 기록을 함께 보고 생성한다.
아래는 LCEL로 구성한 간단한 흐름이다.
from langchain_core.runnables import RunnablePassthrough
rephrase_prompt = ChatPromptTemplate.from_messages([
('system', '대화 기록과 최신 질문을 보고 검색에 사용할 독립적인 질문을 작성하라. 답변하지 말고 검색 질문만 작성하라.'),
MessagesPlaceholder(variable_name='history'),
('human', '{question}'),
])
rephrase_chain = rephrase_prompt | model | StrOutputParser()
def format_docs(docs):
return 'nn'.join(
f'[문서 {i+1}] {doc.page_content}'
for i, doc in enumerate(docs)
)
answer_prompt = ChatPromptTemplate.from_messages([
('system', '아래 문맥에 근거해 답하라. 문맥에 없으면 모른다고 말하라.'),
MessagesPlaceholder(variable_name='history'),
('human', '문맥:n{context}nn질문: {question}'),
])
rag_chain = (
RunnablePassthrough.assign(search_query=rephrase_chain)
.assign(docs=lambda x: retriever.invoke(x['search_query']))
.assign(context=lambda x: format_docs(x['docs']))
| answer_prompt
| model
| StrOutputParser()
)
chat_rag_chain = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key='question',
history_messages_key='history',
)이 구조에서 history는 두 곳에 쓰인다. 먼저 재작성 단계에서 "그 문서", "아까 말한 방식", "두 번째 항목" 같은 표현을 풀어낸다. 다음으로 답변 생성 단계에서 대화 흐름을 자연스럽게 이어 간다.
단, RAG의 사실 근거는 대화 기록이 아니라 검색된 문서여야 한다. 대화 기록은 사용자의 의도를 이해하는 보조 정보다. 답변의 근거와 출처는 RAG 애플리케이션 만들기에서 다룬 것처럼 Document.metadata에서 애플리케이션 코드가 관리하는 편이 안전하다.
상태 관리 설계 체크리스트
대화형 서비스를 만들 때는 다음 질문을 먼저 정리하자.
세션은 어떤 기준으로 나눌 것인가?
같은 사용자의 여러 대화를 어떻게 구분할 것인가?
프롬프트에 넣을 최근 메시지는 몇 개로 제한할 것인가?
오래된 대화는 버릴 것인가, 요약할 것인가?
사용자 프로필로 저장할 정보와 저장하지 않을 정보는 무엇인가?
대화 기록을 삭제하거나 내보내는 기능이 필요한가?
RAG 검색에는 원 질문을 쓸 것인가, 재작성 질문을 쓸 것인가?
저장소 장애나 서버 재시작 후에도 대화가 이어져야 하는가?
대화형 서비스의 품질은 모델 성능만으로 결정되지 않는다. 사용자가 이미 말한 내용을 적절히 기억하고, 불필요한 내용은 잊고, 필요한 상태를 안전하게 유지해야 한다. 좋은 상태 관리는 답변을 길게 만드는 기술이 아니라, 지금 질문에 필요한 맥락만 정확히 전달하는 기술이다.
도구 호출과 에이전트 구성하기에서는 이 상태 위에 도구 호출을 얹는다. 사용자의 요청을 이해하는 것에서 한 걸음 더 나아가, 캘린더 조회, 파일 생성, API 호출처럼 실제 행동을 수행하는 에이전트 구조를 살펴본다.
참고
2RunnableWithMessageHistory | LangChain Python API Reference
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.