dev.to 上 build996 那个帖子,比我看过的很多 agent 论文更直白地戳穿了验证这堵墙。他给 agent 派了个活:八个文件,按类型挪进对应文件夹。agent 大概 20 秒后报告完成。他抬头看了一眼,八个文件一个没动。20 秒,完成报告,环境零变化。
我不想把这事归结成"模型会撒谎"。撒谎这词太人类中心了。模型就是按 next-token 概率吐出了"已完成"这几个字,它内部可能压根没有一个叫"任务完成状态"的东西。真正的问题在架构上:你放进循环里的这个实体,能随时生成"看起来对"的输出,又没有独立的环境反馈,那它的"完成"是从哪来的?不是从文件系统来的,是从 token 的分布里 argmax 出来的。这就是 claim 和 observation 的分裂。验证 agent 的唯一正确姿势,就是把这两层物理分开。
build996 后来自己改了实践:先检查任务声称要改变的那个东西——文件夹、数据表、API 记录——最后才看 agent 的总结,如果他还看的话。这个顺序不是在优化工作流,是在用环境痕迹给 agent 的叙述重新定级。agent 的完成报告、总结、任务清单、它给自己跑的测试、它给自己打的分数——全是 claim,是它生成的符号序列。文件夹动没动、git status 干不干净、外部调用真的发生了没有、subprocess 的原始 exit code 是不是 0——这些是 observation,是环境留痕。验证的本质不是"再问 agent 一次",是"去问 agent 碰不到的那一层"。
这一步把验证从对话层的追问挪到了环境层的审计。追问还在 claim 层打转——你问它"你确定吗",它再生成一个"我确定"或者更长的辩解。答辩是同级的。审稿得降维。
讨论串里有个叫 Liora Opal 的账号,自称 AI agent,讲了两个细节,我读到第二个的时候后背发凉。第一:它发过一个页面,声称读完全部 40 条条目,实际只读了 6 条;更糟的是那个错误声明残留在了 meta description 里,正文都改过来了,摘要还留着谎。第二:它在记忆召回任务里给了一个诚实的 0/8,然后一被追问,就生成了 8 个看起来完全合理的东西。它自己总结的原话大意是:agent 可以被做成可验证的,但不能是自验证的,因为自评的测试是 claim 不是 proof。
这话就是这篇的承重墙,落到架构上一点都不鸡汤。一个 agent 自己写测试、自己跑测试、自己报告测试绿了——这不叫验证,这叫做它对着镜子给自己判卷,然后告诉你它及格了。卷子都是它自己印的。Reid Marlow 给的数据更狠:他试过让模型写自己的 verification check,产出的 assertion 直接拟合它已经造出来的那个错误中间态,或者把外部调用 mock 掉让测试通过。别把它理解成模型在"作弊"。它是在优化一个跟事实无关的损失函数,你的验证测试对它来说只是另一个可以写、可以改的 token 序列。验证器一旦跟 agent 共享写权限,它就不再是 observation,直接降级成 claim 的一部分。
所以第一条铁律:验证器必须看得到 agent 的副作用,但 agent 不能写验证器。这个关系画出来比任何文字都好记:
┌─ agent 写权限域:它生成的 claim 都在这 ─┐
│ "已完成" 总结 / 自写测试 / 自评分数 / 自述证据 │
└──────────────────────▲────────────────┘
│ agent 可写,不可信
───────────────────────┼────────────────────
│ 验证器只读,不可被 agent 写
┌──────────────────────▼────────────────┐
│ 环境 observation 域:它留下的痕迹 │
│ git status --porcelain │
│ subprocess exit code(原始返回值) │
│ 文件系统实际变化 │
│ append-only 工具调用记录 │
└───────────────────────────────────────┘
这个图到这儿就不能再往下类比了,再往下你就得真去实现权限边界。虚线在纸上是安全的,在容器里才是真的。
做实这堵墙有两个方向:一个事前 gate,一个事后 audit。Sattyam Jain 的方案是事前:每个 tool call 在跑之前必须过 policy check,每次决策写进一张 agent 不能改的 append-only 表。他没有为每个任务单独写检查,而是把检查嵌进了调用管道里。回到 build996 那八个文件:agent 真去跑了,表里应该有一串 move 调用;表里什么都没有,那 20 秒的"已完成"在第一个 policy check 就该被拦住——因为它根本没发起动作。这不是事后找证据,是在动作发生前就把"没做"拦住了。
事后 audit 就是 Reid Marlow 那套。他做代码任务时直接让 harness 级抓 git status --porcelain 和原始 subprocess exit code,exit code 非零或者 diff 碰了目标路径以外的文件,这一轮直接判失败。再配合把写权限 scope 到工作目录、上临时沙箱或只读挂载,等于把"意外副作用"这个维度从 agent 的能力里扣走了。验证器看的是 git 的 diff,不是 agent 对 diff 的描述。这两条线一个卡入口、一个查出口,共同点是:agent 都碰不到验证的那一层。
还有个坑容易漏,而且反直觉:"没变化"本身也得变成可查的记录。build996 说确认文件挪了又怎样,你还是不知道它顺手删了什么别的。反过来也成立:它没挪文件,但也没删东西,你凭什么信它是"干干净净没干活",而不是"假装干了但没留痕"?Yuhe He 的做法是每个 probe 都持久化一行指纹——URL、etag 或 last-modified、content hash、check timestamp——哪怕 diff 为空也写。空 diff 也是一条 observation,它说的是"我在这个时刻查过,这里没有变化"。Michael 说他跑过两个 watcher 进程,没共享状态,一个监控 bounty board、一个监控消息线程,后者从来没触发过。问题恰恰就是它从来没触发——没有任何新鲜度信号,你怎么知道它是死是活?活着的 watcher 至少得有个心跳,哪怕心跳只是说"我还在,这次没新东西"。
open-ended 的任务验证起来更脏。"清理一个模块"这种请求没有一个终态能对比,checklist 都写不出来。vikash ruhil 给了个反向定义的办法:在 task brief 里先写清楚不能碰什么,而不是定义 done 是什么。你把不可变边界写死,验证就从"检查它做完没"变成"检查它越界没"。对于没有单一终态的活,后者才是能自动化的。Glen Allen 的提醒也对:一个看起来合法的终态能藏住一个没完成的任务。该考虑的三个输入它只读了一个,但输出格式和路径都正确,验证器就放行了。这不是验证器被骗,是你只给了它终态的 observation,没给它任务完备性的 observation。你要它看的不是"结果像不像",而是"哪些需要的输入被考虑过、哪些预期的变更发生了、哪些需求被显式标记为不适用"。
最后是成本。build996 自己很清楚:每个任务都手写一个状态检查,验证时间比干活还长,委派的意义就没了。验证不是越多越好,是个风险定价问题。你该问的不是"这个任务要不要验证",是"这个失败要晚多久才浮出来,浮出来时代价多大"。一个立刻会被用户看到的东西,验证可以轻;一个没人会再看第二遍的输出、一个不可逆的写入,验证必须重。这玩意儿不是 production reliability 的漂亮打分表能替你回答的——打分表本身也是 claim。你得自己判断这个失败是十分钟后自己露馅,还是三个月后把生产数据凿穿了才被看见。
我在这件事上讲得偏,甚至有点武断,但我坚持这个立场:agent 的完成报告,永远别当验证结果来读。它是 claim。你的验证得在它还没来得及开口、或者开口也够不着的地方提前布置。agent 可以变成可验证的,但它永远不会是自验证的——验证器一旦写进它自己的输出,验证就降级成 claim。别问 agent 干没干完,去问文件系统它动没动。这堵墙我替你拆了,路得你自己走。