본문으로 건너뛰기
AIPida

컨텍스트 엔지니어링

프롬프트가 아니라 컨텍스트 윈도를 설계하는 법: 토큰 예산, 압축, 메모리, 검색, 캐시까지 실전 레시피

실전AI 개발·

LLM 애플리케이션을 만들다 보면 한 가지 불편한 진실을 만난다. 같은 모델, 같은 프롬프트인데 컨텍스트에 무엇을 어떤 순서로 넣느냐에 따라 정확도가 크게 갈린다. 프롬프트 엔지니어링이 "지시문을 어떻게 쓸까"의 문제라면, 컨텍스트 엔지니어링(context engineering) 은 "컨텍스트 윈도라는 한정된 작업 공간에 무엇을·얼마나·어떤 순서로 채울까"의 문제다.

모델이 1M 토큰 윈도를 갖게 된 지금도 이 문제는 사라지지 않았다. 오히려 더 중요해졌다. 윈도가 크다고 다 채우면 비용이 폭발하고, lost in the middle(가운데 정보 무시)·context rot(긴 컨텍스트에서 정확도 하락) 같은 현상으로 정확도가 떨어진다. 핵심 관점은 컨텍스트 윈도를 CPU의 레지스터/RAM 같은 유한 자원으로 보는 것이다. 들어갈 수 있는 토큰 수는 정해져 있고, 그 안에서 가장 신호 대 잡음비(signal-to-noise)가 높은 토큰만 남겨야 한다.

이 글은 그 자원 관리를 4개 레버로 나눠서 다룬다.

레버하는 일대표 기법
압축(Compression)같은 정보를 더 적은 토큰으로요약·compaction·중복 제거
메모리(Memory)윈도 밖으로 상태를 영속화파일/DB 기반 메모리, 세션 간 상태
필터링(Filtering/Retrieval)관련 있는 것만 골라 넣기RAG, reranking, 동적 도구 로딩
격리(Isolation)작업별로 컨텍스트 분리서브에이전트, 스크래치패드

코드 예시는 Anthropic Claude API(Python/TypeScript SDK)를 기준으로 하지만 원리는 모든 LLM에 동일하게 적용된다. 토큰 카운팅·프롬프트 캐싱·compaction처럼 모델/API에 의존하는 부분은 실제 동작 방식 그대로 다루고, 나머지는 일반화해서 설명한다.

왜 "더 많이 넣기"가 정답이 아닌가: 컨텍스트가 망가지는 4가지 방식

컨텍스트 엔지니어링을 시작하기 전에, 컨텍스트를 무작정 키우면 무슨 일이 벌어지는지부터 정확히 알아야 한다. 막연히 "긴 컨텍스트는 비싸다" 수준이 아니라, 실패 모드가 구분된다.

1. Context Poisoning (오염) — 잘못된 정보(환각, 오래된 데이터, 잘못된 도구 결과)가 한번 컨텍스트에 들어가면 이후 모든 추론이 그 위에서 일어난다. 에이전트 루프에서 특히 치명적이다. 한 턴의 환각이 다음 턴의 전제가 되어 누적된다.

2. Context Distraction (산만) — 컨텍스트가 길어질수록 모델이 학습된 일반 지식 대신 컨텍스트에 쌓인 히스토리를 그대로 반복하는 경향이 강해진다. 긴 에이전트 트레이스에서 "같은 실패한 접근을 계속 재시도"하는 패턴이 대표적이다.

3. Context Confusion (혼란) — 작업과 무관한 도구·문서·예시가 컨텍스트에 있으면, 모델이 그것까지 고려해 응답을 오염시킨다. 도구를 50개 정의해두면 5개만 필요한 요청에서도 엉뚱한 도구를 부른다. 도구 수가 늘수록 도구 선택 정확도가 떨어진다.

4. Context Clash (충돌) — 컨텍스트 안의 정보끼리 모순될 때(예: 이전 턴의 결정과 새 시스템 지시가 충돌). 모델은 어느 쪽을 따를지 비결정적으로 선택한다.

여기에 더해 위치 기반 효과가 있다.

  • Lost in the middle: 긴 컨텍스트에서 맨 앞과 맨 뒤의 정보는 잘 활용하지만 가운데 정보는 무시되는 경향. 그래서 가장 중요한 컨텍스트는 시작이나 끝에 배치하는 게 실전 규칙이다.
  • Context rot: 입력 토큰이 늘어날수록 모델의 정확도가 점진적으로 하락하는 일반적 경향. 같은 정보를 1만 토큰에 담느냐 10만 토큰에 담느냐에 따라 답이 달라진다.

