阅读源码: CLaude Code 如何实现Agent Loop
queryLoop 核心机制
本质:一个
while(true)的 agent 主循环
queryLoop(query.ts:241-1729)是 Claude Code 的心脏。它把“用户一句话”变成“模型 + 工具反复协作直到任务完成”的循环。骨架极简:
while (true) {
// 拆出本轮状态
let { toolUseContext } = state
// ① 调模型、流式接收回复
// ② 根据回复里有没有 tool_use 块分叉
if (!needsFollowUp) {
// 收尾路径:恢复尝试 -> stop hook -> token budget -> return completed
}
// ③ 执行工具、收集结果 -> continue 进下一轮
}
它只能靠 return 退出(while(true) 没有条件),靠 continue 重启迭代自救或推进。理解它的关键是三样东西:
State— 跨轮状态toolUseContext— 共享总线needsFollowUp— 继续/结束的开关
一、核心骨架与三块基石
1. State:跨轮携带的可变状态(:204-217)
type State = {
messages: Message[] // 对话历史(含 tool_use/tool_result 对)
toolUseContext: ToolUseContext // 共享运行时上下文
autoCompactTracking // 压缩状态(跨轮延续)
maxOutputTokensRecoveryCount // 截断恢复计数
hasAttemptedReactiveCompact // compact 是否已试过
maxOutputTokensOverride // 输出上限覆盖
pendingToolUseSummary // 上轮启动的摘要 promise
stopHookActive // stop hook 阻塞中?
turnCount // 轮次
transition // 上轮为何 continue(测试+守卫用)
}
每个 continue 都构造一个新 State。改写哪些字段、保留哪些字段是理解每个 continue 的钥匙——改写的是本轮新行为,保留的是防死循环守卫。
2. toolUseContext:贯穿一切的共享总线
它是调用工具时传入的运行时对象,把以下内容打包:
- 配置(
options.tools/model) - 共享状态(
readFileState/toolDecisions) - 控制信号(
abortController) - UI 回调
- 身份(
agentId) - 追踪(
queryTracking)
工具从它取所需、往它写副作用;执行器和循环给它追加追踪、合并改写。它的演化(newContext + 可变 mutation)就是工具间状态流转的载体,简单来说,作为唯一的"可变外部环境",它记录了每一轮次发生的变化和一些关键信息,为后续轮次大模型的决策提供依据。
举个例子:
toolUseContext = {
// ── 配置(options):本轮可用的"工具箱"和模型设置 ──
options: {
tools: [Read, Edit, Write, Bash, Glob, Grep, AgentTool, ...], // 所有可用工具
mainLoopModel: 'claude-sonnet-4-6',
thinkingConfig: { type: 'enabled', budget_tokens: 8000 },
commands: [...],
mcpClients: [<已连的 MCP server>],
isNonInteractiveSession: false,
agentDefinitions: {...},
refreshTools: f, // 中途连上 MCP 时刷新工具列表
...
},
// ── 控制信号 ──
abortController: AbortController { signal: <未 abort> }, // Ctrl+C 时 abort
// ── 共享可变状态(跨工具、跨轮累积)──
readFileState: Map { // FileStateCache:哪些文件读过、内容/时间戳
'E:\\...\\src\\query.ts' => { content: '...', timestamp: 1690000000, offset: undefined, limit: undefined }
},
toolDecisions: Map { 'toolu_01ABC' => { source: 'config', decision: 'accept', ... } },
// ── UI / 状态回调 ──
getAppState: () => ({
toolPermissionContext: { mode: 'default', alwaysAllowRules: {...}, ... },
mcp: { tools: [...], clients: [...] },
fastMode: false,
effortValue: 'medium',
...
}),
setAppState: f,
setInProgressToolUseIDs: f, // 告诉 UI 哪些工具在跑
setResponseLength: f,
updateFileHistoryState: f, // 改文件历史
updateAttributionState: f,
addNotification: f, // 推通知
appendSystemMessage: f,
setToolJSX: f, // 渲染工具的自定义 UI
// ── 身份 ──
agentId: undefined, // undefined = 主线程;子代理才有值
agentType: undefined,
// ── 当前消息与追踪 ──
messages: [...], // 当前 messages 历史
queryTracking: { chainId: 'abc-123', depth: 2 }, // query 链追踪
// ── 工具调用级(toolExecution 调 call 前临时加上)──
toolUseId: 'toolu_01ABCxyz', // 这次调用的 id
userModified: false,
}
3. needsFollowUp:继续/结束的开关
- 初始化
false(:558) - 流式收到任何
tool_use块就置true(:834)
代码不用不可靠的 stop_reason,改用直接检测 tool_use 块:
true→ 走工具执行 →next_turnfalse→ 走收尾 →completed
简单来说,当大模型决定还需要使用tool,他就会生成一个tool_use_block放到ToolUseBlock[],needsFollowUp=True,循环继续。当大模型决定只说话不用tool了,ToolUseBlock[]为空,needsFollowUp=False,循环结束
继续的最终决策权在模型手里(它决定吐不吐 tool_use)。
二、一次迭代的完整轨迹
阶段 A:准备(:307-580)
每轮开头:
- 从
State拆出toolUseContext - 启动 memory 预取、skill 发现预取(与模型流式并发的后台任务)
- 给
toolUseContext追加queryTracking、更新messages - 依次跑上下文管理流水线(把过长的历史压短,避免 prompt too long):
snip→microcompact→context-collapse→autocompact
- 算出本轮要发的系统提示词、模型、工具清单
阶段 B:调模型、流式接收(:653-997)
for await (const message of deps.callModel({
messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt,
tools: toolUseContext.options.tools, // 工具清单(核心工具全量、延迟工具 defer_loading)
signal: toolUseContext.abortController.signal,
...,
})) { ... }
这一段把系统提示词 + 历史 messages + 工具清单发给模型,流式收回复。每收到一条 assistant 消息:
- 扣留检查(
:799-822):如果是可恢复的错误(prompt-too-long、max-output-tokens、媒体超限),先扣下不 yield,等后面看能不能恢复。避免把中间错误漏给 SDK 调用方(它们见到 error 就终止会话)。 - 挑出
tool_use块(:829-835):toolUseBlocks.push(...)+needsFollowUp = true。 - 流式开跑工具(
:841-844):开了流式执行的话,streamingToolExecutor.addTool(block, message)让工具在模型还没说完时就开始跑。 - 顺手 yield 已完成结果(
:851):getCompletedResults()非阻塞地把已跑完的工具结果推给 UI。 - fallback 处理(
:894-953):模型降级重试时,清掉半成品、发 tombstone、换 fallback model 重来。
流式期间被 abort → return aborted_streaming(:1051)。
阶段 C:分叉(:1062)
if (!needsFollowUp) { ... } // 模型没要工具 -> 收尾
// 否则 -> 工具执行 -> next_turn
分叉一:收尾路径(!needsFollowUp,:1062-1357)
模型这轮没要工具(纯文本回复),本该结束。但插了一堆“恢复尝试”(都是 continue,不退出)和“退出点”。按顺序:
恢复尝试(continue,自救重试)
-
collapse_drain_retry(
:1115)
解决上下文过长,会自动尝试把一部分历史消息归档掉,缩小上下文体积- 被扣留的 413?先提交暂存的
context-collapse缩小上下文重试。 - 守卫:
transition !== 自身(单发)。
- 被扣留的 413?先提交暂存的
-
reactive_compact_retry(
:1164)
collapse drain 没解决(或不适用),跑一次全量摘要,把整段对话压成 summary 替换掉 messages- 413 或媒体超限?跑全量摘要压缩重试。
- 守卫:
hasAttemptedReactiveCompact=true(防 compact 螺旋)。
-
max_output_tokens_escalate(
:1219)
模型输出被 8k 默认上限截断 ,用同一个请求、更大的输出上限(8k -> 64k ESCALATED_MAX_TOKENS)重试,不加任何消息- 输出被 8k 截断?升到 64k 重试。
- 守卫:
maxOutputTokensOverride===undefined(每轮一次)。
-
max_output_tokens_recovery(
:1250)
max_output_tokens 截断,且升级(第 3 个)已试过或未启用,且恢复次数 < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT,升级到 64k 仍不够时的多轮恢复。靠"从断点续写 + 拆小任务"逐步完成,而非无限抬高上限。- 还截断?注入“从断点续写”消息重试。
- 守卫:
count < 3。
-
stop_hook_blocking(
:1305)
Stop hook 是用户配置的、在"模型结束一轮、不再调用工具时"触发的 shell 命令。
典型用途:模型说"我做完了"时,跑个钩子检查"真的做完了吗"–比如"有没有跑测试",“提交信息格式对吗”- stop hook 说没干完(如没跑测试)?塞反馈回去让模型返工。
- 守卫:保留
hasAttemptedReactiveCompact(防跨路径螺旋)。
这五个都是“卡住了 → 自救重试”,每个带防死循环守卫。
退出点(return)
恢复都不适用/失败时:
- API 错误(
:1264)→completed(跳过 stop hook,防 error→hook→retry 死循环) - prompt_too_long / image_error(
:1175/1182)→ 恢复失败,暴露错误 - stop_hook_prevented(
:1279)→ stop hook 硬停
推进(continue)
- token_budget_continuation(
:1340)- 用户给了 +500k 预算、还没花到 90%、且没收益递减?注入 nudge 让模型继续自主干。
- 这是唯一“模型本可停、系统推它继续”的
continue。 - 守卫:
checkTokenBudget的收益递减检测(续≥3 次且最近两次产出都 <500 token 就停)。
都没触发 → return completed(:1357),循环正常结束。
分叉二:工具执行路径(needsFollowUp,:1360-1727)
模型要了工具,执行它们然后 next_turn 推进。共 8 个阶段:
Phase 1:执行工具(:1366-1409)
const toolUpdates = streamingToolExecutor
? streamingToolExecutor.getRemainingResults() // 流式:收尾 drain
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext) // 批量:现跑
二选一拿 async generator。批量路径 runTools 分批:
- 并发安全工具(读类)并行 ≤ 10
- 非并发安全工具(写类 / Bash)串行
每个工具走 runToolUse 的完整链:
查表找实现 → Zod 校验类型 →
validateInput校验语义 →PreToolUse钩子 → 权限(allow/deny/ask)→tool.call()真正执行 →mapToolResult转结果 →PostToolUse钩子
每条结果 yield 给 UI + push 进 toolResults + 合并工具返回的 newContext。
Phase 2:异步启动工具摘要(:1411-1482)
用 Haiku 给这批工具生成一句话摘要(如 “Searched in auth/”),给移动端用户看进度,不喂回模型。
- fire-and-forget 存进
nextPendingToolUseSummary - 下一轮早期 yield — 把 ~1s Haiku 延迟藏进下一轮 5-30s 模型流式里,零新增延迟
- 子代理跳过
Phase 3:中止/钩子停拦截(:1484-1521)
- 工具执行中被
abort→return aborted_tools - 某工具钩子要求停 →
return hook_stopped
这是 next_turn 路径上的两个 return 闸门。
Phase 4:post-compact 轮次追踪(:1523-1533)
若处于压缩后上下文,递增 turnCounter(分析用,与 maxTurns 的 turnCount 不同)。
Phase 5:收集附件(:1535-1657)
必须在工具执行后(API 不允许 tool_result 与 user 消息交错)。收集 4 类“非工具结果上下文”喂模型下轮:
- 排队命令:用户中途输入 / 任务通知(按
agentId作用域过滤) getAttachmentMessages:最关键的是edited_text_file(文件被改的 diff,防模型基于过期内容干活)+nested_memory、各种 delta、todo 提醒、模式状态等(1s 超时尽力而为)- memory 预取:零等待消费,用
readFileState去重 - skill 发现预取:注入发现的技能
全部 yield + push 进 toolResults(追加在工具结果之后,不交错)。
Phase 6:刷新工具列表(:1659-1671)
中途有新 MCP server 连上 → 刷新 tools,下轮可用新工具。
Phase 7:maxTurns 拦截(:1704-1712)
turnCount+1 > maxTurns → return max_turns。第三个 return 闸门。
Phase 8:构造 next State 并 continue(:1714-1727)
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults], // ← 工具结果进历史
toolUseContext: toolUseContextWithQueryTracking,
turnCount: nextTurnCount, // +1
maxOutputTokensRecoveryCount: 0, // 重置
hasAttemptedReactiveCompact: false, // 重置
pendingToolUseSummary: nextPendingToolUseSummary,
transition: { reason: 'next_turn' },
...
}
state = next // continue 进下一轮
- 恢复计数重置(新轮清零)
- 跨轮状态保留(
autoCompactTracking)
next_turn 是 agent loop 的心跳:模型调工具 → 执行 → 喂结果回去 → 模型继续。
三、全局机制与总结
1. tool_use 的完整生命周期
- 模型吐
tool_use{id, name, input}
→ 流式收集进toolUseBlocks+needsFollowUp=true(流式执行器可能提前开跑) - → Phase 1 执行:查表→校验→权限→
tool.call() - → 生成
tool_result{tool_use_id:同id}配对回执 - → Phase 8 拼进
messages历史(assistant消息含tool_use+user消息含tool_result) - → 下一轮发给模型,它看结果决定再开单子(
next_turn)或收工(结束) - → 对话太长时 compact 摘要掉原始块,但副作用(
readFileState/文件改动)留存 → 退休
每个 tool_use 必须有配对的 tool_result(API 协议要求),所以中断/出错时系统要合成回执兜底。
2. continue / return 的完整分类
7 个 continue(重启迭代)
| 类型 | 具体入口 |
|---|---|
| 恢复型(5个) | collapse_drain_retry、reactive_compact_retry、max_output_tokens_escalate、max_output_tokens_recovery、stop_hook_blocking |
| 推进型(2个) | token_budget_continuation(系统推模型继续)、next_turn(模型要继续) |
12 个 return(退出循环),归三类
| 类别 | 退出点 |
|---|---|
| 正常完成 | completed(主出口)、stop_hook_prevented、hook_stopped |
| 中断/错误 | aborted_streaming、aborted_tools、model_error、image_error、prompt_too_long、blocking_limit |
| 上限 | max_turns |
3. 防死循环守卫
while(true) + continue 恢复 = 潜在死循环。每个恢复 continue 都配守卫,把“无限重试”变“有界恢复阶梯”:
- 计数器封顶(
count < 3) - 单次幂等(
override === undefined) - 转移记录去重(
transition !== 自身) - 黏性跨路径保留(
hasAttemptedReactiveCompact在stop_hook_blocking里不清零,防compact↔stop_hook螺旋 — 曾烧掉几千次 API 调用) - 外部预算耗尽(
checkTokenBudget递减检测、maxTurns)
守卫触发时停止恢复、return 暴露错误或降级到更重手段。
4. 工具清单每轮发但不全量
每轮 API 带 tools 参数,但:
- 核心工具发完整 schema
- 延迟工具(MCP/LSP)标
defer_loading轻量登记(模型要先 ToolSearch 加载) - 新增工具只发
delta增量 - schema 会话级缓存 + prompt cache 跨轮几乎零成本
发完清单后用哪个工具由模型按 description 自行判断。
5. 一个端到端例子
用户:“找出 src 下所有 TODO 并修第一个”
| 迭代 | 行为 |
|---|---|
| 迭代1 | 系统发 [系统提示词+历史+工具清单]模型 → tool_use Glob("src/**/*.ts")needsFollowUp=true → Phase1 执行 Glob → 结果 [文件列表] 进历史 → next_turn continue |
| 迭代2 | 模型看到文件列表 → tool_use Grep("TODO", 多个文件)→ 执行 Grep → 结果 [含TODO的行] 进历史 → next_turn |
| 迭代3 | 模型 → tool_use Read("src/foo.ts")(读含TODO的文件)→ 执行 Read( readFileState 记录)→ next_turn |
| 迭代4 | 模型 → tool_use Edit("src/foo.ts", ...)(改TODO)→ Edit 校验 readFileState(读过✓)+ 新鲜度(没被改✓)→ 执行 → next_turn |
| 迭代5 | 模型 → tool_use Bash("npm test")(验证)→ 执行 → next_turn |
| 迭代6 | 模型 → text "已修复并测试通过"(纯文本,无 tool_use)needsFollowUp=false → 收尾路径→ stop hook 检查(若配了“必须跑测试”且没跑 → stop_hook_blocking 返工)→ token budget 检查(若有 +500k 且没花完 → 续写,否则) → return completed 循环结束 |
任一环节出岔子都有对应机制:
- Read 读目录报错 → 模型下轮换 Bash(错误反馈自愈)
- Edit 发现文件被改过 → 抛
FILE_UNEXPECTEDLY_MODIFIED_ERROR让模型重读 - 上下文太长 → reactive compact 压缩重试
- 输出被截断 → 升 64k 或注入续写消息
- 用户 Ctrl+C →
aborted_tools配合成回执干净退出
6. 一句话总结
queryLoop是一个while(true)的 agent 主循环:每轮把系统提示词 + 历史messages+ 工具清单发给模型,流式收回复;据回复里有没有tool_use块分叉——有则执行工具、收集附件、把结果喂回去、next_turn推进下一轮;没有则走收尾路径,经五道恢复尝试(context collapse / reactive compact / max_output 升级+续写 / stop hook 返工)和 token budget 续写后return completed结束。它以toolUseContext为共享总线串联工具间状态,以tool_use/tool_result配对维护 API 协议,以每个continue的防死循环守卫保证“能自救又不会无限自救”,以 12 个return覆盖正常完成、中断、错误、上限四类退出。整个设计的精神是:模型负责语义决策(选什么工具、要不要继续),系统负责机械执行与容错(校验、权限、恢复、配对、状态流转),循环既“能持续协作推进任务”又“能容忍各种故障并最终收敛退出”。
更多推荐
所有评论(0)