Joyuno's Dev Blog

컨텍스트 1M 시대에도 “기억”은 부족하다: 2026년 8월 LLM Long Context Compaction 설계 가이드 (Lost-in-the-Middle 대응 포함)

AI LLM

들어가며

긴 컨텍스트(window 200K~1M)가 보편화되면서 “그냥 다 넣으면 되겠지”라는 유혹이 커졌습니다. 하지만 실제 프로덕션에서는 (1) 토큰 비용/TTFT 폭증, (2) 대화·에이전트가 길어질수록 누적되는 노이즈, (3) 컨텍스트 안에 있어도 중간 정보가 씹히는 Lost-in-the-Middle(LITM) 때문에 “긴 컨텍스트 = 안정적 기억”이 성립하지 않습니다. LITM은 관련 정보가 컨텍스트 앞/뒤에 있을 때 성능이 높고, 가운데에 있을 때 크게 떨어지는 U-curve로 관측됩니다. (arxiv.org)

그래서 2026년의 실무 해법은 “long context를 최대한 쓰되, 그대로 쌓지 말고 compaction/summary로 SNR(signal-to-noise ratio)을 관리”하는 쪽으로 수렴 중입니다. OpenAI는 Responses API에 native compaction을 넣어 “개발자가 직접 요약/상태 관리 시스템을 설계하지 않도록” 한다고 밝히고 있고, (openai.com) Anthropic도 Messages API에서 compaction 전략을 베타로 제공하며 요청 헤더/전략 설정으로 활성화하게 했습니다. (platform.claude.com) 또한 연구 쪽에서는 “컨텍스트 길이 자체가(심지어 perfect retrieval이어도) 성능을 떨어뜨린다”는 결과가 나오면서, (arxiv.org) 단순히 retrieval을 잘하는 것만으로는 부족하다는 경고도 명확해졌습니다.

언제 쓰면 좋은가

  • 장시간 에이전트(코딩/운영/리서치)처럼 “대화 히스토리가 업무 상태(state)”가 되는 경우
  • 긴 문서/로그/PRD/Incident timeline을 반복적으로 참조해야 하는 경우
  • RAG를 쓰더라도 “회차가 누적되며 컨텍스트가 비대해지는” 워크플로우

언제 쓰면 안 좋은가

  • 법/의료/금융처럼 요약 손실이 곧 리스크인 고정밀 도메인(요약은 항상 lossy)
  • “요약이 곧 증거”가 되는 감사/규정 준수 기록(원문을 별도로 저장하고, 요약은 인덱스/가이드로만)
  • 짧은 단발 작업(컨텍스트가 차지 않는데 compaction부터 걸면 오히려 품질 저하)

🔧 핵심 개념

1) Compaction의 정의: “토큰을 줄이는 것”이 아니라 “업무 상태를 재부호화하는 것”

실무에서 compaction은 보통 아래 중 하나(혹은 조합)입니다.

  • Conversation summarization compaction: 오래된 turns를 요약으로 치환(서버/클라이언트 어디서든 가능)
  • Structured state compaction: “요약 문장” 대신 스키마화된 상태(결정/제약/TODO/인터페이스/키값)로 재구성
  • External memory offload + retrieval: 원문은 저장소로 보내고, 컨텍스트에는 “인덱스/요약+핵심 포인터”만 유지

문제는 요약이 누적될수록 정보가 증발하는 경향이 있다는 점입니다. 최근 일부 이론/분석 글들은 이걸 rate-distortion 관점(제한된 채널에 무엇을 보존할 것인가)으로 모델링하면서 “summarize-a-summary는 관련 정보가 기하급수적으로 줄어들 수 있다”는 논지를 강화합니다. (tmls.nyc)

2) Lost-in-the-Middle(LITM)와 compaction의 관계

LITM은 “컨텍스트가 길면 모델이 가운데 정보를 못 본다” 수준이 아니라, 길어질수록 유효 주의(attention) 분배가 희박해지고, 결과적으로 “중간 구간의 신호가 잡음에 묻히는 현상”으로 이해하는 게 실전에서 더 유용합니다. 고전 연구인 Lost in the Middle은 multi-doc QA와 key-value retrieval에서 관련 정보 위치에 따라 성능이 크게 변동함을 보여줍니다. (arxiv.org)

