inqizitblog

Theme

Linen — The house face. Warm paper, rust ink, and nothing shouting.

ai · llm ·

Prompt caching, from the causal mask outwardPart 1 of 4

Why the cache is a prefix

Why the causal mask makes a prefix the only cacheable unit, what the cache holds, where a breakpoint cuts, and why a miss walks backward.

IndexFull pageRecap

ContentsPart 1
  1. Start hereIntroduction
  2. Part 1Why the cache is a prefix
    1. 03Why the unit is a prefix, and can never be a block
    2. 04What is actually in the cache
    3. 05Anatomy of a cached request
    4. 06Write here; read backwards
  3. Part 2Where the breakpoint goes
  4. Part 3What it costs, and for how long
  5. Part 4Running it
  6. After the partsRecap
  7. After the partsPractice
  8. AppendixGlossary
  9. AppendixReferences

IndexFull page

Why the unit is a prefix, and can never be a block

Attention is causal. Token i attends to tokens 1 through i and to nothing to its right — that is what the causal mask enforces, and it is the reason a language model can be trained on all positions at once. Two things follow immediately.

First, the work is a pure function of the prefix. The K/V vectors at position i depend on tokens 1..i and on nothing else — not on what comes after, not on the question you eventually ask. A pure function of its inputs is exactly what a cache can stand in for, and purity is what makes the cache correct rather than merely fast.

Second, and this is the load-bearing half: the cacheable unit is a prefix, not a block. A block of text has no position-independent representation. Change one byte anywhere before it and every vector from that point on is different.

Attention is causal, so the cacheable unit is a prefix12345678910The K/V vectors for token 7 are a function of tokens 1–7 and nothing else.1234!5678910still validevery one of these must be recomputedChange one byte in token 4 and the cache is worthless from position 4 onward.
Above: token 7's vectors are fixed by tokens 1–7. Below: one byte changes at position 4, and positions 4 onward must all be recomputed. The first three survive.

But I send the same document every time — why can’t the server recognise it wherever it lands? Because “the same document” is not the same computation. Its vectors are a function of the 12,000 tokens of system prompt sitting in front of it. Caching it independently of that would mean caching a function of an argument you never recorded, and returning it later would be returning a wrong answer quickly. A block-level cache is not a harder engineering problem; it is an incorrect one.

Then why a hash, rather than comparing the text? Because you cannot diff 200,000 tokens on every request for free. The system fingerprints the cumulative prefix and compares fingerprints. That makes the match exact and byte-level: one space, one reordered JSON key, one tool description reworded, and it is a different prefix. There is no fuzzy match, and there cannot be one for the same reason as above.

And why does the order tools → system → messages exist? A prefix requires a total order over the request, and the request is a set of fields. Something has to impose one. That single choice, frozen, is what produces the entire invalidation hierarchy later on: it is why editing a tool costs you the whole conversation, and why appending a message never costs you the tools.

What is actually in the cache

The K/V tensors, plus the hash that identifies them. Held in memory, not written to disk, never used for training, and isolated: per workspace on the Claude API, Claude Platform on AWS and Microsoft Foundry, and per organization on Amazon Bedrock and Vertex AI. Never across organizations.

That isolation is worth knowing before you debug a hit rate. Traffic for one prompt split across two workspaces writes and reads two separate entries and looks, from the usage fields, exactly like a broken prefix. Caches are also model-scoped — a fact that quietly prices every multi-model design, and which we will come back to.

Anatomy of a cached request

The request is assembled in that fixed order, and a cache_control marker is a cut line through the stack. Everything at or above the line is one cache entry, keyed by a hash of all of it together. Everything below is ordinary uncached input.

Anatomy of a cached requesttoolssystemmessagessearch(query, top_k)420 tokfetch(url)180 tokhouse rules2,400 tokretrieved document18,000 tokuser — turn 1assistant — turn 1user — the question60 tokcache_control breakpointONE cache entry,keyed by a hash ofall four blocks togetheruncached input,full price onevery requestThe order is fixed: tools → system → messages. A prefix needs a total order over the request, and this is it.
One breakpoint, one entry. The marker does not cache the block it sits on — it caches everything from position zero down to and including that block.

There are two ways to place one.

Automatic — a single cache_control at the top level of the request body. The system applies the breakpoint to the last cacheable block and moves it forward as the conversation grows, so you never touch markers again:

const response = await client.messages.create({
  model: 'claude-opus-5',
  max_tokens: 16_000,
  cache_control: { type: 'ephemeral' },   // ← top level, not on a block
  system: houseRules,
  messages: history,
})

Explicit — a marker on individual content blocks, up to four per request. Use these when different sections change at different rates:

const response = await client.messages.create({
  model: 'claude-opus-5',
  max_tokens: 16_000,
  tools,                                    // changes on deploy
  system: [
{ type: 'text', text: HOUSE_RULES },
{ type: 'text', text: retrievedDoc,
  cache_control: { type: 'ephemeral' } }, // ← caches tools + both system blocks
  ],
  messages: history,
})

The two compose. The automatic breakpoint consumes one of the four slots, and there are two documented ways to get a 400 out of combining them: all four slots already taken by explicit markers, and an explicit marker on the last block whose TTL differs from the top-level field’s. Declaring breakpoints is otherwise free — you are billed on what is written and read, never on how many markers you placed.

Write here; read backwards

Two rules govern whether you get a hit, and the second is the one that surprises people.

  1. A write happens only at your breakpoint. Marking a block creates exactly one entry — the hash of the cumulative prefix ending there. No entries are created for earlier positions.
  2. A read walks backward. If the hash at your breakpoint does not match, the system steps back one position at a time, checking each earlier position for an entry a previous request wrote. It is looking for prior writes, not for content that happens to be stable.
How a cache hit is resolvedrequest 1 · writeblock 1block 2block 3block 4block 5block 6◂hash(b1…b5)→ KV cacheone entry. Nothing iswritten for blocks 1–4.request 2 · readblock 1block 2block 3block 4block 5 — differsblock 6✗✗✗✗hash mismatch at block 5, so the read walks back.It finds nothing at 4, 3, 2, 1 — no request everwrote there. Stability is not the criterion; aprior write is.The walk covers at most 20 positions. A run of consecutive tool_use blocks counts as one, and so does a run of tool_result blocks.
The walk covers at most 20 positions, counting the breakpoint as the first. A run of consecutive tool_use blocks counts as one position, and so does a run of tool_result blocks — so parallel tool calls do not exhaust the window on their own.

Why not just write an entry at every block boundary? Because a write is real memory and real work. The K/V cache for a 20,000-token prefix is large, and writing one at every position would multiply the store by the number of blocks in service of a benefit only one position will ever deliver. The design instead asks you to declare where you expect to resume, and honours exactly that. The four-breakpoint limit is the same trade seen from the other side.