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

Lab 08 · 약 20분

배열 디버깅

1 개념

원문: 12-debugging-the-array.md

무엇이 깨지나

에이전트가 이상하게 굴 때 '모델이 실제로 본 것'을 모른다.

패치

모든 요청·응답을 JSONL 로 캡처, usage 로 캐시 적중률(활력징후) 계산, 이전/이번 요청의 prefix 가 어디서 갈라졌는지 비교.

비용

캡처 파일엔 비밀이 들어갈 수 있다. 커밋 금지.

할 일 (exercise.py)

python run_tests.py 8 -v

생각해볼 것

2 따라 해보기

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

예제 1 블랙박스 녹화: 실제로 보낸 것을 남겨라

비유 — 사고가 나면 블랙박스부터 봅니다. 에이전트가 이상하게 굴 때도 "모델이 실제로 받은 배열"을 보면 원인이 대부분 보입니다.

세 번째 턴 직전에 누군가 첫 메시지를 고쳤습니다(예: 시스템이 날짜를 끼워 넣음). 요약표가 prefix 깨짐을 바로 잡아냅니다.

import os
import tempfile
import solution as lab
from common.brain import FakeBrain

path = os.path.join(tempfile.mkdtemp(), "capture.jsonl")
brain = lab.CapturingBrain(FakeBrain(), path)

messages = []
for said in ["안녕", "규정 알려줘"]:
    messages.append({"role": "user", "content": said})
    messages.append({"role": "assistant", "content": brain({"messages": messages})["content"]})

messages[0]["content"] = "[2026-10-20] 안녕"          # 😱 앞부분을 몰래 수정
messages.append({"role": "user", "content": "고마워"})
brain({"messages": messages})

lab.summarize(path)
실행 결과
#  1  msgs=  1  ~    11 tok  cache=   0%
#  2  msgs=  3  ~    68 tok  cache=   0%
#  3  msgs=  5  ~   131 tok  cache=   0%  ⚠️ prefix 깨짐 @ 0

💡 바꿔 보기: 첫 메시지를 고치는 줄을 지우면 경고가 사라집니다.

예제 2 캐시 활력징후 읽기

API 응답의 usage 에는 캐시에서 읽은 토큰 수가 들어 있습니다. 평소 90% 근처이던 적중률이 갑자기 0% 가 되면 → 누군가 앞부분을 바꾼 겁니다.

import solution as lab

timeline = [
    ("1턴 (첫 요청)", {"input_tokens": 50, "cache_creation_input_tokens": 9_000}),
    ("2턴",          {"input_tokens": 40, "cache_read_input_tokens": 9_000, "cache_creation_input_tokens": 600}),
    ("3턴",          {"input_tokens": 40, "cache_read_input_tokens": 9_600, "cache_creation_input_tokens": 500}),
    ("4턴 ⚠️",       {"input_tokens": 40, "cache_creation_input_tokens": 10_600}),
]
for label, usage in timeline:
    r = lab.cache_hit_ratio(usage)
    print(f"{label:<14} 적중률 {r:5.0%}  {'█' * round(r * 20)}")
실행 결과
1턴 (첫 요청)      적중률    0%  
2턴             적중률   93%  ███████████████████
3턴             적중률   95%  ███████████████████
4턴 ⚠️          적중률    0%

예제 3 범인 찾기: 어디서부터 달라졌나

first_divergence 는 이전 요청과 이번 요청을 앞에서부터 비교해 처음 달라진 위치를 알려줍니다. None 이면 끝에 덧붙이기만 한 것(캐시 친화적)입니다.

import solution as lab

base = [{"role": "user", "content": "시작"},
        {"role": "assistant", "content": [{"type": "text", "text": "네"}]}]
cases = {
    "끝에 덧붙이기만": base + [{"role": "user", "content": "다음"}],
    "옛 결과 요약으로 교체": [base[0], {"role": "assistant", "content": [{"type": "text", "text": "[요약]"}]}],
    "첫 메시지에 날짜 삽입": [{"role": "user", "content": "[오늘] 시작"}] + base[1:],
}
for name, curr in cases.items():
    print(f"{name:<14} → 처음 달라진 위치: {lab.first_divergence(base, curr)}")
실행 결과
끝에 덧붙이기만       → 처음 달라진 위치: None
옛 결과 요약으로 교체   → 처음 달라진 위치: 1
첫 메시지에 날짜 삽입   → 처음 달라진 위치: 0

3 확인 퀴즈

Q1 cache_read 80, input 10, cache_creation 10 일 때 캐시 적중률은?

Q2 prefix 비교 시 무시해야 하는 것은?

4 실습: exercise.py 채우기

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

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 8 — 배열 디버깅: 보이지 않는 프롬프트는 고칠 수 없다.

에이전트가 이상하게 굴면 원인은 거의 항상 '실제로 보낸 배열' 안에 있습니다.
1. 캡처   : 매 요청/응답을 JSONL 로 그대로 기록 (HARNESS_DEBUG=1)
2. 캐시 활력징후: usage 에서 캐시 적중률을 계산 → 갑자기 0 이 되면 prefix 가 깨진 것
3. prefix 비교: 이전 요청과 이번 요청이 '어디서부터' 달라졌는지 찾기

