Glean 拾遗
Daily /2026-08-31 / KV, Prefix, Prompt and Semantic Caching in LLMs Explained

KV, Prefix, Prompt and Semantic Caching in LLMs Explained

Source x.com Glean’d 2026-08-31 06:00 Read 33 min
AI summary

This tutorial breaks down the four cache layers in an LLM serving stack from first principles: the KV cache for a single request, prefix caching for cross-request reuse on the server, provider-billed prompt caching, and a semantic cache that returns stored responses by embedding similarity. It includes runnable code for transformers DynamicCache/StaticCache, a compact vLLM-style chain-hash block scheduler, an Anthropic prompt-caching example, and a tiny semantic cache. Concrete numbers ground each trade-off: a 70B model holds roughly 40GB of KV tensors at 128K context; Anthropic bills reads at 0.1x and writes at 1.25x input rates; two sentences differing only by a negation score 0.952 cosine similarity yet need opposite answers. It also covers what silently breaks reuse: variable content in system prompts, reordered RAG chunks, partial tail blocks, eviction, and per-tenant salt separation. The piece is honest about failure modes, including a throughput regression on unique traffic and the false-positive risk of semantic caching. Useful for engineers operating inference, tuning RAG cost, or debugging prompt-cache misses.

Original · 33 min
x.com ↗
§ 1

Everything you need to understand where your input tokens are being recomputed and what to do about it. It covers the four cache layers from first principles, their trade-offs, what happens when they interact, and the five most common problems that inhibit cache reuse.


Four things in an LLM stack store four different objects, and all of them get called caching.

KV, Prefix, Prompt and Semantic Caching in LLMs, clearly explained

  • The KV cache stores attention tensors for one request.
  • Prefix caching stores those same tensors on the server, keyed by a hash chain over token IDs.
  • Prompt caching is the provider’s billed version of that same lookup, at 0.1x the base input rate on a read against a 1.25x premium on the write.
  • A semantic cache stores finished response strings, keyed by cosine similarity over an embedding. The first three are exact-match and correctness-neutral, so a miss costs you money and latency. The fourth is fuzzy-match, and it will hand you a wrong answer with a 200.

这篇文章解释的是:你的输入 token 到底在哪些环节被重复计算,以及你能做什么。内容从第一性原理讲清四层缓存、各自的权衡、彼此叠加时的表现,以及最常见的五个阻碍缓存复用的问题。


LLM 技术栈里有四个东西都在缓存,但它们缓存的对象各不相同。

KV、前缀、提示词与语义缓存详解

  • KV 缓存(KV cache)保存单次请求的注意力张量。
  • 前缀缓存(prefix caching)在服务器上保存同样的张量,用 token ID 的哈希链作为 key。
  • 提示词缓存(prompt caching)是云厂商把同样的查询做成了计费项:读命中按基础输入价格的 0.1x 计费,写入则要付 1.25x 的溢价。
  • 语义缓存(semantic cache)保存的是已经生成完的响应字符串,用 embedding 的余弦相似度作为 key。 前三种都是精确匹配,且不影响正确性,所以 miss 只会让你多花钱、多等延迟。第四种是模糊匹配,它可能在命中时直接给你一个错误答案,而且 HTTP 状态码还是 200。
§ 2

So today, let’s go through all four, what each one stores, and what quietly breaks it.

Everything here runs on one machine, CPU included, with a 360M parameter model. There is also one Anthropic API example and one small semantic cache built on sentence-transformers. Where a mechanism only exists inside a serving engine, we walk the logic in pseudocode instead of pretending it is reproducible on a laptop.

Also, the cache API changed shape in transformers v5, so the snippets below assume v5 or later. On v4, the equivalents are DynamicCache() with no config argument and torch_dtype= instead of dtype=.

pip install "transformers>=5.0" torch

# only for the quantized cache example
pip install optimum-quanto

# only for the semantic cache example
pip install sentence-transformers 

# only for the prompt caching example
pip install anthropic

所以今天我们把四层缓存逐个过一遍:每一层存什么,以及是什么在悄悄破坏它。

本文所有演示都跑在一台机器上,包括 CPU 场景,模型是 360M 参数。另有一个 Anthropic API 示例,以及一个基于 sentence-transformers 的小型语义缓存。凡是只存在于服务引擎内部的机制,我们就用伪代码讲清逻辑,不会假装它能在笔记本上复现。

另外,transformers v5 改了缓存 API 的形状,下面的代码默认 v5 或更新版本。如果你用的是 v4,等价写法是 DynamicCache() 不传 config 参数,以及用 torch_dtype= 代替 dtype=。

pip install "transformers>=5.0" torch

# 仅用于量化缓存示例
pip install optimum-quanto

# 仅用于语义缓存示例
pip install sentence-transformers 

# 仅用于提示词缓存示例
pip install anthropic
§ 3

During prefill, the model computes a key and value vector for every prompt token at every layer and stores them.

Decoding then attends over those stored vectors and appends one new pair per generated token, instead of recomputing the whole sequence each step.

KV, Prefix, Prompt and Semantic Caching in LLMs, clearly explained

Queries don’t get cached, and the reason is causal masking. A token’s query vector is used once, at the step that token is processed, and never read again. Its key and value are read by every token that comes after it, so those are the two most important vectors to save.

  • Without storing them, each decode step requires a matrix-matrix multiply over the full sequence that has been generated so far.
  • With it, the step becomes a matrix-vector multiply over one new token, which is far fewer FLOPs. The video below depicts LLM inference with and without KV caching:

While this reduces the computation on each token, you have to load the entire cache from HBM on every single step, so decode is no longer compute-bound but rather becomes memory bandwidth-bound.

Attention kernels finish faster than the cache can be streamed in, and the GPU spends most of a decode step waiting on memory.

预填充(prefill)阶段,模型为每个 prompt token、每一层各计算一个 key 向量和一个 value 向量,然后存下来。

解码(decoding)时,注意力机制在这些已存向量上做查询,每生成一个新 token 就追加一对新向量,而不是每一步都把整个序列重算一遍。

KV、前缀、提示词与语义缓存详解

查询向量(query)不在缓存里,原因是因果掩码。一个 token 的 query 向量只在该 token 被处理的这一步用到一次,之后不会再被读取;而它的 key 和 value 会被后面每个 token 读取,所以这两个才是值得保存的向量。

  • 如果不保存它们,每一步解码都要对已生成的全部序列做一次矩阵乘矩阵。
  • 保存之后,这一步变成对新 token 的矩阵乘向量,FLOPs 少得多。 下面这个视频展示的是有和没有 KV 缓存时,LLM 推理的差别:

虽然每个 token 的计算量降下来了,但每一步都要把整份缓存从 HBM 搬到芯片上,所以解码不再是算力受限,而是内存带宽受限。

