Prompt cache 设计

目标

这页回答:

为什么 Claude Code 里很多实现看起来“有点拧巴”,其实是在围绕 prompt cache 命中率做工程优化?

这页不是讲某一个文件,而是把前面分散在各模块里的 cache 设计思路收束起来。

相关文件:

  • src/tools/AgentTool/forkSubagent.ts
  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/runAgent.ts
  • src/utils/forkedAgent.ts
  • src/tools.ts

一句话结论

这套系统非常在意 cache-critical prefix 的字节稳定性

很多看似“为什么这么麻烦”的实现,背后真正目标都是:

  • 让相似请求共享 prompt cache
  • 减少重复上下文传输成本
  • 降低多 agent / fork / subagent 的 token 开销

也就是说:

这里的 cache 不是小优化,而是反过来塑造架构的硬约束之一。


1. 什么叫 cache-critical prefix?

forkedAgent.tsCacheSafeParams 注释里可以看得很清楚:

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 路径里还会特意继承:

  • thinkingConfig
  • isNonInteractiveSession

这些看起来不像“上下文内容”, 但实际上会影响请求形状。

这又说明一个事

这套系统对 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 前缀 服务。