写在开头:已经有好多年没写技术文章了,没想到这次回归,前端又一次“骄傲”地死去了。这回是也是迫不得已又得开始学习新技术了 -- 最近在研究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 的理由有三层:
- 增量 UI 更新 —
yield让引擎把思考过程、工具调用结果实时推送给 UI,用户知道 agent 在干活- 终态返回 —
return携带Terminal类型,完成 agentic 循环的闭合- 背压控制(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 需要同时做两件事:
- 向上游流式输出中间结果(模型 token、工具输出、状态事件),传递给负责渲染 UI 的任何层。
- 最终产出一个终止值,告诉调用方 循环为什么停下来了。
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(依赖注入)缝合点。在生产环境中它提供真实的 callModel、autocompact、microcompact 和 uuid 实现。在测试中,你可以注入 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 writestate = { ... }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;remainingtells 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 在循环迭代之间不会改变(用户的消息是一样的),所以没必要每回合都运行这个查询。
这里有两点值得注意:
using关键字。 这是 TC39 的 Explicit Resource Management(显式资源管理)提案(Symbol.dispose协议)。当 generator 退出时——无论是通过return、throw,还是消费者调用.return()——预取会被自动释放,取消任何进行中的网络调用并记录遥测数据。无需在每个退出点手动清理。- 发起后不阻塞,直到消费时才处理。 预取立即启动,但其结果直到第一次迭代的工具执行之后才被消费。那时候模型的流式传输(约 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。但这里有个微妙之处——toolUseContext 用 let 解构,因为它是唯一一个在单次迭代内会被重新赋值的字段(当 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 的架构可以通过五个设计决策来理解:
- Async generator(异步生成器) ——因为循环必须既能流式输出中间结果,又能产出一个带类型的终止值。Generator 契约就是公开的 API 边界。
- 不可变参数 + 可变状态对象——因为 7 个
continue站点需要一个单一的、可审查的地方来写入下一次迭代的状态,而不可变参数需要被证明是稳定的。 - 循环外的一次性初始化——因为 memory prefetch、config snapshot 和 budget tracker 是每个用户回合的不变量,而非每次模型调用的。
while (true)配合显式 return——因为循环有 10 多种可能的退出原因,每种都有类型标注,而继续路径是所有这些检查的隐式"else"。- 状态累积——每个回合的消息都会输入到下一个回合中,构成 agent 不断增长的上下文。各种 compaction 策略之所以存在,正是因为这种累积否则会撞上 API 的限制。
在下一章中,我们将深入上下文管道——在消息数组发送到模型 API 之前对其施加的一系列转换,以及五种不同的 compaction 策略如何组合起来将上下文控制在限制之内。
📝 学习笔记:状态机 vs 异步生成器
queryLoop 里有两个独立的概念容易混在一起:
- 状态机(State Machine) = 引擎的变速箱(内部、私有)
state对象在 turn 之间转换,通过transition.reason字段记录- consumer 看不到
state- 不管用什么迭代模式都需要状态机
- 异步生成器(Async Generator) = 引擎的排气管(外部、公开输出)
yield把事件/消息推给 consumer- 提供背压控制
- 这是选择 generator 模式的原因
状态不断变化是状态机的职责。持续产出中间结果是 async generator 的职责。前者是内部簿记,后者是外部通信。两者都在 queryLoop 里,但做的是不同的事。