注意力算子跑完的速度比缓存流入还快,GPU 在解码的绝大部分时间里都在等内存。

§ 4

KV cache growth with each token

The transformers library exposes the cache as a first-class object, so you can hold it, inspect it, and pass it back in.

Here is a minimal code demo of it:

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

inputs = tokenizer("The capital of France is", return_tensors="pt")
inputs = inputs.to(model.device)

past_key_values = DynamicCache(config=model.config)

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=20,
    past_key_values=past_key_values,
)

>>> print(tokenizer.decode(out[0], skip_special_tokens=True))
"""The capital of France is Paris. It is the largest city in
France and the second-largest city in the European Union."""

>>> print("prompt tokens: ", inputs["input_ids"].shape[1])
"prompt tokens: 5"

>>> print("total tokens: ", out.shape[1])
"total tokens:  25"

>>> print("cache length: ", past_key_values.get_seq_length())
"cache length:  24"

Normally, you invoke the generate method and the cache is created and destroyed internally, invisible to you. Here we construct a DynamicCache ourselves and hand it in, which means we still hold a reference to it after generation finishes.

get_seq_length() then reports how many token positions the cache holds. When you run this, the output contains the prompt length plus the tokens generated, minus one.

The final token's key and value are computed but never attended over by anything.

This code shows the cache holds one entry per token seen, and it grows by exactly one entry per decode step.

DynamicCache is used as the default because it grows as generation proceeds rather than pre-allocating, so short requests don't reserve memory they will never use.

The cache decides how many requests can fit on a GPU. Its size is fixed by the model shape and grows linearly with token count, since every layer holds a key and value tensor for every KV head.

For a 70B model at BF16, a single 128K context holds around 40 GB of cache, comparable to the entire model at 4-bit weights.

These are some ways to reduce this. For instance, Grouped-query attention shares one key and value head across a group of query heads, which shrinks the cache and raises FLOPs per byte of data loaded.

Multi-head latent attention in the DeepSeek line compresses the whole thing into a latent vector.

Cache quantization trades a little numerical accuracy for roughly double the capacity, and transformers implements it:

# requires: pip install optimum-quanto
out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=20,
    cache_implementation="quantized",
    cache_config={"nbits": 4, "backend": "quanto"},
)
print(tokenizer.decode(out[0], skip_special_tokens=True))

Two arguments replace the default cache with a quantized one.

The KV values are stored at reduced precision, which reduces memory at the cost of quantizing and dequantizing on every access.

The backend also requires the group size to divide the model's head dimension evenly, so an unusual architecture can reject the config outright.

On short contexts, that overhead can make things slower rather than faster, so it is best used when running low on memory.

KV 缓存随每个 token 增长

transformers 把缓存当成一等对象暴露出来,你可以持有它、检查它,再把它传回去。

下面是一段最小演示:

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

inputs = tokenizer("The capital of France is", return_tensors="pt")
inputs = inputs.to(model.device)

past_key_values = DynamicCache(config=model.config)

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=20,
    past_key_values=past_key_values,
)

>>> print(tokenizer.decode(out[0], skip_special_tokens=True))
"""The capital of France is Paris. It is the largest city in
France and the second-largest city in the European Union."""

>>> print("prompt tokens: ", inputs["input_ids"].shape[1])
"prompt tokens: 5"

>>> print("total tokens: ", out.shape[1])
"total tokens:  25"

>>> print("cache length: ", past_key_values.get_seq_length())
"cache length:  24"

平时你调用 generate,缓存在内部创建又在内部销毁,你完全看不见。这里我们手动构造一个 DynamicCache 并传进去,所以生成结束后仍然持有它的引用。

get_seq_length() 返回缓存里有多少个 token 位置。运行这段代码,输出是 prompt 长度加生成 token 数再减一。

最后一个 token 的 key 和 value 虽然算出来了,但不会有任何 token 再去关注它们。

这段代码说明:缓存对每个见过的 token 保存一条记录,每解码一步就精确增长一条。

DynamicCache 是默认实现,因为它随生成过程增长,而不是预分配,所以短请求不会预留永远用不到的内存。

缓存大小决定了 GPU 能同时跑多少个请求。它由模型形状固定,并随 token 数线性增长,因为每一层、每个 KV head 都各保存一份 key 和 value 张量。

对于一个 BF16 的 70B 模型,单个 128K 上下文大约要占 40 GB 缓存,几乎相当于整个模型 4-bit 量化后的大小。

有几种办法可以缩减。比如分组查询注意力(Grouped-query attention)让一组 query head 共享同一个 key/value head,缓存变小,但每字节数据加载对应的 FLOPs 变高。

DeepSeek 系列的 Multi-head latent attention(MLA)则把整份缓存压缩进一个潜变量向量。

缓存量化牺牲一点数值精度,换来约一倍的容量,transformers 直接支持:

# 需要:pip install optimum-quanto
out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=20,
    cache_implementation="quantized",
    cache_config={"nbits": 4, "backend": "quanto"},
)
print(tokenizer.decode(out[0], skip_special_tokens=True))

两个参数把默认缓存换成量化缓存。

KV 值以低精度存储,内存占用下降,代价是每次访问都要做量化和反量化。

这个后端还要求 group size 能整除模型的 head dimension,所以某些特殊架构会直接拒绝这个配置。

在短上下文上,这些额外开销可能让速度变慢而不是变快,所以最好在内存吃紧时再用。

§ 5

The cache is freed with the request

Everything above happens inside one call. The engine frees those blocks when the request finishes, so a 20-turn chat prefills turns 1 through 19 again on turn 20, at full cost.

You can see the alternative by keeping the cache alive yourself across turns.

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

past_key_values = DynamicCache(config=model.config)
messages = []

questions = ["What is the capital of France?", "And its population?"]

for prompt in questions:
    # Add to the history
    messages.append({"role": "user", "content": prompt})

   # Tokenize
    inputs = tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,
        return_tensors="pt", return_dict=True
    ).to(model.device)

    # Generate
    input_length = inputs["input_ids"].shape[1]
    outputs = model.generate(
         **inputs, do_sample=False,
         max_new_tokens=64,
         past_key_values=past_key_values
    )

    # decode
    completion = tokenizer.decode(outputs[0, input_length:], skip_special_tokens=True)

    # Append to message history
    messages.append({"role": "assistant", "content": completion})
    print(f"turn tokens in: {input_length} | cache now: {past_key_values.get_seq_length()}")

# Output:
"turn tokens in: 42 | cache now: 55"
"turn tokens in: 71 | cache now: 92"
  • The past_key_values object is created once, outside the loop, and passed into every generate call. That way, the cache is not freed at the end of turn one and is still populated when turn two begins.
  • On each turn, we rebuild the full message list and re-render it through apply_chat_template. The prompt sent on turn two contains everything from turn one plus the new question.
  • Because the cache already holds the tokens from turn one, the model only prefills the new suffix. The printed input_length grows every turn while the actual prefill work does not.
  • The completion is sliced off the generated IDs and appended back into messages. That is what makes the next turn's prompt a strict extension of the last one. Reuse only works because turn two's token sequence starts with turn one's token sequence, absolutely identical, bit by bit. If you edit anything earlier in the history, the cache becomes invalid.

