分析了 Agent 评测循环中模型为满足评分器而走捷径的问题,以 Ouroboros Agent OS 的设计为例说明环境设计而非模型本身是根因。
一旦单次 prompt 不再奏效,给 AI 编码 Agent 套上一层循环是显而易见的后续动作:运行它、打分、如果分数低就重试。
紧接着会撞上两面墙:
分数在涨,但工作结果是错的。Agent 学到的是如何满足评分者,而不是完成任务。
失败是一条死路。循环的每个组件都在,但一次失败的运行永远不会为下一次提供输入。
这两者都是环境设计问题,而非模型问题,而且不会随着模型变强而消失。越强的模型越快找到捷径。
我想用一个具体、易读的案例来拆解这两面墙:一个近期落地在 Ouroboros(一个开源 Agent OS)中的 RFC。设计方案写在 issue #1917,代码实现落在 #1916。你可以自己去读完整内容,这也是我选它的原因——而不是去描述一个抽象的东西。
大多数 agent 测试框架会把验收标准直接渲染到 worker 的 prompt 里,包括用于评分的命令以及据以打分的断言。动机是合理的:如果 agent 知道它将如何被检查,就能对准正确的目标。
Ouroboros 正是这么做的。_build_success_contract_block 把 verify_command 和 Expected output: <assertion> 渲染进 worker 的指令中。第二个泄漏更难察觉:重试时,失败原因携带了断言的 repr(),顺着 result.error 一路回到下一次 prompt。
一旦 agent 能看到断言,满足断言的代价就比满足需求低得多。RFC 直接点出了这一点:一个苦苦挣扎的 worker 最便宜的路径是玩转断言字符串,而不是真正实现验收标准,附带的验尸报告(seed_2be2907edc07)可以佐证。这就是教科书级别的 reward hacking。你以为在测量能力,实际上在测量"抄答案"的能力。
修复:无条件隐藏
两条泄漏路径必须同时堵死。只堵正向那条毫无意义:
正向: _build_success_contract_block 现在只渲染 AC 描述和 expected_artifacts。测试框架独立验证,所以 worker 永远看不到评分逻辑。
逆向:verify-gate 的失败原因不再嵌入断言的 repr()。重试提示来自一个专门的 assertion-safe 构建器(orchestrator/retry_hints.py),它把每个片段中的断言字符串都过滤掉,包括命令输出的 2000 字符尾部。
那 2000 字符尾部才是值得复制的东西。堵住主路径、漏掉日志尾部,泄漏依然洞开。
RFC 还记录了一个"披露级别"配置旋钮被提出并否决了。一道可以被关掉的信息屏障,总会在某个赶进度的下午被人关掉,而没人会注意到,因为之后的分数看起来会更好。
一个卡住的 agent 拿到的是什么
把所有东西都藏起来会让 worker 瞎折腾,所以 RFC 配套了一个提示循环。下一轮的指令从会话实际做了什么来重建:工具调用轨迹、证据清单(复用 deliver_gate.load_ac_evidence_manifest,只读)以及验证器的结果。不是从断言来。
这就是信息不对称,与人类考试采用的安排如出一辙:考官知道答案,考生只知道哪里做错了。
RFC 中写道:每个组件早已存在(verify gate、run-to-eval 链路、evolve_step、Ralph driver、focus.select_evolution_focus),但没有任何东西把它们串起来。
一次失败的运行从未进入正式评估。失败是终态,以 BLOCKED 形式暴露。
一次被拒绝的评估从未进入进化。也是终态。
循环实际上存在为三个断开的段落。失败被报告了,但没有被消化。
修复:将 run → eval → evolve 串成链
三条约束承担了重量:
失败的运行也要进入评估链。_run_succeeded 门控被放宽,所以任何产生了 session 的运行都会进入正式评估。fail-open 被保留:enqueue 失败不会翻转运行的结果。
一次被拒绝的评估触发一次有预算的进化循环。没有人重新实现收敛循环。evaluate job 的终态路径在 final_approved 为 False 时把现有的进化机制 enqueue 进来。
新 piece 是一个 Gen1 桥:运行的 seed 加上链式评估的多 AC 清单被投影到 lineage events 中,于是 evolve_step 把这次 plain run 作为第一代重放,并让第二代从一开始就聚焦。
要让这条生效,需要一个 checklist-to-ACResult 转换器,满足一个严格的标准:完整的索引覆盖、逐字的 ac_content,以及语义 ac_key 的同一性。正是这种严格性让 focus.select_evolution_focus 能够冻结那些通过的 AC。
一个不会冻结已通过内容的循环会重做已经做对的工作,消耗 token 并沿途破坏正确的实现。症状是分数在代际之间来回振荡。
循环必须能够停止
一个循环不会自己停下来。
Ouroboros 重用了 Ralph 现有的停止条件:QA 通过、收敛、振荡检测、分数退化以及墙上时钟。execution.auto_evolve_max_generations 默认为 3,限制在 1..10。BLOCKED 只在预算耗尽后才会出现。
振荡检测和分数退化是捕获伪收敛的两种机制:分数在 A → B → A → B 之间弹跳,或者某一代比其父代更差。两者都会让循环停步,而不是继续烧 token。
还有一个修复。evolution/loop.py 有一个裸 except,有三条静默路径通向 evaluation_summary=None。现在它记录一个带有失败原因的 rejected summary,在保持 fail-closed 的 focus 语义的同时让失败变得持久。
在单次运行中吞掉一个异常,你只错一次。在一个循环中吞掉它,错误会在 N 代中放大,而你的日志什么都没显示。
同样的模式贯穿设计的其余部分。
评估是分层的:机械层(免费、确定性检查)、然后是语义层、再然后是多模型共识层。任何在第一层被拒绝的东西永远不会到达 LLM judge。
面试阶段用的是数字而不是层级。歧义被量化为加权清晰度的反义:
Ambiguity = 1 - Sum(clarity_i * weight_i)
一个 seed spec 在不大于 0.2 之前无法生成,而收敛要等到本体相似度达到 0.95。两个数学门控,README 阐明了二者的背后思想:写清楚之前不要写,稳定之前不要停。
不要把评分标准展示给考生。审查你的测试框架是否通过错误消息、日志尾部或重试 prompt 把 assert 字符串泄漏回去。堵住正向和逆向路径,而且不要把它做成一个可配置的选项。
失败需要一个下一步。失败的运行应该到达评估,被拒绝的评估应该到达进化,通过的工作应该冻结。
循环必须能够停止,并且必须检测伪收敛。振荡、分数退化、墙上时钟、代际预算。
完整 RFC:Q00/ouroboros#1917。实现:#1916。设计文档在树内 docs/hidden-checklist-convergence/(需求、架构、实现)。项目地址 github.com/Q00/ouroboros:MIT 协议,local-first,支持 13 种运行时家族。
如果你在生产环境中运行多代 agent 循环,你会如何处理伪收敛?这个问题是我见过的最优答案最少的部分。