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()),并把「这一轮为什么结束」和「这一轮为什么要再来一次」都做成了字面量联合类型。

这一章回答:

  1. query() 与 queryLoop() 为什么要分成两个 async generator;
  2. 跨迭代状态为什么要显式建模成一个 State 类型;
  3. 十个终止 reason 与七个继续 transition 分别对应什么代码路径;
  4. 「流式」在这个系统里其实有两层(模型层与工具层)。

一、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
}

三处设计意图都写在注释里:

状态字段各自在防什么,从代码位置可以读出来:

queryLoop 里还有一段值得注意的说明:taskBudgetRemaining 之所以必须跨压缩边界传递,是因为压缩之后服务端只看到摘要,会低估消耗;这个字段告诉服务端「被摘要掉的最终窗口有多大」。

三、一轮的八个阶段

while (true) 循环体(第 314 行起)按顺序做八件事:

  1. 重建查询消息视图:getMessagesAfterCompactBoundary(messages),然后 applyToolResultBudget(...) 施加单条 user message 的聚合结果预算。代码位置很讲究——它在 microcompact 之前,注释解释:cached microcompact 只按 tool_use_id 操作、从不看内容,所以内容替换对它不可见,两者可以正交组合。
  2. snip(feature('HISTORY_SNIP')):snipCompactIfNeeded(messagesForQuery),产出 tokensFreed,并可能 yield 一条 boundary message。
  3. microcompact:deps.microcompact(messages, toolUseContext, querySource)。它会返回可选的 pendingCacheEdits(feature('CACHED_MICROCOMPACT')),此时 boundary message 被推迟到 API 响应之后——因为要用真实的 cache_deleted_input_tokens。
  4. contextCollapse(feature('CONTEXT_COLLAPSE')):applyCollapsesIfNeeded(...)。注释说明它不 yield 任何东西——折叠视图是 REPL 全量历史之上的一次读时投影(read-time projection),摘要消息存在 collapse store 而不是 REPL 数组里;正是这一点让折叠能跨轮持续。
  5. autocompact:deps.autocompact(messages, toolUseContext, cacheSafeParams, querySource, tracking, snipTokensFreed)。snipTokensFreed 必须显式传入,因为 tokenCountWithEstimation 读的是受保护的尾部 assistant 的 usage,看不到 snip 省下的量。
  6. 构造 system prompt 并流式请求模型:asSystemPrompt(appendSystemContext(systemPrompt, systemContext)),然后进入 for await (const message of deps.callModel({ ... }))。
  7. 执行工具:走 streamingToolExecutor 或退化为 runTools(...)。
  8. 收尾与决策: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 块时就开跑:

是否启用这条路径由门控决定: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 章)。如果允许并行批立刻改上下文,后一个工具就能看到前一个工具尚未确定的中间态——那不是并行,而是竞态。

自测题

  1. query() 里那句 notifyCommandLifecycle(uuid, 'completed'),如果用 try/finally 实现会得到什么不同的语义?为什么作者选择「非对称信号」而不是「保证一定发 completed」?
  2. hasAttemptedReactiveCompact 一旦被错误重置就会造成无限循环。请描述这个循环的完整状态转移链,并说明 stop_hook_blocking 与 reactive_compact_retry 两个 transition 在其中各自扮演什么角色。
  3. budgetTracker 与 taskBudgetRemaining 都被刻意排除在 State 之外。这个选择在「测试要断言 token budget 恢复路径」时会带来什么困难?你会怎么改造?
  4. StreamingToolExecutor 用子 abort controller 杀兄弟进程而不中止整轮。请举一个「应该杀兄弟」和「不应该杀兄弟」的具体工具组合,并说明边界在哪里。
  5. 八阶段顺序是 applyToolResultBudget → snip → microcompact → contextCollapse → autocompact。请解释为什么 applyToolResultBudget 必须排在 microcompact 之前,以及如果把 autocompact 提到 microcompact 之前会发生什么。

进入 keel 阅读