Dev

Find what broke your prompt cache

Cache diagnostics names the first place a request stopped matching the one before it. The useful part is reading that verdict next to the cache read count, and covering the parameter changes it cannot see yourself, because those are the misses hardest to find by eye.

A prompt cache miss is silent. When the start of your prompt stops matching the previous request byte for byte, the API does not complain. It answers, charges you to write the prefix again, and moves on. (Opus 5.5 has one loud cousin of this: replaying its thinking blocks after you edited what came before them can return a 400 on newer accounts. A plain miss never does.) The only trace is usage.cache_read_input_tokens sitting at zero, with nothing to say whether the model changed, the system text changed, or the history did.

Cache diagnostics, in public beta since May and out of beta on the Claude API since September 23, answers that question for the prompt. It does not answer it for the parameters around the prompt, and those are the misses you will not find by eye.

What does cache diagnostics actually tell you?

It compares two consecutive requests and names the first place they diverge. You pass the id of the previous response, the API fingerprints the new request, compares it with the stored fingerprint, and attaches a diagnostics object to the response. The causes it can name are model_changed, system_changed, tools_changed and messages_changed, each with cache_missed_input_tokens, an estimate of how much input fell after the break. The docs call that estimate a magnitude, not a billing number, and it can even exceed input_tokens.

The important word is first. The docs order the prefix as tools, then system, then messages, and a change at one level invalidates that level and everything after it. The response reports only the earliest divergence. If your system text carries a timestamp and your tool schemas also serialize unstably, you will see tools_changed, fix it, and only then meet the timestamp.

It compares requests, not cache outcomes. A clean verdict means your request did not change. Whether the cache hit is a different number, and you need both.

How do you turn it on?

Put a diagnostics object on every request. The first turn passes previous_message_id: null, which opts in with nothing to compare against. Every later turn passes the id of the response before it. The cache-diagnosis-2026-04-07 beta header is no longer required, but in the Python SDK the diagnostics parameter sits on client.beta.messages.create, not on the stable method, and every SDK example in the docs goes through the beta namespace.

Over plain HTTP it is two ordinary Messages calls with one extra field. The document has to be real: Opus 5.5 will not cache a prefix under 512 tokens, and a placeholder never gets written, so there is nothing to miss.

bash
DOC=$(cat big-document.txt)   # well over 512 tokens
SYSTEM=$(jq -n --arg d "$DOC" '"You are analyzing this document. <document>" + $d + "</document>"')
 
call() {
  curl -sS --fail-with-body https://api.anthropic.com/v1/messages \
    -H "x-api-key: $ANTHROPIC_API_KEY" \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d "$1"
}
 
# Turn 1: opt in, write the cache
r1=$(call "$(jq -n --argjson s "$SYSTEM" '{
  model: "claude-opus-5-5", max_tokens: 1024,
  cache_control: {type: "ephemeral"}, system: $s,
  messages: [{role: "user", content: "Summarize section 1."}],
  diagnostics: {previous_message_id: null}}')") || exit 1
jq '{id, usage}' <<< "$r1"   # expect cache_creation_input_tokens > 0
 
# Turn 2: same prefix, the assistant turn echoed back verbatim, one new question
r2=$(call "$(jq -n --argjson s "$SYSTEM" --argjson r "$r1" '{
  model: "claude-opus-5-5", max_tokens: 1024,
  cache_control: {type: "ephemeral"}, system: $s,
  messages: [
    {role: "user", content: "Summarize section 1."},
    {role: "assistant", content: $r.content},
    {role: "user", content: "Now section 2."}],
  diagnostics: {previous_message_id: $r.id}}')") || exit 1
jq '{usage, diagnostics}' <<< "$r2"

In an agent loop the whole feature is one variable carried forward. The verdict has four states, not three, so log them apart:

python
import anthropic
 
client = anthropic.Anthropic()
SYSTEM = "You are analyzing this document. <document>" + open("big-document.txt").read() + "</document>"
 
def verdict(r, prev_id):
    d = r.diagnostics
    if prev_id is None:
        return "first-turn"
    if d is None:
        return "no-divergence"
    if d.cache_miss_reason is None:
        return "pending"
    return d.cache_miss_reason.type
 
