Joyuno's Dev Blog

긴 컨텍스트(200k~1M+)가 ‘쓸 수 있게’ 되는 법: 2026년식 LLM compaction/summary 설계와 lost-in-the-middle 대응

AI LLM

들어가며

LLM의 context window가 200k~1M 토큰까지 커졌는데도, “그 안에 넣기만 하면 알아서 잘 쓰겠지”는 여전히 잘 안 됩니다. 실제 현업에서 터지는 문제는 보통 3가지로 요약됩니다.

1) 비용/지연: 긴 컨텍스트는 입력 토큰 비용과 latency를 선형(혹은 시스템적으로 더)로 밀어 올립니다.
2) 성능 저하(특히 중간): 문서/대화가 길어질수록, 중요한 정보가 중간에 묻히면 정확도가 떨어지는 lost in the middle이 반복 관측됩니다. Google의 Found in the middle은 이를 U-shaped attention bias(앞/뒤 과대, 중간 과소)로 설명합니다. (research.google)
3) 장기 세션의 ‘기억 부패’: agent가 수백 step을 돌면, compaction(요약/압축)이 요약을 다시 요약하면서 중요한 사실을 잃거나(irreversible loss), 잘못된 사실이 요약에 들어가 “독”처럼 남습니다. (tianpan.co)

언제 쓰면 좋나

  • 장기 대화/agent(코딩 에이전트, 운영 자동화, 리서치 에이전트)
  • RAG에서 검색 결과/툴 출력이 길고 반복 참조되는 경우(티켓/런북/로그/PR diff)
  • “문서 전체를 한 번에”가 아니라, 반복적으로 일부만 참조하면서 작업이 진행되는 워크플로우

언제 쓰면 안 되나

  • 단발성 Q&A(입력 짧음): compaction 호출 자체가 “Summary tax(요약 비용)”만 추가할 수 있음 (tianpan.co)
  • 정확히 보존해야 할 원문(법무/규정/정산 등)을 요약에만 의존: 요약은 항상 손실 가능
  • 디버깅이 안 된 상태로 “자동 compaction”만 켜기: 무엇이 언제 사라지는지 관측 불가

🔧 핵심 개념

1) “long context 활용”의 적: position bias + middle loss

Lost-in-the-middle은 단순히 “컨텍스트가 길어서”가 아니라, 모델 내부적으로 앞/뒤 토큰에 더 주의를 주는 편향과 연결됩니다. Found in the middle은 이 편향을 보정해 긴 컨텍스트에서 관련 정보를 찾는 성능을 올리는 접근을 제안합니다. (research.google)
즉, 길이를 늘리는 것(LongRoPE 같은 확장)만으로는 해결이 안 되고, 배치(placement), 압축(compaction), 검색(RAG), 평가가 같이 가야 합니다. (LongRoPE류는 “더 많이 넣을 수 있음”의 영역) (github.com)

2) compaction의 본질: “요약”이 아니라 “상태(state) 재인코딩”

실무 compaction은 보통 다음 중 하나(또는 혼합)입니다.

  • Hard compression(텍스트 요약/프루닝): 오래된 대화를 요약문으로 치환, 중요하지 않은 메시지는 drop
  • Query-aware compression(RAG 압축): 질문/의도(Q)에 따라 문서에서 쓸 부분만 남김(문서 재정렬/랭킹 포함). 잃기 쉬운 중간 정보를 “앞쪽으로 끌어올리는” 효과도 있음. (academ.us)
  • Soft compression(잠재 표현/KV cache 압축): 텍스트 자체가 아니라 latent/KV에 정보를 넣어 효율적으로 유지(연구가 활발, 배포 난이도 높음). (openreview.net)

2026년 현업 관점에서 당장 쓸 수 있는 건 대개 Hard + Query-aware 혼합이고, 여기에 “middle loss를 줄이기 위한 배치 규칙”이 핵심입니다.

3) 자동 compaction(플랫폼 기능) vs 앱 레벨 compaction

예를 들어 AWS Bedrock의 Claude Messages는 server-side compaction을 제공하며, 특정 beta header로 활성화하고 “compaction block” 이전 메시지를 자동으로 요약/드롭해 이어갑니다. (docs.aws.amazon.com)
이 방식은 통합은 쉽지만, 실무에선 아래가 중요합니다.

  • 요약 포맷을 내가 통제할 수 있는가?
  • 무엇을 절대 보존(pinned state)할지 정의할 수 있는가?
  • compaction 시점/빈도/토큰 예산을 관측하고 튜닝할 수 있는가?

