agent 主循环

agent 主循环

agent 主循环解决的问题:让模型能”自己动手干活”,即调一次模型只能得到文字,必须把「调模型 → 模型点名要工具 → 本地跑工具 → 结果喂回去再调模型」循环起来,直到模型不再要工具。
Claude Code 把这个循环写成 src/query.ts 里的一个 async generator:query(),每轮迭代做四件事:

  • 压上下文:五级瘦身流水线。
  • 流式调模型:deps.callModel
  • 跑工具:runTools
  • 结果回灌:拼进下一轮 messages。

交互式 REPL(src/screens/REPL.tsx)、SDK/print 模式(src/QueryEngine.ts)、子代理(src/utils/forkedAgent.ts)全部复用这同一个循环。

注:Claude Code 是未公开的商业实现,下面只引签名级片段(每段不超过 3 行),实现逻辑用注释转述,全部对照本地反混淆源码核实过。

代码块TS · 59 行收起展开
// 基于 Claude Code 2.1.88 反混淆源码 (本地 D:/1ForCode/CCSourceCode), src/query.ts

export async function* query(          // 对外入口是个薄壳:真循环在内部的 queryLoop()
  params: QueryParams,                 // messages/systemPrompt/tools/canUseTool/maxTurns/fallbackModel...
): AsyncGenerator<StreamEvent | RequestStartEvent | Message | TombstoneMessage | ToolUseSummaryMessage, Terminal>
// 返回值是 Terminal:{ reason: 'completed' | 'blocking_limit' | 'max_turns' | 'aborted_tools' | ... }
// 用生成器是因为 agent 输出天然是流:上层 for await 边到边渲染,类似一条惰性 Stream;
// 中断 = 停止迭代 + AbortController.abort(),协作式,和 Thread.interrupt 一个思路

// queryLoop 内部:显式状态机,不是递归。跨迭代的可变状态收进一个 State 对象
type State = {
  messages: Message[]                  // 会话消息,每轮结尾整体替换
  toolUseContext: ToolUseContext       // 工具执行上下文(权限模式/abort 信号/readFileState...)
  // ... 另有 turnCount、压缩追踪、恢复计数等 7 个字段
  transition: Continue | undefined     // 上一轮为什么 continue(next_turn/reactive_compact_retry...),测试靠它断言恢复路径
}

  while (true) {
    // ① 上下文瘦身流水线,从便宜到贵依次跑:
    //    applyToolResultBudget(限单条工具结果尺寸) → snip(剪旧历史) → deps.microcompact
    //    → contextCollapse(折叠旧片段为摘要,可逆) → deps.autocompact(全量摘要,大锤)
    //    全试完还超硬阻塞线 → yield PROMPT_TOO_LONG_ERROR_MESSAGE, return { reason: 'blocking_limit' }

    // ② 流式调模型。deps.callModel 生产实现 = services/api/claude.ts 的 queryModelWithStreaming
    const toolUseBlocks: ToolUseBlock[] = []
    let needsFollowUp = false          // 唯一的循环出口信号。不信 API 的 stop_reason === 'tool_use',源码注释明说它 unreliable
    for await (const message of deps.callModel({
      // messages + systemPrompt + tools + thinking 配置 + fallback + MCP 工具 + task_budget...
    })) {
      // 边流边 yield 给上层;遇到 tool_use 块:
      //   toolUseBlocks.push(...msgToolUseBlocks); needsFollowUp = true
      // 流式 fallback:主模型断流切备用模型时,已吐出的半截消息逐条 yield tombstone 撤回。
      // 半截 thinking 块带着主模型签名,原样回灌会被 API 以 "thinking blocks cannot be modified" 拒掉
    }

    if (!needsFollowUp) {
      // 模型只说话不要工具。但先别急着结束:
      // - 流里被 withhold(暂扣未 yield)的可恢复错误在这处理:prompt-too-long 先试
      //   contextCollapse.recoverFromOverflow(便宜,保细粒度),再试 reactiveCompact.tryReactiveCompact(全量摘要),
      //   成功就 continue 原地重试;max_output_tokens 则把上限 8k 提到 64k 重发同一请求
      // - API 错误消息直接 return,跳过 stop hooks:否则 错误→hook拦截→重试→错误 死亡螺旋
      // - handleStopHooks(src/query/stopHooks.ts):hook 可以 block 掉"结束",注入错误消息逼模型继续
      return { reason: 'completed' }
    }

    // ③ 跑工具(见下一个代码块)
    const toolUpdates = streamingToolExecutor
      ? streamingToolExecutor.getRemainingResults()      // 灰度路径:边流边跑,模型还在说话工具已开跑
      : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
    // 逐条消费:yield 工具结果给 UI,同时 normalizeMessagesForAPI 过滤出 user 消息存进 toolResults

    // ④ 结果回灌:工具结果 + attachments(排队的用户消息/memory 预取/skill 发现)一起拼进下一轮
    const next: State = {
      messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
      // ... turnCount + 1,超 maxTurns 则 return { reason: 'max_turns' }
      transition: { reason: 'next_turn' },
    }
    state = next                       // 整体换态后 continue。循环前的 checkpoint 至今叫 'query_recursive_call',是递归时代的化石
  }

