云计算百科
云计算领域专业知识百科平台

zcode源码解析 Day6:Agent Loop 的具体实现

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 层。这个分层带来一个非常重要的读码心法,本文第九节会展开:词汇表 ≠ 实际用法——状态机"能表达"和驱动代码"实际使用"之间有缝隙,而缝隙里藏着架构演进史。

图1:Agent Loop 三层结构——词汇表定义规则、执法者校验规则、驱动者推动循环

三、10 个阶段和那张转换表

turn-state.ts 用 const 对象 + 派生联合类型(而不是 enum)定义了 10 个阶段:

export const TurnPhase = {
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", // 终态②:异常结束
} as const;

真正的心脏是文件末尾的 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 可以从大多数工作阶段直接进入。

图2:10 状态转换有向图,高亮唯一回环边 aggregating_results → awaiting_model_response

四、不可变状态机:每次转换都换个新对象

TurnMachineImpl 有个反直觉的设计:它从不原地修改 state。每个操作返回一个全新的 TurnState,调用方负责"落地":

// runtime/methods/turn-tools.ts:146
state.turnMachine = new TurnMachineImpl(
state.turnMachine.scheduleTools(coreToolCalls, this.toScheduleState(schedule)),
);

为什么这么绕?两个直接好处:

  • 抛错不毁现场:非法转换在 transition() 里查表直接抛 CoreError(InvalidTurnPhase),因为旧 state 从未被改过,失败后机器完好无损;
  • 快照即留档:任何时刻把 state 存下来都不会被后续操作污染,这对事件溯源、回放、调试都是天然友好。
  • 配套的还有 completeTool 的设计——工具完成时只更新 toolCalls/toolResults,不改 phase:

    completeTool(toolCallId, result) {
    const updatedToolCalls = this.state.toolCalls.map((tc) =>
    tc.id === toolCallId ? { …tc, status: result.success ? "completed" : "failed", … } : tc,
    );
    return { …this.state, toolCalls: updatedToolCalls, toolResults: […] };
    }

    这让"同批 3 个工具并发执行、乱序完成"变得毫无压力:完成顺序不重要,phase 在整批工具跑完后才由 aggregateResults() 推进一次。

    图3:不可变更新示意——旧 state 对象保持不变,方法返回全新 state,调用方重新包装

    五、真实驱动链: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 次,最后一次纯文本收尾。

    图4:3 工具回合的时间线——上方是 turn 的 phase 轨迹条,下方对齐模型往返①/工具执行/模型往返②的泳道

    六、停止条件:一轮怎么才算完

    从代码归纳,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 的写法复刻一遍:

    let m = TurnMachineImpl.create(sid("session-1"), 1, "看三个文件");
    m = new TurnMachineImpl(m.start()); // → processing_input
    m = new TurnMachineImpl(m.startModelRequest("test-model", MSGS)); // → awaiting_model_response
    m = new TurnMachineImpl(m.receiveModelResponse("我来读取这三个文件。"));// → streaming
    m = new TurnMachineImpl(m.scheduleTools(calls, schedule)); // → scheduling_tools
    m = new TurnMachineImpl(m.startToolExecution()); // → executing_tools
    for (const id of ["t2", "t3", "t1"]) { // 乱序完成,phase 不动
    m = new TurnMachineImpl(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)。读架构代码,类型只能告诉你"能说什么",调用点才告诉你"实际说什么"。

    图5:词汇表 vs 实际用法对照——左列 turn-state 定义的能力,右列真实路径,中间是"断层"标记

    十、总结

    把 Agent Loop 的实现要点收拢成一段话:用户一句话开启一个 turn;turn-loop 的 while(true) 每圈完成一次模型往返;模型要工具就排程执行、乱序回报、汇总后从唯一的回环边折返再问模型;纯文本回答、用户中断或撞上限时回合终结。整个过程的合法性由一张 10 状态的转换表守卫,状态以不可变方式更新,中断是一根随时绷断的线而不是一个状态,权限和插话则在词汇表之外自成体系。

    下一篇打算顺着数据流往下走:模型到底"看见"了什么——消息历史的结构、上下文窗口的组装、以及重开会话时的水合(hydration)机制。

    系列回顾:Day 1 动态 Subagent 与 AIMD 并发治理器 / Day 2 子代理文件冲突 / Day 3 父子代理通信 / Day 4 Subagent 与 Actor 的区别

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » zcode源码解析 Day6:Agent Loop 的具体实现
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!