(플랫폼 compaction을 쓰더라도, 앱 레벨에서 “핀/체크포인트/검증”을 얹는 게 안전합니다.)


💻 실전 코드

아래는 “장기 세션 agent”에서 lost-in-the-middle + 요약 드리프트를 동시에 잡는 패턴입니다.

  • 핵심 아이디어: 컨텍스트를 1) PINNED(절대 상태): 프로젝트/환경/식별자/규칙
    2) COMPACTED MEMORY(요약 상태): 결정/진행/시도/결과/오픈이슈
    3) RECENT WINDOW(최근 N step): 최신 대화 원문
    4) EPISODIC BLOBS(큰 툴 출력): 원문은 외부 저장 + 필요 시 query-aware로 얇게 가져오기
    로 분리하고, 매 turn마다 “중요 상태”는 앞/뒤에 배치합니다(중간에 묻히지 않게).

0) 의존성/실행

python -m venv .venv
source .venv/bin/activate
pip install pydantic rich
python agent_compaction_demo.py

1) 코드 (실전 스켈레톤)

# agent_compaction_demo.py
from __future__ import annotations

import json
import time
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from rich import print


# --- 1) 데이터 모델: "요약"이 아니라 "상태"로 보관 ---
class PinnedState(BaseModel):
    # 절대 잃으면 안 되는 값들(식별자/환경/목표/안전규칙/버전 등)
    project: str
    env: str
    customer_id: str
    repo: str
    hard_constraints: List[str]


class MemoryState(BaseModel):
    # 장기적으로 유지할 "결정/시도/결과" (요약의 핵심 단위)
    goal: str
    decisions: List[str] = Field(default_factory=list)
    attempts: List[str] = Field(default_factory=list)
    findings: List[str] = Field(default_factory=list)
    open_questions: List[str] = Field(default_factory=list)
    last_updated_ts: float = Field(default_factory=lambda: time.time())


class Turn(BaseModel):
    role: str  # "user" | "assistant" | "tool"
    content: str
    ts: float = Field(default_factory=lambda: time.time())


# --- 2) LLM 호출은 실제 프로젝트의 SDK로 교체 ---
def call_llm(messages: List[Dict[str, str]]) -> str:
    """
    실제로는 OpenAI/Bedrock/Anthropic 등으로 교체.
    여기서는 '형태'만 보여주기 위해 더미 응답.
    """
    # 현실 코드에서는 messages를 그대로 API에 전달하고 텍스트를 반환
    return "DUMMY: (여기서 LLM이 답변/다음 행동을 생성한다고 가정)"


# --- 3) Compaction: "요약문"이 아니라 구조화된 MemoryState로 갱신 ---
def compact_memory(pinned: PinnedState, memory: MemoryState, old_turns: List[Turn]) -> MemoryState:
    """
    핵심: (a) 구조화 포맷, (b) 덮어쓰기/드리프트 방지, (c) 회복불가 손실 최소화.
    """
    # 실무에서는 이 프롬프트를 LLM에 보내 JSON으로 업데이트를 받는다.
    # 여기서는 old_turns에서 키워드 기반으로 "시도/결과"를 누적하는 예시.
    updated = memory.model_copy(deep=True)
    updated.last_updated_ts = time.time()

    for t in old_turns:
        c = t.content.lower()
        if "tried:" in c:
            updated.attempts.append(t.content.strip())
        if "decision:" in c:
            updated.decisions.append(t.content.strip())
        if "found:" in c or "result:" in c:
            updated.findings.append(t.content.strip())
        if "open:" in c or "todo:" in c:
            updated.open_questions.append(t.content.strip())

    # 드리프트 방지: constraints는 pinned로만 유지(요약이 수정하지 못하게 분리)
    return updated


# --- 4) Query-aware context compression (RAG/툴 출력 압축) 자리 ---
def compress_blob_for_query(blob_text: str, query: str, token_budget_chars: int = 1800) -> str:
    """
    실제로는 AttnComp/ACC-RAG 류처럼 '질문 적응형 압축'을 적용하거나 ([aclanthology.org](https://aclanthology.org/2025.findings-emnlp.449/?utm_source=openai))
    LLM/규칙 기반으로 코드/JSON은 byte-preserving, 설명 텍스트만 축약하는 혼합 전략을 쓴다.
    """
    # 매우 단순한 placeholder: query 관련 줄만 우선 + 길이 제한
    lines = blob_text.splitlines()
    picked = [ln for ln in lines if any(k in ln.lower() for k in query.lower().split())]
    out = "\n".join(picked)[:token_budget_chars]
    return out if out.strip() else blob_text[:token_budget_chars]


