第七章:Claude Code CLI 内部 Fork 原语——系统如何驱动记忆提取与对话压缩

一、两层 Agent 体系

第六章介绍的是用户可见的多 Agent 系统——用户主动调用 AgentTool,spawn 出来的子 Agent 执行具体任务,用户能看到进度通知。

本章介绍的是另一套完全不可见的基础设施:系统在每轮对话结束后,悄悄 fork 出后台 Agent 提取记忆、压缩对话、召回相关文件——用户感知不到这些过程,但它们对 Claude Code 的长期记忆和性能至关重要。

┌──────────────────────────────────────────────┐
│  用户可见层(第六章)                         │
│  AgentTool → Coordinator / Async Agent       │
├──────────────────────────────────────────────┤
│  系统内部层(本章)                           │
│  runForkedAgent()   sideQuery()              │
│  CacheSafeParams    createSubagentContext()  │
└──────────────────────────────────────────────┘

两层共享同一套底层 query() 循环,但内部层有不同的约束重点:

维度 用户可见层 系统内部层
触发方式 主模型工具调用 每轮结束后代码自动触发
对话轮次 不设上限或较高上限 严格限制(1–5 轮)
工具权限 按 Agent 定义配置 硬编码最小权限
转录记录 写入 sidechain 大多跳过(skipTranscript)
对 Cache 的态度 新建 Cache 条目 尽可能命中父级 Cache

二、为什么要命中父级 Cache?

理解本章的核心,先要理解 Anthropic API 的 Prompt Cache 机制。

每次调用 Claude API,如果这次请求的消息前缀与上一次完全一致,API 会直接复用已缓存的 KV 计算结果,跳过重新计算——这就是 cache hit。Cache hit 意味着这部分 token 几乎不产生计算费用,速度也更快。

主对话循环每轮结束后,会立刻 fork 出记忆提取 Agent。这个 fork 的消息列表前缀与刚结束的主请求完全一致(因为都以相同的对话历史开头)。如果参数配置得当,fork 可以 100% 命中主循环刚建立的 Cache——相当于用 0 额外成本换来了一个并行工作的 Agent

反过来,如果 fork 的任何一个参数与主请求不同(比如系统提示多了一行、工具列表少了一个、thinking config 的 budget 变了),前缀就不再匹配,cache 失效,这次 fork 需要重新计算所有 token,成本陡增。

这就是整个第七章所有设计决策的根源:一切细节都在服务于"让 fork 命中父级 Cache"这一目标。


三、CacheSafeParams:Cache Key 的五个要素

utils/forkedAgent.ts:57

Anthropic API 的 Cache Key 由五部分组成,CacheSafeParams 把这五部分打包在一起,供 fork 使用:

export type CacheSafeParams = {
  systemPrompt: SystemPrompt              // 系统提示
  userContext: { [k: string]: string }   // 前置到 messages 的用户上下文
  systemContext: { [k: string]: string } // 追加到系统提示的系统上下文
  toolUseContext: ToolUseContext          // 包含工具列表、模型、thinking config
  forkContextMessages: Message[]         // 父级的完整消息历史
}

五个字段与 Cache Key 五要素的对应关系:

Cache Key 要素 来自 CacheSafeParams 的哪个字段
system prompt systemPrompt + systemContext
tools toolUseContext.options.tools
model toolUseContext.options.mainLoopModel
messages prefix forkContextMessages
thinking config toolUseContext.options.thinkingConfig

3.1 全局槽位:如何传递给 fork

fork 发生在每轮结束之后,但 fork 函数的调用方(如记忆提取模块)并没有直接持有这些参数——它们存在于主循环的调用栈深处,无法直接传递。

解决方案是一个模块级全局变量

// forkedAgent.ts:73
let lastCacheSafeParams: CacheSafeParams | null = null

主循环每轮结束时,handleStopHooks 调用 saveCacheSafeParams() 把当前状态快照写进这个槽位。记忆提取、压缩摘要等 fork 调用方通过 getLastCacheSafeParams() 读取,拿到与父请求完全一致的参数。

这个设计看起来"不优雅"(全局变量、命令式写入),但它是在不改变函数调用签名的前提下,跨模块传递上下文的最简方案。从系统边界来看,lastCacheSafeParams 的生命周期非常明确:每轮开始时置空,轮结束时写入,fork 立即读取。


四、createSubagentContext():状态隔离的实现

utils/forkedAgent.ts:345

Fork Agent 和父 Agent 并发运行,如果共享同一份可变状态,会有数据竞争风险。createSubagentContext() 为 fork 创建一份隔离的 ToolUseContext,确保 fork 的任何状态变更不会影响主循环。

