Skip to content

Split View: 멀티턴 챗봇 대화 상태 관리와 컨텍스트 압축 전략 2026

✨ Learn with Quiz
|

멀티턴 챗봇 대화 상태 관리와 컨텍스트 압축 전략 2026

멀티턴 챗봇 대화 상태 관리와 컨텍스트 압축 전략 2026

개요

멀티턴 챗봇은 단일 질의-응답이 아니라 여러 턴에 걸친 연속적인 대화를 처리한다. 사용자가 "이전에 말한 것"을 참조하거나 문맥에 의존하는 후속 질문을 던질 때, 챗봇은 이전 대화 내용을 정확히 기억하고 적절한 맥락을 유지해야 한다. 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 MemoryLLM으로 요약 생성낮음 (요약 길이)낮음 (세부사항 손실)장시간 대화, 비용 절감
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%까지 허용할 수 있다.

트러블슈팅

컨텍스트 유실로 인한 반복 질문

증상: 챗봇이 이미 수집한 정보를 다시 묻는다.

원인 진단 순서:

  1. 슬라이딩 윈도우 크기가 너무 작은지 확인한다. 윈도우 밖으로 밀려난 메시지에 핵심 정보가 있는 경우다.
  2. 요약이 제대로 동작하는지 확인한다. 요약 프롬프트가 핵심 slot 정보(이름, 주문번호 등)를 누락하고 있을 수 있다.
  3. 상태 관리 로직에서 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를 발급하고, 사용자 대시보드에서 활성 세션 목록을 관리할 수 있도록 했다.

참고자료

Multi-Turn Chatbot Conversation State Management and Context Compression Strategies 2026

Multi-Turn Chatbot Conversation State Management and Context Compression Strategies 2026

Overview

A multi-turn chatbot processes continuous conversations spanning multiple turns, not just single question-answer pairs. When a user references "what was said earlier" or asks follow-up questions that depend on context, the chatbot must accurately remember prior conversation content and maintain appropriate context. As of 2026, Claude 4 Sonnet offers a 200K token context window and GPT-5 offers 400K tokens, but a longer context window is not always better.

As the context window grows, attention computation increases at O(n^2) and costs rise proportionally. The more serious problem is "context rot" -- research has confirmed that model accuracy and recall degrade as input length increases. Therefore, rather than unconditionally stuffing in the entire conversation history, strategies for selecting and compressing key information are essential.

This article covers memory architectures for managing multi-turn chatbot conversation state, compression techniques for efficiently utilizing the context window, LangGraph state machine implementation, Redis-based session persistence, and token budget management -- all with production-ready strategies.

Challenges of Multi-Turn Conversations

Token Limits and Cost Issues

The first wall you hit in multi-turn conversations is the token limit. Taking a customer support chatbot as an example, it is common for sessions to exceed 50 turns. At an average of 200 tokens per turn, 10,000 tokens are consumed by conversation history alone at the 50-turn mark. Add system prompts, RAG documents, and function call results, and the token budget shrinks rapidly.

Loss of Critical Information

If you use a sliding window that keeps only the most recent N messages, core requirements set by the user early in the conversation disappear. If the user said "my budget is under 5 million won" 10 turns ago and that message slides out of the window, the chatbot ends up making irrelevant recommendations.

Complexity of State Management

Beyond simple Q&A, task-oriented conversations like bookings, orders, and troubleshooting require managing structured state such as the current step, collected information (slots), and confirmation status. This state must be tracked separately from the conversation history and must be reset or branched under specific conditions.

Concurrent Session Isolation

In production environments, hundreds to thousands of users converse simultaneously. Each user's conversation state must be isolated to prevent cross-contamination, and if a user closes their browser and reopens it, the previous state must be restored.

Memory Architecture Types

LangChain and LlamaIndex provide various memory types for multi-turn conversations. Understanding the pros and cons of each and selecting the right combination for your situation is critical.

