LLM Prompt Cache 命中率:从 37% 到 97% 的实战手记
2026-07-08
大模型 API 处理一个请求时,最贵的部分是 input tokens——你发给模型的 system prompt、对话历史、工具定义加起来可能有几万 token。每次请求都从头处理一遍,计算量和费用都很可观。
Prompt Cache 是 API 提供商做的优化:如果这次请求的前缀(从第一个 byte 开始的连续相同部分)和上一次请求一致,就直接复用上次的中间计算结果。缓存命中的 token 通常只收正常价格的 10–25%,而且首 token 延迟大幅降低。
这篇文章记录一次实际优化的过程——把一个 agent harness 的 DeepSeek 缓存命中率从 37% 拉到 97%。核心教训只有一个:**字节前缀越长越稳定,命中率越高。**任何破坏前缀稳定性的操作——移动消息位置、重新渲染不变的内容、在中间插入或删除——都会让缓存作废。
一、缓存机制速览
三家主要提供商的做法各有不同:
- DeepSeek——全自动前缀缓存。不需要显式标记,只要请求的字节前缀和之前的请求一致就自动命中。粒度精确到 byte,按连续前缀匹配。
- OpenAI——同样自动缓存,按 128 token 块对齐。前缀相同就命中,但粒度比 DeepSeek 粗一些。
- Anthropic——需要在请求中显式标记
cache_control断点来指定缓存位置。更精细的控制,但需要主动管理。
对于 DeepSeek 和 OpenAI 的自动缓存,优化的核心就是:让请求的字节前缀尽可能长、尽可能不变。
二、一个典型 agent 请求长什么样
一个多轮工具调用的对话请求大致是这样的结构:
|
|
理论上,system + tools + 已有的对话历史都没变,只在尾部追加了一条新的 user message。前缀应该完全一致,缓存命中率应该很高。
但实际测得的加权命中率只有 37.2%(811 轮实战数据)。
三、37% 的根因
根因一:动态上下文在「漂移」
agent 每轮需要注入一些动态上下文——技能建议、工作记忆、任务检索结果等等。原来的做法是:每次发请求时临时生成一条合成消息,插在最新用户消息前面,请求结束后丢弃。
这意味着什么?
Turn 1 的请求体:
[system] [tools] [volatile_1] [user_1]
Turn 2 的请求体:
[system] [tools] [user_1] [assistant_1] [tool_results] [volatile_2] [user_2]
注意:volatile_1 消失了。user_1 从原来的第 4 个位置移到了第 3 个位置。从这个 byte 开始,所有内容都和上一次请求的对应位置不一样了。
上一轮积累的全部内容——assistant 的回复、tool 的执行结果——全部缓存未命中。每轮只有 system + tools 这个头部(约 3800 tokens)能命中,后面的 messages 数组全部作废。随着对话变长,缓存命中部分的占比越来越低。
根因二:System prompt 的重复渲染
system prompt 里包含一些动态字段(当前工作目录、活跃技能列表、工作记忆等)。原来每轮都重新拼接整个 system prompt。即使内容没变,重新渲染可能导致微小的字节差异(字段顺序、空白字符、时间戳之类),直接破坏掉 system 前缀的缓存。
四、修复方案
修复一:持久化动态上下文
核心思路:动态上下文一旦注入对话,就永久留在那里,不再移动。
把它作为一种特殊的消息类型持久化到数据库。下一轮请求时,这条消息还在原来的位置,字节完全一致。新的动态上下文追加在尾部,而不是替换掉旧的:
Turn 1:
[system] [tools] [snapshot_0] [user_0]
Turn 2(同一轮内的 tool loop,多次请求同一个模型):
[system] [tools] [snapshot_0] [user_0] [assistant_0] [tool_results_0]
snapshot_0 还在原位。整个前缀字节完全一致——缓存全部命中。
Turn 3(新的用户消息):
[system] [tools] [snapshot_0] [user_0] [...] [snapshot_1] [user_1]
新的 snapshot 追加在尾部。旧的一切都没动。对话历史真正做到了 append-only:旧消息永远不动,新消息只在尾部追加。
修复二:System prompt 冻结
system prompt 整个 session 只计算一次,缓存在内存中。每轮请求直接复用同一份 bytes,除非发生了会改变 system 内容的事件(比如加载了新技能)。
这确保 system prompt 在所有请求之间 byte-for-byte 相同,不会因为重复渲染产生意外的字节差异。
修复三:只发增量
如果下一轮的动态上下文和上一轮完全一样(技能建议没变、工作记忆没更新),就不生成新的 snapshot,直接复用上一轮的。只有内容真正发生变化时才追加新 snapshot。
五、修复前后对比
修复后 6 轮实战数据(DeepSeek V4 Flash):
| Turn | Total Input | Cached | 命中率 |
|---|---|---|---|
| 1 | 3,963 | 3,840 | 96.9% |
| 2 | 4,136 | 4,096 | 99.0% |
| 3 | 4,240 | 4,096 | 96.6% |
| 4 | 4,348 | 4,224 | 97.1% |
| 5 | 4,456 | 4,352 | 97.7% |
| 6 | 4,710 | 4,608 | 97.8% |
加权命中率从 37.2% 跳到了 97.5%。
连 Turn 1 都有 97%——因为 system + tools 的前缀和之前的 session 相同,DeepSeek 的缓存跨 session 也能命中。
六、DeepSeek 与 OpenAI 的差异
同样的 append-only 原则对 OpenAI 也有效,但实测效果不同:
| 提供商 | 缓存机制 | 粒度 | 实测命中率 |
|---|---|---|---|
| DeepSeek | 自动前缀匹配 | byte 精确 | 97.5% |
| OpenAI | 自动前缀匹配 | 128 token 块 | 78.0% |
OpenAI 的 128 token 对齐粒度意味着即使前缀完全 byte 一致,也可能因为对齐边界的原因只命中部分块。此外 OpenAI 的缓存淘汰策略更激进——连续请求之间也会出现命中率波动(66%、79%、98% 三档交替出现)。
一个重要的推论:如果你通过 CLI 中转(比如 codex exec),那 CLI 自己加的 system prompt 你控制不了。 那部分能缓存多少完全取决于 CLI 的实现。只有自己拼 API 请求,才能最大化前缀长度。
七、经济账
以 DeepSeek V4 Flash 的定价为例:
- 未缓存 input:¥1 / M tokens
- 缓存命中 input:¥0.1 / M tokens(90% 折扣)
一个 100 轮的 agent session,每轮平均 5000 input tokens:
| 场景 | 总 input | 缓存命中 | 未命中 | 费用 |
|---|---|---|---|---|
| 37% 命中率 | 500K | 185K | 315K | ¥0.334 |
| 97% 命中率 | 500K | 485K | 15K | ¥0.064 |
费用降低 5.2 倍。对于每天跑几百个 session 的高频 agent 场景,这个差距直接影响运营成本。而且缓存命中的请求首 token 延迟通常减半——省钱的同时还更快。
八、总结:四条工程规则
- 对话历史 append-only——旧消息一旦发出就永远不动,新内容只在尾部追加。
- System prompt 冻结——计算一次,整个 session 复用,动态内容不放在 system 里。
- 动态上下文持久化——不要用「临时注入再丢弃」的模式,注入了就留着,下次 append 新的增量。
- 检测回归——hash 每次请求的前 N 个 message 的 bytes,命中率下降时自动报告是哪个组件破坏了前缀。
归根结底就是一句话:字节前缀越长越稳定,命中率越高。
「凡事豫则立,不豫则废。言前定则不跲,事前定则不困。」——《礼记·中庸》
“In all things success depends upon preparation; without preparation there is always failure. If what is to be spoken be previously determined, there will be no stumbling.” — Book of Rites, Doctrine of the Mean