Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Three developers ask a coding assistant different questions about the same repository. Each request carries 6,000 shared tokens: 600 of instructions, a 4,200-token architecture guide, an 800-token tool schema, and 400 tokens of examples. The first question adds another 48 tokens. Should the model process that entire guide three times?
An available prefix cache can reuse the guide's previously computed state. Put a changing request ID before the guide, though, and the guide no longer has the same preceding context. The unchanged text is insufficient. Reuse also needs compatible model settings, an eligible matching boundary, and the right sharing scope.
For ordinary full causal attention, prefix caching retains Key-Value (KV) state for an exact starting token sequence. The state can live in a GPU pool or another cache tier. Our idealized warm request reuses 6,000 tokens and freshly processes its 48-token question during prefill. Standard KV caching retains state inside a request during decoding; prefix caching makes compatible state reusable across requests. A cache hit saves input work, not a previously generated answer.
The previous KV-cache lesson explained how a request retains attention state while generating. Here, we tackle how independent requests safely borrow and share that state across queries, tenants, and GPU clusters.
What is the core difference between normal KV cache and prefix caching?
Answer
Normal KV cache reuses keys and values inside one active request during autoregressive decoding. Prefix caching reuses already-computed KV state across eligible requests that start with the exact same token prefix.

Worked example: tracking tokens across cache hits
Suppose our coding assistant's shared prefix breaks down into four parts:
1system instructions: 600 tokens
2repo guide: 4,200 tokens
3tool schema: 800 tokens
4few-shot examples: 400 tokensAssume the first request has made its prefix available before the second arrives. Predict what request two can skip: it can avoid recomputing the shared guide, but it still processes its own question and generates its own response. A simultaneous cold burst can require multiple warmups; matching text doesn't mean a cache write has finished.
With the eligible entry available, the runtime reuses those 6,000 prefix tokens for requests two and three. New question tokens still attend to the cached guide; reuse does not remove the guide from the context.
Now place a changing request_id before the guide. Tokens before the changed ID may still match, including message wrappers. The guide after it cannot reuse state computed with a different ID. Moving the ID after the eligible guide boundary preserves more reusable work.
For questions of 48, 37, and 51 tokens, three cold requests freshly process 18,136 input tokens. One cold request followed by two full-prefix hits freshly processes 6,136, avoiding 12,000 repeated prefix-token computations. This authored ledger assumes compatible settings, a qualifying 6,000-token boundary, and an available entry. It doesn't predict FLOPs, billing, or milliseconds.
1shared_prefix_tokens = 6000
2question_tails = [48, 37, 51]
3
4total_fresh_without_cache = 0
5total_fresh_with_cache = 0
6
7for request_index, tail_tokens in enumerate(question_tails, start=1):
8 fresh_without_cache = shared_prefix_tokens + tail_tokens
9 fresh_with_cache = fresh_without_cache if request_index == 1 else tail_tokens
10 reused_prefix = 0 if request_index == 1 else shared_prefix_tokens
11
12 total_fresh_without_cache += fresh_without_cache
13 total_fresh_with_cache += fresh_with_cache
14
15 print(
16 f"request {request_index}: reused_prefix={reused_prefix:4d} "
17 f"fresh_tokens={fresh_with_cache:4d}"
18 )
19
20saved_tokens = total_fresh_without_cache - total_fresh_with_cache
21print(f"fresh tokens without cache: {total_fresh_without_cache}")
22print(f"fresh tokens with cache: {total_fresh_with_cache}")
23print(f"prefix tokens saved: {saved_tokens}")1request 1: reused_prefix= 0 fresh_tokens=6048
2request 2: reused_prefix=6000 fresh_tokens= 37
3request 3: reused_prefix=6000 fresh_tokens= 51
4fresh tokens without cache: 18136
5fresh tokens with cache: 6136
6prefix tokens saved: 12000For the 6,000-token shared prefix example, which work is skipped on requests two and three?
Answer
The runtime skips repeated prefill computation for the identical 6,000-token prefix. It still processes each new user question and still decodes each answer token.
The prefix tokens saved count represents a work ledger, not a billing outcome. Provider write and read fees, cache retention lifetimes, and arrival rates determine whether the initial cache write pays for itself. Those savings also depend on how the inference engine indexes and stores those 6,000 tokens. What does a "match" actually hash under the hood?
Two indexes, one causal dependency
During generation, a decoder-only transformer stores keys and values for tokens it has already processed. When predicting token 501, it doesn't recompute attention keys and values for tokens 1 through 500. That's the normal KV cache.
Retaining that state for generation doesn't by itself make it discoverable by the next incoming request.
With ordinary full causal attention, position attends only to positions . Appending a question doesn't change the prefix's ideal mathematical computation. Changing earlier tokens can change deeper-layer state for a later tools section even if the tools text stays identical. Compatibility includes weights, adapters, positions, attention configuration, and any nontext conditioning. Different execution paths can still introduce floating-point variation.
A single whole-prompt hash would miss a partial overlap. A hash table indexed by successive prefix blocks can discover it. A compressed token trie offers another approach:
| Index | How it finds overlap |
|---|---|
| SGLang's original RadixAttention | Traverses a compressed token trie; splits an edge when requests diverge within its token sequence |
| vLLM's automatic prefix cache | Looks up successive full blocks using chained keys that include preceding history |
SGLang's paper describes RadixAttention retaining prompt and generation state through a radix tree and evicting eligible leaves with LRU.[1] A trie split can expose a shared initial segment. Its current cache granularity depends on the backend; the original paper isn't a guarantee that every deployment caches arbitrary single-token boundaries.
vLLM uses a hash-based index, not that radix-tree implementation.[2] With sixteen-token blocks, 6,005 shared positions contain 375 complete blocks (6,000 tokens). Eligibility and the logits boundary can leave additional work, as the previous lesson demonstrated. PagedAttention supplies block-oriented attention storage; it isn't itself the prefix-matching algorithm.[3]
The vLLM key includes its parent hash, block token IDs, and relevant extras such as adapter IDs, multimodal hashes, and a cache salt. A teaching notation is:
The engine's cache namespace must also be compatible with its model revision; this formula doesn't assert that every vLLM block embeds a model ID. vLLM documents sha256 as the default and sha256_cbor for reproducible cross-language serialization. It also offers noncryptographic options with a collision-risk warning. Collision resistance is different from authenticating who selected a sharing scope.[2]
Before looking at the diagram, predict the downstream key: if the repository guide changes in block 1 while the tools schema in block 2 stays identical, can block 2 reuse its old cache key?