4.1 各字段的隔离策略及原因

readFileState(文件读取缓存)—— 克隆

主循环和 fork 可能同时读写文件状态(比如记录某个文件是否已读取)。克隆而不是共享,防止 fork 的读取记录污染主循环的缓存状态。

abortController(中止控制器)—— 新建子控制器

createChildAbortController(parentContext.abortController) 建立父子关系:父中止时子也中止(用户按 Esc 可以同时停掉 fork),但子中止时父不受影响(fork 出错不影响主对话继续)。

setAppState(应用状态更新)—— 默认替换为 no-op

后台 fork 不应该修改主界面的应用状态(比如更新 token 计数、修改 UI)。把这个回调替换为空函数,从架构上杜绝 fork 影响主循环的可能性。交互型子 Agent(如 AgentTool 派出的前台 Agent)可以通过 shareSetAppState: true 选择共享。

shouldAvoidPermissionPrompts(权限提示开关)—— 强制 true

后台 Agent 在用户不知情的情况下运行,弹出权限提示框会中断用户正在进行的操作。这个标志强制设为 true,让 fork 在遇到需要权限确认的操作时静默拒绝,而不是弹框。

addNotification / setToolJSX(UI 回调)—— 置为 undefined

后台 Agent 无权操作主界面,这些回调直接不传,试图调用会得到 undefined is not a function,在编译期而非运行期暴露错误。

contentReplacementState(内容替换状态)—— 克隆而非新建

这是最微妙的一处。Claude 的工具调用结果有时会被"替换"(比如大文件内容被摘要替代),这个状态记录了哪些 tool_use_id 已经被替换。

如果给 fork 一个全新的空状态,fork 看到父级消息里的 tool_use_id 时会认为"这是新的",做出不同的替换决策,导致 fork 的消息前缀与父请求不同——cache miss。

克隆父级状态,fork 会对相同的 tool_use_id 做出相同的决策,前缀字节完全一致——cache hit。

4.2 隔离 vs 共享快速参考

字段 默认 可 opt-in 共享
readFileState 克隆 可替换为自定义状态
abortController 新建子控制器 shareAbortController: true
setAppState no-op shareSetAppState: true
setResponseLength no-op shareSetResponseLength: true
contentReplacementState 克隆 可替换为重建的状态
shouldAvoidPermissionPrompts 强制 true 共享 abortController 时跳过此逻辑
addNotification / setToolJSX undefined 不可共享
updateAttributionState 直接共享 并发写安全,无需隔离
setAppStateForTasks 始终指向根 store 不可覆盖,防止后台 Bash 任务变成孤儿进程

五、runForkedAgent():完整 Query 循环的封装

utils/forkedAgent.ts:489

runForkedAgent() 是系统级 fork 的统一入口,封装了"创建隔离上下文 → 合并消息 → 运行 query 循环 → 清理资源 → 上报指标"的完整流程。

5.1 关键参数及其设计意图

export type ForkedAgentParams = {
  promptMessages: Message[]         // fork 新增的任务消息(追加到父级历史末尾)
  cacheSafeParams: CacheSafeParams  // 与父请求共享 cache 的五要素
  canUseTool: CanUseToolFn          // 工具权限检查(决定 fork 能用哪些工具)
  querySource: QuerySource          // 用于分析追踪的来源标识
  forkLabel: string                 // 日志和指标的标签
  maxTurns?: number                 // 轮次上限——防止 fork 失控
  skipTranscript?: boolean          // 不写 sidechain 转录——后台静默工作用
  skipCacheWrite?: boolean          // 不建新 Cache 条目——fire-and-forget 用
  maxOutputTokens?: number          // ⚠️ 谨慎使用,见下方说明
}

关于 maxOutputTokens 的警告

设置 maxOutputTokens 会影响 API 请求里的 budget_tokens(thinking config),而 thinking config 是 Cache Key 的一部分。一旦 budget_tokens 与父请求不同,Cache 就失效了。

因此:需要命中父级 Cache 的 fork(如记忆提取)绝对不能设置此值;不需要 Cache 共享的 fork(如压缩摘要的 streaming fallback 路径)可以设置。

5.2 执行流程

false

true

runForkedAgent 入口

createSubagentContext
创建隔离上下文

拼接初始消息
forkContextMessages + promptMessages

skipTranscript?

createAgentId + 记录初始转录

不创建 agentId,不记录转录

进入 query 循环

stream_event: 累加 totalUsage

非 stream 消息: 收集到 outputMessages

skipTranscript=false 且有 agentId?

recordSidechainTranscript 记录本轮

