引言:缓存命中率为什么值得花心思
在大模型应用的账单里,input token 往往占总成本的绝对大头,而长上下文应用(RAG、Agent、多轮对话)中,大部分 input token 是重复发送的------同一个 system prompt、同一批工具定义、同一段历史消息,每一轮都要重新传给服务端。
Prompt Caching 就是针对这个场景的优化:服务端按前缀缓存计算结果,命中后只按约 1/10 的价格计费,同时显著降低首 token 延迟(TTFT)。
但缓存不是"全有或全无"的。它的设计牵扯到请求的渲染顺序、工具的稳定性、厂商对"智能体应该如何工作"的假设。这篇文章分两部分:
- API 厂商端:三家主流厂商的服务器端缓存机制对比
- 智能体端:作为客户端/智能体框架,如何设计才能最大化命中率
第一部分:API 厂商端(服务器端缓存机制)
0. 一切缓存设计的核心不变量:前缀匹配
几乎所有主流厂商的 prompt cache 都是前缀匹配(prefix match) :缓存键由渲染后的提示词前缀的实际字节哈希决定,前缀中任何一个字节发生变化,其后所有内容全部失效。
这个不变量是理解后面所有机制的基础。谁能让自己的稳定内容尽可能靠前、让易变内容尽可能靠后,谁就能最大化命中率。
1. OpenAI:显式的缓存路由与控制
OpenAI 的缓存设计基本结构:
[固定系统指令][工具定义][历史消息][最新用户消息]
其服务器端机制分三步:
- Cache Routing(缓存路由) :请求根据提示语前缀的哈希值被分配到对应机器。哈希通常由前 256 个字符生成(具体长度因模型而异)。这一步决定了请求落在哪台机器上------前缀哈希不同,就路由到不同机器,前一个请求写入的缓存别人读不到。
- Cache Lookup(缓存查询):系统检查提示语开头是否命中该机器的缓存。
- Cache Hit / Cache Miss:命中则按缓存费率计费并直接返回;未命中则全量处理,并在启用自动缓存时把前缀写入该机器供后续使用。
OpenAI 给客户端提供了三个控制参数:
| 参数 | 作用 | 类比 |
|---|---|---|
prompt_cache_key |
指定缓存 key,让相似请求进入同一缓存分组 | 仓库编号 |
prompt_cache_options |
决定自动/手动缓存,以及缓存时长 ttl |
仓库管理规则 |
prompt_cache_breakpoint |
指明缓存到哪个位置截止,后续不缓存 | 在货物上画截止线 |
示例(prompt_cache_breakpoint 的 explicit 模式):
{
"model": "gpt-5.6",
"prompt_cache_key": "customer-service-v1",
"prompt_cache_options": { "mode": "explicit", "ttl": "30m" },
"messages": [
{
"role": "system",
"content": [
{
"type": "text",
"text": "这里是很长的客服规则......",
"prompt_cache_breakpoint": { "mode": "explicit" }
}
]
},
{ "role": "user", "content": "我的订单什么时候发货?" }
]
}
OpenAI 的已知问题 :工具定义排在缓存的第二位,只要工具发生变动,变动之后的缓存命中立即失效。
2. DeepSeek:结构相似,问题相同
DeepSeek 的缓存结构:
<BOS>[system prompt][tool descriptions / tool definitions][user1][assistant1][tool call / tool result][user2][assistant2]...[最新 user][assistant generation prefix]
与 OpenAI 大致相同:system prompt 在前,工具描述紧随其后,之后是交替的对话历史。因此缓存失效的问题也一样------工具定义一变,后续全部失效。
3. Anthropic:不同的渲染顺序与三段缓存层级
Anthropic 的渲染顺序是三家中最特殊的:
tools(位置 0)→ system → messages
工具在位置 0 渲染,system 其次,messages 最后。这意味着:
- tools 任何变动都会破坏整个缓存(包括 system 和 messages 的缓存)------所以 tools 必须高度稳定;
- system 可以分段缓存,动态信息应放到末尾的 breakpoint 之后;
- messages 本身就在不断变化,其命中靠的是"每轮只 append 新消息,历史前缀不变"。
为什么 Anapthropic 敢把最容易变动的 tools 放在最前面?这背后是它对智能体的设计哲学(后面详述)。
更细的机制层面,Anthropic 的缓存有三层独立层级,变化只破坏自己层级及以下的缓存:
| 变化 | tools 缓存 | system 缓存 | messages 缓存 |
|---|---|---|---|
| 增删改工具定义 | ❌ | ❌ | ❌ |
| 切换模型 | ❌ | ❌ | ❌ |
| 修改 system prompt 内容 | ✅ | ❌ | ❌ |
修改 tool_choice 或开关 thinking |
✅ | ✅ | ❌ |
| 修改 messages 内容 | ✅ | ✅ | ❌ |
几个容易踩坑的工程细节:
- 最小可缓存前缀 :不同模型要求不同,低于阈值即使打了标记也静默不缓存(Opus 5 是 512 tokens,Opus 4.6/Haiku 4.5 是 4096 tokens)。
- 20-block 回看窗口:每个 breakpoint 最多向前回看 20 个 content block。Agent 循环中工具调用密集时,一轮可能新增超过 20 个 block,下一轮就找不到上一轮缓存。解法是每 15 个 block 左右放一个中间 breakpoint。
- 并发请求时序:缓存条目在第一个响应开始流式输出后才可读。N 个前缀相同的并发请求都会 miss------先发一个请求等首 token,再发剩下的 N-1 个,就能全部命中。
Anthropic 的逃逸口(这两个机制恰好回答了"工具变了怎么办"):
- mid-conversation system message :在
messages[]里追加{"role": "system", ...},代替修改顶层 system,不破坏缓存前缀; - mid-conversation tool changes (beta):在
messages[]中以tool_addition/tool_removalblock 增删工具,工具声明时用defer_loading: true占位。
4. 三家对比与 Anthropic 的设计哲学
| 维度 | OpenAI | DeepSeek | Anthropic |
|---|---|---|---|
| 渲染顺序 | system → tools → messages | system → tools → messages | tools → system → messages |
| 缓存控制 | prompt_cache_key + breakpoint + ttl | 自动缓存为主 | cache_control breakpoint |
| 工具变动的代价 | 工具后全部失效 | 工具后全部失效 | 整个缓存全部失效 |
Anthropic 把 tools 放在位置 0,是一个带着强烈立场的设计------里面藏着它对"智能体应该怎样工作"的三条判断:
- 工具是能力边界,不是临时道具。 Agent 的工具集是身份的一部分(你是谁、能干什么),不是会话的装饰品。频繁换工具意味着身份在漂移,在模型的设计里这是异常行为。
- System Prompt 是约束,Messages 是事实。 System 说"你应该做 A",但 messages 中工具跑出来的结果是"实际发生了 B"。模型在矛盾时相信实际发生的事实。所以动态信息放 messages 是合理的------模型会自己判断优先级。
- 智能体的主流使用模式是长周期、少变动。 这个缓存设计假设你构建一个 agent → 运行几百万次 → 偶尔调整能力。它对"脚手架一次性搭好就跑"的模式做了极致优化,而对"每轮动态增减工具"的模式,回答是"去用逃逸口"。
第二部分:智能体端(客户端缓存机制)
看完厂商端,回到我们自己要写的智能体框架。客户端优化的核心就一句话:
1. 最初的认识:system 固化、messages 只追加
system prompt:保持不动(字节级)
user message :只向后追加
原则是:旧的历史消息完全固化,所有新东西(新提问、工具结果)只追加到末尾。这样每次请求的整个前缀(system + 全部历史)都保持一致,服务端每轮都能命中缓存,成本曲线几乎是平的而不是线性增长。
顺带一提,思考链(thinking)等过程性垃圾数据也不要拼进历史,它们会无谓地增加前缀长度、拉低缓存性价比。
2. 难题:工具更新、记忆更新后如何让模型感知?
把 system 完全固化之后,一个自然的问题浮现出来:
更新了记忆、更新了工具之后,怎么把"实时变化"传递给大模型,让它随时知道?
直观想法:在下一轮 user message 里更新说明,或者做记忆重新加载、工具说明重新加载。这样现有历史 message 完全不变,缓存命中率有保障,同时模型能在最新一轮看到变化。
但这个想法部分可行,部分不可行:
- ✅ 记忆更新------可行:记忆本质是文本信息,追加成一条 user message 让模型看到,代价只是占一点上下文空间(注意旧版本记忆会残留在历史里,可能造成新旧矛盾,最好在压缩时处理)。
- ❌ 工具变动------不可行 :参考 OpenAI 的缓存设计,
tools是 API 调用层的独立参数 ,与system、messages平级。即使你在 user message 里写"现在有个新工具叫 X",模型也无法生成合法的tool_use调用------因为tools参数里没有对应 schema。反过来,tools里定义了的工具,消息里说"已废弃",模型照样可能去调用。工具变更绕不开tools参数,而这正是破坏缓存的真正元凶。
3. 工程实践:稳定段 + 动态段
所以纯靠"messages 只追加"并不能解决全部问题。兼顾缓存与动态性的工程做法是把 system prompt 分段:
system = 稳定段(身份、核心约束、规则) ← 几乎不变,缓存完美命中
+
动态段(记忆索引、技能描述、workspace 指令) ← 变化时才变
- 稳定段放在前面、几乎不动,保证缓存前缀稳定;
- 动态段放在末尾 breakpoint 之后,变化只影响缓存的后半部分,不破坏稳定段;
- 再加一层:把记忆、工具执行的结果通过 tool_result 自然写进 messages------这是天然的动态通道,不需要额外设计监控机制。
工具描述想要字节级固化 ,关键是三点:工具列表顺序固定(按名称排序)、schema 序列化确定(sort_keys=True)、以及尽量少动------把工具当成身份而不是临时道具。
4. 工具变动情况
目前工具变动的情况没有更好的方法,主流的智能体当前都会破坏掉高缓存,所以尽量不要变动工具。
总结
缓存命中率不是客户端单方面能决定的,它反映的是厂商的设计哲学与客户端的工程策略如何对齐:
- 厂商端:前缀匹配是铁律,三家在 tools 的位置上分道扬镳------Anthropic 选择"工具即身份"的激进设计,换来跨请求、跨会话的最大前缀复用,同时用逃逸口兜底。
- 客户端:核心是"稳定内容固化、动态内容后置"------system 分段、messages 只追加、变化尽量走消息通道而非顶层参数。
最后给一个实用 checklist:
- system prompt 冻结,动态信息放 breakpoint 之后;
- tools 定义一次,排序稳定,序列化确定;
- messages 只 append,永不修改历史;
- 工具/记忆变化优先走 tool_result 或 mid-conversation 通道;
- 高频 fan-out 场景先发 1 个请求预热缓存再并发;
- 用
usage.cache_read_input_tokens验证,如果长期为 0,检查是否有静默失效源(时间戳、乱序 JSON、动态工具集)。
如果你也想弄懂 Claude Code 这类 Coding Agent 到底是怎么工作的,这个仓库也许能帮你少走一些弯路。目前我的github的项目(OpenAI SDK 版),我学习的课程为learn-claude-code的v2版本课程(感谢原作者),大家一起学习,共同进步。如果对你有帮助,请为我点一个Star。
项目地址:https://github.com/peijiping/learn-claude-code-langchain