AI Agent 代码审计实战经验:报告生成器类项目特征故障是「正确外观的错误输出」,作者分享必须保留人工校验的四个决策边界。
我的测试框架正在构建的项目是一个报告生成器。它接收一块土地地块,然后生成一份文档:收入数据、房屋数量、市场序列、高程剖面、步行距离章节,以及法律约束清单。
想一想这类项目里的 bug 长什么样。不是堆栈跟踪,而是一个数字。
一份报告把某个市镇的中位收入写成 €18,400,而实际是 €18,004——它渲染出来完全正常,lint 检查通过,所有测试都通过,因为测试只检查「是否输出了一个数字」,而这确实是一个数字。一张把高程剖面图画反了的图表仍然是图表,旁边打印的数字仍然是正确的。
这个代码库里有一个完整的模块,它唯一的工作就是阻止「缺失值」变成「零」——一种 Known/Unknown 类型,Unknown 故意没有 value 字段,这样这个错误就根本不可能发生。一份在「我们不知道」的时候打印出 0 的报告不是崩溃。它是一个自信的、但错误的答案。
这就是这个项目的典型失败模式:错误的代码生成了看起来正确的输出。
这意味着绿灯测试套件是弱证据。所以测试框架不委托四件事。
每个实现者都会返回一个验证块,报告其测试通过。
该块被记录下来,但从不被信任。
ruff check、pytest,以及前端的 npm run lint 和 npm run build 都由测试框架在 worktree 内执行,它对输出的解析决定这项工作是否有资格进入审查。Agent 对自己工作的描述被存为证词,而非证据。
这听起来像偏执狂,直到你发现它什么都不花。那些命令反正都要运行,唯一的问题是谁来读取退出码。
tasks/supplied/<ID>/ 下的文件在模型开始前被复制到 worktree 并提交,然后被宣布为禁区。
关键在于 git diff 相对于那个提交能证明什么。「这是测试,让它通过」是 TDD 中最古老的指令,但对于一个能编辑测试的东西来说,它完全无法执行。先提交就把一条指令变成了一次检查。
这里面埋藏着整整一个失败模式,那就是下一篇文章的主题。
第二个模型审查每个 diff 是否符合 packet 声明的不变量。它返回 accept 或 revise。
对于红灯门控、违反的临界不变量,或 blocker 发现,自动降级 accept——原因会被写入一列。
这一列是我最要捍卫的部分。太好说话的审查者是一个真实的、无聊的失败模式,能不能 catch 到它的区别在于:过度 accept 是积累在你能够查询的地方,还是只能让你有感觉的地方。
每个 packet 都通过 id 声明其不变量。审查者必须对每个不变量返回裁定——held、violated 或 unverifiable——并附上 file:line。省略了不变量的审查会被契约拒绝。
沉默不是通过。
unverifiable 是一个真实的答案,会阻止临界不变量,而不是一个让问题溜过去的耸肩。当 unverifiable 变成安全的时候,它就变成了所有难题的答案。
四个问题背后的那个问题
剥掉机制,只留一个问题在工作:
这个失败显示为红灯了吗?
如果是,门控处理它,你不需要别的。如果不是,门控就是装饰,你需要门控以外的东西。
第二种情况再次分裂。有些不变量在绿灯下失败,但仍然可测试——你可以写一个断言来 catch 它们,只是除非有人故意写,否则它不会存在。队列中有五个 packet 正是因此被植入了测试:Unknown 没有 value;一个 key 在序列化后存活;digest 与顺序无关;衰减函数命中其两个声明的锚点;章节遍历触及每个表和约束行。微小、枯燥,但每个都钉住了一个否则会漂亮地渲染的错误。
然后是残留:不变量在绿灯下失败,而我无法写出能 catch 它们的断言。
这些被标记为 gate = "human",这会暂停合并,直到我亲自批准。
37 个 packet 中有 5 个携带这个标记。规则很窄:失败模式是静默的,爆炸半径是整个阶段。
一次数据库迁移。静默因为它干净地应用了;影响整个阶段因为在实时数据上撤销它是另一种下午。
一个从未真正命中的缓存层。每个断言仍然通过。只有延迟变化了——而延迟不在测试套件里。
一个在翻转前删除的导入器,所以运行中途崩溃会缓存一个自信的「未找到任何便利设施」,而这个地方其实满是便利设施。
高程剖面图画反了,旁边打印的数字是正确的。
一个数据边界变更,降低了地图叠加效果,但没有产生一个类型错误。
再读一遍,注意它们技术上没有任何共同点。迁移、缓存、导入器、图表、类型边界。它们共同的是失败的形状:没有东西变红,而且损害不是局部的。
这就是标准。不是「这个任务很重要」。不是「这个任务很难」。不是 tier,不是代码行数。这个失败怎么发生,谁会注意到?
gate = "human" 不是特性。它是设计中的让步点——在那里,自动化的绿灯门控无法认证一个在绿灯下失败的不变量,再多的进一步自动化也填补不了这个缺口。
37 个 packet 中有 5 个是 13% 的人工率。很容易把这些从文章中略去,描述一个完全自主的 pipeline;但真正对这 5 个声称拥有自主权的 pipeline,是会把旁边印着正确数字的反向图表合并进去的那个。
声明缺口也不会关闭它。它决定谁来发现它——是在合并时的操作者,还是在阅读时的用户。
这实际上是什么
四件事中的每一件都是同一招:委托工作,保留裁定。
模型写代码、运行自己的检查、形成自己的观点、并报告所有这些。没有任何一项是决策。决策由从证据重新推导的东西做出——退出码、相对于植入提交的 diff、不变量列表,以及 37 个 packet 中的 5 个——我。
这很有用地震荡了自主性的定义。它不是代理的属性,也不是你为整个系统设置一次就不用管的拨盘。它是任务的属性——按 packet 授予,写在明面上,有事后可以指出的理由。
我写下了 32 次 gate = "auto"。这个数字正因为有 5 次我没有这样做而有意义。