In this code demo, the cache belongs to one Python variable in one process. In a serving engine, it belongs to a shared pool that thousands of requests look up against. Let's learn about that next.

缓存随请求结束而释放

上面这些都发生在一次调用内部。请求结束后,引擎会释放这些块,所以一个 20 轮对话在第 20 轮时,会把第 1 到第 19 轮全部重新 prefill 一遍,照单全收。

想避开这个开销,可以自己在多轮之间把缓存留住。

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

past_key_values = DynamicCache(config=model.config)
messages = []

questions = ["What is the capital of France?", "And its population?"]

for prompt in questions:
    # 先追加到历史
    messages.append({"role": "user", "content": prompt})

   # Tokenize
    inputs = tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,
        return_tensors="pt", return_dict=True
    ).to(model.device)

    # Generate
    input_length = inputs["input_ids"].shape[1]
    outputs = model.generate(
         **inputs, do_sample=False,
         max_new_tokens=64,
         past_key_values=past_key_values
    )

    # decode
    completion = tokenizer.decode(outputs[0, input_length:], skip_special_tokens=True)

    # 追加到消息历史
    messages.append({"role": "assistant", "content": completion})
    print(f"turn tokens in: {input_length} | cache now: {past_key_values.get_seq_length()}")

# 输出:
"turn tokens in: 42 | cache now: 55"
"turn tokens in: 71 | cache now: 92"
  • past_key_values 对象在循环外只创建一次,每次 generate 都传进去。这样第一轮结束时缓存不会被释放,第二轮开始时它仍然有效。
  • 每一轮我们都重建完整消息列表,再通过 apply_chat_template 重新渲染。第二轮发送的 prompt 包含第一轮全部内容加上新问题。
  • 因为缓存里已经有第一轮的 token,模型只需要 prefill 新的后缀部分。打印出的 input_length 每轮都在涨,但真正的 prefill 工作量没有涨。
  • completion 从生成的 ID 里切出来,再放回 messages。这是让下一轮 prompt 成为上一轮严格扩展的关键。 复用能成立,是因为第二轮 token 序列以第一轮 token 序列开头,完全一致,逐位相同。只要你改了历史里的任何内容,缓存就失效了。

这段演示里,缓存属于一个进程里的一个 Python 变量。在服务引擎里,它属于一个被成千上万个请求共享的池子。下面就来聊这个。

§ 6

2) Prefix caching

The shared pool discussed above comes from one change in behavior.

When a request finishes, the engine keeps its KV blocks in memory instead of freeing them, and leaves them indexed so a later request can find them. That is prefix caching.

The index has to enforce the same rule covered in the chat loop, where reuse is only valid if the earlier tokens are identical.

vLLM does that by storing the cache of 16 tokens by default and identifying each block by a hash over the parent block's hash plus the token IDs inside it.

Chaining the parent hash into the child turns a block lookup into a prefix lookup, since a block only matches if everything before it matched too.

The scheduler iterates over the incoming blocks in order and stops at the first miss. A hit increments that block’s reference count, which also pins it against eviction while a request is using it.

Everything from the miss onward gets fresh allocation and a fresh prefill.

2) 前缀缓存

上面说的共享池,其实只源于一个行为变化。

请求结束后,引擎不再释放 KV 块,而是把它们留在内存里,并保留索引,让后续请求能找到。这就是前缀缓存。

索引必须遵守和上面聊天循环一样的规则:只有当早期 token 完全相同时,复用才有效。

vLLM 默认按 16 个 token 一块来存缓存,每个块的标识是“父块哈希 + 块内 token ID”的哈希。

把父块哈希链进子块,就把“块查找”变成了“前缀查找”:一个块要能命中,前提是它之前的所有块都命中了。

调度器按顺序遍历传入的块,遇到第一个 miss 就停。命中会让该块的引用计数 +1,只要请求还在用,它就不会被逐出。

从 miss 位置开始的所有内容,都重新分配、重新 prefill。

§ 7

The lookup code

vLLM runs this inside its scheduler, wrapped in the memory management that owns the actual tensors.

The code below keeps only the two parts that decide reuse, i.e., the function that turns a token sequence into block keys and the function that walks those keys to work out how much of the prefix it can skip prefilling.

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """Chain-hash a token sequence into per-block keys."""

    hashes, parent = [], hash(salt)

    # Only complete blocks are hashed. A partial tail block is skipped.
    for start in range(0, len(token_ids) - BLOCK_SIZE + 1, BLOCK_SIZE):
        block = tuple(token_ids[start : start + BLOCK_SIZE])
        parent = hash((parent, block))
        hashes.append(parent)

    return hashes

def schedule(token_ids, cache):

    """Return how many tokens are reusable, and allocate the rest."""

    matched_blocks = 0

    for h in block_hashes(token_ids):
        if h not in cache:
            break                      # first miss ends all reuse
        cache[h].ref_count += 1        # pin it against eviction
        matched_blocks += 1

    reused_tokens = matched_blocks * BLOCK_SIZE
    to_prefill = token_ids[reused_tokens:]

    return reused_tokens, to_prefill
  • The block_hashes method slices the token sequence into fixed 16-token blocks. Each block's key folds in the previous block's key through hash((parent, block)), so key number five encodes blocks one through five rather than block five alone.

  • The range stops at len(token_ids) - BLOCK_SIZE + 1, which drops any partial block at the tail. Those tokens are never indexed and get recomputed on every request that ends there.

  • The schedule method iterates over the keys in order and stops on the first missing one. There is no attempt to resume matching later in the sequence, because a later block's key already depends on the earlier one that failed.

  • ref_count += 1 marks the block as in use. Eviction only touches blocks whose count is zero, which is what stops a running request from having its own cache pulled out from under it.

  • Whatever gets matched becomes reused_tokens, and everything after it is prefilled fresh.

查找代码

vLLM 把这套逻辑放在调度器里,外面包着一层真正持有张量的内存管理。

下面只保留决定复用的两个部分:把 token 序列转成块 key 的函数,以及遍历这些 key、算出可以跳过多少 prefill 的函数。

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """把 token 序列链式哈希成每个块的 key。"""

    hashes, parent = [], hash(salt)

    # 只哈希完整的块,尾部不完整的块跳过。
    for start in range(0, len(token_ids) - BLOCK_SIZE + 1, BLOCK_SIZE):
        block = tuple(token_ids[start : start + BLOCK_SIZE])
        parent = hash((parent, block))
        hashes.append(parent)

    return hashes