그리고 2025~2026엔 더 불편한 결과가 나왔습니다. 관련 정보를 완벽히 제공(perfect retrieval)해도 입력 길이만 증가하면 성능이 크게 하락할 수 있다는 실험 보고가 있습니다. (arxiv.org)
즉, “RAG로 관련 chunk만 넣었으니 괜찮다”가 아니라, 컨텍스트를 짧고 밀도 있게 유지해야 합니다.

3) 2026년 흐름: “모델/서빙 레벨의 compaction”과 “애플리케이션 레벨 compaction”의 분리

  • 서빙/인프라 레벨: KV-cache 병목을 줄여 긴 컨텍스트를 “돌릴 수 있게” 만드는 기술(예: paged KV 등). 다만 이건 비용/서빙 효율 문제를 푸는 것이지, LITM을 직접 해결하진 않습니다. (예: llama.cpp에서도 paged KV 논의가 활발) (github.com)
  • 애플리케이션 레벨: 무엇을 남기고 무엇을 버릴지(업무 의미론) 결정하는 compaction. 여기서 LITM 방지(앞/뒤에 앵커 배치, 구조화 상태 유지)가 핵심입니다.
  • 모델 내부 레벨(KV compaction): 2026년엔 KV cache 자체를 “압축/compaction”하는 연구도 나오고 있습니다(예: Still: single forward pass로 amortized KV cache compaction). (arxiv.org)
    이 축은 “토큰을 줄이는 요약”과는 다르게, 모델이 보는 내부 메모리 표현을 줄여 장기 지평을 늘리는 계열입니다.

💻 실전 코드

아래는 “긴 컨텍스트 채팅/에이전트”에서 흔한 실패(히스토리 누적 → 비용 증가 + LITM + 제약 유실)를 막기 위한 현실적인 compaction 파이프라인 예시입니다.

핵심 아이디어: 1) 대화 로그는 원문 그대로 저장(감사/재현/디버깅용) 2) 모델에 넣는 컨텍스트는 (a) 고정 Anchor(규칙/제약/목표) + (b) 작업 상태(state) + (c) 최근 turns로 제한 3) 임계치에 도달하면 “free-form 요약”이 아니라 스키마 기반 state 업데이트로 compact (요약의 누적 손실을 줄임)

0) 의존성/환경

python -m venv .venv
source .venv/bin/activate
pip install openai tiktoken pydantic
export OPENAI_API_KEY="..."

1) 상태 스키마 + 토큰 예산 기반 compaction

from __future__ import annotations

import json
import os
from typing import List, Literal, Optional, Dict, Any
from pydantic import BaseModel, Field

from openai import OpenAI
import tiktoken

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

# ---- 1) "요약" 대신 남길 업무 상태를 구조화한다 ----
class WorkState(BaseModel):
    goal: str
    hard_constraints: List[str] = Field(default_factory=list)   # 절대 깨면 안 되는 규칙/제약
    decisions: List[str] = Field(default_factory=list)          # 이미 합의된 결정
    open_questions: List[str] = Field(default_factory=list)     # 아직 미해결
    next_actions: List[str] = Field(default_factory=list)       # 다음 할 일(짧고 명령형)
    key_artifacts: Dict[str, str] = Field(default_factory=dict) # 파일/링크/티켓/커밋 등 포인터

class Memory(BaseModel):
    anchor: WorkState
    recent_messages: List[Dict[str, str]] = Field(default_factory=list)  # role/content
    raw_log_path: str = "raw_log.jsonl"

# ---- 2) 토큰 카운팅(대략) ----
enc = tiktoken.get_encoding("o200k_base")

def approx_tokens(messages: List[Dict[str, str]]) -> int:
    # 모델별 정확 카운터가 있으면 그걸 쓰는 게 더 좋다.
    return sum(len(enc.encode(m["content"])) + 8 for m in messages)

def render_prompt(mem: Memory) -> List[Dict[str, str]]:
    anchor_block = {
        "role": "system",
        "content": (
            "You are a senior engineer assistant.\n"
            "Use the following WORK_STATE as the source of truth.\n"
            "If something is missing, ask a question instead of guessing.\n\n"
            f"WORK_STATE(JSON):\n{mem.anchor.model_dump_json(indent=2)}\n"
        )
    }
    return [anchor_block] + mem.recent_messages

def append_raw(mem: Memory, role: str, content: str) -> None:
    with open(mem.raw_log_path, "a", encoding="utf-8") as f:
        f.write(json.dumps({"role": role, "content": content}, ensure_ascii=False) + "\n")