messages, prev_id = [], None
for prompt in ["Summarize section 1.", "Now section 2.", "Now section 3."]:
    messages.append({"role": "user", "content": prompt})
    r = client.beta.messages.create(
        model="claude-opus-5-5",
        max_tokens=1024,
        cache_control={"type": "ephemeral"},
        system=SYSTEM,
        messages=messages,
        diagnostics={"previous_message_id": prev_id},
    )
    u = r.usage
    print(
        f"read={u.cache_read_input_tokens} write={u.cache_creation_input_tokens} "
        f"uncached={u.input_tokens} verdict={verdict(r, prev_id)}"
    )
    messages.append({"role": "assistant", "content": r.content})  # verbatim, never rebuilt
    prev_id = r.id

In a streaming response the same object arrives on the message_start event, so you can log it before the first token lands.

Are you logging the number that shows a miss?

Probably not all of it. The usage block splits input three ways: tokens read from cache, tokens written to cache, and input_tokens, which counts only what comes after the last breakpoint. Total input is the sum of the three. A logger that keeps only input_tokens and output_tokens loses the cached prefix entirely, on hits and on misses alike. With the prefix and the question unchanged, its numbers look identical whether the prefix was read at a twentieth of the price or written at more than full price.

My agent-recall package has exactly that shape. Its Anthropic caller returns input_tokens and output_tokens and nothing else. It never sets cache_control, so today those two numbers are the whole story. The day it adds a breakpoint, they stop being the whole story, and nothing in its own accounting would say so.

The price makes that gap worth closing. On Opus 5.5 a cache read costs 0.05 times the base input rate, against 0.1 on most models. A miss pays the write rate instead: 1.25 times base with the default five-minute TTL, 2 times with the one-hour one. Log all three fields before you read a single verdict.

How do you read the result?

Read the verdict first, then read it against cache_read_input_tokens. The diagnostics field is null when you did not opt in, on the first turn, or when a comparison ran and found nothing. It is {"cache_miss_reason": null} when the comparison was still running as the response went out; that one is inconclusive, look at the next turn. Otherwise the reason sits nested inside, in the shape the docs show:

json
"diagnostics": {
  "cache_miss_reason": {
    "type": "system_changed",
    "cache_missed_input_tokens": 41850
  }
}

previous_message_not_found and unavailable arrive in the same wrapper but mean no comparison was produced. For the turns that did get one, the docs lay out a four-cell matrix, and it is the part worth memorizing:

DiagnosticsCache readWhat it means
nullhighWorking. The prefix is stable and the cache hit.
nulllow or zeroYour request did not change, but no usable entry was there. Usually the TTL: shorten gaps between turns or use the 1-hour cache.
a *_changed typelow or zeroYour bug. Fix what the type names.
a *_changed typehighA late change, but an earlier breakpoint still hit. Worth fixing, low impact.

The second row is why the matrix exists. A null verdict with zero reads is not a clean bill of health, and no amount of hunting through your prompt will explain it. Before you blame the TTL, check that turn one actually wrote something: if cache_creation_input_tokens was zero there too, the prefix was never cacheable, too short or without a breakpoint.

Which fix goes with which reason?

Each type points at one layer of the prefix. The fixes are mostly boring, which is good news.

system_changed. Something per-request got interpolated into the system field: a timestamp, a request ID, today's date. Make the system text a constant and move the dynamic part into the first user message after the cache breakpoint.

tools_changed. The tool list was added to, reordered, or serialized differently. A registry assembled from a plugin scan or an unordered set can do this without anyone touching a tool. Send the same tools in the same order every turn and serialize schemas deterministically:

python
import json
 
def stable_tools(tools):
    ordered = sorted(tools, key=lambda t: t["name"])
    # round-trip through sorted JSON so key order never depends on how the dict was built
    return json.loads(json.dumps(ordered, sort_keys=True))

This also sorts the properties inside each schema, which changes the field order the model reads once. It is stable from then on, so apply it from the first request, not halfway through a session. If a tool genuinely has to appear mid-session, leave the top-level tools array alone and let it arrive in the conversation, which is what deferred tool loading does. I made the same argument about deferred loading and the cached prefix: whatever sits at the front of the prompt is the part the cache protects, so that is the part you stop touching.

messages_changed. Model, system and tools all match, but an earlier message was edited, reordered or dropped instead of appended to. History truncation does this, and so does rebuilding assistant turns from your own data model instead of echoing content back verbatim. The docs also warn that some languages randomize key order when they convert objects to JSON, which breaks the cache through the tool_use blocks you send back. Treat the history as append-only.

model_changed. A router, an A/B split or a fallback picked a different model mid-conversation, and the cache is per-model. The turn that falls back pays for the whole prefix again, and so does the turn that comes back if the original entry has expired by then. If you fall back, fall back for the rest of the conversation. This is not an argument against running workers and reviewers on different models: separate roles are separate conversations and never shared a cache to begin with.

