子Agent省90%费用的Prompt Cache共享机制

Claude Code 执行复杂任务时会并行派生多个子 agent,每个都需要父对话的完整上下文。假设父对话积累了 100K token,并行 3 个子 agent,朴素实现要付 300K 的输入费用。

熟悉 LLM 推理优化的人会立刻想到:这不就是 KV Cache 共享的问题吗?多个请求如果前缀相同,Attention 层的 Key/Value 可以复用,省掉重复计算。Anthropic 把这个能力以 Prompt Cache 的形式暴露给了 API 用户,对缓存命中的前缀部分打一折。但前提是多个请求的前缀字节完全一致。Claude Code 的 fork 子 agent 被刻意构造成彼此之间 99% 以上的字节相同,3 个子 agent 的实际输入成本大约只有 120K 等价(100K 全价 + 2 × 100K × 10%)。

字节一致,不是语义一致

Prompt Cache 的 key 由五个维度决定:system prompt 的完整内容、tools 定义的 JSON 序列化、model 名称、messages 数组的前缀、thinking config。任何一个维度有一个字节的差异,缓存就完全失效,没有"部分命中"这回事。

一个多余的空格,一个字段顺序的变化,一个布尔值因为 feature flag 读取时机不同而产生的差异,都会让缓存作废。这个约束条件决定了整个设计的走向。

把差异推到最后一行

这是整个设计里最精巧的部分。父 agent 发出 3 个并行工具调用,Claude Code 给每个 fork 构造的消息序列长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Fork 1:
  [...parent_history,
   assistant { tool_use_1, tool_use_2, tool_use_3 },
   user { tool_result_1="Fork started...",
          tool_result_2="Fork started...",
          tool_result_3="Fork started...",
          <fork-directive> directive_1 </fork-directive> }]

Fork 2:
  [...parent_history,
   assistant { tool_use_1, tool_use_2, tool_use_3 },
   user { tool_result_1="Fork started...",
          tool_result_2="Fork started...",
          tool_result_3="Fork started...",
          <fork-directive> directive_2 </fork-directive> }]

Fork 3:  (same pattern, directive_3)

除了最后的 directive 文本不同,三个 fork 的整个消息序列字节完全一致。用伪代码表示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FORK_PLACEHOLDER_RESULT = "Fork started - processing in background"

def build_forked_messages(directive, assistant_message):
    full_assistant = clone(assistant_message)

    tool_use_blocks = [b for b in assistant_message.content
                       if b.type == "tool_use"]

    # ALL tool_uses get IDENTICAL placeholder results
    tool_results = [
        {"type": "tool_result",
         "tool_use_id": block.id,
         "content": FORK_PLACEHOLDER_RESULT}
        for block in tool_use_blocks
    ]

    user_message = create_user_message(
        content=[*tool_results,
                 {"type": "text",
                  "text": build_child_directive(directive)}]
    )
    return [full_assistant, user_message]

这段逻辑里藏着几个反直觉的选择。

占位符是一个常量字符串,所有 fork 的所有 tool_result 都用完全相同的文本。为什么不放真实结果?因为 fork 是并行启动的,Fork 1 开始执行时 Fork 2 和 Fork 3 的结果还不存在。而且就算存在,放入不同的结果也会破坏前缀一致性。

父 agent 的 assistant 消息被完整保留,包含所有 tool_use 块,不是只取当前 fork 对应的那个。初看有点浪费:Fork 1 只执行 directive_1,为什么要看到 tool_use_2 和 tool_use_3?还是为了缓存。如果每个 fork 只看到自己那个 tool_use,三个 fork 从 assistant 消息开始就分叉了,前缀一致性在这里就断了。

所有 fork 的 user 消息里都包含全部 3 个 tool_result,不管它实际上只关心哪一个。这保证了从 tool_result 序列到 directive 之间的每一个字节都相同。directive 本身也用了一个固定的 XML 模板包裹,模板在所有 fork 之间一样,真正不同的只是末尾几十个 token 的具体任务描述。

所有差异都被推到了消息序列的最末尾。这很像数据库 clustered index 的设计:把高基数列放在最后,让公共前缀尽可能长。

不只是消息要对齐

消息前缀的构造只解决了五个维度中的一个。剩下四个也不能有任何差异。

最微妙的是 system prompt。它的渲染可能依赖 feature flag 的状态,而 flag 状态可能在父 agent 回合开始和 fork 创建之间发生变化。哪怕逻辑相同,flag 变了,输出的字节就可能不同。所以 fork 子 agent 直接使用父 agent 已经渲染好的字节,而不是重新渲染。

tools 定义走同样的思路:fork 通过 useExactTools=true 直接用父 agent 的工具数组引用,不做任何过滤或重排。thinking config 完整继承,不设 maxOutputTokens,避免下游的 clamping 逻辑改变字节表示。

还有一个容易忽略的维度:ContentReplacementState。Claude Code 有工具结果预算管理机制,会对过长的 tool result 做截断。同一个 tool_use_id,全新状态和克隆状态可能做出不同的截断决策。所以 fork 克隆父 agent 的替换状态而非创建全新的,保证对继承的消息做出相同的处理。从行为上看,fork 选择克隆而非新建,确保对相同的 tool_use_id 做出相同的截断决策。

这五个维度的对齐要求意味着,fork 子 agent 本质上不能有任何"自己的想法"。它的 system prompt 不是自己的,工具集不是自己选的,thinking 配置不是自己定的,甚至截断策略都是克隆来的。一切都是为了那个字节一致的前缀。

一笔账

来算算这个设计省了多少钱。假设 parent_history 100K token,3 个 tool_use 的 assistant 消息 500 token,placeholder results 200 token,每个 directive 约 100 token。

Fork 1 需要为 assistant 消息 + placeholder + directive 付全价(约 800 token),前面 100K 命中缓存按 10% 计费。Fork 2 和 Fork 3 连 assistant 消息和 placeholder 都命中了缓存,只有 directive 的 100 token 付全价。

三个 fork 的总输入成本:100K × 10% × 3 + 800 + 100 × 2 = 约 31K 等价。对比不做缓存共享的 300K+,省了近 90%。对话越长,省得越多。100K 变 200K 时,节省的绝对量也翻倍。

而且这个收益不只是并行 fork 能享受。每轮对话结束后跑的 session memory 提取、prompt suggestion 这些后台任务也走同一套缓存共享。系统在每个主循环回合结束后保存一份参数快照,后续的旁路 fork 读这个快照来构造请求,而不是各自重新收集参数,避免了时间窗口差异导致的字节不一致。

为了字节一致,保留一个不会被调用的工具

Fork 子 agent 的工具池里保留了 Agent 工具,但实际上禁止调用它。为什么留着?去掉 Agent 工具会改变工具 schema 的序列化字节,让缓存在 tools 维度上对不齐。一个工具的存在纯粹是为了它序列化时产生的字节,跟功能完全无关。

防止 fork 递归调用 Agent 工具的检测分两层:第一层查 querySource(设在 options 上,不受消息压缩影响),第二层扫消息历史里有没有 fork boilerplate 标签(兜底,防 querySource 没正确传递)。两层各有盲区,组合起来才能覆盖所有路径。

共享缓存不代表共享状态。每个 fork 拿到的是隔离的运行时:文件缓存是克隆的,abort controller 是新建的(但链接到父 agent,中断信号向下传播),mutation 回调默认 no-op。fork 完成后资源被显式释放,防止长 session 里的内存泄漏。

在这个系统里,一个工具可以没有被调用的可能性,但不能没有被序列化的必要性。这大概是字节级兼容对工程美学的终极要求。