def schedule(token_ids, cache):

    """返回可复用的 token 数,其余部分重新分配。"""

    matched_blocks = 0

    for h in block_hashes(token_ids):
        if h not in cache:
            break                      # 第一个 miss 结束所有复用
        cache[h].ref_count += 1        # 固定该块,防止被逐出
        matched_blocks += 1

    reused_tokens = matched_blocks * BLOCK_SIZE
    to_prefill = token_ids[reused_tokens:]

    return reused_tokens, to_prefill
  • block_hashes 把 token 序列切成固定 16 token 的块。每个块的 key 通过 hash((parent, block)) 把前一个块的 key 也折叠进来,所以第 5 个 key 编码的是第 1 到第 5 个块,而不只是第 5 块。

  • range 停在 len(token_ids) - BLOCK_SIZE + 1,也就是丢掉尾部不完整的块。这些 token 永远不会被索引,每次请求只要结束在这里,就要重算。

  • schedule 按顺序遍历 key,遇到第一个缺失就停。它不会尝试在序列后面恢复匹配,因为后面块的 key 已经依赖失败的那个更早的块。

  • ref_count += 1 标记该块正在使用。逐出只碰引用计数为零的块,这能防止正在运行的请求被抽走自己的缓存。

  • 匹配成功的部分变成 reused_tokens,后面的全部重新 prefill。

§ 8

There's one more important thing in the code we just discussed:

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """Chain-hash a token sequence into per-block keys."""

    hashes, parent = [], hash(salt)

    # Only complete blocks are hashed. A partial tail block is skipped.
    for start in range(0, len(token_ids) - BLOCK_SIZE + 1, BLOCK_SIZE):
        block = tuple(token_ids[start : start + BLOCK_SIZE])
        parent = hash((parent, block))
        hashes.append(parent)

    return hashes

Notice the salt argument in the function above.

When two requests send identical text, they produce identical block keys, so they end up pointing at the same physical KV blocks in GPU memory. There is one copy of those tensors, and both requests read it.

That is the behavior you want when both requests come from the same application.

But it may need a decision when they come from different customers. So passing a per-tenant value as the salt changes the first parent hash, so identical text now produces different keys for each tenant and their requests never land on the same blocks.

This way, every tenant gets its own copy, which costs memory and hit rate but provides separation.

刚才那段代码里还有一个值得注意的点:

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """把 token 序列链式哈希成每个块的 key。"""

    hashes, parent = [], hash(salt)

    # 只哈希完整的块,尾部不完整的块跳过。
    for start in range(0, len(token_ids) - BLOCK_SIZE + 1, BLOCK_SIZE):
        block = tuple(token_ids[start : start + BLOCK_SIZE])
        parent = hash((parent, block))
        hashes.append(parent)

    return hashes

注意函数里的 salt 参数。

当两个请求发送相同文本时,它们会生成相同的块 key,于是指向 GPU 内存里同一组物理 KV 块。这些张量只有一份,两个请求都读它。

如果两个请求来自同一个应用,这正是你想要的行为。

但如果是不同客户,就需要斟酌了。给 salt 传一个租户级的值,会改变第一个父哈希,于是相同文本在不同租户下生成不同的 key,请求永远不会落到同一个块上。

这样每个租户都有自己的副本,代价是内存和命中率,但获得了隔离。

§ 9

Implementation in transformers

transformers lets you prefill a prompt once and reuse the resulting cache across several different continuations.

import copy
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, StaticCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

SHARED_PREFIX = """You are a careful assistant. 
                   Answer in one short sentence."""

prompt_cache = StaticCache(config=model.config, max_cache_len=1024)

prefix_inputs = tokenizer(SHARED_PREFIX, return_tensors="pt")
prefix_inputs = prefix_inputs.to(model.device)

# Prefill the shared prefix exactly once. No token is sampled here.
with torch.no_grad():
    prompt_cache = model(**prefix_inputs, past_key_values=prompt_cache)
    prompt_cache = prompt_cache.past_key_values

questions = ["What is the capital of France?", "Name one ocean."]

for question in questions:
    inputs = tokenizer(SHARED_PREFIX + question, return_tensors="pt")
    inputs = inputs.to(model.device)

    # each request gets its own copy
    past_key_values = copy.deepcopy(prompt_cache)   

    outputs = model.generate(
        **inputs, past_key_values=past_key_values, do_sample=False
    )
    print(tokenizer.decode(outputs[0], skip_special_tokens=True))
  • StaticCache is used instead of DynamicCache because we need a fixed allocation we can copy around.
  • The model(...) call is a prefill. No token is sampled here. We run the shared prefix through the model purely to populate the cache, then keep the returned past_key_values.
  • Inside the loop, each question is concatenated onto the same prefix. The full string is tokenized, so the token IDs for the prefix portion are identical every time, which is exactly the condition the engine's hash chain checks for.
  • copy.deepcopy gives each request its own copy of the prefilled cache. Generation mutates the cache in place by appending, so without the copy, the first question would corrupt the prefix for the second. A production engine does not copy the tensors. Instead, it shares the physical blocks and tracks reference counts, which is what makes reuse nearly free instead of proportional to prefix length.

transformers 里的实现

transformers 允许你把一个 prompt 先 prefill 一次,然后在多个不同续写之间复用得到的缓存。

import copy
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, StaticCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

SHARED_PREFIX = """You are a careful assistant. 
                   Answer in one short sentence."""

prompt_cache = StaticCache(config=model.config, max_cache_len=1024)

prefix_inputs = tokenizer(SHARED_PREFIX, return_tensors="pt")
prefix_inputs = prefix_inputs.to(model.device)

# 共享前缀只 prefill 一次,这里不采样任何 token。
with torch.no_grad():
    prompt_cache = model(**prefix_inputs, past_key_values=prompt_cache)
    prompt_cache = prompt_cache.past_key_values

questions = ["What is the capital of France?", "Name one ocean."]

for question in questions:
    inputs = tokenizer(SHARED_PREFIX + question, return_tensors="pt")
    inputs = inputs.to(model.device)

    # 每个请求拿到自己的副本
    past_key_values = copy.deepcopy(prompt_cache)   

    outputs = model.generate(
        **inputs, past_key_values=past_key_values, do_sample=False
    )
    print(tokenizer.decode(outputs[0], skip_special_tokens=True))
  • 这里用 StaticCache 而不是 DynamicCache,因为我们需要一份固定分配、可以到处复制的缓存。
  • model(...) 这一步就是 prefill,不采样任何 token。把共享前缀跑一遍模型,纯粹是为了填满缓存,然后保留返回的 past_key_values。
  • 循环里每个问题都拼在同一个前缀后面。完整字符串被 token 化,所以前缀部分的 token ID 每次都完全一致,这正是引擎哈希链要检查的条件。
  • copy.deepcopy 给每个请求一份独立的预填充缓存。生成会原地往缓存里追加内容,如果不复制,第一个问题会弄脏第二个问题要用到的前缀。生产引擎不会复制张量,而是共享物理块并维护引用计数,这才是复用几乎零成本、而不是随前缀长度线性增长的原因。
