하네스 워크북 Lab 04 교재 0/11

Lab 04 · 약 30분

컨텍스트는 예산, 캐싱은 순서

1 개념

원문: 06-context-is-a-budget.md

무엇이 깨지나

배열이 비싸지고(매번 전체 전송), 결국 컨텍스트 창이 가득 찬다.

패치

(1) 큰 도구 결과는 들어오는 순간 자른다. (2) 배열을 append-only 로 다루고 마지막 블록에 cache_control 을 붙여 prefix 를 캐시한다. (3) 프로젝트 메모리(AGENT.md)는 시스템 프롬프트에 고정으로.

비용

옛 결과를 지우면(elide_old_tool_results) 공간은 생기지만 prefix 가 바뀌어 캐시가 깨진다 — Lab 8 에서 눈으로 확인.

할 일 (exercise.py)

python run_tests.py 4 -v

생각해볼 것

2 따라 해보기

비유 → 짧은 코드 → 실제 출력. 아래 출력은 미리 실행해 둔 결과이고, ▶ 실행을 누르면 브라우저 안 파이썬으로 다시 돌립니다(첫 실행 때 약 10MB 내려받음).

예제 1 로그 2만 줄을 통째로 넣으면?

cat server.log 결과가 2만 줄이면 그대로 배열에 들어가 컨텍스트를 다 먹어 버립니다. 그래서 결과는 들어오는 순간 자릅니다.

앞뒤를 남기는 이유: 에러는 보통 맨 끝에 있고, 파일의 성격은 맨 앞에서 드러나기 때문입니다.

import solution as lab

log = "\n".join(f"line {i}: OK" for i in range(1, 20_001)) + "\nline 20001: ERROR disk full"
cut = lab.truncate(log, limit=160)

print(f"원본 {len(log):,}자 (~{len(log)//4:,} 토큰) → 자른 뒤 {len(cut)}자")
print("── 모델이 보게 될 내용 ──")
print(cut)
실행 결과
원본 288,921자 (~72,230 토큰) → 자른 뒤 192자
── 모델이 보게 될 내용 ──
line 1: OK
line 2: OK
line 3: OK
line 4: OK
line 5: OK
line 6: OK
line 7: OK
lin
...[288761 chars truncated]...
997: OK
line 19998: OK
line 19999: OK
line 20000: OK
line 20001: ERROR disk full

💡 바꿔 보기: limit 을 400 으로 늘리면 무엇이 더 보이나요?

예제 2 캐시는 '앞부분이 같을 때'만 — 10라운드 비용 계산

비유 — 책을 매번 처음부터 읽는 대신 책갈피를 꽂아 두면, 이미 읽은 부분은 훑기만 하면 됩니다. 단, 앞 페이지가 한 글자라도 바뀌면 책갈피는 무효입니다.

Anthropic 문서 기준으로 캐시 쓰기는 1.25배, 읽기는 0.1배 요금입니다. 시스템 프롬프트+도구 1만 토큰에 라운드마다 2천 토큰씩 늘어나는 에이전트를 계산해 봅시다.

BASE, GROW, ROUNDS = 10_000, 2_000, 10
no_cache = with_cache = 0
for r in range(ROUNDS):
    total = BASE + GROW * r                     # 이번 요청의 입력 토큰
    no_cache += total                           # 캐시 없음: 매번 전부 정가
    if r == 0:
        with_cache += total * 1.25              # 첫 요청: 전부 캐시에 쓰기
    else:
        prev = BASE + GROW * (r - 1)
        with_cache += prev * 0.1 + (total - prev) * 1.25   # 앞부분 읽기 + 새 부분 쓰기
    print(f"라운드 {r+1:>2}: 입력 {total:>6,} 토큰")
print(f"\n캐시 없음 : {no_cache:>9,.0f} 토큰 요금")
print(f"캐시 사용 : {with_cache:>9,.0f} 토큰 요금  (약 {1 - with_cache/no_cache:.0%} 절약)")
실행 결과
라운드  1: 입력 10,000 토큰
라운드  2: 입력 12,000 토큰
라운드  3: 입력 14,000 토큰
라운드  4: 입력 16,000 토큰
라운드  5: 입력 18,000 토큰
라운드  6: 입력 20,000 토큰
라운드  7: 입력 22,000 토큰
라운드  8: 입력 24,000 토큰
라운드  9: 입력 26,000 토큰
라운드 10: 입력 28,000 토큰

캐시 없음 :   190,000 토큰 요금
캐시 사용 :    51,200 토큰 요금  (약 73% 절약)

💡 바꿔 보기: ROUNDS 를 30 으로 늘리면 절감률이 어떻게 되나요?

예제 3 책갈피(cache_control)는 맨 끝에