실전 규칙: 컨텍스트에 토큰을 추가할 때마다 "이 토큰이 정답 확률을 올리는가, 잡음을 더하는가"를 자문하라. 애매하면 빼는 쪽이 거의 항상 옳다. 컨텍스트 엔지니어링의 목표는 "필요한 최소한의 고신호 토큰"을 찾는 것이지 "넣을 수 있는 모든 토큰"이 아니다.

기초: 토큰 예산을 측정하라 (tiktoken을 쓰지 말 것)

최적화하려면 먼저 측정해야 한다. 컨텍스트의 각 구성요소가 몇 토큰을 차지하는지 모르면 어디를 줄여야 할지 알 수 없다.

가장 흔한 함정: OpenAI의 tiktoken으로 Claude 토큰을 세는 것. tiktoken은 OpenAI 토크나이저라서 Claude 토큰을 일반 텍스트에서 ~15-20% 적게 세고, 코드나 비영어(한국어 포함) 입력에서는 훨씬 더 크게 빗나간다. 토큰 카운트는 모델별로 다르다. 추론에 쓸 모델과 같은 ID로 세야 한다.

정확한 카운트는 count_tokens 엔드포인트를 쓴다. 무료이고 빠르다.

from anthropic import Anthropic

client = Anthropic()

# 시스템 프롬프트 + 메시지 + 도구를 통째로 세기
resp = client.messages.count_tokens(
    model="claude-opus-4-8",
    system=SYSTEM_PROMPT,
    tools=TOOLS,
    messages=messages,
)
print(resp.input_tokens)  # 실제 입력 토큰 수

TypeScript: await client.messages.countTokens({ model, system, tools, messages }).input_tokens.

구성요소별로 분해해서 측정하는 패턴이 실무에서 유용하다. 시스템 프롬프트, 도구 정의, 검색된 문서, 대화 히스토리가 각각 몇 토큰인지 따로 재면 병목이 보인다.

def budget_breakdown(client, model, system, tools, history, retrieved_docs):
    def count(**kwargs):
        return client.messages.count_tokens(
            model=model, messages=[{"role": "user", "content": "x"}], **kwargs
        ).input_tokens

    base = count()  # 최소 오버헤드
    return {
        "system": count(system=system) - base,
        "tools": count(tools=tools) - base,
        "retrieved": client.messages.count_tokens(
            model=model,
            messages=[{"role": "user", "content": retrieved_docs}],
        ).input_tokens,
        "history": client.messages.count_tokens(
            model=model, messages=history
        ).input_tokens,
    }

실전 가이드라인: 컨텍스트 윈도의 절대 크기(예: 200K, 1M)를 기준으로 삼지 말고, 유효 작업 예산을 따로 잡아라. 정확도가 가장 좋은 구간은 윈도 전체보다 한참 작다. 경험적으로 검색 기반 작업은 입력을 수만 토큰 이하로 유지할 때 정확도와 비용이 모두 좋다. 측정 → 예산 설정 → 각 구성요소를 예산 안에 맞추기, 이 순서로 작업한다.

압축 1 — 시스템 프롬프트와 도구 정의 다이어트

압축의 첫 대상은 의외로 "매 요청마다 들어가는 고정 비용"이다. 시스템 프롬프트와 도구 정의는 대화 길이와 무관하게 항상 컨텍스트 맨 앞을 차지한다. 여기가 비대하면 모든 요청이 비싸진다.

시스템 프롬프트의 적정 고도(altitude)를 찾아라. 흔한 두 극단이 있다.

  • 너무 낮은 고도: if-else로 모든 엣지 케이스를 하드코딩. 깨지기 쉽고 길다.
  • 너무 높은 고도: "최선을 다하라" 수준의 막연한 지시. 동작이 비결정적이다.

적정 고도는 "구체적이되 휴리스틱으로 일반화 가능한" 지점이다. 예시:

# 나쁜 예 (저고도, 장황)
사용자가 "가격"이라고 하면 price 도구를 호출하고, "비용"이라고 하면
 cost 도구를 호출하고, "요금"이라고 하면... (수십 줄)

# 좋은 예 (적정 고도)
현재 가격·시세 정보가 필요한 질문에는 답하기 전에 price 도구를 호출하라.
학습된 지식으로 추정하지 말 것.

최근 모델일수록 지시를 더 문자 그대로 따른다. 과거 모델의 소극성을 극복하려고 쓴 CRITICAL: YOU MUST... 같은 공격적 지시는 최신 모델에서 과트리거(overtriggering)를 유발한다. 도구를 과하게 부르거나 불필요하게 장황해진다. 해법은 가드레일을 더 추가하는 게 아니라 어조를 낮추는 것이다.