Memory Type Comparison

Memory TypeStorage MethodToken UsageInformation FidelitySuitable Scenarios
Buffer MemoryStores entire conversation historyHigh (linear growth)Very highShort conversations, debugging
Window MemoryKeeps only the most recent K messagesFixed (window size)Medium (early info lost)General customer support
Summary MemoryGenerates summary via LLMLow (summary length)Low (detail loss)Long conversations, cost savings
Summary BufferHybrid of summary + recent bufferMediumHighMost production use cases
Vector MemoryRetrieves relevant conversations via embeddingsVariable (search results)High (relevance-based)Long-term memory, cross-session

ConversationBufferMemory vs ConversationSummaryBufferMemory

Buffer Memory is the simplest approach. It stores all messages as-is and feeds them into the prompt. It is easy to debug and has no information loss, but the token limit is quickly reached as conversations grow longer.

Summary Buffer Memory is the most commonly used approach in practice. It keeps recent messages in their original form while compressing older messages into summaries via LLM calls. Summarization is automatically triggered when the token count exceeds a configured threshold.

from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o", temperature=0)

# Automatically summarizes old messages when max_token_limit is exceeded
memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=2000,
    return_messages=True,
    memory_key="chat_history",
    human_prefix="Customer",
    ai_prefix="Agent",
)

# Save conversations
memory.save_context(
    {"input": "I'm considering buying a laptop and my budget is 1.5 million won"},
    {"output": "I'll recommend a great laptop within your 1.5 million won budget. What will be the primary use?"},
)
memory.save_context(
    {"input": "I'll be doing programming and light video editing"},
    {"output": "For development and video editing, I recommend specs with at least 16GB RAM and 512GB SSD."},
)
memory.save_context(
    {"input": "Which is better, the MacBook Air M4 or Lenovo ThinkPad?"},
    {"output": "Both are excellent products, but there are differences depending on use case. The MacBook Air M4 is ..."},
)

# Automatic summary + recent messages retained when token limit is exceeded
loaded = memory.load_memory_variables({})
print(loaded["chat_history"])
# SystemMessage: "The customer is looking for a laptop for programming and video editing with a 1.5M won budget..."
# + recent original messages

LlamaIndex ChatSummaryMemoryBuffer

LlamaIndex provides a similar mechanism. ChatSummaryMemoryBuffer periodically summarizes older messages when the configured token limit is exceeded, while keeping recent messages in their original form.

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,
    # Summary trigger token ratio (triggers when exceeding 70% of total limit)
    summarize_threshold=0.7,
)

# Connect memory to 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="You are a technical support specialist.",
)

response = chat_engine.chat("Tell me more about the error code I mentioned earlier")

Context Window Management

Sliding Window Strategy

The sliding window is the most intuitive context management method. It keeps only the most recent K messages and discards the rest. It is simple to implement and token usage is predictable, but the downside is that early conversation information is completely lost.

An improved sliding window determines window size based on token count rather than simply message count. Twenty short messages and five long messages should not be treated the same.

Token-Based Window Implementation

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]:
    """Token count-based sliding window.
    Always keeps system messages, fills from recent messages in reverse order.
    """
    enc = tiktoken.encoding_for_model(model)
    result = []
    current_tokens = 0

    # Reserve system messages first
    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

    # Add from most recent messages in reverse order
    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

Context Window Management Method Comparison

Management MethodImplementation ComplexityToken EfficiencyInformation PreservationLatency
Message count-based slidingVery lowMediumLowNone
Token count-based slidingLowHighLowVery low
Summary + slidingMediumHighHighMedium (LLM call)
Vector search-basedHighVery highHighMedium (embedding + search)
Hybrid (summary + vector)Very highVery highVery highHigh

Conversation Summarization Strategies

Conversation summarization is a core technique for saving context window space while preserving key information. Simply saying "summarize the conversation" can cause important details to be omitted, so structured summarization prompts should be used.

