AI代码审查实际测评:14条意见仅8条正确,2条破坏构建
作者对120行C++补丁进行AI审查,对14条意见逐一打补丁测试,发现8条正确、2条破坏构建、1条降低性能,证明AI审查意见本质是假设而非事实。
作者对120行C++补丁进行AI审查,对14条意见逐一打补丁测试,发现8条正确、2条破坏构建、1条降低性能,证明AI审查意见本质是假设而非事实。
结论先行:一个免费模型审查了一个 120 行的 C++ 补丁,产生了 14 条评论。我将每条评论作为补丁应用,并运行了测试套件。8 条评论正确。2 条导致构建失败。1 条让基准测试变慢。2 条什么都没改变。1 条根本无法转化为补丁。该模型的置信度与正确性无关。
代码审查评论是对代码的一个假设。验证代码假设最便宜的方式是应用它并运行测试。阅读评论并点头是模式匹配。我下面描述的审计是测量。
背景:一个需要第二双眼睛审视的补丁
这个补丁是对 config_loader 类的一个小型优化。原解析器使用 std::istringstream 分割每一行:
std::vector<std::pair<std::string, std::string>> parse(std::istream& in) {
std::vector<std::pair<std::string, std::string>> out;
std::string line;
while (std::getline(in, line)) {
std::istringstream ss(line);
std::string key, value;
std::getline(ss, key, '=');
std::getline(ss, value);
out.emplace_back(std::move(key), std::move(value));
}
return out;
}
替换方案使用 find 和 substr 手动扫描字符串:
auto eq = line.find('=');
if (eq == std::string::npos) continue;
out.emplace_back(line.substr(0, eq), line.substr(eq + 1));
大约 120 行被修改。单元测试通过。微基准测试显示在 10,000 行配置文件上提速 2.3 倍。
补丁没问题。问题是免费模型的审查是否也没问题。
方法:每条评论都是一次实验
我通过 MonkeyCode 的 API 将 diff 和周围文件发送给一个免费模型,要求提供具体、可操作的审查评论。披露:本文是 MonkeyCode 产品推广的一部分。
模型返回了 14 条评论。每一条都经过三个步骤。
第一步:将评论转化为补丁。一条评论只有在能表达为 diff 时才是可操作的。"这不是线程安全的"不是补丁。"用 std::string_view 替代 std::istream&"是补丁。不能变成 diff 的评论进入未验证桶。
第二步:将补丁应用到干净的 checkout。每条评论成为自己的分支。测试套件和微基准测试针对该分支运行。
第三步:记录结果。五种结果:通过(测试绿灯,基准测试未变差)、失败_测试(构建或测试失败)、失败_基准(测试绿灯,基准测试退化)、无操作(没有可观察的变化)和未验证。
artifact:review_audit.sh
审计脚本故意写得笨拙。它遍历补丁文件目录,将每个补丁应用到干净的 checkout,运行测试命令,并写入 CSV。
#!/usr/bin/env bash
# review_audit.sh <repo> <patches-dir> <test-cmd> <bench-cmd>
set -euo pipefail
REPO_DIR="$1"
PATCHES_DIR="$2"
TEST_CMD="$3"
BENCH_CMD="$4"
RESULTS="audit_results.csv"
echo "comment_id,status,notes" > "$RESULTS"
for patch in "$PATCHES_DIR"/*.patch; do
id="$(basename "$patch" .patch)"
git -C "$REPO_DIR" checkout -- .
git -C "$REPO_DIR" clean -fdq
if ! git -C "$REPO_DIR" apply --check "$patch" 2>/dev/null; then
echo "$id,does_not_apply,"
continue
fi
git -C "$REPO_DIR" apply "$patch"
if ! (cd "$REPO_DIR" && eval "$TEST_CMD" >/tmp/audit_test.txt 2>&1); then
echo "$id,fails_tests,$(tail -1 /tmp/audit_test.txt)"
continue
fi
if ! (cd "$REPO_DIR" && eval "$BENCH_CMD" >/tmp/audit_bench.txt 2>&1); then
echo "$id,fails_benchmark,$(tail -1 /tmp/audit_bench.txt)"
continue
fi
echo "$id,passes,"
done >> "$RESULTS"
column -t -s, "$RESULTS"
本次运行的 BENCH_CMD 是一个小型包装器,将打过补丁的二进制文件与基线二进制文件进行比较,如果中位数退化超过 2% 则非零退出。该脚本自动化了昂贵的部分:构建、测试和每个评论的基准测试。便宜的部分——阅读每个通过的 diff 以区分真正的修复和化妆品式的重命名——留给人类。
14 条评论的分类如下:
脚本运行完毕后,我阅读了每个通过的 diff。其中两个是纯重命名,没有行为变化,所以我将它们重新分类为无操作。脚本的工作是廉价地过滤掉失败。人类的工作是阅读幸存者。
三个失败案例是最有趣的。
失败 1:string_view 评论。模型声称 parse() 应该接受 std::string_view,因为"调用者已经在内存中持有配置文本"。他们没有。调用者打开文件并传递 std::ifstream。补丁改变了签名,测试套件中的每个调用点都停止了编译。
失败 2:const 重载评论。模型建议添加 const std::istream& 的重载。它无法编译。std::getline 需要一个可修改的流,而 const std::istream& 无法绑定到非 const 的 getline 重载。模型忘记了标准库的工作方式。
失败 3:reserve 评论。模型建议在循环之前使用 out.reserve(1024),因为"配置文件可能很大"。补丁应用顺利。测试通过。基准测试慢了 18%:为 47 行的测试配置预留 1024 个条目,分配的内存超过了向量实际需要的大小。它通过了测试套件但未通过性能检查。
最后一个是最重要的教训。一条评论可以通过每个测试但仍然是错的。审计的好坏取决于它运行的套件——这就是为什么基准测试是审计的一部分,而不是事后想法。
错误评论的共同点
模型没有误读 diff。它围绕 diff 发明了上下文。它假设调用者在内存中持有字符串。它假设 const 流是一种有意义的类型。它假设配置文件很大。每个假设都是似是而非的。每个假设都是错的。
正确的评论则不同。它们指向具体的行和具体的输入:\r\n 处理、parse_key_value 中的双重复制、尾部空白缺少的 trim。它们扎根于存在的代码,而不是可能存在的代码。
置信度不是信号。string_view 评论是整篇审查中最自信的一条。它也是错得最离谱的。
为什么审计优于阅读审查
阅读审查评论很快。太快了。一条自信的错评论在你运行它之前感觉是正确的,而那时你已经花了精力将它整合到你对代码的模型中。审计将这个成本从你脑中转移到构建系统中。
免费服务器让这变得便宜。14 条评论意味着 14 次干净的 checkout、14 次构建、14 次测试运行、14 次基准测试。整个循环在 MonkeyCode 的免费服务器上无人值守地运行,而我做其他事情。当我回来时 CSV 已经在等着了。
局限性:谁不应该使用这个
审计测量的是测试套件和基准测试测量的内容。如果套件薄弱,审计就薄弱。一条修复了测试未覆盖的 bug 的评论会被标记为通过,而你不会知道。
审计无法评判架构。"考虑用状态机替代"按其性质是不可验证的。这并不使它错。它使它成为一场对话,而不是一个声明。
不要在没有人类阅读每个失败_测试结果的情况下对安全敏感代码使用此工作流。构建失败不是评论不好的证明。它是评论与当前代码冲突的证明。有时候评论是对的而代码是错的。
下次模型审查你的代码时,要求它提交补丁而不是段落。然后运行补丁。测试套件是审查评论的更好评判者——而不是写它的审查者——尤其是当审查者非常自信的时候。