queryLoop:Claude Code 源码的 Agent 运作引擎
导航
- 🧠 概念派? → 什么是 queryLoop — 它在整个系统里的位置
- ⚙️ 流程派? → 五层结构 — 一轮迭代里发生了什么
- 📡 UI 派? → yield 消息类型 — 界面是怎么渲染的
目录
极限场景:一个问题引出所有设计
想象这样一个时刻:
用户让 Claude Code 重构一个大型模块。模型已经读了 20 个文件,上下文到了 180k tokens,正在同时调用 5 个工具(读文件、写文件、执行测试……)。就在这一刻,用户按下了 Ctrl+C。
这一秒里,系统面临四个并发问题:
- 上下文快溢出了——180k tokens 离 200k 的硬限制只剩 20k,下一轮还能调用模型吗?
- 5 个工具正在并行执行——有的已经写了一半文件,有的还没开始,怎么安全中止?
- UI 已经渲染了部分消息——流式输出到一半的 thinking block 是无效的,不能留在界面上
- token 预算快耗尽了——调用方设了总预算,但 compact 之后服务端看不到完整历史,会低估消耗
这四个问题,分别对应 queryLoop 里的四个设计:六级上下文压缩、abortController 信号传播、TombstoneMessage 回滚协议、taskBudget 客户端补偿。
后面的每一节,都是这个极限场景的一个答案。
什么是 queryLoop
答案在 src/query.ts 的 queryLoop 函数里。它是 Claude Code 唯一的执行引擎——所有推理、工具调度、上下文管理都在这里发生。
用户输入
↓
QueryEngine.ts(会话管理)
↓
query() → queryLoop() ← 这里
↓
AsyncGenerator(事件流)→ REPL.tsx(UI 渲染)
queryLoop 不是一个请求-响应函数。它是一个 Agent 循环:
while (模型还有工具要调用) {
准备上下文
调用模型
执行工具
把结果喂回模型
}
return 退出原因
每一轮,模型看到工具结果,决定下一步。循环持续,直到模型不再调用工具,或触发退出条件。
为什么是 AsyncGenerator 而不是普通函数?
因为用户不能等。一次复杂任务可能跑 10 轮、调用 30 个工具,总耗时几十秒。如果等循环结束才返回,界面会冻住。async function* 让 queryLoop 边运行边 yield 事件——模型输出第一个 token 的那一刻,UI 就开始渲染。这是流式体验的根基。
输入:QueryParams
// src/query.ts(简化)
export type QueryParams = {
messages: Message[] // 完整消息历史
systemPrompt: SystemPrompt // 系统提示词
userContext: { [k: string]: string }
systemContext: { [k: string]: string }
canUseTool: CanUseToolFn // 权限检查函数
toolUseContext: ToolUseContext // 工具列表 + abort 控制器
fallbackModel?: string // 降级模型
querySource: QuerySource // 调用来源(REPL / subagent / ...)
maxOutputTokensOverride?: number
maxTurns?: number // 最大轮次限制(熔断器)
taskBudget?: { total: number } // output token 总预算
deps?: QueryDeps // 可注入的依赖(测试用)
}
几个值得注意的字段:
messages vs messagesForQuery
messages 是完整历史,在循环里永不修改。每一轮,queryLoop 从 messages 派生出 messagesForQuery——经过压缩、截断等处理后才交给模型。两个变量分开,防止处理后的临时状态污染下一轮。
taskBudget
调用方设定的整个 agentic turn 的 output token 总预算。跨越 compact 边界时,queryLoop 在客户端做补偿,防止服务端因为只看到摘要而低估已消耗的量。
补偿逻辑示例:
初始 budget.total = 10000
第 1 轮:模型 output 3000 tokens,剩余 7000
触发 compact:压缩前上下文 5000 tokens → 从剩余预算扣除 → 剩余 2000
第 2 轮:模型 output 1500 tokens,剩余 500
累计 output = 3000 + 5000 + 1500 = 9500,不超过 10000 ✅
服务端只看到 compact 后的摘要,不知道之前消耗了多少。客户端通过累计扣除来弥补这个信息差。具体实现见 011 上下文压缩篇。
toolUseContext
包含工具列表和 abortController。用户按 Ctrl+C 时,信号通过这里传播到正在进行的 API 调用和工具执行,整个链路立即中断。
fallbackModel
可选的降级模型。当流式传输中途发生 FallbackTriggeredError(通常是主模型不可用或返回特定错误)时,queryLoop 切换到 fallbackModel,清空本轮已收集的消息,重建 executor,重新发起请求。对外层调用者完全透明——它只看到一次完整的响应,不知道中间换过模型。
maxTurns:模型的熔断器
queryLoop 并不信任模型会自己停止。即使模型陷入死循环(不断调用工具、不断继续),turnCount 的硬计数也会在第 maxTurns 轮强制终止循环,返回 Terminal.MaxTurns。这是防御性 Agent 编程的体现:确定性的外壳,包裹随机性的模型。
输出:AsyncGenerator 事件流
// src/query.ts:241
// 💡 async function* 是关键:不等循环结束,每个事件实时 yield 出去
// 模型输出第一个 token 的那一刻,UI 就开始渲染——这是流式体验的根基
async function* queryLoop(
params: QueryParams,
consumedCommandUuids: string[], // 后台命令队列的生命周期追踪数组(见 FAQ)
): AsyncGenerator<
StreamEvent | RequestStartEvent | Message | TombstoneMessage | ToolUseSummaryMessage,
Terminal // return 值:告诉调用方为什么停下来
>
queryLoop 是 async function*——AsyncGenerator。它不等循环结束才返回,而是边运行边 yield 事件,调用方实时消费。
最终 return Terminal,告诉调用方为什么停下来(end_turn / max_turns / blocking_limit / abort……)。
consumedCommandUuids 是一个由外层 query() 函数传入的数组,queryLoop 在注入层消费后台命令时,把命令的 uuid 追加进去。循环正常结束后,外层遍历这个数组,逐一触发 notifyCommandLifecycle(uuid, 'completed')。这是一个生命周期通知机制:命令被消费时通知 started,整个 turn 结束时通知 completed,让外部系统能追踪命令的完整生命周期。
yield 出来的消息类型
| 消息类型 | 触发时机 | UI 用途 |
|---|---|---|
StreamEvent | 模型输出 token 时 | 渲染打字效果(实时流) |
RequestStartEvent | 每次 API 请求开始 | 显示"正在思考..." |
AssistantMessage | 模型完整消息收齐后 | 渲染对话气泡 |
ToolUseSummaryMessage | 工具执行完成后 | 显示"正在读取文件..." |
TombstoneMessage | 流式中断需要回滚时(onStreamingFallback 回调触发后,孤儿消息被逐一 tombstone) | 告诉 UI 撤掉已渲染的不完整消息 |
AttachmentMessage | memory/skill/queue 注入时 | 不直接渲染,注入下一轮上下文 |
TombstoneMessage 对应极限场景问题 3。流式传输中途切换模型时,已经 yield 给 UI 的部分消息是无效的——不完整的 thinking block 有无效签名,会导致后续 API 调用报"thinking blocks cannot be modified"错误。
这不只是 UI 清理问题。Anthropic API 要求每次请求里的 assistant 消息必须是合法的完整结构——thinking block 必须有配对的签名,tool_use 必须有配对的 tool_result。Tombstone 是绕过这个协议限制的唯一手段:把无效消息从 transcript 里抹掉,让下一次请求能通过 API 校验。
实际触发链:
callModel调用时,onStreamingFallback回调作为选项传入- 流式传输中途切换模型时,该回调被触发 →
streamingFallbackOccured = true - 流式循环继续,下一条消息进来时检查该标志
- 为已收集的
assistantMessages逐一 yield{ type: 'tombstone', message: msg } - UI 收到 tombstone,把这些消息从界面和 transcript 中移除
- 流式循环用新模型继续,重新输出完整消息
注意:FallbackTriggeredError(catch 块路径)走的是另一条路——yield 合成的 tool_result(补齐缺失的工具响应,防止 API 报错),不是 tombstone。两条路都和 fallback 有关,但处理的问题不同。
AttachmentMessage 不渲染给用户,而是把 memory、skill 内容、后台任务结果注入到下一轮的上下文里。用户看不到,但模型能看到。
AttachmentMessage 会写入 JSONL transcript。resume 时,loadConversationForResume 读取这些消息并恢复 skill 状态(restoreSkillStateFromMessages)。为防止重复注入,skill_listing 类型的 attachment 有一个 fire-once 锁:suppressNextSkillListing()——resume 后第一次 skill 注入会被跳过,避免重复发送约 600 tokens 的 skill 列表。
五层结构
queryLoop 的 1700 行可以分成五层:
取指层:六级压缩漏斗
每一轮,messages 不能直接交给模型——历史可能太长,工具结果可能太大。取指层按顺序过六道处理,像一个 token 漏斗,把原始历史榨成模型能消化的大小:
messages(原始历史,可能 200k+ tokens)
│
▼ getMessagesAfterCompactBoundary
│ 只取最近一次 compact 之后的消息
│ → 截断旧历史,避免重复处理已压缩的内容
│
▼ applyToolResultBudget
│ 裁剪过大的工具结果(如读取了一个 10MB 的文件)
│ → 省钱:减少不必要的 input tokens
│
▼ snipCompact
│ 删除历史中间段,保留头部(系统提示)和尾部(最近对话)
│ → 保留最相关的上下文,丢弃中间的"流水账"
│
▼ microcompact
│ 缓存感知的 tool_result 清理(通过 Cache Editing API 删除旧工具结果)
│ → 保护缓存 prefix 不被无效化:删除内容但不改变消息结构,
│ 服务端已缓存的 prefix 继续命中,大幅降低长对话的首字延迟
│ 例如:第 20 轮时,第 1-15 轮的 tool_result 内容被清空,
│ 但消息 ID 保留,缓存 prefix 不重算
│
▼ contextCollapse
│ 折叠已归档段(CONTEXT_COLLAPSE feature)
│ → 把远古历史折叠成摘要,释放 token 空间
│
▼ autocompact
│ 整体摘要压缩(触发条件:当前上下文 token 数 > contextWindow * 0.8)
│ → 最重量级的压缩,把整个历史变成一段摘要
│ 例如:180k / 200k = 90%,触发 → 生成摘要,历史被替换为"之前的对话摘要:..."
│
▼ blocking limit check(极限场景问题 1 的答案)
│ 超过 contextWindow - 3000 tokens 的硬限制?
│ → 是:yield 错误消息,return blocking_limit,停止循环
│ → 否:继续
│
▼ callModel
为什么是六道而不是一道? 因为每道处理的代价和适用场景不同:
| 处理 | 代价 | 适用场景 |
|---|---|---|
getMessagesAfterCompactBoundary | 极低 | 每轮必做 |
applyToolResultBudget | 低 | 工具结果过大时 |
snipCompact | 低 | 历史过长时 |
microcompact | 低(仅本地重排) | 缓存命中率低时 |
contextCollapse | 中 | 远古历史过多时 |
autocompact | 高(需要 LLM 调用) | 接近上下文上限时 |
轻量的先做,重量级的只在必要时触发。上下文压缩的完整细节是一个独立的大话题,后续会单独一篇展开。
执行层:并行工具执行
// 💡 传统模式:模型输出完毕 → 执行工具(串行,等待时间 = 模型输出时间 + 工具执行时间)
// 流式模式:收到完整 AssistantMessage 后立即开始执行(并行,等待时间 ≈ max(两者))
// 注意:addTool 在整条 AssistantMessage 收齐后触发,不是逐 block 触发
// 原因:callModel 的流式输出以 AssistantMessage 为单位组装,block.input 在此时才完整
if (streamingToolExecutor) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message) // message 是完整的 AssistantMessage
}
}
StreamingToolExecutor 让工具在模型还在输出后续消息时就开始执行,多轮之间的"思考"和"执行"并行,减少等待。
一个值得注意的细节:addTool 的触发时机是收到完整的 AssistantMessage(整条消息所有 block 组装完毕),而不是流式参数收集的中途。这避免了"参数不完整就开始执行"的风险——工具拿到的 block.input 始终是完整的 JSON。
并发控制时序:
模型输出 AssistantMessage(含 tool_use A、B、C)
↓ 整条消息收齐
executor.addTool(A) executor.addTool(B) executor.addTool(C)
↓ ↓ ↓
A: 并发安全 B: 并发安全 C: Bash(非并发)
→ 立即执行 → 立即执行 → 等 A、B 完成后执行
结果到达顺序:B 先完成,A 后完成,C 最后
缓冲输出:tool_result A, tool_result B, tool_result C(保持原始顺序)
并发安全的工具可以同时跑,非并发工具(如 Bash)必须独占执行。结果按工具接收顺序缓冲输出,保证 tool_result 的顺序和 tool_use 一致。
注入层:把外部信息带入下一轮
每轮结束前,queryLoop 检查三类待注入内容:
- 后台任务队列(queue):后台 Agent 完成的任务结果
- memory prefetch:相关记忆内容
- skill prefetch:预加载的 skill 内容
这些内容以 AttachmentMessage 的形式 yield 出去,同时加入下一轮的 messages。
状态层:确定性外壳
// query.ts:204-217
// 💡 为什么用单个 State 对象而不是 9 个分散变量?
// 循环里有 7 个 continue 出口,每个都写 state = { ...state, 变更字段 }
// 不可变替换,不会漏掉字段,continue 站点清晰,状态变更一目了然
type State = {
messages: Message[]
toolUseContext: ToolUseContext
maxOutputTokensOverride: number | undefined
hasAttemptedReactiveCompact: boolean // 熔断器:防止压缩死循环
maxOutputTokensRecoveryCount: number // 输出截断恢复计数
turnCount: number // 已执行轮次(配合 maxTurns 熔断)
pendingToolUseSummary: ToolUseSummaryMessage | undefined
transition: Transition | undefined
// ...
}
turnCount 是模型的熔断器。queryLoop 不信任模型会自己停止——即使模型陷入死循环,turnCount >= maxTurns 的硬检查也会在第 N 轮强制拔插头。这是确定性外壳包裹随机性模型的核心体现:模型决定做什么,queryLoop 决定能做多少。
hasAttemptedReactiveCompact 是另一个熔断器,防止"413 → 压缩 → 413 → 压缩"的死循环。触发一次后设为 true,下一轮检查到 true 就不再尝试。
退出层:所有退出条件
needsFollowUp === false → 模型不再调用工具,正常结束(end_turn)
max_turns 达到上限 → 返回 Terminal.MaxTurns(熔断)
blocking limit 触发 → 上下文超硬限制,返回 blocking_limit
abort 信号 → 用户中断,返回 Terminal.Abort
stop-hook 触发 → 外部 hook 要求停止
needsFollowUp 是循环继续的核心判断。它不依赖 API 返回的 stop_reason(不可靠),而是在流式过程中自己观察:看到 tool_use block 就设为 true,用事实而不是声明来决定是否继续。
一轮迭代的完整时序
把五层结构串起来,一轮迭代的时序是:
① state.messages
↓
② 取指层:派生 messagesForQuery(六级压缩漏斗)
↓
③ 执行层:callModel 流式接收
↓(并行)
④ 执行层:StreamingToolExecutor 执行工具
↓
⑤ 注入层:检查 queue / memory / skill,生成 AttachmentMessage
↓
⑥ 退出层:needsFollowUp?
├── true → 工具结果加入 messages,state = {...state, turnCount+1},continue
└── false → 检查 stop-hook,无则 return Terminal
每一轮,state 整体替换,messages 追加,messagesForQuery 重新派生。循环持续,直到退出层触发。
回到极限场景:180k tokens、5 个并行工具、用户按下 Ctrl+C——
- 取指层检测到接近 blocking limit,下一轮会触发 autocompact
- 执行层的
abortController.signal收到中止信号,正在进行的工具调用立即停止 - 退出层检测到 abort,return
Terminal.Abort - TombstoneMessage 通知 UI 撤掉已渲染的不完整消息
四个问题,四个答案,全部在这 1700 行里。
本系列后续文章
001 是整个系列的入口地图。每一层对应后续的深度文章:
| 层 | 对应文章 |
|---|---|
| 执行层(工具系统) | 002 工具系统设计总览 + 权限机制 |
| 执行层(工具实现) | 003-009 各类工具的具体实现 |
| 取指层(上下文压缩) | 011 五层上下文压缩全解析 |
| 状态层(持久化) | 012 状态管理与会话持久化 |
| 注入层(记忆) | 014 记忆系统:MEMORY.md 的实现 |
| 注入层(Hook) | 015 Hook 系统:三类 Hook 的设计 |
系列导航:
- 上一篇: 000 - Claude Code 架构全景图
- 当前: 001 - queryLoop:Agent 运作引擎
- 下一篇: 002 - 工具系统设计总览
常见问题 FAQ
Q:queryLoop 的循环到底长什么样?能不能用伪代码展示?
下面是一个极度简化的可运行版本,去掉了压缩、错误恢复、注入层,只保留核心骨架:
async function* minimalQueryLoop(messages: Message[], tools: Tool[]) {
let history = [...messages]
while (true) {
// 取指层:这里简化为直接用 history(真实版本会过六级压缩)
// 执行层:调用模型
const response = await callModel({ messages: history, tools })
// yield 给 UI(真实版本是流式 yield,这里简化为一次性)
yield response
// 退出层:模型没有调用工具,结束
const toolCalls = response.content.filter(b => b.type === 'tool_use')
if (toolCalls.length === 0) {
return { reason: 'end_turn' }
}
// 执行工具,把结果追加到 history(真实版本是并行执行)
const toolResults = await Promise.all(toolCalls.map(executeTool))
history = [
...history,
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults },
]
// continue → 下一轮
}
}
真实的 queryLoop 在这个骨架上加了:六级上下文压缩、流式并行工具执行、fallback 模型切换、TombstoneMessage 回滚、memory/skill 注入、taskBudget 补偿……但循环的本质不变。
Q:consumedCommandUuids 是什么?为什么不直接在 queryLoop 内部处理?
它是后台命令队列的生命周期追踪机制。queryLoop 消费一个后台命令时,把它的 uuid 推入这个数组,同时触发 notifyCommandLifecycle(uuid, 'started')。循环结束后,外层 query() 函数遍历数组,触发 notifyCommandLifecycle(uuid, 'completed')。
不在内部处理的原因:completed 必须在整个 turn 成功结束后才能触发,而不是命令执行完就触发。如果 turn 中途失败(抛出异常),外层的 for...of 不会执行,completed 就不会被错误地发出。这是一个利用 JavaScript 控制流来保证生命周期正确性的设计。
Q:fallbackModel 在什么情况下触发?
当流式传输中途抛出 FallbackTriggeredError 时。这个错误通常由 withRetry 层在主模型返回特定错误码(如服务不可用)时抛出。触发后:
currentModel切换为fallbackModel- 本轮已收集的
assistantMessages被清空(yield TombstoneMessage 通知 UI 撤掉) attemptWithFallback = true,重新进入内层 while 循环- 用新模型重新发起本轮请求
如果 fallbackModel 未设置,FallbackTriggeredError 会直接向上抛出,不做降级处理。
Q:用户按 Ctrl+C,信号是怎么从键盘传到 queryLoop 退出的?
完整传播链:
用户 Ctrl+C
→ REPL 层调用 abortController.abort()
→ toolUseContext.abortController.signal 传播到两处:
① callModel 内部的 fetch(signal) → 网络请求中断 → AbortError
② StreamingToolExecutor 中每个工具的 execute 函数检查 signal
→ 正在执行的工具收到中止信号,生成合成的 tool_result(标记为中断)
→ AbortError 被 withRetry 捕获,判断为用户主动中断,重新抛出
→ queryLoop 的 catch 块捕获
→ yield 错误消息,return Terminal.Abort
两个并行路径(API 请求 + 工具执行)都通过同一个 abortController.signal 协调,不需要额外的通信机制。StreamingToolExecutor 还有一个子级 siblingAbortController:当某个 Bash 工具报错时,只中止同批次的兄弟工具,不影响父级 abortController——queryLoop 不会因为单个工具失败而退出整个 turn。
Q:blocking limit 触发后用户能做什么?
收到 blocking_limit 错误后,queryLoop 已经停止。用户需要手动执行 /compact 压缩上下文,才能继续对话。这就是为什么 blocking limit 要预留 3000 tokens 的缓冲——确保用户在触发硬限制后,还有足够的空间执行 /compact 命令本身。
Q:messages 永不修改,这个设计还有什么好处?
/resume 能工作,正是因为这个设计。
queryLoop 每轮只往 messages 追加,从不修改已有内容。这意味着 messages 始终是一条完整的、可信的对话链——每条消息都有 uuid 和 parentUuid,构成一棵树。Claude Code 把这条链实时追加写入 JSONL transcript 文件(~/.claude/projects/.../session-id.jsonl)。
当用户执行 /resume(或 claude --resume)时,loadConversationForResume 从 JSONL 文件里读取消息链,沿 parentUuid 走到最新的叶节点,重建出完整的 messages 数组,直接传给下一次 queryLoop。
中断时:messages = [msg1, msg2, msg3, ...] → 追加写入 session.jsonl
↓
resume 时:读取 session.jsonl → 重建 messages → 传给新的 queryLoop
如果 messages 是可变的——每轮压缩后直接修改历史——transcript 里存的就是处理后的临时状态,resume 时拿到的是损坏的上下文,无法正确恢复。
不可变历史是 resume 的基础设施,而不只是一个循环内部的实现细节。这个模式在任何需要"可恢复执行"的系统里都适用:canonical state 永不修改,derived working copy 随用随丢。