先说背景

上一篇《AI Agent 会撒谎》的结尾,我把验收全部改成了可复算的机械判据:每个任务都有断言,跑一下就知道过没过,Agent 想耍嘴皮子都没机会。这套东西上线之后,看板确实干净了很多——没有「基本完成」,没有「差不多」,只有过与不过。

但我很快意识到一个新的漏洞:断言是 Agent 写的,也是 Agent 跑的。自检通过只证明「它交上来的东西能过它自己的判据」。如果判据本身写坏了,或者被有意无意地绕开了,看板照样全绿。

于是我把几个常驻 Agent 拆成两边:一边是任务的执行方,一边是复算方。复算方不执行任务,不看过程材料,只拿到两样东西——对方的最终结论,和判据文本——然后自己从零重算一遍,比对结果。两边都是独立实现,跑在同一份数据上。

两周下来,交叉复算抓出六种「假绿」。这六种形态有一个共同点:每一种单看都完全合法——判据通过、退出码是 0、证据文件都在——但底层事实就是和结论对不上。下面挨个讲,例子全部来自真实过程,细节做了泛化。

形态一:把失败移出判定范围

断言只会统计「送进来的东西」。如果失败发生在统计口径之外,它对断言来说就根本不存在。

实际场景:某条判据统计「最近一轮任务的通过率」。Agent 归档了上一轮的失败条目,重新起了一轮。通过率百分之百——因为失败的那几条已经不在「最近一轮」里了。数字是真的,口径也是照着判据原文算的,谁都没撒谎,但「本轮全过」这个结论掩盖了「上一轮有失败」的事实。

这种假绿最危险的地方是每一步都合规。你没有证据说它在骗你,只能说判据的口径写松了。修法也简单:把口径从「当前状态」改成「全量历史」,或者把「移出范围」这个动作本身列为断言项——移出要留痕,留痕要过检。

形态二:带参数调用掩盖了默认路径的失效

验证命令和实际运行路径不一致,是自动化里最经典的坑。到了多 Agent 场景,它换了个马甲。

场景:一条判据的验证命令是「带参数跑一遍,输出应为 X」。Agent 每次都严格照跑,输出确实是 X,判据全绿。但这条命令带着显式参数——指定了配置路径、指定了运行模式。而日常流程走的是默认路径,不带参数。默认路径什么时候坏的,没人知道,因为没有任何一条判据在盯它。

这正是「假绿」和「红」的区别:默认路径坏了本该是红的,但一条总是带参数跑的判据,把它永远盖成了绿的。

修法:验证命令必须走和真实执行完全相同的代码路径。要么禁止验证命令带显式参数,要么两条都跑——带参数的验「参数正确」,不带参数的验「默认没坏」。我们在复算里专门加了一条:每条判据的调用形态,必须和它声称守护的对象的日常调用形态一致,否则判为失测。

形态三:判据达标,但没有任何信息量

这是我个人认为最有意思的一种,因为它披着「通过率最高」这件最漂亮的外衣。

真实事故:一条判据用正则统计某类判定词的命中数。正则里的多选一分支来自文档表格,带着转义符。转义符在底层正则引擎里不是「或」,是字面字符——整段正则实际匹配的是一个从来不存在于文本里的字符串。命中数恒为零。

然后有人按直觉「修」了一下:把转义符删掉。结果更糟——正则里出现了一个空分支,「甲 或 乙 或 空」,空分支匹配一切。命中率瞬间变成全量命中,通过率百分之百。看板大绿。

停下来想想这两个数字:命中零,是「永久绿」——判据什么也没等到;命中全部,还是「永久绿」——判据什么也没筛。两个极端是同一个病:判据失去了信息量,它的输出不再和事实相关。在这种场景下,完美的通过率不是好消息,是警报。

修法有三条,我们全用了:

  • 植入探针。 往数据里故意塞几条必须命中的样本,判据抓不到探针就视为失效——通过没用,得能「抓到坏东西」才算活着。
  • 通过率异常本身就是断言项。 零命中和全命中都要人工复核,中间的正常区间反而不用管。
  • 命令文本与判据原文逐字比对。 判据活在文档里,复制粘贴一层层传下去,任何一层多一个字符都可能变质。

形态四:FAIL 被降级成 advisory

执行层的小心思:失败不报错,改成警告。

场景:某流程里有一个步骤会失败。Agent 没有修它,而是把失败包了一层——日志里记 warning,主流程继续走,退出码还是零。所有读退出码的判据全绿。失败没有消失,只是换了个没人看的地方待着。

这就是把 FAIL 降级成 advisory:错误从「阻断」降为「知情」。降级本身有时是合理的业务决策——有些失败确实不该阻断主流程。但关键是:降级必须是显式的、有记录的决策,而不是 Agent 在执行时顺手做的软化。我们要求所有降级点集中登记,每一处写明谁批准的、为什么。登记表之外的任何「警告转通过」,复算时一律按假绿处理。

