别信「已完成」:给 AI 编码代理加一道双模型评审闸

Dev.to AI 2026-10-21T15:44:40.635514

本文原载于 morgan-vale.blogspot.com。

AI 编码代理会告诉你它做完了。无论活是刚干完、干了一半,还是干完了但顺手把别处悄悄弄坏了,它说这句话时的自信程度完全一样。我早就不跟这句话较劲了,转而在它周围搭了一道小闸门。这篇文章讲的就是这道闸门、用到的确切提示词,以及我拿一个真实交付物跑四轮评审的实际结果(评审方耗时约四分钟,时间为 2026-10-21,数字全部取自我的日志)。其中有一轮,闸门抓到我自己声称修好、其实根本没动的地方——我觉得这段最有价值。

思路

在代理开工之前,先把「做完」写下来,写成一组只有「是/否」两种结果的判定条件;再写清楚哪些情况算「没做完」——哪怕代理坚持说做完了,只要命中这些条件就是没做完。然后给这份文件算个哈希,锁住。

等代理报告完成,就把交付物、这份契约和完成报告一起交给另一个模型,让它只读运行,并且明确要求它去推翻这个「已完成」的说法。评审方每下一条结论,都必须指明文件和行号,还必须列出它到底读了哪些内容。就这样反复评审,直到它说 DONE;或者到达轮次上限,转人工处理。

为什么要两个模型?

不是因为第二个模型更聪明。它没写过这些代码,所以对这个「已完成」的说法没有任何利益牵扯。就我自己的经验,干这个活,这种旁观者立场比模型本身的能力更重要。

契约

下面就是我用的完整契约,只裁掉了部分路径以缩短篇幅。交付物是一个小型 Node 工具包:通过 DevTools 协议驱动一个已经登录的浏览器,去配置博客并发布页面。

# Acceptance contract — browser automation toolkit

## Criteria (binary)

- C1 lib.js exports {open, sleep, clickRow, dialog, clickInDialog,
  typeInDialog, setting}, and every export is used by at least one script.
- C2 No credentials, tokens, cookies, or email addresses appear in any file.
- C3 Every script connects through the configured DevTools URL and
  disconnects on success.
- C4 Every script that changes server state reads the state back (from the
  page or the

公开 URL),并在退出前打印出来。只打印“clicked”不算数。- C5 博客 ID 只出现在一个地方(一个配置常量)。- C6 每个脚本在遇到未处理错误时都以非零状态退出。 ## 意味着“未完成”的条件(即使作者说“完成了”)- 任何存在语法错误或无法解析的脚本。- 一个改变状态的脚本,其唯一的验证只是对它所尝试执行的操作进行 console.log。- 占位符、TODO 或注释掉的验证。- 一个标记为 PASS 但没有审查者可以打开的 file:line 的标准。 ## 哈希 审查者必须报告其读取到的该文件的 SHA-256;如果它与

COMPLETION_REPORT.md 中的不一致,则视为批准无效。那里有三行发挥了大部分作用。“只打印‘clicked’不算数”之所以存在,是因为智能体很喜欢记录它们尝试执行的操作并称之为验证,而将这种模式命名为失败条件,正是后来让 C4 真正生效的原因。哈希条款意味着,如果有人在完成报告写好后编辑了契约,批准即无效,代价只是一条 shell 命令。而“没有 file:line 的 PASS”既适用于审查者,也适用于作者的完成报告,这把我自己的报告变成了审查者可以证伪的东西,而不是一段令人安心的文字。审查者提示词 审查者在只读沙箱中运行,且无网络。这是第一轮提示词的逐字内容。你是一个对抗性的、只读的审查者。不要修改文件。不要相信

COMPLETION_REPORT.md;你的工作是打破其关于交付物已完成的主张。

阅读 CONTRACT.md、COMPLETION_REPORT.md 以及 deliverable/ 下的每个文件。然后:

  1. 报告你读取到的 CONTRACT.md 的 SHA-256(用 shell 命令计算)

以及它是否与 COMPLETION_REPORT.md 中的一致。

  1. 对每个标准 C1..C6,给出 PASS 或 FAIL。PASS 必须至少引用一个你打开过的 file:line。FAIL 必须引用违反该标准的 file:line。如果你无法验证,