# --- 5) 메시지 구성: lost-in-the-middle 대응(앞/뒤로 중요 상태 배치) ---
def build_messages(
    pinned: PinnedState,
    memory: MemoryState,
    recent: List[Turn],
    user_query: str,
    blob_snippets: Optional[List[str]] = None,
) -> List[Dict[str, str]]:
    blob_snippets = blob_snippets or []

    pinned_block = (
        "PINNED_STATE (do not modify):\n"
        + json.dumps(pinned.model_dump(), ensure_ascii=False, indent=2)
    )
    memory_block = (
        "COMPACTED_MEMORY (authoritative state):\n"
        + json.dumps(memory.model_dump(), ensure_ascii=False, indent=2)
    )

    # 중요 포인트:
    # 1) PINNED + MEMORY를 컨텍스트 "앞쪽"에 둔다
    # 2) user_query를 "뒤쪽"에도 한 번 더 둬서 end-bias를 활용한다(중요 질문이 중간에 묻히지 않게)
    messages: List[Dict[str, str]] = [
        {"role": "system", "content": "You are a senior engineer agent. Prefer precise, verifiable steps."},
        {"role": "system", "content": pinned_block},
        {"role": "system", "content": memory_block},
    ]

    for snip in blob_snippets:
        messages.append({"role": "system", "content": f"COMPRESSED_BLOB:\n{snip}"})

    for t in recent:
        messages.append({"role": t.role, "content": t.content})

    messages.append({"role": "user", "content": user_query})
    messages.append({"role": "user", "content": f"(REPEAT_QUERY_FOR_END_BIAS) {user_query}"})
    return messages


def main():
    pinned = PinnedState(
        project="Payments Incident Agent",
        env="prod",
        customer_id="CUST-18421",
        repo="git@github.com:acme/payments.git",
        hard_constraints=[
            "Never rotate production keys without change ticket approval",
            "All changes must include rollback plan",
            "Do not expose PII in logs",
        ],
    )

    memory = MemoryState(goal="Reduce checkout latency regression introduced in v2.18.0")
    history: List[Turn] = []

    # 현실적인 장기 세션 흐름(툴 출력이 길다고 가정)
    history.append(Turn(role="user", content="최근 배포(v2.18.0) 이후 결제 latency가 2배 증가했어. 원인 분석해줘."))
    history.append(Turn(role="assistant", content="Decision: 먼저 p95 latency가 상승한 endpoint/region을 분리해서 본다."))
    history.append(Turn(role="tool", content="FOUND: ap-northeast-2 /checkout p95 900ms -> 1800ms (after v2.18.0)"))
    history.append(Turn(role="assistant", content="Tried: DB 커넥션 풀 설정 확인"))
    history.append(Turn(role="tool", content="RESULT: pool_max=20, wait 증가. 하지만 v2.18.0 변경점과 직접 연결은 약함."))
    history.append(Turn(role="assistant", content="Open: v2.18.0에서 추가된 fraud-check 호출이 병목인지 확인"))

    # ---- Compaction 트리거(예: turn 수/토큰 수 기반). 여기선 4개를 'old'로 간주 ----
    old_turns = history[:-2]
    recent = history[-2:]

    memory = compact_memory(pinned, memory, old_turns)

    # 큰 툴 출력(로그/트레이스)을 외부에 저장했다고 치고, query-aware로 얇게 가져오는 단계
    huge_trace = "\n".join([f"span={i} service=fraud_check dur_ms={5+i%20}" for i in range(5000)])
    query = "fraud_check dur_ms"
    blob = compress_blob_for_query(huge_trace, query=query, token_budget_chars=1200)

    messages = build_messages(
        pinned=pinned,
        memory=memory,
        recent=recent,
        user_query="fraud-check 호출이 latency 병목인지 확인하고, 롤백/완화 플랜을 제안해줘.",
        blob_snippets=[blob],
    )

    print("[bold]--- COMPOSED MESSAGES (truncated view) ---[/bold]")
    print(messages[1]["content"][:400] + "...\n")
    print(messages[2]["content"][:400] + "...\n")
    print("[bold]--- LLM OUTPUT ---[/bold]")
    out = call_llm(messages)
    print(out)


if __name__ == "__main__":
    main()

