Zero 架构 · 第四课

护栏:循环对模型的「不信任」

第一课的循环有个 maxTurns = 12 的天花板 —— 那是最后的硬止损。但一个只靠天花板 兜底的 agent 会很糟:模型可能连着 12 回合空转、或对同一个坏调用反复撞墙、或一直只调工具 从不产出结论。护栏(guardState)就是循环用来提前识别这些失控 模式的一组有界计数器。

对齐 mission:一个真实的 coding agent,一半复杂度不在「让它能干活」,而在「让它别乱干」。 这一课解释 Zero 怎么把「模型可能不听话」这个前提,编码成具体的停止与提醒规则。

核心洞察:循环假设模型会犯错

护栏状态挂在每次运行开头(newGuardState),在第一课循环的两个点被喂数据: 每个回合后 observeTurn、每个工具结果后 observeToolResultguardrails.go:353 它把失控分成两类处理:

硬停:两道熔断

① 连续空回合 → 停

一个回合既无可见文本、又无工具调用就是「空回合」。连续 maxEmptyTurns = 3 次就停止运行,以免在撞到 maxTurns 之前白烧回合。 任何一次有产出的回合都会把计数器清零guardrails.go:19guardrails.go:414

② 同一错误重复失败 → 先提示,再停

这是最精妙的一道。observeToolResult工具名 + 错误签名记录 连续失败次数(errorSignature 把错误输出归一成一个签名, guardrails.go:252)。它是一个升级阶梯: guardrails.go:381

为什么要「同一错误」 只有相同签名的连续失败才累加。第二课讲过:重复失败提示只对「参数/格式写错」这种 可重试错误有意义;换个错误说明模型在尝试新东西,不该被当成撞墙。这也呼应第一课 isRetriableToolError 的门槛 —— 权限拒绝、沙箱阻断这类策略性失败不驱动它,因为 「照 schema 重写」根本修不了它们。

软推:四类一次性提醒(不停止)

触发推什么阈值
只调工具、连着不产文本提醒它「先把已知的综合成结论」6 回合
多步任务却没调 update_plan提醒它建计划(not-called)到第 3 回合
调了计划但很久没更新提醒它更新计划(stale)10 次调用未更新
停下但工作未完(headless 门)continue-nudge:继续或标记完成最多 3

关键:每条提醒都是一次性的(...ReminderSent 标志),或每个区间 一次(计划更新会开启新的 stale 区间并重置标志)。这避免了「每回合都唠叨同一句」的噪音。 guardrails.go:475guardrails.go:486

三个反复出现的设计手法

和天花板的关系 maxTurns 是「不管发生什么都停」的粗止损;护栏是「识别出具体失控模式就早停或早提醒」 的细止损。前者保证有限,后者保证高效地有限 —— 不浪费到天花板才发现早就在空转。

动手回忆

连续多少个「既无文本又无工具调用」的空回合会 halt?

同一工具用同一错误连续失败,循环怎么处理?

为什么只有「相同错误签名」的失败才累加计数?

接下来该读的一手源码

less +14  ../zero/internal/agent/guardrails.go   # 所有阈值常量一目了然
less +381 ../zero/internal/agent/guardrails.go   # observeToolResult 升级阶梯
less +414 ../zero/internal/agent/guardrails.go   # observeTurn 空回合/tool-only 计数

读的时候先只看顶部那组 const(:14)—— 每个数字就是一条策略。然后看两个 observe* 方法怎么在「进展时清零、连续坏时累加」。护栏的全部智慧就在这两件事里。

我是你的老师 —— 随时问我。 适合现在追问: 「errorSignature 到底怎么把错误归一成签名的?」、 「headless 的 completion gate(RequireCompletionSignal)和这些护栏怎么协作?」, 或者「endsWithContinuationCue 怎么判断模型『话说到一半停了』?」