把理解「你的输入 token 在哪里被重复计算、又能做些什么」所需的一切一次讲清。本文从第一性原理出发覆盖四层缓存机制、它们的取舍、相互之间的作用,以及阻碍缓存复用的五个最常见问题。
LLM 技术栈里有四样东西,各自存储四种不同的对象,而它们全都被称为 caching(缓存)。
- KV cache 为单个请求存储 attention 张量。
- Prefix caching 在服务器上存储同样的这些张量,以基于 token ID 的哈希链作为索引键。
- Prompt caching 是云服务商对同一套查找逻辑的计费版本——读取按基础输入价的 0.1 倍计费,写入则要付 1.25 倍的溢价。
- Semantic cache(语义缓存)存储的是生成完毕的响应字符串,以 embedding 上的余弦相似度作为索引键。
前三者是精确匹配(exact-match)、对正确性无影响(correctness-neutral),所以一次 miss(未命中)让你付出的是金钱和延迟。第四者是模糊匹配(fuzzy-match),它会在返回 200 状态码的同时递给你一个错误答案。
所以今天,我们把四者逐一过一遍:每一层存的是什么,又是什么在悄无声息地破坏它。
本文的所有内容都跑在一台机器上(包括纯 CPU),用的是 360M 参数的模型。另有一个 Anthropic API 示例,以及一个基于 sentence-transformers 搭的小型语义缓存。凡是只存在于推理引擎内部的机制,我们就用伪代码走一遍逻辑,而不是假装它能在笔记本电脑上复现。
另外,transformers v5 里 cache API 的形态变了,下面的代码片段假定 v5 或更高版本。在 v4 上,等价写法是不带 config 参数的 DynamicCache(),以及用 torch_dtype= 而不是 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
1)KV Cache
在 prefill 阶段,模型为每个 prompt token 在每一层计算一个 key 向量和一个 value 向量,并把它们存起来。
随后的 decoding 阶段就对这些已存储的向量做 attention,并为每个新生成的 token 追加一对新的 KV,而不是每一步都重算整个序列。
Query 不会被缓存,原因在于因果掩码(causal masking)。一个 token 的 query 向量只用一次——就在处理该 token 的那一步——之后再也不会被读取。而它的 key 和 value 会被排在它之后的每一个 token 读取,所以这两个才是最值得保存的向量。
- 不存它们,每个 decode 步都需要对到目前为止的完整序列做一次矩阵-矩阵乘法。
- 存了它们,每一步就变成只对一个新的 token 做矩阵-向量乘法,FLOPs 少得多。
下面的视频展示了有和没有 KV caching 时的 LLM 推理过程:(原文此处内嵌视频,见文末原链接)
虽然这减少了每个 token 上的计算量,但每一步都得把整个 cache 从 HBM 加载进来,所以 decode 不再是计算受限(compute-bound),而是变成了内存带宽受限(memory bandwidth-bound)。
Attention kernel 的执行速度快于 cache 流入的速度,于是 GPU 在 decode 步的大部分时间都在等内存。
KV cache 随每个 token 增长
transformers 库把 cache 暴露为一等公民对象,你可以持有它、检视它、再把它传回去。
下面是一个最小化的代码演示:
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 方法时,cache 在内部创建又销毁,对你不可见。这里我们自己构造一个 DynamicCache 传进去,这意味着生成结束后我们仍然持有对它的引用。
get_seq_length() 报告 cache 持有的 token 位置数。运行之后你会发现,输出等于 prompt 长度加上生成的 token 数,再减一。
最后一个 token 的 key 和 value 被计算了,但不会再被任何东西 attend 到。
这段代码表明 cache 为每个见过的 token 持有一个条目,并且每个 decode 步恰好增长一个条目。
默认使用 DynamicCache,是因为它随生成推进而增长、而不是预分配,这样短请求就不会占用一块永远用不上的内存。
决定一张 GPU 上能装下多少请求的,正是 cache。它的大小由模型结构决定,并随 token 数线性增长,因为每一层都要为每个 KV head 持有一份 key 和 value 张量。
对 BF16 的 70B 模型来说,单个 128K 上下文的 cache 约 40 GB,相当于整个模型以 4-bit 权重量化后的大小。
有若干办法可以压缩它。比如 Grouped-query attention(GQA,分组查询注意力)让一组 query head 共享一个 key/value head,从而缩小 cache,并提高每加载一字节数据能换来的 FLOPs。
DeepSeek 系的多头潜在注意力(Multi-head Latient Attention, MLA)则把整件事压缩进一个潜在向量。
cache 量化(quantization)拿一点数值精度换大约翻倍的容量,transformers 也实现了它:
# 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))
两个参数就把默认 cache 换成了量化版。
KV 值以降低的精度存储,这节省了内存,代价是每次访问都要量化和反量化。
后端还要求 group size 能整除模型的 head dimension,所以一个不常见的架构可能直接拒绝这个配置。
在短上下文上,这点开销可能反而让事情更慢而不是更快,所以它最适合内存吃紧的时候用。
cache 随请求一起释放
上面这一切都发生在一次调用内部。请求结束时引擎会释放这些 block,于是一段 20 轮的对话在第 20 轮要把第 1 到 19 轮全部重新 prefill 一遍,付全价。
自己把 cache 跨轮次保活,就能看到另一种做法:
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"
past_key_values对象在循环外创建一次,传进每次 generate 调用。这样 cache 不会在第一轮结束时被释放,第二轮开始时它仍然有内容。- 每一轮,我们重建完整的消息列表,并用 apply_chat_template 重新渲染。第二轮发出的 prompt 包含第一轮的全部内容加上新的问题。
- 因为 cache 已经持有第一轮的 token,模型只 prefill 新增的后缀。打印出的 input_length 每轮都在涨,而实际的 prefill 工作量并没有。
- completion 从生成的 ID 里切出来,再追加回 messages。正是这一点让下一轮的 prompt 成为上一轮的严格扩展。
复用之所以成立,是因为第二轮的 token 序列以第一轮的 token 序列开头——逐位完全一致。只要你改动历史中更早的任何内容,cache 就失效。
在上面这个代码演示里,cache 属于一个进程里的一个 Python 变量。而在推理引擎里,它属于一个由成千上万个请求共同查询的共享池。接下来就讲这个。
2)Prefix Caching
上面讨论的共享池来自一处行为上的改变。
请求结束时,引擎把它的 KV block 留在内存里而不是释放掉,并保留索引,好让后来的请求能找到它们。这就是 prefix caching。
这个索引必须执行与聊天循环里相同的规则:只有当前面的 token 完全一致时,复用才有效。
vLLM 的做法是默认以 16 个 token 为单位存储 cache,并用「父 block 的哈希加上 block 内部的 token ID」再取哈希来标识每个 block。
把父哈希链进子 block,就把 block 查找变成了前缀查找,因为一个 block 只有在它之前的所有内容都匹配上时才会匹配。
调度器按顺序遍历传入的 block,在第一个 miss 处停下。一次命中会让该 block 的引用计数加一,这也让它在有请求正在使用期间不被驱逐。
从 miss 往后的一切都会得到全新分配和一次全新的 prefill。
查找代码
vLLM 在它的调度器里跑这套逻辑,外面裹着持有实际张量的内存管理。
下面的代码只保留决定复用的两个部分:把 token 序列变成 block key 的函数,以及遍历这些 key、算出前缀中有多少可以跳过 prefill 的函数。
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
block_hashes方法把 token 序列切成固定 16-token 的 block。每个 block 的 key 通过hash((parent, block))折入前一个 block 的 key,所以第 5 个 key 编码的是第 1 到第 5 个 block,而不只是第 5 个。- 循环范围止于
len(token_ids) - BLOCK_SIZE + 1,丢弃尾部任何不完整的 block。这些 token 永远不会被索引,每个在此结束的请求都会重算它们。 schedule方法按顺序遍历这些 key,遇到第一个缺失就停。不会尝试在序列更靠后的位置恢复匹配,因为后面 block 的 key 已经依赖于那个失败的前面 block 了。ref_count += 1把 block 标记为使用中。驱逐只会触碰计数为零的 block,这就是防止一个正在运行的请求被抽走自己 cache 的机制。- 匹配上的部分成为 reused_tokens,它之后的一切都被全新 prefill。
刚才讨论的代码里还有一件重要的事:
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
注意上面函数里的 salt 参数。
当两个请求发送完全相同的文本时,它们产生完全相同的 block key,于是指向 GPU 内存里同一批物理 KV block。这些张量只有一份,两个请求都读它。
两个请求来自同一个应用时,这正是你想要的行为。
但当它们来自不同客户时,这可能需要一个决策。把每个租户独有的值作为 salt 传入,会改变第一个父哈希,于是相同的文本现在为每个租户产生不同的 key,他们的请求永远不会落在同一批 block 上。
这样每个租户都得到自己的一份拷贝,代价是内存和命中率,换来的是隔离。
在 transformers 中的实现
transformers 允许你 prefill 一次 prompt,然后把得到的 cache 复用到若干个不同的续写上。
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而不是 DynamicCache,因为我们需要一块可以拷来拷去的固定分配。 model(...)这一次调用是 prefill。此处不采样任何 token。我们纯粹为了填充 cache 而把共享前缀过一遍模型,然后保留返回的 past_key_values。- 循环内部,每个问题都拼在同一个前缀后面。完整字符串被重新 tokenize,因此前缀部分的 token ID 每次都完全一致——这恰好就是引擎哈希链所检查的条件。
copy.deepcopy给每个请求一份 prefill 好的 cache 的独立拷贝。生成过程会通过追加原地修改 cache,不做拷贝的话,第一个问题就会污染第二个问题的前缀。生产级引擎不拷贝张量,而是共享物理 block 并跟踪引用计数,这让复用几乎零成本,而不是与前缀长度成正比。
驱逐对命中率的影响
如上所述,只有完整的 block 才会被索引,所以尾部不完整的 block 每次都被重算。
这意味着 block size 需要恰当调优:
- 更大的 block 意味着更少的查表次数和更好的内存局部性。
- 更小的 block 意味着更细粒度的共享和更少的尾部浪费。
驱逐会降低命中率,意料之中。
cache 和正在运行的批次从同一块 GPU 内存池取用,所以更大的 cache 意味着更少的并发序列;在压力之下,vLLM 会按最近最少使用(LRU)丢弃无引用的 block。
混合流量会让情况更糟,因为长的共享前缀占用的 block 最多,而丢掉它们才是真正疼的。
开启这个功能之前,你应该知道两件事:
- 它只省 prefill,decode 时间不变,把整体加速都归功于 cache 会高估它。
- 而且哈希本身有成本,在 prompt 天然各不相同的流量上,基准测试测出过吞吐倒退而非提升。
还有第三个问题,它取决于工作负载,对 RAG 影响最大。
一个 RAG prompt 包含系统指令、然后是检索到的 chunk、最后是查询,而 chunk 每个请求都在变、顺序也在变。两个检索到相同文档但顺序不同的请求,在链式哈希下完全不共享任何东西。
把每个 chunk 单独 prefill 再把各段 cache 缝在一起,是不行的。
缝合起来的张量带着错误的位置编码。没有任何 chunk 曾 attend 到其他 chunk。而且每个 chunk 都在它自认为是位置零的地方贡献了自己的 attention sink。要让这套可行,需要在边界处做部分重算,而不是简单拼接。
顺便说一句,开源界已经有现成的解决方案。
LMCache(开源)实现了 CacheBlend:它不是把各段 chunk cache 首尾相接粘起来,而是让它们在任意位置可复用,只重算一小部分 token——选出来的依据是预先算好的值与完整 attention 本会产出的值偏差最大的地方。
这个子集恢复了跨 chunk 的 attention 并修正了位置编码,因此输出保持在完整 prefill 的质量水平。
相比全部重算,这带来了约 2–3 倍的首 token 时间(TTFT)改善,且重算成本与从较慢存储取回缓存 chunk 的过程做了流水线并行。
它可以插进 vLLM 并从你的 prompt 里读出 chunk 边界,所以即使检索到的文档每次以不同顺序到来,检索类流量也能被复用。
仓库在这里:https://github.com/LMCache/LMCache
3)Prompt Caching
在托管模型上,你看不到任何 block 表或驱逐策略。你得到的是一份基于服务商自己前缀复用的价目表,外加两个控制旋钮。
被缓存的对象仍然是 KV 张量,不是你的 prompt 文本,而且它依然要求在完全渲染后的上下文上做精确前缀匹配。
渲染后的上下文包含你从未写过的、服务商侧的系统内容,这也是最小长度和失效规则从外面看起来很随意的一部分原因。
下面用代码演示 prompt caching 的一个版本:
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"
这段代码里只有一行真正涉及 cache。
你在哪里指定 cache_control,决定了请求的哪一部分会为它写入一条缓存条目,而 usage 计数器告诉你之后的调用有没有把那条条目读回来。
- 标记附着在你想要覆盖的最后一个 block 上,而不是一个区间。它会写入一条缓存条目,覆盖从请求开头到该 block(含)的全部内容。
- 用户消息位于标记之下,所以保持在缓存区域之外——它每次调用都在变,因此绝不能放进缓存区。
- usage 计数器告诉你底层发生了什么。第一次调用报告非零的 cache_creation_input_tokens 和零读取。第二次正好相反,指令部分按十分之一的输入价计费。
- 如果两个计数器都返回零,说明前缀低于模型的最小可缓存长度,整个请求在完全没有缓存的条件下处理了。这不会抛出任何错误。
凭直觉(也如上文所讨论的),如果我们把 cache_control 下移到用户消息上,读取计数将永远是零,因为被标记的 block 每次调用都在变。
Prompt caching 的经济学
Anthropic 对写入条目收基础输入价的 1.25 倍、读取收 0.1 倍,若想让条目保留更久还有更高的写入乘数。OpenAI 在其当前模型上采用同样的两个乘数。
这笔溢价会在后续请求中收回,因为 TTL 内被复用的一切都免于重算。
一次读取只能找到某个更早的请求写入过的条目,而写入只发生在你放置的断点处。
每次调用都会检查你的断点,未命中时会向后回溯有限个 block 去找一次更早的写入。
Anthropic 把这个上限设为 20 个 block,所以两次调用之间若塞进超过 20 个 block 的对话,就会把上一次写入推出范围,命中随之停止。
4)Semantic Caching
上面三种技术节省的都是 prefill 工作,模型照常运行。
语义缓存则把进来的 prompt 做 embedding,在已存储的 prompt 上跑最近邻搜索,相似度超过阈值就直接返回存储的响应。
这就是它既省输出 token 也省输入 token 的原因。也是每个请求都必须承担一次 embedding 往返的原因——包括所有 miss。
下面是几行代码实现的一个能跑的语义缓存演示:
# 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?"
上面这个类的每个方法都对应一个你在生产环境必须做的决策:
normalize_embeddings=True把每个向量变成单位长度,这让self.vectors @ vec可以用普通点积算余弦相似度。如果跳过归一化,不同长度 prompt 的得分之间就不可比。lookup把 embedding 随结果一起返回,这样 answer 稍后可以直接存储而无需重新 embed。这很重要,因为 embedding 的费用每个请求都要付,命中与否都一样,算两遍等于让缓存本身的固定成本翻倍。- 暴力 argmax 做演示没问题,上规模就错了。一旦超过几千条目,这要换成近似最近邻(ANN)索引,而那又在阈值之上引入了它自己的召回率设置。
store只在 miss 路径上、模型回答完之后被调用。在它成为未来所有超过阈值的 prompt 的响应之前,没有任何东西校验过那个答案。这暴露了该技术最大的风险:缓存不知道存储的响应是否正确,只知道新 prompt 看起来像不像旧的。
下面的代码展示了最后一点:
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'
- 第一对是真正的同义改写,应该共用一个答案。
- 第二对只差一个否定,需要相反的答案。
- 第三对只差一个操作数值,需要不同的答案。
尽管并不匹配,三组的得分却挤在一起。同义改写和否定之间只差不到百分之一分,这个边际太薄了,撑不住真实流量。
- 调高阈值,命中率崩塌,而你还在每次调用付 embedding 的钱。
- 调低阈值,命中率上去了,自信满满的错误答案比率也一起上去。
- 已发表的默认值从 0.75 到 0.97 不等,取决于你问谁——这告诉你它是你的流量属性,而不是一个可以照抄的值。
这本身不是一项完全可靠的技术,因为有些失败(如上面演示的)可以绕过任何阈值取值——它们源自 embedding 所表征的东西。
四项技术回顾
上面讨论的四项技术中有三项对正确性无影响,它们的 miss 只体现在成本和延迟上,别无其他。
语义缓存的工作方式不同,所以命中率在这里不是该报告的正确指标。
还有第五层,用得较少:精确匹配的响应缓存,当请求逐字节一致时返回存储的答案。它像语义缓存一样同时省输入和输出,却不带任何误报风险,因为它根本不做相似度匹配。在伸手拿 embedding 之前,先量一量你的逐字节重复率。当然它也有问题——现在你应该能自己指出来了,发在回复里吧。
生产环境的要点
每项技术都有一些失败点,在生产环境使用前应当记下:
- 如果 prompt 前部有任何变量——比如系统提示里的时间戳、请求 ID 或用户名——它之后的所有 block 全部失效。永远把稳定内容放前面、可变内容放最后,并在边界上打标记。
- 工具 schema 通常排在系统提示之前,所以一次重排就可能让整个缓存失效。
- 检查那些会被渲染进 prompt 的设置。在 Anthropic 上,切换 web search、citations、thinking 配置或 tool_choice 都会改写 prompt 文本并使其后的 block 失效。对两种推理力度做 A/B 测试会把你的缓存一分为二。
- 对历史做摘要会改写前缀,于是下一次调用要为冷 token 付全价。原地截断工具输出能让前缀逐字节保持一致、缓存继续存活。
- 缓存条目与模型绑定,所以路由到一个更便宜的模型,仍要按冷价 prefill 整段累积的历史。
要精确判断两个 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 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|>
"""
两个在你的日志里看起来一模一样的 prompt,可能差在一个序列起始(BOS)token、一个行尾换行、或一份重新序列化的工具 schema 上。
比较 token ID 而不是渲染后的文本,能找到复用中断的确切下标,把两侧各几个 ID 解码出来,通常就能定位到确切的文本。
上面这次运行展示了一个常见情形。
第一轮没有指定 system 消息,于是聊天模板填入了模型的默认值,两个 prompt 在下标 3 处就不一样了,因此毫无复用可言。
前三层其实是同一个思想,应用在三个不同的范围上。
- KV cache 在单个请求的存续期内持有 attention 状态。
- Prefix caching 在请求结束后保留那份状态,供后来的请求查找。
- Prompt caching 是服务商在自己的硬件上跑 prefix caching,并对你在他们那边复用的部分单独定价。
语义缓存的工作方式则不同。它以 embedding 相似度为键存储响应文本,所以一旦命中,它完全跳过模型,输入输出 token 一起省。命中也可能是错的,而且错的时候照样以正常的成功状态返回。
轮到你了:这四层里,哪一层耗费了你最多的调试时间?
就到这里!
如果你喜欢这篇教程:
来找我 → @_avichawla
我每天分享 DS、ML、LLM 和 RAG 的教程与洞察。
作者:Avi Chawla(@_avichawla,Daily Dose of DS 联合创始人)
原文:KV, Prefix, Prompt and Semantic Caching in LLMs, clearly explained(X Article,2026-08-28)
数据:57.7 万次阅读 · 1158 赞 · 2118 收藏 · 157 转发
相关链接:LMCache / CacheBlend