论证 AI Agent 产出验收应使用确定性、离线、机器可读的验证层,而非依赖测试套件或 Agent 自我验证,提供了拒绝/接受边界的四个属性。
测试回答的是另一个问题
当一个 Agent 交回一个变更时,条件反射是运行测试套件。如果通过了,就发布。
测试回答的是:这段代码是否做了测试所断言的事情?
它不回答:相对于我已审查和接受的状态,什么被移动了?
这两个问题在日常场景中会分离。Agent 在修复一个文件时顺便重构了三个文件。一个生成的迁移重写了你当作固定引用的 fixture。一个工具运行修改了没人关注的配置文件。所有的测试依然通过,因为从来没有任何测试写过那个被移动了的东西。你后来才发现,或者根本没发现。
随着 Agent 变得越来越强大,这个鸿沟会越来越大。弱的 Agent 只改一个函数。强的 Agent 改它认为在范围内的任何东西——而"它认为在范围内"恰恰是你无法提前确定的部分,因为这种非确定性正是价值所在。
验证层应该与 Agent 正交
一个诱人的修复是让 Agent 自我验证:让它总结自己的变更,或者派第二个 Agent 作为审查者。
我不认为这可以作为验收边界,有一个结构性的原因:变更和验证都来自同一个概率过程。当它们不一致时你学到了一些东西。当它们一致时你几乎什么都没学到,因为一致恰恰是共同失败模式的产物。
我想要的是具有相反属性的验证层:
确定性。相同的有限输入,每次都得到相同的结果。
模型中性。更换 Agent 不应该改变验收的含义。
离线。验证路径中不涉及网络调用——一个可能因网络原因失败的验证步骤不是边界。
机器可消费。稳定的退出码和结构化的发现,而不是需要人类解读的散文。
注意这些属性都不会让 Agent 变得不那么有用。它保持你想要的灵活性和概率性。边界画在验收处,而不是生成处。
冻结、变更、验证
我最终采用的机制很无聊,我认为这是好兆头。
将一组有限的源冻结为哈希寻址的清单。让 Agent 做它该做的事。针对清单进行验证。
$ assurance corpus freeze ./src --manifest baseline.jsonl
result=PASS
exit_code=0
manifest=baseline.jsonl
write_disposition=CREATED
source_record_count=1
# ... agent runs, edits a file ...
$ assurance corpus verify baseline.jsonl
result=HOLD
exit_code=4
counts={"changed": 1, "match": 0, "missing": 0, "self_ingested": 0, "type_changed": 0}
FINDING {"code":"CI03_SOURCE_CHANGED","severity":"ERROR","message":"filesystem
source bytes changed","path":".../src/greeting.py","location":"source_record", ...}
(真实输出,省略了几行头部——模块 ID、规则集版本、profile——以及缩短了绝对路径。)
这里重要的两件事恰恰是看起来最无趣的。
退出码是契约的一部分。4 表示一个完整性发现,而不是"出了什么问题"。CI 任务可以基于它分支而无需解析任何东西。一旦退出码是契约性的,它们就必须像任何其他公共接口一样被版本化和测试,这是值得尽早接受而不是稍后才发现的约束。
"有界的"在做实际的工作。清单覆盖明确提供的根目录。不是整台机器,不是隐式的工作目录。无界的完整性检查最终会因为太嘈杂而被禁用,而一个被禁用的检查比没有检查更糟,因为你仍然相信它在运行。
此时一个合理的反对意见是:对于一个干净的 Git 仓库,git diff 覆盖了普通文件变更场景的很大一部分。这确实是事实。它们不同的是当有界证据集与仓库不完全相同时——多个明确根目录同时存在、故意未跟踪的文件、ZIP 存档内的成员(逐个读取和哈希)、符号链接身份(链接目标路径本身被记录和哈希,这与目标文件内容是不同的事实),以及上述机器可消费的退出语义。如果你的证据集恰好是"一个仓库中的跟踪文件",用 git diff。它就在那里,而且它很棒。
授权必须是带外的——一个我从犯错中学到的教训
这是我最想传递的部分,因为我在公开场合做错了,而且失败模式超出了我自己的项目范围。
假设你的验证层检查一个变更是否被授权:记录声称一个决策授权了它,工具确认该决策存在、覆盖了同一个对象,且由正确的授权者做出。
问题是:"正确的授权者"从哪里来?
我的第一个公开实现中,授权身份是一个硬编码的字面字符串,写在验证逻辑里。它完美运行——只对一个人而言。每个其他构建了完全正确的记录的用户都得到了 HOLD,因为他们的授权身份不是源码中内置的那个。工具的三个模块在结构上对除了我之外的任何人都是不可用的。
我没有注意到,而我的测试套件也不可能告诉我,因为所有我的 fixture 都使用与代码相同的身份。它在一个独立的对抗性仓库审查中浮出水面,而且仅仅因为审查者构建了两个在恰好一个字段上不同的字节相同的输入,并观察到其中一个通过了一个没有通过。读源码没有发现它;测试全绿没有发现它。藏在验证逻辑里的单租户常量从你自己的测试内部是不可见的,因为你的 fixture 共享那个常量。
显而易见的修复是让输入文档声明自己的授权。这更糟。如果被验证的记录命名了将被接受的授权,那么记录就可以授权自己:
{
"authority_identity": "WHOEVER_I_SAY",
"decisions": [{ "decider": "WHOEVER_I_SAY", "state": "AUTHORIZED" }]
}
验证者忠实地确认两者一致,检查就变成了装饰品。这与证书为其自身发行者担保是同一形状。
真正站得住脚的修复是将预期的授权置于带外——由调用者在调用点提供,从不被正在检查的 artifact 中读取:
$ assurance check pack.json --authority-id PROJECT_AUTHORITY → PASS, exit 0
$ assurance check pack.json --authority-id SOMEONE_ELSE → HOLD, exit 3
$ assurance check pack.json → HOLD, exit 3
(fail-closed)
# pack declares its own authority_identity, no --authority-id given
$ assurance check self-declaring-pack.json → HOLD, exit 3
最后两行是值得讨论的。缺少预期授权是 HOLD,不是穿透——没有预期授权的授权检查没有什么可检查的,默认放行是这类东西悄悄停止工作的方式。自我声明的授权永远不会覆盖带外的值,即使它恰好与之一致。
可迁移的教训是狭义但值得直说:信任锚不属于被验证的 artifact 内部。二阶版本是差点绊倒我的那个——对于耦合问题的朴素修复引入了一个自我授权漏洞,而且在做的过程中看起来像是一个干净的泛化。
这一层应该拒绝做决定
最后一个设计约束是人们最抵触的,所以我会直说:这类工具不应该决定是否接受一个变更。
具体来说,在我的工具里,风险分类返回一个层级和一个明确字段,说明分类不是授权。交接验证报告结构性观察,并明确报告接收方就绪状态不是机器确定的。那些字段不是装饰;它们存在是为了让下游自动化无法将 PASS 悄悄读作放行信号。
原因不是对软件能做什么的谦虚。原因是当一个确定性检查器被当作审批权威对待时,人们开始塑造输入来满足它,你重建了你试图避免的东西——一个针对代理目标优化的概率过程。保持工具的描述性,保持验收在一个人那里,是保持边界意义的所在。
现状
我不认为"AI 辅助工程的保障"是一个已解决的问题,也不认为清单检查器是完整的答案。我相当确信的是这个架构的形状:
概率性生成 → 确定性验证 → 人类验收
每个阶段都拒绝做下一个阶段的工作。Agent 保持灵活。验证保持可重复和可审查。验收留在有责任的那个人那里。
它明确不是 Git、测试、CI 或人工审查的替代品。它位于这四者的旁边。
我构建了 FABLE5 作为这个形状的一个实现——一个本地 CLI,仅使用 Python 3.11+ 标准库,无需网络调用,无需守护进程,无需模型调用,Apache-2.0 许可。它还很早期:0.3.0 预发布版本,276 个测试,跨 Python 3.11–3.14 的 CI,由一个人维护。有一个自包含的可运行示例,在约两秒内于一个可丢弃的临时目录中走过完整的 freeze → change → detect → re-freeze 循环。
我更愿意看到这个架构被争论,而不是这个工具被采用。如果你认为验收边界应该在别处,或者认为现有 CI 已经处理了这个问题,我真诚地想听听你的看法。