关于「你的输入 token 到底在哪里被重新计算、以及该怎么办」,这篇文章想讲清楚需要知道的一切。它从第一性原理出发,过一遍四层缓存、它们各自的取舍、它们相互作用时会发生什么,以及最常见、最影响缓存复用的五个问题。
LLM 技术栈里有四样东西存着四种不同的对象,而它们全都被叫做「缓存」。
- KV 缓存存的是单次请求的注意力张量。
- **前缀缓存(prefix caching)**把同样的张量留在服务端,用 token ID 上的哈希链作为键。
- Prompt 缓存是服务商对同一套查找逻辑的计费版本:命中读取按基础输入价的 0.1 倍收费,代价是写入时要付 1.25 倍的溢价。
- **语义缓存(semantic cache)**存的是完整的回复字符串,用 embedding 的余弦相似度作为键。
前三种是精确匹配、不影响正确性的,所以未命中只会让你多花钱、多等一会儿。第四种是模糊匹配,它会给你一个错误答案,还带着 200 状态码。
所以今天我们把这四种都过一遍:每一种存什么,以及什么会悄悄让它失效。
这里所有代码都能在一台机器上跑(包括纯 CPU),用的是一个 3.6 亿参数的模型。另外有一个 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
# 仅 Prompt 缓存示例需要
pip install anthropic
1)KV 缓存
在预填充(prefill)阶段,模型为每一层、每一个 prompt token 计算一个 key 向量和一个 value 向量,并把它们存下来。
之后的解码阶段就在这些存好的向量上做注意力,每生成一个 token 就追加一对新的 key/value,而不是每一步都把整个序列重算一遍。
query 不会被缓存,原因是因果掩码。一个 token 的 query 向量只在处理它的那一步用一次,之后再也不会被读到。而它的 key 和 value 会被排在它后面的每一个 token 读取,所以这两个才是最值得保存的向量。
如果不存它们,每个解码步都需要在「到目前为止生成的完整序列」上做一次矩阵-矩阵乘法。
存了它们之后,这一步就变成只在一个新 token 上做矩阵-向量乘法,FLOPs 少得多。
把开启和关闭 KV 缓存的 LLM 推理过程对比一下,差别正在这里。
虽然这减少了每个 token 的计算量,但你每一步都得把整份缓存从 HBM(显存)里加载进来,所以解码不再受算力限制,而是变成受内存带宽限制。
注意力核(kernel)算完的速度比缓存流式读入的速度还快,于是 GPU 在一个解码步里大部分时间都在等内存。
缓存会随每个 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 就有一条记录,而且每个解码步刚好增加一条。
默认用 DynamicCache,是因为它随生成过程增长,而不是预先分配,所以短请求不会占用它永远用不到的内存。
缓存决定了一张 GPU 上能放下多少个请求。它的大小由模型形状固定,并随 token 数线性增长,因为每一层都要为每一个 KV 头保存一份 key 和 value 张量。
对一个 BF16 的 70B 模型,单个 128K 上下文的缓存大约占 40 GB,和整个模型按 4-bit 量化后的权重差不多大。
有几种办法可以压这个开销。比如**分组查询注意力(GQA)**让一组 query 头共享一个 key/value 头,这既缩小了缓存,又提高了「每加载一字节数据对应的 FLOPs」。
DeepSeek 系列里的**多头潜在注意力(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 维度,所以某些不常见的架构会直接拒绝这个配置。
在短上下文下,这点额外开销可能让速度变慢而不是变快,所以它最适合在内存紧张时用。
缓存随请求一起释放
上面这一切都发生在一次调用内部。请求结束时引擎就释放这些块,所以一段 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 对象只在循环外创建一次,然后传进每一次 generate 调用。这样缓存在第一轮结束时不会被释放,第二轮开始时它还装着数据。
每一轮我们都重建完整的消息列表,再用 apply_chat_template 重新渲染一遍。第二轮发出去的 prompt 包含第一轮的全部内容加上新问题。
因为缓存里已经有第一轮的 token,模型只预填充新增的那段后缀。打印出来的 input_length 每轮都在涨,但实际的预填充工作量没有涨。
生成的 ID 里切出 completion,再追加回 messages。正是这一步让下一轮的 prompt 成为上一轮的严格扩展。
复用能成立,唯一的原因是第二轮的 token 序列以第一轮的 token 序列开头——完全一致,逐 bit 相同。只要你改动了历史里更靠前的任何东西,缓存就失效了。
在这个示例里,缓存属于一个进程里的一个 Python 变量。在推理引擎里,它属于一个共享池,成千上万的请求都对着它做查找。下面就来讲这个。
2)前缀缓存
上面说的共享池,来自一个行为上的改动。
当一个请求结束时,引擎不释放它的 KV 块,而是留在内存里,并保留索引,好让后来的请求能找到它。这就是前缀缓存。
这个索引必须执行和聊天循环里一样的规则:只有当更靠前的 token 完全相同时,复用才有效。
vLLM 的做法是默认按 16 个 token 一块来缓存,并用「父块的哈希 + 块内 token ID」的哈希值来标识每个块。
把父块哈希链进子块,就把「块查找」变成了「前缀查找」,因为一个块只有在它前面的所有东西都匹配时才会匹配上。
调度器按顺序遍历进来的块,在第一个未命中处停下。命中会让那个块的引用计数加一,这同时也会把它钉住,在有请求正在用它时不被淘汰。
从未命中那一块往后的所有东西,都会重新分配、重新预填充。
查找代码
vLLM 在它的调度器里跑这段逻辑,外面还包着管理实际张量的内存管理代码。
下面的代码只保留决定复用的两部分:把 token 序列变成块键的函数,以及遍历这些键、算出前缀里有多少可以跳过预填充的函数。
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 可复用,并为其余部分分配空间。"""
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-token 块。每个块的键通过 hash((parent, block)) 把前一个块的键折叠进来,所以第五个键编码的是第一到第五块,而不是单独的第五块。
range 停在 len(token_ids) - BLOCK_SIZE + 1,这会丢掉尾部任何不满一块的部分。那些 token 永远不会被索引,每个在那里结束的请求都要重算它们。
schedule 方法按顺序遍历这些键,在第一个缺失的键处停下。它不会尝试在序列后面某处重新开始匹配,因为后一个块的键本来就依赖于前一个失败的块。
ref_count += 1 把块标记为使用中。淘汰只碰计数为零的块,这正是防止一个运行中的请求被人从底下抽走自己缓存的机制。
匹配上的部分成为 reused_tokens,它之后的一切都重新预填充。
刚才那段代码里还有一个重要的东西:
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
注意上面函数里的 salt 参数。
当两个请求发送完全相同的文本时,它们产生完全相同的块键,于是最终指向 GPU 内存里同一批物理 KV 块。这些张量只有一份,两个请求都读它。
如果两个请求来自同一个应用,这正是你想要的行为。
但当它们来自不同客户时,这就需要斟酌了。把一个「每租户一份」的值作为 salt 传进去,会改变第一个父哈希,于是相同文本现在为每个租户产生不同的键,他们的请求永远不会落到同一批块上。
这样每个租户都有自己的一份副本,代价是内存和命中率,换来的是隔离。
在 transformers 里的实现
transformers 允许你把一个 prompt 预填充一次,然后在多个不同的续写里复用得到的缓存。
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)
# 把共享前缀预填充,且只做一次。这里不采样任何 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(...) 这次调用是一次预填充,不采样任何 token。我们把共享前缀跑一遍,纯粹是为了填充缓存,然后留下返回的 past_key_values。
循环里,每个问题都拼在同一个前缀后面。整个字符串一起分词,所以前缀部分的 token ID 每次都完全一致——这正是引擎的哈希链要检查的条件。
copy.deepcopy 让每个请求拿到预填充缓存的自己那一份。生成会通过追加原地修改缓存,所以没有这份拷贝的话,第一个问题就会把前缀弄坏,轮不到第二个。生产级引擎不会拷贝张量,而是共享物理块并跟踪引用计数,这才是让复用近乎免费、而不是随前缀长度线性增长的原因。
淘汰对命中率的影响
如上所述,只有完整的块会被索引,所以尾部不满一块的部分每次都要重算。
这意味着块大小要调好:
- 块越大,查表次数越少,内存局部性越好
- 块越小,共享粒度越细,尾部浪费越少
淘汰会降低命中率,这在意料之中。
缓存和运行中的 batch 从同一个 GPU 内存池里取,所以缓存越大,能同时跑的序列越少;在压力下,vLLM 会按最近最少使用把没有被引用的块丢掉。
混合流量会让情况更糟,因为长的共享前缀占的块最多,而它们又恰恰是丢掉之后最疼的。
开启之前,你该知道两件事
它只省预填充,所以解码时间不变,把整个加速都算在缓存头上会夸大它的效果。
而且哈希本身是有成本的,所以在 prompt 确实各不相同的流量上,基准测试测出来的是吞吐量下降,而不是提升。
还有第三个问题,它取决于负载类型,受影响最大的是 RAG。
一个 RAG prompt 包含一段系统指令,然后是检索到的分块,然后是查询;而分块每个请求都在变,请求之间顺序也在变。两个请求检索到相同的文档但顺序不同,在链式哈希下什么都共享不了。
把每个分块单独预填充、再把缓存拼起来,是行不通的。
拼起来的张量带着错误的位置编码。没有任何一个分块对其它分块做过注意力。而且每个分块都在「模型以为是位置零」的地方贡献自己的注意力沉降(attention sink)。要让它工作,需要在边界处做部分重算,而不是简单拼接。
顺带一提,这个方案在开源里已经有了。
LMCache(开源)实现了 CacheBlend:它不是把分块缓存首尾相接地粘起来,而是在任意位置复用它们,只重算一小部分 token——挑的是「预计算的值」与「完整注意力本应产生的值」偏离最大的那些位置。
这一小部分 token 恢复了跨分块的注意力,并修正了位置编码,于是输出保持在完整预填充的质量。
相比全部重算,这让首 token 时间大约提升两到三倍,而重算成本与「从较慢存储里取回缓存分块」是流水线并行的。
它接进 vLLM,从你的 prompt 里读出分块边界,所以即使检索到的文档每次到达顺序不同,检索流量也能被复用。
仓库在这里:https://github.com/LMCache/LMCache
3)Prompt 缓存
在托管模型上,你拿不到任何块表或淘汰策略。你拿到的是一张价目表,盖在服务商自己的前缀复用之上,外加两个控制旋钮。
被缓存的对象仍然是 KV 张量,不是你的 prompt 文本,而且它仍然要求在完整渲染后的上下文上做精确前缀匹配。
渲染后的上下文包含你从没写过的、服务商那一侧的系统内容,这也是为什么最小长度和失效规则从外面看起来那么随意的部分原因。
下面是 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 计数器告诉你后续调用有没有把那条记录读回来。
这个标记是附在「你想覆盖到的最后一个块」上的,不是附在一个范围上。它写入一条缓存记录,覆盖从请求开头一直到(含)那个块的全部内容。
user 消息在标记下方,所以它留在缓存区域之外——因为它每次调用都在变,所以绝不能放进去。
usage 计数器告诉你底下发生了什么。第一次调用报告一个非零的 cache_creation_input_tokens 和零读取。第二次报告的正好相反,而那段指令按输入价的十分之一计费。
如果两个计数器都回零,说明前缀低于模型的最小可缓存长度,请求是完全没有缓存地处理的。这种情况不会报错。
直觉上(也如上所述),如果我们把 cache_control 往下挪到 user 消息上,读取计数器会一直是零,因为被标记的块每次调用都在变。
Prompt 缓存的经济账
Anthropic 写入一条记录收基础输入价的 1.25 倍,读取收 0.1 倍;如果你想让它保留更久,写入倍率更高。OpenAI 在它当前的模型上用的是同样的两个倍率。
这笔溢价在后续请求里收回来,因为 TTL 内任何被复用的东西都不用重算。
一次读取只能找到某个更早的请求写下的记录,而写入只发生在你放的断点处。
每次调用检查你的断点,未命中时它会往回走有限个块,寻找更早的一次写入。
Anthropic 把这个上限设为 20 个块,所以在两次调用之间加超过 20 个块的对话,会把上一次写入推出范围,命中就停了。
4)语义缓存
上面三种技术省的是预填充工作,模型还是要跑的。
语义缓存把进来的 prompt 做 embedding,在存好的 prompt 上做最近邻搜索,当相似度超过阈值时直接返回一条存好的回复。
这就是为什么它连输出 token 也一起省了。这也是为什么每个请求都得付一次 embedding 的往返开销——包括每一次未命中。
下面是一个几行代码的可运行语义缓存示例:
# 需要: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 让每个向量都是单位长度,这样 self.vectors @ vec 就能把余弦相似度当成一个普通点积来算。如果你跳过归一化,不同长度的 prompt 之间分数就没法比较。
lookup 把 embedding 和结果一起返回,这样 answer 之后存它的时候不用重新 embedding。这很关键,因为 embedding 每个请求都要付,命中未命中都一样,算两遍就把「有个缓存」的固定成本翻倍了。
暴力 argmax 在示例里没问题,规模上去就错了。一旦超过几千条记录,这就得换成近似最近邻(ANN)索引,而它在阈值之上又引入了自己的一个召回率设置。
store 只在未命中路径上、模型答完之后被调用。没有任何东西在这个答案成为「未来所有分数超阈值的 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 所表示的东西本身。
四种技术回顾
上面讨论的四种技术里,有三种不影响正确性,所以它们的未命中只体现在成本和延迟上,别处看不到。
语义缓存的工作方式不同,所以命中率在这里不是该报告的指标。
其实还有第五层,用得较少:精确匹配的回复缓存,当请求逐字节相同时返回一条存好的答案。它像语义缓存一样省输入和输出,而且没有误报风险,因为它根本不做任何相似度匹配。你只要在动用 embedding 之前,先测一下自己「逐字节重复」的比率。当然它也有问题,你现在大概能自己看出来。
生产环境要点
每项技术都有一些失败点,在生产里用之前你应该留意:
- 如果 prompt 前面有任何变量——比如系统提示里的时间戳、请求 ID 或用户名——它会让后面的每个块都失效。永远把稳定内容放前面,可变内容放最后,并在边界上放一个标记。
- 工具 schema 通常放在系统提示之前,所以一次重排就能让整份缓存失效。
- 检查那些会被渲染进 prompt 的设置。在 Anthropic 上,切换 web search、citations、thinking 配置或
tool_choice,都会改写 prompt 文本并让下游的块失效。A/B 测试两种推理强度会把你的缓存一分为二。 - 总结历史会改写前缀,所以下一次调用要为冷 token 付全价。原地截断工具输出能让前缀保持逐字节相同,缓存也就还活着。
- 缓存记录是绑定到某个模型的,所以路由到一个更便宜的模型,仍然会按冷价把累积的全部历史预填充一遍。
要确定两个 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)、一个行尾换行,或一段重新序列化的工具 schema 上。
比较 token ID 而不是渲染后的文本,能找到复用中断的确切位置;把两侧那几个 ID 解码出来,通常就能找到确切的文本。
上面这次运行展示了一个常见情况:第一轮没指定系统消息,于是聊天模板填进了模型的默认值,两个 prompt 在索引 3 处就不一样了,所以无法复用。
前三层讲的是同一个想法,应用在三个不同的作用域上。
- KV 缓存在单次请求期间保存注意力状态。
- 前缀缓存在请求结束后仍保留这个状态,好让后来的请求能查到它。
- Prompt 缓存是服务商在自己的硬件上跑前缀缓存,并对你复用的那部分单独收费。
语义缓存的工作方式不同。它存的是回复文本,用 embedding 相似度作键,所以一旦命中就完全跳过模型,连输出 token 带输入 token 一起省。命中也可能是错的,而且出错时它带着正常的成功状态返回。
交给你了:这四层里,哪一层花了你最多的调试时间?
本文编译自 Avi Chawla(@_avichawla)的教程,原作者每天分享数据科学、机器学习、LLM 与 RAG 相关的教程和心得。译文按中文阅读习惯做了删改。
关于
关注我获取更多资讯