과거 (효과 있던 표현)현재 (권장)
CRITICAL: 반드시 이 도구를 써라이 도구를 ~할 때 사용하라
기본적으로 [도구]를 사용[도구]가 X를 개선할 때 사용
의심되면 [도구]를 써라(삭제 — 더 이상 불필요)

도구 정의도 압축 대상이다. 도구가 많을수록 (1) 정의가 차지하는 고정 토큰이 늘고 (2) 도구 선택 정확도가 떨어진다. 두 가지 접근:

  1. 도구 수를 줄여라. 정말 필요한 도구만 남긴다. bash 하나로 넓게 커버하다가, 게이팅·렌더링·감사·병렬화가 필요한 액션만 전용 도구로 승격하는 게 좋은 출발점이다.
  2. 도구 설명을 "언제 부르는지" 중심으로 써라. 최신 모델은 도구를 보수적으로 부르므로, "무엇을 하는 도구인가"보다 "언제 이 도구를 호출해야 하는가"를 명시한 설명이 호출 정확도를 측정 가능하게 올린다.
{
  "name": "search_orders",
  "description": "사용자가 과거 주문, 배송 상태, 환불을 물을 때 호출하라. 현재 세션의 대화에 답이 없는 주문 관련 질문이 트리거 조건이다.",
  "input_schema": { "...": "..." }
}

이 한 줄짜리 트리거 조건이 장황한 "기능 설명"보다 실전에서 더 효과적이다.

압축 2 — 대화 히스토리 compaction (서버 사이드 자동 요약)

긴 멀티턴 대화나 에이전트 루프에서 가장 빠르게 부풀어 오르는 건 메시지 히스토리다. 매 턴 도구 호출·결과·사고 과정이 쌓이면서 결국 컨텍스트 윈도를 넘기게 된다. 이때 compaction(압축)이 해법이다.

Compaction은 컨텍스트가 임계치(기본 150K 토큰)에 가까워지면 API가 이전 히스토리를 서버에서 자동 요약compaction 블록으로 대체한다. 이 기능은 베타이며 베타 헤더 compact-2026-01-12가 필요하다.

import anthropic

client = anthropic.Anthropic()
messages = []

def chat(user_message: str) -> str:
    messages.append({"role": "user", "content": user_message})

    response = client.beta.messages.create(
        betas=["compact-2026-01-12"],
        model="claude-opus-4-8",
        max_tokens=16000,
        messages=messages,
        context_management={
            "edits": [{"type": "compact_20260112"}]
        },
    )

    # 핵심: 텍스트만이 아니라 response.content 전체를 다시 append
    messages.append({"role": "assistant", "content": response.content})

    return next(b.text for b in response.content if b.type == "text")

여기서 절대 틀리면 안 되는 함정: 응답을 히스토리에 다시 넣을 때 response.content 전체를 append해야 한다. 텍스트 문자열만 뽑아서 넣으면 compaction 블록이 사라진다. API는 그 블록을 다음 요청에서 압축된 히스토리를 복원하는 데 쓰기 때문에, 텍스트만 추출하면 압축 상태를 조용히 잃어버린다(에러도 안 난다).

# 잘못된 예 — compaction 상태 유실
text = response.content[0].text
messages.append({"role": "assistant", "content": text})  # ❌

# 올바른 예
messages.append({"role": "assistant", "content": response.content})  # ✅

compaction이 트리거됐는지 확인하려면 응답 content에서 compaction 타입 블록을 찾으면 된다.

언제 쓰나: 대화가 윈도 한계에 도달할 가능성이 있는 장시간 멀티턴/에이전트 워크로드. compaction은 "요약"이고, 곧 다룰 context editing은 "가지치기"다. 둘은 보완 관계다 — editing은 오래된 도구 결과를 잘라내고, compaction은 한계 근처에서 전체를 요약한다.

모델 지원: compaction은 Fable 5, Opus 4.8/4.7/4.6, Sonnet 4.6에서 베타로 지원된다.

압축 3 — Context Editing으로 오래된 도구 결과 가지치기

Compaction이 "요약"이라면 context editing은 "삭제"다. 에이전트가 수십 턴을 돌면 초반에 읽은 파일 내용, 완료된 사고 블록, 이미 처리된 도구 결과가 컨텍스트에 그대로 남는다. 이것들은 더 이상 추론에 필요 없는데도 토큰을 먹고, context distraction을 유발한다.

Context editing은 설정 가능한 임계치에 따라 오래된 도구 결과와 사고 블록을 제거한다. compaction과 달리 내용을 요약하지 않고 그냥 잘라낸다. 대화 구조는 유지하면서 트랜스크립트를 가볍게 만든다.

