死循环里的优雅:QueryEngine 的 while(true) 状态机与原子操作

《Claude Code 架构解密》读书笔记 · 第03篇
对应章节:第3章前半(3.1-3.5)— 查询引擎的核心循环


导语

当你按下回车,Claude Code 开始自主执行——读文件、改代码、跑测试,一气呵成。谁在驱动这个循环?答案是 QueryEngine——Claude Code 架构的"心脏"。但如果你翻开源码,驱动这颗心脏的不是什么精巧的状态机框架,而是一个朴素到近乎"土气"的结构:while(true)

本篇拆解 QueryEngine 为什么选择最朴素的循环、如何用二元分离管理生命周期、AsyncGenerator 如何实现流式驱动,以及配置快照如何防止运行时漂移。


一、架构定位:五层架构中的引擎核心

在第1章介绍的五层架构中,QueryEngine 位于编排层的核心位置——

入口层(Entrypoints)
  CLI / SDK / REPL
       │ 创建/调用
       ▼
引擎层(Engine)  ◀── 本篇焦点
  QueryEngine.ts   ← 会话级生命周期管理器
  query.ts         ← 单轮查询循环(异步生成器)
  query/config.ts  ← 配置快照
  query/deps.ts    ← 依赖注入边界
  query/stopHooks.ts ← Hook 编排
  query/tokenBudget.ts ← 预算决策
       │ 调用
       ▼
服务层(Services)
  API / Compact / Tools / Hooks / MCP / ...

向上对接入口层的各种调用方式(REPL、SDK、MCP),向下协调能力层的工具执行、权限检查和上下文管理。这个位置决定了 QueryEngine 必须同时处理好"谁来调用我"和"我能调用什么"两个方向的复杂性。


二、二元分离:QueryEngine vs query.ts

为什么需要两层抽象?

这是引擎层最重要的架构决策:将引擎拆分为两个独立的抽象层级。

维度 QueryEngine(类) query(异步生成器函数)
职责 管理跨轮次状态 管理单轮查询循环
状态 消息历史、权限拒绝记录、累计用量、文件缓存 当前轮消息快照、轮次计数、压缩追踪、错误恢复计数
生命周期 每个会话一个实例,等同整个会话 每次用户输入时创建,输入处理完毕后结束
类比 数据库连接 SQL 查询执行

为什么要分开?答案在于生命周期的不对称性——

会话开始
用户输入 #1 → query() 循环 #1 开始 → Ctrl+C 中断 → 循环 #1 结束
   (消息历史保留,累计用量保留)
用户输入 #2 → query() 循环 #2 开始 → 正常完成 → 循环 #2 结束
   (消息历史增长,累计用量增加)
用户输入 #3 → query() 循环 #3 开始 → ...

如果混在一个对象里,两个头疼的问题:

  1. 重置的范围模糊:用户中断后重新输入,哪些状态该重置、哪些该保留?遗漏一个就是 bug
  2. 复用的粒度不匹配:query() 需要被 REPL、子 Agent、SDK 等多方调用,不应为此创建完整 QueryEngine 实例

