菜鸟AI - 让提示词生成更简单! 全站导航 全站导航
AI工具安装 新手教程 进阶教程 辅助资源 AI提示词 热点资讯 技术资讯 产业资讯 内容生成 模型技术 AI信息库

已有账号?

首页 > AI教程 > AI智能体核心循环工作原理解密:Claude Code queryLoop运行机制与实现原理全解析
进阶教程 综合资讯

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

2026-06-08
阅读 0
热度 0
作者 菜鸟AI编辑部
摘要

摘要

queryLoop是驱动AIAgent核心循环的关键组件,采用asyncgenerator实现流式输出与终止值返回。主循

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 里,但做的是不同的事。

来源:互联网

免责声明

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

同类文章推荐

相关文章推荐

更多