Compaction과 같은 context_management 인터페이스를 쓰되 edit 타입이 다르다. 도구 사용 이력을 지우는 clear_tool_uses 계열, 사고 블록을 지우는 clear_thinking 계열 등이 있다(정확한 타입 이름과 임계치 옵션은 모델/베타 버전에 따라 다르므로 최신 문서를 확인할 것).

선택 기준:

상황기법
오래된 도구 출력이 무관해짐 (장기 에이전트)Context editing — 잘라냄
대화가 윈도 한계에 도달할 듯Compaction — 요약함
세션을 넘어 상태가 유지돼야 함Memory — 파일에 영속화 (다음 섹션)

많은 장기 에이전트는 셋을 다 쓴다. editing으로 평소에 가지치기 → 한계 근처에서 compaction으로 요약 → 세션을 넘기는 상태는 memory로 영속화.

직접 구현하는 경우 (수동 가지치기): API 기능 없이 직접 히스토리를 관리한다면, 다음 원칙을 따른다.

def prune_history(messages, keep_recent=10):
    """오래된 도구 결과를 placeholder로 대체. 구조는 유지."""
    pruned = []
    for i, msg in enumerate(messages):
        is_old = i < len(messages) - keep_recent
        if is_old and isinstance(msg.get("content"), list):
            new_content = []
            for block in msg["content"]:
                if block.get("type") == "tool_result":
                    # 도구 결과 본문을 짧은 placeholder로 교체
                    new_content.append({
                        **block,
                        "content": "[오래된 도구 결과 — 가지치기됨]",
                    })
                else:
                    new_content.append(block)
            pruned.append({**msg, "content": new_content})
        else:
            pruned.append(msg)
    return pruned

주의: 수동 가지치기 시 tool_use 블록과 그에 대응하는 tool_result의 ID 짝을 깨면 API가 400을 반환한다. 도구 결과를 지우더라도 placeholder는 남겨 짝을 유지해야 한다.

메모리 1 — 윈도 밖으로 상태를 영속화하는 패턴

압축으로 한 세션 안의 토큰을 줄였다면, 메모리는 세션의 경계를 넘는다. 컨텍스트 윈도는 본질적으로 휘발성이다. 세션이 끝나면 모델이 알아낸 모든 것이 사라진다. 메모리는 그 상태를 윈도 밖 영속 저장소(파일, DB)에 두고 필요할 때만 불러온다.

핵심 발상: 모델의 컨텍스트를 RAM이라고 보면, 메모리는 디스크다. 자주 안 쓰는 상태는 디스크에 두고, 작업에 필요한 것만 RAM(컨텍스트)으로 로드한다. 이렇게 하면 윈도를 작게 유지하면서도 장기 작업을 수행할 수 있다.

가장 단순하고 강력한 형태: 파일 기반 메모리. 에이전트에게 메모리 디렉터리(예: /memories)에 대한 읽기/쓰기 도구를 주고, 시스템 프롬프트로 "중요한 발견은 여기 적고, 다음 세션에 참조하라"고 지시한다. Claude API는 이를 위한 memory 도구를 제공한다(클라이언트 사이드 — 저장 백엔드는 직접 구현).

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    messages=[{"role": "user", "content": "내 선호 언어는 Python임을 기억해."}],
    tools=[{"type": "memory_20250818", "name": "memory"}],
)

memory 도구는 view/create/str_replace/insert/delete/rename 명령을 지원하고 /memories 디렉터리의 파일을 다룬다. 저장 백엔드(파일시스템, S3, DB)는 직접 구현한다. Python/TypeScript SDK는 백엔드 구현을 돕는 헬퍼 클래스(BetaAbstractMemoryTool 등)를 제공한다.

메모리 파일 포맷이 효과를 좌우한다. 막연히 "기억해"가 아니라 구조를 지시하라.

파일 하나당 교훈 하나, 맨 위에 한 줄 요약.
잘못된 접근과 확인된 접근 모두 기록하되, 왜 중요했는지 적어라.
레포나 대화 히스토리가 이미 기록하는 것은 저장하지 마라.
중복을 만들지 말고 기존 노트를 업데이트하라.
나중에 틀린 것으로 판명된 노트는 삭제하라.

보안 경고 (중요): 메모리 파일에 API 키·비밀번호·토큰을 절대 저장하지 마라. PII는 GDPR/개인정보보호법을 확인 후 다룬다. 레퍼런스 구현에는 접근 제어가 없으므로, 멀티유저 시스템에서는 유저별 메모리 디렉터리와 인증을 도구 핸들러에 직접 구현해야 한다. 유저 A의 에이전트가 유저 B의 메모리를 읽으면 데이터 유출이다.

