Claude Code 源码分析
之前 Claude Code 的源码泄漏,我们可以借助ai工具很快梳理出其核心原理。下面把源码中有意思的部分整理出来,再和 最近很火的OpenClaw 做个对照看看。
整体架构
Claude Code 的核心其实就是一个标准的 ReAct Agent。入口是 query(),真正干活的是它委托出去的 queryLoop()——一个由 while(true) 驱动的 AsyncGenerator。每跑一圈就是一个 turn,从准备上下文、调模型、处理响应到执行工具,一圈下来就是一个完整闭环。

下面按几个核心维度分别拆开看。
Agent 循环:queryLoop 怎么转
为什么用 AsyncGenerator 而不是普通的 async function?理由很直接:每一轮里都要把流式事件 yield 给 UI,让模型输出和工具执行的进度能实时渲染出来。如果换成普通的 async,必须等整个 turn 结束才能拿到结果,体验立刻就断了。
状态管理上也做了点手脚。Claude Code 没有把状态散在一堆局部变量里,而是封装成一个 State 对象,每次 continue 时整体替换:
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined
turnCount: number
transition: Continue | undefined // 记录上一次循环继续的原因
}
好处也明显:每个 continue 站点只需要造一个新的 State 然后赋值跳转,不用一个个去更新九个变量。transition 字段则把上一次循环为什么继续的原因留了下来,让后面几轮可以基于前一次的决定做判断——比如检查上一轮是否已经做过 collapse_drain_retry,避免来回重复同一个动作。
一轮 turn 的执行顺序
循环之前先预热一次。startRelevantMemoryPrefetch() 和 startSkillDiscoveryPrefetch() 都放在循环外启动,返回的是 Promise 句柄,内部异步执行不阻塞主流程。后面 turn 里通过 settledAt 字段判断是否就绪,就绪了再消费。
进入循环后第一步是组装上下文,按顺序跑这一串:
applyToolResultBudget() 工具结果瘦身,把超大 tool_result 换成摘要
snipCompactIfNeeded() 历史裁剪,删掉太旧的消息
microcompact() 微压缩,清理过期的 tool result
applyCollapsesIfNeeded() 上下文折叠,分段生成摘要
autocompact() 自动压缩,最后兜底
calculateTokenWarningState 检查是否触及硬上限
这几步不是互斥的——snip 在 microcompact 之前,因为两者都可能需要执行;collapse 在 autocompact 之前,是为了让折叠先把上下文压到 autocompact 的阈值之下,autocompact 就直接 no-op,从而保留更细粒度的内容。
第二步把组装好的消息丢给 callModel()。流式返回的消息一边 yield 给 UI,一边累积到 assistantMessages 里;遇到 tool_use 块就记录到 toolUseBlocks,把 needsFollowUp 置成 true。
只要 needsFollowUp 是 true,就进入工具执行阶段,由 runTools() 或 StreamingToolExecutor 接手。工具跑完之后再调 getAttachmentMessages() 注入各种附件——文件变更通知、任务通知、memory 和 skill 的预取结果。
最后是决策。如果模型没请求工具,那就跑 Stop Hooks,看 Token Budget 还够不够、有没有撞到 Max Turns,决定要不要再跑下一轮。
整个循环的伪代码如下:
async function* queryLoop(params):
state = initialState(params)
pendingMemoryPrefetch = startRelevantMemoryPrefetch(state.messages)
while true:
// --- 上下文准备 ---
pendingSkillPrefetch = startSkillDiscoveryPrefetch(messages)
messagesForQuery = applyToolResultBudget(messages)
messagesForQuery = snipCompactIfNeeded(messagesForQuery)
messagesForQuery = microcompact(messagesForQuery)
messagesForQuery = applyCollapsesIfNeeded(messagesForQuery)
messagesForQuery, compactionResult = autocompact(messagesForQuery)
// --- 硬上限拦截 ---
if isAtBlockingLimit(messagesForQuery):
yield APIErrorMessage
return { reason: 'blocking_limit' }
// --- 模型调用 ---
for await message in callModel(messagesForQuery, systemPrompt, tools):
yield message
if message.type == 'assistant':
assistantMessages.push(message)
toolUseBlocks.extend(extractToolUseBlocks(message))
// --- 工具执行 ---
if toolUseBlocks:
for update in runTools(toolUseBlocks):
yield update.message
toolResults.push(update.message)
// --- 附件注入 ---
for attachment in getAttachmentMessages(...):
yield attachment
toolResults.push(attachment)
// 消费 memory prefetch(如果就绪)
if pendingMemoryPrefetch.settledAt and not consumed:
for mem in pendingMemoryPrefetch.promise:
yield createAttachmentMessage(mem)
toolResults.push(mem)
// 消费 skill prefetch
for skill in collectSkillDiscoveryPrefetch(pendingSkillPrefetch):
yield createAttachmentMessage(skill)
toolResults.push(skill)
// --- 决策 ---
if not needsFollowUp:
// 处理 413 恢复、max_output_tokens 恢复
// 跑 Stop Hooks
// 检查 Token Budget / Max Turns
if shouldContinue:
state = newStateWithContinueReason(...)
continue
return { reason: 'completed' }
// 有 follow-up,进入下一轮
state = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
turnCount: turnCount + 1,
...
}
流式 fallback 与错误恢复
模型调用阶段有一段 streaming fallback。流式过程中如果触发 FallbackTriggeredError(一般是模型负载过高),系统会把当前轮收集到的 assistantMessages 和 toolResults 全部清空,模型切到 fallbackModel,丢掉 StreamingToolExecutor 的 pending 结果重建一个新的,然后整个请求重发。fallback 触发时还会向 UI yield 一条 tombstone 消息,让前端把之前流出来的不完整内容移除掉。