Why can changing one token near the beginning of a long prompt destroy many prefix-cache hits?
Answer
Prefix caches tie keys to exact token blocks and their parent prefix hashes: H_k = hash(H_{k-1} || tokens_k). If an early block changes, later blocks inherit a different parent hash, so the engine can't safely reuse their cached KV state.
1from hashlib import sha256
2import json
3
4def chain(blocks: list[str]) -> list[str]:
5 parent = "root"
6 hashes = []
7 for block in blocks:
8 material = json.dumps(["teaching-block-v1", parent, block], separators=(",", ":"))
9 parent = sha256(material.encode()).hexdigest()
10 hashes.append(parent)
11 return hashes
12
13original = chain(["system", "repo guide v1", "tools"])
14edited = chain(["system", "repo guide v2", "tools"])
15
16for index, (left, right) in enumerate(zip(original, edited)):
17 print(f"block {index}: {'same key' if left == right else 'different key'}")
18assert original[0] == edited[0]
19assert all(a != b for a, b in zip(original[1:], edited[1:]))
20assert all(len(digest) == 64 for digest in original + edited)1block 0: same key
2block 1: different key
3block 2: different keyThe strings above stand in for token blocks. This fixture tests history dependence, not vLLM's serialization or cache availability: a matching key still needs a stored usable entry.
Prompt architecture: the static-to-dynamic contract
Cache hits require strictly stable prefixes. Put shared, immutable content first and variable, dynamic content last. Before comparing these two layouts, predict which pair shares more initial tokens.
Good prompt shape:
1system: You are the repository maintenance assistant.
2repo_guide: <shared repository guide>
3tools: <shared tool schema>
4examples: <shared examples>
5user: Why is this test failing?Poor prompt shape:
1user: Why is this test failing?
2system: You are the repository maintenance assistant.
3repo_guide: <shared repository guide>
4tools: <shared tool schema>In the second layout, changing the question changes the context preceding the guide. A common wrapper might still match, but the guide after the divergence can't reuse its old state. These layouts illustrate serialized content order, not instructions to violate an API's role rules.
That's why agent architectures keep dynamic tool outputs and user-specific state after stable instructions whenever possible. Anthropic also renders tools, then system, then messages internally, so reshuffling a tool schema invalidates a cache even when the visible user text didn't move at all.[4]
1def shared_prefix_units(left: list[str], right: list[str]) -> int:
2 count = 0
3 for a, b in zip(left, right):
4 if a != b:
5 break
6 count += 1
7 return count
8
9stable_first_a = ["system", "repo guide", "tools", "test failure question"]
10stable_first_b = ["system", "repo guide", "tools", "lint failure question"]
11variable_first_a = ["test failure question", "system", "repo guide", "tools"]
12variable_first_b = ["lint failure question", "system", "repo guide", "tools"]
13
14print("stable-first reusable units:", shared_prefix_units(stable_first_a, stable_first_b))
15print("variable-first reusable units:", shared_prefix_units(variable_first_a, variable_first_b))1stable-first reusable units: 3
2variable-first reusable units: 0These lists contain named sections for clarity; in production, compare actual tokenizer token IDs when diagnosing cache behavior. Always preserve proper message roles; these sketches aren't instructions to stuff user queries into a system message.
Given a long repo guide and a short user question, where should each part go for cache hits?
Answer
Put stable instructions, the repo guide, tool schemas, and few-shot examples first. Put the user question, retrieved snippets, per-user session state, and dynamic tool results after the stable prefix.
Exact-match invalidation traps in production
Semantic equivalence isn't enough. Prefix reuse depends on the exact resulting token sequence, so formatting and serialization differences break the prefix earlier than you might expect.
- Whitespace and line endings: Extra spaces or
\r\ninstead of\ncan change tokenization. The result depends on preprocessing and the tokenizer. - JSON key order: Reordering keys in JSON rendered as prompt text can alter its token sequence. A provider may separately canonicalize structured API fields.
- Non-deterministic renderers: Timestamps, generated IDs, iteration over sets, or unstable external tool ordering can change the rendered prefix. Python dictionaries themselves preserve insertion order.
Make prompt templates strictly deterministic at the serialization boundary. Serialize JSON with sorted keys where key order doesn't carry meaning, and preserve ordered arrays. Don't blindly strip whitespace from code or structured markdown, since whitespace often defines syntax. Log a prefix version hash so a cache miss can be traced directly to rendered prompt tokens rather than guessed from template files.
Exact stability still doesn't guarantee memory residency. Self-hosted engines silently evict unreferenced blocks when VRAM is tight, using least recently used (LRU) policies.[1] A later request takes the correct cold path and recomputes the prefix. Treat eviction as a latency and cost event, not a correctness failure: capacity-plan for misses and never let application correctness depend on a cache hit.
Why can two prompts that mean the same thing miss the prefix cache even when no guide changed?
Answer
Formatting or unstable rendering can change token IDs even when meaning is unchanged. Eligibility and availability are separate: an identical prefix can miss after eviction. Keep rendering deterministic and the cold path correct.
Hosted provider contracts and pricing economics
Hosted providers don't expose the same raw block-hash controls as vLLM. They still match exact prefixes, but the knobs, field names, and break-even economics differ by vendor and model family. Before reading either contract, predict what happens when a stable guide is followed by a changing question: which provider setting decides where reusable work ends?
OpenAI documents prompt caching in usage fields.[5] Anthropic documents cache_control at the top level for automatic caching and on content blocks for explicit breakpoints.[4] Both APIs expose the same underlying mechanics through different controls.
Provider contracts checked September 22, 2026. Model, platform, settings, and cache availability matter. The examples below are request structures and authored usage fixtures; they aren't live API results.
Don't assume a hosted cache behaves like your local runtime. Verify:
| Question | Why it matters |
|---|---|
| Is caching automatic or explicit? | Determines prompt construction and breakpoint placement |
| What is the minimum cacheable prefix? | Short prompts below the threshold never hit the cache |
| How long does the cache live? | Dictates traffic batching and request interval requirements |
| Is cache scoped by org, project, or region? | Governs privacy isolation and cross-worker hit rates |
| Where are cached tokens reported? | Required for accurate cost accounting and margin tracking |
OpenAI's guide distinguishes GPT-5.6 and later from earlier models. The newer family has a 1,024-visible-token minimum, exact boundary reporting, and prompt_cache_options.ttl: "30m" (minimum lifetime after the latest write or reuse). Earlier-model minimums vary with request settings; reported cached counts round down to multiples of 128, and retention uses prompt_cache_retention.[5]
The current guide's matching section says implicit lookup can also check earlier eligible message endings and the initial developer-message boundary. A later gotcha still describes a latest-message-only miss. Don't turn that inconsistency into a claim that every changing question necessarily misses. When you want only a particular stable prefix written, use an explicit marker and explicit-only mode; confirm actual reuse from usage and diagnostics.[5][6]