하네스는 요청을 보낼 때마다 마지막 블록에 cache_control 을 붙입니다. 그러면 "여기까지는 다음에 다시 쓸 거예요"라는 표시가 됩니다. 원본 배열은 건드리지 않고 사본에만 붙이는 게 핵심입니다.

import solution as lab
from common.show import show

messages = [
    {"role": "user", "content": "규정 문서 요약해 줘"},
    {"role": "assistant", "content": [{"type": "text", "text": "목록부터 볼게요."},
                                      {"type": "tool_use", "id": "t1", "name": "run_command",
                                       "input": {"command": "ls docs"}}]},
    {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "01.md 02.md"}]},
]
sent = lab.with_cache_breakpoint({"messages": messages})
show(sent["messages"], "보낸 사본 (📌 = cache_control)")
show(messages, "원본 배열 (그대로)")
실행 결과
── 보낸 사본 (📌 = cache_control) ──
[0] user      "규정 문서 요약해 줘"
[1] assistant text "목록부터 볼게요."
              tool_use[t1] run_command({"command": "ls docs"})
[2] user      tool_result[t1] "01.md 02.md" 📌

── 원본 배열 (그대로) ──
[0] user      "규정 문서 요약해 줘"
[1] assistant text "목록부터 볼게요."
              tool_use[t1] run_command({"command": "ls docs"})
[2] user      tool_result[t1] "01.md 02.md"

예제 4 공간 vs 비용: 옛 도구 결과 지우기

창이 꽉 차면 오래된 도구 결과를 [생략] 으로 바꿔 공간을 만들 수 있습니다. 하지만 앞부분이 바뀌므로 캐시가 깨집니다. 공짜 점심은 없습니다 — Lab 8 에서 이 깨짐을 탐지합니다.

import solution as lab
from common.show import show

