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 行),实现逻辑用注释转述,全部对照本地反混淆源码核实过。
代码块收起展开
// 基于 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/ 下,核心是”怎么安全地并发”:
代码块收起展开
// 基于 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 hooksquery() 是无状态函数,SDK 和 print 模式需要跨轮持有会话,于是包了一层有状态对象,类似把工具函数包成一个 service Bean:
代码块收起展开
// 基于 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) 里。
每轮迭代先给上下文瘦身。
五级流水线按成本排序:
applyToolResultBudget掐超大工具结果snipCompactIfNeeded剪远古历史deps.microcompact折叠旧工具调用contextCollapse.applyCollapsesIfNeeded把旧片段收进折叠存储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),此刻先试恢复。
- 413 先走
contextCollapse.recoverFromOverflow提交暂存的折叠。 - 不行再
reactiveCompact.tryReactiveCompact做全量摘要,成功就continue原地重试,用户根本看不到那次报错。 - 恢复穷尽才把扣下的错误消息补 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。
每个工具最终落到 runToolUse → checkPermissionsAndCallTool(src/services/tools/toolExecution.ts):
- zod 校验
- 权限检查
- PreToolUse hooks
- 真正执行
- PostToolUse hooks
为什么只合并”连续”的安全批而不是全局把读和写分成两组?因为模型下发工具是有顺序语义的,先 Read 再 Edit 再 Read,全局重排会让第二次 Read 读到 Edit 前的内容,按连续段切批则天然保序。
工具结果经 normalizeMessagesForAPI 过滤成 user 消息。
为什么工具结果要伪装成 user 消息?这是 Anthropic API 的协议约定:tool_result 块只能出现在 user role 里,模型视角等于”用户替我跑了工具,把结果贴回来了”。
回灌时还会顺路塞进 attachments:
- 用户排队的消息
- 后台预取的 memory
- skill 发现结果
都趁这个空档混进上下文。
最后 state = next 整体换态,turnCount + 1,超 maxTurns 就 return { 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-任务与子代理。