99% 的人一听到“缓存”,就以为都是一回事:命中就快、未命中就慢。对 LLM 来说,这个直觉是错的。KV 缓存、前缀缓存、提示缓存和语义缓存,名字相似、位置相近,但存的东西完全不同,踩坑方式也不一样。理解错一层,你可能白花 30% 的推理成本,还以为是模型太贵。
LLM 堆栈里,这四个组件都被叫做“缓存”,但它们各自负责的对象和风险差异巨大。

KV 缓存存的是单次请求里的注意力张量。
前缀缓存把这些张量搬到服务端共享,用 token ID 的哈希链做键。

提示缓存是云厂商对同一类前缀重用做了“计费包装”,读写按不同倍率收费。
语义缓存则直接存完整响应字符串,用嵌入向量的余弦相似度做键。
前三种都是精确匹配:命中与否只影响成本和延迟,不会改变答案本身。语义缓存是模糊匹配:可能给你一个“看起来对”的错误答案,还老老实实返回 200 状态码。
可以把它们想成四层不同的“记忆”:从显存里的瞬时记忆,到跨请求共享的长期记忆,再到云厂商托管的记忆,最后是带主观判断的“模糊记忆”。每一层都能省钱,也都能埋雷。
下面按层拆开讲:它们各自存什么、怎么命中、在哪些真实场景里会帮你,在哪些地方会坑你。

所有代码示例都在单机 CPU 上跑,模型是 3.6 亿参数的小模型,外加一个 Anthropic API 示例和一个基于 sentence-transformers 的迷你语义缓存。对那些只存在于服务引擎内部的机制,用伪代码说明逻辑,不强行在 notebook 里复现。
transformers 在 v5 改了缓存 API,下面代码默认你用的是 v5+。如果你还在 v4,DynamicCache() 不需要配置参数,dtype 参数名也不同(叫 torch_dtype)。
安装依赖:
- LLM 推理与 KV / 前缀缓存示例:
pip install "transformers>=5.0" torch- 量化缓存示例:
pip install optimum-quanto- 语义缓存示例:
pip install sentence-transformers- 提示缓存(Anthropic)示例:
pip install anthropic
1) KV 缓存:单次请求里的“短期记忆”
1.1 KV 缓存到底在存什么?
预填充阶段,模型会为每个提示 token、在每一层都算出 key 和 value 向量,并把它们存起来。
解码阶段,模型用这些已经存好的向量做注意力计算,每生成一个新 token,只追加一对新的 key-value,而不是每一步都重算整个序列。


查询向量不会被缓存,原因是因果掩码:每个 token 的 query 只在它自己那一步用一次,之后就没人再读它。key 和 value 会被后续所有 token 反复读取,所以它们才是缓存的主角。
不使用 KV 缓存时,每一步解码都要对“到目前为止的整个序列”做一次矩阵乘法。
用了 KV 缓存之后,每一步只需要对“新 token”做矩阵-向量乘法,算力需求直接降一个量级。


说实话,这里有个很多人忽略的反转:虽然每个 token 的计算量变小了,但每一步都要从高带宽显存里把整块缓存搬出来,解码过程从“算力受限”变成了“内存带宽受限”。
注意力核本身跑得飞快,GPU 很多时间其实在等内存。

1.2 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))
print("prompt tokens:", inputs["input_ids"].shape[1])
print("total tokens:", out.shape[1])
print("cache length:", past_key_values.get_seq_length())
generate 一般会在内部自动创建和销毁缓存,对用户是透明的。这里我们手动构造 DynamicCache 并传入,生成结束后还能继续拿它做实验。
get_seq_length() 返回缓存里记录了多少个 token 位置,结果是“提示长度 + 生成 token 数 - 1”。
最后一个 token 的 key 和 value 虽然算出来了,但还没被任何后续 token 关注。
从这个例子可以看出:缓存会为每个见过的 token 存一条记录,每一步解码都线性增长。
DynamicCache 是默认实现,因为它按需增长,不预分配整块内存,短请求不会白占空间。


