Skip to content
Published on

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

Authors
  • Name
    Twitter
멀티턴 챗봇 대화 상태 관리와 컨텍스트 압축 전략 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를 발급하고, 사용자 대시보드에서 활성 세션 목록을 관리할 수 있도록 했다.

참고자료