通过 12 个审查者对三个已合并的 Copilot PR 进行实证研究,发现 AI 审查的主要问题不是幻觉,而是忽略代码库已有知识,提供了具体修复方向。
我预先注册了一项小规模研究,架设了一条包含 12 个审查员的流水线,对三家大型 .NET 组织中已合并的三个 Copilot PR 进行分析——最终发现的失败模式,并非人人都在谈论的那种。
先披露利益相关:我是 review-pro(开源审查系统)的维护者,本文使用的工具正是它。这篇文章之所以存在,是因为我想知道它的前提假设在真实数据面前能否站住脚。部分结论没能站住——这些也会写进文中,还包括工具帮我发现的两个我自己判断错误的案例。
你让一个 Agent 写功能,再让一个 Agent 审查它。结果往往在两个方向上都没用:要么是四十条挑剔的琐碎意见,要么是一路欢快地点头通过。
人们解释 AI 生成的代码为何需要专项审查时,惯用的说法是幻觉——虚构不存在的 API、导入不存在的包、定义没人见过的配置项。我发行的工具里就有一个专门审查这类问题的 reviewer,所以我有充分的动机去找它。
我在最诚实的地方寻找它:真实维护者已经合并到正经代码库中的 Agent 提交的 PR。
在运行任何东西之前,我先写下了什么算"找到"的标准——一份预注册文档,包含了语料库标准、分类桶和反作弊规则(首次运行算结果、看到发现后不能删除 diff、无论多少都要公布误报率、阴性结果也要发布)。完整文档和每个案例的记录都是公开的;下文没有任何内容是在看到结果后决定的,有两处事后才确认的地方在文档中标注为"修订"。
工具说明:review-pro 不是让一个 LLM 来审查 diff。分流关卡先读取变更内容,然后只调度相关的专业 reviewer——每个 reviewer 只有一个 mandate 和自己范围内的上下文——随后综合关卡去重重叠的发现、按领域所有权解决冲突,输出一份裁定。最重要的规则:reviewer 必须先在代码库中定位证据才能提出主张;未经核实的"这个看起来有问题"是他们评分表里明令禁止的。
diff
└── triage — which reviewers does this change actually need?
├── correctness ┐
├── api-contract │ only the relevant specialists,
├── tests │ in parallel — each must locate
├── craft │ repo evidence for every claim
└── ai-antipatterns ┘ (12 exist; the chore diff got 4)
└── synthesis — dedup, resolve conflicts, one verdict
这是在测的工具形态,不只是工具本身:专业的、有证据要求的审查,能否发现单个 diff 阅读关卡发现不了的东西。
语料库:来自 GitHub Copilot 编程 agent 提交的已合并 PR(作者:app/copilot-swe-agent——GitHub 自己做归因,不是靠"这个看起来像 AI 写的"的感觉),来自成熟组织,变更行数 30–400 行,按时间顺序取样而非挑肥拣瘦。三个案例:
一个 .NET 版本库——71 行,在核心查询路径中加入类似 HEAD~2 风格的祖先解析。合并时零审查评论、审批为空,12/12 CI 全绿。
一个广泛使用的 AI 扩展库——125 行,删除了一个针对上游 SDK bug 的原始 JSON 变通方案,外加一个依赖升级。合并时有两个人的审批和内联讨论。
一个大型应用框架的 E2E 测试基础设施——48 行超时加固。一个杂活 diff,有意纳入作为噪声基准测试。
我在文中对这些仓库做了匿名化处理,因为论点讲的是模式,不是点名合并了这些 PR 的维护者。完整记录(含 PR 链接)随预注册文档一起发布,因为"经过手工验证"这件事只有在你能够核查时才值钱;文章正文保持匿名是为了让讨论聚焦于模式而非个人。
在任何审查运行之前就有一个观察结果,记录在预注册文档中,因为它与我的核心论点相悖:成熟组织中已合并的 agent PR 高度偏向机械性操作。版本号提升、lint 修复、依赖更新、禁用一个不稳定的测试。如果大多数实际合并上线的 agent 代码都是机械性的,那么"自信地虚构一个 API"这类问题的覆盖面就比业界讨论的要窄得多。
结果 1:在这个语料库中,可证伪的类别只触发了一次—— hallucination 本身从未触发
我工具中的 AI 反模式 reviewer 有三个可证伪的类别——claims that are objectively true or false,每个发现我都能手工核实:
这些 agent 写的每个符号、每个导入、每个配置项都真实存在。我手工检查了;reviewer 独立检查了;我们达成了一致。唯一一次命中是一个依赖升级,其声称的理由站不住脚——详见下文,因为这是这堆案例中最有意思的缺陷。
如果你来读本文是想找"LLM 捏造函数",这是个阴性结果,我会把它当阴性结果发表。在这个语料库中,已合并的 agent 代码根本不是那个样子。工具访问——agent 在导入前先 grep——很可能是一部分原因,但这是关于机制的假设,本研究没有测试它。
结果 2:真正触发的是什么
反复触发、且产生了所有本该改变合并决定的发现的类别,是被忽视的约定。不是在代码库没有的东西上发明新东西,而是没能注意到代码库已有的东西。
案例 1。新增的祖先遍历代码在一个方法内部调用了一个会抛异常的 API,而该方法的文档契约是"未找到时返回 null"。这本身只是一个普通的 bug。有意思的地方在于:代码库早已知道这个确切的故障模式。在别处有一个标准的守卫(guard),注释原文写道:"Our managed git implementation throws this on shallow clones." Agent 的代码放在那个守卫之外,所以在浅克隆 CI 环境中 get-version HEAD~1 从干净的"坏引用"退出码退化成了原始内部错误。同样的故事又说了一遍:代码库知道如何剥离 annotated tag——有一个 helper 做了这件事——而新代码不知道,所以 <release-tag>~1 完全无法解析。在一个标签驱动的版本工具中,一个 release tag 的祖先可以说是头条用例。
案例 2。这个 PR 删除了约 97 行防御性原始 JSON 解析,引用上游 SDK bug 已修复作为理由。
PR 假设的前提——SDK bug 已修复;变通方案可以移除。 upstream 源码实际显示——修复只覆盖了变通方案防护的两个字段中的一个。这意味着在线路上——"created_at": null 仍然会抛出这个 PR 声称已消失的确切异常类。审查后果——采用类型化 API 是对的;因为一个单字段修复就退役整个 guard 是不对的。
我从 SDK 在两个版本标签处的生成反序列化器中核对了中间两行:
// "bytes" — null guard present in BOTH the old and new SDK version
if (prop.NameEquals("bytes"u8))
{
if (prop.Value.ValueKind == JsonValueKind.Null) { sizeInBytes = null; continue; }
...
}
// "created_at" — no guard in either version, non-nullable target
if (prop.NameEquals("created_at"u8))
{
createdAt = DateTimeOffset.FromUnixTimeSeconds(prop.Value.GetInt64());
continue;
}
以及可证伪类别的命中:证明这一切合理化的版本升级对于其声称的目的来说是不必要的。修复已经存在于代码库所在的那个版本中。升级之所以需要,只是因为 agent 把一个调用改写成了新版本 API 的形态——一个对已发布包的自伤式依赖变更,却被包装成 bug 修复的必需品。两位人类审查员批准了它。
案例 3。即使是这个杂活 diff 也有一个类似的问题。新超时常量的文档注释说它"镜像了一个已有 helper 设置的显式预算"。但它把方向搞反了——已有 helper 是从 wait 推导出 budget;新代码是从 budget 推导出 wait。而且这个常量被应用到了两个通过 dotnet run 启动的调用点,而它所校准的环境变量在那里从未被设置,把一个文档不变量变成了死去的文字,把一个 2 分钟的失败变成了 4 分钟。
这里用一句话总结这个模式:缺陷不在于 agent 写了什么,而在于 agent 不知道代码库早已知道什么。每一个发现的证据都存在于 diff 从未触碰过的文件中——另一个模块里的一个 guard、一个上游反序列化器、一个兄弟 helper 的耦合方向。这些正是人类扫读 diff 或只审查 diff 的 agent 都无法捕捉它们的原因。
结果 3:绿色 CI 认证不了任何重要的事情
案例 2 的测试套件很不错——900+ 行,用假 HTTP handler 驱动真实的 SDK。但它仍然无法测试这个 PR 的核心前提:
项目中没有一个 fixture 发送了显式的 "bytes": null。它们都省略了这个字段——一条不同的反序列化器路径。无论上游 bug 是否修复,测试套件的通过情况完全一样。
项目中唯一的 has_more 值是 false。分页重写——用 SDK 自动分页替换仔细的手动分页——没有任何多页覆盖。上游 bug 是在自动分页过程中崩溃报告的。
案例 1 的 12 个绿色检查。处处 CI 全绿。没有一个案例中 CI 实际 exercise 了这个变更所携带的实际风险。"测试通过"和"有人追溯了浅克隆路径"是两个不同的主张,而且只有一个是廉价的。
结果 4:工具纠正了我。两次。
每次调度前我都写了属于自己的 ground-truth notes,这样我就能对照一次独立阅读来给工具打分。尴尬的是,打分是双向的:
我把 HEAD~2000000000 标记为无限循环。两位 reviewer 独立追溯发现那个循环在根 commit 处退出——受历史深度限制,而非用户输入——两人都引用了 PR 自己的 HEAD~999 测试作为证明。我错了。
我把一个硬编码的环境变量名标记为代码库"有一个标准常量"。三位 reviewer 确定那个常量是内部的,在一个测试项目不引用的程序集中,InternalsVisibleTo 只授予了另外两个项目——而字面量是那个文件预先存在的惯用法。我又错了。
我这两个误报恰好就是整篇文章在谈论的那类自信但未经证实的 claim。捕捉到它们的不是我的 discipline;而是 reviewer 评分表里的一条规则:没有定位到的、经过核实的证据,就不能有发现。如果你从这篇文章里只带走一个实现思路,就带走这一条。
流量也是双向的:一位 reviewer 读了 PR 的提交历史(我只读了 diff),发现 PR 自己的最后一个提交是一个 build 修复, necessitated by its hand-rolled index arithmetic——一个更简单实现的证据,就在这个 PR 本身里面。
坦诚节。有三件事,都进了我的 issue tracker:
一个发现理由虽然对了但原因错了。一位 reviewer 正确地标记了缺失的多页测试覆盖,但声称测试 harness 需要重构才能支持。不对——harness 已经接受一个 request-predicate 函数,其他测试已经在用。读者按这个补救方案行事会做一个不必要的重构。核心 claim 正确,支持 claim 错误;我把这个和干净的真正阳性分开计数。
离开代码库不是自动的。两位 reviewer 正确地将案例 2 的前提标记为"代码库未验证"——但没有自己去检查上游 SDK。做到这点的两位(并且做对了)包括一位我明确告诉过他可以去的。代码库接地验证是可靠的;外部事实核查目前取决于你给 reviewer 的 mandate。
n = 3。这是我在预注册规则下观察到的模式,不是已证明的群体声明。我真心希望有人用相同的协议在 Rust 或 TypeScript 语料库上跑一遍然后发表不同意见。
为什么是十二个 reviewer 而不是一个
形态在方法节里已经勾勒过了;这里说为什么数据支持它:
最有力的案例 1 发现由来自四个不同角度(契约、错误路径、可维护性、约定)的四位 reviewer 独立产生,由一个综合关卡折叠成一个条目。这种收敛是你从一个上下文中得不到的信号。
Reviewer 有分歧——一位标记了一个部分 rollout,另一位把同一个观察范围划为已有而排除了。两者都有防御性;综合关卡按领域所有权解决它。一个单独的 agent 永远不会 even surface 这种张力。
沉默保持廉价。在杂活 diff 上,分流调度了 4 个 reviewer 而不是 7 个,一个返回零发现,另一个明确拒绝五个本可以提出的发现并附有理由。噪声是所有人都预测的扇出审查失败模式;噪声基准正是我最想测量的,而且它守住了。
Agent 代码失败的分布已经转移到了某个业界讨论还没有跟上的地方。在这个语料库中,至少不是发明。而是在没有代码库累积知识记忆的情况下写出的局部可信代码:某人在一次生产事故后加的 guard、那个 peel helper、一个兄弟函数建立的耦合方向。每一个都在 diff 中不可见,只能通过追踪 diff 触及的内容来发现。
这是一个可审查的性质。但这意味着审查必须离开 diff——搜索代码库、阅读调用者、检查上游源码——而且意味着"已审查"必须意味着比绿色 CI 上的一个批准更多的东西。Diff 是变更所在的地方。代码库是证据所在的地方。
如果你想自己试试,这是运行起来的样貌——安装一次,然后在你的 agent 工具的分支上调用这个 skill:
/plugin marketplace add tufantunc/review-pro # Claude Code
/plugin install review-pro@review-pro
# then, in a session on the branch under review:
"review this branch with review-pro"
这是发现长什么样。这是真的——第一个试点案例的发现,从完整记录中压缩而来:
- severity: High
category: api-contract.breaking
file: src/.../ManagedGit/GitRepository.cs
line: 379
title: new `~` path makes public Lookup throw where its
documented contract is "otherwise null"
evidence: GetCommit (line 329) throws GitException on a missing
object; every pre-existing path in Lookup returns null
impact: get-version HEAD~1 in a shallow CI clone regresses from
a clean BadGitRef exit to a raw InternalError
remedy: resolve the parent via the non-throwing TryGetObjectBySha
before walking, keeping the documented null contract
confidence: high
这个工具是 MIT 协议,纯 Markdown,运行在 Claude Code / opencode / Cursor / Codex(repo)上。你能发给我的最有用的东西不是 star——而是一个误报或一个漏掉的发现,带上代码。有一个 issue 模板专门为这个而设;评分表从真实例子中校准,而本文就是当你把校准过程本身指向工具时看起来的样子。
预注册文档、修订和每个案例的完整发现:studies/2026-08-copilot-pr-pilot。