缓存大小直接决定了 GPU 上能并行多少个请求。大小由模型结构决定,并且随 token 数线性增长——每一层、每个 KV 头,都要为每个 token 存一份 key 和 value。
有用户反馈,用 700 亿参数 BF16 模型、128K 上下文时,单个请求的 KV 缓存就接近 40GB,几乎和 4-bit 量化权重一样大。
常见的“瘦身”手段包括:
- 分组查询注意力(Grouped-query attention):一组 query 头共享同一份 key 和 value,缓存体积变小,每字节 FLOPs 提升。


- DeepSeek 系列的多头潜变量注意力:把整块缓存压缩成潜变量向量,再从潜变量恢复注意力信息。
- 缓存量化:用更低精度存 KV,换一点数值精度,换来大约两倍的容量节省。transformers 已经内置:
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 值会以低精度存储,内存占用下降,但每次访问都要做量化/反量化。
后端要求“组大小”能整除头维度,一些特殊架构会直接报错拒绝配置。
短上下文场景里,这点额外开销可能让你变慢,比较适合显存吃紧但延迟还能接受的情况。
1.3 缓存如何随请求释放?
上面的例子都在一次调用里完成。大多数推理引擎会在请求结束时释放这块缓存,所以 20 轮对话在第 20 轮时,模型会重新预填充第 1~19 轮的所有内容,成本照样算满。


另一种玩法是:跨轮次保留缓存,不释放。
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})
inputs = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
return_tensors="pt", return_dict=True
).to(model.device)
input_length = inputs["input_ids"].shape[1]
outputs = model.generate(
**inputs, do_sample=False,
max_new_tokens=64,
past_key_values=past_key_values
)
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 只在循环外创建一次,每轮都传进去,第一轮结束后不会被释放,第二轮还能继续用。
- 每轮都重建完整消息列表,再用 apply_chat_template 渲染,第二轮的提示 = 第一轮所有内容 + 新问题。
- 因为缓存已经包含第一轮的 token,模型只需要为新后缀做预填充。input_length 虽然变长,但真正的预填充工作量没变。
- 回答从 outputs 中切片出来,追加回 messages,下一轮的提示就是上一轮的严格扩展。
这种重用只在“第二轮的 token 序列以第一轮为前缀且完全一致”时成立,一旦你改了更早的内容,缓存就失效。
这个例子里,缓存只存在于一个 Python 进程里的一个变量。生产环境中,缓存通常属于一个共享池,成千上万的请求会一起用它。接下来要讲的前缀缓存,就是把这件事做成系统级能力。

2) 前缀缓存:跨请求共享的“公共记忆”
2.1 前缀缓存的核心思路
前缀缓存的出发点,是对引擎行为做了一个小小的改动:
请求结束后,不再把 KV 缓存块直接释放,而是留在显存里,挂到一个索引表上,供后续请求查找。这就是所谓的前缀缓存。
索引必须遵守聊天循环的规则:只有“前面的 token 完全相同”时,才允许重用。
以 vLLM 为例,它默认把 token 序列切成 16 个 token 一块的缓存块,每块通过“父块哈希 + 当前块 token ID 哈希”的链式方式唯一标识。