还有下一轮?

finally 清理

readFileState.clear 释放克隆内存

initialMessages.length = 0 释放消息引用

logForkAgentQueryEvent 上报指标

返回 messages + totalUsage

为什么不在进入 query 循环前过滤不完整的 tool_use?

主循环可能在一个 tool_use block 还没有对应 tool_result 时就结束了(中断场景)。filterIncompleteToolCalls 会直接丢弃整个 assistant 消息,但这会让已有的 tool_result 成为"孤儿"——API 会拒绝这种消息格式(400 错误)。

正确的处理是让下游的 ensureToolResultPairing(在 claude.ts 中)来修复这个配对问题。主循环和 fork 用同样的修复逻辑,修复后的前缀字节一致,Cache 得以命中。

finally 块里的显式内存释放

fork 克隆了父级的文件状态缓存和消息列表,这两份数据可能很大。finally 块确保无论 query 循环是正常结束还是抛出异常,都会立即释放这些内存,不等 GC。


六、sideQuery():不需要工具循环时的轻量选择

utils/sideQuery.ts:107

runForkedAgent() 的核心是一个可以多轮、可以调用工具的 query 循环。但有些场景根本不需要这么重:

  • 语义召回:给模型一个查询,让它从记忆文件列表里选出相关的几条,输出 JSON——一次调用就够
  • 权限解释器:遇到危险操作时,让模型解释"为什么这个操作需要权限"——一次调用就够
  • 模型校验:检查某个模型名是否有效——发一条消息,max_tokens=1,看看有没有报错

这些场景用 sideQuery(),它是对 client.beta.messages.create() 的轻量封装,只调用一次 API,不启动循环

6.1 sideQuery 做了哪些封装?

直接调用 SDK 的 messages.create() 有几个反复要处理的繁琐细节,sideQuery 统一处理了:

OAuth fingerprint 归因:计算请求的 fingerprint 并注入 attribution header,用于 OAuth token 验证和 COGS 成本归因(能在数据仓库里把这次 API 调用归到具体功能上)。

CLI 系统提示前缀:每次 API 调用都要附带声明"这是 Claude Code CLI 发出的请求"的前缀。skipSystemPromptPrefix: true 可以跳过(内部分类器使用自己的系统提示时)。

Betas 管理:根据模型自动选择合适的 beta flag(如 structured outputs beta)。

指标记录:调用完成后自动记录 tengu_api_success 事件,包含 token 消耗和延迟。

6.2 runForkedAgent vs sideQuery 的选型依据

选择依据 runForkedAgent sideQuery
需要调用工具
需要多轮推理
需要命中父级 Cache 否(无 Cache 共享机制)
需要写转录记录 可选 不写
返回结果类型 多条 Message 的列表 单个 API Response

简单判断:如果需要工具调用,用 runForkedAgent;如果只需要一次推理,用 sideQuery


七、工具权限沙箱:createAutoMemCanUseTool()

services/extractMemories/extractMemories.ts:171

记忆提取 Agent 获得了父级工具列表的完整副本(这是 Cache 共享的要求——工具列表是 Cache Key 的一部分,fork 不能有不同的工具列表)。但这不代表它能用所有工具——canUseTool 函数在运行时动态拦截每一次工具调用。

createAutoMemCanUseTool() 实现了精确的运行时沙箱:

工具类型 权限 原因
Read / Grep / Glob 无条件允许 天然只读,无副作用
Bash(只读命令) 允许 isReadOnly() 检测:ls、cat、find、stat、wc 等
Bash(写操作) 拒绝 记忆 Agent 不应执行任意 Shell 命令
Edit / Write(在 memoryDir 内) 允许 记忆提取的核心操作
Edit / Write(在 memoryDir 外) 拒绝 严格限制写操作范围
REPL 工具 允许 见下方说明

为什么允许 REPL 工具?

在某些部署模式下(ant-default),底层工具(Read、Bash 等)被隐藏,所有操作通过一个 REPL 沙盒来执行——模型调用 REPL 工具,REPL 内部再调用真实的 Read/Bash/Edit。

如果 canUseTool 拒绝 REPL 工具,记忆 Agent 就什么都做不了。但如果允许 REPL,REPL 内部的每次原始工具调用还会再次经过 canUseTool 检查——真正的权限控制在原始工具那一层依然有效。

允许 REPL 还有另一个原因:工具列表稳定性。如果记忆 Agent 的工具列表与父请求不同(比如过滤掉了 REPL),Cache Key 就不匹配,cache 失效。


八、三个业务场景的参数配置

8.1 记忆提取(extractMemories)

