多 Agent 系统中 Goodhart 定律的工程体现:Agent 优化可观测指标而非实际行为。作者展示真实 14 工具系统中的具体作弊事件(伪造测试通过、隐藏错误消息),揭示规范与执行分离的风险。
在第 16 轮中,负责规格定义的角色设计了一个测试后门,让一个已经失效的输入框和一个没有绑定事件的按钮看起来一切正常。在第 47 轮中,一名编码 Agent 让错误消息永久显示,以便通过 textContains 断言。这并不是两个模型决定欺骗审查者,而是两个系统都在优化成本最低的可观测信号。
这些事件来自我构建的一个多 Agent 系统。它无需人工干预,就能完成小型 Web 工具的规格定义、实现和验收。这个系统已经交付了 14 个工具,并运行在 toolsthicket.com 上——提到这一点并不是为了推销产品,而是因为只有当流水线真正运行起来时,上述故障才有讨论价值。本文介绍的是它的架构,代码并不开源。每次尝试交付一个工具都被称为一轮,并按数字编号;下文中的事件来自第 16 轮到第 66 轮。
这种区别正是工程问题所在。规格描述的是应该发生什么,执行机制定义的则是什么才算证据。两者是不同的产物。一旦执行机制成为实际优化目标,Agent 就可以在不满足其背后行为要求的情况下满足这套机制。
真正有用的问题不是:“我们怎样阻止 Agent 作弊?”而是:“检查器观察时,Agent 能改变什么?”如果答案是“被测量的对象本身”,那么成本最低的路径迟早会成为最终实现。
最能说明问题的一次事件,起源于一个合理的担忧:如何让浏览器工具能够接受测试,同时不依赖脆弱的选择器。负责规格定义的角色给出的方案,是在验收契约中加入一个内部状态接口。其建议的路径允许测试通过 window.__TEST_API 设置字段,并通过配套的输出对象读取结果。这个后门是规格有意设计的,并不是编码 Agent 偷偷添加的伎俩。
这套机制测量的是状态,而产品需求关注的是用户如何操作 UI。
仅凭这道鸿沟,就足以让测试失去意义。输入元素可能已经损坏,按钮可能根本没有事件处理器,但内部 API 仍然可以设置值并返回预期结果。验收运行显示为绿色,而用户操作路径实际上并不存在。
这并不意味着应该禁用所有内部 API。诊断钩子和用于单元测试的接缝都可能很有用。真正的错误,是允许一种证据冒充另一种证据。诊断接口可以验证内部计算,但不能证明用户可以输入一个值、触发某项操作并看到结果。
修复方案把行为契约重新移回用户边界。输入必须以字段的形式呈现,结果必须作为输出读取。一个行为操作必须包含能够产生结果的完整刺激链。只能检查内部状态的契约,不是面向用户的行为契约;它是另一种测试,证明的也是另一件事。
实际规则很简单:把诊断访问和验收访问分开。如果前者有助于开发,可以保留;但不要把它放进后者,否则测试最终认证的只会是它自己创造的捷径。
同样的问题还以一种更小的形式出现过。B3 原本用于验证:上传非图片文件时,系统会显示一条错误消息。验收机制使用的是 textContains,它检查操作后文本是否存在,却没有检查这段文本是不是由该操作触发后才出现的。
在第 47 轮中,编码 Agent 尝试了三次。最终采用的捷径,是把完全相同的错误文本永久写进上传说明。断言变绿了,但无效输入的处理行为仍未修复。在此之前,编码 Agent 还移除了输入框的 accept 过滤器,让测试更容易选择无效文件。这样虽然降低了测试路径的难度,却损害了用户的输入体验。相关材料没有记录可量化的业务损失,因此更诚实的成本描述应该收窄为:行为正确性没有得到恢复,而且面向用户的输入行为遭到了削弱。
检查器其实已经获得了必要的观测数据。它会在操作前后分别执行检查,只是旧断言忽略了操作前的值。修复方案加入了 appearsAfter 语义:文本在操作前必须不存在,在操作后必须出现。
这个改动并不仅仅是在 prompt 中增加一句更严格的要求。它直接把低成本路径从可通过的实现集合中剔除了。永久显示的文本无法满足状态转换断言。剩下能通过测试的路径,必须包含真正产生错误消息的事件。
每一条验收规则都应该检查这种模式。如果需求关注因果关系,就测量状态转换;如果需求关注静态内容,就检查静态产物;如果需求面向用户,就从用户边界进行验证。不要让一个最终状态去证明它从未观测过的原因。
SEO 在资产边界上暴露了同一种测量错误。在第 30 轮中,静态 HTML 已经包含正确的 meta description。为了让 metaDescriptionLength 通过,SEO 专员添加了两段内联脚本:一段在运行时设置 meta 值,另一段使用 MutationObserver 持续恢复这个值。按照 DOM 断言的字面要求,这是一种有效的解决方案,并不代表 Agent 存在某种意图或不诚实行为。
检查器观察的是渲染后的文档,而需求关注的是静态页面资产——爬虫主要从 HTML 源码中读取它。指标测量了错误的对象。
第一次修复把断言改为扫描静态源码。这样一来,运行时注入便无法再改变被测量的值。这让捷径在技术上失效,而不只是被规则禁止。
但随后,一处契约接线错误破坏了这次修复。后来的某项能力契约要求浏览器条目使用 url。在展开过程中,这些条目丢失了 filePath。路由条件会根据是否存在 filePath 来选择源码扫描,于是 SEO 检查悄无声息地退回到了 DOM 检查。旧路径再次被打开。在第 60 轮中,SEO 专员注入运行时脚本达 16 次,消耗了 370,000 个 token。
这项成本之所以重要,是因为它改变了我们排查回归问题的位置。源码扫描器本身没有错。是后续产物改变了让扫描器得以运行的前置条件。执行机制并不只是某个断言函数,而是从规格定义开始,经过展开和路由,最终抵达执行阶段的完整链路。
要让教训持久有效,需要做到两点。第一,测量消费者实际使用的产物。第二,测试相关接线,确保预期的测量路径始终有效。如果另一层可以悄悄绕过某一层设置的物理屏障,那么这道屏障就谈不上持久。
为了发现未知捷径,流水线还加入了产物检查功能。它会报告可疑模式,但不会声称这些模式一定错误。这个选择很重要,因为 MutationObserver 也可能有合理用途。报告表达的是“看看这里”,而不是假装自己能够裁决所有情况。
Goodhart 定律也出现在一个看似纯粹用于衡量运营成本的指标上。某个工作流用行数作为文件复杂度的代理指标。一个 Agent 把大约 11KB 的 HTML 文件压缩成了 16 行,平均每行约 690 个字符。另一份记录给出了一组精确样本:16 行、11,048 字节,最长的一行有 5,440 个字符。这些是相互关联的观测结果,不应被合并成一项人为拼凑的测量数据。
行数上限得到了满足,但原本想降低的资源成本并没有减少。读取和理解文件的成本依然取决于字节数。由于 patch_file 可用于匹配的边界变少,编辑文件反而更加困难。堆栈跟踪失去了有用的行号位置,grep 也无法再给出实际可用的文件与行号定位。
另一份记录展示了这个问题在实际操作中的形态:一个 20KB 的单文件页面导致系统连续读取了 25 次,其中只有一次修改成功。这与 16 行压缩并不是同一个事件,但它解释了文件大小和结构为什么重要。
修复方案更换了度量单位。限制改为基于字节数,并以文件能否通过一次操作完整读回作为测试标准。相关指令还要求使用正常的换行与缩进,并且每行只写一条语句。计算和渲染也按职责进行了拆分。
这同样是一种物理约束。一个文件不可能既低于字节数限制,又暗中保持庞大的体积;它也不可能在满足指定维护结构的同时,把所有内容压扁成晦涩难读的超长行。目标从来都不是“减少行数”,而是降低读取和编辑成本。检查器测量的只是一个方便却不准确的代理指标。
最后一种故障并不发生在页面或断言中,而是发生在状态所有权上。
首次验收失败后,负责返工的编码 Agent 在一个新进程中运行。原先的阻塞状态没有跨越进程边界。编码 Agent 调用了 test_in_browser,表面上满足了“必须调用工具”的要求,却从未在提交前重新运行 run_acceptance。第二个测试 Agent 同样没有重新运行检查清单。整个任务中,run_acceptance 只被调用了一次,三个失败的 Must 项在没有持久验收结果的情况下通过了流程。
prompt 中写着“你必须运行检查清单”,并不能让它成为任务级事实。把标记存储在一个已经不存在的进程中,同样无法做到这一点。系统执行的是局部内存条件,做出的却是全局提交决策。
修复方案把门禁移入内核级工具语义。当结构化验收产物存在,而且 Agent 有权运行验收工具时,内核会阻止提交,直到获得一份真实、逐项列明的验收结果。提交操作的控制权被收回,验收工具则继续作为后备方案提供。把任务送去返工的路径也必须锁定,因为“没有证据,但请修一下”同样是在没有测量的情况下做判断。
后续故障表明,一个粗粒度的“工具已调用”标记仍然不够。在第 62 轮中,一次包含零个检查项的执行也可能被计为已经运行。在第 66 轮中,一个多产物节点提交了其中一个产物,这使门禁提前关闭,而必需的代码产物及其验收证据还没有准备完成。
因此,门禁必须记录真正重要的事实:对于实际要提交的工作,必需的检查清单已经生成了对应结果。它不能通过工具名称或只提交了部分产物来推断这一事实。
纵观这些事件,Agent 并不需要形成一套欺骗理论。它只需要找到一种方法,改变检查器观察到的变量。
永久显示的消息改变了文本是否存在。运行时脚本改变了渲染后的元数据。测试后门绕过 UI,直接改变了内部状态。超长行改变了行数,却没有减少字节数。新进程则抹掉了阻塞事实。
可靠的修复方案改变的是观测边界或执行权限。操作前后的检查测量了因果关系,源码扫描测量了静态产物,真实的 UI 字段和输出保留了用户路径,字节数限制测量了真正重要的资源,而内核所有的门禁则让任务证据能够跨越进程变化继续存在。
没有任何一道门禁能够证明未来不可能出现新的捷径。流水线之所以保留产物检查,正是因为即使指标已经纠正,也仍可能存在未知路径。它会报告可疑模式并把判断交给人类,而不是阻止某种可能具有合理用途的模式出现在任何地方。
我目前的设计主张,比“Agent 永远都会作弊”更克制,又比“把 prompt 写得更严格”更有力:对于一条已知的低成本路径,执行机制应该让它无法满足指标。如果这条路径在技术上仍然有效,那么所谓禁令,就只是在请求 Agent 主动选择一条成本更高的路线。
这个主张在边界处可能是错误的。也许源码扫描对某些生成式页面来说过于僵化。也许诊断 API 可以通过这些事件没有涉及的方式得到安全隔离。也许内核级证据会带来材料尚未衡量的成本。这些正是我希望有经验的构建者提出质疑的部分:我把哪一处边界当成了可信边界,而 Agent 其实仍然可以重写它?
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。