KV Cache 与前缀缓存:原理、对比与 DeepSeek 的实践
1 从一个「重读」的直觉说起
上学时做数学大题,老师要求写步骤。如果每一步都要从第一行重新推导,一张卷子能写到天黑。草稿纸的价值,就是把中间结果留下来,后面直接引用。
大模型生成文字的方式,本质上和这件事一样:它一个字一个字地往外蹦,而每蹦一个字,都要「回顾」前面已经写下的所有字。
这两个缓存概念,说的都是「怎么把回顾的结果留下来」:
- KV Cache:一次生成过程中,把回顾的中间结果存下来,避免自己重读自己写的东西。
- 前缀缓存(Prefix Cache):不止自己用,把这份中间结果存档,下次别人(或下次的自己)写到同样的开头时,直接从存档接着往下写。
先记住一个总纲:KV Cache 是「存」,前缀缓存是「复用」。它们不是两种并列的技术,而是基础机制与上层优化的关系。 后面会反复回到这句话。
2 KV Cache:单次生成里的「草稿纸」
2.1 模型是怎么「接龙」的
先建立最小的心智模型。Transformer 里的注意力机制,可以粗略类比成「查字典」:
- 每个 token(可以粗略理解为词或字)会被投影出三个向量:Q(查询)、K(键)、V(值)。
- 把 Q 想成「我现在想找什么」,K 是每个词挂在门口的标签,V 是词条的正文。
- 算注意力就是:拿我的 Q 去和前面每一个词的 K 比对打分,得到一组权重,再按权重把对应的 V 加权求和。
注意「前面」两个字。这是因果语言模型的铁律——生成第 5 个词时,只能看到第 1 到第 4 个词,看不到第 6 个。这也正是前缀缓存「天然是前缀形」的根本原因,第 3 节会展开。
举个具体例子:
输入:今天天气
模型要生成的下一个字是「真」。
计算「真」的时候,需要「今」「天」「天」「气」这四个 token 各自的 K 和 V。
2.2 不缓存的话,算力浪费在哪
假设要让模型续写 L 个字。按照定义:
- 生成第 1 个字,要算 1 个 token 的 K/V;
- 生成第 2 个字,要算前 2 个 token 的 K/V;
- ……
- 生成第 L 个字,要算前 L 个 token 的 K/V。
这里的问题一眼可见:前 L-1 个 token 的 K 和 V,在上一步已经算过了,而且结果一模一样,却被完整地重算了一遍。 总计算量是 1 + 2 + … + L,量级 O(L²)。
生成 1000 个字,就要做大约 50 万次 token 级的注意力计算,其中绝大部分是重复劳动。
2.3 有了 KV Cache
KV Cache 的做法很朴素:既然算过的 K、V 不会变,那就存下来。
生成「真」:算出「真」的 K/V,追加进缓存,拿「真」的 Q 和缓存里全部 K 做注意力
生成「的」:只算「的」这个 token 的 K/V,追加进缓存,再和缓存里全部 K 做注意力
于是每一步只需要计算「一个新 token」的 K/V,总计算量从 O(L²) 降到 O(L)。这就是 KV Cache 的全部价值,也是所有现代推理框架的标配。
2.4 Prefill 与 Decode:两个性格完全不同的阶段
理解前缀缓存之前,必须先分清推理的两个阶段,因为前缀缓存省掉的恰恰是前一个阶段。
flowchart LR
subgraph P["Prefill 预填充"]
P1["整段输入一次性并行过所有层"] --> P2["算出全部输入 token 的 K/V,写满 KV Cache"]
P2 --> P3["计算密集,GPU 算力打满"]
end
subgraph D["Decode 解码"]
D1["每次只算一个新 token"] --> D2["读出整个 KV Cache,再算注意力"]
D2 --> D3["访存密集,GPU 算力闲置"]
end
P3 --> D1- Prefill(预填充):把你的整段输入(system prompt、历史对话、文档)一次性并行喂进模型,算出这堆 token 的 KV,填进缓存。这个阶段算力吃满,是真正的「重活」,耗时随输入长度线性增长。
- Decode(解码):开始一个字一个字往外蹦。每一步只算一个新 token,但要把整个 KV Cache 读一遍,瓶颈在显存带宽而不是算力。
这就解释了一个反直觉的现象:输入越长,首字延迟(TTFT)越高,而钱主要花在 prefill 上。 如果你每次都把同一段 5000 字的 system prompt 重发一遍,那就是每次都重新做一遍这 5000 字的 prefill。
前缀缓存要解决的,正是这堆重复的 prefill。
2.5 KV Cache 的账:省算力,但吃显存
KV Cache 不是免费的。它把计算成本转移成了存储成本,而且这笔账不小。
每个 token 的 KV Cache 大小可以这样估:
字节数 = 2 × 层数 × 注意力头数 × 每头维度 × 单精度字节数
↑
K 和 V 两份
拿一个常见的 7B 级模型举例(32 层、32 头、每头 128 维、FP16 即 2 字节):
2 × 32 × 32 × 128 × 2 = 524,288 字节 ≈ 0.5 MB / token
也就是说一个 token 就要占半兆显存。1000 个 token 的上下文要 500 MB,如果并发 32 个请求,就是 16 GB——一台消费级显卡的全部显存,还没算模型权重本身。
这就是长上下文的真实成本结构:不是模型算不动,是显存装不下这么多 KV。也正因如此,业界才有了一条和 KV Cache 直接相关的技术分支:想办法把它变小。DeepSeek 的 MLA 就属于这一支,后面第 4 节会说。
3 前缀缓存:把「草稿纸」跨请求复用
3.1 重复的输入从哪里来
KV Cache 只在一次请求的生命周期内有效,请求结束就释放了。但现实中的请求,开头往往高度重复:
| 场景 | 重复的部分 | 典型长度 |
|---|---|---|
| 固定人格的客服 / 助手 | system prompt、规则、工具定义 | 500 到 5000 token |
| Few-shot 提示 | 固定的示例样本 | 几百到几千 token |
| 长文档问答 | 同一份文档 + 不同问题 | 可达 10 万 token |
| 多轮对话 | 前面若干轮的历史 | 随轮数增长 |
| Agent 工具调用 | 反复重发的工具 schema | 几百到几千 token |
算一笔账。假设你的应用有一个 1000 token 的 system prompt,每小时有 10000 次请求,用户消息平均 20 token:
- 不做前缀缓存:每小时要 prefill 10000 × 1020 = 1020 万 token;
- 做前缀缓存:system prompt 只算一次,之后每小时只算 10000 × 20 = 20 万 token。
省掉约 98% 的 prefill 计算。 这就是为什么所有主流 API 都把缓存命中的输入价格定得极低——那不是营销折扣,而是服务端真的少干了这些活。
3.2 为什么缓存天然是「前缀形」的
很多人第一反应是:把整段 prompt 做个哈希当 key 存起来不就行了。这个思路在工程上会撞墙。
原因在于因果注意力的性质:第 n 个 token 的 K/V,是对前 n 个 token 的总结。 换句话说,每个位置的 KV 都携带了它前面全部上下文的信息。
用一个比喻:这就像一条链式账本。第 5 条记的不是「今天花了 100」,而是「从第 1 条到第 5 条的累积账」。你把第 3 条改掉,第 5 条就整体作废了。
请求 A:你今天 很 开心
请求 B:你今天 很 难过
↑
第 4 个 token 不同,前 3 个 token 的 KV 仍然相同
但从第 4 位开始,A 和 B 的 KV 完全不可共享
结论有两条,都很硬:
- 缓存只能从第 0 个 token 开始,向后延伸到某一个位置为止。 中间截取一段单独存下来没有意义——它的值依赖于前面全部内容。
- 前缀里任何一个 token 变了,从那个位置往后全部失效。 少一个空格、多一个换行,都可能让缓存从那一刻起完全失效。
这也解释了「精确哈希整段 prompt」为什么不灵:哈希函数对输入极度敏感,两段 prompt 哪怕只差一个字符,哈希值也完全不同,缓存永远命中不了。你需要的不是一个哈希值,而是一个能表达「前 k 个 token 相同」的数据结构。
3.3 工程上的两种主流实现
vLLM 的自动前缀缓存(Automatic Prefix Caching)。 把 KV Cache 切成固定大小的 block(常见 16 个 token 一块),然后对每个 block 算一个链式哈希:
block 1 的 hash = hash(block 1 的 token)
block 2 的 hash = hash(block 2 的 token + block 1 的 hash)
block 3 的 hash = hash(block 3 的 token + block 2 的 hash)
链式是关键。因为「said hi」这两个词,在「Alice said hi」和「Bob said hi」里字面相同,但它们算出的 KV 并不一样——前面的上下文不同。只有把前缀的哈希一路串进来,才能保证「内容相同」等价于「KV 相同」。
SGLang 的 RadixAttention。 更进一步,把所有请求的 KV 组织成一棵基数树(radix tree),每个节点存一段 token 范围对应的物理 block。新请求进来,就沿着树走,找到最长匹配前缀,挂上去,只算剩下的部分。
两者的差别体现在多轮对话这种「分叉」场景:两个请求共享 system prompt 和用户前几轮对话,只在最新一轮分叉。链式哈希能命中到分叉点,基数树在结构上天然就擅长表达这种树状共享,命中率通常更高。
3.4 命中率就是钱
前缀缓存不是「开了就省钱」,省多少完全取决于命中率:
| 工作负载 | 典型命中率 |
|---|---|
| 固定 system prompt | 接近 100% |
| 多轮对话 | 70% 到 90% |
| RAG(每次塞不同文档) | 较低,除非共享的指令部分占大头 |
| 完全随机的独立请求 | 接近 0 |
需要提醒的是,命中率不是一个固定值,它会随请求序列的变化而波动。实践中命中率偏低,通常不是缓存没生效,而是前缀被污染了——比如在 system prompt 前面插了一个当前时间戳,或者把需要复用的内容放在了变化内容之后。
4 DeepSeek 的实践:先压缩,再落盘
前面讲的是通用原理。DeepSeek 的实现有两个值得单独说的选择:把 KV Cache 压到极小,然后把它放到磁盘上。
4.1 前提:MLA 让缓存先瘦身
第 2.5 节算过,KV Cache 是显存黑洞,一个 token 动辄几百 KB。DeepSeek 的做法是不硬扛,而是从模型结构上压缩它。
传统多头注意力(MHA)会为每个注意力头分别缓存完整的 K 和 V。DeepSeek 的 MLA(Multi-head Latent Attention) 换了个思路:不缓存完整的高维 K/V,而是先把它们压成一个低秩的 latent 向量,只缓存这个向量。需要算注意力时再投影回去。
官方推理代码里,这个区别直接体现在两块缓存 buffer 的声明上——一个要按头展开,一个只存一串数:
# 传统 MHA 的缓存:每个 head 都要存一份完整的 K 和 V
self.register_buffer("k_cache", torch.zeros(args.max_batch_size, args.max_seq_len,
self.n_local_heads, self.qk_head_dim), persistent=False)
self.register_buffer("v_cache", torch.zeros(args.max_batch_size, args.max_seq_len,
self.n_local_heads, self.v_head_dim), persistent=False)
# MLA 的缓存:只存一个低秩 latent,加一份所有 head 共享的 RoPE 键
self.register_buffer("kv_cache", torch.zeros(args.max_batch_size, args.max_seq_len,
self.kv_lora_rank), persistent=False)
self.register_buffer("pe_cache", torch.zeros(args.max_batch_size, args.max_seq_len,
self.qk_rope_head_dim), persistent=False)
代入 DeepSeek-V3 的真实配置(inference/configs/config_671B.json:128 个注意力头、kv_lora_rank = 512、qk_rope_head_dim = 64、每头维度 128),把上面的形状摊平成数字:
传统 MHA(每 token,每层)
k_cache = n_heads × qk_head_dim = 128 × 128
v_cache = n_heads × v_head_dim = 128 × 128
合计 = 32,768 个数
MLA(每 token,每层)
kv_cache = kv_lora_rank = 512 ← 低秩 latent,K 与 V 共用
pe_cache = qk_rope_head_dim = 64 ← RoPE 键,所有 head 共享一份
合计 = 576 个数
相差约 57 倍。 同样一张卡,MLA 能承载的上下文长度是传统做法的几十倍——这是缓存能够从显存走向磁盘的前提。
压缩还有一个工程细节需要交代:latent 终究要参与注意力计算,如果每次读缓存都做一遍「latent 升维回完整 K/V」的上投影,读取成本会重新涨回来。官方用了一个叫权重吸收的技巧绕开它:
wkv_b = self.wkv_b.weight if self.wkv_b.scale is None else weight_dequant(self.wkv_b.weight, self.wkv_b.scale, block_size)
wkv_b = wkv_b.view(self.n_local_heads, -1, self.kv_lora_rank)
# 把「上投影矩阵」提前吸收进 Q 的投影里,而不是把缓存的 latent 还原成完整 K
q_nope = torch.einsum("bshd,hdc->bshc", q_nope, wkv_b[:, :self.qk_nope_head_dim])
self.kv_cache[:bsz, start_pos:end_pos] = self.kv_norm(kv)
self.pe_cache[:bsz, start_pos:end_pos] = k_pe.squeeze(2)
scores = (torch.einsum("bshc,btc->bsht", q_nope, self.kv_cache[:bsz, :end_pos]) +
torch.einsum("bshr,btr->bsht", q_pe, self.pe_cache[:bsz, :end_pos])) * self.softmax_scale
关键在第三行那个 einsum("bshd,hdc->bshc", ...):它让 Q 先降到 latent 空间,再和缓存的 latent 直接做点积,而不是把缓存的 latent 升维回完整 K。这样缓存里永远只躺着那 576 个数,读取路径上的计算量也一并省掉了。这也是 MLA 名字里「Latent」的由来——注意力不再发生在完整的高维 K/V 上,而是发生在低维 latent 空间里。
关于数字口径:上面的对比按每头 128 维计算,与 MLA 中 nope 部分的维度对齐;MLA 里那 64 维 RoPE 键是所有头共享一份,所以只计 64。另外,DeepSeek 论文摘要里还有一个流传很广的数字——相比 DeepSeek 67B,KV cache 减少 93.3%。那是拿新模型和另一个 dense 模型对比,口径与这里不同,引用时别混淆。
4.2 从显存到磁盘
缓存既然已经这么小了,DeepSeek 做了第二步选择:把它落进分布式磁盘阵列,而不是留在显存里。
这是一个很值得琢磨的取舍。显存快但极贵、容量有限,而磁盘便宜、容量巨大。KV Cache 被 MLA 压小之后,「读磁盘」这条路的性价比就成立了:从盘上读回一份缓存,仍然比重新做一遍 prefill 快得多、也省得多。
官方给的数据很能说明差距:128K 输入、高重复率的请求,首 token 延迟从 13 秒降到 500 毫秒。
4.3 「缓存前缀单元」与三种落盘时机
DeepSeek 的缓存不是按 token 无限拼接的,而是切成一个个独立的「缓存前缀单元」。规则很严格:后续请求必须完整匹配某个单元,才能命中。
一个单元落盘有三种时机:
- 请求结束位置落盘:每次请求的「用户输入结束处」和「模型输出结束处」,各产生一个缓存前缀单元。
- 公共前缀检测落盘:系统发现多次请求之间存在公共前缀时,把这段公共前缀单独作为一个单元落盘。
- 按固定 token 间隔落盘:在长输入或长输出中,按一定 token 间隔截取单元,避免超长前缀因为迟迟等不到结束位置而完全无法被缓存。
官方文档还特别提到,受 Sliding Window Attention 机制影响,缓存前缀的存取与判别和早期版本有所不同——缓存粒度上,系统以 64 个 token 作为存储单元,短于这个长度的内容不会被缓存。
4.4 两个例子
例一:多轮对话,这是最自然的命中场景。
第一轮请求:
system: 你是一位乐于助人的助手
user: 中国的首都是哪里?
第二轮请求:
system: 你是一位乐于助人的助手
user: 中国的首都是哪里?
assistant: 中国的首都是北京。
user: 美国的首都是哪里?
第二轮的前三个消息,和第一轮完全一致。第一轮结束时,这段输入已经在「请求结束位置」落了一个单元,第二轮完整匹配它,于是这部分计入缓存命中。
把每一轮的角色序列摊开看,复用关系就很清楚:
第 1 轮 [system] [问题 1]
第 2 轮 [system] [问题 1] [回答 1] [问题 2]
└──────── 与上一轮逐位相同,直接复用 ────────┘
第 3 轮 [system] [问题 1] [回答 1] [问题 2] [回答 2] [问题 3]
└────────────────── 与上一轮相同,复用 ──────────────────┘
每一轮都是在上一轮末尾追加新内容,所以「上一轮的完整输入」几乎总是下一轮的合法前缀。这就是多轮对话命中率天然很高的原因。
例二:长文档问答,命中不是从第一轮就发生的。
第一轮:system + <财报内容> + 「请总结一下这份财报的关键信息。」
第二轮:system + <财报内容> + 「请分析一下这份财报的盈利情况。」
第三轮:system + <财报内容> + 「请分析一下公司收入与支出占比。」
前两轮都不会命中——因为第一轮落下的单元是「到『关键信息』为止」的完整前缀,第二轮的问题不一样,无法完整匹配。
但关键机制在这里生效:系统检测到这两轮存在公共前缀 system + <财报内容>,把它单独落成一个单元。等到第三轮到来,它的前缀完整匹配了这个单元,从第三轮起开始命中。
这个例子说明一件事:前缀缓存的命中率会随着请求的积累而提升,第一批请求是在「预热」缓存。 对于固定文档反复提问的场景,越用越便宜。
4.5 计费:命中与未命中差多少
前缀缓存是 DeepSeek API 默认开启的,不需要改代码、不需要加参数,服务端自动完成。计费上,输入 token 被分成两类,价格差了整整一到两个数量级。
以下为官方定价页当前(2026-10)的数据,单位为美元 / 百万 token:
| 模型 | 缓存命中输入(谷 / 峰) | 缓存未命中输入(谷 / 峰) | 输出(谷 / 峰) |
|---|---|---|---|
| deepseek-flash | $0.003 / $0.006 | $0.15 / $0.30 | $0.60 / $1.20 |
| deepseek-v4-pro | $0.022 / $0.044 | $0.66 / $1.32 | $1.98 / $3.96 |
命中价与未命中价的比值:
- deepseek-flash:约 1 : 50
- deepseek-v4-pro:约 1 : 30
峰谷规则是:峰时为 UTC 周一至周五的 01:00–04:00 和 06:00–10:00(北京时间 09:00–12:00 和 14:00–18:00,不含中国法定节假日),谷时价格是峰时的一半,周末全天按谷时计。
算一笔具体的账。假设一个客服机器人,system prompt 加知识库共 5000 token,每天 10000 次请求,每次用户输入 100 token,按 deepseek-flash 谷时价:
共享前缀的 5000 token × 10000 次 = 5000 万 token
全部未命中:5000 万 ÷ 100 万 × $0.15 = $7.50
全部命中: 5000 万 ÷ 100 万 × $0.003 = $0.15
同样的输入量,差 50 倍。 而用户实际感受到的回答质量完全一样。
命中情况可以直接从返回里读到,usage 字段里有两个专门的计数器:
{
"usage": {
"prompt_cache_hit_tokens": 4864,
"prompt_cache_miss_tokens": 136
}
}
把它接进监控,就能算出真实命中率,而不是靠猜。
需要提醒的是,缓存是「尽力而为」的:官方明确不保证 100% 命中,缓存构建耗时为秒级,不再使用的缓存会在几小时到几天后自动清空。另外,缓存只作用于输入前缀,输出仍然是实时推理出来的,仍然受 temperature 影响,效果与不用缓存完全一致。
5 两者到底差在哪
5.1 对照表
| 维度 | KV Cache | 前缀缓存(Prefix Cache) |
|---|---|---|
| 一句话定义 | 把注意力算出的 K/V 张量存下来 | 复用相同前缀已经算好的 KV Cache |
| 解决的问题 | 单次生成内,避免重复计算历史 token | 跨请求,避免重复 prefill 同一段前缀 |
| 层级关系 | 基础机制 | 建立在 KV Cache 之上的复用策略 |
| 生命周期 | 一次请求,prefill 到生成结束 | 跨请求,数小时到数天 |
| 存储位置 | GPU 显存(HBM) | 本地框架存显存;DeepSeek API 落分布式磁盘 |
| 匹配粒度 | 没有匹配概念,全量保留 | 缓存前缀单元,DeepSeek 以 64 token 为存储单元 |
| 命中条件 | 不适用 | 必须从第 0 个 token 起完整匹配 |
| 主要收益 | 生成复杂度从 O(L²) 降到 O(L) | 省掉重复的 prefill,直接体现为延迟和费用 |
| 主要代价 | 吃显存,长上下文下是主要瓶颈 | 吃存储;命中率不可控;缓存会失效 |
| 谁负责实现 | 模型推理代码 / 推理框架 | 推理框架(vLLM / SGLang)或 API 服务层 |
5.2 三个最容易混淆的点
第一,前缀缓存不是另一套缓存。 它复用的对象就是 KV Cache,只是把复用的范围从「本次请求内」扩展到了「多次请求之间」。把它理解成 KV Cache 的一个使用策略,会更准确。
第二,命中与否不改变输出。 缓存命中的只是输入前缀部分,输出依旧是实时推理的结果,仍然受 temperature 等参数影响。省下的是计算和钱,不是质量。
第三,命中看的是 token 级前缀,不是语义相似。 不是「这段话说得差不多」就能命中,而是要求从第 0 个 token 起逐位完全一致。在 system prompt 前面加一个当前时间戳,或者把工具定义调换一下顺序,都可能让命中率归零。
6 怎么把命中率提上去
前面讲的都是机制,这一节讲怎么用。分三层:先理解所有优化都逃不开的三条约束,再看 API 调用方能做什么,最后看 Agent 开发者的专属坑。
6.1 三条绕不开的底层约束
这三条不是经验之谈,是因果注意力结构直接推出来的,后面所有手法都是它们的推论:
- 前缀必须稳定。 第 3.2 节说过,任何一个 token 变化,从它往后全部失效。所以「什么内容放在前面」远比「怎么把 prompt 写短」重要。
- 上下文只能追加,不能改写。 历史消息一旦发出去,就成了后续所有请求的前缀。回头去改写、摘要、重排它,等于把已经攒下的缓存全部清零。
- 序列化必须是确定性的。 这条最隐蔽。不少语言和库在序列化 JSON 时不保证键的顺序稳定——同一份数据两次序列化出不同的字符串,缓存就在你完全察觉不到的地方失效了。
6.2 作为 API 使用者
内容分层:从最稳定排到最易变
把一次请求的内容按变化频率排列,稳定的在前,易变的在后:
┌──────────────────────────────────────┐
│ system prompt / 行为规则 │ ← 全程不变
├──────────────────────────────────────┤
│ 工具定义 / JSON Schema │ ← 一个会话内不变
├──────────────────────────────────────┤
│ few-shot 示例 │ ← 固定
├──────────────────────────────────────┤
│ 长文档 / 检索到的上下文 │ ← 单轮内固定
├──────────────────────────────────────┤
│ 对话历史 │ ← 只追加,不修改
├──────────────────────────────────────┤
│ 当前这条用户消息、动态参数 │ ← 每次都变,放最后
└──────────────────────────────────────┘
这个顺序看着像常识,但在真实项目里被破坏的方式往往很隐蔽。看一个最常见的反例:
# 反例:把当前时间写进 system prompt
system = f"你是一位客服助手。当前时间是 {datetime.now()}。"
这一行让每次请求的 system prompt 都不同,整个前缀从第一个 token 之后全部失效,命中率直接归零。而它想解决的问题(让模型知道当前时间)完全可以用另一种方式处理:
# 正例:system prompt 保持静态,动态信息追加到最后
messages = [
{"role": "system", "content": "你是一位客服助手。"}, # 永远不变
# ... 历史消息 ...
{"role": "user", "content": f"(当前时间:{now})\n{question}"}, # 变化只发生在这里
]
同样的道理适用于请求 ID、session 标识、随机种子、用户昵称——任何会变的东西,都不要让它出现在稳定内容之前。 用 f-string 或模板拼 prompt 时也要留意:如果某个条件分支会让某段内容有时出现、有时消失,那这段内容之后的前缀就是不稳定。
多轮对话:只追加,不重构
每一轮都在上一轮末尾追加新消息,这是最自然的写法,也正好是缓存最友好的写法。要克制的是「回头整理」的冲动:
- 不要重新构造历史消息对象,直接把服务端返回的 message 原样追加进列表。
- 不要为了省 token 而改写、翻译、重排前面的对话。
- 不要把历史里某条消息的格式「美化」一下再发出去。
让同前缀的请求在时间上聚在一起
DeepSeek 的缓存「不再使用后会自动被清空,时间一般为几个小时到几天」。这意味着:
- 高频场景(在线聊天)天然容易命中;
- 低频场景(比如每天跑一次的批处理)可能每次都赶在缓存失效之后才来,命中率接近 0;
- 批量任务如果可以自由调度,把相同前缀的请求集中发送,比打散在一天里更容易互相命中。
第一轮不命中是正常的
长文档问答这类场景,第一次请求必然全量计算。这时缓存并没有「失效」,它只是在预热——系统会识别出公共前缀并落盘,从第二次或第三次请求开始命中,第 4.4 节的例二就是这个过程。如果上线后发现命中率偏低,先确认是不是预热还没完成,再考虑动 prompt。
把命中率接进监控
别凭感觉判断缓存有没有生效,从返回的 usage 里读真实数据:
usage = response.usage
hit = usage.prompt_cache_hit_tokens
miss = usage.prompt_cache_miss_tokens
hit_rate = hit / (hit + miss) if (hit + miss) else 0
这个指标值得像监控服务可用性一样持续跟踪——命中率突然掉下去,通常意味着某次发版动到了前缀结构。
注意 64 token 的最小粒度
DeepSeek 以 64 个 token 作为缓存存储单元,短于这个长度的内容不会被缓存。一个只写了三五行、总共几十个 token 的短 system prompt,本身就不足以形成可命中的单元。要享受缓存,共享部分得先够长。
6.3 作为 Agent 开发者
Agent 的调用特征和普通聊天差别很大:一次任务几十轮循环,输入输出 token 比例可能高到 100 : 1,绝大部分成本压在 prefill 上。换句话说,Agent 是前缀缓存收益最大的场景,也是最容易把它搞砸的场景。
坑一:工具定义中途增删
工具定义会被序列化进上下文的前部。只要这份定义有任何变动,从它往后的一切——包括已经积累的几十轮工具调用历史——全部作废。
Anthropic 的 Claude Code 团队和 Manus 团队都在公开复盘里提到过这一点,Manus 的说法很直接:除非绝对必要,不要在迭代过程中动态增删工具。 他们实测过「按需加载工具」的 RAG 式方案,结论是负面的——不仅缓存失效,还会因为历史消息引用了当前上下文中已不存在的工具,让模型开始产生幻觉调用。
替代方案是「掩码,而不是移除」:保持 tools 数组本身不变,改用提示词里的状态说明来控制当前哪些工具可用。 这样前缀稳定,缓存照常命中。
如果工具确实太多、必须分批暴露,可以把「工具目录」(只有名称和一句话描述)放在前面且保持不变,把具体工具的参数细节按需追加到后面。
坑二:序列化不确定
这条在 6.1 里提过,但在 Agent 场景里杀伤力更大,因为工具返回值往往要经过一轮序列化才进入上下文:
- Python 3.7 之后 dict 保序,
json.dumps通常是稳定的——但如果键来自set遍历,或者数据跨了语言边界,顺序就可能每次都不同。 - 同一个工具在不同轮次返回的字段顺序不一致,会让「看起来一样」的两段历史序列化成不同的字符串。
对策是让序列化显式可控:对要进入上下文的 JSON,固定键的顺序(比如排序后输出),不要依赖默认行为。
坑三:压缩上下文时改写了历史
上下文快要撑爆时需要压缩。这里有个几乎人人都会踩的坑:直接重写历史消息——把前面二十轮总结成一段话塞回去。代价是之前积累的全部前缀缓存瞬间清零,而且是在上下文最长、最贵的时刻清零。
更省的做法是保住前缀,只追加指令:原有 system prompt、工具定义、对话历史一律不动,在末尾追加一条「请压缩上述对话」的消息。这样除了新增的这条指令,其余 token 全部命中缓存。代价是要多接受一次往返。
坑四:thinking 模式下的上下文账
这是 DeepSeek 特有的一个点。开启 thinking 模式后,模型会额外输出一段思维链 reasoning_content。它是否进入下一轮的上下文,取决于这次请求有没有带 tools 参数:
- 不带
tools(普通多轮对话):reasoning_content无需回传,即使传了也会被忽略,不会拼进上下文。 - 带
tools(也就是 Agent 场景):历史轮次的reasoning_content必须回传,而且会被拼接进上下文;漏传会直接返回 400 错误。
对 Agent 开发者来说,这意味着两件事。
第一,必须原样保留服务端返回的 message 对象。思维链内容比最终回答长得多,如果代码里习惯性地把历史重建成 {"role": "assistant", "content": ...},不仅会丢掉 reasoning_content 导致报错,还会破坏「追加式」这条原则。
第二,多步任务里思维链会迅速吃掉上下文,这是 Agent 上下文膨胀的主要来源之一。好消息是它天然满足「只追加」——每一轮的思维链都是新增内容,不影响此前已缓存的前缀。要控制体积,应该用上面说的「追加压缩指令」,而不是回头改写。
坑五:中途切换模型
缓存与模型是绑定的。同一个会话里从大模型切到小模型,或者反过来,都会让缓存全部重建。Agent 里常见的「简单任务用便宜模型、复杂任务用贵模型」策略,如果发生在同一段上下文的生命周期内,就等于主动放弃了缓存收益。要分流的话,最好在任务开始前决定,而不是中途切。
坑六:自托管时的额外两件事
如果你不是调 API,而是自己用 vLLM 之类的框架部署,还有两件事必须确认:
- 前缀缓存要显式开启,不少框架默认是关闭的。
- 请求路由要一致。多副本部署时,相同前缀的请求如果被分散到不同 worker,命中就会落空;通常的做法是用会话 ID 之类的前缀感知路由,把同前缀的请求稳定地导向同一个 worker。
6.4 上线前自查清单
对着这张表过一遍,命中率通常不会太难看:
| 检查项 | 合格标准 |
|---|---|
| system prompt 里有没有时间戳、ID、随机值 | 没有,或已挪到消息末尾 |
| 工具定义在一个会话内是否会变 | 不变;需要控制可用性时用掩码而非增删 |
| 历史消息是原样追加还是重新构造 | 原样追加服务端返回的 message 对象 |
| 进入上下文的 JSON 键顺序是否固定 | 显式排序,不依赖默认行为 |
| 上下文压缩是否会重写历史 | 不重写,只在末尾追加压缩指令 |
| 是否在同一会话内切换模型 | 不切换,或只在会话边界切换 |
有没有在读 usage 里的缓存计数 |
有,并且接进了监控 |
| 共享前缀的长度 | 超过 64 token,否则不会被缓存 |
7 总结
回到开头那句话:KV Cache 是「存」,前缀缓存是「复用」。
KV Cache 解决的是「一次生成不要重复读自己」,把生成复杂度从 O(L²) 降到 O(L),代价是把计算压力转移成了显存压力。前缀缓存解决的是「多次请求不要重复算同一段开头」,代价是要求你尊重「前缀」这个约束——共享内容必须在最前面,且必须逐位一致。
DeepSeek 在这条链路上各走了一步:用 MLA 把 KV Cache 压到传统做法的约 1/57,再用磁盘缓存把压缩后的结果存到显存之外,最后用命中价与未命中价近 50 倍的差距,把这份工程收益直接让给了调用方。
至于调用方这一侧,第 6 节那一长串做法其实可以收成一句话:你唯一能真正控制的事情,是别去动已经发出去的前缀。 稳定内容前置、只追加不改写、序列化确定化、工具定义不变——它们不是四条彼此独立的技巧,而是同一条约束在不同层面的投影。理解这两层机制的区别,再看那些「把 system prompt 放前面」「别在开头插时间戳」的经验之谈,就会明白它们不是玄学,而是因果注意力结构决定的硬约束。
评论