def compact_if_needed(mem: Memory, max_input_tokens: int = 12000, keep_last_n: int = 10) -> None:
    """
    - recent_messages가 너무 길어지면, 오래된 메시지를 "요약"하지 말고
      anchor(WorkState)를 업데이트하는 방식으로 compaction 한다.
    - 그리고 recent_messages는 최근 N개만 남긴다.
    """
    messages = render_prompt(mem)
    if approx_tokens(messages) <= max_input_tokens:
        return

    # compaction 대상(최근 N개를 제외한 과거 turns)
    old = mem.recent_messages[:-keep_last_n]
    if not old:
        return

    # 모델에게 "스키마 업데이트"를 요구: summarize-a-summary를 피하고,
    # anchor를 누적 갱신(append/merge)하게 만든다.
    compaction_prompt = [
        {
            "role": "system",
            "content": (
                "You update WORK_STATE. Do NOT write a narrative summary.\n"
                "Return ONLY valid JSON that matches the WorkState schema.\n"
                "Rules:\n"
                "- Preserve hard constraints verbatim if present.\n"
                "- Convert long discussion into decisions/open_questions/next_actions.\n"
                "- Add pointers to key artifacts.\n"
            )
        },
        {
            "role": "user",
            "content": (
                "Current WORK_STATE:\n"
                f"{mem.anchor.model_dump_json(indent=2)}\n\n"
                "Conversation turns to compact:\n"
                f"{json.dumps(old, ensure_ascii=False, indent=2)}\n\n"
                "Return the updated WorkState JSON:"
            )
        }
    ]

    resp = client.responses.create(
        model="gpt-5-mini",  # 예시. 조직 표준 모델로 교체
        input=compaction_prompt,
        temperature=0.2,
    )

    updated_json = resp.output_text.strip()
    mem.anchor = WorkState.model_validate_json(updated_json)

    # recent는 tail만 남기고, 나머지는 "anchor에 흡수"됐다고 간주
    mem.recent_messages = mem.recent_messages[-keep_last_n:]

# ---- 3) 실제 에이전트 루프(예: 운영 이슈 대응) ----
def ask(mem: Memory, user_text: str) -> str:
    append_raw(mem, "user", user_text)
    mem.recent_messages.append({"role": "user", "content": user_text})

    compact_if_needed(mem)

    resp = client.responses.create(
        model="gpt-5-mini",
        input=render_prompt(mem),
        temperature=0.2,
    )
    answer = resp.output_text

    append_raw(mem, "assistant", answer)
    mem.recent_messages.append({"role": "assistant", "content": answer})

    return answer

if __name__ == "__main__":
    mem = Memory(
        anchor=WorkState(
            goal="서비스 장애(5xx 급증) 원인 파악 및 30분 내 완화, 24시간 내 근본 해결책 도출",
            hard_constraints=[
                "절대 프로덕션 DB에 write-heavy 쿼리/마이그레이션을 실행하지 않는다.",
                "PII 로그는 외부로 반출하지 않는다.",
            ],
            key_artifacts={
                "runbook": "internal://runbooks/api-5xx",
                "dashboard": "internal://grafana/api-latency",
            }
        )
    )

    print(ask(mem, "지난 2시간 동안 /checkout 5xx가 3배 증가했어. 우선 완화책부터 제안해줘."))
    print(ask(mem, "최근 배포는 40분 전, 결제 모듈의 timeout 설정 변경이 있었어. 다음으로 확인할 포인트?"))

예상 출력(형태)

  • 초기에는 일반 답변이 나오다가, 입력 토큰이 예산을 넘으면 compact_if_needed()가 동작해
    • hard_constraints는 유지
    • “결론/결정/다음 액션”이 WorkState에 축적
    • recent_messages는 최근 N개만 유지

이 패턴의 장점은 LITM을 줄이기 위해 중요한 상태를 항상 컨텍스트 맨 앞(system anchor)에 고정한다는 점입니다. “긴 대화 중간에 묻힌 제약/결정”이 모델에게 다시 잘 보이게 됩니다.


⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) Anchor를 ‘앞’에 고정하고, 업데이트는 append/merge로만

  • LITM U-curve 특성상 “중간에 있는 정보”는 신뢰하기 어렵습니다. (arxiv.org)
  • 따라서 “현재 상태(규칙/결정/인터페이스/중요 키값)”는 항상 system 또는 첫 블록에 두고, compaction은 그 블록을 갱신하는 방식이 안정적입니다.

