死循环里的优雅:QueryEngine 的 while(true) 状态机与原子操作
死循环里的优雅: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 开始 → ...
如果混在一个对象里,两个头疼的问题:
- 重置的范围模糊:用户中断后重新输入,哪些状态该重置、哪些该保留?遗漏一个就是 bug
- 复用的粒度不匹配: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) 的安全保证
五层保护确保不会真正无限循环:
- maxTurns 限制:配置参数限制最大循环轮次
- Token Budget 检查:每轮结束后检查累计 token 是否超预算
- 错误恢复上限:每种错误类型都有最大恢复次数(如 Max Output Tokens 最多3次)
- LLM 的自然终止:如果 LLM 不返回工具调用,循环自然结束
- 外部中断: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 的工程保障三件套(待发布)
更多推荐




所有评论(0)