- Authors
- Name

- 개요
- 멀티턴 대화의 과제
- 메모리 아키텍처 유형
- 컨텍스트 윈도우 관리
- 대화 요약 전략
- 컨텍스트 압축 기법
- LangGraph 상태 머신 구현
- 세션 관리와 영속성
- 토큰 예산 관리
- 트러블슈팅
- 운영 체크리스트
- 실패 사례
- 참고자료
개요
멀티턴 챗봇은 단일 질의-응답이 아니라 여러 턴에 걸친 연속적인 대화를 처리한다. 사용자가 "이전에 말한 것"을 참조하거나 문맥에 의존하는 후속 질문을 던질 때, 챗봇은 이전 대화 내용을 정확히 기억하고 적절한 맥락을 유지해야 한다. 2026년 현재 Claude 4 Sonnet은 200K 토큰, GPT-5는 400K 토큰의 컨텍스트 윈도우를 제공하지만, 긴 컨텍스트가 항상 좋은 것은 아니다.
컨텍스트 윈도우가 커질수록 Attention 연산은 O(n^2)로 증가하고, 비용도 비례해 상승한다. 더 심각한 문제는 "컨텍스트 부패(context rot)"다. 입력 길이가 늘어날수록 모델의 정확도와 재현율이 저하되는 현상이 연구로 확인되었다. 따라서 무조건 전체 대화 기록을 넣는 것이 아니라, 핵심 정보를 선별하고 압축하는 전략이 필수적이다.
이 글에서는 멀티턴 챗봇의 대화 상태를 관리하는 메모리 아키텍처부터, 컨텍스트 윈도우를 효율적으로 활용하는 압축 기법, LangGraph 상태 머신 구현, Redis 기반 세션 영속화, 토큰 예산 관리까지 실전에서 바로 적용할 수 있는 전략을 다룬다.
멀티턴 대화의 과제
토큰 한계와 비용 문제
멀티턴 대화에서 가장 먼저 부딪히는 벽은 토큰 한계다. 고객 상담 챗봇을 예로 들면, 한 세션에서 50턴 이상 대화가 이어지는 경우가 흔하다. 턴당 평균 200토큰이라면 50턴 기준 10,000토큰이 대화 기록에만 소모된다. 여기에 시스템 프롬프트, RAG 문서, 함수 호출 결과까지 더하면 토큰 예산은 급격히 줄어든다.
핵심 정보 손실
슬라이딩 윈도우로 최근 N개의 메시지만 유지하면 초반 대화에서 설정된 사용자의 핵심 요구사항이 사라진다. 사용자가 10턴 전에 "예산은 500만 원 이하"라고 말했는데, 해당 메시지가 윈도우 밖으로 밀려나면 챗봇은 엉뚱한 추천을 하게 된다.
상태 관리의 복잡성
단순 Q&A를 넘어서 예약, 주문, 문제 해결 같은 태스크 지향 대화에서는 현재 단계(step), 수집된 정보(slot), 확인 상태(confirmation) 같은 구조화된 상태를 관리해야 한다. 이 상태는 대화 기록과는 별도로 추적되어야 하며, 특정 조건에서 초기화되거나 분기해야 한다.
동시 세션 격리
프로덕션 환경에서는 수백~수천 명의 사용자가 동시에 대화한다. 각 사용자의 대화 상태가 섞이지 않도록 세션을 격리하고, 사용자가 브라우저를 닫았다가 다시 열어도 이전 상태를 복원해야 한다.
메모리 아키텍처 유형
LangChain과 LlamaIndex는 멀티턴 대화를 위한 다양한 메모리 유형을 제공한다. 각각의 장단점을 이해하고 상황에 맞는 조합을 선택하는 것이 중요하다.
메모리 유형 비교표
| 메모리 유형 | 저장 방식 | 토큰 사용량 | 정보 충실도 | 적합한 시나리오 |
|---|---|---|---|---|
| Buffer Memory | 전체 대화 기록 저장 | 높음 (선형 증가) | 매우 높음 | 짧은 대화, 디버깅 필요 |
| Window Memory | 최근 K개 메시지만 유지 | 고정 (윈도우 크기) | 중간 (초반 손실) | 일반 고객 상담 |
| Summary Memory | LLM으로 요약 생성 | 낮음 (요약 길이) | 낮음 (세부사항 손실) | 장시간 대화, 비용 절감 |
| Summary Buffer | 요약 + 최근 버퍼 혼합 | 중간 | 높음 | 대부분의 프로덕션 |
| Vector Memory | 임베딩으로 관련 대화 검색 | 가변 (검색 결과) | 높음 (관련성 기반) | 장기 기억, 크로스 세션 |
ConversationBufferMemory vs ConversationSummaryBufferMemory
Buffer Memory는 가장 단순하다. 모든 메시지를 그대로 저장하고 프롬프트에 넣는다. 디버깅이 쉽고 정보 손실이 없지만, 대화가 길어지면 토큰 한계에 금방 도달한다.
Summary Buffer Memory는 실전에서 가장 많이 쓰이는 접근이다. 최근 메시지는 원문 그대로 유지하고, 오래된 메시지는 LLM을 호출해 요약으로 압축한다. 토큰 수가 설정 임계치를 넘으면 자동으로 요약이 트리거된다.
from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# max_token_limit을 초과하면 오래된 메시지를 자동 요약
memory = ConversationSummaryBufferMemory(
llm=llm,
max_token_limit=2000,
return_messages=True,
memory_key="chat_history",
human_prefix="고객",
ai_prefix="상담원",
)
# 대화 저장
memory.save_context(
{"input": "노트북 구매를 고려 중인데 예산이 150만 원이에요"},
{"output": "150만 원 예산으로 좋은 노트북을 추천드리겠습니다. 주 용도가 어떻게 되시나요?"},
)
memory.save_context(
{"input": "프로그래밍과 가벼운 영상 편집을 할 거예요"},
{"output": "개발과 영상 편집용이시라면 RAM 16GB 이상, SSD 512GB 이상의 사양을 추천드립니다."},
)
memory.save_context(
{"input": "맥북 에어 M4와 레노버 씽크패드 중에 뭐가 나을까요?"},
{"output": "두 제품 모두 훌륭하지만 용도에 따라 차이가 있습니다. 맥북 에어 M4는 ..."},
)
# 토큰 한계 초과 시 자동 요약 + 최근 메시지 유지
loaded = memory.load_memory_variables({})
print(loaded["chat_history"])
# SystemMessage: "고객은 150만 원 예산으로 프로그래밍과 영상 편집용 노트북을 찾고 있으며..."
# + 최근 원본 메시지들
LlamaIndex ChatSummaryMemoryBuffer
LlamaIndex에서도 유사한 메커니즘을 제공한다. ChatSummaryMemoryBuffer는 설정된 토큰 한도를 초과하면 오래된 메시지를 주기적으로 요약하면서, 최근 메시지 원문은 그대로 유지한다.
from llama_index.core.memory import ChatSummaryMemoryBuffer
from llama_index.llms.openai import OpenAI
llm = OpenAI(model="gpt-4o", temperature=0)
memory = ChatSummaryMemoryBuffer.from_defaults(
llm=llm,
token_limit=3000,
# 요약 트리거 토큰 비율 (전체 한도의 70% 초과 시)
summarize_threshold=0.7,
)
# Chat Engine에 메모리 연결
from llama_index.core.chat_engine import CondensePlusContextChatEngine
chat_engine = CondensePlusContextChatEngine.from_defaults(
retriever=index.as_retriever(similarity_top_k=3),
memory=memory,
llm=llm,
system_prompt="당신은 기술 지원 전문가입니다.",
)
response = chat_engine.chat("이전에 설명한 에러 코드에 대해 더 자세히 알려주세요")
컨텍스트 윈도우 관리
슬라이딩 윈도우 전략
슬라이딩 윈도우는 가장 직관적인 컨텍스트 관리 방법이다. 최근 K개의 메시지만 유지하고 나머지는 버린다. 구현이 간단하고 토큰 사용량이 예측 가능하지만, 초반 대화 정보가 완전히 사라지는 단점이 있다.
개선된 슬라이딩 윈도우는 단순히 메시지 개수가 아니라 토큰 수 기반으로 윈도우 크기를 결정한다. 짧은 메시지 20개와 긴 메시지 5개를 동일하게 취급하지 않는 것이다.
토큰 기반 윈도우 구현
import tiktoken
def sliding_window_by_tokens(
messages: list[dict],
max_tokens: int = 4000,
model: str = "gpt-4o",
always_keep_system: bool = True,
) -> list[dict]:
"""토큰 수 기반 슬라이딩 윈도우.
시스템 메시지는 항상 유지하고, 최근 메시지부터 역순으로 채운다.
"""
enc = tiktoken.encoding_for_model(model)
result = []
current_tokens = 0
# 시스템 메시지 우선 확보
system_messages = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
if always_keep_system:
for sm in system_messages:
sm_tokens = len(enc.encode(sm["content"]))
result.append(sm)
current_tokens += sm_tokens
# 최근 메시지부터 역순으로 추가
selected = []
for msg in reversed(non_system):
msg_tokens = len(enc.encode(msg["content"]))
if current_tokens + msg_tokens > max_tokens:
break
selected.append(msg)
current_tokens += msg_tokens
result.extend(reversed(selected))
return result
컨텍스트 윈도우 관리 방법 비교
| 관리 방법 | 구현 복잡도 | 토큰 효율 | 정보 보존 | 지연 시간 |
|---|---|---|---|---|
| 메시지 수 기반 슬라이딩 | 매우 낮음 | 중간 | 낮음 | 없음 |
| 토큰 수 기반 슬라이딩 | 낮음 | 높음 | 낮음 | 매우 낮음 |
| 요약 + 슬라이딩 | 중간 | 높음 | 높음 | 중간 (LLM 호출) |
| 벡터 검색 기반 | 높음 | 매우 높음 | 높음 | 중간 (임베딩 + 검색) |
| 하이브리드 (요약 + 벡터) | 매우 높음 | 매우 높음 | 매우 높음 | 높음 |
대화 요약 전략
대화 요약은 컨텍스트 윈도우를 절약하면서 핵심 정보를 유지하는 핵심 기법이다. 단순히 "대화를 요약해줘"라고 하면 중요한 세부 사항이 누락될 수 있으므로, 구조화된 요약 프롬프트를 사용해야 한다.
점진적 요약 (Progressive Summarization)
전체 대화를 한 번에 요약하는 것이 아니라, 일정 턴마다 기존 요약에 새로운 내용을 병합하는 방식이다. 이 접근은 요약 품질이 높고 비용도 낮다.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
PROGRESSIVE_SUMMARY_PROMPT = ChatPromptTemplate.from_messages([
("system", """당신은 고객 상담 대화 요약 전문가입니다.
기존 요약과 새로운 대화를 병합하여 업데이트된 요약을 생성하세요.
규칙:
1. 고객의 핵심 요구사항, 제약 조건, 선호도를 반드시 유지
2. 확인된 사실(이름, 주문번호, 날짜 등)을 절대 누락하지 않기
3. 현재 진행 단계와 다음 필요 액션을 명시
4. 해결된 이슈는 간략히, 미해결 이슈는 상세히 기록
5. 200자 이내로 작성"""),
("human", """기존 요약:
{existing_summary}
새로운 대화:
{new_messages}
업데이트된 요약:"""),
])
class ProgressiveSummarizer:
def __init__(self, llm, summary_interval: int = 5):
self.llm = llm
self.chain = PROGRESSIVE_SUMMARY_PROMPT | llm
self.summary = ""
self.buffer = []
self.summary_interval = summary_interval
self.turn_count = 0
def add_turn(self, user_msg: str, assistant_msg: str):
self.buffer.append(f"고객: {user_msg}")
self.buffer.append(f"상담원: {assistant_msg}")
self.turn_count += 1
if self.turn_count % self.summary_interval == 0:
self._update_summary()
def _update_summary(self):
new_messages = "\n".join(self.buffer)
result = self.chain.invoke({
"existing_summary": self.summary or "(없음)",
"new_messages": new_messages,
})
self.summary = result.content
self.buffer = [] # 버퍼 초기화
def get_context(self) -> str:
"""요약 + 최근 버퍼를 결합한 컨텍스트 반환"""
parts = []
if self.summary:
parts.append(f"[대화 요약]\n{self.summary}")
if self.buffer:
parts.append(f"[최근 대화]\n" + "\n".join(self.buffer))
return "\n\n".join(parts)
구조화 요약 vs 자유형 요약
| 요약 방식 | 장점 | 단점 | 추천 시나리오 |
|---|---|---|---|
| 자유형 요약 | 구현 간단, 유연함 | 핵심 정보 누락 가능 | 일반 잡담 챗봇 |
| 슬롯 기반 구조화 | 필수 정보 보장 | 프롬프트 설계 필요 | 예약/주문 챗봇 |
| 키-밸류 추출 | 검색/필터 가능 | 맥락 손실 가능 | 데이터 수집 목적 |
| 점진적 병합 | 비용 효율, 품질 높음 | 누적 오류 가능 | 장시간 상담 |
컨텍스트 압축 기법
LLMLingua를 활용한 프롬프트 압축
Microsoft의 LLMLingua 시리즈는 프롬프트를 20배까지 압축하면서도 성능 저하를 최소화하는 기술이다. 작은 언어 모델의 Perplexity를 기반으로 중요하지 않은 토큰을 제거한다. LLMLingua-2는 GPT-4 증류 데이터로 학습되어 도메인에 무관한 범용 압축이 가능하며, 기존 LLMLingua 대비 3~6배 빠른 속도를 보인다.
from llmlingua import PromptCompressor
# LLMLingua-2 초기화
compressor = PromptCompressor(
model_name="microsoft/llmlingua-2-xlm-roberta-large-meetingbank",
use_llmlingua2=True,
device_map="cpu", # GPU 사용 시 "cuda"
)
# 긴 대화 기록을 압축
conversation_history = """
고객: 안녕하세요, 지난주에 주문한 상품에 대해 문의드립니다.
상담원: 안녕하세요! 주문번호를 알려주시면 확인해 드리겠습니다.
고객: 주문번호는 ORD-2026-03-1234입니다. 배송이 아직 안 왔어요.
상담원: 확인해 보겠습니다. 잠시만 기다려주세요.
상담원: 주문번호 ORD-2026-03-1234 확인했습니다. 현재 배송 중이며 내일 도착 예정입니다.
고객: 내일이요? 원래 어제 도착했어야 하는데 왜 늦어진 건가요?
상담원: 물류센터 사정으로 하루 지연되었습니다. 불편을 드려 죄송합니다.
고객: 배송비 환불은 가능한가요?
상담원: 네, 배송 지연으로 인한 배송비 환불이 가능합니다. 환불 처리를 진행할까요?
고객: 네, 부탁드립니다.
상담원: 배송비 3,000원 환불 처리를 완료했습니다. 1-3일 내 원래 결제 수단으로 환불됩니다.
"""
compressed = compressor.compress_prompt(
conversation_history,
rate=0.5, # 50% 압축률
force_tokens=["주문번호", "ORD-2026-03-1234", "환불"], # 반드시 유지할 토큰
)
print(f"원본 토큰: {compressed['origin_tokens']}")
print(f"압축 토큰: {compressed['compressed_tokens']}")
print(f"압축률: {compressed['ratio']:.1f}x")
print(f"압축 결과:\n{compressed['compressed_prompt']}")
압축 기법 비교
| 압축 기법 | 압축률 | 성능 유지 | 속도 | 학습 필요 |
|---|---|---|---|---|
| LLMLingua | 최대 20x | 높음 | 중간 | 아니오 (추론만) |
| LLMLingua-2 | 최대 20x | 매우 높음 | 빠름 (3-6x) | 아니오 |
| LongLLMLingua | 최대 4x | 매우 높음 | 중간 | 아니오 |
| LLM 요약 | 가변 | 중간 | 느림 (LLM 호출) | 아니오 |
| 규칙 기반 필터링 | 2-3x | 낮음 | 매우 빠름 | 아니오 |
| Selective Context | 최대 10x | 높음 | 빠름 | 아니오 |
LangGraph 상태 머신 구현
LangGraph는 대화 흐름을 그래프 기반 상태 머신으로 모델링할 수 있다. LangChain의 기존 메모리 방식과 달리, 명시적인 상태 스키마와 리듀서 함수를 사용해 복잡한 멀티턴 워크플로우를 안정적으로 관리한다. 체크포인터를 통해 상태가 자동으로 영속화되므로 세션 복원도 자연스럽다.
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
# 1. 상태 스키마 정의
class OrderSupportState(TypedDict):
messages: Annotated[list, add_messages] # 리듀서로 메시지 누적
order_id: str | None
issue_type: str | None # "배송", "환불", "교환", "기타"
step: str # "greeting", "identify", "diagnose", "resolve", "close"
collected_info: dict
summary: str # 이전 대화 요약
# 2. 노드 함수 정의
llm = ChatOpenAI(model="gpt-4o", temperature=0)
def greeting_node(state: OrderSupportState) -> dict:
"""인사 및 초기 분류"""
response = llm.invoke([
SystemMessage(content="고객 상담 챗봇입니다. 고객의 문의 유형을 파악하세요."),
*state["messages"],
])
return {
"messages": [response],
"step": "identify",
}
def identify_node(state: OrderSupportState) -> dict:
"""주문번호 및 이슈 유형 식별"""
context_parts = []
if state.get("summary"):
context_parts.append(f"이전 대화 요약: {state['summary']}")
system_msg = f"""고객의 주문번호와 문제 유형을 파악하세요.
수집된 정보: {state.get('collected_info', dict())}
{chr(10).join(context_parts)}"""
response = llm.invoke([
SystemMessage(content=system_msg),
*state["messages"][-10:], # 최근 10개 메시지만 사용
])
# 응답에서 주문번호 추출 (실제로는 더 정교한 파싱 필요)
return {
"messages": [response],
"step": "diagnose",
}
def resolve_node(state: OrderSupportState) -> dict:
"""문제 해결 제안"""
response = llm.invoke([
SystemMessage(content=f"문제 유형: {state.get('issue_type', '미확인')}. "
f"주문번호: {state.get('order_id', '미확인')}. 해결책을 제시하세요."),
*state["messages"][-6:],
])
return {
"messages": [response],
"step": "close",
}
# 3. 라우팅 함수
def route_by_step(state: OrderSupportState) -> str:
step = state.get("step", "greeting")
if step == "greeting":
return "greeting"
elif step == "identify":
return "identify"
elif step in ("diagnose", "resolve"):
return "resolve"
else:
return END
# 4. 그래프 구성
graph = StateGraph(OrderSupportState)
graph.add_node("greeting", greeting_node)
graph.add_node("identify", identify_node)
graph.add_node("resolve", resolve_node)
graph.add_conditional_edges(START, route_by_step)
graph.add_conditional_edges("greeting", route_by_step)
graph.add_conditional_edges("identify", route_by_step)
graph.add_conditional_edges("resolve", route_by_step)
# 5. 체크포인터로 상태 영속화
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)
# 6. 실행 (thread_id로 세션 구분)
config = {"configurable": {"thread_id": "user-session-abc123"}}
result = app.invoke(
{
"messages": [HumanMessage(content="주문한 상품이 아직 안 왔어요")],
"step": "greeting",
"collected_info": {},
"summary": "",
},
config=config,
)
LangGraph의 체크포인터는 매 노드 실행 후 자동으로 상태를 저장한다. MemorySaver는 인메모리 저장이므로 개발/테스트에 적합하고, 프로덕션에서는 SqliteSaver, PostgresSaver, 또는 MongoDB Store를 사용해야 한다. thread_id로 세션을 구분하므로 동시에 여러 사용자의 대화를 격리할 수 있다.
세션 관리와 영속성
Redis 기반 세션 저장소
프로덕션 환경에서 대화 상태를 영속화할 때 Redis는 가장 보편적인 선택이다. 저지연 읽기/쓰기, TTL 기반 자동 만료, Pub/Sub를 통한 실시간 알림을 모두 지원한다.
import json
import time
import redis
import tiktoken
class ChatSessionManager:
"""Redis 기반 멀티턴 대화 세션 관리자"""
def __init__(
self,
redis_url: str = "redis://localhost:6379",
session_ttl: int = 3600, # 1시간
max_history_tokens: int = 4000,
):
self.redis = redis.from_url(redis_url, decode_responses=True)
self.session_ttl = session_ttl
self.max_history_tokens = max_history_tokens
self.encoder = tiktoken.encoding_for_model("gpt-4o")
def _key(self, session_id: str, suffix: str) -> str:
return f"chat:session:{session_id}:{suffix}"
def create_session(self, session_id: str, metadata: dict | None = None) -> dict:
"""새 세션 생성"""
session_data = {
"session_id": session_id,
"created_at": time.time(),
"updated_at": time.time(),
"turn_count": 0,
"total_tokens": 0,
"metadata": json.dumps(metadata or {}),
"summary": "",
}
self.redis.hset(self._key(session_id, "meta"), mapping=session_data)
self.redis.expire(self._key(session_id, "meta"), self.session_ttl)
return session_data
def add_message(self, session_id: str, role: str, content: str) -> None:
"""메시지 추가 및 토큰 관리"""
msg = json.dumps({
"role": role,
"content": content,
"timestamp": time.time(),
"tokens": len(self.encoder.encode(content)),
})
history_key = self._key(session_id, "history")
self.redis.rpush(history_key, msg)
self.redis.expire(history_key, self.session_ttl)
# 메타데이터 업데이트
self.redis.hincrby(self._key(session_id, "meta"), "turn_count", 1)
self.redis.hset(
self._key(session_id, "meta"), "updated_at", str(time.time())
)
# TTL 갱신
self.redis.expire(self._key(session_id, "meta"), self.session_ttl)
def get_context_messages(self, session_id: str) -> list[dict]:
"""토큰 예산 내에서 컨텍스트 메시지 반환"""
history_key = self._key(session_id, "history")
all_messages = self.redis.lrange(history_key, 0, -1)
if not all_messages:
return []
parsed = [json.loads(m) for m in all_messages]
result = []
token_count = 0
# 요약이 있으면 먼저 추가
summary = self.redis.hget(self._key(session_id, "meta"), "summary")
if summary:
summary_tokens = len(self.encoder.encode(summary))
token_count += summary_tokens
result.append({"role": "system", "content": f"이전 대화 요약: {summary}"})
# 최근 메시지부터 역순으로 토큰 예산 내에서 추가
selected = []
for msg in reversed(parsed):
msg_tokens = msg.get("tokens", 0)
if token_count + msg_tokens > self.max_history_tokens:
break
selected.append({"role": msg["role"], "content": msg["content"]})
token_count += msg_tokens
result.extend(reversed(selected))
return result
def update_summary(self, session_id: str, summary: str) -> None:
"""대화 요약 업데이트"""
self.redis.hset(self._key(session_id, "meta"), "summary", summary)
def delete_session(self, session_id: str) -> None:
"""세션 삭제"""
for suffix in ("meta", "history"):
self.redis.delete(self._key(session_id, suffix))
세션 저장소 비교
| 저장소 | 지연 시간 | 영속성 | 확장성 | TTL 지원 | 적합 시나리오 |
|---|---|---|---|---|---|
| 인메모리 (dict) | 나노초 | 없음 | 단일 프로세스 | 수동 구현 | 개발/테스트 |
| Redis | 밀리초 | 조건부 (AOF/RDB) | 클러스터 지원 | 내장 | 프로덕션 실시간 |
| PostgreSQL | 수 밀리초 | 완전 | 높음 | 트리거 구현 | 감사 로그 필요 |
| MongoDB | 수 밀리초 | 완전 | 샤딩 지원 | TTL 인덱스 | 비정형 상태 |
| DynamoDB | 수 밀리초 | 완전 | 무제한 | TTL 내장 | AWS 기반 서비스 |
프로덕션에서는 Redis를 메인 세션 스토어로 사용하면서 PostgreSQL이나 MongoDB에 비동기로 플러시하는 하이브리드 패턴이 일반적이다. Redis에서 현재 대화 상태를 빠르게 읽고, 대화 종료 시 관계형 DB에 전체 기록을 저장하면 성능과 영속성을 모두 확보할 수 있다.
토큰 예산 관리
프로덕션 챗봇에서 토큰 예산 관리는 비용 통제와 응답 품질의 핵심이다. 모델의 컨텍스트 윈도우를 시스템 프롬프트, 대화 기록, RAG 문서, 응답 예약 등으로 분할 배분하는 전략이 필요하다.
import tiktoken
from dataclasses import dataclass
@dataclass
class TokenBudget:
"""토큰 예산 배분 계산기"""
model: str = "gpt-4o"
max_context: int = 128000 # gpt-4o context window
system_prompt_tokens: int = 500
response_reserve: int = 4000 # 응답용 예약
rag_budget: int = 3000 # RAG 문서용
tool_result_budget: int = 2000 # 도구 실행 결과용
def __post_init__(self):
self.encoder = tiktoken.encoding_for_model(self.model)
@property
def conversation_budget(self) -> int:
"""대화 기록에 할당 가능한 토큰 수"""
reserved = (
self.system_prompt_tokens
+ self.response_reserve
+ self.rag_budget
+ self.tool_result_budget
)
return self.max_context - reserved
def count_tokens(self, text: str) -> int:
return len(self.encoder.encode(text))
def allocate(self, messages: list[dict]) -> dict:
"""현재 메시지에 대한 토큰 사용 현황 리포트"""
msg_tokens = sum(
self.count_tokens(m.get("content", "")) for m in messages
)
budget = self.conversation_budget
return {
"total_context": self.max_context,
"system_prompt": self.system_prompt_tokens,
"response_reserve": self.response_reserve,
"rag_budget": self.rag_budget,
"tool_result_budget": self.tool_result_budget,
"conversation_budget": budget,
"conversation_used": msg_tokens,
"conversation_remaining": budget - msg_tokens,
"utilization_pct": round(msg_tokens / budget * 100, 1),
"needs_compression": msg_tokens > budget * 0.8,
}
# 사용 예시
budget = TokenBudget(model="gpt-4o", max_context=128000)
print(f"대화 기록 가용 토큰: {budget.conversation_budget:,}")
report = budget.allocate([
{"role": "user", "content": "이전 주문 상태를 확인해주세요"},
{"role": "assistant", "content": "주문번호를 알려주시겠어요?"},
])
print(f"사용률: {report['utilization_pct']}%")
print(f"압축 필요: {report['needs_compression']}")
토큰 예산의 80%를 초과하면 자동으로 압축을 트리거하는 것이 좋은 관행이다. 이 임계치는 서비스 특성에 따라 조정한다. 고객 상담처럼 정확성이 중요한 경우 70%로 낮추고, 캐주얼 대화에서는 90%까지 허용할 수 있다.
트러블슈팅
컨텍스트 유실로 인한 반복 질문
증상: 챗봇이 이미 수집한 정보를 다시 묻는다.
원인 진단 순서:
- 슬라이딩 윈도우 크기가 너무 작은지 확인한다. 윈도우 밖으로 밀려난 메시지에 핵심 정보가 있는 경우다.
- 요약이 제대로 동작하는지 확인한다. 요약 프롬프트가 핵심 slot 정보(이름, 주문번호 등)를 누락하고 있을 수 있다.
- 상태 관리 로직에서
collected_info가 제대로 업데이트되고 있는지 확인한다.
해결: 구조화 요약 프롬프트에 "반드시 유지해야 할 필드" 목록을 명시한다. slot 정보는 별도의 상태 딕셔너리로 관리한다.
Redis 세션 만료로 인한 대화 단절
증상: 사용자가 잠시 자리를 비운 뒤 돌아오면 챗봇이 처음부터 다시 시작한다.
원인: TTL이 너무 짧게 설정되어 있다.
해결: 메시지가 추가될 때마다 TTL을 갱신하고, 비즈니스 요구에 맞는 TTL을 설정한다. 고객 상담은 2시간, 쇼핑 어시스턴트는 24시간이 적절하다. 만료 전 경고 메시지를 보내는 것도 좋다.
요약 누적 오류 (Summary Drift)
증상: 점진적 요약을 반복하면서 사실이 왜곡되거나 환각이 포함된다.
원인: 요약의 요약을 반복하면서 정보 손실과 왜곡이 누적된다.
해결: 5~10회 요약 반복마다 원본 메시지 기반으로 요약을 재생성한다. 수치, 날짜, 고유명사 같은 팩트 정보는 요약과 별도로 추출하여 상태에 저장한다.
동시 요청으로 인한 상태 충돌
증상: 사용자가 빠르게 연속 메시지를 보내면 응답이 뒤섞이거나 상태가 깨진다.
원인: 동시 실행되는 두 요청이 같은 세션 상태를 읽고 쓰면서 race condition이 발생한다.
해결: Redis의 WATCH/MULTI/EXEC 트랜잭션 또는 분산 락을 사용한다. LangGraph의 경우 체크포인터가 순차 실행을 보장하므로 이 문제가 자연스럽게 해결된다.
운영 체크리스트
프로덕션 멀티턴 챗봇을 배포하기 전 확인해야 할 항목들이다.
메모리 및 컨텍스트 관리:
- 메모리 유형 선정 완료 (Buffer, Summary Buffer, Vector 등)
- 토큰 예산 배분 정의 (시스템 프롬프트, 대화 기록, RAG, 응답 예약)
- 컨텍스트 압축 임계치 설정 (80% 이상 시 트리거)
- 요약 프롬프트에 필수 유지 필드 명시
세션 관리:
- Redis 또는 영속 저장소 연결 확인
- 세션 TTL 설정 (서비스 유형에 맞게)
- 동시 요청 처리 전략 수립 (락, 큐, 순차 실행)
- 세션 만료 시 사용자 알림 로직 구현
모니터링:
- 턴당 평균 토큰 사용량 추적
- 요약 호출 빈도 및 비용 모니터링
- 컨텍스트 유실로 인한 반복 질문 비율 측정
- 세션 평균 지속 시간 및 턴 수 추적
장애 대응:
- Redis 장애 시 인메모리 폴백 로직
- 요약 LLM 호출 실패 시 원본 메시지 유지 전략
- 상태 복구 절차 문서화
실패 사례
사례 1: 무한 컨텍스트 확장
한 고객 상담 챗봇 프로젝트에서 "정보 손실을 절대 허용하지 않겠다"는 방침 하에 Buffer Memory를 사용하고 컨텍스트 관리를 하지 않았다. 초기에는 문제가 없었지만, 평균 대화 턴 수가 30을 넘으면서 API 비용이 월 500만 원에서 2,000만 원으로 급증했다. 응답 지연도 평균 2초에서 8초로 늘어났다.
교훈: 모든 메시지를 유지하는 것이 최선이 아니다. Summary Buffer Memory로 전환하고 토큰 예산을 4,000으로 설정한 뒤 비용이 70% 절감되었고, 고객 만족도에는 유의미한 변화가 없었다.
사례 2: 자유형 요약의 함정
여행 예약 챗봇에서 자유형 요약을 사용했더니, 요약 과정에서 출발일과 도착일이 뒤바뀌거나 인원수가 누락되는 사고가 반복되었다. 고객이 "2명이요"라고 한 정보가 요약에서 빠지면서 1인 요금으로 예약이 진행되었다.
교훈: 태스크 지향 대화에서는 반드시 슬롯 기반 구조화 요약을 사용해야 한다. 출발지, 도착지, 날짜, 인원, 좌석 등급 같은 필수 슬롯을 명시하고, 요약에 해당 값이 포함되어 있는지 검증 로직을 추가했다.
사례 3: 세션 격리 실패
멀티테넌트 SaaS 챗봇에서 세션 키를 단순히 user_id로만 구성했다. 동일 사용자가 여러 브라우저 탭에서 서로 다른 주제로 대화를 시도하자, 두 대화의 상태가 섞이면서 엉뚱한 응답이 발생했다.
교훈: 세션 키는 user_id + session_id의 복합 키로 구성해야 한다. 각 브라우저 탭에 고유한 session_id를 발급하고, 사용자 대시보드에서 활성 세션 목록을 관리할 수 있도록 했다.
참고자료
- LangGraph Memory 공식 문서 - Memory overview
- LlamaIndex Chat Engine Context Mode
- LLMLingua - Prompt Compression for Accelerated Inference
- Microsoft LLMLingua GitHub - LLMLingua-2
- Redis for GenAI Apps - Session Management
- Context Window Management Strategies for Long-Context AI Agents and Chatbots
- LangGraph Checkpointing Best Practices 2025
- Prompt Compression Survey - NAACL 2025