从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
Claude Code 是 Anthropic 推出的终端 AI 编程助手。与普通的聊天式 AI 不同,它直接在终端里工作,能够读取代码、执行命令、修改文件、管理 Git 操作。阅读其源码后,可以从 Agent 循环、上下文工程、提示词工程和多 Agent 协同几个维度梳理出它的设计脉络。
整体架构
Claude Code 的核心是一个典型的 ReAct Agent 架构,入口是 query() 函数,它内部委托给 queryLoop() —— 一个通过 while(true) 无限循环驱动的 AsyncGenerator。每一次循环就是一个 turn,完成从上下文准备、模型调用、响应处理到工具执行的完整闭环。
整体架构可以概括为以下几个层次:
下面按几个核心维度逐一拆解。
Agent 循环:queryLoop 的运转机制
设计思想
Claude Code 的 Agent 循环采用了 AsyncGenerator + while(true) 的组合。选择 AsyncGenerator 而非简单 async function 的原因很直接:每一轮循环都会向 UI 层 yield 流式事件,调用方可以在循环执行过程中实时渲染模型输出和工具执行进度。如果用普通 async function,只能等整个 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 对象然后赋值 + continue,不需要分别更新 9 个变量。同时 transition 字段记录了上一次循环继续的原因,让后续迭代能够基于前一次决策做判断(比如判断上一次是否已经做过 collapse_drain_retry,避免重复操作)。
实现过程
循环的执行流程如下:
第一步:循环前预启动(只执行一次)
startRelevantMemoryPrefetch() 和 startSkillDiscoveryPrefetch() 在循环外启动。这两个操作返回 Promise 句柄,内部异步执行,不阻塞主流程。后续 turn 中通过检查 settledAt 字段判断是否就绪,就绪了就消费。
第二步:上下文准备
每轮 turn 进入后,按顺序执行:
-
applyToolResultBudget()— 工具结果瘦身,把超大的 tool_result 替换为摘要 -
snipCompactIfNeeded()— 历史裁剪,移除过旧的消息 -
microcompact()— 微压缩,清理过期的 tool result -
applyCollapsesIfNeeded()— 上下文折叠,分段生成摘要 -
autocompact()— 自动压缩,最后的兜底策略 -
calculateTokenWarningState()— 检查是否触及硬上限
这些步骤不是互斥的。snip 在 microcompact 之前执行,因为两者都可能运行。collapse 在 autocompact 之前执行,因为如果折叠能把上下文压到 autocompact 阈值以下,autocompact 就变成 no-op,从而保留更细粒度的上下文。
第三步:模型调用
通过 callModel() 将组装好的消息发给 API。流式返回的消息逐条 yield 给 UI,同时收集到 assistantMessages 数组中。遇到 tool_use 块就记录到 toolUseBlocks,设置 needsFollowUp = true。
第四步:工具执行
needsFollowUp 为 true 时进入工具执行阶段,通过 runTools() 或 StreamingToolExecutor 执行工具调用。
第五步:附件注入
工具执行完毕后,调用 getAttachmentMessages() 注入各类附件:文件变更通知、任务通知、memory 预取结果、skill 预取结果。
第六步:决策
如果没有 follow-up(模型没有请求工具),进入决策层:执行 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 state = { messages: [...messagesForQuery, ...assistantMessages, ...toolResults], turnCount: turnCount + 1, ... }
流式 fallback 与错误恢复
模型调用阶段有一个 streaming fallback 机制。如果流式调用过程中触发了 FallbackTriggeredError(通常是模型负载过高),系统会:
-
清空当前轮收集的所有 assistantMessages 和 toolResults -
将模型切换到 fallbackModel -
丢弃 StreamingToolExecutor的 pending 结果,重建新的 executor -
重新发起完整请求
fallback 触发时,还会 yield 一个 tombstone 消息给 UI 层,让前端移除上次流式调用产生的不完整内容。
Agent 架构细节
工具结果预算控制
设计思想
工具调用的返回结果(比如 BashTool 执行 ls -la / 的输出、FileReadTool 读取一个大文件的内容)可能非常庞大。如果不加限制地全部塞进上下文,几次工具调用就能吃光整个 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 消息序列分组,同一序列的 tool_result 共享预算 │ ├─ 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: -
依赖 Claude Code 的 cache_editsAPI -
将压缩操作作为缓存编辑提交给服务端 -
服务端返回实际的 cache_deleted_input_tokens -
客户端用实际值替换预估的 token 删除数 -
边界消息(boundary message)延迟到 API 响应后才 yield,确保使用真实的 token 数据
**为什么使用 ****cache_edits**会又块又节省 token?
要理解这一点,需要先了解 Anthropic API 的 prompt caching 机制。当请求中包含 cache_control: { type: 'ephemeral' } 标记时,API 服务端会缓存该标记位置之前所有消息的 KVCache(Key-Value Cache,即 transformer 模型中 attention 计算产生的中间状态)。下一轮请求如果前缀字节完全一致,服务端可以直接复用缓存的 KV,不需要重新 prefill,从而显著降低延迟和 token 消耗。
问题是:当我们要删除某些旧的 tool result 时,如果直接修改消息内容,整个前缀的字节序列就变了,KVCache 全部失效,服务端必须重新 prefill 全部内容。这就是传统压缩的代价——你节省了上下文 token 数量,但浪费了 cache 命中率。
cache_edits API 解决了这个矛盾。它的工作方式是:
-
客户端在请求体中额外附带一个 cache_edits块,声明要删除哪些内容 -
服务端收到请求后,先匹配前缀 KVCache(此时前缀未变,cache 命中),然后再执行删除操作 -
删除操作是在 cache 命中之后进行的,所以不会影响前缀的 cache 命中率
autocompact 的处理逻辑:
-
守卫检查: DISABLE_COMPACT开启则跳过 -
熔断器检查:连续失败次数达到阈值则跳过 -
shouldAutoCompact()判断是否需要压缩 -
优先尝试 trySessionMemoryCompaction()— 将对话信息提取到 session memory 中,然后删除旧消息 -
如果 session memory 方式不可用,兜底执行 compactConversation()— 生成整个对话的摘要替换全部历史 -
失败时递增失败计数,触发熔断
执行顺序也很讲究。collapse 在 autocompact 之前执行,因为 collapse 是一个 read-time 的 view projection,不会真正删除消息。如果 collapse 能把上下文压到 autocompact 阈值以下,autocompact 就变成 no-op,从而保留更细粒度的上下文而不是一个粗粒度的摘要。
这些压缩机制都通过 bun:bundle 的 feature() 函数进行编译期树摇。外部构建可以完全剔除未启用的功能代码,减小包体积。
设计思想
当上下文超出模型限制,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 系统的设计目标很明确:让 Agent 能在跨会话中积累知识并复用。但注入太多记忆会浪费 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 过滤,这是 cumulative 的状态,跨 turn 累加,确保同一份记忆不会被重复注入。Sonnet 筛选保证了选出的 5 个文件与当前上下文的相关度最高。每个文件只读取 4KB,这个量级大致只够做开头摘要,至于是否读取全文留给后续工具决策。
Skill 发现预取
设计思想
Skill 是 Claude Code 中自定义的命令集合(类似 .claude/commands/ 目录下的命令)。如果每次 turn 都阻塞式搜索哪些 skill 适用,生产数据显示 97% 的情况下搜不到匹配项,白白浪费时间。所以设计为异步预取。
实现过程
startSkillDiscoveryPrefetch() 每轮 turn 启动一次(与 Memory prefetch 不同,skill prefetch 是每轮都启动的,因为它依赖 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匹配自己的通知 -
用户提示( mode: 'prompt')始终只发送给主线程,子 Agent 永远不会看到用户的 prompt 流
实现过程
在 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 command 不走这个路径 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 状态(分支、近期提交、用户等)
getUserContext 和 getSystemContext 都用 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) 追加到系统提示词的末尾。
这种 prepend/append 的设计保证了用户上下文(CLAUDE.md 等)出现在消息序列的最前面,离用户输入最近,注意力权重最高。而系统上下文(git 状态等)放在系统提示词最后,作为背景信息。
工具 Schema 构建
工具 schema 的构建流程:
-
filteredTools— 按当前权限和可用性过滤工具列表 -
toolToAPISchema— 将每个工具的定义转换为 API 可接受的 schema 格式 -
toolSchemas + extraToolSchemas— 合并基础工具 schema 和额外工具 schema -
allTools— 最终发送给模型的完整工具列表
在 Coordinator 模式下,Coordinator 只能看到 4 个工具(Agent、SendMessage、TaskStop、SyntheticOutput),而 Worker 能看到完整的工具池。这个过滤在 toolUseContext.options.tools 中完成,由 coordinatorMode 的配置决定。
多 Agent 架构
Coordinator 模式:编排者与工作者
设计思想
Coordinator 模式是最经典的多 Agent 架构,核心思想是角色分离:Coordinator 负责理解和规划,Worker 负责执行和探索。Coordinator 被刻意限制只能使用 4 个工具,防止它越俎代庖去做具体的代码工作。
工作流分为四个阶段:Research → Synthesis → Implementation → Verification,支持并行研究。
Coordinator 的工具限制
Coordinator 只能使用以下 4 个工具:
-
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) -
否则在已定义的 agent 列表中查找匹配的 agent -
Agent 配置解析 -
fork 的 Agent 继承父 Agent 的 system prompt、上下文、tool result 和指令 -
普通 Agent 重新组装,只传递一条单独的简单 user message -
等待 MCP 服务器连接 -
解析模型并记录 -
解析 isolation 参数(worktree 或 remote) -
构建 system prompt 和 prompt messages: -
组装 runAgentParams对象 -
异步/同步 Agent 执行 -
异步 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 输出 -
实时更新 UI 进度( updateAsyncAgentProgress()) -
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 的系统提示词有一个非常关键的设计:它被明确告知 “Workers can’t see your conversation”,所以每个 Worker prompt 必须是自包含的。这防止了 Coordinator 写出 “基于我们刚才讨论的内容” 这种依赖对话历史的无效 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 来实现。Fork 子 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) -
生成确定性的 Lead Agent ID: formatAgentId(TEAM_LEAD_NAME, teamName) -
读取当前模型的配置(处理 session model、settings、CLI 覆盖) -
写入 Team File( .claude/teams/{team_name}/team.json) -
重置并创建对应的 Task List 目录(确保任务编号从 1 开始) -
注册团队名称到 Leader 上下文,更新 AppState
队友初始化
队友(teammate)启动时通过 initializeTeammateHooks() 注册 Stop hook:
-
读取 team file,获取 Leader 的 Agent ID -
查找 Leader 的名字 -
如果当前 Agent 就是 Leader,跳过(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 每 1 秒轮询自己的邮箱:
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 messages(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 的具体介绍可以看:OpenClaw架构与实现解析
定位差异:专用工具 vs 通用平台
Claude Code 是 Anthropic 自家的产品,深度绑定 Claude API。它的设计目标是把一个 Agent 做到极致——极致的上下文控制、极致的 cache 利用、极致的单用户体验。所有压缩策略都围绕 cache_edits API 设计,所有消息格式都围绕 Anthropic 的 message schema 构建。
OpenClaw 是一个通用 Agent 平台,需要对接所有主流厂商(Anthropic、OpenAI、Google、Ollama 等),支持多种消息通道(Discord、Telegram、WhatsApp、Slack、Web UI),并且允许插件注入自定义的上下文管理逻辑和安全守卫。它的设计目标是可扩展性和多厂商适配,而非对单一 API 的深度优化。
Agent 实现的差异:手写工具 vs 框架包装
Claude Code:手写完整的 Agent 循环
Claude Code 的 queryLoop() 从零手写了整个 Agent 循环。query.ts 里 1700 多行代码完整实现了:上下文准备管线(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 APIOpenClaw 的 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 调度。Claude Code 是”工具”,把一件事做到极致;OpenClaw 是”平台”,让不同的 Agent 实现和插件都能跑起来。
上下文管理的差异
Claude Code:极致精细的内置管线
Claude Code 的上下文管理完全内置,包含 6 层递进管线:
applyToolResultBudget → snipCompact → microcompact → applyCollapses → autocompact → 硬上限拦截。
每一层都有精确的阈值控制和状态跟踪。
关键特点:
-
深度适配 Claude API 的 cache_edits,利用 KVCache 保持能力 -
工具结果分三类(mustReapply/frozen/fresh),每类处理策略不同 -
200KB 工具结果预算、4KB memory 截断、60KB 累计熔断 -
413 错误两层渐进恢复
OpenClaw:插件化的 ContextEngine 接口
OpenClaw 通过 ContextEngine 接口实现上下文管理的可插拔:
interface ContextEngine { assemble(params): AssembleResult // 组装上下文 compact(params): CompactResult // 压缩 ingest(params): IngestResult // 摄入新信息 maintenance(params): ContextEngineMaintenanceResult // 维护}
用户可以注册自定义的 ContextEngine 实现,替换默认的压缩和组装逻辑。插件还可以通过 before_prompt_build 和 before_agent_start 钩子注入上下文或修改消息。
关键特点:
-
多厂商适配:不同 API 有不同的上下文限制和错误模式。OpenClaw 的 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 适配层统一调用
核心差异总结:Claude Code 的上下文管理是”深度优化”——针对单一 API 做到极致。OpenClaw 的上下文管理是”广度适配”——用接口抽象让不同厂商和插件都能工作。Claude Code 选择深度是因为 Anthropic 控制整个栈,可以做字节级优化;OpenClaw 选择广度是因为它面对的是碎片化的 AI 生态。
多 Agent 通信的差异
在异步 Agent 通信方面,两者的协调模式有一个共同点:Coordinator/主 Agent 启动子 Agent 后都继续做自己的事,子 Agent 完成后通过某种机制把结果送回给父 Agent。但它们的”turn 结束”语义和通信通道截然不同。
Claude Code:循环不结束,轮询消费
Claude Code 的 Coordinator 调用 Agent tool 启动异步 Worker 后,queryLoop 继续运行——它不会 yield 或暂停。Worker 完成后通过 enqueueAgentNotification() 把 XML 通知推入全局 CommandQueue,Coordinator 在下一轮 turn 的开头通过 getCommandsByMaxPriority() 从队列中取出通知,作为 AttachmentMessage 注入。
团队模式下,队友(Teammate)可能运行在不同进程(tmux pane / iTerm2 pane),通过文件邮箱系统通信:写入 .claude/teams/{team}/inboxes/{agent}.json,Leader 每 1 秒轮询一次。
OpenClaw:session_yield 让出,Gateway 事件唤醒
OpenClaw 的主 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 是”分布式优先”的设计。Gateway 是一个独立的 WebSocket 服务,所有 Agent 实例(无论是同进程、同机不同进程、还是远程机器)都通过 Gateway 通信。这使得 OpenClaw 可以部署在云服务器上,Agent 可以分布在不同的节点。代价是必须维护 Gateway 基础设施,增加了系统复杂度。
相同点
两者在异步 Agent 的核心思想上是一致的:父 Agent 启动子 Agent 后不阻塞等待,子 Agent 完成后通过某种通知机制把结果送回。这都体现了”非阻塞优先”的设计理念。两者的 CommandQueue 和 Gateway 事件总线在功能上是等价的——都是解耦生产者和消费者的消息通道。
不同点的根源
这些差异都可以追溯到产品定位。Claude Code 是一个精心打磨的单机编程工具,Anthropic 控制整个技术栈,可以做深度优化。OpenClaw 是一个通用的 Agent 平台,需要适配多厂商、多通道、多部署场景,必须在灵活性和深度优化之间做取舍。Claude Code 选择了深度,OpenClaw 选择了广度。两者没有对错之分,只是各自服务于不同的用户群体和使用场景。
总结
Claude Code 的架构设计中,有几个贯穿始终的设计原则:
非阻塞优先。Memory 预取、Skill 发现、工具执行总结都是异步启动、按需消费的模式。主流程绝不等待可能没有结果的后台操作。97% 的 skill 搜索命中率意味着阻塞式搜索几乎总是浪费时间。
精细化控制。从 tool result 的三类分区(mustReapply/frozen/fresh),到 memory 文件的 4KB 截断和已读过滤,到 60KB 的累计熔断,到 fork 子 Agent 的字节级 cache 对齐,每个环节都在精打细算 token 和 cache 的使用。
Agent 间通信解耦。Coordinator 模式的 XML 通知、Fork 模式的继承上下文、Swarm 模式的文件邮箱,都保持了 Agent 间的松耦合。每个 Agent 不需要知道对方是谁,只需要按照协议读写。文件邮箱系统通过 JSON 类型区分、文件锁序列化、轮询投递,实现了跨进程的可靠消息传递。
一些个人见解-关于Claude Code的“遥遥领先“
Claude Code的代码泄漏让我们得以窥见其内部运作的精妙,但这并不意味着它就是AI编程领域的唯一真理。Claude Code的设计优秀,更多体现的是Anthropic在特定技术路径上的深耕,而非AI编程助手的终极形态。
这恰恰是我们在评估AI工具时最需要警惕的认知陷阱。今天也有不少人因为看到Claude Code使用体验不错,Agent设计又用优秀之处,就对 Claude Code 大吹特吹,甚至贬低其他的 AI Coding 工具。但实际上也没有几个人会去分析它的代码和设计,也不会同时深度使用多个 AI Coding 工具。就像几年前新能源汽车爆发时,很多人不认识几个汽车零件,就盲目吹捧某个品牌”天下第一”一样。
真正的智慧在于理解差异的本质。Prompt工程的差异决定了AI如何理解任务,Agent架构的差异决定了AI如何执行任务,而模型差异只是底层的能力支撑。对于开发者而言,与其纠结于哪个工具”更好”,不如从原理出发,理解不同设计选择背后的权衡,然后在实际使用中验证这些权衡是否适合自己的需求。
多用、多总结、多学习,这才是AI时代技术人的正确打开方式。无论是Claude Code的精工细作,还是OpenClaw的宏大架构,都值得我们深入研究和实践。因为最终,定义我们生产力的不是某个工具,而是我们驾驭工具的能力。
夜雨聆风