def result(i, body):
    return {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": body}]}

messages = [{"role": "user", "content": "로그 분석해 줘"},
            result(1, "로그 A 3만 자…"), result(2, "로그 B 2만 자…"),
            result(3, "로그 C 1만 자…"), result(4, "방금 읽은 로그 D")]
show(lab.elide_old_tool_results(messages, keep_last=2), "keep_last=2 로 정리한 배열")
실행 결과
── keep_last=2 로 정리한 배열 ──
[0] user      "로그 분석해 줘"
[1] user      tool_result[t1] "[이전 도구 결과 생략됨]"
[2] user      tool_result[t2] "[이전 도구 결과 생략됨]"
[3] user      tool_result[t3] "로그 C 1만 자…"
[4] user      tool_result[t4] "방금 읽은 로그 D"

3 확인 퀴즈

Q1 캐시 친화적인 배열 조작은?

Q2 cache_control 표시는 어디에 두나요?

4 실습: exercise.py 채우기

TODO 표시 부분을 채우고 채점하세요. 입력 내용은 이 기기에 자동 저장됩니다.

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 4 — 컨텍스트는 '예산'이다 + 캐싱은 '순서'에 달려 있다.

배열을 매번 통째로 보내니 (1) 비용이 쌓이고 (2) 창(window)이 가득 찹니다.
- 캐싱: 앞부분(prefix)이 이전 요청과 '바이트 단위로 같으면' 싸게 재사용됩니다.
        그래서 배열은 append-only 로 다뤄야 하고, 캐시 표시는 맨 끝에 둡니다.
- 예산: 큰 도구 결과는 '들어오는 시점에' 잘라야 합니다. 나중에 옛 결과를 지우면
        공간은 생기지만 prefix 가 바뀌어 캐시가 깨집니다 (비용 ↔ 공간 트레이드오프).
"""
import copy
import json
import os
import sys

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))

MAX_RESULT_CHARS = 2_000
ELIDED = "[이전 도구 결과 생략됨]"


def truncate(text, limit=MAX_RESULT_CHARS):
    """limit 보다 길면 앞 절반 + 표시 + 뒤 절반만 남긴다.
    표시 형식: "\\n...[N chars truncated]...\\n"  (N = 잘라낸 글자 수)
    짧으면 그대로 반환."""
    # >>> TODO: 앞/뒤를 limit//2 씩 남기고 가운데를 표시로 바꾸세요
    if len(text) <= limit:
        return text
    half = limit // 2
    cut = len(text) - 2 * half
    return f"{text[:half]}\n...[{cut} chars truncated]...\n{text[-half:]}"
    # <<< TODO


def estimate_tokens(obj):
    """아주 거친 추정: JSON 글자 수 / 4. (정확한 값은 API 응답의 usage 로 확인)"""
    return len(json.dumps(obj, ensure_ascii=False)) // 4


def with_cache_breakpoint(request):
    """요청의 '사본'을 만들어 마지막 메시지의 마지막 블록에
    "cache_control": {"type": "ephemeral"} 를 붙여 반환한다.
    - 마지막 메시지 content 가 문자열이면 [{"type":"text","text":...}] 로 바꿔서 붙인다.
    - 원본 request/messages 는 절대 바꾸지 않는다 (append-only 배열 보호)."""
    # >>> TODO: deepcopy 후 마지막 블록에 cache_control 을 붙여 반환하세요
    req = copy.deepcopy(request)
    if not req.get("messages"):
        return req
    last = req["messages"][-1]
    if isinstance(last["content"], str):
        last["content"] = [{"type": "text", "text": last["content"]}]
    last["content"][-1]["cache_control"] = {"type": "ephemeral"}
    return req
    # <<< TODO


def elide_old_tool_results(messages, keep_last=2):
    """마지막 keep_last 개를 제외한 옛 tool_result 의 content 를 ELIDED 로 바꾼 '새 배열'을 반환.
    (원본은 그대로. 이 함수를 쓰면 공간은 생기지만 캐시 prefix 가 깨진다는 걸 Lab 8에서 확인합니다)"""
    # >>> TODO: 모든 tool_result 블록 위치를 모은 뒤, 뒤에서 keep_last 개를 빼고 내용을 바꾸세요
    out = copy.deepcopy(messages)
    spots = [b for m in out if isinstance(m["content"], list)
             for b in m["content"] if b.get("type") == "tool_result"]
    for b in spots[:max(len(spots) - keep_last, 0)]:
        b["content"] = ELIDED
    return out
    # <<< TODO


def load_memory(path="AGENT.md"):
    """프로젝트 메모리 파일이 있으면 시스템 프롬프트에 붙일 문자열로, 없으면 ""."""
    if not os.path.exists(path):
        return ""
    with open(path, encoding="utf-8") as f:
        return f"\n\n<project-memory path={path!r}>\n{f.read()}\n</project-memory>"


if __name__ == "__main__":
    big = "x" * 10_000
    print("원본", len(big), "→ 자른 뒤", len(truncate(big)))
    req = {"system": "s", "messages": [{"role": "user", "content": "hi"}]}
    print(json.dumps(with_cache_breakpoint(req), ensure_ascii=False, indent=1))
채점 테스트 보기 (test_lab.py)
import copy
import os
import sys
import unittest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.labkit import load_target  # noqa: E402

lab = load_target(__file__)


def tr(i, content="결과"):
    return {"type": "tool_result", "tool_use_id": f"t{i}", "content": content}


class Lab4(unittest.TestCase):
    def test_truncate_short_untouched(self):
        self.assertEqual(lab.truncate("abc", 10), "abc")

    def test_truncate_long(self):
        s = "A" * 50 + "B" * 50
        out = lab.truncate(s, 20)
        self.assertTrue(out.startswith("A" * 10))
        self.assertTrue(out.endswith("B" * 10))
        self.assertIn("[80 chars truncated]", out)

    def test_cache_breakpoint_on_last_block(self):
        req = {"system": "s", "messages": [
            {"role": "user", "content": "첫 질문"},
            {"role": "assistant", "content": [{"type": "text", "text": "답"}]},
            {"role": "user", "content": [tr(1), tr(2)]}]}
        before = copy.deepcopy(req)
        out = lab.with_cache_breakpoint(req)
        self.assertEqual(out["messages"][-1]["content"][-1]["cache_control"], {"type": "ephemeral"})
        self.assertNotIn("cache_control", out["messages"][-1]["content"][0])
        self.assertEqual(req, before, "원본 요청을 바꾸면 안 됩니다 (append-only!)")

    def test_cache_breakpoint_string_content(self):
        out = lab.with_cache_breakpoint({"messages": [{"role": "user", "content": "hi"}]})
        blk = out["messages"][0]["content"][0]
        self.assertEqual(blk["text"], "hi")
        self.assertIn("cache_control", blk)

    def test_elide_keeps_recent(self):
        msgs = [{"role": "user", "content": "go"},
                {"role": "user", "content": [tr(1, "old1")]},
                {"role": "user", "content": [tr(2, "old2"), tr(3, "new3")]},
                {"role": "user", "content": [tr(4, "new4")]}]
        before = copy.deepcopy(msgs)
        out = lab.elide_old_tool_results(msgs, keep_last=2)
        flat = [b["content"] for m in out if isinstance(m["content"], list) for b in m["content"]]
        self.assertEqual(flat, [lab.ELIDED, lab.ELIDED, "new3", "new4"])
        self.assertEqual(msgs, before, "새 배열을 돌려줘야 합니다")


if __name__ == "__main__":
    unittest.main()

📚 더 읽을거리

교재 전체 목록 →