LLM 컨텍스트 윈도우 관리: 긴 문서와 대화 기록 처리 전략

AI 기술

컨텍스트 윈도우LLM 메모리토큰 관리대화 기록프롬프트 최적화

이 글은 누구를 위한 것인가

  • 긴 대화가 이어지면서 초반 내용을 LLM이 잊어버리는 문제를 겪는 팀
  • 100페이지 PDF를 LLM에 넣으려는 개발자
  • 컨텍스트 한계로 인한 에러를 처리해야 하는 엔지니어

들어가며

Claude의 컨텍스트 윈도우는 200K 토큰이지만, 비용은 토큰 수에 비례한다. 100K 토큰을 매 요청마다 보내면 하루 API 비용이 수백만 원이 된다. 스마트한 컨텍스트 관리가 필요하다.

이 글은 bluefoxdev.kr의 LLM 컨텍스트 최적화 를 참고하여 작성했습니다.


1. 컨텍스트 관리 전략

[컨텍스트 관리 방법 비교]

슬라이딩 윈도우:
  최근 N개 메시지만 유지
  장점: 단순, 최신 컨텍스트 보장
  단점: 초반 중요 정보 손실
  적합: 짧은 태스크형 대화

요약 압축:
  오래된 메시지를 LLM으로 요약
  장점: 핵심 정보 보존
  단점: 요약 비용, 세부 정보 손실
  적합: 장기 상담, 프로젝트 대화

중요도 기반 선택:
  중요 메시지 태그 → 우선 보존
  장점: 유연한 정보 선택
  단점: 중요도 판단 로직 필요

RAG 하이브리드:
  대화 기록 → 벡터 DB 저장
  현재 질문과 관련된 과거 메시지 검색
  장점: 무제한 기억, 관련 정보만 로드
  단점: 구현 복잡도

[토큰 추정 (대략)]
  한국어: 1글자 ≈ 1-2 토큰
  영어: 1단어 ≈ 1.3 토큰
  코드: 1줄 ≈ 5-10 토큰
  Claude 200K ≈ 약 15만 한국어 글자

2. 대화 기록 관리 구현

import anthropic
from dataclasses import dataclass, field

client = anthropic.Anthropic()

@dataclass
class Message:
    role: str
    content: str
    token_count: int = 0
    importance: float = 1.0  # 0.0 (낮음) ~ 1.0 (높음)
    is_summary: bool = False

class ConversationManager:
    """컨텍스트 윈도우 관리"""
    
    def __init__(
        self,
        max_tokens: int = 100_000,
        summary_trigger_ratio: float = 0.8,
    ):
        self.max_tokens = max_tokens
        self.summary_trigger = int(max_tokens * summary_trigger_ratio)
        self.messages: list[Message] = []
        self.system_tokens = 0
    
    def count_tokens(self, text: str) -> int:
        return len(text) // 2  # 간단 추정 (실제: tiktoken 또는 API 호출)
    
    def total_tokens(self) -> int:
        return sum(m.token_count for m in self.messages) + self.system_tokens
    
    def add_message(self, role: str, content: str, importance: float = 1.0):
        msg = Message(
            role=role,
            content=content,
            token_count=self.count_tokens(content),
            importance=importance,
        )
        self.messages.append(msg)
        
        if self.total_tokens() > self.summary_trigger:
            self._compress()
    
    def _compress(self):
        """오래된 메시지 압축"""
        if len(self.messages) < 4:
            return
        
        # 압축할 메시지: 앞 절반 중 낮은 중요도 메시지
        cutoff = len(self.messages) // 2
        to_compress = self.messages[:cutoff]
        
        summary_text = self._summarize_messages(to_compress)
        summary_msg = Message(
            role="assistant",
            content=f"[이전 대화 요약]: {summary_text}",
            token_count=self.count_tokens(summary_text),
            importance=1.0,
            is_summary=True,
        )
        
        self.messages = [summary_msg] + self.messages[cutoff:]
    
    def _summarize_messages(self, messages: list[Message]) -> str:
        """LLM으로 메시지 요약"""
        history = "\n".join(
            f"{m.role}: {m.content[:200]}" for m in messages
        )
        
        response = client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=500,
            messages=[{
                "role": "user",
                "content": f"""다음 대화를 3-5문장으로 요약하세요. 중요한 결정, 정보, 컨텍스트를 보존하세요.

{history}"""
            }]
        )
        return response.content[0].text
    
    def get_messages_for_api(self) -> list[dict]:
        """API 전송용 메시지 리스트"""
        return [{"role": m.role, "content": m.content} for m in self.messages]
    
    def sliding_window(self, n_recent: int = 20) -> list[dict]:
        """슬라이딩 윈도우: 요약 + 최근 N개"""
        summaries = [m for m in self.messages if m.is_summary]
        recent = [m for m in self.messages if not m.is_summary][-n_recent:]
        
        return [{"role": m.role, "content": m.content} for m in summaries + recent]

def chunk_document(text: str, chunk_size: int = 2000, overlap: int = 200) -> list[str]:
    """긴 문서를 오버랩 청크로 분할"""
    chunks = []
    start = 0
    
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        
        # 문장 경계에서 자르기
        if end < len(text):
            last_period = chunk.rfind(".")
            if last_period > chunk_size * 0.7:
                chunk = chunk[:last_period + 1]
                end = start + last_period + 1
        
        chunks.append(chunk)
        start = end - overlap
    
    return chunks

async def process_long_document(
    document: str,
    question: str,
    max_chunks: int = 5,
) -> str:
    """긴 문서 처리: 관련 청크만 선택하여 질문 답변"""
    
    chunks = chunk_document(document)
    
    # 관련도 스코어링 (간단한 키워드 매칭)
    question_words = set(question.lower().split())
    scored_chunks = []
    
    for i, chunk in enumerate(chunks):
        chunk_words = set(chunk.lower().split())
        overlap = len(question_words & chunk_words)
        scored_chunks.append((overlap, i, chunk))
    
    # 상위 K개 청크 선택
    top_chunks = sorted(scored_chunks, reverse=True)[:max_chunks]
    top_chunks = sorted(top_chunks, key=lambda x: x[1])  # 원래 순서 복원
    
    context = "\n\n---\n\n".join(chunk for _, _, chunk in top_chunks)
    
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1000,
        messages=[{
            "role": "user",
            "content": f"""다음 문서 내용을 바탕으로 질문에 답하세요.

문서:
{context}

질문: {question}"""
        }]
    )
    
    return response.content[0].text

마무리

컨텍스트 관리의 핵심은 "무엇을 버릴 것인가"다. 상담 봇은 최근 10턴 + 요약으로 충분하다. 문서 Q&A는 전체를 보내지 말고 관련 청크만 선택하라. 프롬프트 캐싱으로 시스템 프롬프트 토큰 비용을 90% 줄이면 남은 예산을 사용자 컨텍스트에 쓸 수 있다.