For our coding assistant, mark the end of the repository guide explicitly and place the user question after it. This Responses API request structure illustrates the controls. Replace its placeholder with the complete guide; the placeholder alone won't meet the minimum:
1{
2 "model": "gpt-6-astra",
3 "prompt_cache_options": { "mode": "explicit", "ttl": "30m" },
4 "prompt_cache_key": "workspace-alpha:repo-guide-v7",
5 "input": [
6 {
7 "role": "developer",
8 "content": [{
9 "type": "input_text",
10 "text": "<stable instructions and the complete repository guide>",
11 "prompt_cache_breakpoint": { "mode": "explicit" }
12 }]
13 },
14 { "role": "user", "content": "Why is this test failing?" }
15 ]
16}Explicit-only mode leaves the suffix outside the selected write. For GPT-5.6 and later, prompt_cache_key is optional separate accounting, not a routing requirement. Diagnostics even distinguish a key-related reported miss from a physical cache miss. Earlier models use keys to help routing. Choose any customer key in the trusted gateway; a caller-supplied label isn't authenticated isolation.[6]
Current OpenAI pricing lists 1.25-times write and 0.10-times read rates for GPT-6 Astra and the GPT-5.6 family, relative to their corresponding uncached input rates.[7] Anthropic's pricing lists 1.25-times five-minute writes and 2-times one-hour writes. Most Claude reads cost 0.10 times base input; Fable 5.1 and Mythos 5.1 use 0.025 times. Availability and other pricing dimensions remain model-specific.[8]
At a 0.10-times read rate, the 5-minute write surcharge is 0.25 units and each subsequent full read saves 0.90 units. The break-even requirement is:
For the 5-minute tier, read repays that premium. For the 1-hour tier with a 1.0-unit surcharge, reads are required. If a cached prefix is written and never read before it expires, you paid a 25% to 100% financial penalty over uncached input!
Claude minimums currently range from 512 to 4,096 tokens: Opus 5 uses 512, Sonnet 5 uses 1,024, and Haiku 4.5 uses 4,096. The guide directs Bedrock users to AWS's contract. A breakpoint has a twenty-block lookback for earlier entries, not a twenty-block prefix-length limit. Requests have four breakpoint slots; top-level automatic cache_control uses one. Concurrent reuse becomes available after the first response begins.[4]
For the same stable-guide pattern, Claude's Messages API places the explicit marker on a system content block. Replace the placeholder with your complete guide; the literal placeholder is below the model's minimum:
1{
2 "model": "claude-opus-5",
3 "max_tokens": 256,
4 "system": [{
5 "type": "text",
6 "text": "<stable instructions and the complete repository guide>",
7 "cache_control": { "type": "ephemeral" }
8 }],
9 "messages": [{ "role": "user", "content": "Why is this test failing?" }]
10}Both examples select a reusable boundary. Neither marker creates a hit unless a compatible entry is available. For supported newer OpenAI models, prompt_cache_options.prewarm: true can write the selected prefix without generating a response; cache-write charges still apply.[5]
1response_usage = {
2 "input_tokens": 2600,
3 "input_tokens_details": {
4 "cached_tokens": 2000,
5 "cache_write_tokens": 400,
6 },
7}
8
9details = response_usage["input_tokens_details"]
10cache_reads = details.get("cached_tokens", 0)
11cache_writes = details.get("cache_write_tokens", 0)
12uncached = response_usage["input_tokens"] - cache_reads - cache_writes
13print(f"cache-read prompt tokens: {cache_reads}")
14print(f"cache-write prompt tokens: {cache_writes}")
15print(f"uncached prompt tokens: {uncached}")
16assert cache_reads + cache_writes + uncached == response_usage["input_tokens"]
17assert min(cache_reads, cache_writes, uncached) >= 01cache-read prompt tokens: 2000
2cache-write prompt tokens: 400
3uncached prompt tokens: 200Now compute the exact reuse threshold that repays the write premium across cache lifetimes:
1from math import ceil
2
3read_rate = 0.1
4for ttl, write_rate in [("5m", 1.25), ("1h", 2.0)]:
5 extra_write_cost = write_rate - 1.0
6 savings_per_read = 1.0 - read_rate
7 reads_needed = ceil(extra_write_cost / savings_per_read)
8 print(f"{ttl} cache: {reads_needed} reuse request(s) to repay write premium")15m cache: 1 reuse request(s) to repay write premium
21h cache: 2 reuse request(s) to repay write premiumThis fixture uses Responses API field names; Chat Completions uses a different usage schema. For an authored Claude usage breakdown of the same 2,600-token total, use 2,000 cache_read_input_tokens, 400 cache_creation_input_tokens, and 200 input_tokens. Claude's input_tokens excludes the read/write portions. Sum all three before calculating its reused-token percentage.[4]
Why should production code log provider usage fields instead of hardcoding a prompt-caching discount?
Answer
Provider thresholds, lifetimes, scopes, and pricing vary by model. Usage fields tell you exactly how many tokens were read from cache, written to cache, or processed uncached for every request.
Distributed serving: cache-aware routing versus round-robin
With independent worker-local caches, round-robin routing can require one cold warmup per worker. After those workers warm, repeated requests can hit. A deployment with shared tiers or cache transfers has different locality and transfer costs; not every cache entry is confined permanently to its original GPU.
Cache-aware routing can prefer a compatible warm worker. SGLang's paper describes cache-aware scheduling inside its engine; that isn't itself a distributed router.[1] Routing affinity also doesn't enforce cache ownership. Apply the authenticated sharing scope at lookup independently of the destination.
Affinity needs health and load handling. A hot prefix can overload its preferred worker; spilling to another worker may be worth a cold warmup. Bounded-load consistent hashing is one policy, not a universal router implementation or a fixed requests-per-second threshold.
Why can round-robin routing require more cold requests than prefix affinity?
Answer
Worker-local caches store KV blocks in local GPU VRAM. Round-robin spreads requests across distinct workers, forcing each replica to perform an independent cold prefill. Affinity routes matching prefixes to the same warm worker.
The fixture uses a fixed set of three initially cold workers, sequential completed warmups, one prefix, and no eviction. It hashes modulo the worker count, so changing that count can remap many keys. This is static affinity, not consistent hashing or a health-aware router.
1from hashlib import sha256
2
3workers = ["gpu-a", "gpu-b", "gpu-c"]
4prefix = "repo-guide-v7|tool-schema-v2"
5
6def affinity_worker(stable_prefix: str) -> str:
7 digest = int(sha256(stable_prefix.encode()).hexdigest(), 16)
8 return workers[digest % len(workers)]
9
10round_robin = [workers[i % len(workers)] for i in range(4)]
11affinity = [affinity_worker(prefix) for _ in range(4)]
12print("round-robin workers:", round_robin)
13print("affinity workers:", affinity)
14print("cold workers with round-robin:", len(set(round_robin)))
15print("cold workers with affinity:", len(set(affinity)))1round-robin workers: ['gpu-a', 'gpu-b', 'gpu-c', 'gpu-a']
2affinity workers: ['gpu-b', 'gpu-b', 'gpu-b', 'gpu-b']
3cold workers with round-robin: 3
4cold workers with affinity: 1Prefill versus decode: what prefix caching doesn't change
In our full-attention example, new input and generated tokens still attend to the retained prefix. GPU-resident entries still occupy the pool; another cache tier can add transfer costs. Reuse doesn't extend the model's context limit. Long output generation can dominate total response time even when input processing improves.
Separate time to first token (TTFT) from inter-token latency (ITL). A hit can reduce input processing; client TTFT also includes queueing and transport. It doesn't directly skip output-token generation. Decode performance can still change indirectly with batching, kernels, or load.
Prefix caching doesn't match by meaning. These questions express similar intent but don't share the same complete question prefix; any common message wrapper is a separate overlap:
1Why did this test fail after the refactor?
2What broke in the failing spec after the code change?Reusing an answer for such questions belongs to semantic caching. Semantic caching needs an answer-validity policy. Correct prefix reuse preserves the supplied context; it doesn't make the model's answer accurate or eliminate numerical differences between execution paths.
Why does prefix caching need a different correctness check from semantic caching?
Answer
Correctly keyed prefix caching reuses mathematically equivalent tensor activations, not an old answer. It requires weight and isolation checks; different execution kernels can also introduce floating-point variation. Semantic caching deliberately reuses an answer for a different query, so it also needs an answer-validity policy.
Here are authored phase times, with decode deliberately held constant. They are assumptions, not measurements of the running example or a prediction for your model:
1uncached = {"prefill_ms": 1220, "decode_ms": 880}
2cached = {"prefill_ms": 170, "decode_ms": 880}
3
4prefill_saved = uncached["prefill_ms"] - cached["prefill_ms"]
5decode_change = uncached["decode_ms"] - cached["decode_ms"]
6print(f"prefill saved: {prefill_saved} ms")
7print(f"decode change: {decode_change} ms")
8assert prefill_saved > decode_change1prefill saved: 1050 ms
2decode change: 0 ms
Before looking at dashboards, predict this counterexample: if input work falls by 1,050 ms but queueing grows by 1,180 ms, does the client see the first token sooner?
1# Authored times; first-token work includes computation through the first output.
2cold = {"queue_ms": 20, "first_token_work_ms": 1220}
3warm = {"queue_ms": 1200, "first_token_work_ms": 170}
4cold_ttft = sum(cold.values())
5warm_ttft = sum(warm.values())
6print("first-token work saved:", cold["first_token_work_ms"] - warm["first_token_work_ms"], "ms")
7print("cold / warm TTFT:", cold_ttft, "/", warm_ttft, "ms")
8print("client TTFT change:", warm_ttft - cold_ttft, "ms")
9assert warm["first_token_work_ms"] < cold["first_token_work_ms"]
10assert warm_ttft > cold_ttft1first-token work saved: 1050 ms
2cold / warm TTFT: 1240 / 1370 ms
3client TTFT change: 130 msThe warm request is slower from the client's perspective. Compare both server work and client timestamps under representative load; a hit alone doesn't determine latency.
Telemetry: token-level yield versus binary hit counters
Usage fields report credited reuse for a request. Operations teams also need to know whether that reuse helps the route over time. Provider accounting and physical cache behavior need not be identical, as OpenAI's key-change diagnostic illustrates.[6]
For self-hosted runtimes, log prefix cache hit rates, reused token counts, prefill latency, decode latency, and GPU memory allocation. For hosted APIs, parse and record returned cache fields. Always slice metrics by route, model revision, prefix version, and traffic window. Long-lived multi-turn chat sessions reuse substantial prefixes too; route names alone don't explain savings.
Synthetic application log schema:
1{
2 "route": "repo-guide-rag",
3 "prompt_tokens": 6048,
4 "cached_tokens": 6000,
5 "cache_write_tokens": 0,
6 "prefill_ms": 170,
7 "decode_ms": 880,
8 "cache_version_id": "repo_guide_v7"
9}Version the static prefix in telemetry. If the repository guide changes, blocks containing and following the first changed token can't reuse the old cache entry, but earlier unchanged blocks can still hit. A version label correlates that partial warmup with software rollouts.
Beware the binary hit counter trap. A request reusing 6,000 of 6,048 tokens has 99.2% token reuse; one reusing 600 has 9.9%. Both increment a binary hit counter. These percentages measure input-token volume, not prefill FLOPs or elapsed time. Compute a volume-weighted token ratio, then measure time and price separately:

Which metrics prove prefix caching is helping a route?
Answer
Track token reuse and read/write costs alongside input-processing time, client TTFT, output intervals, queueing, and block-pool pressure. Slice by route, model, and prefix version. Token counts alone don't establish latency gains or unchanged decode speed.
Memory management: cached can also mean available
Physical GPU VRAM is strictly bounded. What happens when hundreds of concurrent requests arrive and memory fills up?
vLLM V1's block pool distinguishes references, cached content, and allocation availability. On release, an unreferenced block can retain its cache key while joining the free queue. A matching request can touch it; allocation for different content can evict it. Resident pool storage need not shrink when ownership changes.[2]
Don't recycle a block while owners or pending GPU operations still need it. Proper scheduler preemption can instead release ownership and arrange recomputation or supported offload. When available blocks run out, the engine must defer or preempt work according to its policy. The previous lesson's failed-append lab demonstrated that allocation failure must preserve existing state.
No universal 85%/95% VRAM watermarks follow from this design. Measure referenced blocks, cached unreferenced candidates, available blocks, eviction churn, and preemption. GPU allocation alone can stay constant while the logical pool fills.
In the authored snapshot below, how many blocks can be allocated: 150, 550, or 950?
1pool = {"referenced": 450, "cached_unreferenced": 400, "uncached_free": 150}
2assert sum(pool.values()) == 1000
3available = pool["cached_unreferenced"] + pool["uncached_free"]
4new_blocks = 200
5print("available blocks:", available)
6print("200 new blocks fit:", new_blocks <= available)
7print("available after allocating 200:", available - new_blocks)
8print("must replace at least this many cached blocks:", max(0, new_blocks - pool["uncached_free"]))
9assert available == 5501available blocks: 550
2200 new blocks fit: True
3available after allocating 200: 350
4must replace at least this many cached blocks: 50The allocation order could replace more cached entries; this is a lower bound, not a simulation of the engine's free-queue order. A hit-rate drop can accompany successful allocation without any change in resident pool bytes.
Treat LRU eviction as a capacity signal. If your token hit ratio suddenly drops during peak traffic hours, check eviction churn: high concurrency may be evicting unpinned prefixes before subsequent requests arrive to reuse them.
1events = [
2 {"version": "guide-v6", "cached_tokens": 6000, "input_tokens": 6048},
3 {"version": "guide-v7", "cached_tokens": 600, "input_tokens": 6048},
4 {"version": "guide-v7", "cached_tokens": 6000, "input_tokens": 6048},
5]
6
7for version in sorted({event["version"] for event in events}):
8 rows = [event for event in events if event["version"] == version]
9 hits = sum(event["cached_tokens"] > 0 for event in rows)
10 reused = sum(event["cached_tokens"] for event in rows)
11 total = sum(event["input_tokens"] for event in rows)
12 print(f"{version}: {hits}/{len(rows)} requests hit; {reused / total:.1%} tokens reused")1guide-v6: 1/1 requests hit; 99.2% tokens reused
2guide-v7: 2/2 requests hit; 54.6% tokens reusedMulti-tenant security: timing side-channels and cache isolation
Prefix caching introduces subtle security vulnerabilities in multi-tenant architectures if trust boundaries aren't strictly isolated.
Consider a probe containing suspected confidential text. A repeatedly faster response could reveal shared cache availability. One fast response doesn't prove which tenant submitted a document: queueing, routing, and other warm prefixes are confounders. Returned cache counters can also be a probing signal.
Incorrect history keys or collisions can cause reuse of the wrong state; that isn't automatically an out-of-bounds memory-corruption bug. Exact token matching and SHA-256 also don't authenticate the caller or authorize cross-tenant sharing.
vLLM supports an optional request cache_salt included in the first block's key. The gateway should select and enforce the scope from verified identity, overriding or rejecting unauthorized caller-selected values.[2] A salt known to an attacker and accepted without authorization isn't an isolation boundary. Distinct structured keys and collision resistance protect this lookup path under the configured policy; they don't prove all side channels impossible.
Provider boundaries are separate contracts. OpenAI documents organization and regional processing isolation; its newer accounting keys should not be described as proof of physically separate KV storage. Claude documents workspace isolation on its API, Claude Platform on AWS, and Microsoft Foundry, and organization isolation on Bedrock and Google Cloud. A tenant label in application logs doesn't create an additional provider isolation boundary.[5][4]
This CPU fixture verifies distinct keys for supplied scopes. It doesn't authenticate those strings or test a provider's isolation:
1from hashlib import sha256
2import json
3
4def cache_key(tenant: str, prefix_version: str, tokens: str) -> str:
5 material = json.dumps(
6 ["model-revision-42", tenant, prefix_version, tokens],
7 ensure_ascii=False, separators=(",", ":"),
8 )
9 return sha256(material.encode()).hexdigest()
10
11prefix = "system|private-guide|tools"
12alpha = cache_key("tenant-alpha", "v7", prefix)
13beta = cache_key("tenant-beta", "v7", prefix)
14print("same tokens, scoped keys differ:", alpha != beta)
15print("stored key length:", len(alpha))
16print("alpha key for display:", alpha[:12])
17assert alpha != beta
18assert len(alpha) == 64
19assert cache_key("a|b", "c", "d") != cache_key("a", "b|c", "d")1same tokens, scoped keys differ: True
2stored key length: 64
3alpha key for display: 5f867552dca0Now try a forged label. The sessions below are trusted test fixtures standing in for an authentication result; they aren't a real login service.
1from hashlib import sha256
2import json
3
4verified_sessions = {"session-a": "tenant-alpha", "session-b": "tenant-beta"}
5
6def scoped_key(scope: str, prefix: str) -> str:
7 return sha256(json.dumps([scope, prefix], separators=(",", ":")).encode()).hexdigest()
8
9def gateway_key(session: str, client_label: str, prefix: str) -> str:
10 # The untrusted client_label never selects the sharing scope.
11 principal = verified_sessions[session]
12 return scoped_key(principal, prefix)
13
14prefix = "private-guide-v7"
15victim = gateway_key("session-a", "tenant-alpha", prefix)
16unsafe_probe = scoped_key("tenant-alpha", prefix)
17safe_probe = gateway_key("session-b", "tenant-alpha", prefix)
18print("caller-selected probe matches victim:", unsafe_probe == victim)
19print("server-selected probe matches victim:", safe_probe == victim)
20assert unsafe_probe == victim and safe_probe != victim
21try:
22 gateway_key("unknown-session", "tenant-alpha", prefix)
23except KeyError:
24 print("unknown session rejected before lookup")
25else:
26 raise AssertionError("unauthenticated session accepted")1caller-selected probe matches victim: True
2server-selected probe matches victim: False
3unknown session rejected before lookupFailure modes: debugging stale outputs and silent eviction
A common engineering trap is blaming exact-prefix caching when a model outputs outdated rules:
- Symptom: The coding bot continues enforcing last week's 90-day key rotation rule even after the repository guide changed to 30 days.
- Input check: Did the request actually contain the new guide? Inspect retrieval and rollout versions, plus a protected digest of the actual rendered stable guide. Comparing a full prompt digest to a guide-only digest is meaningless; changing user questions also changes the full digest.
- Cache check: Were preceding history, weights, adapters, and other conditioning included in the computation key? Correct exact keys prevent the edited guide from matching its old entry; custom keys can be wrong.
- Answer check: If input and cache behavior are correct, evaluate instruction following and answer quality. Fresh computation can still produce stale advice.
For supported OpenAI Responses models, set prompt_cache_options.comparison_response_id to a recent completed baseline response's ID and inspect prompt_cache_diagnostics. This requests a comparison; it doesn't load the old conversation or change caching behavior. Diagnostics are best effort. Use current usage counts for credited reuse.[6]
1from hashlib import sha256
2
3def exact_prefix_key(guide: str) -> str:
4 return sha256(f"system|{guide}|tools".encode()).hexdigest()
5
6before = exact_prefix_key("keys rotate after 90 days")
7after = exact_prefix_key("keys rotate after 30 days")
8print("changed guide invalidates exact-prefix key:", before != after)
9assert before != after1changed guide invalidates exact-prefix key: TrueCan requests still count as cache hits immediately after a guide edit?
Answer
Yes. Earlier unchanged blocks before the edit can hit while the changed guide and its downstream blocks are recomputed. Check the matched token boundary and rendered version, not just a binary hit counter.
Before promoting the guide, test both cold and warm requests against the updated rule. The cache should change the work performed, not which guide the model receives.