실행:  python labs/08_debugging/solution.py debug.jsonl   (캡처 파일 요약)
"""
import json
import os
import sys
import time


class CapturingBrain:
    """두뇌를 감싸서 모든 왕복을 path(JSONL)에 한 줄씩 기록한다."""

    def __init__(self, brain, path="harness_debug.jsonl"):
        self.brain, self.path, self.n = brain, path, 0

    def __call__(self, request):
        # >>> TODO: 원래 두뇌를 호출하고, {"n", "t", "request", "response"} 한 줄을 path 에 append 하세요
        response = self.brain(request)
        self.n += 1
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(json.dumps({"n": self.n, "t": time.time(),
                                "request": request, "response": response},
                               ensure_ascii=False) + "\n")
        return response
        # <<< TODO


def cache_hit_ratio(usage):
    """캐시에서 읽은 입력 토큰 비율 = cache_read / (input + cache_read + cache_creation).
    분모가 0 이면 0.0."""
    # >>> TODO: usage dict 의 키가 없으면 0 으로 취급하세요
    read = usage.get("cache_read_input_tokens", 0) or 0
    total = (usage.get("input_tokens", 0) or 0) + read + (usage.get("cache_creation_input_tokens", 0) or 0)
    return read / total if total else 0.0
    # <<< TODO


def first_divergence(prev_messages, curr_messages):
    """이전 요청의 배열이 이번 요청 배열의 '앞부분'으로 그대로 남아 있으면 None.
    아니면 처음으로 달라진 메시지 인덱스를 반환한다. (None = 캐시 친화적)
    주의: cache_control 표시는 비교에서 무시한다."""
    # >>> TODO: cache_control 을 지운 사본끼리 앞에서부터 비교하세요
    def clean(m):
        m = json.loads(json.dumps(m))
        if isinstance(m.get("content"), list):
            for b in m["content"]:
                b.pop("cache_control", None)
        return m
    for i, pm in enumerate(prev_messages):
        if i >= len(curr_messages) or clean(pm) != clean(curr_messages[i]):
            return i
    return None
    # <<< TODO


def summarize(path):
    """캡처 파일을 읽어 요청마다 메시지 수, 추정 크기, 캐시율, prefix 깨짐 여부를 출력."""
    prev = None
    for line in open(path, encoding="utf-8"):
        rec = json.loads(line)
        msgs = rec["request"]["messages"]
        div = first_divergence(prev, msgs) if prev is not None else None
        ratio = cache_hit_ratio(rec["response"].get("usage", {}))
        flag = f"  ⚠️ prefix 깨짐 @ {div}" if div is not None else ""
        print(f"#{rec['n']:>3}  msgs={len(msgs):>3}  ~{len(json.dumps(msgs)) // 4:>6} tok"
              f"  cache={ratio:5.0%}{flag}")
        prev = msgs


if __name__ == "__main__":
    if len(sys.argv) < 2 or not os.path.exists(sys.argv[1]):
        print("사용법: python labs/08_debugging/solution.py <capture.jsonl>")
    else:
        summarize(sys.argv[1])
채점 테스트 보기 (test_lab.py)
import json
import os
import sys
import tempfile
import unittest

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

lab = load_target(__file__)
ELIDED = "[이전 도구 결과 생략됨]"


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


class Lab8(unittest.TestCase):
    def test_capture(self):
        with tempfile.TemporaryDirectory() as d:
            p = os.path.join(d, "cap.jsonl")
            b = lab.CapturingBrain(ScriptedBrain([reply(text("a")), reply(text("b"))]), p)
            r1 = b({"messages": [{"role": "user", "content": "1"}]})
            b({"messages": [{"role": "user", "content": "2"}]})
            self.assertEqual(r1["content"][0]["text"], "a")
            lines = [json.loads(x) for x in open(p, encoding="utf-8")]
            self.assertEqual(len(lines), 2)
            self.assertEqual(lines[1]["request"]["messages"][0]["content"], "2")
            self.assertEqual(lines[0]["response"]["content"][0]["text"], "a")

    def test_cache_ratio(self):
        self.assertEqual(lab.cache_hit_ratio({}), 0.0)
        self.assertAlmostEqual(lab.cache_hit_ratio(
            {"input_tokens": 10, "cache_read_input_tokens": 80,
             "cache_creation_input_tokens": 10}), 0.8)

    def test_append_only_is_cache_friendly(self):
        prev = [{"role": "user", "content": "go"},
                {"role": "user", "content": [tr(1, "big")]}]
        curr = prev + [{"role": "user", "content": [tr(2, "more")]}]
        self.assertIsNone(lab.first_divergence(prev, curr))

    def test_cache_control_ignored(self):
        prev = [{"role": "user", "content": [{"type": "text", "text": "hi",
                                              "cache_control": {"type": "ephemeral"}}]}]
        curr = [{"role": "user", "content": [{"type": "text", "text": "hi"}]},
                {"role": "assistant", "content": "x"}]
        self.assertIsNone(lab.first_divergence(prev, curr))

    def test_eliding_breaks_prefix(self):
        """Lab 4의 elide 를 쓰면 공간은 생기지만 캐시가 깨진다는 걸 눈으로 확인."""
        prev = [{"role": "user", "content": "go"},
                {"role": "user", "content": [tr(1, "big")]},
                {"role": "user", "content": [tr(2, "big2")]}]
        curr = [{"role": "user", "content": "go"},
                {"role": "user", "content": [tr(1, ELIDED)]},
                {"role": "user", "content": [tr(2, "big2")]},
                {"role": "user", "content": [tr(3, "new")]}]
        self.assertEqual(lab.first_divergence(prev, curr), 1)


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

📚 더 읽을거리

교재 전체 목록 →