§ 10

The impact of eviction on hit rate

As discussed above, only complete blocks get indexed, so a trailing partial block is recomputed every time.

This means the block size should be tuned appropriately

  • Larger blocks imply fewer table lookups and better memory locality
  • Smaller blocks imply finer-grained sharing and less waste at the tail.

Eviction reduces hit rates, as expected.

The cache and the running batch draw from the same GPU memory pool, so a larger cache leads to fewer concurrent sequences, and under pressure vLLM drops unreferenced blocks by least recent use.

Mixed traffic makes this worse, because long shared prefixes occupy the most blocks and are the ones whose loss actually hurts.

Before you turn this on, you should know two things

  • It saves prefill only, so decode time is unchanged and crediting a whole speedup to the cache will overstate it.
  • And the hashing itself costs something, so on traffic with genuinely unique prompts, benchmarks have measured a throughput regression rather than a gain.

逐出对命中率的影响

如前所述,只有完整的块才会被索引,所以尾部不完整的块每次都要重算。

这意味着块大小需要仔细调:

  • 块越大,查表的次数越少,内存局部性越好。
  • 块越小,共享粒度越细,尾部的浪费越少。

逐出会降低命中率,这是意料之中的。

缓存和正在运行的 batch 共用同一块 GPU 内存池,所以缓存越大,能并发的序列就越少;在内存压力下,vLLM 会按最近最少使用策略丢弃未被引用的块。

混合流量会让情况更糟,因为长共享前缀占用的块最多,而这些块一旦丢失,代价最大。

在打开这个功能之前,有两个事实要知道:

  • 它只省 prefill,decode 时间不变,所以把整个加速都归功于缓存是高估了。
  • 哈希本身也有成本,所以在真正彼此独立的 prompt 流量上,基准测试测到的是吞吐下降,而不是提升。
§ 11

There’s a third problem, which is workload dependent, and it impacts RAG the most.

A RAG prompt includes a system instruction, then retrieved chunks, then the query, and the chunks change per request and change order between requests. Two requests that retrieve the same documents in a different order share nothing at all under the chain hash.

Prefilling each chunk on its own and stitching the caches together does not work.

The stitched tensors carry the wrong positional encoding. No chunk ever attended to any other chunk. And every chunk contributes its own attention sink at what the model thinks is position zero. Making it work needs partial recomputation at the boundaries rather than plain concatenation.

还有第三个问题,它取决于具体负载,而受打击最大的是 RAG。

一个 RAG prompt 包含系统指令、检索到的 chunks、最后是 query;chunks 每次请求都变,而且顺序也会变。两个请求即使检索到相同的文档,只要顺序不同,哈希链下就一点也不共享。

把每个 chunk 单独 prefill,再把缓存拼接起来,是行不通的。

拼接后的张量带上错误的位置编码。没有任何 chunk 关注过其他 chunk,而且每个 chunk 都会在模型以为的“位置 0”贡献一个自己的 attention sink。要让它工作,需要在边界做部分重算,而不是简单拼起来。

§ 12

Btw, the solution already exists in open source.

LMCache (open-source) implements CacheBlend, wherein, instead of gluing the chunk caches end to end, it reuses them at any position and recomputes only a small subset of tokens, chosen by where the precomputed values deviate most from what full attention would have produced.

That subset restores the cross-chunk attention and fixes up the positional encoding, so the output holds at full-prefill quality.

This leads to an improvement in the time to first token by roughly two to three times compared to recomputing everything, with the recompute cost pipelined against fetching the cached chunks from slower storage.

It plugs into vLLM and reads the chunk boundaries out of your prompt, so retrieval traffic gets reused even when the retrieved documents arrive in a different order each time.

Here's the repo: https://github.com/LMCache/LMCache

顺便说一句,开源社区已经有解决方案了。

LMCache(开源)实现了 CacheBlend:它不把各 chunk 的缓存头尾相接地粘起来,而是在任意位置复用它们,只重算一小部分 token——选择标准是这些位置的预计算值与完整注意力结果偏差最大。

这一小部分补上了 chunk 间的注意力,并修好位置编码,所以输出质量与完整 prefill 一致。

相比把所有内容重算一遍,首 token 时间大约能提升两到三倍;重算成本与从慢速存储取缓存块的过程流水线重叠。

它可以直接插进 vLLM,从你的 prompt 里读取 chunk 边界,所以即使检索文档每次到达的顺序不同,检索流量也能被复用。

仓库地址:https://github.com/LMCache/LMCache

§ 13

3) Prompt caching

On a hosted model, you don’t get any block table or the eviction policy. Instead, you get a price sheet over the provider’s own prefix reuse, plus two knobs for control.

The cached object is still KV tensors, not your prompt text, and it still requires an exact prefix match on the fully rendered context.

The rendered context includes provider-side system content you never wrote, which is part of why the minimum lengths and the invalidation rules look arbitrary from the outside.

Here's a version of prompt caching demonstrated with code:

import anthropic

client = anthropic.Anthropic()   # reads ANTHROPIC_API_KEY from the environment

# Must clear the model's minimum cacheable length or nothing is cached at all.
LONG_INSTRUCTIONS = "You are a precise technical editor. " * 400

def ask(question: str):
    return client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        system=[
            {
                "type": "text",
                "text": LONG_INSTRUCTIONS,
                "cache_control": {"type": "ephemeral"},   # everything above is cacheable
            }
        ],
        messages=[{"role": "user", "content": question}],
    )

for question in ["Summarize section 3.", "Now rewrite it for a beginner."]:
    resp = ask(question)
    u = resp.usage
    print(
        f"write={u.cache_creation_input_tokens} "
        f"read={u.cache_read_input_tokens} "
        f"uncached={u.input_tokens}"
    )

# Output:
"write=2823  read=0     uncached=14"
"write=0     read=2823  uncached=17"

Only one line in that snippet touches the cache.

Where you specify cache_control decides which part of the request gets an entry written for it, and the usage counters tell you whether a later call read that entry back.

  • The marker is attached to the last block you want covered, not to a range. It writes one cache entry spanning everything from the start of the request up to and including that block.
  • The user message sits below the marker, so it stays outside the cached region since it changes every call, so it must not be inside.
  • The usage counters tell you what's happening under the hood. The first call reports a non-zero cache_creation_input_tokens and a zero read. The second reports the reverse, and the instructions are billed at a tenth of the input rate.
  • If both counters come back as zero, the prefix was below the model's minimum cacheable length, and the request was processed with no caching at all. No error is raised for this. Intuitively (and as discussed above), if we move cache_control down onto the user message, the read counter will always be zero, because the marked block changes on every call.