工具执行这一层单独在 src/services/tools/ 下,核心是”怎么安全地并发”:

代码块TS · 22 行收起展开
// 基于 Claude Code 2.1.88, src/services/tools/toolOrchestration.ts

export async function* runTools(
  toolUseMessages: ToolUseBlock[],
  // ... assistantMessages, canUseTool, toolUseContext
)
// 先 partitionToolCalls 分批:连续的 concurrency-safe 工具(纯读,见 [01-Tool 抽象](/articles/sourcecode/claude-code/01-tool-抽象))
// 合并成一批并行跑;非安全工具(写文件/命令)每个自成一批串行跑
    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      acc[acc.length - 1]!.blocks.push(toolUse)    // 只合并「连续」的安全工具:穿插一个写操作就切批,保住模型下发的顺序语义
    }
// isConcurrencySafe(input) 抛异常(如 shell-quote 解析失败)按不安全处理,保守兜底

async function* runToolsConcurrently(  // 并行批,并发上限:
    parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
)

// 单个工具的执行在 src/services/tools/toolExecution.ts:
export async function* runToolUse(/* toolUse, assistantMessage, canUseTool, toolUseContext */)  // 查找工具(含废弃别名回退) → 权限与执行:
async function checkPermissionsAndCallTool(/* ... */)
// 内部顺序:zod 校验输入(源码原话:模型生成合法输入的能力 surprisingly 不行) →
// 权限检查(见 [04-权限系统](/articles/sourcecode/claude-code/04-权限系统)) → PreToolUse hooks → tool.call() → PostToolUse hooks

query() 是无状态函数,SDK 和 print 模式需要跨轮持有会话,于是包了一层有状态对象,类似把工具函数包成一个 service Bean:

代码块TS · 25 行收起展开
// 基于 Claude Code 2.1.88, src/QueryEngine.ts

export class QueryEngine {
  private mutableMessages: Message[]         // 会话消息,query() 每轮的产出写回这里
  private abortController: AbortController
  // ...
  private totalUsage: NonNullableUsage       // 跨轮累计 token 用量
  private readFileState: FileStateCache      // 读过的文件缓存:Edit 前必须 Read 的校验就靠它

  async *submitMessage(                      // 驱动入口:一次用户输入 → 组装 QueryParams → yield* query()
    prompt: string | ContentBlockParam[],
    options?: { uuid?: string; isMeta?: boolean },
  ) { /* ... */ }

  interrupt(): void {
    this.abortController.abort()             // 中断只是 abort 信号,循环内各 await 点自己检查并善后
  }
  setModel(model: string): void {
    this.config.userSpecifiedModel = model   // 改配置即可,下一轮 callModel 自然用新模型
  }
}

export async function* ask({                 // 一次性便捷包装:new QueryEngine + submitMessage,print 模式(claude -p)走这里
  // ...
}) {}

原理串讲

一次典型调用走完整链路:
用户在终端敲一句话,交互式 REPL(src/screens/REPL.tsx)直接 for await 消费 query()
如果是 claude -p 或 SDK,则经 ask()QueryEngine.submitMessage() 组装 QueryParams 后进入同一个 query()
query() 本体只是薄壳,真循环在 queryLoop()while (true) 里。

每轮迭代先给上下文瘦身。
五级流水线按成本排序:

  1. applyToolResultBudget 掐超大工具结果
  2. snipCompactIfNeeded 剪远古历史
  3. deps.microcompact 折叠旧工具调用
  4. contextCollapse.applyCollapsesIfNeeded 把旧片段收进折叠存储
  5. deps.autocompact 全量摘要

为什么 collapse 特意排在 autocompact 前面?
源码注释写得直白:如果折叠已经把 token 压到阈值以下,autocompact 就成了 no-op,能保住细粒度上下文就不动大锤,摘要是有损压缩,一旦摘了细节就找不回来了。