事实上,query() 的提取正是 Claude Code 演进中的一次关键重构(PR#22546)——最初的 ask() 方法包含了整个查询循环,随系统需要支持 Headless、SDK、REPL 等多种调用模式,将查询循环提取为独立函数成了必然选择。

接口边界设计

QueryEngine 对外暴露两个核心方法:

class QueryEngine {
  // 提交用户消息,返回流式结果
  async *submitMessage(
    prompt: string,
    options: SubmitOptions
  ): AsyncGenerator<SDKMessage>

  // 中止当前查询
  interrupt(): void
}

query() 是一个纯函数式的异步生成器:

async function* query(params: QueryParams): AsyncGenerator<QueryYield, Terminal> {
  // ...查询循环
}

type QueryParams = {
  messages: Message[]           // 历史消息
  systemPrompt: string          // 系统提示
  canUseTool: PermissionFn      // 权限检查函数
  toolUseContext: ToolUseContext // 工具上下文
  querySource: QuerySource      // 调用来源标记
  deps?: Partial<QueryDeps>     // 可选的依赖注入
  maxTurns?: number             // 最大轮次限制
}

关键设计哲学:query() 接收所有必要上下文作为参数,不从全局状态中读取。给定相同输入,行为确定——这是测试和调试的福音。

状态所有权矩阵

状态 所有者 生命周期 说明
mutableMessages QueryEngine 会话级 完整的对话历史
totalUsage QueryEngine 会话级 累计 token 使用量
permissionDenials QueryEngine 会话级 被拒绝的权限记录
readFileState QueryEngine 会话级 文件读取缓存
discoveredSkills QueryEngine 会话级 已发现的技能集合
state.messages query() 轮次级 当前轮的消息快照
state.turnCount query() 轮次级 当前轮次计数
state.transition query() 轮次级 状态转换原因
state.maxOutputTokensRecoveryCount query() 轮次级 错误恢复计数
state.hasAttemptedReactiveCompact query() 轮次级 压缩尝试标记

这个矩阵的核心原则:生命周期不同的状态严格分开。任何长生命周期的交互式系统——编辑器、游戏引擎、交易系统——都能从中受益。


三、while(true) 状态机:朴素的力量

最简单的循环结构

进入 QueryEngine 最核心的代码——queryLoop:

async function* queryLoop(state: State, config: QueryConfig, deps: QueryDeps) {
  while (true) {
    // 1. 上下文压缩(如果需要)
    state = await maybeCompact(state, config)

    // 2. 调用 LLM API
    const response = await deps.callModel(state.messages, config)

    // 3. 处理流式响应 + 执行工具
    const result = yield* processResponse(response, state)

    // 4. 错误恢复
    if (result.error) {
      const recovery = attemptRecovery(result.error, state)
      if (recovery) {
        state = { ...state, ...recovery, transition: recovery.reason }
        continue  // ← 回到循环顶部重试
      }
      return { reason: 'error', error: result.error }  // 无法恢复,终止
    }

    // 5. 检查是否需要继续(有工具调用 → 继续)
    if (result.needsFollowUp) {
      state = { ...state, turnCount: state.turnCount + 1, transition: { reason: 'next_turn' } }
      continue  // ← 工具执行完毕,带着结果继续下一轮
    }
    // ...
  }
}

就这么简单。一个 while(true) 循环,内部通过 continue 继续、return 终止。没有状态枚举、没有转换表、没有事件调度。

隐式状态机 vs 显式状态机

你可能觉得这种写法过于粗糙。Claude Code 的回答是:用 transition 字段实现隐式状态机

type State = {
  messages: Message[]
  turnCount: number
  transition: Continue | undefined  // ← 记录"为什么回到循环顶部"
  // ...其他字段
}

type Continue = {
  reason:
    | 'next_turn'                  // 正常的工具调用后继续
    | 'collapse_drain_retry'       // Context Collapse 后重试
    | 'reactive_compact_retry'     // 响应式压缩后重试
    | 'stop_hook_blocking'         // Stop Hook 阻止后重试
    | 'token_budget_continuation'  // Token 预算延续
}

每次循环通过 continue 回到顶部时,state.transition 都会被设置为一个明确的原因。循环体内部可以根据"为什么回到这里"做出不同决策:

if (state.transition?.reason === 'collapse_drain_retry') {
  // 上次因为 Context Collapse 失败回来的,跳过再次尝试 collapse
  skipCollapse = true
}
if (state.transition?.reason === 'reactive_compact_retry') {
  // 上次因为响应式压缩回来的,跳过再次尝试压缩
  skipReactiveCompact = true
}

对比显式状态机:

维度 while(true) 隐式状态机 XState/LangGraph 显式状态机
代码量 ~100行循环体 ~300行状态定义+转换表
可视化 需要读代码理解流程 可自动生成状态图
转换路径 约6种(线性为主) 可支持复杂图结构
AsyncGenerator 兼容 天然兼容(yield在循环内) 需要适配层
调试 transition 字段+日志 状态快照+可视化回放
依赖 零依赖 需引入框架
学习曲线 懂 while 循环即可 需学习框架概念

选型关键:queryLoop 的状态转换路径是线性的——要么继续下一轮,要么终止。少数"非常规继续"不过是给"继续"附加了原因标签。如果状态转换路径更复杂(多 Agent 并发、条件分支合并、回退历史状态),显式图结构会是更好的选择。

循环不变式:while(true) 的安全保证

五层保护确保不会真正无限循环:

  1. maxTurns 限制:配置参数限制最大循环轮次
  2. Token Budget 检查:每轮结束后检查累计 token 是否超预算
  3. 错误恢复上限:每种错误类型都有最大恢复次数(如 Max Output Tokens 最多3次)
  4. LLM 的自然终止:如果 LLM 不返回工具调用,循环自然结束
  5. 外部中断:Ctrl+C 触发 AbortController,通过 interrupt() 终止

使用 while(true) 而非 for 循环的原因——终止条件不是简单计数器,而是多种条件组合,while(true) + 内部多点 return 反而更清晰。

为什么排除递归方案?

// 假设用递归实现 queryLoop
async function* queryLoop(state: State): AsyncGenerator<...> {
  const response = await callModel(state.messages)
  const result = yield* processResponse(response, state)
  if (result.needsFollowUp) {
    yield* queryLoop({ ...state, turnCount: state.turnCount + 1 })  // 递归
  }
}

看起来很函数式、很"正确",但有致命问题:

  • 栈溢出风险:一次查询可能涉及数十轮工具调用,每次递归创建新栈帧。V8 默认调用栈深度约 10,000-15,000 帧,AsyncGenerator 的 yield 点也占栈帧,实际可用递归深度更少
  • 状态更新隐式:递归中状态"隐藏"在参数传递中,调试时需在调用栈中上下跳转;而 while(true)state = {...state, ...updates} 是显式更新,可在循环顶部设断点清晰看到每轮变化

四、AsyncGenerator:流式驱动的查询引擎

为什么不用 Promise?

query() 是 AsyncGenerator(async function*),而非返回 Promise 的异步函数。选择源于 LLM 交互的本质——流式响应

如果返回 Promise,调用方只能等待整个查询循环完成后才获得结果——用户会看到漫长等待后突然出现一大段文本。AsyncGenerator 提供了完美抽象:

for await (const event of query(params)) {
  switch (event.type) {
    case 'stream_event':
      terminal.write(event.text)       // LLM 正在输出,实时显示
      break
    case 'tool_use':
      terminal.showProgress(event.toolName)  // 工具开始执行
      break
    case 'tool_result':
      terminal.showResult(event.result)      // 工具执行完毕
      break
  }
}

三重优势

1. 背压控制(Backpressure)

AsyncGenerator 是 pull-based——消费者调用 next() 时生产者才产生下一个值。终端渲染速度跟不上 LLM 输出时,生产者自动暂停,不会堆积未处理事件导致内存膨胀。

对比 EventEmitter(push-based),后者需手动实现缓冲和流控(Node.js Stream 中 highWaterMark / pause()/resume() 的存在原因)。AsyncGenerator 把这个复杂性消除在了语言层面。

2. 自然的取消语义

Generator 的 return() 方法提供优雅的取消机制:

class QueryEngine {
  private currentGenerator: AsyncGenerator | null = null

  interrupt() {
    // 让 queryLoop 中当前的 yield 点抛出 return
    // finally 块中的清理代码会正常执行
    this.currentGenerator?.return(undefined)
  }
}

比 AbortController + try/catch 自然得多。Generator 的 return() 保证 finally 块中的清理代码一定执行——保存部分结果、关闭连接、更新状态。

3. 组合性

yield* 语法允许将一个 generator 的输出"转发"到另一个:

const toolResults = yield* executeTools(toolCalls, toolUseContext)
// yield* 会把 executeTools 内部 yield 的每个事件透传给 queryLoop 的消费者

Trade-off:Generator 的代价

  • 堆栈跟踪不友好:Generator 内部错误不会包含 for await...of 的调用点
  • 调试困难:断点调试时执行在 yield 点暂停,控制流跳到消费者
  • 测试复杂:需用 for await...of 收集所有产出,或手动调用 next() 逐步验证
  • 概念门槛:yield/yield*/return 语义比 await/return 更难理解

Claude Code 团队认为这些代价可接受——不支持流式输出的 Agent 在用户体验上是不及格的。


五、配置快照模式:防止运行时漂移

问题:长查询中的配置一致性

用户启动复杂重构任务,Claude Code 持续几分钟。期间可能发生:

  • 管理员通过 GrowthBook 远程修改了 Feature Flag
  • 用户在另一个终端修改了 .claude/settings.json
  • 环境变量因后台进程而变化

如果每次迭代都重新读取配置,同一 queryLoop 实例可能在第3轮使用旧行为,第4轮突然切换——这就是运行时配置漂移

实践中会导致极其难排查的 bug:

  • 日志显示第3轮和第4轮行为不同,但代码路径完全一样
  • 用户报告"有时候正常,有时候出错",取决于 Flag 变更时机
  • 本地复现一切正常,因为配置是静态的

解决方案:入口快照

在 queryLoop 入口一次性"快照"所有运行时配置,循环期间不再读取:

function buildQueryConfig(): QueryConfig {
  // 在查询循环开始时,一次性读取所有配置
  return {
    model: getCurrentModel(),
    maxOutputTokens: getMaxOutputTokens(),
    contextWindow: getContextWindow(),
    autoCompactThreshold: calculateThreshold(),
    enableStreaming: getStreamingConfig(),
    // ...16+ 个配置字段
  }
}

async function* queryLoop(state, deps) {
  const config = buildQueryConfig()  // ← 快照!整个循环期间使用这个快照
  while (true) {
    // 循环内部只使用 config,不再调用 getCurrentModel() 等函数
    const response = await deps.callModel(state.messages, {
      model: config.model,           // 使用快照值
      maxOutputTokens: config.maxOutputTokens,
      // ...
    })
  }
}

快照的 Trade-off

优势

  • 确定性:同一 queryLoop 实例内,行为完全由入口时配置决定
  • 可复现性:记录快照配置值,就能精确复现该次查询行为
  • 可测试性:测试时只需构造 QueryConfig 对象,不需模拟环境变量和远程配置

代价

  • 延迟更新:紧急修改 Feature Flag 不会立即生效,需等到下次用户输入
  • 与 Feature Gate 不一致:Bun 的 feature() 门控要求代码在使用处调用,不能提前批量调用,导致部分配置走快照路径、部分走实时读取

设计哲学:将可复现性置于灵活性之上。对面向开发者的生产工具来说,"行为可预测"比"行为可热更新"更重要。这与数据库 Snapshot Isolation、React state batching、Git snapshot 文件系统遵循着同样的原则。


六、横向对比

维度 Claude Code LangChain/LangGraph OpenAI Assistants API
循环模式 while(true) + 隐式状态机 显式 Graph(节点+边) 服务端 Run 循环
配置策略 入口快照,循环内不可变 每次迭代重新读取 服务端统一管理
状态管理 函数式 {...state, ...updates} Graph 节点间传递状态 服务端 Thread
取消机制 AsyncGenerator.return() 无标准方案 Cancel Run API
流式输出 AsyncGenerator 天然支持 需回调/事件适配 服务端 SSE
复杂度门槛 懂 while 循环即可 需学习 Graph 概念 需理解 Run 生命周期

核心差异:Claude Code 追求"用最简结构表达最核心逻辑"。当状态转换路径线性时,简单就是优势——更少的概念、更少的间接层、更少的 bug。


七、实战启示

启示一:生命周期分离是第一直觉

遇到"同一个对象里既有长生命周期状态又有短生命周期状态"时,第一反应应该是拆分,而不是用标志位区分。QueryEngine vs query() 的分离告诉我们:当你开始纠结"这个状态该重置还是保留"时,就是拆分的信号

启示二:不要为未来可能不存在的复杂度引入框架

while(true) 看起来"粗糙",但它解决了眼前的问题。如果一开始就用 LangGraph 定义状态图,代码量翻3倍,但 Agent 循环的本质——线性执行——并没有因此变得更清晰。当转换路径以线性为主时,隐式状态机胜出

启示三:配置快照是长操作的安全网

不只是 Agent 系统——任何持续秒到分钟级的异步操作(ETL 管道、CI 流水线、交易策略回测),都应考虑在操作入口快照配置。行为一致性 > 配置实时性。

启示四:AsyncGenerator 是 Agent 系统的"语言级基础设施"

背压、取消、组合性——这三个特性让 AsyncGenerator 成为 Agent 查询循环的天然载体。如果你在设计 Agent 系统,先把流式输出的需求想清楚,再决定用 Generator、Stream、还是 EventEmitter。


下期预告

第04篇:流式、幂等、安全——QueryEngine 的工程保障三件套

本篇聚焦了 while(true) 的"为什么"和"怎么做"。但一个裸的循环只是骨架——真正让它可靠运行的,是四层压缩管线、分级错误恢复、窄依赖注入、子模块单一职责等工程保障。下篇拆解 3.6-3.13,看看 Claude Code 如何让"死循环"不崩。


思考题:如果 Claude Code 未来需要支持多 Agent 并发协调(一个 Agent 等待另一个 Agent 的输出),while(true) 隐式状态机是否仍然适用?在什么条件下应该切换到显式状态机?


📖 本系列基于《Claude Code 架构解密》精读整理,系列共20篇,本文为第03篇。
上一篇:第02篇 CLI工具的蜕变:启动流程与分布式路由架构解析
下一篇:第04篇 流式、幂等、安全:QueryEngine 的工程保障三件套(待发布)

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