~15 min · prompt-caching, master-chief, tool-use, observability
Level 0구경꾼
0 XP0/44 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete
하루에 열세 번 다시 쓰기
레슨 2는 수리 하나로 끝났어. 2026-10-18에 Chief의 문 줄이 시스템 프롬프트에서 빠졌지. 문이 바뀔 때마다 캐시된 프롬프트 330K~415K 토큰을 다시 쓰고 있었으니까. 수리는 맞았어. 근데 그걸로 끝이 아니었어. 2026-10-22에 Master Chief 리뷰(Coop todo #597)가 Chief의 기준 기록을 복호화해서 셌더니, 첫 요청이 280K~500K 토큰을 캐시에 다시 쓴 턴이 하루에 열세 번쯤이었어. 다시 쓰면 프롬프트 전체에 읽기 값이 아니라 쓰기 값을 내. 그리고 아빠는 그 쓰기가 끝날 때까지 답의 첫 글자를 기다려. 열세 번 대부분은 레슨 2에서 고친 버그랑 모양이 같았어. 바로 앞 턴과 다른 문으로 들어온 턴이었던 거야.
리뷰는 이걸 힘들게 알아냈어. 턴마다 prefix가 뭐였는지 적어 둔 데가 없었거든. 그래서 기록을 복호화하고, 청구서에서 거꾸로 짚어 가며 원인을 댔어.
문이 아직 prefix에 닿던 다섯 갈래
Claude 요청은 내건 도구로 시작해. 그다음 시스템 프롬프트, 그다음 히스토리 재생. 프로바이더 캐시는 이 prefix를 바이트 단위로 맞춰 봐. origin 줄은 거기서 빠졌어. 근데 다섯 가지가 아직 문을 따라 들어오고 있었어.
달랐던 것
어떻게
도구 목록
WebUI는 브레인별 토글 목록을, Pippa 앱은 Bash까지 든 전체를, Telegram은 Bash 빠진 전체를 보냈어. 이걸 고친 함수의 docstring에 따르면 리뷰가 잰 다시 쓰기 41번 중 10번이 여기서 나왔어.
앱 문의 호스트
형제 앱 안에서 온 턴은 그 앱의 호스트 도구를 도구 블록에, 그 앱의 규칙을 시스템 프롬프트에 얹었어.
클라이언트가 보낸 덧붙임
그 클라이언트 턴에서만 시스템 프롬프트에 붙었어.
입력창의 추론 수준
WebUI에서 medium으로 보낸 턴은 시스템 프롬프트 첫 줄들인 베셀 메타에 medium이라고 적었어.
참조한 아티팩트
그걸 부른 턴 하나에만 system 필드에 붙어서, 다음 턴이 prefix를 또 다시 썼어.
다섯 중 어느 것도 따로 보면 실수가 아니야. 문마다 그걸 보낼 이유가 있었어. 실수는 따로 있었어. 모든 문이 같이 쓰는 대화인데, prefix를 문마다 제멋대로 정하게 둔 거.
도구 목록 하나, 시스템 프롬프트 하나
고친 건 같은 날이야 (커밋 a5605c92, 리뷰 M1 지적). 이제 prefix는 문이 아니라 Chief가 정해.
도구 목록은 하나.chief_tool_posture가 모든 Chief 턴에 Chief의 전체 표면을 줘. 도구는 켜고, Bash도 켜고, 허용 목록은 없어. 앱 호스트 도구는 어느 문에서 오든 얹혀. 문마다 따로 고르던 도구 토글은 이제 Chief를 좁히지 못해. Pippa 앱은 애초에 그걸 안 보냈으니까, 거기선 원래부터 안 먹혔던 거야. 도구를 아예 끈 턴은 꺼진 채로 둬.
제한은 호출 자리로. 문에서 Bash를 대놓고 끈 스위치(Telegram의 /bash. 기본은 꺼져 있어)는 여전히 먹혀. 다만 이제는 도구 목록을 줄이는 대신 CallGate가 막아. 모델이 Bash를 부르는 순간 거절돼. Claude 어댑터의 PreToolUse 훅도, 다른 베셀의 도구 다리도 전부 call_refusal에 물어. 거절 문구는 대신 뭘 하면 되는지까지 알려 줘.
시스템 프롬프트도 하나. Chief 덧붙임만 실려. 클라이언트 덧붙임, 앱 문의 규칙, Coop 에스컬레이션 브리프는 턴 노트(TurnState.notes)가 되고, 그 턴 컨텍스트에서 origin 줄 다음에 붙어.
추론 수준은 기본값. Chief 턴에선 베셀 메타에 베셀의 기본 추론 수준이 적혀 (chief_reasoning). 턴이 다른 수준으로 돌면 그 턴 컨텍스트의 한 문장이 그렇다고 말하고, 그 턴이 남기는 행동 기록엔 실제 수준이 실려. 다만 이건 시스템 프롬프트 글자에서 수준을 뺀 거지, 요청에서 뺀 게 아냐. 실제 수준은 여전히 그 턴의 effort 설정으로 Claude SDK에 가. 프로바이더 캐시는 effort가 바뀌면 적어도 히스토리부터는 프롬프트가 바뀐 걸로 쳐 (모델에 따라선 맨 앞부터). 그래서 high 턴들 사이에 WebUI에서 medium으로 보낸 턴은 재생 히스토리를 아직도 다시 써.
아티팩트는 메시지에. 참조한 아티팩트는 Chief든 아니든 모든 대화에서 지금 메시지에 타.
두 번째 항목이 뭘 맞바꿨는지 봐. 이제 모델 눈엔 이 문에서 거절될 도구가 보이고, 써 보려 할 때에야 알게 돼. 가끔 거절당하는 호출 한 번이 값이야. 줄인 목록의 값은 문이 바뀔 때마다 수십만 토큰짜리 프롬프트를 통째로 다시 쓰는 거였고. 도구를 내거는 건 prefix에 들어가지만, 거절은 prefix에 안 들어가.
찍지 말고 재
나머지 반쪽(m6 지적, 51b5cdd3)은 다음 리뷰가 필요 없게 만들었어. services/prompt_cache_watch.py가 Claude 턴마다 prefix 지문을 떠. 지문엔 도구 블록(뒤에 붙은 MCP 서버까지)과 시스템 프롬프트의 해시, 그리고 모델, 추론 수준, thinking, fast 모드가 들어가. 지문은 그 턴의 첫 pippa_usage 이벤트에 실려 기록에 남아. 첫 요청이 50K 토큰 이상을 캐시에 쓰면, 감시자가 같은 대화의 앞 턴 지문과 비교해서 [turn-provider] 줄에 원인을 적어.
rewrite=tools,system (또는 model, effort, thinking, fast): prefix의 그 부분이 바뀌었어.
ttl: 바뀐 건 없는데 앞 턴이 한 시간보다 오래됐어. 캐시 항목이 만료된 거야.
replay: 한 시간 안인데 바뀐 게 없어. 그럼 히스토리 자체가 움직인 거야 (압축, 편집, healing으로 되살린 행).
first-seen: 서버가 뜬 뒤로 이 대화의 앞 턴이 없어.
Admin → Health가 오늘의 다시 쓰기를 원인별, 대화별로 보여 줘. 숫자는 메모리에 살아서 재시작하면 처음부터 다시 세고, Health 줄도 그렇다고 적어.
일부러 남긴 다시 쓰기
prefix가 하나라고 절대 안 움직인다는 뜻은 아냐. 설계 문서는 일부러 다시 쓰는 경우를 적어 둬. 한 시간 조용하다가 온 첫 턴(캐시 항목의 수명), 압축, 프롬프트에 들어가는 볼트 파일 수정(엔진이 5분 안에 다시 지어), 다른 모델, thinking 전환, 한 턴에만 붙는 look 도구나 On Air 효과, 그리고 목소리가 아빠가 아닌 드문 음성 턴. 마지막 건 행동 도구가 빠지거든. 목록에 빠진 게 하나 더 있어. 앞 턴과 추론 수준이 다른 턴이야. 실제 수준은 여전히 요청에 실리니까. 이것들은 전부 턴이 뭔지, 뭘 할 수 있는지를 바꿔. 문만 바뀌는 건 둘 다 안 바꿔. 그래서 이제 문은 prefix를 못 건드려. 다른 추론 수준까지 같이 보내는 문이면, 그 수준 값은 여전히 치러.
같이 쓰는 대화에서 prefix는 대화의 거야. 문이나 턴이 할 말은 캐시 중단점 뒤, 그 턴 컨텍스트에서 해. 문이 막아야 하는 건 호출 자리에서 거절해. 그리고 턴마다 자기 prefix가 뭐였는지 남겨 둬. 그래야 다음 다시 쓰기가 제 원인을 스스로 대.
Code
원문, services/master_chief.py, 2026-10-27 기준: Chief 턴은 전부 Chief의 표면을 받고, 문의 Bash 스위치는 호출 자리의 게이트가 돼·python
def chief_tool_posture(payload: dict) -> CallGate | None:
"""Give a Chief turn the Chief's own tool surface; return what the
doorway's switches still hold back, as a call gate.
Each doorway sent its own tool wire (the WebUI its per-brain toggle list,
the Pippa app the whole surface with Bash, Telegram the whole surface
without it), so the tool block, the first bytes of every Claude request,
changed with the doorway, and every switch between devices re-wrote the
Chief's whole cached prompt (10 of the 41 rewrites the review measured).
The Chief now advertises one list from every doorway: the whole surface,
Bash included, with the app's host tools (``merge_sidekick_tool_allowlist``).
A doorway's per-tool toggles do not narrow the Chief (the app never sent
them, so they never held there either); a doorway's explicit Bash switch
(Telegram's ``/bash``, off by default) still holds, at the call. A turn
with tools switched off entirely keeps that: no gate, no change."""
from services.tool_policy import TOOL_BASH
if payload.get("tools_enabled") is False:
return None
bash_off = payload.get("bash_enabled") is not True
payload.update(tools_enabled=True, bash_enabled=True, tool_allowlist=None)
if not bash_off:
return None
where = payload.get("source") or (payload.get("host_context") or {}).get("origin_surface") or "this doorway"
return CallGate(denied=frozenset({TOOL_BASH}),
reason=f"Bash is switched off for turns from {where}; answer without it, or tell Dad that "
"this needs Bash switched on there.")
돌려볼 수 있는 최소 스케치: 같은 여섯 턴을 문마다 prefix로, 그다음 prefix 하나로. 다시 쓰기마다 감시자처럼 원인을 대 (표준 라이브러리, Python 3.9 이상)·python
"""A minimal sketch: fingerprint each turn's prefix, and name what changed when a turn writes the cache again."""
import hashlib
import json
REWRITE_TOKENS = 50_000 # a first request writing this much rewrote the prompt
CACHE_TTL = 3600 # the cache entry's life, in seconds
PARTS = ("tools", "system", "effort")
def sha(value) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()[:12]
def fingerprint(tools, system, effort):
return {"tools": sha(tools), "system": sha(system), "effort": effort}
def cause(previous, current, written, quiet_seconds):
if written < REWRITE_TOKENS:
return None # read from the cache; only the newest exchange was written
if previous is None:
return "first-seen"
changed = [part for part in PARTS if previous[part] != current[part]]
if changed:
return ",".join(changed)
return "ttl" if quiet_seconds > CACHE_TTL else "replay"
SYSTEM = "Identity, shared rules, the Chief addendum."
SURFACE = ["coop", "vestige_memory", "web_search", "Bash"]
def per_doorway(doorway):
"""Before: each doorway sent its own wire, and the app its rules too."""
tools = {"webui": ["coop", "web_search"], "app": SURFACE, "telegram": SURFACE[:-1]}[doorway]
system = SYSTEM + ("\n(the app's host rules)" if doorway == "app" else "")
effort = "medium" if doorway == "webui" else "high"
return fingerprint(tools, system, effort)
def one_prefix(doorway):
"""After: one posture and one system prompt. Telegram's Bash switch is a
gate refused at the call, and the app's rules ride the turn's own context.
The turn's effort still rides the request, so a WebUI turn at medium
still rewrites."""
effort = "medium" if doorway == "webui" else "high"
return fingerprint(SURFACE, SYSTEM, effort)
DOORWAYS = ["app", "app", "webui", "telegram", "telegram", "app"]
for label, build in (("prefix per doorway", per_doorway), ("one prefix", one_prefix)):
print(f"--- {label}")
previous = None
for doorway in DOORWAYS:
current = build(doorway)
moved = previous is None or any(previous[p] != current[p] for p in PARTS)
written = 400_000 if moved else 3_000 # a rewrite, or only the newest exchange
print(f" {doorway:9} wrote {written:>7,} tokens rewrite={cause(previous, current, written, 120)}")
previous = current
네 앱에서 대화 하나에 클라이언트 여럿(웹 페이지, 폰 앱, 봇)이 붙는다면, 요청마다 prefix 지문을 클라이언트 이름이랑 같이 남겨. 지문엔 도구 정의의 해시, 시스템 프롬프트의 해시, 모델, 추론 수준 같은 설정을 담아. 하루 돌리고, 클라이언트가 바뀌면서 지문이 바뀐 경우를 전부 적어. 차이마다 어디 둘지 정해. 모든 클라이언트에 같은 prefix로, 한 턴짜리 최신 메시지로, 아니면 그 클라이언트에서 못 쓰는 도구를 모델이 부르는 순간의 거절로.
Hint
도구는 머릿속 모양 말고 실제로 보내는 그대로, 보내는 순서 그대로 해시해. 정렬하면 안 돼. 순서만 바뀐 목록도 프로바이더한텐 다른 prefix야. 피파의 감시자는 도구 이름을 정렬해서 해시하니까, 순서만 바뀌면 replay로 읽어. 스케치는 보내는 목록 그대로 해시해. 다시 쓰기는 프로바이더 숫자가 그렇다고 할 때만 세 (Anthropic은 요청마다 cache_creation_input_tokens를 알려 줘). 비교는 앱 전체의 바로 앞 요청이 아니라 같은 대화의 앞 턴이랑 해.
Progress
Progress is local-only — sign in to sync across devices.