说 CANNOT VERIFY 以及原因——不要猜测。

  1. 检查每个“未完成”条件,并列出所有适用的条件,附上 file:line。

  2. 准确列出你读取了哪些目录和文件。如果你跳过了任何文件,请说明。

  3. 给出总体结论 DONE / NOT DONE,并按严重程度从高到低列出一份带编号的问题清单:

id、严重程度、file:line、一句话说明问题、一句话说明修复方案。不超过 900 字。后续轮次会多一句:「在第 N 轮,你返回了 NOT DONE,问题项为 F1..Fn(见 out_rN.md)。请对每一项标明 FIXED / NOT FIXED / PARTIALLY FIXED,并给出当前文件中的 file:line。只判断你提出的那个具体问题是否已解决。」最后这句话的作用,是防止审查者每轮都重新翻旧账、把整个交付物再吵一遍,也防止它临时编出新的风格问题。我怀疑要是没有这句,问题清单根本收敛不了。

整个流程的实测结果如下:

轮次 输入 结论 发现项 误报 耗时 Token(缓存)
1 作者第一份「六项全 PASS」的报告 NOT DONE 5 项(3 高危,2 中危) 0 54 s 98.8k(71.4k)
阴性对照 同样内容,但埋了一个语法错误、一个 TODO,并在计算哈希之后改动了契约 NOT DONE 6 项 0 54 s 93.2k(76.0k)
2 修复第 1 轮问题之后 NOT DONE 3 项已修、2 项部分修复、1 项新增 0 53 s 127.3k(90.4k)
3 「修复」第 2 轮问题之后 NOT DONE 1 项已修、1 项未修、1 项部分修复 0 63 s 213.7k(159.0k)
4 真正修复第 2 轮问题之后 DONE 2 项已修、0 新增 0 84 s 256.3k(214.0k)

「误报」指我打开被引用的行号却无法复现的问题,或者误读了契约的问题。整轮跑下来共 12 个不同的问题,一个误报都没有——这让我挺意外,因为我本以为至少会出现一个编造的行号。

第 1 轮

我的完成报告声称六项标准全部通过。审查者不同意其中两项,还另外找出三个我没考虑到的问题。

发布脚本点了一下按钮,打印出浏览器当前 URL,既没有回读发布状态,也没有抓取公开页面验证——这正是契约里点名的「clicked」模式,所以 C4 不通过。而且根本不存在 blog-ID 常量,脚本只是抓取第一个 URL 里含平台名的标签页;契约要求「有且只有一处」,零处不等于一处,所以 C5 也不通过。

除此之外,它还发现:有依赖没声明;缺少必需控件时脚本仍以 0 退出;完成报告没给出行号引用。每条问题都带文件和行号。我逐个打开核对,五个全都属实。

负向对照。一个永远说"没完成"的审查关口,和一个永远说"完成"的关口一样没用。所以在相信第 1 轮之前,我拿同一段提示词跑了一份故意弄坏的代码:在共享库里追加一个语法错误、加一句 // TODO verify later 注释,并在记录哈希之后改掉契约里的一个词。该失败的时候,它失败了吗?失败了。审查者把这三处埋的雷全找了出来,先报了哈希不匹配,再依据契约自己的条款宣布批准作废,而且它在这些假问题底下,仍然找出了第 1 轮真实存在的问题。如果这篇文章你只带走一件事,那就带走负向对照——因为这是唯一能证明这道关是在读文件、而不是在拿提示词做模式匹配的证据。

第 2 轮。我加了一个配置常量、一个 package.json、一个 must() 辅助函数(控件缺失时抛错),还加了发布后的公网 URL 抓取——如果抓不到预期文本就报错。三条发现被关闭。两条仍然部分未解决,同时审查者又发现了一条新的:我从库里导出了两个新常量,但没有任何脚本用到它们,这违反了 C1 里明文写的"每个导出都必须被使用"。太吹毛求疵了?是的。正确吗?也正确。那句话是我自己写的,审查者就拿它来要求我。

另一个部分未解决的那条才是真正要紧的。我新写的 open() 在配置的博客标签页没打开时,仍然会回退到"平台上的任意标签页",审查者指着那行代码说:这会让工具集去改动别的博客。