父哈希会被带入子哈希,块查找就变成了“前缀查找”:只有前面所有块都匹配,才算命中。
调度器会按顺序遍历请求的块,一旦遇到第一个未命中,就停止继续匹配。命中的块会增加引用计数,防止正在被使用时被驱逐。
未命中之后的部分,会重新分配缓存并做预填充。
2.2 查找逻辑长什么样?
vLLM 在调度器内部跑这套逻辑,真实实现还嵌着内存管理和张量分配。下面是抽掉细节后的核心:
BLOCK_SIZE = 16
def block_hashes(token_ids, salt=None):
"""把 token 序列链式哈希成每块一个键。"""
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 数,以及需要预填充的剩余 token。"""
matched_blocks = 0
for h in block_hashes(token_ids):
if h not in cache:
break # 第一次未命中就停
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 个一组切块,每块的键通过
hash((parent, block))递归折叠,键 5 代表“块 1~5 的组合”,而不是单独的第 5 块。 - 范围止于
len(token_ids) - BLOCK_SIZE + 1,尾部不完整块不进索引,每次请求都要重算。


- schedule 顺序遍历这些键,遇到未命中就停,不会尝试“跳过一个块再匹配后面的块”,因为后续块的键本身就依赖前面的块。
- ref_count 用来标记块是否正在被使用,只有计数为 0 的块才会被驱逐。
匹配到的部分就是 reused_tokens,后面的 to_prefill 需要重新预填充。
这里还有一个容易被忽略的细节:
BLOCK_SIZE = 16
def block_hashes(token_ids, salt=None):
"""把 token 序列链式哈希成每块一个键。"""
hashes, parent = [], hash(salt)
...
salt 参数的作用是:
- 如果两个请求发的是完全相同的文本,且 salt 一样,就会得到相同的块键,指向显存里同一块 KV 缓存。张量只存一份,多请求共享读取。
- 这对同一应用的多用户请求来说非常理想,命中率高、显存利用率好。
- 但对多租户平台,可能需要隔离。给每个租户传入不同的 salt,会让相同文本生成不同键,避免跨租户共享缓存。
代价也很直接:内存占用变大,命中率变低,但隔离性更好。
2.3 transformers 里的“前缀重用”玩法
transformers 也支持“先预填充一个前缀,再在多个续写之间复用缓存”。

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)
# 只跑一次预填充,不采样
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(...) 做预填充,不采样,只是把 KV 填进缓存,返回 past_key_values。
- 循环里,每个问题都拼在同一个前缀后面,完整字符串再分词,前缀部分的 token ID 每次都一样,满足“链式哈希前缀一致”的条件。
- copy.deepcopy 给每个请求一份独立缓存,避免第一条问题的生成结果“污染”第二条问题的前缀。在生产里不会真的复制张量,而是共享物理块,用引用计数来管理。
2.4 驱逐策略与命中率的真实影响
只有完整块会被索引,尾部不完整块每次都要重算。
这意味着块大小是个需要调的参数:
- 块大:查表次数少,内存局部性好;
- 块小:共享更细粒度,尾部浪费更少。

驱逐会直接拉低命中率。
缓存和运行时批次共享同一块 GPU 内存池,缓存越大,可并行的序列就越少。vLLM 在压力下会按 LRU 驱逐未被引用的块。

混合流量场景更棘手:长对话或长系统前缀占用最多块,一旦被驱逐,损失也最大。
启用前缀缓存前,有两件事要想清楚:
- 它只节省预填充时间,对解码时间没有帮助,不能把所有加速都算在它头上。
- 哈希和查表本身有开销,如果你的提示几乎从不重复,基准测试里吞吐量可能反而下降。
还有一个更隐蔽的问题,对 RAG 影响尤其大。

RAG 的提示通常包含:系统指令 + 检索到的文档块 + 用户查询。文档块会随请求变化,顺序也可能不同。哪怕两次检索到了同一批文档,只要顺序不一样,在链式哈希下就完全无法共享。


有人会想:那我把每个文档块单独预填充,再把缓存拼起来不就行了?
问题在于:
- 直接拼接张量会让位置编码错位;
- 块之间没有交叉注意力,每块都以为自己从位置 0 开始;
- 边界处必须重算一部分 token,不能简单拼接。

开源社区已经给出了一些更聪明的方案。

LMCache 提出的 CacheBlend,不是简单拼接块缓存,而是允许在任意位置重用缓存,只重算“对全注意力影响最大”的少量 token。
这部分 token 恢复了跨块注意力,也修正了位置编码,输出质量和全量预填充几乎一致。

和完全重算比,首 token 延迟可以提升大约 2~3 倍,重算成本还能和“从慢存储加载缓存块”并行。
它支持 vLLM,会自动从提示里读出块边界,即便检索文档顺序不同,也能复用缓存。


仓库地址:https://github.com/LMCache/LMCache
3) 提示缓存:云厂商卖给你的“前缀缓存服务”
3.1 提示缓存的行为与计费
托管模型不会把块表和驱逐策略暴露给你,而是提供一个更高层的能力:你只需要告诉它“这里可以缓存”,剩下的由服务端完成。
缓存对象依然是 KV 张量,而不是提示文本本身;匹配规则依然是“渲染后的上下文前缀必须完全一致”。


渲染后的上下文还包含提供商侧的系统内容,对你来说,最小缓存长度和失效规则看起来有点“黑盒”。
下面用 Anthropic 的提示缓存做个例子:
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": {"type": "ephemeral"}。
- cache_control 标记了“从请求起始到这个块”为可缓存前缀。
- 用户消息放在标记下面,因为每次都变,不会进缓存。
- usage 里的计数器能帮你确认缓存是否真的生效:第一次写入非零、读取为零;第二次反过来,说明命中缓存,指令部分按输入费率的 0.1 倍计费。
- 如果两者都为零,通常是因为前缀长度低于模型的最小缓存长度,服务端直接忽略了缓存请求,也不会报错。
直观一点说,如果你把 cache_control 挪到用户消息上,每次问题都不一样,读取计数就永远是 0。

3.2 提示缓存的经济学:什么时候值回票价?
Anthropic 的定价是:
- 写入缓存:按基础输入费率的 1.25 倍收费;
- 读取缓存:按基础输入费率的 0.1 倍收费;
OpenAI 的新模型也采用了类似的倍数结构。
这意味着:
- 写入是溢价操作,只有后续多次命中,才能摊薄成本;
- 读取只能命中你之前写入过的断点;
- 写入只会发生在你显式标记的地方。

每次调用,服务端会从你标记的断点开始,向后查找有限数量的旧写入记录。

Anthropic 的文档里提到,最多会回溯 20 个块。超过 20 块的长对话,最早的写入会被“看不见”,命中就此停止。
这些都建立在“提供商还保留着你的缓存条目”的前提下,一旦缓存被清理,你就回到冷启动状态。
有一个常被忽略的玩法,是预先对一批语料做“离线预填充”,把缓存存下来,后续查询直接命中。这在大规模 RAG 系统里很有用,有团队在内部测试中发现,热门文档的预填充可以在 100~200 次查询内回本。
4) 语义缓存:可能“省一次推理,也可能错一次答案”
4.1 语义缓存的工作流
前面三种技术都只是在省预填充计算,模型本身还是要跑一遍。
语义缓存的玩法完全不同:

- 对输入提示做一次嵌入;
- 在缓存里对历史提示做最近邻搜索;
- 如果相似度超过阈值,直接返回历史响应,完全跳过模型调用。

所以它缓存的是“输入 + 输出文本”,而且每次请求都要做嵌入计算,哪怕最后没命中。
下面是一个极简语义缓存示例:
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})"
fake_model = lambda p: f""
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把向量单位化,方便用点积算余弦相似度。如果不归一化,不同长度的提示得分根本没法比。- lookup 返回嵌入向量,是为了在未命中时直接复用,避免重复算嵌入,节省一次模型调用或 API 费用。
- 线性搜索只适合 demo,规模大了要用近似最近邻索引(如 HNSW、IVF),并且要调召回率。
- store 只在未命中时调用,缓存的响应没有任何“正确性验证”,最大风险就是:你可能在缓存里固化了一个错误答案。
4.2 语义缓存的“隐形风险”:相似度不等于可复用
下面这段代码专门演示风险:
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'
- 第一对是同义句,答案可以完全复用。
- 第二对只多了一个 not,答案应该完全相反。
- 第三对是不同计费周期,很多公司年付和月付的退款策略差别很大。
但从相似度上看,三组都很高,第一和第二组只差 0.009,远远不足以在真实流量中可靠区分。
这就是语义缓存的核心矛盾:
- 阈值调高:命中率下降,嵌入费用还在,收益变小;
- 阈值调低:命中率上升,但错误答案的概率也上升;
- 公开产品里的默认阈值从 0.75 到 0.97 都有人用,这本身就说明“没有通用安全值”。
我自己的观察是:语义缓存更适合“FAQ + 明确边界”的场景,比如工单系统里高频问题、固定模板回复,而不适合高风险决策或合规敏感内容。

语义缓存从设计上就不是 100% 可靠的,它依赖嵌入模型的表达能力,而嵌入本身就会在否定、时间、数量这些维度上犯错。你可以降低风险,但没法彻底消除。
5) 四种缓存的对比与一个“第五层”

可以粗略这样记:
- KV 缓存:单次请求内的注意力状态,精确匹配,未命中只多花算力。
- 前缀缓存:请求结束后把 KV 状态留在共享池里,后续请求按 token 前缀查找,依然是精确匹配。
- 提示缓存:云厂商在自家硬件上跑前缀缓存,并把“重用部分”单独计费给你。
- 语义缓存:按嵌入相似度存储和查找“输入 + 输出文本”,命中时直接跳过模型,可能给出错误答案。
还有一种不太常被提起的“第五层”:精确匹配响应缓存。只有当请求字节完全一致时,才直接返回历史答案。它同时缓存输入和输出,但没有误判风险。很多团队会先测“字节级重复率”,如果已经能覆盖 10% 以上的请求,再考虑是否要上语义缓存。
6) 生产环境里的实战建议
6.1 提示结构:一行变量,毁掉整条缓存
在生产里,这些问题比算法本身更常见:
- 提示前面带变量(时间戳、请求 ID、用户名等),会让后续所有块都失效。应该把稳定内容放前面,变量内容放后面,并在边界处设置缓存标记。

- 工具 schema 经常被放在系统提示前,一旦重排,整条缓存报废。
- 检查所有“渲染进提示”的配置项。比如 Anthropic 里切换网页搜索、引用、思考模式或工具选择,都会重写提示文本,导致后续块失效。做 A/B 测试时,两种推理策略会把缓存一分为二。
- 对话历史摘要如果是“重写前缀”,下一次调用就要为新前缀全额付费。更稳妥的做法是“原地截断工具输出”,保证前缀字节完全一致,缓存仍然有效。

- 缓存条目是绑定模型的,切到更便宜的模型时,之前的缓存都用不了,需要重新预填充整段历史。

6.2 用 token ID 而不是日志文本来排查缓存失效
要精确知道两条提示从哪里开始不一样,最靠谱的办法是直接比较 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}: "
f"{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.
两条提示在日志里看起来几乎一样,但因为 BOS token、尾部换行、默认系统消息或工具 schema 顺序不同,token 序列其实早早就分叉了。
比较 token ID 可以精确定位“重用停止点”,再顺着附近的 ID 解码成文本,通常就能看到真正的差异。
上面这个例子里,第一轮没有显式 system 消息,聊天模板自动填了一个默认 system,导致两条提示在索引 3 就开始不同,前缀缓存完全用不上。
如果把这四层缓存放在一条线上看:
- KV 缓存:保存单次请求的注意力状态;
- 前缀缓存:请求结束后保留状态,供后续请求按前缀查找;
- 提示缓存:云厂商在自家硬件上跑前缀缓存,并对重用部分单独计费;
- 语义缓存:按嵌入相似度存储和查找响应文本,命中时跳过模型,可能给出错误答案但状态码依然是成功。
很多团队在实测中发现:只要把提示结构和缓存策略理顺,整体成本可以稳定下降 20%~40%。这套判断方法在不同项目里反复验证有效,值得你收藏下来,遇到新模型或新工作负载时拿出来对照一遍。
如果你正在为“到底该开哪种缓存、阈值怎么调、提示怎么拆”纠结,这篇内容往往比问一圈身边人更有用,也更接近真实的生产经验。
常见问题
Q:KV 缓存和前缀缓存有什么本质区别,实际部署时该先上哪一个?
A:KV 缓存是单次请求内部的优化,几乎所有现代 LLM 推理框架都会默认开启;前缀缓存是跨请求共享 KV 的机制,需要引擎层支持。前者只影响“这一次”的预填充速度,后者能在多轮对话或高重复前缀场景下持续省钱。判断标准是:如果你的服务是长对话或大量相同系统提示,优先确认 KV 缓存已开启,再评估前缀缓存命中率(可通过统计前缀重复度和平均上下文长度)。一般建议:先确保 KV 缓存配置正确,再在压测环境里逐步打开前缀缓存,观察显存占用和首 token 延迟的变化。
Q:提示缓存什么时候值得用,怎么判断写入溢价能不能回本?
A:提示缓存适合“长指令 + 多轮调用”的场景,比如复杂系统提示、长工具 schema 或大段文档说明。判断是否回本,可以用一个简单公式:设指令部分长度为 L,写入成本是 1.25L,后续每次命中只付 0.1L,当命中次数 N 满足 1.25L + 0.1L×(N-1) < N×L 时,就开始赚。化简后大约 N≥2 时就有优势,但要考虑缓存失效和提供商的块回溯上限。实操建议:先在灰度流量上打开提示缓存,记录 cache_creation 和 cache_read 的 token 数,按账单反推真实节省,再决定是否全量启用。
Q:语义缓存会不会把错误答案“固化”,怎么降低风险?
A:语义缓存确实可能把一次错误回答长期复用,这是它最大的风险。原因在于嵌入模型对否定、时间、数量等细节不够敏感,相似度高不代表答案可复用。降低风险的做法包括:只对低风险场景启用(如 FAQ、模板回复),对高风险问题(支付、合规、医疗)直接绕过缓存;设置较高阈值并分场景调参;对命中结果增加轻量校验,比如检查关键实体、日期或金额是否一致。还可以定期抽样命中结果做人工评估,用错误率来动态调整阈值和缓存策略。
Q:RAG 系统里前缀缓存命中率很低,有没有实用的优化思路?
A:RAG 的提示结构天然不利于前缀缓存,因为检索块顺序和内容经常变。原因是链式哈希要求“从开头到当前位置完全一致”,一旦文档顺序不同,后面全部失效。可操作的优化包括:把系统提示和工具 schema 固定在最前面,并单独作为一个可缓存前缀;对检索块做稳定排序(按文档 ID 或相似度降序),减少顺序抖动;使用像 LMCache 这类支持任意位置重用的方案,只重算跨块影响最大的少量 token。实践中,先把“稳定前缀”抽出来缓存,往往就能拿到 10%~20% 的预填充节省,再考虑更复杂的 CacheBlend 方案。
Q:如何快速判断一条对话历史还能不能命中缓存,有没有通用排查步骤?
A:最直接的方法是比较两次请求的 token ID 前缀,而不是肉眼看文本。通用步骤是:1)用同一个 tokenizer 对两次完整提示做编码;2)从头开始逐个比较 token ID,找到第一个不相等的位置;3)把该位置附近的 ID 反解成文本,确认差异来源(系统消息、时间戳、工具顺序等)。如果差异出现在很靠前的位置,说明缓存几乎帮不上忙,需要调整提示结构。建议在调试环境里加一个“前缀重合度”日志指标(如 shared_prefix_len / total_len),长期观察哪些改动会显著拉低这个比例,再针对性优化。这样比盯着延迟曲线猜原因要高效得多。
——
我也不太确定哪一种缓存会在你那边制造最多的惊喜或麻烦,但可以肯定的是:一旦你把这四层的边界和风险摸清楚,调优 LLM 成本和延迟就不再是“黑魔法”,而是可以复用的一套工程手艺。