然后 deps.callModel(生产绑定 queryModelWithStreaming,src/services/api/claude.ts)发起流式请求。
消息边到边 yield 给上层,遇到 tool_use 块就收进 toolUseBlocks 并置 needsFollowUp = true
为什么不用 API 返回的 stop_reason === 'tool_use' 判断?query.ts:554 的注释直说这个字段 not always set correctly,所以干脆自己数块,流式过程中见到一个 tool_use 就置位,这个布尔量成了整个循环唯一的出口信号。

流结束后分岔。
needsFollowUp 为 false 时也别急着返回:流式过程中 prompt-too-long、max-output-tokens 这类”可恢复错误”被 withhold(暂扣不 yield),此刻先试恢复。

  1. 413 先走 contextCollapse.recoverFromOverflow 提交暂存的折叠。
  2. 不行再 reactiveCompact.tryReactiveCompact 做全量摘要,成功就 continue 原地重试,用户根本看不到那次报错。
  3. 恢复穷尽才把扣下的错误消息补 yield 出去。

没有错误则跑 handleStopHooks(src/query/stopHooks.ts),hook 有权 block “结束”并注入错误消息把模型逼回来干活,最后才 return { reason: 'completed' }

有一个例外被写死:API 错误消息直接返回、跳过 stop hooks,因为模型压根没产出可评估的回复,hook 拦截会造成 错误 → 拦截 → 重试 → 错误 的死亡螺旋,每圈还多注入一段 token。

needsFollowUp 为 true 则进工具执行。
runTools(src/services/tools/toolOrchestration.ts)先 partitionToolCalls 分批:

  • 安全批:连续的 concurrency-safe 工具合并成一批交给 runToolsConcurrently 并行(上限 10,可用 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 覆盖)。
  • 非安全批:每个自成一批走 runToolsSerially

每个工具最终落到 runToolUsecheckPermissionsAndCallTool(src/services/tools/toolExecution.ts):

  1. zod 校验
  2. 权限检查
  3. PreToolUse hooks
  4. 真正执行
  5. PostToolUse hooks

为什么只合并”连续”的安全批而不是全局把读和写分成两组?因为模型下发工具是有顺序语义的,先 Read 再 Edit 再 Read,全局重排会让第二次 Read 读到 Edit 前的内容,按连续段切批则天然保序。

工具结果经 normalizeMessagesForAPI 过滤成 user 消息。
为什么工具结果要伪装成 user 消息?这是 Anthropic API 的协议约定:tool_result 块只能出现在 user role 里,模型视角等于”用户替我跑了工具,把结果贴回来了”。
回灌时还会顺路塞进 attachments:

  • 用户排队的消息
  • 后台预取的 memory
  • skill 发现结果

都趁这个空档混进上下文。
最后 state = next 整体换态,turnCount + 1,超 maxTurnsreturn { reason: 'max_turns' },否则 continue 进下一轮。

为什么用显式 State 对象加 transition 字段,而不是老实的递归?
这个循环有 7 个 continue 站点:

  • 下一轮(next_turn)
  • collapse 排空后重试(collapse_drain_retry)
  • reactive compact 重试(reactive_compact_retry)
  • max_output_tokens 抬上限重发(max_output_tokens_escalate)
  • max_output_tokens 多轮恢复(max_output_tokens_recovery)
  • stop hook 拦截(stop_hook_blocking)
  • token 预算续跑(token_budget_continuation)

递归写法每个站点要背着十来个参数调自己,漏传一个就丢状态;显式 State 让每个站点一次性整体换态,transition.reason 还让测试能直接断言”这次续跑是 reactive_compact_retry”,不用去翻消息内容反推。
循环入口前的 checkpoint 至今叫 query_recursive_call,就是从递归改成循环留下的化石。

设计取舍

  • 出口信号自己造:不信 stop_reason,流式过程中数 tool_use 块置 needsFollowUp,协议字段不可靠时宁可冗余计算。
  • 错误先扣后放:可恢复错误 withhold 住,恢复成功用户无感知,恢复穷尽才亮出来;代价是 withhold 与恢复两处的开关必须严格一致,源码里专门 hoist 成同一个变量防止流中途配置翻转。
  • 压缩是流水线:五级从细粒度到大锤,每级只在上级不够用时才出手;一步到位的全量摘要最省事,但细节丢了就回不来。
  • 并发保守优先:isConcurrencySafe 抛异常按不安全处理、只合并连续安全段、上限 10,宁可慢也不让两个写操作互相踩。
  • 流式 fallback 必须撤回半截消息:thinking 块签名绑定模型,换模型重试前要对已 yield 的消息发 tombstone,否则回灌时 API 直接 400。

工具怎么定义见 01-Tool 抽象,工具清单见 02-内置工具全景,权限拦截见 04-权限系统,派生子代理见 05-任务与子代理

延伸阅读