Progressive Summarization

Rather than summarizing the entire conversation at once, this approach merges new content into the existing summary at regular turn intervals. This approach yields high summary quality at low cost.

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", """You are a customer support conversation summarization expert.
Merge the existing summary with the new conversation to produce an updated summary.

Rules:
1. Always preserve the customer's core requirements, constraints, and preferences
2. Never omit confirmed facts (names, order numbers, dates, etc.)
3. Specify the current progress stage and next required action
4. Record resolved issues briefly, unresolved issues in detail
5. Keep within 200 characters"""),
    ("human", """Existing summary:
{existing_summary}

New conversation:
{new_messages}

Updated summary:"""),
])


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"Customer: {user_msg}")
        self.buffer.append(f"Agent: {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 "(none)",
            "new_messages": new_messages,
        })
        self.summary = result.content
        self.buffer = []  # Clear buffer

    def get_context(self) -> str:
        """Return context combining summary + recent buffer"""
        parts = []
        if self.summary:
            parts.append(f"[Conversation Summary]\n{self.summary}")
        if self.buffer:
            parts.append(f"[Recent Conversation]\n" + "\n".join(self.buffer))
        return "\n\n".join(parts)

Structured vs Free-Form Summarization

Summarization MethodProsConsRecommended Scenarios
Free-form summarizationSimple implementation, flexibleMay miss critical infoGeneral chat bots
Slot-based structuredGuarantees required infoRequires prompt designBooking/ordering chatbots
Key-value extractionSearchable/filterableMay lose contextData collection purposes
Progressive mergeCost-efficient, high qualityPossible cumulative errorsLong support sessions

Context Compression Techniques

Prompt Compression with LLMLingua

Microsoft's LLMLingua series can compress prompts up to 20x while minimizing performance degradation. It removes unimportant tokens based on the perplexity of a small language model. LLMLingua-2 is trained on GPT-4 distillation data, enabling domain-agnostic general-purpose compression that is 3-6x faster than the original LLMLingua.

from llmlingua import PromptCompressor

# Initialize LLMLingua-2
compressor = PromptCompressor(
    model_name="microsoft/llmlingua-2-xlm-roberta-large-meetingbank",
    use_llmlingua2=True,
    device_map="cpu",  # Use "cuda" for GPU
)

# Compress long conversation history
conversation_history = """
Customer: Hello, I'd like to inquire about an order I placed last week.
Agent: Hello! Please provide your order number and I'll check for you.
Customer: The order number is ORD-2026-03-1234. My delivery hasn't arrived yet.
Agent: Let me check on that. Please wait a moment.
Agent: I've confirmed order number ORD-2026-03-1234. It's currently in transit and scheduled to arrive tomorrow.
Customer: Tomorrow? It was supposed to arrive yesterday. Why is it delayed?
Agent: It was delayed by one day due to logistics center circumstances. We apologize for the inconvenience.
Customer: Can I get a shipping fee refund?
Agent: Yes, a shipping fee refund is available due to the delivery delay. Shall I proceed with the refund?
Customer: Yes, please.
Agent: The shipping fee refund of 3,000 won has been processed. It will be refunded to the original payment method within 1-3 days.
"""

compressed = compressor.compress_prompt(
    conversation_history,
    rate=0.5,  # 50% compression rate
    force_tokens=["order number", "ORD-2026-03-1234", "refund"],  # Tokens to always keep
)

print(f"Original tokens: {compressed['origin_tokens']}")
print(f"Compressed tokens: {compressed['compressed_tokens']}")
print(f"Compression ratio: {compressed['ratio']:.1f}x")
print(f"Compressed result:\n{compressed['compressed_prompt']}")

Compression Technique Comparison

Compression TechniqueRatioPerformance RetentionSpeedTraining Required
LLMLinguaUp to 20xHighMediumNo (inference only)
LLMLingua-2Up to 20xVery highFast (3-6x)No
LongLLMLinguaUp to 4xVery highMediumNo
LLM summarizationVariableMediumSlow (LLM call)No
Rule-based filtering2-3xLowVery fastNo
Selective ContextUp to 10xHighFastNo

LangGraph State Machine Implementation

LangGraph can model conversation flows as graph-based state machines. Unlike LangChain's traditional memory approach, it uses explicit state schemas and reducer functions to reliably manage complex multi-turn workflows. State is automatically persisted through checkpointers, making session restoration seamless.

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. State schema definition
class OrderSupportState(TypedDict):
    messages: Annotated[list, add_messages]  # Accumulate messages with reducer
    order_id: str | None
    issue_type: str | None  # "shipping", "refund", "exchange", "other"
    step: str  # "greeting", "identify", "diagnose", "resolve", "close"
    collected_info: dict
    summary: str  # Previous conversation summary


# 2. Node function definitions
llm = ChatOpenAI(model="gpt-4o", temperature=0)


def greeting_node(state: OrderSupportState) -> dict:
    """Greeting and initial classification"""
    response = llm.invoke([
        SystemMessage(content="You are a customer support chatbot. Identify the customer's inquiry type."),
        *state["messages"],
    ])
    return {
        "messages": [response],
        "step": "identify",
    }


def identify_node(state: OrderSupportState) -> dict:
    """Identify order number and issue type"""
    context_parts = []
    if state.get("summary"):
        context_parts.append(f"Previous conversation summary: {state['summary']}")

    system_msg = f"""Identify the customer's order number and problem type.