메모리 2 — 워크스페이스 단위 영속 메모리와 버전 관리

단순 파일 메모리에서 한 단계 더 나아가면, 워크스페이스 단위로 스코핑된 영속 메모리 스토어가 있다. 여러 세션·여러 에이전트가 공유하는 작은 텍스트 문서들의 컬렉션이다. Claude의 Managed Agents에는 이를 위한 memory store가 있다(베타, managed-agents-2026-04-01).

구조는 3계층이다.

객체스코프비고
Memory store워크스페이스세션에 attach
Memory스토어텍스트 파일 하나, path로 주소 지정 (각 ≤100KB)
Memory version메모리변경마다 불변 스냅샷 (감사·롤백용)
# 스토어 생성 — description은 모델이 읽으므로 모델 관점에서 쓴다
store = client.beta.memory_stores.create(
    name="User Preferences",
    description="유저별 선호와 프로젝트 컨텍스트. 작업 시작 전 항상 확인할 것.",
)

# 미리 시드 (선택)
client.beta.memory_stores.memories.create(
    store.id,
    path="/formatting_standards.md",
    content="모든 보고서는 GAAP 포맷. 날짜는 ISO-8601...",
)

낙관적 동시성 제어 (optimistic concurrency) 가 중요하다. 여러 워커가 동시에 같은 메모리를 쓸 때 덮어쓰기를 막으려면 read → modify → write에 precondition을 건다.

mem = client.beta.memory_stores.memories.retrieve(memory_id, memory_store_id=store.id)

client.beta.memory_stores.memories.update(
    mem.id,
    memory_store_id=store.id,
    content="수정된 내용",
    # sha256이 안 맞으면 409 — 다시 읽고 재시도
    precondition={"type": "content_sha256", "content_sha256": mem.content_sha256},
)

버전 관리와 redaction: 모든 변경은 불변 버전(memver_...)을 남긴다. 감사 추적과 롤백이 가능하고, 유출된 비밀·PII는 redact로 내용만 지우면서 감사 기록(누가·언제)은 보존한다. 유저 삭제 요청 대응에도 쓴다.

메모리 설계 원칙:

  1. 많은 작은 파일 > 하나의 큰 파일. 검색·부분 로드·동시성에 유리하다.
  2. description은 모델을 위해 쓴다. 모델이 이 description으로 스토어 내용을 판단한다.
  3. read-only와 read-write를 분리한다. 공유 참조용 스토어는 read-only로, 유저별 누적 스토어는 read-write로 attach해서 사고를 막는다.
  4. access를 명시적으로 건다. read_only/read_write를 파일시스템 레벨에서 강제한다.

필터링 1 — RAG: 관련된 것만 검색해서 넣기

압축·메모리가 "있는 것을 줄이고 옮기는" 일이라면, 필터링은 "애초에 관련된 것만 골라 넣는" 일이다. 가장 널리 쓰이는 형태가 RAG(Retrieval-Augmented Generation)다. 전체 지식 베이스를 컨텍스트에 욱여넣는 대신, 질문과 관련된 청크만 검색해서 넣는다.

RAG의 컨텍스트 엔지니어링 관점 핵심은 "검색 품질 = 컨텍스트 품질" 이다. 잘못된 청크를 가져오면 context poisoning이고, 너무 많이 가져오면 context confusion이다. 그래서 단순 top-k 벡터 검색에서 멈추면 안 된다.

기본 파이프라인:

def build_rag_context(query, vector_store, max_tokens=8000):
    # 1. 1차 검색 — 넉넉히 가져옴 (recall 우선)
    candidates = vector_store.search(query, top_k=30)

    # 2. reranking — 정밀하게 재정렬 (precision)
    reranked = rerank(query, candidates)  # cross-encoder 등

    # 3. 토큰 예산 안에서 자르기
    selected, used = [], 0
    for doc in reranked:
        doc_tokens = count_tokens(doc.text)
        if used + doc_tokens > max_tokens:
            break
        selected.append(doc)
        used += doc_tokens
    return selected

1차 검색은 recall, reranking은 precision으로 역할을 나누는 게 핵심이다. 벡터 검색은 의미적으로 가까운 후보를 넓게 잡고, reranker(cross-encoder 등)가 질문-문서 쌍을 직접 평가해 정밀하게 재정렬한다. 이 2단계가 top-k 단독보다 거의 항상 낫다.