3) 提示词缓存

在托管模型上,你看不到块表,也看不到逐出策略。你能拿到的,是厂商对自己前缀复用的一份价目表,外加两个控制旋钮。

缓存的对象仍然是 KV 张量,不是你的 prompt 文本,而且仍然要求在完整渲染后的上下文上精确匹配前缀。

渲染后的上下文里包含你从未写过的厂商侧系统内容,这也是为什么从外部看,最小可缓存长度和失效规则显得很随意。

下面用代码演示提示词缓存:

import anthropic

client = anthropic.Anthropic()   # 从环境变量读取 ANTHROPIC_API_KEY

# 必须超过模型的最小可缓存长度,否则什么都不会缓存。
LONG_INSTRUCTIONS = "You are a precise technical editor. " * 400

def ask(question: str):
    return client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        system=[
            {
                "type": "text",
                "text": LONG_INSTRUCTIONS,
                "cache_control": {"type": "ephemeral"},   # 上面所有内容都可缓存
            }
        ],
        messages=[{"role": "user", "content": question}],
    )

for question in ["Summarize section 3.", "Now rewrite it for a beginner."]:
    resp = ask(question)
    u = resp.usage
    print(
        f"write={u.cache_creation_input_tokens} "
        f"read={u.cache_read_input_tokens} "
        f"uncached={u.input_tokens}"
    )

# 输出:
"write=2823  read=0     uncached=14"
"write=0     read=2823  uncached=17"

这段代码里只有一行碰了缓存。

你在哪里指定 cache_control,就决定请求的哪一部分会写入缓存条目;usage 计数器则告诉你后面的调用有没有读回这个条目。

  • 标记是挂在你想覆盖的最后一个 block 上,而不是一个范围。它会写入一个缓存条目,覆盖从请求开始到该 block(含)为止的所有内容。
  • 用户消息在标记下面,所以留在缓存区之外:因为它每次调用都会变,不能放进缓存。
  • usage 计数器揭示了底层发生了什么。第一次调用里 cache_creation_input_tokens 非零,read 为零;第二次反过来,指令部分按输入价格的十分之一计费。
  • 如果两个计数器都返回 0,说明前缀低于模型的最小可缓存长度,请求完全没走缓存。这种情况不会报错。 直观上(也如前文所说),如果把 cache_control 移到用户消息上,read 计数器将永远是 0,因为被标记的块每次都变。
§ 14

The economics of prompt caching

Anthropic charges 1.25x the base input rate to write an entry and 0.1x to read it, with a higher write multiplier if you want it for a longer time. OpenAI applies the same two multipliers on its current models.

The premium cost is recovered in subsequent requests since anything reused inside the TTL will avoid any recomputation.

A read can only find an entry that some earlier request wrote, and writes happen only at a breakpoint you placed.

Each call checks your breakpoint, and on a miss it walks backward through a limited number of blocks looking for an older write.

Anthropic caps that at 20 blocks, so adding more than 20 blocks of conversation between two calls pushes the last write out of range and the hits stop.

提示词缓存的经济账

Anthropic 写入一条缓存按基础输入价格的 1.25 倍收费,读取按 0.1 倍;想保留更久,写入倍率会更高。OpenAI 当前模型也采用同样的两个倍率。

多付的溢价会在后续请求中赚回来,因为只要在 TTL 内命中,就省掉了全部重算。

一次读取只能找到之前某个请求写入的条目,而写入只发生在你放置的断点处。

每次调用都会检查你的断点;miss 时,它会向后回退有限数量的块,寻找更早的写入。

Anthropic 的上限是 20 个块。所以如果两次调用之间对话增加了超过 20 个块,最后一次写入就被推出范围,命中随之停止。

§ 15

4) Semantic caching

The three techniques above save prefill work and still run the model.

A semantic cache embeds the incoming prompt, runs a nearest-neighbor search over stored prompts, and returns a stored response outright when the similarity exceeds a threshold.

That’s why it saves output tokens as well as input. It’s also why every request must bear an embedding round trip, including every miss.

Here's a working semantic cache demo in a few lines of code:

# requires: pip install sentence-transformers
import numpy as np
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("all-MiniLM-L6-v2")

class SemanticCache:
    def __init__(self, threshold=0.95):
        self.threshold = threshold
        self.vectors = np.empty((0, encoder.get_sentence_embedding_dimension()))
        self.prompts, self.responses = [], []

    def _embed(self, text):
        return encoder.encode([text], normalize_embeddings=True)[0]

    def lookup(self, prompt):
        vec = self._embed(prompt)
        if len(self.prompts) == 0:
            return None, 0.0, vec
        scores = self.vectors @ vec           # cosine sim, vectors are unit length
        best = int(np.argmax(scores))
        if scores[best] >= self.threshold:
            return self.responses[best], float(scores[best]), vec
        return None, float(scores[best]), vec

    def store(self, prompt, response, vec):
        self.vectors = np.vstack([self.vectors, vec])
        self.prompts.append(prompt)
        self.responses.append(response)

cache = SemanticCache(threshold=0.95)

def answer(prompt, call_model):
    hit, score, vec = cache.lookup(prompt)
    if hit is not None:
        return hit, f"HIT  (score {score:.3f})"
    response = call_model(prompt)             # the expensive path
    cache.store(prompt, response, vec)
    return response, f"MISS (best {score:.3f})"

# Stand in for the model so this runs without an API key.
fake_model = lambda p: f"<answer for {p!r}>"

for q in ["How do I reset my password?",
          "How can I reset my password?",
          "Is the API rate limited?" ]:
    _, status = answer(q, fake_model)
    print(f"{status}  {q}")

# Output:
"MISS (best 0.000)  How do I reset my password?"
"HIT  (score 0.961)  How can I reset my password?"
"MISS (best 0.112)  Is the API rate limited?"

Every method in the class above maps onto a decision you have to make in production:

  • normalize_embeddings=True makes every vector unit length, which lets self.vectors @ vec compute cosine similarity as a plain dot product. If you skip the normalization, the scores cannot be compared across prompts of different lengths.
  • lookup returns the embedding alongside the result, so answer can store it later without re-embedding. That matters because the embedding is paid on every request, hit or miss, and computing it twice doubles the standing cost of having a cache at all.
  • The brute-force argmax is fine for a demo and wrong at scale. Once you are past a few thousand entries, this becomes an approximate nearest neighbor index, which introduces its own recall setting on top of the threshold.
  • store is called only on the miss path, after the model has answered. Nothing validates that answer before it becomes the response for every future prompt that scores above the threshold. This highlights the biggest risk with this technique. The cache has no notion of whether the stored response was correct, only of whether the new prompt looks similar to the old one.

4) 语义缓存

