컨텍스트 오염이란 무엇인가

LLM 애플리케이션에서 컨텍스트 오염(context pollution)은 대화가 길어질수록 불필요하거나 모순된 정보가 컨텍스트 윈도우에 누적되어 응답 품질이 떨어지는 현상이다. 초기의 잘못된 가정, 폐기된 시도, 도구 호출의 대량 출력이 계속 남아 모델의 주의(attention)를 분산시킨다. 특히 에이전트형 워크플로우에서 도구 결과가 수만 토큰씩 쌓이면 비용이 선형으로 증가하고, 모델은 오래된 지시를 최신 지시로 착각하기 시작한다.

왜 문제가 되는가

첫째, 비용과 지연이다. 매 턴마다 전체 히스토리를 재전송하므로 입력 토큰이 누적되고, 대화 후반에는 한 번의 요청이 초반의 수십 배에 달한다. 둘째, 정확도 저하다. 무관한 맥락이 많을수록 모델이 핵심 지시를 놓치는 "lost in the middle" 현상이 심해진다. 셋째, 상태 모순이다. 사용자가 "역시 A 말고 B로 하자"고 해도 폐기된 A 관련 논의가 그대로 남아 모델이 오락가락한다.

리셋 전략의 네 가지 축

전략동작적합한 상황위험
하드 리셋히스토리 전체 폐기, 시스템 프롬프트만 유지주제 전환, 실패 루프 탈출맥락 손실
요약 압축이전 턴을 요약본으로 치환장기 대화 유지세부 정보 유실
슬라이딩 윈도우최근 N턴만 유지단기 문맥 중심 태스크초반 지시 소실
선택적 프루닝도구 대량 출력만 제거에이전트 실행구현 복잡

임계값 기반 자동 압축

실무에서는 토큰 예산을 정해두고 임계값을 넘으면 오래된 턴을 요약으로 치환하는 방식이 가장 안정적이다. 시스템 메시지와 최근 몇 턴은 원문 그대로 두고, 중간 구간만 압축한다.

import anthropic

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 60_000  # 압축 트리거 임계값

def count_tokens(system, messages):
    r = client.messages.count_tokens(
        model=MODEL, system=system, messages=messages)
    return r.input_tokens

def compact(system, messages, keep_recent=4):
    head, tail = messages[:-keep_recent], messages[-keep_recent:]
    summary = client.messages.create(
        model=MODEL, max_tokens=1024,
        system="이전 대화를 사실 위주로 요약하라. 폐기된 시도는 제외.",
        messages=head + [{"role": "user", "content": "위 대화를 요약하라."}],
    ).content[0].text
    return [{"role": "user", "content": f"[이전 요약]\n{summary}"}] + tail

def ensure_budget(system, messages):
    if count_tokens(system, messages) > MAX_TOKENS:
        return compact(system, messages)
    return messages

도구 출력 프루닝

에이전트에서 가장 흔한 오염원은 도구 호출 결과다. 한 번 참조하고 나면 필요 없는 대량 출력은 요약 스텁으로 교체해 토큰을 회수한다. 최근 결과는 원본을 유지해 직전 판단 근거를 보존한다.

def prune_tool_results(messages, keep_last=2):
    tool_msgs = [i for i, m in enumerate(messages)
                 if m.get("role") == "tool"]
    stale = set(tool_msgs[:-keep_last]) if len(tool_msgs) > keep_last else set()
    pruned = []
    for i, m in enumerate(messages):
        if i in stale:
            body = str(m["content"])
            stub = body[:200] + f"... [프루닝됨, 원본 {len(body)}자]"
            pruned.append({**m, "content": stub})
        else:
            pruned.append(m)
    return pruned

세션 관리와 프롬프트 캐싱

리셋 전략은 캐싱과 충돌할 수 있으니 순서에 주의해야 한다. 프롬프트 캐시는 접두사가 동일해야 적중하므로, 앞부분(시스템 프롬프트·초기 문맥)은 고정하고 변경은 뒤쪽에서만 발생하도록 설계한다. 히스토리 중간을 압축하면 접두사가 깨져 캐시가 무효화되니, 압축은 캐시 경계를 넘지 않는 지점에서 일괄 수행한다.

system = [
    {"type": "text", "text": SYSTEM_PROMPT,
     "cache_control": {"type": "ephemeral"}},
]
# 압축은 '주기적으로 한 번' 실행해 캐시 무효화 빈도를 낮춘다
messages = ensure_budget(system, messages)
resp = client.messages.create(
    model=MODEL, max_tokens=2048, system=system, messages=messages)

운영 시 주의점

  • 요약 자체가 오염원이 될 수 있다. 요약 프롬프트에 "폐기된 시도와 실패 경로는 제외"를 명시해 잘못된 맥락이 영구 고착되는 것을 막는다.
  • 하드 리셋은 사용자에게 티가 난다. UX상 "새 주제로 전환합니다" 같은 신호를 주거나, 핵심 결정 사항만 별도 상태로 보존한다.
  • 압축 임계값은 모델 컨텍스트 한계가 아니라 품질이 꺾이는 지점(대개 한계의 절반 이하)을 기준으로 잡는다.
  • 요약·프루닝 전후 토큰 수와 응답 품질을 로깅해 회귀를 추적한다. 비용 절감에만 최적화하면 정확도가 조용히 무너진다.