정답 코드 보기 (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()