前面三种技术省的是 prefill 工作量,模型仍然要跑。

语义缓存则会把进来的 prompt 做 embedding,在已存 prompt 里做最近邻搜索,当相似度超过阈值时,直接把存好的响应返回。

这就是为什么它连输出 token 也一起省了。也正因如此,每个请求都要付出一次 embedding 往返,哪怕 miss 也一样。

这里是一个几行代码就能跑起来的语义缓存示例:

# 需要:pip install sentence-transformers
import numpy as np
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("all-MiniLM-L6-v2")

class SemanticCache:
    def __init__(self, threshold=0.95):
        self.threshold = threshold
        self.vectors = np.empty((0, encoder.get_sentence_embedding_dimension()))
        self.prompts, self.responses = [], []

    def _embed(self, text):
        return encoder.encode([text], normalize_embeddings=True)[0]

    def lookup(self, prompt):
        vec = self._embed(prompt)
        if len(self.prompts) == 0:
            return None, 0.0, vec
        scores = self.vectors @ vec           # 余弦相似度,向量已是单位长度
        best = int(np.argmax(scores))
        if scores[best] >= self.threshold:
            return self.responses[best], float(scores[best]), vec
        return None, float(scores[best]), vec

    def store(self, prompt, response, vec):
        self.vectors = np.vstack([self.vectors, vec])
        self.prompts.append(prompt)
        self.responses.append(response)

cache = SemanticCache(threshold=0.95)

def answer(prompt, call_model):
    hit, score, vec = cache.lookup(prompt)
    if hit is not None:
        return hit, f"HIT  (score {score:.3f})"
    response = call_model(prompt)             # 昂贵的路径
    cache.store(prompt, response, vec)
    return response, f"MISS (best {score:.3f})"

# 用假模型代替,免去 API key 也能运行。
fake_model = lambda p: f"<answer for {p!r}>"

for q in ["How do I reset my password?",
          "How can I reset my password?",
          "Is the API rate limited?" ]:
    _, status = answer(q, fake_model)
    print(f"{status}  {q}")

# 输出:
"MISS (best 0.000)  How do I reset my password?"
"HIT  (score 0.961)  How can I reset my password?"
"MISS (best 0.112)  Is the API rate limited?"

上面类里的每个方法,都对应生产环境里你必须做的一个决定:

  • normalize_embeddings=True 让所有向量长度为 1,于是 self.vectors @ vec 就能用普通点积算余弦相似度。如果不做归一化,不同长度 prompt 的得分无法比较。
  • lookup 在返回结果的同时把 embedding 也返回,这样 answer 可以直接存,不必再 embed 一次。这很重要,因为 embedding 成本每次请求都要付,无论命中与否;算两次等于把缓存本身的固定成本翻倍。
  • 暴力 argmax 做演示没问题,规模一大就不行。一旦超过几千条,就要换成近似最近邻索引,这会在阈值之上再引入一个自己的召回率设置。
  • store 只在 miss 路径上、模型回答之后调用。没有任何东西校验这个答案,就直接把它当作未来所有得分超过阈值的 prompt 的响应。这暴露了该技术最大的风险:缓存根本不知道存下的响应是否正确,只知道新 prompt 和旧 prompt 看着像。
§ 16

The code below depicts the last point:

pairs = [
    ("How do I reset my password?", "How can I reset my password?"),
    ("Is the API rate limited?",     "Is the API not rate limited?"),
    ("Refund policy for annual plans", "Refund policy for monthly plans"),
]

for a, b in pairs:
    va, vb = encoder.encode([a, b], normalize_embeddings=True)
    print(f"{float(va @ vb):.3f}   {a!r}  vs  {b!r}")

This is the output we get:

0.961   'How do I reset my password?'  vs  'How can I reset my password?'
0.952   'Is the API rate limited?'  vs  'Is the API not rate limited?'
0.887   'Refund policy for annual plans'  vs  'Refund policy for monthly plans'
  • The first pair is a genuine paraphrase and should share an answer.

  • The second pair differs by one negation and needs opposite answers.

  • The third differs by one operational value and needs different answers. Despite some mismatches, the scores for all three are close together. The paraphrase and the negation are separated by less than a hundredth of a point, which is far too thin a margin to hold across real traffic.

  • If you increase it, the hit rate collapses while you keep paying for embeddings on every call.

  • If you decrease it, the hit rate climbs alongside the rate of confidently wrong answers.

  • Published defaults range from 0.75 to 0.97 depending on who you ask, which tells you it’s a property of your traffic rather than a value to copy. This is not a fully reliable technique per se since some failures (as demonstrated above) can bypass any threshold value, because they come from what embeddings represent.

下面这段代码演示最后这一点:

pairs = [
    ("How do I reset my password?", "How can I reset my password?"),
    ("Is the API rate limited?",     "Is the API not rate limited?"),
    ("Refund policy for annual plans", "Refund policy for monthly plans"),
]

for a, b in pairs:
    va, vb = encoder.encode([a, b], normalize_embeddings=True)
    print(f"{float(va @ vb):.3f}   {a!r}  vs  {b!r}")

这是输出:

0.961   'How do I reset my password?'  vs  'How can I reset my password?'
0.952   'Is the API rate limited?'  vs  'Is the API not rate limited?'
0.887   'Refund policy for annual plans'  vs  'Refund policy for monthly plans'
  • 第一对是真正的同义改写,应该共享同一个答案。

  • 第二对只差一个否定词,需要的答案完全相反。

  • 第三对只差一个运营参数,需要的答案也不一样。 尽管语义各不相同,三对的得分却很接近。同义改写和否定之间的差距不到 0.01,这个余量太薄,真实流量里根本守不住。

  • 调高阈值,命中率崩掉,但每次调用仍要为 embedding 付费。

  • 调低阈值,命中率上去了,自信满满的错误答案也跟着上去了。

  • 公开资料里默认值从 0.75 到 0.97 都有,这恰恰说明它是你流量的属性,不是可以抄的数字。 严格来说,这并不是一种完全可靠的技术,因为某些失败(如上所示)可以绕过任何阈值——它们来自 embedding 本身所代表的东西。

§ 17

Recap of all four techniques

Three of the four techniques discussed above are correctness-neutral, so their misses show up in cost and latency and nowhere else.

The semantic cache works in a different way, so hit rate is not the right metric to report here.

There is a fifth, lesser-used layer as well. It's exact-match response cache that returns a stored answer when the request is byte identical. It saves input and output like a semantic cache and carries no false positive risk, because it does no similarity matching at all. You just measure your byte-identical repeat rate before reaching for embeddings. There are problems, of course, as you can probably identify by now. Post them in replies.

四种技术回顾

前面四种技术里有三种不影响正确性,所以它们的 miss 只体现在成本和延迟上,不会出现在别的地方。

语义缓存的工作方式不同,所以在这里报命中率并不是合适的指标。