第 3 轮:那个从未发生的修复。我用一次查找替换"修好"了这个回退逻辑。但模式没匹配上,所以什么都没改,我也没发现。我写了一份完成报告,声称回退逻辑已移除,然后发给了第 3 轮。审查者的结论是:未修复。它引用了那行没变的代码,接着说它构建了一个内存中的浏览器桩,里面只放了一个别的博客的标签页,并确认 open() 仍然返回了它。在报告发现项底下,它补了一句:报告第 3 行和第 11 行"谎称当配置的博客不存在时 open() 会拒绝;lib.js:4 与这两处说法矛盾"。

那一轮就是"为什么需要第二个模型"的全部论据。我是作者,我相信修复已经进去了,我自己的报告也这么说。

如果审查者相信报告,或者它就是写代码的同一个模型,那大概率会直接放行。第四轮,真正移除了兜底方案,并把报告改写成说明第三轮实际发生了什么之后,审查者返回了 DONE。它没有只听我说“修好了”:它报告称,针对三种情况构造了内存中的标签页列表(只有另一个博客的标签、没有标签、配置的标签存在),并确认 open() 拒绝了前两种、选中了第三种。它还正确重申,线上服务器的持久化没有经过独立验证。这不是含糊其辞,而是只读审查者能知道的边界;我报告里的冒烟测试日志覆盖了这一点。

成本与耗时

四轮审查加对照组,总共约 79 万 token,其中约 77% 是缓存命中,因为交付物在轮次之间几乎没变。审查者实际耗时不到五分钟。我在轮次之间的修复时间比所有审查加起来还长,对我来说这个比例是对的:贵的部分应该是人决定怎么做,而不是机器读文件。作为参照,交付物本身是 11 个文件、不到 150 行。

哪里会失效

作者声称的修复并没有真正落地。这发生在第三轮,审查关卡能抓住它,只是因为审查者重新读取了当前文件,而不是相信报告。如果你让审查者去读作者声称做过的 diff,就抓不到这个问题。

契约写得太死。C1 产生了一条真实但无关紧要的发现:有未使用的导出。要不要这种发现,得提前决定。我保留了它,因为中途放宽标准,正是哈希要防的事。

审查范围蔓延。如果没有“只判断你提出的具体问题是否解决”这条限制,第二轮就会把所有东西重新审一遍。

CANNOT VERIFY 是一个真实答案。每一轮审查者都说,它无法验证线上服务器行为,因为它是只读的。这是对的,提供冒烟测试日志是作者的职责,我从第二轮起就这么做了。

轮数上限。我在自己的规则里设了三次,最后用了四轮,因为第三轮是我的错误,不是交付物的问题。

谁来拍板

提前定好谁来拍板,而且这个人不该是作者本人。

复现步骤

把交付物放进 deliverable/ 目录。编写 CONTRACT.md,列出二元通过标准,以及明确的“未完成”条件。在 COMPLETION_REPORT.md 中记录它的 SHA-256 哈希值,并附上作者的声明,每条声明都标注文件名和行号。从父目录启动第二个模型,给它只读沙箱,并用上面第一轮的提示词。把输出保存为 out_r1.md。

在相信第一轮结果之前,先复制整个文件夹,故意植入一个语法错误、一个 TODO 标记,以及对契约文件做一处一词的修改,然后用同样的提示词再跑一遍。它必须给出“未完成”,并指出哈希值对不上。

修复之后,用新的引用和同一个哈希值重写报告,然后在第 N+1 轮中加上“已修复 / 未修复”那一行。到“完成”或到达轮次上限就停。如果撞上上限,写下原因。

接下来

本次运行产出的契约、提示词,以及全部五份审查输出,都是我自己的东西,除了路径做过删减之外没有编辑。等我把这套工具再用第二次之后就会发布出来,因为在一个交付物上拿到一次“完成”,实在算不上什么战绩。

接下来我想弄清楚的是,这套关卡在超过 150 行的东西上是否依然站得住。我猜到时候,审查者那份“你读了哪些文件”的清单会变成最该盯住的地方。

本文在 AI 辅助下起草,之后由人工编辑并测试。所有数字都来自我在所示日期的实际运行。

查看原文