KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 主循环 — keel 龙骨
Agent 循环是整个 Harness 的心脏,也是最容易被写成「一个 while + 一堆 if」的地方。Claude Code 2.1.88 的做法相反:它把这颗心拆成一层薄壳(query())和一个带显式状态对象的循环(queryLoop()),并把「这一轮为什么结束」和「这一轮为什么要再来一次」都做成了字面量联合类型。
Agent 循环是整个 Harness 的心脏,也是最容易被写成「一个 while + 一堆 if」的地方。Claude Code 2.1.88 的做法相反:它把这颗心拆成一层薄壳(query())和一个带显式状态对象的循环(queryLoop()),并把「这一轮为什么结束」和「这一轮为什么要再来一次」都做成了字面量联合类型。
这一章回答:
query()与queryLoop()为什么要分成两个 async generator;- 跨迭代状态为什么要显式建模成一个
State类型; - 十个终止 reason 与七个继续 transition 分别对应什么代码路径;
- 「流式」在这个系统里其实有两层(模型层与工具层)。
一、query() 是壳,queryLoop() 是心
query.ts: query()(第 219 行)只有二十来行,形状是:
export async function* query(params: QueryParams): AsyncGenerator<..., Terminal> {
const consumedCommandUuids: string[] = []
const terminal = yield* queryLoop(params, consumedCommandUuids)
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
return terminal
}
它的价值全在注释里:yield* 委托意味着 queryLoop 抛错时会穿透,.return() 关闭时会同时关掉两个 generator,因此那行 notifyCommandLifecycle(..., 'completed') 只在循环正常返回时执行。这给出一套非对称信号——「started 但没有 completed」就代表这一轮失败了。注释直言这与 print.ts 的 drainCommandQueue 是同一套语义。
Terminal 就是这个 generator 的 return 类型参数(AsyncGenerator<Yield, Terminal>),所以类型系统本身强制每个出口都要给出一个终止原因。
二、跨迭代状态为什么是一个显式 State
query.ts: State(第 204 行)是九个字段的显式类型:
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 // 上一轮为什么继续;首轮为 undefined
}
三处设计意图都写在注释里:
- 为什么不用散装变量:循环顶部统一解构,让循环体内可以直接读裸名(
messages、turnCount);而七个 continue 站点只需写state = { ... }而不是九次赋值。 transition为什么存在:注释说「Lets tests assert recovery paths fired without inspecting message contents」——让测试能断言「这轮走的是 max_output_tokens_recovery 路径」,而不必去翻消息内容。- 为什么有些状态故意不在 State 上:
budgetTracker(query/tokenBudget.ts: createBudgetTracker)和taskBudgetRemaining都是 loop-local 变量,注释解释得很直白——「Loop-local (not on State) to avoid touching the 7 continue sites」。
状态字段各自在防什么,从代码位置可以读出来:
maxOutputTokensRecoveryCount——受MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3约束,防止输出截断后无限重试;hasAttemptedReactiveCompact——注释记录了一个真实事故:某些地方把它重置为false导致了compact → 仍然 too long → error → stop hook blocking → compact的无限循环,「burning thousands of API calls」;stopHookActive——区分「第一次收到 Stop hook 的阻塞错误」与「重试后仍然被阻塞」;turnCount——初始为 1,maxTurns检查在循环尾部。
queryLoop 里还有一段值得注意的说明:taskBudgetRemaining 之所以必须跨压缩边界传递,是因为压缩之后服务端只看到摘要,会低估消耗;这个字段告诉服务端「被摘要掉的最终窗口有多大」。
三、一轮的八个阶段
while (true) 循环体(第 314 行起)按顺序做八件事:
- 重建查询消息视图:
getMessagesAfterCompactBoundary(messages),然后applyToolResultBudget(...)施加单条 user message 的聚合结果预算。代码位置很讲究——它在 microcompact 之前,注释解释:cached microcompact 只按tool_use_id操作、从不看内容,所以内容替换对它不可见,两者可以正交组合。 - snip(
feature('HISTORY_SNIP')):snipCompactIfNeeded(messagesForQuery),产出tokensFreed,并可能 yield 一条 boundary message。 - microcompact:
deps.microcompact(messages, toolUseContext, querySource)。它会返回可选的pendingCacheEdits(feature('CACHED_MICROCOMPACT')),此时 boundary message 被推迟到 API 响应之后——因为要用真实的cache_deleted_input_tokens。 - contextCollapse(
feature('CONTEXT_COLLAPSE')):applyCollapsesIfNeeded(...)。注释说明它不 yield 任何东西——折叠视图是 REPL 全量历史之上的一次读时投影(read-time projection),摘要消息存在 collapse store 而不是 REPL 数组里;正是这一点让折叠能跨轮持续。 - autocompact:
deps.autocompact(messages, toolUseContext, cacheSafeParams, querySource, tracking, snipTokensFreed)。snipTokensFreed必须显式传入,因为tokenCountWithEstimation读的是受保护的尾部 assistant 的 usage,看不到 snip 省下的量。 - 构造 system prompt 并流式请求模型:
asSystemPrompt(appendSystemContext(systemPrompt, systemContext)),然后进入for await (const message of deps.callModel({ ... }))。 - 执行工具:走
streamingToolExecutor或退化为runTools(...)。 - 收尾与决策:stop hooks(
query/stopHooks.ts: handleStopHooks)、工具摘要、token budget、maxTurns检查,然后决定return还是continue。
四、终止 reason 与继续 transition
十个终止 reason(全部在 query.ts 内以字面量形式出现):
| reason | 触发点(约) | 含义 |
|---|---|---|
completed |
1271 / 1364 | 正常收尾(含 token budget 判定结束) |
max_turns |
1718 | nextTurnCount > maxTurns |
aborted_tools |
1522 | 工具阶段被 abort |
aborted_streaming |
1058 | 模型流式阶段被 abort |
hook_stopped |
1527 | hook 要求停止继续 |
stop_hook_prevented |
1286 | Stop hook 判定不再继续 |
model_error |
1003 | 模型调用失败 |
image_error |
984 / 1182 | 图片/媒体尺寸问题 |
blocking_limit |
653 | 触达 blocking limit |
prompt_too_long |
1182 / 1189 | 上下文真的放不下 |
注意:第 1286 行的字面量是 stop_hook_prevented,不是 skip_hook_prevented。
七个继续 transition(写进 state.transition):
| transition | 含义 |
|---|---|
next_turn |
常规下一轮:把 assistant + toolResults 追加进 messages |
collapse_drain_retry |
contextCollapse 排空后重试(仅在 transition?.reason !== 'collapse_drain_retry' 时允许) |
reactive_compact_retry |
413/prompt-too-long 后的响应式压缩重试 |
max_output_tokens_escalate |
提升 max output tokens 上限 |
max_output_tokens_recovery |
输出截断后的恢复重试 |
stop_hook_blocking |
Stop hook 提出阻塞错误,把错误作为 user message 回灌 |
token_budget_continuation |
token budget 未用满,注入 nudge message 继续 |
为什么值得显式枚举:stop_hook_blocking 那个 continue 站点是最直接的证据。它的注释说明,重试时必须保留 hasAttemptedReactiveCompact,否则会进入前面提到的无限循环;如果状态是散装变量、transition 是布尔标志,这类「哪个字段必须被保留」的约束会变得不可读。
五、流式其实有两层
模型层的流式通过依赖注入实现。query/deps.ts: QueryDeps 只有四个依赖——这是刻意收窄过的:
export type QueryDeps = {
callModel: typeof queryModelWithStreaming // services/api/claude.js
microcompact: typeof microcompactMessages
autocompact: typeof autoCompactIfNeeded
uuid: () => string
}
注释解释了动机:最常见的 mock(callModel、autocompact)原本在 6–8 个测试文件里各自 spyOn 模块,收成 deps 后测试直接注入假实现。文件还留了一句「Scope is intentionally narrow (4 deps) to prove the pattern」——后续再扩到 runTools、handleStopHooks 等。
工具层的流式由 services/tools/StreamingToolExecutor.ts 承担,它在模型还在吐 tool_use 块时就开跑:
addTool(block, assistantMessage)每收到一个 tool_use 就入队并触发processQueue();canExecuteTool(isConcurrencySafe)的判据是「当前没有 executing 的工具」或「自己并发安全且所有 executing 的都是并发安全的」;- 结果被 buffer,注释承诺「Results are buffered and emitted in the order tools were received」;
- 内部持有
siblingAbortController,它是toolUseContext.abortController的子控制器:Bash 出错时用它立刻杀掉兄弟子进程,但注释强调「Aborting this does NOT abort the parent — query.ts won't end the turn」; discard()用于流式回退(fallback)——已入队的不再启动,进行中的收到合成错误。
是否启用这条路径由门控决定:const useStreamingToolExecution = config.gates.streamingToolExecution(query.ts: 568)。
六、两个恢复计数的细节
输出截断恢复:MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 定义在 query.ts 顶部,maxOutputTokensRecoveryCount < LIMIT 时才允许继续恢复。
token budget 续跑:判据在 query/tokenBudget.ts: checkTokenBudget。
COMPLETION_THRESHOLD = 0.9 // 用满 90% 预算才自然收尾
DIMINISHING_THRESHOLD = 500 // 增量不足 500 token 视为收益递减
isDiminishing = continuationCount >= 3
&& deltaSinceLastCheck < 500
&& lastDeltaTokens < 500
一个常见误读需要澄清:+500k 不是系统自动追加的预算,而是用户在 prompt 里手写的语法。utils/tokenBudget.ts 用三条正则识别它——SHORTHAND_START_RE(^\s*\+(\d+)\s*(k|m|b))、SHORTHAND_END_RE(结尾处)、VERBOSE_RE(use/spend 2M tokens)。parseTokenBudget 解析出数值,findTokenBudgetPositions 给输入框做高亮,getBudgetContinuationMessage 生成那句 Stopped at 90% of token target (x / y). Keep working — do not summarize.
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 薄壳与生命周期信号 | query.ts: query |
第 219 行;yield* queryLoop,正常返回才发 completed |
| 主循环 | query.ts: queryLoop |
第 241 行;while (true) 在第 314 行,出口在第 1735 行 |
| 跨迭代状态 | query.ts: State |
第 204 行;9 字段,其中 transition 为可选的 Continue |
| 终止原因 | query.ts: reason 字面量 |
10 个,含 stop_hook_prevented(注意不是 skip_hook_prevented) |
| 继续原因 | query.ts: transition 字面量 |
7 个,写入 state = { ... transition } 后 continue |
| 输出截断上限 | query.ts: MAX_OUTPUT_TOKENS_RECOVERY_LIMIT |
值为 3 |
| 依赖注入 | query/deps.ts: QueryDeps |
恰好 4 个依赖:callModel / microcompact / autocompact / uuid |
| token 预算判定 | query/tokenBudget.ts: checkTokenBudget |
COMPLETION_THRESHOLD 0.9、DIMINISHING_THRESHOLD 500、continuationCount≥3 |
| 预算语法解析 | utils/tokenBudget.ts: parseTokenBudget |
+500k 是用户书写语法,非自动追加 |
| Stop hook 收口 | query/stopHooks.ts: handleStopHooks |
返回 { blockingErrors, preventContinuation } |
| 流式工具执行器 | services/tools/StreamingToolExecutor.ts: StreamingToolExecutor |
按 tool_use 流式入队;结果按到达顺序 buffer |
| 兄弟中止控制器 | services/tools/StreamingToolExecutor.ts: siblingAbortController |
子控制器,杀兄弟进程但不结束本轮 |
| 并发门 | query.ts: useStreamingToolExecution |
第 568 行,取自 config.gates.streamingToolExecution |
| 工具执行回退路径 | services/tools/toolOrchestration.ts: runTools |
关闭流式执行时的替代入口 |
关键取舍
把循环拆成 query() + queryLoop(),代价是多一层 generator 转发。
好处是生命周期钩子(notifyCommandLifecycle)有了唯一的、语义正确的位置——只有在循环真正跑完时才触发。如果把这段逻辑塞进 queryLoop 的每个 return 点,很容易漏掉某条路径。
显式 State 让状态变更可审计,代价是每个 continue 站点都要重写整份状态。
七个 continue 站点无一例外写成 state = { messages: [...], toolUseContext, autoCompactTracking: tracking, maxOutputTokensRecoveryCount: 0, hasAttemptedReactiveCompact: false, ..., transition: { reason: 'xxx' } }。啰嗦,但正因如此「哪些字段在重试时该重置、哪些必须保留」才成为可 review 的代码——stop_hook_blocking 那段注释(保留 hasAttemptedReactiveCompact 以免无限循环)就是这种可读性的收益。
把 transition 存进 State 主要是为可测试性,代价是生产路径上多了一份数据。
注释说得很明确:让测试不必检查消息内容就能断言恢复路径。代价是 Continue 类型要跟着循环一起演进,且 State 里多了一个「只被测试读」的字段。
QueryDeps 只挑 4 个依赖,代价是注入覆盖不完整。
刻意收窄是为了「证明模式」并解决最痛的两处 mock 样板。剩下 runTools、handleStopHooks、logEvent 等仍需要模块级 spy,这意味着 query.ts 仍然难以完全脱离真实实现做单测。
流式工具执行并行启动,代价是结果顺序与副作用顺序解耦。
为了让「边流边跑」有意义,模型一吐 tool_use 就必须开跑;并行批的上下文修改因此只能延后到批次结束再按序 apply(见第 03 章)。如果允许并行批立刻改上下文,后一个工具就能看到前一个工具尚未确定的中间态——那不是并行,而是竞态。
自测题
query()里那句notifyCommandLifecycle(uuid, 'completed'),如果用try/finally实现会得到什么不同的语义?为什么作者选择「非对称信号」而不是「保证一定发 completed」?hasAttemptedReactiveCompact一旦被错误重置就会造成无限循环。请描述这个循环的完整状态转移链,并说明stop_hook_blocking与reactive_compact_retry两个 transition 在其中各自扮演什么角色。budgetTracker与taskBudgetRemaining都被刻意排除在State之外。这个选择在「测试要断言 token budget 恢复路径」时会带来什么困难?你会怎么改造?StreamingToolExecutor用子 abort controller 杀兄弟进程而不中止整轮。请举一个「应该杀兄弟」和「不应该杀兄弟」的具体工具组合,并说明边界在哪里。- 八阶段顺序是
applyToolResultBudget → snip → microcompact → contextCollapse → autocompact。请解释为什么applyToolResultBudget必须排在microcompact之前,以及如果把autocompact提到microcompact之前会发生什么。