Collected info: {state.get('collected_info', dict())}
{chr(10).join(context_parts)}"""

    response = llm.invoke([
        SystemMessage(content=system_msg),
        *state["messages"][-10:],  # Use only the last 10 messages
    ])

    # Extract order number from response (more sophisticated parsing needed in practice)
    return {
        "messages": [response],
        "step": "diagnose",
    }


def resolve_node(state: OrderSupportState) -> dict:
    """Propose problem resolution"""
    response = llm.invoke([
        SystemMessage(content=f"Issue type: {state.get('issue_type', 'unidentified')}. "
                              f"Order number: {state.get('order_id', 'unidentified')}. Propose a solution."),
        *state["messages"][-6:],
    ])
    return {
        "messages": [response],
        "step": "close",
    }


# 3. Routing function
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 construction
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. Persist state with checkpointer
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)

# 6. Execute (sessions distinguished by thread_id)
config = {"configurable": {"thread_id": "user-session-abc123"}}
result = app.invoke(
    {
        "messages": [HumanMessage(content="My ordered item hasn't arrived yet")],
        "step": "greeting",
        "collected_info": {},
        "summary": "",
    },
    config=config,
)

LangGraph's checkpointer automatically saves state after each node execution. MemorySaver is in-memory storage suitable for development/testing; in production, you should use SqliteSaver, PostgresSaver, or MongoDB Store. Sessions are distinguished by thread_id, allowing multiple users' conversations to be isolated simultaneously.

Session Management and Persistence

Redis-Based Session Store

When persisting conversation state in production, Redis is the most common choice. It supports low-latency reads/writes, TTL-based automatic expiration, and real-time notifications via Pub/Sub.

import json
import time
import redis
import tiktoken


class ChatSessionManager:
    """Redis-based multi-turn conversation session manager"""

    def __init__(
        self,
        redis_url: str = "redis://localhost:6379",
        session_ttl: int = 3600,  # 1 hour
        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:
        """Create a new session"""
        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:
        """Add a message and manage tokens"""
        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)

        # Update metadata
        self.redis.hincrby(self._key(session_id, "meta"), "turn_count", 1)
        self.redis.hset(
            self._key(session_id, "meta"), "updated_at", str(time.time())
        )
        # Renew TTL
        self.redis.expire(self._key(session_id, "meta"), self.session_ttl)

    def get_context_messages(self, session_id: str) -> list[dict]:
        """Return context messages within the token budget"""
        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

        # Add summary first if available
        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"Previous conversation summary: {summary}"})

        # Add recent messages in reverse order within token budget
        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:
        """Update conversation summary"""
        self.redis.hset(self._key(session_id, "meta"), "summary", summary)

    def delete_session(self, session_id: str) -> None:
        """Delete session"""
        for suffix in ("meta", "history"):
            self.redis.delete(self._key(session_id, suffix))

Session Store Comparison

StoreLatencyPersistenceScalabilityTTL SupportSuitable Scenarios
In-memory (dict)NanosecondsNoneSingle processManual implDevelopment/testing
RedisMillisecondsConditional (AOF/RDB)Cluster supportBuilt-inProduction real-time
PostgreSQLFew msFullHighTrigger implAudit logging required
MongoDBFew msFullSharding supportTTL indexUnstructured state
DynamoDBFew msFullUnlimitedTTL built-inAWS-based services

In production, the common hybrid pattern is to use Redis as the main session store while asynchronously flushing to PostgreSQL or MongoDB. Reading current conversation state quickly from Redis and saving the complete history to a relational DB at conversation end provides both performance and persistence.

Token Budget Management

In production chatbots, token budget management is central to cost control and response quality. A strategy is needed to divide the model's context window into allocations for system prompts, conversation history, RAG documents, and response reservation.

import tiktoken
from dataclasses import dataclass


@dataclass
class TokenBudget:
    """Token budget allocation calculator"""
    model: str = "gpt-4o"
    max_context: int = 128000  # gpt-4o context window
    system_prompt_tokens: int = 500
    response_reserve: int = 4000  # Reserved for response
    rag_budget: int = 3000  # For RAG documents
    tool_result_budget: int = 2000  # For tool execution results

    def __post_init__(self):
        self.encoder = tiktoken.encoding_for_model(self.model)

    @property
    def conversation_budget(self) -> int:
        """Number of tokens available for conversation history"""
        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:
        """Token usage status report for current messages"""
        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,
        }


# Usage example
budget = TokenBudget(model="gpt-4o", max_context=128000)
print(f"Available tokens for conversation history: {budget.conversation_budget:,}")

report = budget.allocate([
    {"role": "user", "content": "Please check my previous order status"},
    {"role": "assistant", "content": "Could you provide your order number?"},
])
print(f"Utilization: {report['utilization_pct']}%")
print(f"Compression needed: {report['needs_compression']}")

Triggering compression automatically when 80% of the token budget is exceeded is a good practice. This threshold should be adjusted based on service characteristics. For customer support where accuracy is critical, lower it to 70%; for casual conversations, 90% is acceptable.

Troubleshooting

Repeated Questions Due to Context Loss

Symptom: The chatbot asks again for information it has already collected.

Diagnostic order:

  1. Check if the sliding window size is too small. The key information may be in messages that have slid out of the window.
  2. Check if summarization is working properly. The summarization prompt may be omitting critical slot information (names, order numbers, etc.).
  3. Check if collected_info is being properly updated in the state management logic.

Solution: Specify a "fields that must be preserved" list in the structured summarization prompt. Manage slot information in a separate state dictionary.

Conversation Disconnection Due to Redis Session Expiration

Symptom: When a user leaves briefly and returns, the chatbot starts from the beginning.

Root cause: TTL is set too short.

Solution: Renew the TTL every time a message is added, and set TTL appropriate to the business requirements. 2 hours for customer support and 24 hours for shopping assistants are typical. Sending a warning message before expiration is also recommended.

Summary Drift (Cumulative Summary Errors)

Symptom: Facts become distorted or hallucinations are included as progressive summarization is repeated.

Root cause: Information loss and distortion accumulate as summaries of summaries are repeated.

Solution: Regenerate summaries from original messages every 5-10 summarization cycles. Extract factual information like numbers, dates, and proper nouns separately from the summary and store them in state.

State Conflicts from Concurrent Requests

Symptom: When a user sends messages in rapid succession, responses are mixed up or state breaks.

Root cause: Two concurrently executing requests read and write the same session state, causing race conditions.

Solution: Use Redis WATCH/MULTI/EXEC transactions or distributed locks. With LangGraph, the checkpointer guarantees sequential execution, naturally resolving this issue.

Operational Checklist

Items to verify before deploying a production multi-turn chatbot.

Memory and Context Management:

  • Memory type selection completed (Buffer, Summary Buffer, Vector, etc.)
  • Token budget allocation defined (system prompt, conversation history, RAG, response reservation)
  • Context compression threshold set (trigger at 80% or above)
  • Required preservation fields specified in summarization prompt

Session Management:

  • Redis or persistent store connection confirmed
  • Session TTL configured (appropriate for service type)
  • Concurrent request handling strategy established (locks, queues, sequential execution)
  • User notification logic implemented for session expiration

Monitoring:

  • Average tokens per turn tracked
  • Summarization call frequency and cost monitored
  • Repeated question rate due to context loss measured
  • Average session duration and turn count tracked

Incident Response:

  • In-memory fallback logic for Redis failures
  • Original message retention strategy for summarization LLM call failures
  • State recovery procedures documented

Failure Cases

Case 1: Infinite Context Expansion

In a customer support chatbot project, Buffer Memory was used with no context management under the policy "never allow information loss." Initially there were no issues, but as the average turn count exceeded 30, API costs surged from 5 million won to 20 million won per month. Response latency also increased from an average of 2 seconds to 8 seconds.

Lesson: Keeping all messages is not always optimal. After switching to Summary Buffer Memory and setting a token budget of 4,000, costs were reduced by 70% with no significant change in customer satisfaction.

Case 2: The Pitfall of Free-Form Summarization

In a travel booking chatbot using free-form summarization, incidents repeatedly occurred where departure and arrival dates were swapped or the number of travelers was omitted during summarization. The information "2 people" from the customer was dropped from the summary, leading to bookings processed at single-person rates.

Lesson: Slot-based structured summarization must be used for task-oriented conversations. Required slots like departure, destination, dates, number of travelers, and seat class were specified, and validation logic was added to verify these values are present in the summary.

Case 3: Session Isolation Failure

In a multi-tenant SaaS chatbot, the session key was constructed using only user_id. When the same user attempted conversations on different topics in multiple browser tabs, the two conversations' states mixed, producing irrelevant responses.

Lesson: Session keys should be composite keys of user_id + session_id. A unique session_id was issued for each browser tab, and the user dashboard was updated to manage the list of active sessions.

References

Quiz

Q1: What is the main topic covered in "Multi-Turn Chatbot Conversation State Management and Context Compression Strategies 2026"?

Multi-turn chatbot conversation state management and context compression strategies. Covers session management, memory architecture, conversation summarization, sliding windows, token budget management, and LangGraph state machines with production-ready implementations.

Q2: What is Challenges of Multi-Turn Conversations? Token Limits and Cost Issues The first wall you hit in multi-turn conversations is the token limit. Taking a customer support chatbot as an example, it is common for sessions to exceed 50 turns.

Q3: Describe the Memory Architecture Types. LangChain and LlamaIndex provide various memory types for multi-turn conversations. Understanding the pros and cons of each and selecting the right combination for your situation is critical.

Q4: What are the key aspects of Context Window Management? Sliding Window Strategy The sliding window is the most intuitive context management method. It keeps only the most recent K messages and discards the rest.

Q5: How does Conversation Summarization Strategies work? Conversation summarization is a core technique for saving context window space while preserving key information. Simply saying "summarize the conversation" can cause important details to be omitted, so structured summarization prompts should be used.