🧊 Frozen system prompt
O system prompt (the instruction text before the conversation) sits right at the front of the prefix. If you interpolate date/time, mode, or username there, each request has different bytes at the beginning — and invalidates everything. The rule is: frozen system; dynamic context goes after it, in the messages.
Legend: the dynamic part doesn't disappear — it changes position. Moves out of the way (system) and to the end (messages), where it doesn't break the prefix.
System without variables
Goes to the end of the messages
System is almost position 0
Enough to invalidate it
🔤 Deterministic tools
The list of tools is rendered in the position 0 — before anything else. If you build it from one set/dict without sorting, the order varies across requests and the cache never hits. Always serialize in the same order: sort by name.
✗ Variable order
tools = [t for t in registry] # registry é um set/dict # ordem muda entre requests → miss
✓ Stable order
tools = sorted(registry, key=lambda t: t["name"]) # mesma ordem sempre → prefixo idêntico # cache_read para de ser 0
Objective
Have Claude Code point out what, in your system prompt and in the assembly of the tools, is preventing the cache from working—and rewriting to maximize the hit rate.
Prompt (paste into Claude Code)
Revise meu system prompt e a montagem de `tools` em <caminho/do/arquivo>. Aponte o que está impedindo o prompt caching: 1) interpolação dinâmica no system (data/hora, nome do usuário, modo, uuid, qualquer coisa que muda entre requests); 2) ordem não-determinística das tools (montadas de set/dict sem sort). Depois reescreva: (a) congele o system e mova o dinâmico pro fim das messages; (b) serialize as tools em ordem estável (ordene por nome). Mostre o diff e explique por que cada mudança preserva o prefixo.
How to verify
Run two identical requests after the change and check the usage: cache_read_input_tokens must stop being 0 starting with the 2nd. If it’s still 0, an invalidator remains in the prefix (see topic 5).
Tools come before everything
Sort by name
Order varies = miss
Same bytes = hit
🚫 Don't switch models midstream
Caching is per model. Switching models midstream invalidates everything — one model's cached prefix doesn't work for another. Need a cheaper model for a subtask? Use a subagent, keeping the main loop on a single model.
Legend: the loop purple stays in one model; the subagent cyan uses another model without touching the parent’s cache.
Why a subagent helps
The subagent has its own prefix and its own cache. It runs the subtask on the cheaper model and returns the result as text—the main loop continues on the same model, with the cache intact.
Caching doesn’t work across models
Invalidates everything
Cheap model on the side
Keeps the cache warm
🌿 Fork reuses the parent prefix
A side operation — a summary, a subagent — that rebuilds system/tools/model different loses the parent's cache. To reuse the warm prefix, the fork needs to copy system/tools/model verbatim and just append the specific part at the end.
Legend: the fork cyan inherits the parent’s prefix (only writes the suffix); the fork red rebuilds it and pays for everything again.
✓ Fork that reuses
- ✓Copy
systembyte by byte - ✓Keeps the same list of
toolsand the samemodel - ✓Append the specific instruction only at the end
✗ Fork that loses
- ✗Write a new "lean" system prompt for the summary
- ✗Passes another set of tools or another model
- ✗Result: different prefix, cache starts from scratch
Copy system/tools/model
Append the specific part
Reuses the parent's cache
= pay from scratch
🔎 Silent invalidator checklist
One silent invalidator doesn’t error—it just cache_read_input_tokens always stay 0. Before blaming the model, scan everything that feeds the prefix and look for these suspects.
Legend: any source of variation in what becomes tools/system changes the prefix — and silently breaks the cache.
The 6 usual suspects
- ☐
datetime.now()/Date.now()in the system - ☐
uuidor a random ID in the prefix - ☐
json.dumpswithoutsort_keys=True - ☐Iterate a
set(order not guaranteed) - ☐
toolsassembled by user/mode - ☐Conditional section in system (
if flag: system += …)
Symptom → cause
cache_read_input_tokens always 0 on repeated requests = there’s an invalidator. Diff the prefix bytes between two identical requests, and the byte that changes is the culprit (topic 4 of module 3.3 has the ready-to-use prompt for this).
No error
The single symptom
Scan the prefix
Find the byte at fault
🔥 Warm-up and TTL 1h
A request with max_tokens: 0 when the app starts writes the prefix cache and returns immediately — removes latency from the first real request. This only applies if three things are true; and for traffic with long pauses, there's also the TTL of 1h (write ~2×).
Legend: prewarming pays for the write before of traffic, so the first user doesn’t feel the write latency.
✓ Prewarming is worthwhile when
- ✓The 1st latency is visible to the user
- ✓The prefix is large (the write is worthwhile)
- ✓There is a moment before traffic (boot/deploy)
TTL of 1h
- •More expensive write (~2× instead of ~1.25×)
- •Applies to traffic with gaps > 5 min
- •Keeps the cache warm through long pauses
Records without generating a response
Boot/deploy
Gaps > 5 min
1 h write
Module summary
max_tokens: 0 at boot; 1h for long gaps.Next module
3.3 — Ready-to-use prompts (copy-run): the kit of 6 prompts to audit and fix caching in Claude Code.