Prompt cache 设计
目标
这页回答:
为什么 Claude Code 里很多实现看起来“有点拧巴”,其实是在围绕 prompt cache 命中率做工程优化?
这页不是讲某一个文件,而是把前面分散在各模块里的 cache 设计思路收束起来。
相关文件:
src/tools/AgentTool/forkSubagent.tssrc/tools/AgentTool/AgentTool.tsxsrc/tools/AgentTool/runAgent.tssrc/utils/forkedAgent.tssrc/tools.ts
一句话结论
这套系统非常在意 cache-critical prefix 的字节稳定性。
很多看似“为什么这么麻烦”的实现,背后真正目标都是:
- 让相似请求共享 prompt cache
- 减少重复上下文传输成本
- 降低多 agent / fork / subagent 的 token 开销
也就是说:
这里的 cache 不是小优化,而是反过来塑造架构的硬约束之一。
1. 什么叫 cache-critical prefix?
从 forkedAgent.ts 的 CacheSafeParams 注释里可以看得很清楚:
Anthropic API 的 cache key 会受这些东西影响:
- system prompt
- user context
- system context
- tools
- messages prefix
- thinking config
- model(在上下文里也有影响)
所以如果你想让两个请求共享 cache, 不是“意思差不多”就行, 而是这些前缀内容要尽量一致。
这意味着什么
工程上你得非常克制:
- 不能随手重建 prompt
- 不能随手重排 tools
- 不能让 message 前缀随便变
- 不能让 tool_result 替换逻辑前后不一致
2. fork subagent 为什么长得那么怪?
这是 prompt cache 设计最明显的一块。
目标
多个 fork child:
- 共享父上下文
- 只在最后 directive 上不同
- 其它前缀尽量完全一致
所以才会出现这些设计
A. 继承父 renderedSystemPrompt
不是重新 render 一个逻辑等价的 system prompt, 而是尽量拿父级已经渲染好的 bytes。
原因很简单:
- 重新 render 可能文本上有细微差异
- 细微差异就可能导致 cache miss
B. buildForkedMessages() 保留父完整 assistant message
包括:
- thinking
- text
- 全部 tool_use
这不是为了“上下文更完整”这么简单, 更是为了:
- 尽量保持与父请求前缀的连续性
C. 为每个 tool_use 补完全一样的 placeholder result
Fork started — processing in background
这一步同时满足两件事:
- 协议合法
- 多个 fork child 拥有尽量一致的 tool_result 前缀
D. 只让最后 directive 不同
这相当于把 fork 的差异压到前缀的最尾部。
这是 prompt cache 最喜欢的结构。
3. 为什么 fork child 要 useExactTools?
在 AgentTool.tsx / runAgent.ts 里,fork path 会:
- 传父级 exact tools
- 设置
useExactTools: true
这有什么必要?
如果重新 assemble 一遍 worker tools,就可能出现:
- 工具顺序不同
- 过滤结果不同
- 某些 feature gate 影响工具集合
- tool serialization 不再一样
这些都会破坏 cache key。
所以 fork child 干脆不重建工具池, 而是:
直接拿父 agent 那份 exact tool array。
这就是典型的“为 cache 稳定性牺牲一点抽象整洁度”。
4. 为什么 tools.ts 要强调排序和稳定性?
assembleToolPool() 那段注释其实已经泄底了。
它会:
- 先 built-in tools
- 再 MCP tools
- 各自排序
- 再去重
原因
不是单纯为了好看,而是为了:
- prompt 序列化稳定
- 不因为 MCP tools 插进 built-ins 中间而导致全局 cache key 变化
也就是说, 工具集合如何排序,是 cache 设计的一部分,不是显示层细节。
5. 为什么 contentReplacementState 默认 clone,不 fresh?
createSubagentContext() 里有个特别关键的注释:
对于 cache-sharing forks, 如果给 fresh replacement state, 子 agent 会对父消息里的 tool_use_id 做出不同替换决策, 结果是:
- wire prefix 改变
- cache miss
所以这里必须:
- 不是共享同一个可变对象
- 也不是 fresh state
- 而是 clone 一个语义连续、但彼此隔离的 replacement state
这就是非常典型的 cache-aware state design。
6. 为什么 runForkedAgent() 还要专门定义 CacheSafeParams?
因为作者已经明确知道:
如果要稳定复用父 cache,就不能让调用方随便传一堆“差不多”的参数。
所以用 CacheSafeParams 这个类型把关键字段钉死:
- systemPrompt
- userContext
- systemContext
- toolUseContext
- forkContextMessages
这其实是在做一件很工程化的事:
把“哪些字段会影响 cache 命中”提升为显式 API 设计。
不是靠文档提醒,而是靠类型和函数签名提醒。
7. thinking config 为什么也会被特殊对待?
forkedAgent.ts 的注释提到:
- thinking config 也是 cache key 的组成部分
- 尤其 older models 下,maxOutputTokens 可能间接影响 budget_tokens
这就解释了为什么 fork child / useExactTools 路径里还会特意继承:
thinkingConfigisNonInteractiveSession
这些看起来不像“上下文内容”, 但实际上会影响请求形状。
这又说明一个事
这套系统对 cache 的理解不是浅层的“文本一样就行”, 而是已经意识到:
- request shape
- config shape
- tool serialization
都会影响命中。
8. 为什么很多地方宁可传“父已渲染结果”,也不愿意重算?
这类设计在这套代码里反复出现。
原因归根到底就一句:
重算虽然语义上等价,但字节上不一定等价。
而 prompt cache 要的是后者。
所以能传:
- rendered system prompt
- exact tools
- inherited context messages
就尽量不重建。
这和很多普通业务系统的习惯相反。 普通业务系统喜欢:
- 数据重建
- 对象重组
- 结果等价即可
但这里不行。这里是 token economy 主导的系统。
9. 为什么说 cache 反过来塑造了架构?
因为你回头看前面的模块,会发现很多关键设计都被 cache 影响了:
forkSubagent.ts
消息构造方式明显为 cache 服务。
AgentTool.tsx
fork path 特意传 parent rendered prompt / parent exact tools。
runAgent.ts
useExactTools 分支、forkContextMessages、thinkingConfig 继承,都是 cache-aware。
createSubagentContext()
contentReplacementState clone 是 cache-aware。
tools.ts
工具池排序和分区是 cache-aware。
forkedAgent.ts
直接把 cache-safe 参数显式建模。
这不是零星优化,而是:
cache 命中率已经成为设计约束,反向要求运行时保持 prefix 稳定。
10. 这带来的工程取舍是什么?
优点
- 多 agent / fork 成本更低
- 大上下文重用价值高
- 高频并发分派更可承受
- prompt 侧 token 浪费减少
代价
- 实现更绕
- 有些代码看起来“不够优雅”
- 很多地方必须保留 exact bytes / exact order / exact shape
- 某些本可重构的逻辑反而不敢轻易动
也就是说,cache 优化会让代码更像“精密机械”, 而不是随意可替换的业务层拼装件。
11. 用一句大白话总结
如果不考虑 prompt cache,很多东西完全可以写得更直白:
- fork child 重建一套 prompt
- 重组一套 tools
- 随便拼上下文
- 逻辑相同就行
但这套系统没有这么做。
因为对它来说,多 agent / subagent / fork 的成本如果不靠 cache 控住, 就会非常肉疼。
所以这里的真实设计哲学其实是:
宁可把运行时设计得更讲究,也要把重复上下文成本压下去。
当前结论
Prompt cache 在 Claude Code 里不是一个“底层优化选项”,而是一个上层架构因素。
它直接影响了:
- fork 的消息构造方式
- subagent 的 context 派生策略
- tool pool 的装配方式
- system prompt 的传递方式
- replacement state 的处理方式
你如果理解了这点,很多原本看着古怪的实现都会一下顺眼很多:
它们不是随手写怪了, 是因为这套系统在为 可复用的 token 前缀 服务。