一个 AI Agent 的核心循环到底在干什么?Claude Code 的 queryLoop浅析

0 阅读18分钟

写在开头:已经有好多年没写技术文章了,没想到这次回归,前端又一次“骄傲”地死去了。这回是也是迫不得已又得开始学习新技术了 -- 最近在研究Harness Engineering,借着2026.4.1 Claude Code源码泄露来一次浅浅的源码学习, 有了AI也能更高效的做笔记和看源码,这波用Claude Opus4.6解析Claude Code也是某种牛头人行为了哈哈。学习为主,

AI味大,服用需谨慎。

以后会分享一些Harness Engineering的项目和心得(下次一定)。

Claude Code 中的 queryLoop 函数是整个 agent 的心脏——驱动 模型调用 → 工具执行 → 模型调用 → … 这一持续循环的引擎,直到任务完成或达到终止条件为止。同时,它也是整个代码库中架构设计最为考究的代码之一。本章将层层拆解它为何如此设计:从选择 async generator(异步生成器)说起,到初始化模式,再到单次"回合"的内部结构。

// 缩略版伪代码
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?

📝 学习笔记

选择 async generator 的理由有三层:

  1. 增量 UI 更新yield 让引擎把思考过程、工具调用结果实时推送给 UI,用户知道 agent 在干活
  2. 终态返回return 携带 Terminal 类型,完成 agentic 循环的闭合
  3. 背压控制(Backpressure) — consumer 调用 .next() 的速度决定 generator 的推进速度。如果终端渲染慢,generator 自然暂停,不会淹没 UI。这是 callback/event-emitter 模式做不到的

⚠️ 注意:async generator 本身不具备"防信息过载"或"反逆向工程"的作用。信息过滤是 consumer(REPL/SDK)的职责,generator 会 yield 所有内容。

你可能首先会问:为什么 queryLoop 是一个 async function* 而不是普通的 async function

一个普通的 async function 只有一个返回点——计算出结果然后交还。但 query loop 需要同时做两件事:

  1. 向上游流式输出中间结果(模型 token、工具输出、状态事件),传递给负责渲染 UI 的任何层。
  2. 最终产出一个终止值,告诉调用方 循环为什么停下来了

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

以下是 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 值。包装函数唯一的工作就是记账:通知已消费的命令它们已经完成。这种分离将生命周期管理逻辑排除在核心循环之外。

📝 学习笔记

wrapper 的设计是一个事务模式(transactional pattern)
旨在确保一组分布式操作或本地数据库操作遵循 ACID 原则(原子性、一致性、隔离性,持久性

  • 循环内部:命令被消费时标记为 "started"(line 1740)
  • 循环外部(wrapper):循环正常返回后才标记为 "completed"
  • 如果循环抛异常/崩溃:命令停留在 "started" 状态 → 系统知道这些命令被尝试过但未完成

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


📝 学习笔记

不可变 params (consts) + 可变 state 的分离确保了边界清晰:

while 循环只更新 state,绝不碰注入的 deps。这让每个 continue 站点只需要写一个 state = { ... } 而不是操心 9 个散落的变量。

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<ToolUseSummaryMessage | null> | undefined;
    stopHookActive: boolean | undefined;
    turnCount: number;
    transition: Continue | undefined;
};

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

"Mutable cross-iteration state. The loop body destructures this at the top of each iteration so reads stay bare-name (messages, toolUseContext). Continue sites write state = { ... } instead of 9 separate assignments."

(可变的跨迭代状态。循环体在每次迭代开头解构它,这样读取时可以直接用裸名称。continue 站点写 state = { ... } 而不是 9 个独立赋值。)


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,所有预算检查代码都会被 dead-code elimination(死代码消除)。这是 Claude Code 中普遍使用的 feature() 宏模式:在 bundle (bun的feature flag)时而非运行时解析的开关。

Task Budget Remaining(任务剩余预算)

let taskBudgetRemaining: number | undefined = undefined;

这个变量追踪跨 compaction(压缩)边界的剩余 token 预算。源码注释完美地解释了其中的微妙之处:

"Undefined until first compact fires — while context is uncompacted the server can see the full history and handles the countdown from {total} itself. After a compact, the server sees only the summary and would under-count spend; remaining tells it the pre-compact final window that got summarized away."

(在第一次 compact 触发之前为 undefined——当上下文未压缩时,服务器能看到完整历史并自行从 {total} 开始倒计时。compact 之后,服务器只能看到摘要,会低估消耗;remaining 告诉它被压缩掉的那部分 pre-compact 最终窗口。)

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

📝 学习笔记

taskBudgetRemaining 本质上是一个客户端计数器,用来补偿服务端的信息丢失。compaction 之后服务器只看到摘要,不知道原始对话花了多少 token。这不是 async generator 的"副作用",而是 compaction 架构的必然产物——不管用什么迭代模式(generator、callback、promise chain),只要有 compaction 就需要这个计数器。

Config Snapshot(配置快照)

const config = buildQueryConfig();

这会在循环入口处捕获环境变量和 feature flag(特性标志)的冻结快照:

export type QueryConfig = {
  sessionId: SessionId
  gates: {
    streamingToolExecution: boolean
    emitToolUseSummaries: boolean
    isAnt: boolean
    fastModeEnabled: boolean
  }
}

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

QueryConfig 上的注释揭示了前瞻性的设计意图:
"Separating these from the per-iteration State struct and the mutable ToolUseContext makes future step() extraction tractable — a pure reducer can take (state, event, config) where config is plain data."

(将这些与每次迭代的 State 结构体和可变的 ToolUseContext 分离开来,使得未来提取 step() 成为可能——一个纯 reducer 可以接受 (state, event, config) 其中 config 是纯数据。)

📝 学习笔记

用最简单的代码理解 config snapshot:

// ❌ 不用快照 — feature flag 可能在第1轮和第50轮之间变化
while (true) {
  if (getFeatureFlag("streamingToolExecution")) { ... }
  // 5分钟后这个值可能变了!同一个 session 行为不一致
}

// ✅ 用快照 — session 内行为确定性
const config = buildQueryConfig(); // session 开始时冻结
while (true) {
  if (config.gates.streamingToolExecution) { ... }
  // 永远是同一个值,行为一致
}

关键区别:feature()编译时的开关(baked into binary),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 那种内存),而是保证所有退出路径上的确定性清理(deterministic cleanup)。不管 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

一个回合的解剖

image.png

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 策略如何组合起来将上下文控制在限制之内。

📝 学习笔记:状态机 vs 异步生成器

queryLoop 里有两个独立的概念容易混在一起:

  1. 状态机(State Machine) = 引擎的变速箱(内部、私有)
    • state 对象在 turn 之间转换,通过 transition.reason 字段记录
    • consumer 看不到 state
    • 不管用什么迭代模式都需要状态机
  2. 异步生成器(Async Generator) = 引擎的排气管(外部、公开输出)
    • yield 把事件/消息推给 consumer
    • 提供背压控制
    • 这是选择 generator 模式的原因

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