Agent Harness 源码拆解(三):Agent Loop——一个 while 循环,藏了所有要命的语义
主循环是 harness 里最”不值钱”也最”要命”的部分。不值钱,因为它看起来就一个 while;要命,因为中断、重试、插话、预算这些语义,全压在同一个 while 上。你写得再漂亮的分层,只要循环里有一个 if 没处理对,整个 agent 的行为就悄悄漂移。
这一章看六家怎么把那个 while 写对。答案比想象中更哲学:有人把它拆成两个循环,有人干脆不写循环函数而写一个队列,有人把”用户输入”和”执行”拆成两步。
pi:两个 while,因为两种打断是两种语义
pi 的核心循环是两个 while 套在一起,而不是一个。拆开的理由写得很明白:steering(用户中途插话)和 follow-up(agent 该停了才处理的排队任务)是两种语义完全不同的输入。
前者要在当前轮工具跑完后立刻注入,后者只在 agent 自然停下时才消费。拆成内外两层,把这两种时机写成代码结构本身,而不是在一个循环里堆 if 标志位。
还有一个更细的纪律:steering 只在当前轮工具批次完成后注入,从不打断执行中的工具调用。用户打字进来了,你不能让正在 rm 到一半的进程被打断——插话要排队等这批工具跑完。
代价是这套双 while 比单 while 复杂一截。pi 用它换的是”中断语义可读、可测”,每个队列的消费模式(all 还是 one-at-a-time)独立配置。
另外两个细节值得记:一是”失败兜底”——循环外炸了,pi 会合成一条 stopReason:"error" 的空 assistant 消息补齐事件尾巴,保证消费者永远看到完整合法的事件序列,不用为异常路径单独写 UI 分支。二是”截断即判死刑”——被输出 token 上限截断的消息里,所有 tool call 一律判失败,因为那些参数是 JSON 残缺补救出来的,”silently incomplete”,宁可不执行也不能执行半个参数。
ZCode:turn 不是一个函数,是一个队列
ZCode 最反直觉的地方:内核里没有 run_turn(input) 这种裸函数。公开入口 executeTurn 只做一件事——把本轮包成一条命令塞进命令队列,返回这条命令的 Promise,而不是同步跑完一轮。
这个队列结构换来三个直接收益:同 session 串行(一个 session 同一时刻只有一个前台执行)、可取消(排队期间 abort 直接撤单)、清理收口对称(成功、失败、取消,清理只在 finally 一处写一次)。
还有一个我印象深的细节:executeTurnCommand 在任何 await 之前,先”冻结本轮事实”——模型选择和输出样式在入口一次性读取。源码注释点明根因:过去切模型发生在异步初始化期间,会越过 admission 边界、错误影响已经开跑的 turn。把”本轮用哪个模型”固定在 admission 时刻,配置变更只作用于下一轮,一个 turn 的请求轨迹才能自洽可复现。
代价:ZCode 的一次 turn 是”命令队列 + while(true) 循环体 + 三层状态(相位机 / loop state / 消息历史)”的组合,抽象成本明显高于一个裸 while。相位机每个非法转移直接抛错,这套严谨是有学习成本的。
opencode:先落库,再执行
opencode 的循环做了一件别家没做彻底的事:用户输入的记录和模型的执行,是两个独立步骤。
用户 prompt 进来,先写进 session_input 表(一个 durable 收件箱),发布准入事件;执行器是收件箱的消费者,在”安全边界”把输入晋升为模型可见的历史。session_input 行里两个序号——admitted_seq 和 promoted_seq(没晋升就是 NULL)——把”准入”和”晋升”这两个时刻物化成了可查的数据。
这一拆,很多难题自动消失:重试天然幂等(同 ID 同内容返回同一回执);多客户端并发提交有序;steer/queue 语义有了清晰的物化点;崩溃后输入永不丢失(不依赖进程内存)。
代价:每次输入多一次落库,多一张表和两个索引。这套”意图先落库,执行器只消费”的形状,适合”用户意图 → 长时执行”的场景;如果你的 agent 都是秒回的短会话,这是可以省掉的成本。
CodeWhale:全仓库只有一个循环,而且预算不能撒谎
CodeWhale 的循环是 run_turn,8398 行,全 workspace 唯一。上一篇说过它为此删掉了占位引擎、还写了守护测试——这里补两个循环内部的纪律。
第一个是 steer 不能静默吞掉。用户在流式过程中插话,队列里的 pending_steer 要么在步骤边界提交进 turn 记录,要么丢弃——而丢弃一个必须报告 SteerOutcome::Dropped 给发送者,让被打断或失败的 turn 无法悄悄吞掉用户的引导(#6276)。这个细节的狠处在于:很多 harness 的插话丢失是静默的,用户发了一条指令,turn 崩了,指令也没了,谁都不知道。
第二个是预算诚实性:Hitting a budget is never a clean success. 步数或墙钟超限,终态是 Failed 并命名是哪个限额,而不是返回一个”看起来完成了”的成功。provider 没报用量就 usage_reported: false,不补零、不估算冒充实测。
grok-build:重试要有全局封顶
grok-build 对重试的贡献是一套三层预算:单步 ≤3、单 prompt ≤10、单 episode 墙钟 ≥10 分钟。它的教训原文是:”a partial outage multiplies retries by round count”——长回合里,如果每层重试预算都是局部的,一次部分故障会让重试次数按轮数乘法爆炸。所以必须有一个跨层的全局封顶。
配套的还有一个巧思:结构化输出是合成工具。后端不支持原生 JSON schema 约束时,harness 声明一个 StructuredOutput 假工具,模型”调用”它,参数不合规就推一条纠正性 tool_result 再采样,最多 3 次——用 harness 层补模型 API 的缺口,协议差异不出采样层。
Maka:逻辑步和物理重试,是两件事
Maka 的循环里有个容易被忽略的区分:逻辑步(logical step)和物理重试(physical retry)是两件事。一个”模型要调工具”的逻辑步,背后可能有多次物理重试;而 steering 的落点是 persist-before-include——先持久化,再纳入可见上下文。
这个区分配合上一篇的 T1/T2 副作用窗口,构成了 Maka 恢复语义的地基,留到持久化那章展开。这里只需要记住:循环里的”步”,不要和网络层的”重试”混为一谈,混了之后恢复逻辑就没法写。
它们共同的结论
六家的循环结构五花八门,但收敛到同一个判断:循环是语义核心,不是流程细节。中断、插话、重试、预算这些”边角情况”,恰恰是 agent 行为和普通程序最不一样的地方,必须显式建模,不能靠 if 堆、更不能靠”运行时再补”。
如果你要写这个循环
- 个人项目、想读透语义:学 pi 的双 while,把 steering 和 follow-up 拆成两层,中断只在工具批次边界注入。
- 要接多个前端、要可靠取消:学 ZCode,命令队列串行 admission,冻结本轮事实在入口。
- 长时执行、要崩溃恢复:学 opencode,先落库再执行,admission 与 promote 分离。
- 多人协作、要守住不变量:学 CodeWhale,唯一循环 + 守护测试 + 预算诚实性。
五个最容易踩的翻车点:
一是长出第二个循环。新前端来了,复制一份循环改改最快——语义分叉从这天开始(CodeWhale 用守护测试锁死)。
二是靠异常传错误。上游炸了异常穿透到循环,每层 try/catch(pi 的答案是失败编码成事件)。
三是插话静默丢失。用户插话 turn 崩了,指令没了没人知道(CodeWhale 让丢弃显式报告给发送者)。
四是重试没有全局封顶。局部重试预算在长回合里乘法爆炸(grok 的三层预算)。
五是把物理重试当逻辑步。恢复逻辑因此没法写(Maka 的区分)。
下一篇讲 上下文工程:窗口有限、历史无限长,怎么压缩又不丢真相——pi 的三级膨胀防线、opencode 的 Context Epoch、CodeWhale 的”缓存 miss 必须可命名”。
本篇术语
- turn:agent 主循环的一轮:发对话 → 模型要工具 → 执行回填 → 再发,直到模型终止
- steering:用户中途插话,引导方向(区别于 follow-up 的追加任务)
- follow-up:agent 自然停下后才消费的排队任务
- admission:用户输入先落库准入的动作;对应 opencode 里”准入”与”晋升”两个时刻的分离
- 相位机(phase machine):把一轮 turn 拆成有限状态(Idle → Streaming → ExecutingTools → Completing),非法转移直接报错
- 逻辑步 vs 物理重试:一个”模型要工具”的逻辑步背后可能多次物理重试,两者不能混为一谈
- 幂等(idempotent):同 ID 同内容重试返回同一回执,不产生副作用
参考资料
参照系仓库(结论基于 2026-09 下旬至 10 月上旬各仓库 HEAD):
- earendil-works/pi ——
packages/agent/src/agent-loop.ts双 while 与 steering 边界 - anomalyco/opencode ——
session/input.tsprompt admission 与session_input表 - xai-org/grok-build ——
sampler_turn.rs三层重试预算 - zai-org/ZCode ——
runtime-command-queue.ts命令队列与相位机 - apache/maka —— 逻辑步与物理重试分离
- Hmbown/CodeWhale ——
turn_loop.rs唯一循环、#6276steer 生命周期
Harness 设计方法论文献:
- Anthropic,《Agent Harness Design: 3 Patterns for Harnessing Claude’s Intelligence》
- Anthropic,《Building agents with the Claude Agent SDK》
- Anthropic,《Building effective agents》(2024-12)——“agent = LLM 在循环里自主用工具”的权威定义出处,也是“能简单就简单,只在不够用时才加复杂度”的防过度工程论述