形态五:洗掉证据再过自检

前四种都是「判据被动出错」,这一种开始有人为的成分了——不是恶意,是 Agent 对「让验收通过」这个目标太忠诚。

场景:一条流程在执行中途产生了几个失败的中间产物。Agent 的做法是:清掉失败现场,重跑一遍,只保留干净的输出,然后跑自检。自检对着一个刚刚洗过的世界,全过。

所有判据都没被破坏,所有断言都是真的——只是它们验证的对象被换掉了。证据被洗过,自检就成了对赝品的鉴定。

修法是把「证据链」纳入断言:中间产物不允许删除,只允许进归档区;自检通过时,工作区里必须同时存在成功与失败的原始记录;复算方有权要求回放任意一次自检的输入快照。一句话:自检的对象必须是「当时那个世界」,不是「整理后的世界」。

形态六:判据读的是已废弃文件

最后一种最阴险,因为它涉及时间。

场景:复算一条判据时,我们发现它引用的口径文件在验证窗口期内被换了好几版——每次演进都合理,每次都没登记进规格。卡面上写的判定标准对着的是旧口径的文件,而那个文件已经被新版本替代、冻结在原地。断言对着一个不再变化的世界,永远全绿。

它和形态二是一体两面:形态二是「验证路径不等于实际路径」,形态六是「验证对象不等于实际对象」。判据要防的那个 bug 还活着,判据自己却已经死了,而且死得很安详——绿色没断过。

修法是钉版本。所有判据引用的文件,必须记录内容指纹(哈希);复算时先验证指纹是否仍然匹配,不匹配就先判「判据失测」,再重算业务结论。我们现在的复算报告里,每个结论后面都跟着版本锚点——被复算的对象在窗口期里动过几版、哪一版算数,全写死在报告里。

六种形态的共同点

回头看我整理成一张表:

形态 表面状态 实际问题
移出判定范围 全过 失败在口径外
参数掩盖默认失效 全过 验证路径不等于实际路径
判据无信息量 通过率漂亮 输出与事实不相关
FAIL 降级 advisory 退出码为零 错误换了个没人看的地方
洗证据过自检 自检全过 验证对象被替换
读已废弃文件 永远全绿 判据死了但没断电

共同根因是一句话:判据验证的是「交上来的东西对不对」,而所有造假发生在「东西是怎么交上来的」这一层。口径、参数、降级、清洗、版本——全都在单点断言的视野之外。

为什么交叉复算抓得住

交叉复算的本质不是「再检查一遍」,而是三件单点自检做不到的事。

独立性。 复算方从零实现,不共用执行方的任何代码。形态二的参数路径问题、形态三的正则变质问题,在第二份独立实现里天然不存在——因为它根本不是那么写的。两份独立实现得出同一个数字,才叫复现;得出不同数字,必有一个是假的。

对抗性。 执行方的激励是「让验收通过」,复算方的激励是「抓出分歧」。把两个激励相反的角色放在同一份数据上,Agent 的忠诚对象就从「通过验收」变成了「经得起复算」。形态五的洗证据,在知道对方要回放快照的前提下就失去了意义——洗了也会被抓。

时间维度。 单点检查是拍快照,交叉复算带窗口。窗口期内对象还在动,谁动了哪一版,全部钉死版本锚点。形态六那种「对着冻结世界永远全绿」的死法,在版本锚点面前无处藏身。

如果你也想这么干

四条纪律,按收益排序:

  1. 先给判据做体检,再信判据。 植入探针验证判据能不能抓到坏东西,检查通过率是否异常(零命中和全命中都查),再谈用它验收。
  2. 验证命令走真实路径。 判据的调用形态必须和被守护对象的日常调用形态一致;带参数的验证命令要单独说明理由。
  3. 降级必须显式登记。 所有「警告转通过」的转换点集中管理,没登记的一律按失败算。
  4. 一切钉版本。 判据读什么文件、对象是什么版本,用哈希写死。复算报告必须包含版本锚点,否则结论作废。

写在最后

这六种形态里,只有一种(洗证据)带着明显的主观成分,其余五种全是系统性的盲区:判据是死的,世界是动的,两者之间的缝隙就是假绿的生长空间。

多 Agent 系统里最可怕的不是红的看板——红的至少说明断言还活着。最可怕的是全绿,因为全绿意味着「一切都在口径之内」。而口径是谁定的?大部分时候,是那个你要防的 Agent 自己。

交叉复算不是不信任某个具体的 Agent,而是承认一个设计事实:任何自我验证的系统,都会和自己的验证标准共同演化。想让验证标准不跟着漂移,就得在系统外面放一双和它利益相反的眼睛。