之前 Claude Code 的源码泄漏,我们可以借助ai工具很快梳理出其核心原理。下面把源码中有意思的部分整理出来,再和 最近很火的OpenClaw 做个对照看看。

整体架构

Claude Code 的核心其实就是一个标准的 ReAct Agent。入口是 query(),真正干活的是它委托出去的 queryLoop()——一个由 while(true) 驱动的 AsyncGenerator。每跑一圈就是一个 turn,从准备上下文、调模型、处理响应到执行工具,一圈下来就是一个完整闭环。

Claude Code 整体架构

下面按几个核心维度分别拆开看。

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(一般是模型负载过高),系统会把当前轮收集到的 assistantMessagestoolResults 全部清空,模型切到 fallbackModel,丢掉 StreamingToolExecutor 的 pending 结果重建一个新的,然后整个请求重发。fallback 触发时还会向 UI yield 一条 tombstone 消息,让前端把之前流出来的不完整内容移除掉。

queryLoop 状态机与 fallback 流程

工具结果预算控制

工具调用的返回结果可能特别大。BashTool 跑一次 ls -la /,或者 FileReadTool 读一个大文件,输出动辄几十上百 KB。如果不加限制全塞进上下文,几次调用就把整个 token 预算吃光了。

Claude Code 的处理思路是:别把原始内容直接丢掉,换成摘要,然后在摘要里留一个文件路径。模型如果觉得摘要不够,可以主动用 Read 工具去看全文。本质上是 lazy loading。

applyToolResultBudget() 分四步:

  1. collectCandidatesByMessage:按 API 消息序列分组,把同一轮流式返回的 tool_result 候选放在一起共享预算。
  2. partitionByPriorDecision:根据历史决策把这组候选切成三类——mustReapply(之前换过摘要的,直接复用缓存的 preview 重放)、frozen(之前见过、模型也读过的,标记冻结,不能再动;动了 prompt cache 的字节匹配就废了)、fresh(首次见到的,可以做替换决策)。
  3. selectFreshToReplace:如果 frozen 加 fresh 加起来超过 200KB,从 fresh 里挑最大的先换。策略是贪心的,按大小降序排,从最大开始换,直到总量降到阈值以下。
  4. 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)。这个状态只有在 agentIdrepl_main_threadquerySource 下才会持久化到磁盘,目的是保证 resume 会话能把状态恢复回来。

多级压缩策略

Claude Code 的压缩不是一刀切的,按"影响范围"和"成本"分了四层。原则也很朴素:能用便宜操作就别用贵的,能保留细粒度结构就别做全局摘要。

从轻到重排:

  • snipCompact:直接裁旧消息,零成本。
  • microcompact:清理过期 tool result,借助 cache_edits API 保留 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(),给整段对话生成摘要替换全部历史。失败的话失败计数加一,连着失败几次就熔断。

执行顺序也是有讲究的。collapseautocompact 之前跑,因为 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 句柄。流程是:

  1. isAutoMemoryEnabled() 和对应 feature flag 是不是开的。
  2. 取最后一条真实用户消息(跳过 isMeta 的系统注入)。
  3. 单词数小于 2 的查询直接跳,省得浪费检索资源。
  4. 累计熔断:会话累计注入字节 ≥ 60KB 就停掉,不再预取。
  5. 启动后台搜索,不阻塞主流程。

后台检索本身做几件事:扫 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.tsqueryModel 里最终组装

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 格式,toolSchemasextraToolSchemas 合并基础和额外的 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_typeFORK_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() 把任务标成 completedenqueueAgentNotification() 把一段 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——创建团队的同时就创建对应任务列表,任务所有权通过 TaskUpdateowner 参数管。队友主循环是 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

Swarm 邮箱协议消息流

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 的深度优化。

Claude Code 与 OpenClaw 架构对比

Agent 实现:手写循环 vs 框架包装

Claude Code 是手写完整 Agent 循环queryLoop() 从零开始写,query.ts 一千七百多行代码完整实现了上下文准备管线(6 层压缩)、模型调用、工具执行、附件注入、停止决策、错误恢复,没有任何外部 Agent 框架。

这背后的逻辑是 Claude Code 的定位——它是工具不是平台。Anthropic 控制从 API 到客户端的整个栈,有动机也有能力对每个环节做深度优化。手写循环让他们可以在精确的位置插入精确的逻辑:在 snipCompactmicrocompact 之间塞 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_buildbefore_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 层管线一气呵成:applyToolResultBudgetsnipCompactmicrocompactapplyCollapsesautocompact → 硬上限拦截。每层都有精确阈值控制和状态跟踪。深度适配 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_buildbefore_agent_start 钩子注入上下文或修改消息。

它的几个关键点:多厂商适配,不同 API 有不同的上下文限制和错误模式,pi-embedded-helpers 里有专门的 guard 逻辑(isContextOverflowErrorisCompactionFailureErrorisBillingErrorMessage)做厂商差异处理;安全守卫,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。

Gateway 事件总线与子 Agent 通知机制

通信通道的差异其实更深。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 类型区分、文件锁序列化、轮询投递实现了跨进程的可靠消息传递。

更多推荐