청킹 전략이 검색 품질을 좌우한다:

  • 너무 작은 청크: 문맥이 잘려 검색은 되는데 답이 불완전.
  • 너무 큰 청크: 한 청크에 관련 없는 내용이 섞여 잡음.
  • 권장: 의미 단위(섹션, 함수, 단락)로 청킹하고, 청크에 출처 메타데이터(문서명, 섹션)를 붙인다.

컨텍스트 배치: 검색된 문서는 공유 프리픽스로, 질문은 맨 뒤로 배치한다. 이렇게 해야 (1) lost in the middle을 피하고 (2) 프롬프트 캐싱이 먹는다(다음 섹션). 또한 검색 결과에 명시적 출처를 달면 모델이 "근거 없이 답하기"를 줄인다.

[검색된 문서들 — cache_control 여기]
  <doc source="manual.pdf" section="3.2">...</doc>
  <doc source="faq.md">...</doc>
[질문 — 매번 달라짐, 캐시 안 됨]

RAG가 답이 아닐 때도 있다. 지식 베이스가 작거나(수십 페이지) 질문이 전체 맥락을 요구하면, 전체를 컨텍스트에 넣고 프롬프트 캐싱으로 비용을 줄이는 게 RAG보다 단순하고 정확할 수 있다. RAG는 "검색이 신뢰성 있게 관련 청크를 골라낼 수 있을 때" 빛난다.

필터링 2 — 동적 도구 로딩과 점진적 공개(progressive disclosure)

도구가 많은 에이전트에서 모든 도구 정의를 항상 컨텍스트에 넣으면 두 가지 문제가 생긴다. 고정 토큰 비용과 도구 선택 정확도 하락(context confusion)이다. 도구가 100개인데 요청 하나에 관련된 건 3개뿐이라면, 나머지 97개의 스키마는 순수 잡음이다.

해법 1: Tool Search (동적 도구 발견). 모든 도구 스키마를 미리 컨텍스트에 넣는 대신, 모델이 도구 집합을 검색해서 관련 스키마만 로드하게 한다. 핵심 이점은 도구 정의가 교체(swap)가 아니라 추가(append)된다는 것 — 그래서 프롬프트 캐시가 깨지지 않는다(도구 정의는 컨텍스트 위치 0에 렌더되므로, 교체하면 전체 캐시가 무효화된다).

# 개념: 100개 도구를 모두 정의하는 대신
tools = [{ "type": "tool_search_tool_...", "name": "tool_search" }, ...핵심 도구 몇 개]
# 모델이 필요할 때 tool_search로 나머지를 발견 → 스키마가 append됨

해법 2: Skills (점진적 공개). 작업별 지시를 폴더(SKILL.md)로 패키징하고, 모델이 관련 있을 때만 전체를 읽게 한다. 스킬의 짧은 description만 기본으로 컨텍스트에 있고, 작업이 필요로 할 때 모델이 전체 파일을 읽는다. 이게 progressive disclosure의 핵심 — 짧은 description은 항상, 긴 본문은 필요할 때만.

skills/
  pdf-redline/
    SKILL.md   # description은 항상 컨텍스트, 본문은 on-demand
  excel-report/
    SKILL.md

두 패턴의 공통점은 고정 컨텍스트는 작게 유지하고, 디테일은 요구 시 로드한다는 것이다.

해법 3: Programmatic Tool Calling (스크립트로 도구 합성). 표준 도구 사용은 각 호출이 왕복이다 — 모델이 도구를 부르고, 결과가 컨텍스트에 들어오고, 모델이 추론하고, 다음 도구를 부른다. 세 번의 순차 호출(프로필 조회 → 주문 조회 → 재고 확인)이면 세 번의 왕복이고, 중간 데이터가 전부 컨텍스트에 남는다. PTC는 이를 코드 스크립트로 합성한다. 스크립트가 도구를 함수처럼 호출하고, 결과는 실행 컨테이너로 돌아가지 컨텍스트로 안 들어간다. 스크립트의 최종 출력만 모델 컨텍스트에 도달한다.

# 개념: 모델이 이런 스크립트를 작성하면, 중간 결과(orders, inventory)는
# 컨테이너에서만 처리되고 컨텍스트엔 final_summary만 들어감
orders = get_orders(user_id)            # 큰 결과 — 컨텍스트 안 들어감
low_stock = [check_inventory(o.sku) for o in orders if o.pending]
final_summary = summarize(low_stock)    # 이것만 컨텍스트로

언제 쓰나: 순차 도구 호출이 많거나, 중간 결과가 커서 컨텍스트에 들어가기 전에 필터링하고 싶을 때. 토큰 비용이 중간 결과가 아니라 최종 출력에 비례한다.

격리 — 서브에이전트와 스크래치패드로 컨텍스트 분리