每轮对话结束后,系统自动触发记忆提取,把有价值的信息持久化到 ~/.claude/projects/ 目录。

// extractMemories.ts:415
await runForkedAgent({
  promptMessages: [createUserMessage({ content: userPrompt })],
  cacheSafeParams,             // 共享父 Cache,接近零额外成本
  canUseTool,                  // createAutoMemCanUseTool()
  querySource: 'extract_memories',
  forkLabel: 'extract_memories',
  skipTranscript: true,        // 静默运行,不写转录
  maxTurns: 5,                 // 典型 2-4 轮:读现有记忆 → 决策 → 写文件
})

skipTranscript: true 的原因:记忆提取与主线程并发,同时写 sidechain 转录文件可能产生竞争条件。既然这是系统内部工作,不需要让用户看到,直接跳过。

maxTurns: 5 的原因:正常流程是"读现有记忆文件 → 判断是否有新内容值得保存 → 写文件",2-4 轮完成。5 是上限,防止 Agent 陷入"反复验证"的循环消耗多余 token。

不设 maxOutputTokens:设了就会改变 thinking config,Cache 失效。

8.2 对话压缩(compact)

当对话上下文接近 token 上限时,触发压缩:让 fork Agent 读取全部对话历史,生成一份结构化摘要,替换掉原来的消息列表。

// compact.ts:1188
await runForkedAgent({
  promptMessages: [summaryRequest],
  cacheSafeParams,
  canUseTool: createCompactCanUseTool(),
  querySource: 'compact',
  forkLabel: 'compact',
  maxTurns: 1,                 // 压缩是纯生成任务,不需要工具调用
  skipCacheWrite: true,        // 压缩后旧历史被丢弃,这段 Cache 永远不会被读取
  overrides: {
    abortController: context.abortController,  // 用户 Esc 可中止
  },
})

maxTurns: 1 的原因:压缩摘要不需要工具循环,一次生成即可。

skipCacheWrite: true 的原因:压缩完成后,旧的消息历史被替换掉。这段对话前缀将永远不会再被任何后续请求用到,建立 Cache 条目纯属浪费。

8.3 语义召回(findRelevantMemories)

用户发来新消息时,系统在主 Agent 响应前先做一次记忆搜索,找出最相关的记忆文件注入上下文。

await sideQuery({
  querySource: 'find_relevant_memories',
  model: currentModel,
  system: FIND_MEMORIES_SYSTEM_PROMPT,
  messages: [{ role: 'user', content: userQuery }],
  output_format: {
    type: 'json_schema',
    json_schema: { /* 文件路径列表的 schema */ }
  },
  max_tokens: 256,             // 输出只是文件路径列表,不需要长输出
  skipSystemPromptPrefix: true, // 使用专用分类系统提示,跳过通用 CLI 前缀
})

sideQuery 而非 runForkedAgent:召回是单次分类推理,不需要工具调用,sideQuery 直接返回一个 JSON 列表即可。

max_tokens: 256:输出是一组文件路径,几十个字符就够,严格限制避免模型"多说废话"。


九、完整调用链路总览

每轮结束

saveCacheSafeParams

getLastCacheSafeParams

getLastCacheSafeParams

主对话循环 query loop

handleStopHooks

lastCacheSafeParams
全局槽位

触发 extractMemories

触发 compact 检查

runForkedAgent
canUseTool=autoMem
skipTranscript=true
maxTurns=5

runForkedAgent
skipCacheWrite=true
maxTurns=1

createSubagentContext
状态完全隔离

query 循环
命中父级 Cache

用户新消息到来

findRelevantMemories

sideQuery
max_tokens=256
json_schema 输出

注入相关记忆到上下文


十、设计原则总结

Cache 共享是第一优先级

系统级 fork 的所有设计——CacheSafeParamscontentReplacementState 克隆、工具列表不过滤、不设 maxOutputTokens——都在服务于"命中父级 Cache"这一目标。Cache hit 意味着这次 fork 几乎免费。

状态隔离是安全边界

createSubagentContext() 的"默认隔离、按需共享"模式,确保后台 fork 无法意外修改主循环状态。这是并发安全的基础,也是"后台工作不影响用户体验"的保障。

轮次上限是熔断机制

记忆提取上限 5 轮、压缩摘要上限 1 轮——这些不是经验值,是基于 BigQuery 历史数据分析得出的合理上界,同时也是防止失控的熔断器。

sideQuery vs runForkedAgent 是工具匹配

有工具调用需求、需要多轮推理,用 runForkedAgent;只需单次分类推理,用 sideQuery。选错了不会出错,但成本和复杂度会不必要地上升。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