AI 辅助开发

为什么应该由另一个模型来审查 AI 编写的代码

模型审查自身的输出会带有自身的盲点。将差异 (diff) 发送给其他提供商的独立模型,并交叉检查结果。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

当一个 AI 模型编写了一个补丁,然后由同一个模型来审查时,对于那些重要的错误来说,这种审查几乎毫无价值。生成代码的模型也生成了其背后的推理过程,因此它会将这套推理带入审查中,并确认自己的工作。如果它幻觉出一个不存在的 API,它会认为该调用是正确的。如果它误读了规范,它会依据同样错误的理解来评估代码。这就像一个人校对自己的文章时,会略过那个已经读了十遍的拼写错误,只不过这是机器版本。

解决方法不是一个更好的提示。而是来自一个没有编写该代码的模型的第二意见,理想情况下,该模型来自不同的提供商,并基于不同的数据进行训练。将同一个 diff 发送给两到三个独立的模型,询问每一个模型代码中的错误和风险,然后用一个固定的规则来结合它们的结论。在它们的盲点不重叠的地方,交叉检查就能捕捉到单个审查者会忽略的问题。这篇文章是关于如何运行这种检查、它的成本是多少,以及何时单一审查已经足够。

自我评审继承了作者的盲点

代码评审只有在评审者能看到作者看不到的东西时才有用。两个人协作之所以有效,是因为他们有不同的心智模型,对过去不同 bug 留下的不同“伤疤”,以及对代码预期行为的不同假设。评审者会标记出作者从未设想过的情况。

一个模型评审自己的输出时,完全不具备这种距离感。它对代码的“看法”是生成代码时所用的相同权重、相同训练数据,以及通常相同的上下文窗口的延续。在它写完函数后,立刻问它“这里有 bug 吗?”,这无异于让作者反对作者自己。它会发现一些表面问题,比如一个缺失的 nil 检查、一个明显的拼写错误,但源于错误假设的微妙逻辑错误往往会幸存下来,因为评审过程继承了那个假设。

根据经验,失败之处往往集中在几个地方。一个虚构的方法或字段能通过自我评审,因为模型仍然相信该方法存在。边界条件下的差一错误能通过,因为模型在两次处理中对边界的看法是同一个错误看法。一个安全漏洞,比如说一个缺失的授权检查,能通过,因为模型两次都只关注了正常路径(happy path)。这些恰恰是那些后期捕获成本高昂的缺陷,也恰恰是自我评审最不擅长发现的缺陷。

不同的模型在不同的地方会失败

第二个模型之所以有帮助,是因为模型之间不会共享同一套盲点。每一个模型都使用不同的数据组合进行训练,以不同的目标进行调整,并在谨慎与自信之间的权衡中处于不同的位置。一个模型擅长发现并发风险,但在 SQL 方面较弱。另一个模型能很好地读取数据流,但会放过竞态条件。它们的错误并不相同,而这正是关键所在。

这与集成模型胜过单个分类器的道理相同。如果三个审查者各自漏掉了 30% 的真实缺陷,但他们漏掉的是不同的 30%,那么所有三个人都漏掉同一个特定 bug 的概率将远低于 30%。独立的错误会相互抵消。这里的难点在于“独立”这个词,而且这是一个实实在在的难点。来自同一家族的两个模型,或对同一模型进行两次提示,并不会产生独立的错误。它们只会更自信地给你两次相同的错误。要获得这种好处,你需要真正多样化的来源:不同的提供商、不同的模型家族,而不是同一模型的两种不同温度设置。

因此,设计目标不是“更多审查”。而是“其错误不相关的审查”。来自一个真正不同模型的单次审查,比对作者模型进行五次重新生成更有价值。

工作流分步详解

这个流程很简单。一个模型编写,多个独立模型评判,一条规则整合评判结果,再由人类解决分歧。

步骤 执行者 输出
1. 实现 作者模型 diff 或补丁
2. 并行审查 N 个独立模型,均非作者模型 每个模型的结论:通过 (GO) 或不通过 (NO-GO),外加审查发现
3. 应用共识规则 自动化 通过、失败或上报
4. 解决分歧 人类 对分歧结论做出最终决定

该工作流的有效性得益于两个特性。审查者独立运行,因此它们不会相互锚定。并且,每个审查者都会收到相同的中性提示,因此“不通过”(NO-GO) 对所有模型来说含义相同。要将作者模型完全排除在审查池之外。它的投票是你已经拥有的,也是你最不信任的。

其伪代码形式如下:

def multi_model_review(diff, author_model, reviewers, rule):
    verdicts = []
    for model in reviewers:            # reviewers excludes author_model
        v = model.review(
            diff=diff,
            prompt="List concrete bugs, security risks, and correctness "
                   "issues in this change. Then answer GO or NO-GO.",
        )
        verdicts.append(v)

    passed = rule(verdicts)            # unanimous or majority, see below
    if passed is UNDECIDED:
        return escalate_to_human(diff, verdicts)
    return passed, verdicts

review 调用返回两项内容:一个具体审查发现的列表和一个“通过”(GO) 或“不通过”(NO-GO) 的结论。当审查失败时,人类阅读的就是这些审查发现。而“通过”或“不通过”的结论则供规则使用。

选择与风险相匹配的共识规则

将多个评判结果转变为一个决策的规则,是你调整严格度的地方。有两种合理的默认设置,以及介于两者之间的一个范围。