previous_message_not_found. Not evidence that anything changed. The previous request skipped the diagnostics object, ran in another workspace, or is too old; fingerprints are kept only briefly. If you adopted the beta early, check your first turn: since September 9 a request that sends only the beta header, without the diagnostics object, stores no fingerprint, so the turn after it reports this type every time.

Where does cache diagnostics go blind?

In three places: parameters outside the prompt, very long conversations, and every platform other than the Claude API. The first is the nasty one.

Parameters outside the prompt. When model, system and tools match but tool_choice, thinking, context_management, output_config, output_format or the set of anthropic-beta headers differs, the verdict is unavailable. Now read the caching page's invalidation table: a tool_choice change leaves the tools and system caches valid and invalidates the message blocks, and a change to the thinking configuration or to top-level output_config.effort always invalidates the message blocks, and on some models more. So in exactly the case that is hardest to spot by eye (tools intact, system intact, reads collapsing on the messages), diagnostics declines to name the cause.

Effort is the easy one to trip over. Setting it explicitly to the model's default is the same as omitting it, but an agent that raises top-level effort for one hard step pays with its message cache on that turn. On models that support per-message effort, the change can ride in a role: "system" message inside messages and leave the cached prefix intact. The default itself moved on Opus 5.5, as I covered in the Opus 5.5 migration notes.

Diagnostics stops at the prompt. The parameters are yours to watch, and a short hash per turn, logged beside the verdict, is enough:

python
import hashlib, json
 
PROMPT_PARAMS = ("tool_choice", "thinking", "context_management", "output_config", "output_format")
 
def param_fingerprint(request: dict, betas: list[str]) -> str:
    snapshot = {k: request.get(k) for k in PROMPT_PARAMS}
    snapshot["betas"] = sorted(betas)
    blob = json.dumps(snapshot, sort_keys=True, default=str)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]

When unavailable arrives with collapsed reads, a hash that changed since the last turn names the layer, and a diff of the two snapshots names the field.

Very long conversations. If the only change sits deep in a very long message list, you can get unavailable instead of a location. The docs give no threshold. The sessions where a miss costs the most are the ones most likely to hit it.

Other platforms. Diagnostics runs on the Claude API only, not on Amazon Bedrock, Google Cloud or Microsoft Foundry. Replaying a conversation against the Claude API can show you whether your requests change between turns, but not what the other platform's cache did; there, the usage fields are all you have.

Should you leave it on in production?

My read is yes, for agent loops on the Claude API. A deterministic miss you can debug on demand: switch diagnostics on with null, wait one turn, read the verdict. The ones that justify leaving it on are intermittent, like a plugin scan that reorders tools now and then or a fallback that fires only under load. By the time you opt in, the turn that missed has no fingerprint to compare against, and it may not repeat while you watch. Against that, the feature never blocks or fails a request, and what it stores is hashes and token-count estimates, not prompt text, scoped to your organization and workspace.

Don't alert on the verdict, though. Alert on the read count: any turn after the first with reads near zero is worth a look, whatever diagnostics says. The verdict tells you where to look. A *_changed type is your prompt, null is the TTL or an uncacheable prefix, and unavailable is your parameters, which is what the fingerprint is for.

What should you check first when cache reads drop to zero?

In this order. Make sure you log all three usage fields, so you can tell a miss from a prefix that was never cached. Read the diagnostics type and fix the layer it names, first divergence first, then look again. If the verdict is null, check that turn one wrote the cache, then the gap between turns. If it is unavailable, compare the parameter fingerprints. And while you wait for the next turn, look at the system text: a timestamp there costs nothing to check.

Discussion

No comment section here — all discussions happen on X.

Max Nardit

Max Nardit

@mnardit

More articles

What a cloned repo's settings file can still run

Claude Code just stopped repositories from switching on telemetry export. The narrower fix hides a wider fact: a committed settings file is code that runs as you, and in a headless run nothing asks first. Interactively a dialog does ask, and I accept it without reading.

Opus 5.5 is cheaper, and the 400s are the easy part

A request that returns 400 has already told you what to fix. The changes that cost you on this upgrade come back as 200: a response that no longer starts with text, an effort level that dropped a notch because you never set it, and an alias that moved you to a new model without a deploy.

A price you cannot compute

The bill went up by $96 a year, which is nothing. What changed underneath it is that the number is no longer derivable: the included allowance has no published size, the free credit allotment has no published size, and the annual credit costs more than the monthly one. A price you cannot compute from published figures is an estimate, and an estimate is a different kind of dependency.