2) free-form summary 대신 ‘스키마 기반 상태’로 compact

  • summarize-a-summary 누적은 품질이 서서히 무너집니다(특히 장기 에이전트). (tmls.nyc)
  • decisions/open_questions/next_actions처럼 “업무에 필요한 최소 충분 통계량(sufficient state)”을 정의해 두면, 요약 손실을 관리할 수 있습니다.

3) 원문은 밖에 저장하고, 컨텍스트에는 포인터만

  • “길이 자체가 성능을 해친다”는 결과를 감안하면 (arxiv.org), 원문을 계속 넣어두는 건 손해입니다.
  • 원문은 object storage + 검색(RAG)로 두고, 컨텍스트에는 “무엇을 어디서 다시 꺼낼지”만 남기는 게 비용/품질 모두 유리합니다.

흔한 함정/안티패턴

  • (안티패턴) 임계치마다 ‘전체 대화 요약 1개’로 치환
    • 겉보기엔 깔끔하지만, 실제로는 “중요한 제약/보안 규칙/예외 케이스”가 빠지기 쉽습니다.
  • (안티패턴) compaction 결과를 검증 없이 진실로 취급
    • compaction은 생성(generation)입니다. 반드시 “누락/왜곡 가능성”을 전제로 하고, 중요 항목은 구조화/검증(예: key 목록, 테스트, 체크리스트)으로 방어하세요.
  • (안티패턴) 긴 컨텍스트를 ‘저장소’로 사용
    • 컨텍스트는 DB가 아닙니다. 길어질수록 모델이 잘 못 쓰는 경향(LITM)도 있고 (arxiv.org), 길이 자체가 성능을 떨어뜨릴 수도 있습니다. (arxiv.org)

비용/성능/안정성 트레이드오프

  • 별도 요약 호출(compaction call): 품질은 통제하기 쉽지만 호출 비용/지연이 추가
  • provider-native compaction(OpenAI/Anthropic 등): 운영은 편하지만 “어떤 포맷으로 무엇을 보존하는지”를 100% 통제하기 어려움
    • OpenAI는 Responses API에 native compaction을 넣어 개발자 부담을 줄인다고 설명합니다. (openai.com)
    • Anthropic은 API에서 compaction 전략을 활성화하는 문서를 제공합니다. (platform.claude.com)
  • KV-level compaction(Still 등): 장기 지평에서 매력적이지만, 앱 관점의 “업무 의미론(무엇이 중요한가)”을 자동으로 해결하진 않음. (arxiv.org)

🚀 마무리

정리하면, 2026년 8월의 long context 활용은 “창이 커졌으니 다 넣자”가 아니라:

  • 길이 자체가 성능을 해칠 수 있고 (arxiv.org)
  • 중간 정보는 더 잘 잃어버리며(LITM) (arxiv.org)
  • 그래서 compaction은 선택이 아니라 운영 기법이 되었습니다.

도입 판단 기준:

  • 대화/에이전트가 30분~수시간 이상 이어지고, “이전 결정/제약”이 반복적으로 중요하다 → 스키마 기반 compaction + anchor 고정 추천
  • 문서가 길고 재참조가 잦다 → 원문은 외부 저장 + RAG, 컨텍스트에는 상태+포인터만
  • “요약이 틀리면 큰 사고” → compaction은 최소화하고, 원문 인용/검증 루프를 설계

다음 학습 추천(읽을 거리):

  • LITM의 정석: Lost in the Middle (arxiv.org)
  • “retrieval이 완벽해도 길면 망가진다”: Context Length Alone Hurts… (arxiv.org)
  • 프로바이더 네이티브 compaction: OpenAI Responses API 소개 (openai.com), Anthropic compaction 문서 (platform.claude.com)
  • KV-level 연구 방향: Still (amortized KV cache compaction) (arxiv.org)

원하시면, 위 코드 예제를 (1) RAG 인덱스(예: Postgres+pgvector)와 결합, (2) compaction 결과에 대한 자동 검증(“hard_constraints 누락 감지”, “결정/액션 중복 제거”), (3) 팀 개발용 handoff 문서 자동 생성까지 확장한 버전으로도 정리해드릴게요.