“全体通过”是严格规则:只有当每个审阅者都表示“通过”时,变更才会通过,而一个“不通过”就会阻止它。这会最大化对错误的召回率,你能捕捉到任何模型能发现的任何问题,代价是更多的误报,因为一个过于谨慎的审阅者就会阻止变更。将其用于难以撤销的代码。

“多数通过”是宽松规则:如果大多数审阅者表示“通过”,变更就会通过。来自一个倾向于“狼来了”的模型的单独一个“不通过”并不会阻止合并,但三个中的两个就某个问题达成一致则会阻止合并。这用稍低的错误捕捉能力换取了更小的阻力。将其用于普通变更,在这类变更中,遗漏的问题可以在下一次提交中以很低的成本修复。

def unanimous(verdicts):
    if all(v.decision == "GO" for v in verdicts):
        return PASS
    if all(v.decision == "NO_GO" for v in verdicts):
        return FAIL
    return UNDECIDED          # split -> human decides

def majority(verdicts):
    gos = sum(v.decision == "GO" for v in verdicts)
    if gos > len(verdicts) / 2:
        return PASS
    return FAIL

注意“全体通过”规则在出现分歧时是如何处理的:它不会悄悄地选择一方,而是进行上报。有能力的、独立的审阅者之间的分歧是一个强烈的信号,表明该变更确实存在模糊不清之处,而这恰恰是值得人类花一分钟时间去处理的情况。将分歧处理为“嗯,少数服从多数”会丢掉整个实践中最有价值的产出。

对抗性提示优于“这看起来对吗?”

怎么问和问谁同样重要。默认的审查提示“这看起来正确吗?”,会引导模型表示同意,因为同意是最省力的补全方式。你得到的是确认,而非审视。有两种调整可以对抗这种情况。

第一种是让审查者反驳代码。不要说“审查这个”,而是要求“你的任务是找出这项变更有何错误。给我最有力的证据证明它存在 bug。”将任务设定为反驳,可以减少模型倾向于同意的拉力。现在,模型会因为发现缺陷而获得奖励,而不是因为认可差异(diff),并且它会提出在中性提示下可能会忽略的问题。你不是要求它找出的缺陷必须是正确的,而是要求它努力寻找,之后再由人工过滤掉误报。

第二种是给每个审查者一个不同的视角。与其进行三次笼统的审查,不如分配角色:一个审查者根据规范检查正确性,一个检查安全性和输入处理,一个检查性能和资源使用情况。同一个差异(diff),三个角度。这有目的地扩大了覆盖范围,并避免了两个审查者将全部精力花在同一个明显问题上,而第三个领域却无人问津。

Reviewer A: "Find correctness bugs. Where does this violate the stated behavior?"
Reviewer B: "Find security holes. Assume the input is hostile."
Reviewer C: "Find performance and resource problems under load."

反驳和角色多样性这两种方法可以叠加使用。让一个与作者所用模型不同的模型扮演对抗性安全审查员,这几乎是仅靠自动化所能达到的、离自我确认最远的状态了。

成本是什么,以及何时跳过

这些都不是免费的。每次变更,你都需要额外运行模型调用两到三次,因此你需要支付两到三倍的 token 费用,并且会增加延迟,因为即使它们并行运行,最慢的审查者也会决定整体的步调。对于一个微小或可逆的变更,这种开销几乎带不来任何好处。修复日志行中的一个拼写错误不需要三堂会审。

其价值体现在那些重要且难以回滚的变更上:

  • 就地重写数据的数据库迁移。
  • 部署和基础设施代码,其中的错误会导致生产环境宕机。
  • 授权或计费路径,其中一个细微的错误会引发安全或金钱问题。
  • 单向变更,即任何一旦发布就无法干净地回滚的东西。

对于这些情况,与 bug 的成本相比,三次模型调用的成本微不足道,而严格的一致同意规则也值得它带来的误报。对于一个隐藏在功能开关后面的常规变更,你可以在几秒钟内关闭它,那么进行一次审查,或者不审查,都是正确的选择。让流程与影响范围相匹配。

分层策略可以在每次都无需思考的情况下捕捉到这一点:

变更类型 审查者数量 规则
微不足道或可逆 1(或作者自查) 仅供参考
常规功能开发 2 个独立审查者 多数同意即可
迁移、部署、授权、计费 3 个独立审查者 一致同意

失败模式:达成一致的模型可能全是错的

这种技术的真实局限在于相关的盲点。只有当错误确实是相互独立时,它们才会相互抵消。如果你池中的每个模型都从公共互联网上那些流行但有问题的相同示例中学到了相同的错误用法,那么它们全都会重复这个错误,并且会一致且自信地批准它。共识是证据,而非证明。三个模型亮绿灯意味着这三个模型没有发现问题,但这与不存在问题不是一回事。

对于最新类型的 bug 和领域性最强的 bug 而言,这一点尤为重要。一个全新框架中的微小缺陷,或是一条只存在于你公司内部而未出现在任何训练集中的业务规则,对所有模型来说都是同时不可见的。再多的交叉检查也无法凭空变出所有审查者都不具备的知识。多模型审查减少了漏网的 bug 类别,但并不能将其完全清除。

因此,在风险较高、有必要这样做的情况下,要让人类留在流程中,并且要阅读实际的审查发现,而不仅仅是“通过”或“不通过”的统计结果。这些结论是一个过滤器,它能让人的注意力发挥更大作用,而不是替代品。使用一个不同的模型来审查 AI 编写的代码,因为有第二组盲点比只有一组要好。只是不要把机器之间的一致意见误认为是真相。