第七章:Claude Code CLI 内部 Fork 原语——系统如何驱动记忆提取与对话压缩
第七章:Claude Code CLI 内部 Fork 原语——系统如何驱动记忆提取与对话压缩
第七章: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 执行流程
为什么不在进入 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:输出是一组文件路径,几十个字符就够,严格限制避免模型"多说废话"。
九、完整调用链路总览
十、设计原则总结
Cache 共享是第一优先级
系统级 fork 的所有设计——CacheSafeParams、contentReplacementState 克隆、工具列表不过滤、不设 maxOutputTokens——都在服务于"命中父级 Cache"这一目标。Cache hit 意味着这次 fork 几乎免费。
状态隔离是安全边界
createSubagentContext() 的"默认隔离、按需共享"模式,确保后台 fork 无法意外修改主循环状态。这是并发安全的基础,也是"后台工作不影响用户体验"的保障。
轮次上限是熔断机制
记忆提取上限 5 轮、压缩摘要上限 1 轮——这些不是经验值,是基于 BigQuery 历史数据分析得出的合理上界,同时也是防止失控的熔断器。
sideQuery vs runForkedAgent 是工具匹配
有工具调用需求、需要多轮推理,用 runForkedAgent;只需单次分类推理,用 sideQuery。选错了不会出错,但成本和复杂度会不必要地上升。
更多推荐


所有评论(0)