Prefix caching: the idea
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
Prefix caching keeps the KV cache of earlier prompts, so a new request with the same first tokens skips their prefill.
The idea#
Chat traffic repeats itself#
The KV cache keeps the keys and values of earlier tokens, in blocks of a fixed number of tokens (The KV cache). Without prefix caching, the engine frees these blocks when a request ends. But many requests start with the same tokens as an earlier request:
- System prompt. Every request of one application often starts with the same system message and tool list. That can be thousands of tokens.
- Multi-turn chat. The client sends the whole conversation again for each new turn. Turn 5 starts with turns 1 to 4.
- Few-shot examples. A fixed list of examples comes before each new question.
- RAG documents. Several questions about the same document all start with that document.
The KV values of a token depend only on that token and the tokens before it (causal attention). So if two prompts have the same first 1,000 tokens, the KV cache of those 1,000 tokens is also the same. The engine can keep it and use it again.
What reuse saves#
Prefill computes the KV cache of all prompt tokens before the first output token (Prefill and decode). A cache hit skips the prefill of the matched tokens. This saves GPU compute, and it makes the TTFT (time to first token) shorter. It also frees part of the token budget of the step for other requests.
A cache hit does not change the output. The model sees the same KV values that it would compute again.
Blocks, not single tokens#
vLLM shares the cache in whole blocks, with 16 tokens per block by default. Only a full block can be shared. A block that is not full still gets new tokens from its own request, so another request cannot use it.
To find a block, vLLM computes a hash for each full block. The hash of a block uses three inputs. They are the hash of the block before it (the parent), the token ids of the block, and extra keys. This makes a hash chain: the hash of block 3 depends on blocks 0, 1 and 2 too. So one hash names the whole prefix up to the end of its block.
The chain is necessary because the same 16 tokens can stand after different text. Their KV values are then different. A hash of only the 16 tokens would match the wrong KV values. With the chain, two blocks match only when everything before them matches too.
Eviction: least recently used#
When a request ends, its blocks are free, but they keep their hashes and their KV data. A later request can still hit them. In the normal case, the engine overwrites a cached block only when it needs a free block and has no free block without a hash. It then takes the block that was used least recently (LRU).
What breaks a hit#
- Any change early in the prompt. One different token in block 0 changes the hash of block 0, so every later hash changes too. A date or a request id at the top of the system prompt removes all reuse.
- A different LoRA adapter. The adapter changes the KV values, so the adapter is part of the hash.
- Different images or audio. The placeholder tokens of two images can be the same, so the hash also includes a hash of each multimodal item.
- A different cache salt. A client can add a secret salt. Only requests with the same salt share blocks.
- A different tokenization. The chat template renders the earlier turns again. If the result has other tokens than the first time, the hit stops there.
An everyday comparison#
Think of a long recipe that many cooks start in the same way. The first cook makes the base sauce and puts it in the fridge, with a label that says how it was made, step by step. The next cook who needs the same base takes it from the fridge and starts at the next step. When the fridge is full, the sauce that nobody used for the longest time goes out first.
The comparison stops here: a cook can use part of a pot. vLLM shares only full blocks of 16 tokens. Also, the label of each pot names all earlier steps, not only the last step.
The picture#
Read panel 1 from left to right: each arrow carries a parent hash into the next hash. Panel 2 is the second chat turn. vLLM looks up the hashes of request B from block 0 and stops at the first miss. All blocks after a miss also miss, because their hashes include the missed hash.
In vLLM#
This is a basics page, so this part gives only pointers. The page Prefix caching in the engine core: hashes, lookup, caching, eviction and KV events explains the data structures and the code in full.
- The default.
--enable-prefix-cachinghas the defaultNone(vllm/engine/arg_utils.py:1391-1397).EngineArgsthen sets it fromModelConfig.is_prefix_caching_supported(vllm/engine/arg_utils.py:3032,vllm/engine/arg_utils.py:3062-3063). For generative models this is true for decoder and hybrid models, and false for encoder-decoder and attention-free models (vllm/config/model.py:2276-2291). So prefix caching is on by default for a normal chat model. - The hash. The engine core picks the hash function from
--prefix-caching-hash-algo(defaultsha256,vllm/config/cache.py:256). It does this only when prefix caching or a KV connector is on (vllm/v1/engine/core.py:216-225).hash_block_tokens()hashes the tuple of parent hash, block tokens and extra keys (vllm/v1/core/kv_cache_utils.py:707-737). - The lookup. The full-attention manager walks the hashes from block 0 and stops at the first block that is not in the cache (
vllm/v1/core/single_type_kv_cache_manager.py:791-799).
def hash_block_tokens(
hash_function: Callable[[Any], bytes],
parent_block_hash: BlockHash | None,
curr_block_token_ids: Sequence[int],
extra_keys: tuple[Any, ...] | None = None,
) -> BlockHash:
"""Computes a hash value corresponding to the contents of a block and
the contents of the preceding block(s). The hash value is used for
prefix caching. We use LRU cache for this function to avoid recomputing
hash values for the same block contents.
Args:
hash_function: The hash function used to compute block hash.
parent_block_hash: The hash of the parent block. None
if this is the first block.
curr_block_token_ids: A list of token ids in the current
block. The current block is assumed to be full.
extra_keys: Extra keys for the block.
Returns:
The hash value of the block and the token ids in the block.
The entire tuple is used as the hash key of the block.
"""
if not parent_block_hash:
parent_block_hash = NONE_HASH
curr_block_token_ids_tuple = tuple(curr_block_token_ids)
return BlockHash(
hash_function((parent_block_hash, curr_block_token_ids_tuple, extra_keys))
)NONE_HASH. The hash function gets the parent hash, the token ids and the extra keys as one tuple.Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveWorked example: two chat turns with block size 16
The numbers are made up, but the arithmetic follows the code. The block size is 16, the default (vllm/config/cache.py:182, vllm/config/cache.py:459-460). Some attention backends need another size, and then the platform changes it (vllm/platforms/interface.py:771-781). With one KV cache group, vLLM hashes at the same size as the block size (vllm/v1/core/kv_cache_utils.py:840-858).
Turn 1 (request A). The system prompt, the first user message and the chat template give 75 prompt tokens. The cache is empty, so the hit is 0 and the engine computes all 75 tokens.
- 75 tokens = 4 full blocks (64 tokens) + 11 tokens in block 4. So request A has 4 block hashes after its prompt (
vllm/v1/core/kv_cache_utils.py:990-993). - The model answers with 45 tokens. The request then has 120 tokens. The block hasher runs again when new full blocks appear, so blocks 4, 5 and 6 also get hashes (
vllm/v1/core/kv_cache_utils.py:966-972). Request A ends with 7 hashed blocks (112 tokens) and one block with 8 tokens and no hash. - When request A finishes, its blocks go back to the free queue with their hashes.
Turn 2 (request B). The client sends the system prompt, user message 1, the answer 1 and user message 2. The chat template renders this as 150 tokens. Assume that tokens 0 to 117 are the same as in request A. The template adds tokens after the answer, so token 118 is the first new token.
| Block | Tokens | Same as request A? | Result |
|---|---|---|---|
| 0 to 6 | 0-111 | yes, all tokens and all parents | hit (7 blocks) |
| 7 | 112-127 | no: tokens 118-127 are new; request A had no hash for this block | miss |
| 8 | 128-143 | its parent hash is new | miss |
| 9 | 144-149 | not full, no hash | computed |
So request B gets 7 × 16 = 112 cached tokens and computes 150 − 112 = 38 tokens. That is 75 % less prefill work for this turn. With a long system prompt, the saving is often larger.
The metric for both turns. vLLM counts the prompt tokens of each lookup as queries and the matched tokens as hits (next deep dive). Queries = 75 + 150 = 225 tokens. Hits = 0 + 112 = 112 tokens. The hit rate is 112 / 225 = 49.8 %.
One edge case. Assume a prompt of exactly 64 tokens that is fully in the cache. The model must still compute the last prompt token, because it needs the logits of that token. So the maximum hit is num_tokens − 1 = 63 tokens (vllm/v1/core/kv_cache_manager.py:289-300). The lookup checks only 63 // 16 = 3 blocks (vllm/v1/core/single_type_kv_cache_manager.py:793). So the hit is 48 tokens, and the engine computes the last 16 tokens again.
Deep diveThe hash chain in the code, line by line
The engine core makes one hasher function at start-up. The function gets the hash block size and the hash function (vllm/v1/engine/core.py:216-225). The engine core then computes the block hashes of each new request.
def request_block_hasher(request: Request) -> list[BlockHash]:
start_token_idx = len(request.block_hashes) * hash_block_size
num_tokens = request.num_tokens
if start_token_idx + hash_block_size > num_tokens:
# Early stop when there no new full blocks created.
return []
curr_mm_idx = 0
mm_features = request.mm_features
if start_token_idx > 0 and mm_features:
last_mm_pos = mm_features[-1].mm_position
if last_mm_pos.offset + last_mm_pos.length > start_token_idx:
curr_mm_idx, _ = get_mm_features_in_window(
mm_features,
start_token_idx,
start_token_idx + hash_block_size,
)
prev_block_hash_value = (
request.block_hashes[-1] if request.block_hashes else None
)
new_block_hashes: list[BlockHash] = []
while True:
end_token_idx = start_token_idx + hash_block_size
if end_token_idx > num_tokens:
# We only hash full blocks
break
# MM and LoRA requests need extra keys for block-hash computation.
extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
request, start_token_idx, end_token_idx, curr_mm_idx
)
# Compute the hash of the current block
block_tokens = request.all_token_ids[start_token_idx:end_token_idx]
block_hash = hash_block_tokens(
caching_hash_fn, prev_block_hash_value, block_tokens, extra_keys
)
new_block_hashes.append(block_hash)
start_token_idx += hash_block_size
prev_block_hash_value = block_hash
return new_block_hashes- Lines 967-972. The hasher starts after the blocks that already have a hash. If no new full block exists, it returns an empty list.
- Lines 974-983. For multimodal requests, it finds the first multimodal item that can touch the next block.
- Lines 985-987. The parent of the first new block is the last hash of the request, or
Nonefor block 0. - Lines 988-993. The loop stops before a block that is not full: "We only hash full blocks".
- Lines 996-998. It makes the extra keys of this block (LoRA, multimodal items, cache salt, prompt embeddings).
- Lines 1001-1004. It hashes the parent hash, the 16 token ids and the extra keys.
- Lines 1006-1008. The new hash becomes the parent of the next block. This is the chain.
The value of block −1. Block 0 has no parent, so hash_block_tokens() uses NONE_HASH (vllm/v1/core/kv_cache_utils.py:731-732). init_none_hash() derives it from a seed (vllm/v1/core/kv_cache_utils.py:146-159). PYTHONHASHSEED wins if it is set. Otherwise sha256 uses the fixed seed "vllm-none-hash", and the xxhash options use random bytes (vllm/v1/core/kv_cache_utils.py:117-130). With a random seed, two vLLM instances compute different hashes for the same tokens.
The hash functions. Four names are valid (vllm/config/cache.py:174), and get_hash_fn_by_name() maps them to functions (vllm/utils/hashing.py:84-103):
--prefix-caching-hash-algo | Serialization | Hash | Note (vllm/config/cache.py:257-271) |
|---|---|---|---|
sha256 (default) | pickle | SHA-256 | cryptographic; the docs call it the most secure choice |
sha256_cbor | canonical CBOR | SHA-256 | reproducible across languages and versions |
xxhash | pickle | xxHash 128-bit | faster, not cryptographic; needs the xxhash package |
xxhash_cbor | canonical CBOR | xxHash 128-bit | reproducible, not cryptographic |
Deep diveExtra keys: LoRA, images, cache salt
generate_block_hash_extra_keys() collects the extra keys of one block (vllm/v1/core/kv_cache_utils.py:637-675):
- LoRA.
("lora", lora_name, lora_path)for every block of a LoRA request (vllm/v1/core/kv_cache_utils.py:590-607). The path is in the key, so a name that points to a new adapter path does not reuse old blocks. - Multimodal items.
("mm", identifier, offset)for each image, audio or video item that overlaps the block. The offset is the start of the item minus the start of the block. It is negative when the item starts in an earlier block (vllm/v1/core/kv_cache_utils.py:564-574). - Cache salt.
("cache_salt", salt), but only for block 0 (vllm/v1/core/kv_cache_utils.py:659-663). Block 0 is enough: every later hash depends on the hash of block 0. - Prompt embeddings. A SHA-256 of the embedding tensor of the block, when the request sends embeddings instead of token ids (
vllm/v1/core/kv_cache_utils.py:610-634).
The cache_salt request field. The chat completions request has an optional cache_salt string (vllm/entrypoints/openai/chat_completion/protocol.py:499-511). Its description says that the salt should be random and long, for example 256 bits in base64. A validator checks it before the request runs (vllm/entrypoints/openai/chat_completion/protocol.py:560-566). The salt must be a non-empty string of at most 128 characters, without @, /, \ or NUL. Otherwise the server returns HTTP 400 (vllm/entrypoints/generate/base/protocol.py:39-58).
{
"model": "Qwen/Qwen3-0.6B",
"messages": [{"role": "user", "content": "Hi"}],
"cache_salt": "Zk9hc2l2ZS1yYW5kb20tc2FsdC1mb3ItdGVuYW50LTQy"
}
Why a salt. A hit makes TTFT shorter. An attacker could send a guessed prompt and measure TTFT to learn whether another user sent that prompt. The design document gives this reason (docs/design/prefix_caching.md:87). Requests with different salts never share blocks.
Deep diveFree blocks, LRU order and eviction
The block pool keeps all free blocks in one doubly linked list, FreeKVCacheBlockQueue. The class comment gives the order: the least recently used block is at the front. For two blocks that a request freed at the same time, the block with more hashed tokens (the tail of the chain) comes first (vllm/v1/core/kv_cache_utils.py:258-279).
- Free. A request frees its blocks in reverse order, so its tail block is first (
vllm/v1/core/single_type_kv_cache_manager.py:597-598).free_blocks()puts blocks without a hash at the front of the queue, and blocks with a hash at the end (vllm/v1/core/block_pool.py:776-807). So the pool reuses blocks without a hash first, and it keeps cached blocks as long as it can. - Hit. When a request hits a free cached block,
touch()removes the block from the free queue and increments its reference count (vllm/v1/core/block_pool.py:752-770). Several running requests can share one block. - Allocate.
get_new_blocks()pops blocks from the front of the queue (vllm/v1/core/block_pool.py:678-681). If such a block still has a hash,_maybe_evict_cached_block()removes the hash from the cache (vllm/v1/core/block_pool.py:729-750). This is the eviction.
There is no time limit and no separate cache size. The cache is simply the set of free blocks that still have a hash. The more free KV memory the GPU has, the longer cached blocks survive.
Deep diveWatching the hit rate
The scheduler records one lookup per admitted request (vllm/v1/core/sched/scheduler.py:1378-1382). The KV cache manager adds the prompt tokens as queries and the matched tokens as hits (vllm/v1/core/kv_cache_manager.py:253-262). It skips requests that did not look up the cache. The counters vllm:prefix_cache_queries and vllm:prefix_cache_hits also skip a request that was preempted before. Its new lookup goes to separate preempted_* fields, which no logger exports (vllm/v1/metrics/stats.py:146-151).
| Where | Name | Meaning |
|---|---|---|
| Prometheus counter | vllm:prefix_cache_queries | prompt tokens that were looked up (vllm/v1/metrics/loggers.py:661-667) |
| Prometheus counter | vllm:prefix_cache_hits | tokens found in the cache (vllm/v1/metrics/loggers.py:672-676) |
| Log line | Prefix cache hit rate: x% | printed by the log-line stat logger (vllm/v1/metrics/loggers.py:321-323) |
A hit rate for a dashboard is rate(vllm:prefix_cache_hits[5m]) / rate(vllm:prefix_cache_queries[5m]). The rate is a fraction of tokens, not of requests.
Requests that skip the cache. A request that asks for prompt logprobs does not read the cache by default, because the hit tokens would have no logprobs (vllm/sampling_params.py:625-632). The lookup also does not run when prefix caching is off (vllm/v1/core/kv_cache_manager.py:249-251).
Reset. The route POST /reset_prefix_cache exists, but only as a development route (vllm/entrypoints/serve/dev/cache/api_router.py:20). The API server adds the dev routes only when VLLM_SERVER_DEV_MODE is set (vllm/entrypoints/launchers/api_server/routers.py:46-49).
What can go wrong#
- A changing value at the top of the prompt. A timestamp, a user name or a random id in the system prompt changes block 0. Every hash after it changes too (
vllm/v1/core/kv_cache_utils.py:1001-1008). Put the fixed text first and the changing text last. - The last turn does not hit. The chat template renders earlier answers again. Some templates change them, for example they drop reasoning text. Then the tokens differ from the generated tokens, and the hit stops at that point.
- The hit rate drops under load. Cached blocks are only free blocks with a hash. When many requests run, the pool takes free blocks for new requests and evicts the cached ones (
vllm/v1/core/block_pool.py:686-690). More KV memory, or fewer concurrent requests, keeps more of the cache. - No reuse across instances with xxhash. Without
PYTHONHASHSEED, each process picks a random seed forNONE_HASHand logs a warning (vllm/v1/core/kv_cache_utils.py:146-159). That matters for KV connectors that share blocks between instances. - A reloaded LoRA with the same path. The LoRA key holds the name and the path, not the weights. If you load new weights in place from the same path, old blocks still match. The API server logs a warning about this (
vllm/entrypoints/openai/models/serving.py:215-224). - Prompt logprobs. A request with
prompt_logprobsskips the cache read, so its TTFT does not improve (vllm/sampling_params.py:625-632).
Why it matters. For chat traffic, prefix caching is often the largest single cut of TTFT and prefill compute. You get the full benefit only when the prompts share long, exact token prefixes. Design the prompt order for it, and watch the hit rate.
Check yourself#
- 1The block size is 16. A prompt has 40 tokens. How many block hashes does vLLM compute for it before any output?easy
Show answer
Two. 40 tokens are 2 full blocks (32 tokens) and 8 tokens in a third block. The hasher hashes only full blocks (
vllm/v1/core/kv_cache_utils.py:990-993). - 2Why does the hash of a block include the hash of the block before it?medium
Show answer
The KV values of a token depend on all earlier tokens. The same 16 tokens after different text have different KV values. The parent hash makes each hash depend on the whole prefix (
vllm/v1/core/kv_cache_utils.py:731-737), so a match means that all earlier tokens match too. - 3Two users send the same system prompt. User 1 sendsmedium
cache_salt"a", user 2 sends no salt. Do they share blocks?Show answer
No. The salt is an extra key of block 0 (
vllm/v1/core/kv_cache_utils.py:659-663). So block 0 has a different hash, and every later hash is different too, because each hash includes its parent. - 4A prompt of 48 tokens is fully in the cache (3 hashed blocks). How many tokens does the engine compute for it, and why?hard
Show answer
16 tokens. The maximum hit is
num_tokens − 1= 47, because the engine needs the logits of the last prompt token (vllm/v1/core/kv_cache_manager.py:289-295). The lookup checks only47 // 16= 2 blocks (vllm/v1/core/single_type_kv_cache_manager.py:793). So the hit is 32 tokens, and the engine computes the last block of 16 tokens again. - 5A request finishes. Its blocks have hashes. Which of them does the block pool reuse first for a new request, and which last?hard
Show answer
The pool first reuses free blocks without a hash, because
free_blocks()puts them at the front of the queue. It puts blocks with a hash at the end (vllm/v1/core/block_pool.py:797-807). Among the blocks of the finished request, the tail block is freed first (vllm/v1/core/single_type_kv_cache_manager.py:598), so it is evicted before the head blocks. The head blocks are the shared prefix that other requests most probably need.