其实还有第五层缓存,用得比较少:逐字节精确匹配的响应缓存。当请求字节完全相同时,直接返回存好的答案。它像语义缓存一样省输入和输出,但又完全没有误报风险,因为它根本不做相似度匹配。你只需要在引入 embedding 之前,先量一下自己流量里逐字节重复的比例即可。当然,问题也有,你大概已经能想到几个,欢迎在评论里说出来。

§ 18

Takeaways for production

Every technique has some failure point that you should note before using them in production:

  • If you have any variable in the front of the prompt, like A timestamp, request id or user name in the system prompt, this invalidates every block after it. Always put stable content first, variable content last, and a marker on the boundary.

  • Tool schemas are usually placed ahead of the system prompt, so a reorder can invalidate the whole cache.

  • Check the settings that get rendered into the prompt. On Anthropic, toggling web search, citations, thinking config, or tool_choice rewrites the prompt text and invalidates downstream blocks. A/B testing two reasoning efforts splits your cache in two.

  • Summarizing history rewrites the prefix, so the next call pays full price on cold tokens. Truncating tool outputs in place keeps the prefix byte-identical and the cache alive.

  • Cache entries are keyed to a model, so routing to a cheaper one still prefills the whole accumulated history at cold rates.

生产环境要点

每种技术都有失效点,上生产前你最好先记住这些:

  • 如果 prompt 开头有任何变量,比如系统提示里带时间戳、请求 ID 或用户名,它会让后面所有块全部失效。永远把稳定内容放在最前面,变量放在最后,并在边界打上标记。

  • 工具 schema 通常放在系统提示之前,所以一旦重排,整个缓存可能全部失效。

  • 检查那些会被渲染进 prompt 的设置。在 Anthropic 上,开关 web search、citations、thinking 配置或 tool_choice 都会改写 prompt 文本,使下游块失效。A/B 测试两种 reasoning effort,等于把缓存劈成两半。

  • 总结历史会重写前缀,下一次调用就要按冷 token 全价付费。原地截断工具输出则能让前缀保持逐字节一致,缓存继续存活。

  • 缓存条目绑定模型,所以即使路由到更便宜的模型,也要按冷价格 prefill 全部历史。

§ 19

To determine exactly where two prompts stop matching, compare their token IDs directly rather than the text you logged. Here's a demonstration:

messages_turn_1 = [{"role": "user", "content": "What is the capital of France?"}]
messages_turn_2 = [{"role": "system", "content": "Today is Tuesday."},
                   {"role": "user", "content": "What is the capital of France?"}]

# tokenize=True is the default and returns a plain list of token ids
a = tokenizer.apply_chat_template(messages_turn_1)
b = tokenizer.apply_chat_template(messages_turn_2)

shared = 0
for x, y in zip(a, b):
    if x != y:
        break
    shared += 1

print(f"shared prefix: {shared} tokens of {len(a)} and {len(b)}")
print(f"first divergence at index {shared}: {a[shared:shared+8]} vs {b[shared:shared+8]}")

# Output:
"""
shared prefix: 3 tokens of 35 and 26
diverges at index 3
  turn 1: [2683, 418, 253, 11173, 9042, 14260] You are a helpful AI assistant
  turn 2: [11814, 314, 27758, 30, 2, 198] Today is Tuesday.<|im_end|>
"""

Two prompts that look identical in your logs can differ by a beginning-of-sequence (BOS) token, a trailing newline, or a re-serialized tool schema.

Comparing token IDs instead of rendered text finds the exact index where reuse stops, and decoding the few IDs on either side usually finds the exact text.

The run above shows a common one.

Turn one specified no system message, so the chat template filled in the model's default, and the two prompts looked different at index 3, so no reuse was possible.

要精确定位两个 prompt 从哪里开始不一致,应该直接比较 token ID,而不是比较你记在日志里的文本。下面是个演示:

messages_turn_1 = [{"role": "user", "content": "What is the capital of France?"}]
messages_turn_2 = [{"role": "system", "content": "Today is Tuesday."},
                   {"role": "user", "content": "What is the capital of France?"}]

# tokenize=True 是默认行为,返回纯 token id 列表
a = tokenizer.apply_chat_template(messages_turn_1)
b = tokenizer.apply_chat_template(messages_turn_2)

shared = 0
for x, y in zip(a, b):
    if x != y:
        break
    shared += 1

print(f"shared prefix: {shared} tokens of {len(a)} and {len(b)}")
print(f"first divergence at index {shared}: {a[shared:shared+8]} vs {b[shared:shared+8]}")

# 输出:
"""
shared prefix: 3 tokens of 35 and 26
diverges at index 3
  turn 1: [2683, 418, 253, 11173, 9042, 14260] You are a helpful AI assistant
  turn 2: [11814, 314, 27758, 30, 2, 198] Today is Tuesday.<|im_end|>
"""

日志里看起来一模一样的两个 prompt,可能在 BOS token、末尾换行,或重新序列化的 tool schema 上不同。

直接比 token ID 而不是渲染后的文本,可以找到复用停止的确切位置;把分叉点两侧的几个 ID 解码出来,通常就能看到具体文本。

上面那次运行展示了一个常见情况:

第一轮没有指定 system message,于是聊天模板填入了模型默认值,两个 prompt 在第 3 个 token 处就开始不同,完全无法复用。

§ 20

The first three layers cover one idea, applied at three scopes.

  • The KV cache holds attention state for the duration of a single request.
  • Prefix caching keeps that state after the request ends so a later request can look it up.
  • Prompt caching is a provider running prefix caching on their own hardware and charging a separate rate for the part you reuse. The semantic cache works differently. It stores response text keyed by embedding similarity, so if there's a hit, it skips the model entirely and saves output tokens along with input tokens. A hit can also be wrong, and it returns with a normal success status when it is.

Over to you: which of these four layers has cost you the most debugging time?


That's a wrap!

If you enjoyed this tutorial:

Find me → @_avichawla

Every day, I share tutorials and insights on DS, ML, LLMs, and RAGs.

前三层其实是同一个想法,套在三个作用域上。

  • KV 缓存在单次请求的生命周期内存放注意力状态。
  • 前缀缓存在请求结束后继续保留这些状态,让后续请求可以查询。
  • 提示词缓存是云厂商在自己的硬件上跑前缀缓存,并对你复用的部分单独计费。 语义缓存则完全不同。它存的是响应文本,key 是 embedding 相似度;一旦命中,模型完全不用跑,输出 token 和输入 token 一起省掉。但命中也可能给错答案,而且返回时依然是正常的成功状态。

轮到你了:这四层缓存里,哪一层让你花的调试时间最多?


就到这里!

如果喜欢这篇教程:

找我 → @_avichawla

我每天分享 DS、ML、LLM 和 RAG 相关的教程与见解。

Open source ↗