압축·필터링이 "하나의 컨텍스트를 잘 관리하는" 일이라면, 격리는 "컨텍스트를 여러 개로 쪼개는" 일이다. 한 컨텍스트에 모든 작업을 욱여넣는 대신, 작업별로 분리된 컨텍스트를 둔다.

서브에이전트 패턴. 메인 에이전트가 깊은 탐색이 필요한 하위 작업을 서브에이전트에게 위임한다. 서브에이전트는 자기만의 깨끗한 컨텍스트에서 작업하고, 결과 요약만 메인 컨텍스트로 돌려준다. 핵심 이점:

  1. 메인 컨텍스트 오염 방지 — 서브에이전트가 50번 도구를 부르며 탐색해도, 메인은 최종 결론만 받는다. 탐색 과정의 잡음이 메인을 오염시키지 않는다.
  2. 병렬화 — 독립적인 하위 작업을 여러 서브에이전트가 동시에 수행.
  3. 모델 분리 — 서브 작업에 더 싼/빠른 모델을 써서 비용 절감.
메인 에이전트 (컨텍스트 A)
  ├─ "인증 모듈을 리뷰해" → 서브에이전트 1 (컨텍스트 B, 격리)
  │     20번 파일 읽기·grep → 메인엔 "3개 취약점 발견: ..."만 반환
  └─ "테스트 커버리지 확인" → 서브에이전트 2 (컨텍스트 C, 격리)

캐시 관점의 주의: 세션 중간에 모델을 바꾸면 캐시가 무효화된다. 그래서 "메인 루프는 한 모델로 유지하고, 서브 작업에만 다른(싼) 모델을 쓰는" 서브에이전트 분리가 캐시 친화적이다.

스크래치패드 패턴. 모델이 중간 사고·계획을 별도 공간(파일, 또는 구조화된 도구 호출)에 쓰게 한다. 이렇게 하면 긴 추론 과정이 메인 대화 히스토리를 부풀리지 않으면서도, 모델이 자기 계획을 참조할 수 있다. 메모리 도구와 결합하면 세션을 넘어 계획이 유지된다.

격리가 과한 경우: 작업이 단순하거나 순차적이면 서브에이전트는 오버헤드다. 단일 파일 읽기나 순차 작업은 직접 하는 게 낫다. 격리는 병렬적이거나 독립적인 워크스트림, 또는 탐색 과정이 메인을 오염시킬 위험이 큰 경우에 쓴다. 최신 모델은 위임을 선호하는 경향이 있어서, 오히려 "단순 작업은 직접 하라"는 가이드가 필요할 수 있다.

서브에이전트는 병렬적이거나 독립적인 워크스트림에만 사용하라.
단일 파일 읽기나 순차 작업은 직접 수행하라.

비용 레버 — 프롬프트 캐싱으로 컨텍스트 재사용

컨텍스트 엔지니어링은 정확도뿐 아니라 비용의 문제이기도 하다. 같은 큰 프리픽스(시스템 프롬프트, 도구, 검색 문서)를 매 요청마다 다시 처리하면 비용이 선형으로 늘어난다. 프롬프트 캐싱은 이걸 ~90%까지 줄인다 — 단, 컨텍스트를 캐시가 먹도록 설계해야 한다.

캐싱을 지배하는 단 하나의 불변식: 프롬프트 캐싱은 프리픽스 매치다. 프리픽스의 어느 바이트든 바뀌면 그 이후 전부가 무효화된다. 렌더 순서는 toolssystemmessages다.

그래서 컨텍스트를 안정성 순서로 배치하는 게 핵심이다.

[가장 안정적] tools (절대 안 바뀜)
              system (세션 내내 고정)
              검색된 문서 (요청별이지만 공유 가능)
[가장 휘발적] 질문/타임스탬프/요청 ID (매번 다름)
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    system=[{
        "type": "text",
        "text": LARGE_STABLE_PROMPT,
        "cache_control": {"type": "ephemeral"},  # 안정적 프리픽스 끝에 breakpoint
    }],
    messages=[{"role": "user", "content": question}],  # 휘발적 — 뒤에
)

조용한 캐시 무효화 범인들 (코드에서 grep해서 잡아라):

패턴왜 깨지나
시스템 프롬프트에 datetime.now() / Date.now()매 요청 프리픽스가 바뀜
콘텐츠 앞쪽에 uuid4() / 요청 ID매 요청이 유일해짐
json.dumps() without sort_keys=True비결정적 직렬화 → 바이트 다름
시스템 프롬프트에 유저 ID 보간유저별 프리픽스, 공유 안 됨
유저마다 도구 집합이 다름도구는 위치 0 → 캐시 전부 깨짐

