KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 主循环:turn/step 的八步与三个反直觉约束 — keel 龙骨
主循环在 packages/core/agent-loop/src/agent.ts,647 行,一个类 ReactLoopAgent implements Agent。它的形状出人意料地小:
主循环在 packages/core/agent-loop/src/agent.ts,647 行,一个类 ReactLoopAgent implements Agent。它的形状出人意料地小:
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (_error) {
// Reported failures and cancellation are contained at the driver boundary.
} finally { /* 收尾相位 */ }
}
内核只有 while (await this.turn()) {} 一行。 turn 内部再套一层 while (true) 跑 step。这一章按这个嵌套结构讲清楚八步流程,然后重点讲三个违反直觉的约束——它们是这个循环真正难写的地方。
一、三层嵌套:driver / turn / step
turn() 返回布尔值:true 表示「本 turn 已闭合,但 inbox 里还有待处理输入,请再开一个 turn」,false 表示「停下来」。这个布尔值是 kick() 唯一的循环条件,这也意味着一个 turn 的结束不等于 driver 结束。
turn() 内部是 while (true) 循环 step。每一步的开头都会重新走一次 preStep,因此同一个 turn 里的每步都可能拿到不同的输入(target 在第一次之后固定为 'next-step')。
Phase 类型把相位显式建模成三态:
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
maintenance 是给压缩、fork 这类「不跑模型但必须独占 agent」的工作准备的:runMaintenance(job) 期间 send() 只能把唤醒请求存进 wakeRequested 挂起,等 job 结束再补发。这是**「不能边压缩边收新输入」这条约束的实现方式**。
二、turn() 的八步
| # | 动作 | 落点 |
|---|---|---|
| 1 | 检查相位是 running,读 phase.abort.signal,throwIfAborted() |
turn() 开头 |
| 2 | session.append('turn/start', { turn }) |
turn 边界的唯一开标记 |
| 3 | preStep(target, { turn, step }) → inbox 取消息 → 装配 system prompt → 投影 runtime context → agent/pre-step waterfall |
见下节 |
| 4 | reject → turnEnds = { kind: 'blocked' },直接 return false |
不写 step/start |
| 5 | session.append('step/start', { turn, step }),跑 step(),finally 里 session.append('step/end', ...) |
派生的每一步 |
| 6 | agent/turn-stopping(serial) |
只在 turnEnds 有值且 nextStep 为空时 |
| 7 | 若 turnEnds 有值且 nextStep 仍为空 → break |
step 循环出口 |
| 8 | finally 里 session.append('turn/end', { turn, reason }) |
保证每一轮都有闭合 |
turn/end 的 reason 是 TurnEndReasonMap 的成员:completed / aborted / blocked / error / max-tokens / interrupted / forked。后两个 loop 自己从不写——interrupted 由崩溃恢复写,forked 由 buildForkSeed 写。
第 6 步的 serial 是「顺序 await,遇到 bail 值就停」。一个监听器在这里调 agent.steer(msg),消息进 nextStep inbox,第 7 步的条件就不再成立,于是同一个 turn 再开一步。这就是「插件可以在收尾时追加一步」的机制。
max-tokens 是粘性的:if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd。任何一步撞到输出上限,整个 turn 的结论就是 max-tokens,后续正常完成的步骤不能把它降级。
三、preStep:输入、提示词与拦截点
preStep 做四件事,顺序很重要:
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall('agent/pre-step',
{ messages: claimed, ...position, signal },
() => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context] }))
inbox.claim 先从队列里拿走消息(因此被拦截的消息也不会回队),随后才装配提示词。默认行为是把投影出的 runtime context 作为最后一条 user message 追加在 claimed 之后;agent/pre-step 的 waterfall 可以拒绝({kind:'reject'} → turn/end: blocked)或整段改写 messages。
systemPrompt.assemble 是异步的,因为它要读文件(agent-instructions)。assemble 之后立刻 throwIfAborted(),避免在已经取消的 turn 上继续。
四、step() 的八步
step(decision) 接收 { messages, assembly, startsRequestSeries? },内部还是一个 while (true)——这个内层循环是重试用的,不是工具循环。
| # | 动作 | 关键点 |
|---|---|---|
| 1 | const renderedPrompt = renderPrompt(assembly) |
在重试循环外,只渲染一次 |
| 2 | prepareRequest(turn, step, signal) |
折叠 requestHeader() 得到 seed config,跑 agent/request waterfall,再 llm.prepareCall |
| 3 | systemPrompt.project(...) → 逐条 session.append('system/message', ..., intent) |
首个 commit 是 surface node 0 |
| 4 | 仅首次尝试:session.append('user/message', message, { surfaceOp: 'append' }) |
重试不重复追加 |
| 5 | buildRequest(...) |
写 request/header(initial/resume/change/series)与 request/context;session.deriveMessages() 得到 frozen messages |
| 6 | preparedCall?.stream(request) ?? ctx.llm.stream(request),逐 chunk live.push(chunk) |
走 AssistantStreamAttempt |
| 7 | 结束:assistant/message(带 stream 与 usage)或 assistant/attempt(无 surface 消息) |
失败/取消各有分支 |
| 8 | message.content.filter(b => b.type === 'tool-call');空 → {kind:'completed'},否则 executeToolCalls(...) |
工具阶段 |
buildRequest 里有两个不显眼但重要的细节。其一,request/header 只在四种情况下追加:initial(log 里第一次)、resume(本 loop 首次请求但 log 已有 header)、change(header 与基线不等)、series(header 相同但显式开启新 series)。其二,boundaryMessages 只对首次见到的 message 调 deepFreeze,用 frozenMessages: WeakSet 记账——因为同一个 message 对象会在多次请求里被复用,重复 deepFreeze 是纯浪费。
五、反直觉之一:没有 maxSteps / max-turns
全仓库 grep maxSteps / maxTurns / MAX_STEPS / MAX_TURNS,在 packages/ 下只命中 subagent-claude-code(那是上游 Claude Agent SDK 自己的 error_max_turns),dsh 自己的循环里没有任何步数计数器。
停止只靠四种机制:
concludesTurn:executeToolCalls返回的concluded为真 →step()返回{kind:'completed'}→ turn 结束。preStepreject:agent/pre-step的 waterfall 不调next()或显式返回 reject。- abort:
signal.throwIfAborted()在agent.ts里出现 15 次,每次都是合法的退出点。 - error:
turn()的 catch 把任何异常转成{kind:'error', error: LlmError.failure 或 {message: errorChain(error), code:'UNKNOWN'}}后 rethrow。
设计意图是「循环长度由任务语义决定,不由计数器决定」。代价是:一个不收敛的模型会一直跑到上下文被压缩机制接管,或者用户手动取消。把预算交给 guard/timeout-policy、compaction 和 provider 侧的 maxTokens,而不是一个任意常数。
六、反直觉之二:dispatch 可重叠,提交严格按 model order
工具并发在 tool-calls.ts。调度器同时维护两个指针:
const slots: (Slot | undefined)[] = group.map(() => undefined)
let committed = 0 // 只跨连续槽位前进
const commitReady = async () => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break // 前一个还没落地,谁都不能提交
// finalize/finish → appendToolResult → acceptContext → concluded
committed++
}
}
startCall(index) 把结果写进 slots[index],commitReady() 只能从 committed 游标起连续地往前推。结果是:第 3 个工具可能先于第 1 个跑完,但它的 tool/result 一定在第 1、2 个之后才可能被写入日志。 exclusive 模式自成 barrier(const group = mode === 'parallel' ? planned.slice(next) : [first],非并行只取一个),并且必须在 commit() 完成之后才解除——注释专门说明「The barrier covers post-execute」。
并发上限来自 ctx.agentLoop.config.maxParallelToolCalls,schema 默认值是 constants.ts: DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10。
fillPool() 每轮都会重新读一次 ctx.tools.executionMode(nextCall.exec):
if (nextToStart > 0 && mode === 'parallel'
&& ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
这处理的是运行期注册表变化:一个并行批跑到一半,某个插件把后面的工具改成了 exclusive,那么当前位置就要断开,形成一道新 barrier。executionMode 本身也 fail-closed——只有 isConcurrencySafe(args) === true 才算 parallel,未知/未声明/抛异常一律 exclusive。
七、反直觉之三:abort 要给没跑的调用补合成结果
取消时最容易出的问题是 replay 不合法:模型发了 5 个 tool-call,日志里只有 3 个 tool/result,重建历史时这段就成了断的。
runGroup 的处理是:
if (aborted) {
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
return { consumed: group.length, aborted: true, concluded }
}
appendSkippedToolCall 会为每个「已排入模型消息但没启动」的调用成对写入 tool/call + tool/result,错误码是 TOOL_ABORTED_BEFORE_DISPATCH(其字符串值是 'ABORTED_BEFORE_DISPATCH')。注意成对:tool/result 通过 sourceEventSeqs: [callSeq] 引用刚写的 tool/call 事件,保持 surface 引用完整。
上游 executeToolCalls 对 abort 还有一层:整个 step 的剩余调用(还没进任何 group 的)也要补。注释写得很清楚——「Abort records synthetic error results for skipped calls so replay stays valid. A terminal scheduler failure preserves already-recorded tool/call events without fabricating results.」,即调度器故障与用户取消的语义不同:前者不伪造结果,后者必须伪造。
agent.ts 侧对已启动但被中断的流另有一套补写:如果 live.interruptedBlocks() 非空,就写一条 assistant/message 带 interrupted: true;否则写 assistant/attempt。前者保留模型已经吐出来的文字,后者表示「这一轮没有任何可见内容」。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 驱动外壳 | packages/core/agent-loop/src/agent.ts: ReactLoopAgent.kick |
while (await this.turn()) {},catch 里吞掉已上报的失败 |
| 相位 | packages/core/agent-loop/src/agent.ts: Phase |
idle / maintenance / running 三态 |
| turn 边界 | packages/core/agent-loop/src/agent.ts: ReactLoopAgent.turn |
写 turn/start 与 turn/end,返回「还要不要下一 turn」 |
| 步前装配 | packages/core/agent-loop/src/agent.ts: ReactLoopAgent.preStep |
inbox.claim → systemPrompt.assemble → agent/pre-step waterfall |
| 单步执行 | packages/core/agent-loop/src/agent.ts: ReactLoopAgent.step |
内层 while (true) 是重试循环,不是工具循环 |
| 请求提案 | packages/core/agent-loop/src/agent.ts: ReactLoopAgent.prepareRequest |
agent/request waterfall → llm.prepareCall |
| 请求冻结 | packages/core/agent-loop/src/agent.ts: ReactLoopAgent.buildRequest |
写 request/header/request/context;frozenMessages 去重冻结 |
| 取消原因收敛 | packages/core/agent-loop/src/agent.ts: abortedCancelCause |
只复制 turn/end 需要的字段,避免把 Node fetch 的 stack 写进日志 |
| 工具调度入口 | packages/core/agent-loop/src/tool-calls.ts: executeToolCalls |
按 live executionMode 切 group |
| 组内调度 | packages/core/agent-loop/src/tool-calls.ts: runGroup |
slots + committed 游标 |
| 顺序提交 | packages/core/agent-loop/src/tool-calls.ts: commitReady |
只跨连续 model-order 槽位推进 |
| 补池与重分类 | packages/core/agent-loop/src/tool-calls.ts: fillPool |
每轮重读 executionMode,非 parallel 即断批 |
| 中断补写 | packages/core/agent-loop/src/tool-calls.ts: appendSkippedToolCall |
成对写 tool/call + 合成错误 tool/result |
| 并发上限 | packages/core/agent-loop/src/constants.ts: DEFAULT_MAX_PARALLEL_TOOL_CALLS |
值 10 |
| 输入队列 | packages/core/agent-loop/src/inbox.ts: ReactLoopInbox |
claim(target, turn) / splice / hasPending |
| 流式尝试 | packages/core/agent-loop/src/assistant-stream.ts: AssistantStreamAttempt |
push / settle / interruptedBlocks / replayState |
| 服务注册 | packages/core/agent-loop/src/index.ts: AgentLoop |
第 355 行 super(ctx, 'agentLoop'),第 369 行 setFactory |
关键取舍
没有 maxSteps 计数器,代价是循环长度不受上界保护。
好处是「跑多久」由工具结果和 concludesTurn 决定,不会出现「还剩 1 步但任务没做完」的截断。代价是不收敛的 agent 只能靠 abort、压缩、或用户取消停下来;一个死循环的工具调用序列会被 guard/repeat-tool-reminder 提醒,但不会被强制终止。
dispatch 重叠但提交按 model order,代价是内存里要保留已完成但未提交的结果。slots 数组持有全部已落地结果直到 committed 追上。好处是模型看到的历史顺序与它发出的顺序完全一致(这是 replay 与缓存命中的前提)。代价是一个慢的第 1 个工具会让后面 9 个已完成的结果一直占着槽位。
abort 时伪造工具结果,代价是日志里出现「模型没见到过」的错误。TOOL_ABORTED_BEFORE_DISPATCH 的结果并不来自真实执行,是 loop 构造的。好处是 replay 时每个 tool-call 都有配对结果,重建不出错。代价是这类合成结果必须是可识别的(错误码固定、文案固定),否则会与真实失败混淆。
Phase 显式建模 maintenance,代价是 send() 要处理挂起语义。
压缩和 fork 需要独占 agent 却不能算作运行中的 turn。把它们单独成一个相位,让 wakeDriver 有地方挂 wakeRequested。代价是 send()/cancel()/wakeDriver 三处都要判断当前相位,且 runMaintenance 在非 idle 时直接抛。
自测题
kick()的while (await this.turn()) {}只有一行。请说明turn()在什么情况下返回true、什么情况下返回false,以及为什么kick的finally里还需要再判一次wakeRequested。agent/turn-stopping用的是serial而不是waterfall。如果一个监听器调了agent.steer(msg),从dispatch到循环继续,完整的状态转移是什么?inbox.nextStep在其中扮演什么角色?commitReady只能从committed游标连续推进。假设模型一次发了 4 个并行安全的调用,第 4 个先完成而第 1 个卡住,此时日志里已经写了几条tool/call?几条tool/result?为什么可以这样?appendSkippedToolCall会为未启动的调用伪造错误结果,但调度器内部故障时不会。请说明这个区别在 replay 语义上为什么必要,并给出一个「如果两者都不伪造」会破坏的场景。fillPool()在每轮都会重读executionMode。如果某个插件在第 2 个调用 dispatch 期间把第 3 个调用从 parallel 改成 exclusive,会发生什么?如果改成相反方向(exclusive → parallel)又会发生什么?