工具结果预算控制
工具调用的返回结果可能特别大。BashTool 跑一次 ls -la /,或者 FileReadTool 读一个大文件,输出动辄几十上百 KB。如果不加限制全塞进上下文,几次调用就把整个 token 预算吃光了。
Claude Code 的处理思路是:别把原始内容直接丢掉,换成摘要,然后在摘要里留一个文件路径。模型如果觉得摘要不够,可以主动用 Read 工具去看全文。本质上是 lazy loading。
applyToolResultBudget() 分四步:
collectCandidatesByMessage:按 API 消息序列分组,把同一轮流式返回的 tool_result 候选放在一起共享预算。partitionByPriorDecision:根据历史决策把这组候选切成三类——mustReapply(之前换过摘要的,直接复用缓存的 preview 重放)、frozen(之前见过、模型也读过的,标记冻结,不能再动;动了 prompt cache 的字节匹配就废了)、fresh(首次见到的,可以做替换决策)。selectFreshToReplace:如果 frozen 加 fresh 加起来超过 200KB,从 fresh 里挑最大的先换。策略是贪心的,按大小降序排,从最大开始换,直到总量降到阈值以下。persistToolResult+buildLargeToolResultMessage:被选中的结果先写到磁盘,再生成 preview 字符串去替换原始内容。preview 里会带上完整文件路径。
applyToolResultBudget(messages, contentReplacementState)
│
├─ state === undefined → 直接返回(feature 关闭)
│
└─ enforceToolResultBudget()
│
├─ 1. collectCandidatesByMessage()
│ 按 API 消息序列分组,同一序列共享预算
│
├─ 2. 对每组消息执行:
│ ├─ partitionByPriorDecision()
│ │ ├─ mustReapply: 之前替换过 → 复用 preview
│ │ ├─ frozen: 之前见过且模型已读 → 冻结
│ │ └─ fresh: 首次见到 → 进入替换决策
│ │
│ ├─ frozen + fresh 总量 > 200KB?
│ │ ├─ YES → selectFreshToReplace()
│ │ │ 按大小降序,从最大开始换
│ │ └─ NO → 全部标 seen,跳过
│ │
│ └─ 被选中的结果:
│ ├─ persistToolResult() → 写磁盘
│ └─ buildLargeToolResultMessage() → 生成 preview
│
├─ 3. replaceToolResultContents()
│ 用 preview 去替换原始内容
│
└─ 4. 返回 { messages, newlyReplaced }
contentReplacementState 这个缓存是关键。下一轮循环时同一批 tool_result 还会再过一遍 applyToolResultBudget(),系统得知道哪些已经换过摘要(mustReapply)、哪些被模型读过(frozen)。这个状态只有在 agentId 或 repl_main_thread 的 querySource 下才会持久化到磁盘,目的是保证 resume 会话能把状态恢复回来。
多级压缩策略
Claude Code 的压缩不是一刀切的,按"影响范围"和"成本"分了四层。原则也很朴素:能用便宜操作就别用贵的,能保留细粒度结构就别做全局摘要。
从轻到重排:
- snipCompact:直接裁旧消息,零成本。
- microcompact:清理过期 tool result,借助
cache_editsAPI 保留 KVCache。 - collapse:分段折叠,每段独立摘要,结构基本保得住。
- autocompact:对整段对话生成全局摘要替换历史,最贵的兜底手段。
microcompact 的处理逻辑
先看距离上一条 AI 消息的时间是不是超了阈值。超了就直接清掉过期的 tool result。如果开了 CACHED_MICROCOMPACT,会走 cachedMicrocompactPath:把压缩动作作为 cache edits 提交给服务端,服务端返回真实的 cache_deleted_input_tokens,客户端再用真值替换之前的预估值。边界消息(boundary message)会延迟到 API 响应之后再 yield,确保拿到的是真实数据。
为什么 cache_edits 又快又省 token? 这得先说一下 Anthropic API 的 prompt caching。请求里带了 cache_control: { type: 'ephemeral' } 标记的话,服务端会缓存这个标记之前所有消息的 KVCache(attention 计算的中间状态)。下一轮请求只要前缀字节完全一致,服务端就能直接复用 KV,省掉重新 prefill,延迟和 token 都能降下来。
问题是删除旧的 tool result 时,如果直接改消息内容,整个前缀字节序列就变了,KVCache 全废,服务端必须从头 prefill。压了上下文的 token 数量,但浪费了 cache 命中率,等于得不偿失。
cache_edits API 的处理方式是:客户端在请求体里多带一个 cache_edits 块,声明哪些内容要删;服务端先按未变的前缀匹配 KVCache(命中),然后再去执行删除。删除发生在命中之后,所以不影响前缀的命中率。
autocompact 的处理逻辑
autocompact 比较谨慎。先看 DISABLE_COMPACT 是否打开,开了就跳过;再看熔断器是不是连续失败次数到阈值,到了也跳过。然后 shouldAutoCompact() 判断需不需要压缩。需要的话先试 trySessionMemoryCompaction()——把对话信息抽到 session memory 里再删旧消息;走不通再退到 compactConversation(),给整段对话生成摘要替换全部历史。失败的话失败计数加一,连着失败几次就熔断。
执行顺序也是有讲究的。collapse 在 autocompact 之前跑,因为 collapse 是 read-time 的 view projection,并不真删消息。如果 collapse 能把上下文压到 autocompact 的阈值以下,autocompact 就变成 no-op,留下来的是细粒度的折叠摘要,而不是粗粒度的全局摘要。
这些压缩机制都通过 bun:bundle 的 feature() 函数做编译期 tree shaking。外部构建可以把没启用的功能直接剔掉,包体积也省了。
413 恢复:先便宜后重
上下文一旦超出模型限制,API 返回 413(prompt too long)。Claude Code 不会直接报错退出,而是分两层渐进恢复:
-
recoverFromOverflow():折叠释放。把所有 staged 的 context collapses 全部提交。这个动作相对便宜,只是把已经算好的折叠摘要释放出来用,细粒度结构都还在。如果没有 staged collapses,或者上一轮已经做过(看transition.reason === 'collapse_drain_retry'),就跳过。 -
tryReactiveCompact():反应式压缩。折叠释放还不够用时才上。做一次完整的摘要压缩,替换历史。有hasAttemptedReactiveCompact守卫挡着重复执行。如果 oversized 的是图片/PDF 这类媒体内容,会跳过 collapse drain 直接走这一层,因为 collapse 不处理图片。
max_output_tokens 错误也有类似的两层。一上来先试 escalation——如果用的是 capped 8K 默认值,就把 maxOutputTokensOverride 顶到 64K,用同样的消息再来一遍。还超就转成 multi-turn recovery:注一条 meta 消息 "Output token limit hit. Resume directly — no apology, no recap" 告诉模型继续,最多重试 3 次。3 次还不行就用 withheld 错误终止循环。
上下文工程:每一点 token 都精算
Memory 预取
Memory 系统目标很明确:跨会话积累知识、复用知识。但塞太多记忆是浪费 token,塞了不相关的还会干扰判断。所以核心问题是:检索准确性和注入成本怎么平衡。
startRelevantMemoryPrefetch() 在循环外启动,返回 MemoryPrefetch 句柄。流程是:
- 看
isAutoMemoryEnabled()和对应 feature flag 是不是开的。 - 取最后一条真实用户消息(跳过
isMeta的系统注入)。 - 单词数小于 2 的查询直接跳,省得浪费检索资源。
- 累计熔断:会话累计注入字节 ≥ 60KB 就停掉,不再预取。
- 启动后台搜索,不阻塞主流程。
后台检索本身做几件事:扫 memory 目录读所有文件 header,用 Sonnet 模型的 sideQuery 评分挑出最多 5 个相关记忆,然后逐个读内容,单文件 4KB 上限,超出截断。
消费阶段在 getAttachmentMessages() 里。先看 pendingMemoryPrefetch.settledAt !== null 判断是否就绪,再看 consumedOnIteration === -1 防重复消费,接着 filterDuplicateMemoryAttachments() 过滤掉模型已经读过的文件(基于 readFileState),最后把剩下的转成 AttachmentMessage 注入。
startRelevantMemoryPrefetch(messages, toolUseContext)
│
├─ 1. isAutoMemoryEnabled() + feature flag?
│ └─ NO → 返回 null
│
├─ 2. 提取最后一条非 isMeta 用户消息
│ └─ 单词数 < 2? → 返回 null
│
├─ 3. 会话累计注入字节 >= 60KB?
│ └─ YES → 返回 null
│
├─ 4. 启动异步搜索(fire-and-forget)
│ └─ getRelevantMemoryAttachments()
│ ├─ 扫描 memory 目录读 header
│ ├─ Sonnet sideQuery 评分 → 选最多 5 个
│ └─ 读内容(单文件 4KB 上限)
│
└─ 5. 返回 MemoryPrefetch 句柄
后续 turn 中通过 settledAt 和 consumedOnIteration 判断消费时机
几个细节值得注意。readFileState 是累积状态,跨 turn 累加,确保同一份记忆不会被重复注入。Sonnet 筛选保证选出来的 5 个文件相关度最高。每个文件只读 4KB——这个量大致只够看个开头摘要,要不要看全文留给后续工具判断。
Skill 发现预取
Skill 是 Claude Code 里的自定义命令集合(类似 .claude/commands/ 下的命令)。如果每个 turn 都阻塞式搜哪些 skill 适用,生产数据显示 97% 的情况下都搜不到匹配项,那就纯属浪费时间。所以做成异步预取。
startSkillDiscoveryPrefetch() 每轮 turn 都启动一次(不像 memory 只启动一次),因为它依赖 findWritePivot 守卫判断是否是非 write 迭代。发现搜索在模型流式返回和工具执行的窗口期里跑,等工具执行完再和 memory prefetch 一起消费。只有 turn-0 的用户输入 discovery 会阻塞在 userInputAttachments 里,因为那是唯一一个没有"前置工作"可以遮住等待时间的地方。
Command Queue
多 Agent 场景下,子 Agent 完成任务要通知父 Agent,用户也可能中途发新消息进来。Claude Code 用一个进程全局的 CommandQueue 单例统一管理这些异步消息。
关键设计是消息的作用域隔离。队列是进程全局的,Coordinator 和所有 in-process 子 Agent 共享同一个队列,每个 Agent 在循环里只 drain 属于自己的消息:主线程(agentId === undefined)拿所有非 slash-command 的通知;子 Agent 只拿 mode === 'task-notification' 且 agentId 匹配自己的那部分;用户的 prompt(mode: 'prompt')永远只发给主线程,子 Agent 看不到。
query.ts 每轮 turn 在工具执行后、附件注入前,从队列里取消息:
const sleepRan = toolUseBlocks.some(b => b.name === SLEEP_TOOL_NAME)
const queuedCommandsSnapshot = getCommandsByMaxPriority(
sleepRan ? 'later' : 'next'
).filter(cmd => {
if (isSlashCommand(cmd)) return false // slash 不走这条路
if (isMainThread) return cmd.agentId === undefined // 主线程拿所有通知
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
Sleep 工具在这里起调度作用:本轮跑过 Sleep,队列优先级切到 later,否则用 next。这样不同类型的任务通知就有了不同的调度优先级。
提示词工程
系统提示词的分层构建
系统提示词不是一整块字符串,而是分层拼起来的。这样不同来源的配置可以独立演进互不打架。
构建分三段。第一段拿基础组件(fetchSystemPromptParts()):defaultSystemPrompt 定义 Agent 的基本行为;userContext 来自 getUserContext(),里面是 CLAUDE.md 内容和当前日期;systemContext 来自 getSystemContext(),里面是 git 状态(分支、近期提交、用户等)。后两个都 memoize 了,对话开始时算一次,整个会话复用。
第二段在 query.ts 里拼接:
const systemPrompt = asSystemPrompt([
...(customPrompt ? [customPrompt] : defaultSystemPrompt),
...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
...(appendSystemPrompt ? [appendSystemPrompt] : []),
])
自定义 prompt 在的话直接替换默认;memory mechanics prompt 和 append system prompt 作为可选层追加。
第三段在 claude.ts 的 queryModel 里最终组装:
systemPrompt = asSystemPrompt([
getAttributionHeader(fingerprint), // 归属头
getCLISyspromptPrefix(...), // CLI 前缀
...systemPrompt, // 上面传下来的
...(advisorModel ? [ADVISOR_TOOL_INSTRUCTIONS] : []),
...(injectChromeHere ? [CHROME_TOOL_SEARCH_INSTRUCTIONS] : []),
])
最后注入上下文时,消息要发给模型前用 prependUserContext(messagesForQuery, userContext) 把用户上下文塞到消息数组最前面,系统上下文则用 appendSystemContext(systemPrompt, systemContext) 追加到 system prompt 末尾。这样设计是为了让 CLAUDE.md 这类用户上下文出现在离用户输入最近的位置,注意力权重最高,git 状态那种背景信息放到 system prompt 尾部就够了。
工具 Schema 构建
filteredTools 按当前权限和可用性过滤,toolToAPISchema 把每个工具的定义转成 API 能吃的 schema 格式,toolSchemas 加 extraToolSchemas 合并基础和额外的 schema,最后 allTools 作为最终列表发给模型。
Coordinator 模式下还会再过一道。Coordinator 只看得到 4 个工具(Agent、SendMessage、TaskStop、SyntheticOutput),Worker 才看得到完整的工具池。这个过滤在 toolUseContext.options.tools 里完成,由 coordinatorMode 配置决定。
多 Agent 架构
Coordinator 模式:编排者和工人
最经典的一种。核心思想就是角色分离:Coordinator 负责理解和规划,Worker 负责执行和探索。Coordinator 被刻意限制只能用 4 个工具,避免它越俎代庖去写代码。整个工作流走 Research → Synthesis → Implementation → Verification,研究阶段支持并行。
Coordinator 能用的工具:
Agent:启动新 Worker。SendMessage:往已有 Worker 发消息(复用上下文)。TaskStop:停掉运行中的 Worker。SyntheticOutput:合成输出。
Coordinator 模式下模型参数被忽略,不允许自选模型;Worker 用默认模型干实际任务。
AgentTool 的调用逻辑
AgentTool.call() 大致这么跑:
参数解析和前置校验先跑一遍——Coordinator 模式忽略 model 参数,检查 team_name。
接着是路由分发。指定了 team_name 就走 spawnTeammate,直接进 team 模式;没指定 subagent_type 且 FORK_SUBAGENT 开着就 fork 子 Agent(禁止递归 fork);其他情况按 subagent_type 在已定义的 agent 列表里找匹配。
Agent 配置解析阶段要等 MCP 服务器连接,解析模型并记录,处理 isolation(worktree 或 remote),然后构建 system prompt 和 prompt messages:fork 出来的 Agent 继承父 Agent 的 system prompt、上下文、tool result 和指令;普通 Agent 则重新组装,只传一条单独的简单 user message。
最后是异步/同步执行。异步 Agent 注册到后台,调 runWithAgentContext,每 30 秒生成进度摘要,立即返回 async_launched;同步 Agent 阻塞等结果,但保留中途切异步的能力。
AgentTool.call()
│
├─ 1. 参数解析 & 前置校验
│ ├─ Coordinator → 忽略 model
│ └─ 检查 team_name
│
├─ 2. 路由分发
│ ├─ team_name → spawnTeammate → return
│ ├─ 无 subagent_type + FORK_SUBAGENT → forkSubagent(禁递归)
│ └─ 有 subagent_type → 匹配 agent 定义
│
├─ 3. Agent 配置解析
│ ├─ 等 MCP 服务器连接
│ ├─ 解析模型
│ ├─ 解析 isolation(worktree / remote)
│ └─ 构建 system prompt 和 messages
│ ├─ fork: 继承父 Agent 完整上下文
│ └─ 普通: 单条 user message
│
├─ 4. 执行
│ ├─ 异步: runAsyncAgentLifecycle() → fire-and-forget
│ │ 后台跑,实时更新 UI 进度
│ │ stream 结束 → finalizeAgentTool()
│ │ → completeAsyncAgent()
│ │ → enqueueAgentNotification()
│ └─ 同步: 阻塞等结果
│
└─ 5. 返回 { status: 'async_launched', agentId }
异步 Agent 的通知机制
异步 Agent 这样回话给父 Agent:runAsyncAgentLifecycle() 在后台 for await (msg of runAgent()) 一条条消费 Agent 输出,期间用 updateAsyncAgentProgress() 实时更新 UI 进度。stream 结束后 finalizeAgentTool() 提取结果,completeAsyncAgent() 把任务标成 completed,enqueueAgentNotification() 把一段 XML 通知推进全局 commandQueue。父 Agent 在下一个 turn 开头从队列里 drain 这些通知,作为 AttachmentMessage 注入。模型看到 <task-notification> XML 之后处理结果决定下一步。
AgentTool.call()
│
+--[异步] void runAsyncAgentLifecycle() ←── fire-and-forget
| │
| +-- for await (msg of runAgent()) ←── 后台执行
| | updateAsyncAgentProgress() ←── 实时更新 UI 进度
| │
| +-- finalizeAgentTool() ←── stream 结束
| +-- completeAsyncAgent() ←── task.status = 'completed'
| +-- enqueueAgentNotification() ←── 构造 XML
| │
| +-- enqueuePendingNotification() ←── 推入 commandQueue
| │
| +-- notifySubscribers() ←── 唤醒订阅者
│
+--[立即] return { status: 'async_launched', agentId }
... 父 Agent 继续做别的事 ...
父 Agent 下一个 turn:
query.ts 主循环
│
+-- getCommandsByMaxPriority() ←── 从 commandQueue drain
+-- getQueuedCommandAttachments() ←── 转 attachment
+-- yield AttachmentMessage ←── 作为 user message 注入
│
Claude 看到 <task-notification> XML ←── 处理结果,决定下一步
Coordinator 提示词里的关键一笔
Coordinator 的 system prompt 里有一句很关键的话:"Workers can't see your conversation",所以每个 Worker prompt 必须自包含。这就把"基于我们刚才讨论的内容…"这种依赖对话历史的无效 prompt 直接堵死了。
Research 阶段完成之后,Coordinator 还被要求自己做 Synthesis——不是把研究结果原封不动转给下一个 Worker,而是消化吸收后,写出具体的文件路径、行号、变更内容。提示词里给了反例和正例:
// 反例 —— 偷懒委托
AgentTool({ prompt: "Based on your findings, fix the auth bug" })
// 正例 —— 自己理解后合成规格
AgentTool({ prompt: "Fix the null pointer in src/auth/validate.ts:42.
The user field on Session (src/auth/types.ts:15) is undefined when
sessions expire. Add a null check before user.id access." })
Fork Subagent 模式:上下文继承和并行扇出
Fork 模式的核心价值是上下文继承。和 Coordinator 模式下 Worker 拿独立 prompt 不同,Fork 出来的子 Agent 继承父 Agent 的完整对话历史。在并行探索同一个代码库的不同角度时特别好用。
为了把 prompt cache 命中率拉满,所有 Fork 子 Agent 的 API 请求前缀必须字节级一致。buildForkedMessages() 的实现挺巧妙:保留完整的父 assistant 消息(包括所有 tool_use 块),给每个 tool_use 块生成一个统一的占位 tool_result(FORK_PLACEHOLDER_RESULT = "Fork started — processing in background"),最后再追加每个子 Agent 各自独有的指令。
buildForkedMessages(directive, assistantMessage):
│
├─ fullAssistantMessage = clone(assistantMessage)
│ 保留所有 tool_use、thinking、text
│
├─ toolUseBlocks = extract all tool_use blocks
│
├─ toolResultBlocks = toolUseBlocks.map(block => {
│ type: 'tool_result',
│ tool_use_id: block.id,
│ content: FORK_PLACEHOLDER_RESULT // 所有子 Agent 完全一致
│ })
│
└─ return [
fullAssistantMessage, // 完全一致
createUserMessage([
...toolResultBlocks, // 完全一致
{ text: buildChildMessage(directive) } // 各子 Agent 不同
])
]
这样所有 Fork 子 Agent 的 API 请求只有最后那个 text block 不一样,前面全部完全一致,prompt cache 命中率最大化。
Fork 有个硬限制:禁止递归 fork。源码里靠 isInForkChild() 检测消息中有没有 FORK_BOILERPLATE_TAG。子 Agent 的 system prompt 里也写得很明白:"Your system prompt says 'default to forking.' IGNORE IT — that's for the parent. You ARE the fork. Do NOT spawn sub-agents."
Fork 子 Agent 还会通过 permissionMode: 'bubble' 把权限提示冒泡到父终端,model: 'inherit' 保持和父 Agent 一样的模型选择,这样上下文长度才能对齐。
Swarm / Team 模式:多队友协作
Swarm 模式引入了真正意义上的团队概念。Coordinator 模式里 Coordinator 通过 Agent tool 直接管 Worker,Swarm 模式则有一个 Team Leader 加多个 Teammate,队友之间通过任务列表自主 claim 任务。
Team 就是 TaskList——创建团队的同时就创建对应任务列表,任务所有权通过 TaskUpdate 的 owner 参数管。队友主循环是 findAvailableTask() → tryClaimNextTask() → runAgent() → sendIdleNotification()。
团队创建
TeamCreateTool 跑这几步:先看是不是已经在团队里了(一个 Leader 只能管一个团队);生成唯一的 team name(冲突就重新生成 word slug);按 formatAgentId(TEAM_LEAD_NAME, teamName) 生成确定性的 Lead Agent ID;读当前模型配置(处理 session model、settings、CLI 覆盖);写入 Team File(.claude/teams/{team_name}/team.json);重置并创建对应任务列表目录(保证任务编号从 1 开始);最后在 Leader 上下文注册团队名称,更新 AppState。
队友初始化
队友(teammate)启动时通过 initializeTeammateHooks() 注册 Stop hook:读 team file,拿到 Leader 的 Agent ID,找到 Leader 的名字。如果当前 Agent 就是 Leader,跳过(自己给自己发 idle 通知没意义)。否则注册 Stop hook:每次 Agent 的 turn 结束就触发——标记自己不活跃(setMemberActive(teamName, agentName, false)),创建 idle_notification 写到 Leader 的邮箱。
跨进程通信:文件邮箱
Swarm 最有意思的是通信。队友可能在同一个进程(in-process teammate),也可能在独立进程(tmux pane 或 iTerm2 pane),所以得有一个通用机制。
Claude Code 选了基于文件的邮箱系统。每个队友的收件箱路径是 .claude/teams/{team_name}/inboxes/{agent_name}.json,写操作通过 writeToMailbox() 完成,用 proper-lockfile 防并发写冲突,锁重试 10 次,退避 5ms 到 100ms。
writeToMailbox(recipientName, message, teamName):
│
├─ ensureInboxDir(teamName)
│
├─ inboxPath = .claude/teams/{team}/inboxes/{agent}.json
├─ lockPath = inboxPath + .lock
│
├─ 确保收件箱文件存在(flag: 'wx',已存在忽略)
│
├─ lockfile.lock(inboxPath, { retries: 10, minTimeout: 5, maxTimeout: 100 })
│
├─ readMailbox() → messages(拿锁后重读最新状态)
│
├─ messages.push({ ...message, read: false })
│
├─ writeFile(inboxPath, JSON.stringify(messages))
│
└─ release lock
Team Leader 轮询消费
Team Leader 用 useInboxPoller 每秒轮询自己的邮箱:
useInboxPoller(poll):
│
├─ 每 1000ms 跑一次 poll()
│
├─ poll():
│ ├─ agentName = getAgentNameToPoll(appState)
│ │ 跳过 in-process teammate(有自己的轮询)
│ │ Teammate → getAgentName()
│ │ Team Lead → teamContext.teammates[leadAgentId].name
│ │
│ ├─ unread = readUnreadMessages(agentName, teamName)
│ │
│ ├─ 按消息类型分类:
│ │ ├─ permissionRequests → ToolUseConfirmQueue
│ │ ├─ permissionResponses → 处理权限回调
│ │ ├─ sandboxPermissionRequests → workerSandboxPermissions
│ │ ├─ sandboxPermissionResponses → 沙箱回调
│ │ ├─ shutdownRequests → 传给 UI 组件
│ │ ├─ shutdownApprovals → 关 pane + 清团队状态
│ │ ├─ teamPermissionUpdates → 应用权限更新
│ │ ├─ modeSetRequests → 设置队友模式
│ │ ├─ planApprovalRequests → 自动审批写回
│ │ └─ regularMessages → 常规消息
│ │
│ ├─ 没有 regularMessages → markRead() + return
│ │
│ ├─ 把 regularMessages 格式化成 XML:
│ │ <teammate_message teammate_id="..." color="..." summary="...">
│ │ {message text}
│ │ </teammate_message>
│ │
│ ├─ 如果 idle(!isLoading && !focusedInputDialog):
│ │ onSubmitTeammateMessage(formatted) → 立即作为新 turn 提交
│ │
│ └─ 如果 busy:
│ 入队 AppState.inbox → 等空闲再投递
│
└─ 空闲时交付 pending(useEffect)
格式化 → onSubmitTeammateMessage → 清掉已提交的
邮箱里的协议消息
邮箱不是只传文本,承载了好几种结构化协议消息,JSON 序列化后用 type 字段区分:
PermissionRequest / PermissionResponse 是工具权限请求和响应。Teammate 要执行需要审批的工具时,写 PermissionRequest 到 Leader 邮箱,Leader 在 UI 审批后写回 PermissionResponse。

SandboxPermissionRequest / SandboxPermissionResponse 是沙箱网络访问权限。沙箱运行时检测到非允许主机的网络连接时触发。
PlanApprovalRequest / PlanApprovalResponse 是计划审批。Teammate 在 plan 模式下做完计划后,请 Leader 审批。
ShutdownRequest / ShutdownApproved / ShutdownRejected 是关闭请求。Leader 给队友发 shutdown_request,队友可以批准或者拒绝。
TeamPermissionUpdate 是团队权限广播。Leader 改了权限就广播给所有队友。
ModeSetRequest 是模式设置,Leader 改队友的 permission mode。
IdleNotification 是空闲通知,队友 turn 结束时通过 Stop hook 写入。
和 OpenClaw 的设计对比
Claude Code 和 OpenClaw 都是 Agent 驱动的产品,但读完两边的源码会发现,它们在 Agent 循环、上下文管理和多 Agent 通信上的取舍差得相当远。这些差异不是技术能力的差距,而是产品定位不同推出来的。OpenClaw 的具体介绍可以看:https://ata.atatech.org/articles/11020606837?spm=ata.25287382.0.0.5d0a7536vzrj7U
定位:专用工具 vs 通用平台
Claude Code 是 Anthropic 自家的产品,深度绑定 Claude API。它的目标是把一个 Agent 做到极致——极致的上下文控制、极致的 cache 利用、极致的单用户体验。所有压缩策略都围着 cache_edits 转,所有消息格式都贴着 Anthropic 的 message schema。
OpenClaw 是通用 Agent 平台,要对接所有主流厂商(Anthropic、OpenAI、Google、Ollama 等),支持多种消息通道(Discord、Telegram、WhatsApp、Slack、Web UI),还允许插件注入自定义的上下文管理逻辑和安全守卫。它要的是可扩展性和多厂商适配,不是对单一 API 的深度优化。

Agent 实现:手写循环 vs 框架包装
Claude Code 是手写完整 Agent 循环。queryLoop() 从零开始写,query.ts 一千七百多行代码完整实现了上下文准备管线(6 层压缩)、模型调用、工具执行、附件注入、停止决策、错误恢复,没有任何外部 Agent 框架。
这背后的逻辑是 Claude Code 的定位——它是工具不是平台。Anthropic 控制从 API 到客户端的整个栈,有动机也有能力对每个环节做深度优化。手写循环让他们可以在精确的位置插入精确的逻辑:在 snipCompact 和 microcompact 之间塞 applyCollapses,在工具执行后注 getAttachmentMessages,在 413 错误时走 recoverFromOverflow 而不是直接报错。每一行代码都为 Claude API 服务。
OpenClaw 是包装 PI 框架,提供平台能力。OpenClaw 的 Agent 核心不是自己写的,而是复用了 @mariozechner/pi-coding-agent(简称 PI 框架)。它的职责不是实现 Agent 循环,而是围着 PI 框架做包装和适配:
- 多厂商 API 适配层:
runEmbeddedPiAgent()内部通过streamSimple(来自@mariozechner/pi-ai)统一调用不同厂商的 API。Claude、OpenAI、Google、Ollama 的流式接口各不相同,PI 框架把它们统一成一致的AsyncGenerator接口,OpenClaw 在此基础上再做模型选择、鉴权、重试、降级。 - SessionManager 生命周期:PI 框架的
SessionManager管对话历史、工具注册、消息流转。OpenClaw 负责创建、持久化、预热SessionManager,处理 session 文件锁、transcript 修复、compaction 重试。 - 插件和扩展点:OpenClaw 在 PI 框架外围搭了一整套插件体系——
before_prompt_build、before_agent_start钩子,ContextEngine可插拔接口,安全守卫注入。这些都不是 PI 框架自带的,而是 OpenClaw 作为平台的核心价值。
Claude Code 的 Agent 层次:
┌─────────────────────────────────┐
│ queryLoop() (手写, 1700+ 行) │ ← 完整实现
│ ├── 上下文管线 (6层) │
│ ├── callModel() │ ← 直接调 Anthropic API
│ ├── runTools() │
│ ├── getAttachmentMessages() │
│ └── 决策层 (stop hooks) │
└─────────────────────────────────┘
直接面向 Claude API
OpenClaw 的 Agent 层次:
┌──────────────────────────────────┐
│ OpenClaw 平台层 │ ← 包装 + 适配
│ ├── 多厂商 API 适配 │
│ ├── SessionManager 生命周期 │
│ ├── 插件体系 (钩子/ContextEngine)│
│ ├── 多通道路由 (Discord/Slack..) │
│ └── Gateway 事件总线 │
├──────────────────────────────────┤
│ PI 框架 (@mariozechner/...) │ ← Agent 核心
│ ├── SessionManager │
│ ├── streamSimple (统一 API 调用) │
│ └── 工具注册/消息流转 │
└──────────────────────────────────┘
面向所有厂商 API
说到底,Claude Code 的 Agent 就是它自己——整个 queryLoop 就是 Agent 循环。OpenClaw 的 Agent 是 PI 框架,OpenClaw 提供的是平台能力:多厂商适配、插件扩展、多通道路由、Gateway 调度。前者是工具,把一件事做到极致;后者是平台,让不同的 Agent 实现和插件都能跑起来。
上下文管理
Claude Code 的上下文管理完全内置,6 层管线一气呵成:applyToolResultBudget → snipCompact → microcompact → applyCollapses → autocompact → 硬上限拦截。每层都有精确阈值控制和状态跟踪。深度适配 Claude API 的 cache_edits,工具结果分三类(mustReapply/frozen/fresh)各有处理策略,200KB 工具结果预算、4KB memory 截断、60KB 累计熔断、413 错误两层渐进恢复——一切都为最大化 cache 命中和 token 利用率。
OpenClaw 走的是另一条路:通过 ContextEngine 接口实现上下文管理的可插拔。
interface ContextEngine {
assemble(params): AssembleResult // 组装上下文
compact(params): CompactResult // 压缩
ingest(params): IngestResult // 摄入新信息
maintenance(params): ContextEngineMaintenanceResult // 维护
}
用户可以注册自定义的 ContextEngine 实现,把默认的压缩和组装逻辑替换掉。插件还能通过 before_prompt_build、before_agent_start 钩子注入上下文或修改消息。
它的几个关键点:多厂商适配,不同 API 有不同的上下文限制和错误模式,pi-embedded-helpers 里有专门的 guard 逻辑(isContextOverflowError、isCompactionFailureError、isBillingErrorMessage)做厂商差异处理;安全守卫,resolveContextWindowGuard 检查上下文窗口是不是低于安全阈值(CONTEXT_WINDOW_HARD_MIN_TOKENS),防止极端情况下还硬发请求;插件注入,安全插件可以在 prompt 构建前插入指令,比如注入 rate limit 提示或安全警告;bootstrap 注入,bootstrap-budget 管启动时的上下文注入量,根据剩余预算决定塞多少 bootstrap 文件内容。
OpenClaw 上下文组装流程:
│
├─ 1. 加载 Session 历史
│ limitHistoryTurns(sessionKey) → 按通道类型限制 turn 数
│
├─ 2. Bootstrap 注入
│ resolveBootstrapContextForRun() → 按预算截断
│
├─ 3. ContextEngine.assemble()
│ 可插拔实现
│ 默认: LegacyContextEngine
│ 自定义: 用户注册的插件引擎
│
├─ 4. 插件钩子: before_prompt_build
│ 可改 prompt 和 messages
│
├─ 5. 插件钩子: before_agent_start
│ 做最终修改
│
├─ 6. Context Window Guard
│ evaluateContextWindowGuard() → 低于 MIN 直接拒绝
│
└─ 7. 发起请求
多厂商 API 适配层统一调用
一边是"深度优化"——针对单一 API 做到极致;一边是"广度适配"——用接口抽象让不同厂商和插件都能工作。Claude Code 选深度,是因为 Anthropic 控制整个栈,可以做字节级优化;OpenClaw 选广度,是因为它面对的是碎片化的 AI 生态。
多 Agent 通信
异步 Agent 通信这块两边有共同点:Coordinator 或主 Agent 启动子 Agent 后,自己继续干别的事,子 Agent 完成后再通过某种机制把结果送回去。但"turn 结束"的语义和通信通道完全不同。
Claude Code:循环不结束,轮询消费。Coordinator 调 Agent tool 启动异步 Worker 后,queryLoop 继续跑——它不会 yield 也不暂停。Worker 完成后通过 enqueueAgentNotification() 把 XML 通知推进全局 CommandQueue,Coordinator 在下一轮 turn 开头通过 getCommandsByMaxPriority() 取出来作为 AttachmentMessage 注入。团队模式下队友可能跑在不同进程(tmux pane / iTerm2 pane),通信走文件邮箱:写到 .claude/teams/{team}/inboxes/{agent}.json,Leader 每秒轮询。
OpenClaw:session_yield 让出,Gateway 事件唤醒。主 Agent 调 sessions_yield 后当前 turn 直接结束。queueSessionsYieldInterruptMessage() 通过 agent.steer() 注入一条自定义中断消息,persistSessionsYieldContextMessage() 把 yield 原因写到 session 上下文。整个 Agent 进入等待状态,不再消耗资源。子 Agent 或外部事件完成后,通过 Gateway 的事件系统(emitAgentEvent)通知父 Agent。Gateway 检测到父 Agent 正在 yield 等待,唤醒它,把子 Agent 结果作为下一条消息注入,触发新一轮 turn。

通信通道的差异其实更深。Claude Code 的文件邮箱是"同机优先"——它假设所有 Agent 都跑在同一台机器上,靠共享文件系统通信就够了。这个假设在实际使用中是合理的:Claude Code 的目标用户是单机开发者,Team 模式最多扩展到几个 tmux pane。文件系统通信的好处是零依赖,不用启动额外服务,不用网络连接,开箱即用。
OpenClaw 的 Gateway 是"分布式优先"——一个独立的 WebSocket 服务,所有 Agent 实例(不管是同进程、同机不同进程,还是远程机器)都通过 Gateway 通信。这就让 OpenClaw 可以部署在云上,Agent 分布在不同节点。代价是必须维护 Gateway 基础设施,复杂度上去了。
相同的地方在于异步 Agent 的核心思想是一致的:父 Agent 启动子 Agent 后不阻塞等,子 Agent 完成后通过通知机制把结果回传。CommandQueue 和 Gateway 事件总线在功能上等价——都是解耦生产者和消费者的消息通道。
不同的地方都能追溯到产品定位。Claude Code 是精心打磨的单机编程工具,Anthropic 控制整个栈,可以做深度优化;OpenClaw 是通用 Agent 平台,要适配多厂商、多通道、多部署场景,必须在灵活性和深度优化之间做取舍。前者选了深度,后者选了广度,没有对错,只是服务的对象不一样。
总结
读下来 Claude Code 的几个设计原则贯穿始终:
非阻塞优先。Memory 预取、Skill 发现、工具执行总结都是异步启动、按需消费的。主流程绝不为可能没结果的后台操作等。97% 的 skill 搜索命中率意味着阻塞式搜索几乎总是浪费时间,所以干脆做成异步。
精打细算。从 tool result 的三类分区,到 memory 文件 4KB 截断和已读过滤,到 60KB 累计熔断,再到 fork 子 Agent 的字节级 cache 对齐,每个环节都在算 token 和 cache 怎么省。
渐进式恢复。413 有两层(折叠释放 → 反应式压缩),max_output_tokens 有 escalation 加 multi-turn 重试,autocompact 有熔断器。系统的假设是"什么都会失败",每种失败都准备了退路。
Agent 间松耦合。Coordinator 模式的 XML 通知、Fork 模式的继承上下文、Swarm 模式的文件邮箱,都把 Agent 之间的耦合压到最低。每个 Agent 不需要知道对方是谁,只要按协议读写就行。文件邮箱通过 JSON 类型区分、文件锁序列化、轮询投递实现了跨进程的可靠消息传递。
更多推荐
所有评论(0)