zcode源码解析 Day6:Agent Loop 的具体实现
本文是 zcode 源码学习系列第 6 篇。前面几篇都在看"agent 用什么干活"(子代理、工作流、通信),这篇回到心脏:Agent Loop 本身——用户敲一句话之后,程序内部到底是怎么一轮一轮转起来的。答案的骨架是三个文件:
turn-state.ts定义规则,turn-machine.ts执行规则,runtime/methods/里的五个文件真正推动循环。读完你会得到一张 10 状态的转换图、一份"驱动链"调用地图,和三个只看类型永远发现不了的"考古"真相。
一、先把三个词掰开:session、turn、model step
读这套代码最容易被 turn 这个词绊住,先把时间尺度立起来:
Session(会话)────一次完整对话,含多个 turn──── └─ Turn 1(你问:"总结三个文件") ← turnNumber = 1 ├─ 模型往返①:决定调 3 个 Read 工具 ├─ 模型往返②:看完结果,给出最终回答 └─ 回合结束 └─ Turn 2(你追问:"第二个文件什么意思?") ← turnNumber = 2- Session:整场对话,消息历史跨 turn 累积;
- Turn(回合):从"用户发一条消息"到"agent 给出最终回答"的完整工作单元——中间可能包含好几次模型调用和工具执行;
- Model step(模型往返):turn 内部的一次"发请求 → 收流式响应 → 跑工具 → 汇总"。
一个类比:session 是对话,turn 是回合,model step 是回合里的一次出拳。本文的状态机管的是"回合"这一层。
二、三层结构:词汇表、执法者、驱动者
Agent Loop 的实现刻意拆成了三层,职责分明:
┌────────────────────────────────────────────────────────┐ │ 第 1 层 · 状态定义(词汇表) agent/turn-state.ts │ │ 10 个 TurnPhase + 合法转换表 + 各种子状态类型 │ ├────────────────────────────────────────────────────────┤ │ 第 2 层 · 状态机(执法者) agent/turn-machine.ts │ │ TurnMachineImpl:每次迁移先查转换表,非法即抛错 │ ├────────────────────────────────────────────────────────┤ │ 第 3 层 · 真实驱动(实际运转) runtime/methods/ │ │ turn.ts → turn-loop.ts → turn-model-step.ts │ │ → turn-tools.ts → turn-stop.ts │ └────────────────────────────────────────────────────────┘第 1 层是纯类型加纯函数,没有任何驱动逻辑;第 2 层只做"校验 + 拷贝出新状态";真正干活的循环在第 3 层。这个分层带来一个非常重要的读码心法,本文第九节会展开:词汇表 ≠ 实际用法——状态机"能表达"和驱动代码"实际使用"之间有缝隙,而缝隙里藏着架构演进史。
三、10 个阶段和那张转换表
turn-state.ts用const 对象 + 派生联合类型(而不是 enum)定义了 10 个阶段:
exportconstTurnPhase={Idle:"idle",// 出生态ProcessingInput:"processing_input",// 消化输入、组装上下文AwaitingModelResponse:"awaiting_model_response",// 已发请求等首字节Streaming:"streaming",// 流式响应到达SchedulingTools:"scheduling_tools",// 排工具执行计划ExecutingTools:"executing_tools",// 工具执行中AggregatingResults:"aggregating_results",// 步骤收尾、汇总AwaitingPermission:"awaiting_permission",// 等权限审批(伏笔见第九节)Completing:"completing",// 终态①:正常结束Error:"error",// 终态②:异常结束}asconst;真正的心脏是文件末尾的canTransitionTo——一张Record的合法转换表。把它画成有向图后,最扎眼的是这条边:
[TurnPhase.AggregatingResults]:[TurnPhase.AwaitingModelResponse,// ★ 唯一的回环边TurnPhase.SchedulingTools,TurnPhase.Completing,TurnPhase.Error,],aggregating_results → awaiting_model_response是整张图唯一的回环边。工具结果汇总后,带着新历史再问一次模型——Agent Loop 之所以是"循环"而不是"流水线",全靠这条边。其余值得记住的规则:awaiting_permission 没有退路(只能去 executing_tools 或 error);两个终态只能复位回 idle;error 可以从大多数工作阶段直接进入。
四、不可变状态机:每次转换都换个新对象
TurnMachineImpl有个反直觉的设计:它从不原地修改state。每个操作返回一个全新的TurnState,调用方负责"落地":
// runtime/methods/turn-tools.ts:146state.turnMachine=newTurnMachineImpl(state.turnMachine.scheduleTools(coreToolCalls,this.toScheduleState(schedule)),);为什么这么绕?两个直接好处:
- 抛错不毁现场:非法转换在
transition()里查表直接抛CoreError(InvalidTurnPhase),因为旧 state 从未被改过,失败后机器完好无损; - 快照即留档:任何时刻把 state 存下来都不会被后续操作污染,这对事件溯源、回放、调试都是天然友好。
配套的还有completeTool的设计——工具完成时只更新 toolCalls/toolResults,不改 phase:
completeTool(toolCallId,result){constupdatedToolCalls=this.state.toolCalls.map((tc)=>tc.id===toolCallId?{...tc,status:result.success?"completed":"failed",...}:tc,);return{...this.state,toolCalls:updatedToolCalls,toolResults:[...]};}这让"同批 3 个工具并发执行、乱序完成"变得毫无压力:完成顺序不重要,phase 在整批工具跑完后才由aggregateResults()推进一次。
五、真实驱动链:while(true) 里的一个循环体
状态机自己不会动。谁在推它?把turnMachine.的全部调用点 grep 出来,就得到这张驱动地图:
| 驱动文件 | 行号 | 调用 | 时机 |
|---|---|---|---|
| turn.ts | 124 | TurnMachineImpl.create(...) | 回合开始,phase=idle |
| turn.ts | 279 | .start() | → processing_input |
| turn-loop.ts | 188 | .startModelRequest(model, messages) | 每次模型往返开始 |
| turn-model-step.ts | 630 | .receiveModelResponse(...) | 模型响应落地 → streaming |
| turn-tools.ts | 147 | .scheduleTools(calls, schedule) | 响应里有工具调用 |
| turn-tools.ts | 153 | .startToolExecution() | → executing_tools |
| turn-tools.ts | 262 | .completeTool(id, result) | 每个工具完成时回报 |
| turn-tools.ts | 269 | .aggregateResults() | 工具批次收尾 |
| turn-stop.ts | 190 | .complete(response, "success") | 文本收尾 → completing |
发动机是turn-loop.ts的runRegularTurnLoop,文件开头就是:
while(true){throwIfTurnAborted(state.turnAbortSignal);// ← 每个关键节点前都有这一句...}循环体每个 iteration 干的事:检查中断 → 按需压缩上下文(microcompact/autoCompact)→ 初始化 MCP 和工具 → 发模型请求 → 流式接收 → 有工具就排程执行 → 汇总结果 → 回到循环顶部。对应到状态机,就是那段会重复出现的序列。
"模型连续调 3 个工具"的完整答案(同一响应里 3 个工具,实测验证):
idle → processing_input → awaiting_model_response → streaming → scheduling_tools → executing_tools → aggregating_results → awaiting_model_response → streaming → completing如果是 3 轮串行(每轮 1 个工具),则是循环体awaiting → streaming → scheduling → executing → aggregating重复 3 次,最后一次纯文本收尾。
六、停止条件:一轮怎么才算完
从代码归纳,turn 的结束有五条路:
| 停止条件 | 代码位置 | resultType |
|---|---|---|
| 模型纯文本收尾(没有再要工具) | turn-stop.ts 的finishModelStepWithoutToolCalls | success |
| Stop hook 要求继续(收尾被"续命") | 同上,aggregateResults()后 return “continue” | (不结束) |
| 用户中断 | 循环各处throwIfTurnAborted→ 外层 catch | cancelled |
| 撞上限(轮数/预算/工具数) | TurnResultType的 error_max_* | error_max_* |
| 执行中异常 | fail() | error_during_execution |
两个容易被忽略的细节:
其一,用户中断算正常结束。TurnResultType里 cancelled 的注释写得明白:用户主动中断属于正常结束,复用 TurnComplete 上报而非 TurnError。所以中断不会走 error 相位,而是外层 catch 之后以complete(..., "cancelled")收尾。
其二,fail()绕过了转换表。它直接赋值phase: Error,不经过canTransitionTo校验——因为错误可能发生在任何状态,转换表没法穷举"任意 → error"。这是规则引擎里常见的"逃生门"。
其三,Stop hook 能让"结束"变成"继续"。文本收尾前会跑一次 Stop hook,如果 hook 返回"继续",机器从收尾点折返aggregateResults(),回合延长——同一个 product turn 可以被 hook 续命多次。
七、中断:不是一个状态,而是一根随时绷断的线
看转换表你会以为中断是某个 phase,其实不是。实现形态是:turn.ts用createTurnAbortScope(options?.abortSignal)造出本轮的 abort 信号,然后runRegularTurnLoop在每个关键节点前调用throwIfTurnAborted(state.turnAbortSignal)——用户按 Esc 就是拉响这根线,循环在下最近的检查点抛出异常,穿透所有层被外层 catch 接住,统一以 cancelled 收尾。
工具执行中中断同理。turn-tools.ts里有一段注释专门解释:assistant 的 tool_use 声明已经进了历史,Stop 不能在 tool result 创建前直接抛,而是把 aborted signal 交给 executor,由现有取消路径给每个 tool call 生成 ToolCancelled result,再由循环感知 abort——保证历史账本永远配平(有声明必有回执)。
八、动手验证:37 个断言的状态机实验
这套机制完全可以脱离模型做单元级验证。我写了一个 400 行的实验脚本(packages/core/scratch/day2-turn-machine-lab.ts),自带微型断言框架,把TurnMachineImpl的驱动方式照真实 runtime 的写法复刻一遍:
letm=TurnMachineImpl.create(sid("session-1"),1,"看三个文件");m=newTurnMachineImpl(m.start());// → processing_inputm=newTurnMachineImpl(m.startModelRequest("test-model",MSGS));// → awaiting_model_responsem=newTurnMachineImpl(m.receiveModelResponse("我来读取这三个文件。"));// → streamingm=newTurnMachineImpl(m.scheduleTools(calls,schedule));// → scheduling_toolsm=newTurnMachineImpl(m.startToolExecution());// → executing_toolsfor(constidof["t2","t3","t1"]){// 乱序完成,phase 不动m=newTurnMachineImpl(m.completeTool(tid(id),{success:true,content:[...]}));}10 组用例共 37 个断言,30 秒跑完:并行 3 工具的完整序列、串行 3 轮的循环体、纯文本直通车、非法转换被拒(且旧状态完好)、权限分支、不可变性、cancelled 收尾……全部通过。比起在 TUI 里加日志盲猜,先把状态机当"纯函数"喂参数,是理解它最快的方式。
九、词汇表 ≠ 实际用法:三个考古发现
这是本文最想传达的读码方法论。三个发现全部可以用 grep 复现:
发现一:getNextPhase()全仓库零调用。这个"计算下一步该去哪"的咨询函数,除了接口定义和实现,没有任何调用者。它是预留的 advisers,当前驱动层根本不用。
发现二:状态机的权限词汇是摆设。turnMachine.requestPermission / resolvePermission无人调用——真实的权限审批在工具执行器内部的 permissionBroker(tool/executor/permission-flow.ts、runtime/helpers/permission-broker.ts)里完成。也就是说,AwaitingPermission 这个 phase 在主循环里永远不会出现:需要用户确认时,状态机原地停在 executing_tools,等待发生在更深的执行层。
发现三:pendingInputs 没人用。用户"插话"(steering)在词汇表里是queuePendingInput / drainPendingInputs,但实际走的是 runtime 层的ActiveTurnSteeringState——插话在下一个模型往返起点被 drain 进请求,而不是存进 turn state。
三个发现指向同一个结论:turn-state 是"宪法",runtime 是"实际政治"。状态机先被设计出来,权限和插话后来下沉/外移到了更合适的层,词汇表里留下了演化的化石(同款化石还有一个:acceptsPendingInput初始为 true,全仓库没有任何代码把它翻成 false)。读架构代码,类型只能告诉你"能说什么",调用点才告诉你"实际说什么"。
十、总结
把 Agent Loop 的实现要点收拢成一段话:用户一句话开启一个 turn;turn-loop 的 while(true) 每圈完成一次模型往返;模型要工具就排程执行、乱序回报、汇总后从唯一的回环边折返再问模型;纯文本回答、用户中断或撞上限时回合终结。整个过程的合法性由一张 10 状态的转换表守卫,状态以不可变方式更新,中断是一根随时绷断的线而不是一个状态,权限和插话则在词汇表之外自成体系。
下一篇打算顺着数据流往下走:模型到底"看见"了什么——消息历史的结构、上下文窗口的组装、以及重开会话时的水合(hydration)机制。
系列回顾:Day 1 动态 Subagent 与 AIMD 并发治理器 / Day 2 子代理文件冲突 / Day 3 父子代理通信 / Day 4 Subagent 与 Actor 的区别