2) 예상 출력(요지)

  • PINNED_STATE / COMPACTED_MEMORY가 상단에 있고
  • 큰 툴 출력은 COMPRESSED_BLOB로 예산 내 제공
  • 질문을 끝에 한 번 더 반복해 end-bias를 이용(중요 질의가 middle로 밀리지 않게)

⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) “요약문” 대신 “상태 머신”으로 압축하라
결정(Decisions), 시도(Attempts), 근거(Findings), 오픈이슈(Open questions)를 구조화하면 요약 드리프트가 줄고, 재사용/검증이 쉬워집니다. 장기 compaction의 실패 패턴(요약이 요약을 먹으며 의미가 흐려짐)은 반복적으로 지적됩니다. (tianpan.co)

2) middle loss는 ‘더 넣기’가 아니라 ‘배치/재배치’로 먼저 해결
중요한 식별자/제약/현재 상태는 앞쪽(system/pinned)으로 고정하고, 현재 질문/목표는 뒤쪽에도 반복 배치해 position bias를 역이용하세요. (Found-in-the-middle이 말하는 attention bias 관점과도 정합적) (research.google)

3) RAG는 “그대로 붙이기”보다 “질문 적응형 압축 + 재정렬”이 기본값
EMNLP 2025의 AttnComp처럼 attention-guided로 컨텍스트를 압축/선별하는 흐름이 나오고, 2025년에도 Adaptive Context Compression류가 RAG 비용을 줄이면서 성능을 유지하려는 시도가 많습니다. (aclanthology.org)

흔한 함정/안티패턴

  • 요약을 매번 덮어쓰기(Overwrite)만 하고 검증을 안 함: 한 번 잃은 사실은 복구가 어렵고, 잘못된 사실이 요약에 남으면 계속 전파됩니다. (tianpan.co)
  • 툴 출력/검색 결과를 “원문 그대로” 대화에 넣고 계속 참조: 같은 비용을 매 step 재지불 + middle loss 악화
  • 플랫폼 자동 compaction을 “만능”으로 취급: 통제/관측/회귀테스트 없으면 디버깅이 매우 어려움(예: 어떤 시점에 무엇이 사라졌는지)

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

  • Compaction은 공짜가 아니고, 추가 LLM call과 운영 복잡도를 가져옵니다(“Summary tax”). (tianpan.co)
  • 그러나 “긴 컨텍스트를 그대로 유지”하는 접근은 토큰 경제 측면에서 빠르게 비싸지고, 성능도 일정 길이 이후 떨어질 수 있다는 논의/리뷰가 이어집니다. (openreview.net)
  • 따라서 실무 최적점은 보통: (1) pinned state 최소화 + (2) 구조화 메모리 + (3) 최근 원문 window + (4) 필요 시 query-aware로 blob 복원입니다.

🚀 마무리

2026년 8월 기준, long context window는 충분히 커졌지만 “제대로 쓰게 만드는 기술”은 별개 문제입니다. 핵심은:

  • lost in the middle은 실재하는 실패 모드이며, attention bias/position bias 관점에서 배치와 재배치가 즉효입니다. (research.google)
  • compaction은 요약이 아니라 상태 관리(state management)로 접근해야 드리프트/독성 요약을 줄입니다. (tianpan.co)
  • RAG는 “더 가져오기”가 아니라 “질문 적응형 압축/선별”이 기본 설계가 되어가고 있습니다. (aclanthology.org)

도입 판단 기준

  • 대화/agent가 50~200 turn 이상, 혹은 툴 출력이 크고 반복 참조된다 → compaction 설계 가치 큼
  • 중요한 사실(식별자/제약/결정)이 중간에 묻혀 자주 누락된다 → pinned + end-bias 배치부터 적용
  • 요약 품질이 불안정하다 → 구조화 메모리 + 회귀 테스트(“핵심 상태를 N step 후에도 복원 가능한가”)를 먼저

다음 학습 추천

  • Found in the middle (position bias 교정) (research.google)
  • AttnComp / Adaptive Context Compression 계열(질문 적응형 RAG 압축) (aclanthology.org)
  • Bedrock server-side compaction 문서(플랫폼 기능을 쓸 경우 동작/제약 파악) (docs.aws.amazon.com)

원하면, 당신의 실제 워크플로우(예: “코딩 에이전트 + CI 로그 + PR diff + 이슈 티켓”) 기준으로 PINNED/MEMORY 스키마와 compaction 트리거 정책(토큰/스텝/비용 기반)을 더 구체적으로 설계해 드릴 수 있습니다.