AI智能体核心循环工作原理解密:Claude Code queryLoop运行机制与实现原理全解析

2026-06-08阅读 0热度 0
Claude

QueryLoop 是驱动整个 agent 循环的心脏——模型调用 → 工具执行 → 再调用,一直持续到任务完成或触发终止条件为止。同时,它也是整个代码库中架构设计最为考究的部分之一。下面会层层拆解它的设计思路:为什么选择 async generator 作为载体?初始化模式是怎样的?单次"回合"(turn)的内部结构又做了哪些事?

// 缩略版伪代码
function queryLoop(params):
// ========== 初始化阶段 ==========
// 不可变参数:systemPrompt, canUseTool, fallbackModel 等(const 解构)
// 可变状态:单一 State 对象,7 个 continue 站点都写 state = { ... }
init state { messages, toolUseContext, turnCount=1, ... }
// 一次性初始化(循环外,每个用户回合只执行一次)
budgetTracker = createBudgetTracker() // 编译时 feature gate 保护
taskBudgetRemaining = undefined // compaction 后补偿服务端信息丢失
config = buildQueryConfig() // 运行时 feature flag 快照,保证 session 内行为一致
using prefetchMemory = startMemoryPrefetch(...)// 后台预取,using 保证确定性清理
// ========== 主循环:每次迭代 = 一个"回合" ==========
while true:
// 解构状态(toolUseContext 用 let,其余 const)
destructure state
// ---------- 上下文管道(从最便宜到最激进)----------
messagesForQuery = getMessagesAfterBoundary(messages)// 裁到 compaction 边界之后
messagesForQuery = applyToolResultBudget(messagesForQuery)// 单条工具结果大小限制
messagesForQuery = snip(messagesForQuery)// 免费的 token 回收(feature-gated)
messagesForQuery = microcompact(messagesForQuery)// 外科手术式压缩,用 cache_edits 保持缓存命中
messagesForQuery = collapse(messagesForQuery)// 可逆的上下文折叠(读时投影)
compactionResult = autocompact(messagesForQuery) // 全量摘要(fork 一个 agent 做总结)
if compactionResult:
messagesForQuery = compactionResult.messages
taskBudgetRemaining -= compactedTokens// 客户端补偿服务端看不到的部分
// 阻断限制检查:所有压缩手段用尽后仍然太大
if contextTooLarge: yield error; return blocking_limit
// ---------- 模型流式传输(双层 try-catch)----------
outer try {
while (attemptWithFallback) {
inner try {
for each message in callModel(messagesForQuery):
// 4 步处理管道:
// 1. streamingFallbackOccurred → tombstone 无效的部分消息
// 2. backfillObservableInput → clone-not-mutate(保护 prompt cache)
// 3. withheld errors → PTL/maxTokens/media 错误暂不 yield
// 4. tool_use → 收集 toolUseBlocks,needsFollowUp = true
if message is assistant:
collect assistantMessages
collect toolUseBlocks → needsFollowUp = true
if message is PTL/maxTokens error:
withhold (don't yield yet)// 给恢复逻辑一个机会
else:
yield message// 推给 consumer(REPL/SDK)
} catch (innerError) {
if FallbackTriggeredError:
// 可恢复:切换到 fallbackModel,tombstone 部分消息,
// 剥离 thinking 签名(绑定原模型),重试
switchModel; stripSignatures; continue
else: re-throw// 不可恢复,交给外层
}} catch (error) {
// 不可恢复:ImageSizeError → image_error,其他 → model_error
// 两处都调用 yieldMissingToolResultBlocks 保持消息历史平衡
yield error; return model_error}
if aborted during streaming:
yield interruption; return aborted_streaming
// ---------- 恢复逻辑(无工具调用时)----------
if not needsFollowUp:
// Prompt-too-long (413) 恢复链
if withheldPTL:
try collapse drain → continue // 最便宜:释放折叠的 token
try reactiveCompact → continue// 紧急全量摘要(一次性门控)
else: yield error; return prompt_too_long
// Max output tokens 恢复链
if withheldMaxTokens:
try escalate to 64k → continue // 升级 token 上限(只一次)
try inject resume message → continue// 注入"继续"消息(最多 3 次)
else: yield error
// Stop hooks:用户自定义的继续/阻止检查
run stopHooks
if blocking: inject error message → continue// 注入阻塞错误,重试
if prevented: return stop_hook_prevented // hook 明确拒绝继续
// Token budget 检查:90% 阈值 + 收益递减检测(<500 token 增量)
check tokenBudget
if ok: return completed
// ---------- 工具执行 ----------
// 两种模式:streaming(并行,工具在模型流式传输期间已开始)或 sequential(串行)
for each toolResult in executeTools(toolUseBlocks):
yield toolResult
collect toolResults
if aborted during tools:
yield interruption; return aborted_tools
if hookStoppedContinuation: return hook_stopped
// ---------- 轮次收尾(6 步)----------
generate toolUseSummary (async, Haiku)// 移动端 UI 用的紧凑摘要
drain command queue → inject as attachments// 消费 mid-turn 到达的命令
consume memory prefetch → inject as attachments// 消费后台预取的记忆
inject skill discovery results// 注入 mid-turn 发现的新技能
refresh MCP tools// 刷新工具列表(新连接的 MCP server)
if turnCount >= maxTurns:
yield max_turns_reached; return max_turns
// ---------- 状态转换 ----------
// 完整的新 State 对象,不是 mutation
// 恢复计数器归零:每个新回合获得全新的恢复机会
state = {messages: [...messagesForQuery, ...assistantMessages, ...toolResults],toolUseContext: toolUseContextWithQueryTracking,turnCount: turnCount + 1,maxOutputTokensRecoveryCount: 0,// 重置hasAttemptedReactiveCompact: false,// 重置maxOutputTokensOverride: undefined,// 重置transition: { reason: "next_turn" },// 命名转换,可用于日志/测试}
continue

1 为什么选择 Async Generator?

为什么 queryLoop 是一个 async function*,而不是普通的 async function

一个普通的 async function 只有一个返回点——计算出结果然后交还。但 query loop 需要同时做两件事:一方面向上游流式输出中间结果(模型 token、工具输出、状态事件),传递给负责渲染 UI 的任何层;另一方面最终产出一个终止值,告诉调用方循环为什么停下来了。

Async generator 两者兼顾:它在每个回合通过 yield 输出中间值,在退出时通过 return 返回终止值。这正是 TypeScript 中 AsyncGenerator 的契约——两个类型参数直接对应这两个角色。

以下是 src/query.ts 中的实际函数签名:

async function* queryLoop(params: QueryParams,consumedCommandUuids: string[],): AsyncGenerator<| StreamEvent| RequestStartEvent| Message| TombstoneMessage| ToolUseSummaryMessage,Terminal>

yield 联合类型(第一个类型参数)是一个大型 discriminated union,包含了循环在运行过程中可能产出的所有内容:

产出类型含义
StreamEvent来自模型的原始流式数据块(token、thinking block 等)
RequestStartEvent在每个回合开头发出,表示"一次新的 API 调用正在开始"
Message完整的消息——包括 assistant 响应和合成的 user 消息(工具结果、错误信息)
TombstoneMessage通知 UI 层移除一条之前产出的消息(用于模型回退场景)
ToolUseSummaryMessage工具使用情况的紧凑摘要,由一个较小的模型异步生成

返回类型(Terminal)是一个退出原因的 discriminated union——循环可能停止的所有方式:

{ reason: "completed" }{ reason: "aborted_streaming" }{ reason: "aborted_tools" }{ reason: "blocking_limit" }{ reason: "prompt_too_long" }{ reason: "image_error" }{ reason: "model_error", error }{ reason: "max_turns", turnCount }{ reason: "hook_stopped" }{ reason: "stop_hook_prevented" }

Generator 契约就是架构本身

这不是一个随便做的便利性选择。Async generator 契约定义了 "agent 引擎"与"其他一切"(UI、SDK、测试)之间的边界。消费者通过 for await...of 迭代 generator 来接收流式输出,并检查返回值来知道会话为何结束。这种清晰的分离意味着同一个 queryLoop 可以驱动交互式 REPL、无头 SDK、桌面集成和测试 harness——它们无需了解单个"回合"的内部结构。

还有一个小细节:一个 wrapper 函数 query(),通过 yield* 委托给 queryLoop

export async function* query(params: QueryParams,): AsyncGenerator<| StreamEvent| RequestStartEvent| Message| TombstoneMessage| ToolUseSummaryMessage,Terminal> {const consumedCommandUuids: string[] = [];const terminal = yield* queryLoop(params, consumedCommandUuids);for (const uuid of consumedCommandUuids) {notifyCommandLifecycle(uuid, "completed");}return terminal;}

yield* 委托会透明地转发所有 yield 的值并返回 terminal 值。包装函数唯一的工作就是记账:通知已消费的命令它们已经完成。这种分离将生命周期管理逻辑排除在核心循环之外。

所以 consumedCommandUuids 不只是"传个引用方便记录",它是一个 started-without-completed = failed 的信号机制。注释原文写得很清楚: "This gives the same asymmetric started-without-completed signal as print.ts's drainCommandQueue when the turn fails."

2 不可变参数 vs 可变状态——刻意的分离

while (true) 循环开始之前,函数将其输入严格分成两个桶:不可变参数和可变状态。

不可变参数

// Immutable params — never reassigned during the query loop.const {systemPrompt,userContext,systemContext,canUseTool,fallbackModel,querySource,maxTurns,skipCacheWrite,} = params;const deps = params.deps ?? productionDeps();

这些值解构一次后就再也不会被修改。系统提示词在回合之间不会变。权限函数(canUseTool)不会变。回退模型不会变。通过在函数顶部把它们提升为 const,代码做出了一个架构级保证:你可以审查这里的每一个变量,确信它们在整个循环生命周期内都是稳定的。

为什么 deps 存在

deps 参数(params.deps ?? productionDeps())是一个 dependency injection 缝合点。在生产环境中它提供真实的 callModelautocompactmicrocompactuuid 实现。在测试中,你可以注入 mock。这是经典的 "ports and adapters"模式——循环体从不直接调用模型 API;它调用的是 deps.callModel(...)。这使得整个数百行的循环在不访问任何真实 API 的情况下就能被测试。

可变状态对象

let state: State = {messages: params.messages,toolUseContext: params.toolUseContext,maxOutputTokensOverride: params.maxOutputTokensOverride,autoCompactTracking: undefined,stopHookActive: undefined,maxOutputTokensRecoveryCount: 0,hasAttemptedReactiveCompact: false,turnCount: 1,pendingToolUseSummary: undefined,transition: undefined,};

以及 State 类型定义:

type State = {messages: Message[];toolUseContext: ToolUseContext;autoCompactTracking: AutoCompactTrackingState | undefined;maxOutputTokensRecoveryCount: number;hasAttemptedReactiveCompact: boolean;maxOutputTokensOverride: number | undefined;pendingToolUseSummary: Promise | undefined;stopHookActive: boolean | undefined;turnCount: number;transition: Continue | undefined;};

这就是每一个在迭代之间会变化的数据。与其在函数作用域中散布 9 个以上的 let 变量,所有状态都住在一个带类型的对象里。源码中的注释直接解释了动机:

3 一次性初始化:循环之前

在参数/状态分离和 while (true) 之间,有四段一次性初始化代码。每一段都被刻意放在循环外部,因为它们应该在每个用户回合中只执行一次,而不是每次模型调用都执行。

Budget Tracker(预算追踪器)

const budgetTracker = feature("TOKEN_BUDGET")? createBudgetTracker(): null;

Budget tracker 监控跨迭代的 token 消耗,以支持 "+500k auto-continue" 功能。它从一个简单的结构开始:

export function createBudgetTracker(): BudgetTracker {return {continuationCount: 0,lastDeltaTokens: 0,lastGlobalTurnTokens: 0,startedAt: Date.now(),};}

它受 feature gate 保护——如果 TOKEN_BUDGET 在编译时关闭,就不会创建 tracker,所有预算检查代码都会被死代码消除。这是 Claude Code 中普遍使用的 feature() 宏模式:在 bundle 时而非运行时解析的开关。

Task Budget Remaining(任务剩余预算)

let taskBudgetRemaining: number | undefined = undefined;

这个变量追踪跨 compaction 边界的剩余 token 预算。源码注释完美地解释了其中的微妙之处:当完整的对话历史还在时,服务器可以自己计算 token 数。但在 autocompact 将历史总结之后,服务器只能看到摘要——它不知道原始回合消耗了多少。所以客户端追踪累计总数,并在后续调用中传给服务器。这个变量放在 State 对象外面(正如注释所说:"Loop-local, not on State, to a void touching the 7 continue sites"),因为它只被 autocompact 路径写入,不被 continue transition 触及。

Config Snapshot(配置快照)

const config = buildQueryConfig();

这会在循环入口处捕获环境变量和 feature flag 的冻结快照:

export type QueryConfig = {sessionId: SessionIdgates: {streamingToolExecution: booleane mitToolUseSummaries: booleanisAnt: booleanfastModeEnabled: boolean}}

为什么是快照而不是实时读取?

QueryConfig 上的注释清晰地透露了前瞻性的设计意图:将这些与每次迭代的 State 结构体和可变的 ToolUseContext 分离开来,使得未来提取 step() 成为可能——一个纯 reducer 可以接受 (state, event, config) 其中 config 是纯数据。

关键区别:feature() 是编译时的开关(直接 baked into 二进制),config 是运行时的快照(statsig flags, env vars)。前者不需要快照因为它们不会变,后者需要因为它们随时可能变。

这是一个有意为之的举动,目标是让循环体成为一个纯状态机。如果你能把循环表示为 nextState = step(currentState, event, config) 其中 config 是不可变的,你就能获得确定性重放、更易测试、以及序列化/恢复中间循环状态的能力。快照是通往那个未来的前置条件。

还要注意刻意的排除:feature() 开关没有被捕获在这里。那些是编译时 tree-shaking 边界,必须保持内联,这样 bundler 才能消除死分支。运行时开关(env vars、statsig)才是被快照捕获的。

Memory Prefetch(记忆预取)

using pendingMemoryPrefetch = startRelevantMemoryPrefetch(state.messages,state.toolUseContext,);

这会在循环开始前启动一次后台记忆查询——搜索用户的 CLAUDE.md 文件、项目上下文和相关记忆。Prompt 在循环迭代之间不会改变(用户的消息是一样的),所以没必要每回合都运行这个查询。

这里有两点值得注意:

  1. using 关键字。 这是 TC39 的 Explicit Resource Management 提案(Symbol.dispose 协议)。当 generator 退出时——无论是通过 returnthrow,还是消费者调用 .return()——预取会被自动释放,取消任何进行中的网络调用并记录遥测数据。无需在每个退出点手动清理。
  2. 发起后不阻塞,直到消费时才处理。 预取立即启动,但其结果直到第一次迭代的工具执行之后才被消费。那时候模型的流式传输(约 5-30 秒)已经给了预取充足的完成时间。消费点通过轮询 settledAt 来检查而不阻塞——如果还没准备好,就跳过。

? 学习笔记

using 关键字不是为了防"内存泄漏"(不是 malloc 那种内存),而是保证所有退出路径上的确定性清理。不管 generator 怎么退出(正常 return、throw、用户 .return()、10个退出路径中的任何一个),dispose handler 都会跑。清理的内容是:遥测日志、进行中的 promise/timer、后台 fetch 的取消。把它想成一个自动挂在变量作用域上的 finally 块。

4 while (true) 循环——"一个回合"意味着什么

// eslint-disable-next-line no-constant-condition
while (true) {let { toolUseContext } = state;const {messages,autoCompactTracking,maxOutputTokensRecoveryCount,hasAttemptedReactiveCompact,maxOutputTokensOverride,pendingToolUseSummary,stopHookActive,turnCount,} = state;

这是一个显式的无限循环。每次迭代代表一个回合:一次模型 API 调用加上之后的所有工作(工具执行、恢复逻辑、记账)。循环仅通过散布在各处的显式 return 语句退出——每个 return 都返回一个带有 reason 字段的 Terminal 对象。

在每次迭代的开头,状态对象被解构为局部变量。这是一个人体工学选择:循环体可以到处写 messages 而不是 state.messages。但这里有个微妙之处——toolUseContextlet 解构,因为它是唯一一个在单次迭代内会被重新赋值的字段(当 query tracking 被注入或 MCP 工具被刷新时)。其他所有字段在一次迭代内都是 const

一个回合的解剖

while (true) 循环体的一次完整执行遵循以下顺序:

┌─────────────────────────────────────────┐│ 1. 解构状态││ 2. 发起 skill 预取(后台) ││ 3. 发出 stream_request_start ││ 4. 初始化 query tracking(chainId)│├─────────────────────────────────────────┤│ 5. 上下文管道││├── getMessagesAfterCompactBoundary ││├── applyToolResultBudget ││├── snipCompactIfNeeded ││├── microcompact││├── contextCollapse ││└── autocompact │├─────────────────────────────────────────┤│ 6. 模型流式传输(callModel) ││├── 收集 assistant 消息 ││├── 收集 tool_use block ││├── 暂扣可恢复错误││└── (流式工具执行)│├─────────────────────────────────────────┤│ 7. 流式传输后的恢复││├── Prompt-too-long → compact/重试││├── Max output tokens → 升级││├── Stop hooks││└── Token budget 检查 │├─────────────────────────────────────────┤│ 8. 工具执行││└── for await (update of runTools)│├─────────────────────────────────────────┤│ 9. 记账││├── 生成工具使用摘要(异步)││├── 清空命令队列││├── 消费记忆预取││├── 注入 skill 发现结果 ││├── 刷新 MCP 工具 ││└── 检查 maxTurns │├─────────────────────────────────────────┤│ 10. 继续 ││ state = { ...next }│└──────────────────── ↺ ──────────────────┘

当任何一个 return { reason: ... } 语句被命中时循环终止。最常见的退出是 { reason: "completed" }——模型返回了文本响应且没有工具调用,并且所有 stop hook 都通过了。

继续总是显式的。在最底部:

const next: State = {messages: [...messagesForQuery,...assistantMessages,...toolResults,],toolUseContext: toolUseContextWithQueryTracking,autoCompactTracking: tracking,turnCount: nextTurnCount,maxOutputTokensRecoveryCount: 0,hasAttemptedReactiveCompact: false,pendingToolUseSummary: nextPendingToolUseSummary,maxOutputTokensOverride: undefined,stopHookActive,transition: { reason: "next_turn" },};state = next;

状态累积

看看下一个状态中的 messages 字段:它拼接了 messagesForQuery(发送给模型的上下文)、assistantMessages(模型产出的内容)和 toolResults(工具返回的结果)。这个不断增长的数组就是对话本身。每个回合让它增长。每个回合的模型调用都能看到完整历史(受 compaction 约束)。这就是 agent 如何保持连贯的多回合推理——循环的状态从字面意义上就是 agent 的记忆。

还要注意在正常的 next-turn transition 中哪些被重置了:maxOutputTokensRecoveryCount 归零,hasAttemptedReactiveCompact 回到 false,maxOutputTokensOverride 设为 undefined。这些是单回合的恢复计数器——它们追踪循环在本回合是否已经尝试过某个特定的恢复策略。重置它们意味着每个新回合都获得一组全新的恢复尝试机会。

小结

queryLoop 的架构可以通过五个设计决策来理解:

  1. Async generator ——因为循环必须既能流式输出中间结果,又能产出一个带类型的终止值。Generator 契约就是公开的 API 边界。
  2. 不可变参数 + 可变状态对象——因为 7 个 continue 站点需要一个单一的、可审查的地方来写入下一次迭代的状态,而不可变参数需要被证明是稳定的。
  3. 循环外的一次性初始化——因为 memory prefetch、config snapshot 和 budget tracker 是每个用户回合的不变量,而非每次模型调用的。
  4. while (true) 配合显式 return——因为循环有 10 多种可能的退出原因,每种都有类型标注,而继续路径是所有这些检查的隐式"else"。
  5. 状态累积——每个回合的消息都会输入到下一个回合中,构成 agent 不断增长的上下文。各种 compaction 策略之所以存在,正是因为这种累积否则会撞上 API 的限制。

在下一章中,我们将深入上下文管道——在消息数组发送到模型 API 之前对其施加的一系列转换,以及五种不同的 compaction 策略如何组合起来将上下文控制在限制之内。

状态不断变化是状态机的职责。持续产出中间结果是 async generator 的职责。前者是内部簿记,后者是外部通信。两者都在 queryLoop 里,但做的是不同的事。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策