캐시 히트 검증: usage.cache_read_input_tokens를 확인한다. 동일 프리픽스 반복 요청에서 이게 0이면 무효화 범인이 있다는 신호다. 두 요청의 렌더된 프롬프트 바이트를 diff해서 찾는다.

print(response.usage.cache_creation_input_tokens)  # 캐시에 쓴 토큰 (~1.25배 비용)
print(response.usage.cache_read_input_tokens)       # 캐시에서 읽은 토큰 (~0.1배)
print(response.usage.input_tokens)                  # 캐시 안 된 나머지 (정가)

동적 컨텍스트는 캐시를 깨지 않게 주입한다. 현재 날짜·모드·유저명을 시스템 프롬프트에 보간하지 말고 messages 뒤쪽에 넣어라. 시스템 프롬프트는 얼어붙은 상태(frozen) 로 유지하는 게 캐싱과 컨텍스트 엔지니어링 양쪽의 원칙이다.

조립 — 컨텍스트 어셈블리 파이프라인 설계하기

지금까지의 레버를 하나의 컨텍스트 빌더로 조립해보자. 실전에서는 매 요청마다 "무엇을 어떤 순서로 넣을지"를 결정하는 컨텍스트 어셈블리 함수를 둔다. 임시방편으로 여기저기서 문자열을 이어붙이는 대신, 한 곳에서 토큰 예산을 관리한다.

def assemble_context(query, *, token_budget=120_000):
    """우선순위 + 토큰 예산으로 컨텍스트를 조립."""
    blocks = []  # (우선순위, 안정성, 토큰수, 콘텐츠)

    # 1. 안정적·고정: 시스템 프롬프트, 도구 (항상 포함, 캐시됨)
    blocks.append((0, "stable", count(SYSTEM), SYSTEM))

    # 2. 메모리에서 관련 항목 로드 (윈도 밖 → 안으로)
    mem = load_relevant_memory(query)
    if mem:
        blocks.append((1, "session", count(mem), mem))

    # 3. 검색 — recall 넓게 → rerank 정밀하게
    docs = rerank(query, vector_search(query, top_k=30))

    # 4. 남은 예산 안에서 검색 문서 채우기
    used = sum(b[2] for b in blocks)
    for doc in docs:
        dt = count(doc.text)
        if used + dt > token_budget:
            break
        blocks.append((2, "per-request", dt, doc.text))
        used += dt

    # 5. 안정성 순으로 정렬 (캐시 친화 + lost-in-middle 회피)
    #    stable → session → per-request → 질문(맨 뒤)
    blocks.sort(key=lambda b: b[0])
    return blocks, used

설계 체크리스트 — 컨텍스트 파이프라인을 리뷰할 때:

  1. 측정했는가? 각 구성요소의 토큰을 count_tokens로 쟀는가 (tiktoken 아님).
  2. 예산이 있는가? 윈도 전체가 아니라 유효 작업 예산을 정했는가.
  3. 안정성 순서인가? 안정적 콘텐츠가 휘발적 콘텐츠보다 앞에 오는가 (캐시·lost-in-middle).
  4. 검색 품질을 보장하는가? recall(넓게) + precision(rerank) 2단계인가.
  5. 압축 경로가 있는가? 대화가 길어지면 compaction/editing이 작동하는가.
  6. 메모리 분리되는가? 세션을 넘는 상태가 윈도가 아니라 영속 저장소에 있는가.
  7. 캐시 무효화 범인이 없는가? 시스템 프롬프트에 타임스탬프/UUID/유저ID가 없는가.
  8. 격리가 적절한가? 탐색 과정이 메인을 오염시킬 작업을 서브에이전트로 분리했는가.

가장 흔한 안티패턴은 "일단 다 넣고 모델이 알아서 하겠지" 다. 컨텍스트 엔지니어링은 그 반대다 — 들어가는 모든 토큰을 의도적으로 선택하고, 정답 확률에 기여하지 않는 토큰은 제거한다. 이 규율 하나가 동일 모델·동일 비용에서 정확도를 끌어올린다.

마지막 원칙: 측정 가능하게 반복하라. 컨텍스트 변경(청크 크기, top-k, 압축 임계치, 프롬프트 고도)의 효과는 직관이 아니라 평가셋(eval set)으로 검증한다. 같은 질문 집합에 대해 변경 전후의 정확도·토큰·지연을 비교하고, 정확도를 떨어뜨리지 않으면서 토큰을 줄이는 방향으로 수렴시킨다. 컨텍스트 엔지니어링은 한 번 하고 끝나는 설정이 아니라, eval로 닫는 루프다.