作者用免费模型生成atoi实现,基础断言全过但存在三处缺陷:符号处理错误、越界访问、空指针崩溃。通过差分测试逐层验证单元测试、模糊测试、消毒剂后才安全合并。
每个我用过的免费模型都能在几秒内写出一个看起来像那么回事的 atoi。看起来像不等于正确,而这两个词之间的距离恰好就是 Agent 生成的补丁隐藏 bug 的地方。本文为这种情况提供了一道具体的验证阶梯:单元测试、差异化测试、消毒剂(sanitizer),以及一张告诉你何时可以安全合并补丁的决策表。
我在一个真实补丁上跑了这道阶梯。该补丁由一个免费模型生成,看起来很干净,通过了几个显而易见的测试,但仍然包含三个不同的缺陷。阶梯在补丁触及生产环境之前将三者全部捕获。
任务很简单:实现一个行为类似于 C 标准库 atoi 的函数。这个免费模型一次就返回了如下实现。
// my_atoi.cpp
int my_atoi(const char* s) {
int sign = 1, result = 0;
if (*s == '-') { sign = -1; ++s; }
while (*s >= '0' && *s <= '9') {
result = result * 10 + (*s - '0');
++s;
}
return sign * result;
}
乍一看,逻辑清晰易读,结构也很熟悉。函数处理前导负号、遍历数字并构建结果。它甚至对 "42" 和 "-7" 返回了正确的值。首批单元测试也印证了这种印象。
// test_basic.cpp
#include <cassert>
int my_atoi(const char* s);
int main() {
assert(my_atoi("42") == 42);
assert(my_atoi("-7") == -7);
assert(my_atoi("0") == 0);
assert(my_atoi("12345") == 12345);
return 0;
}
四条断言全部通过。如果评审在这里打住,这个补丁就会被合并。但它不应该被合并。
单元测试编码的是你已有的预期。它们很少给你惊喜,也永远不会捕获你忘记去想象的那个 case。对于解析器来说,被遗忘的 case 通常是:空白字符、显式加号、空字符串,以及整数溢出。
一个稍不留情面的测试套件立即暴露了前两个缺陷。
// test_edges.cpp
#include <cassert>
int my_atoi(const char* s);
int main() {
assert(my_atoi(" 42") == 42); // leading whitespace
assert(my_atoi("+7") == 7); // explicit plus
assert(my_atoi("") == 0); // empty string
return 0;
}
这个测试套件在第一个断言处就失败了。实现没有跳过前导空白,所以 " 42" 产生 0。加号同样被忽略,所以 "+7" 也产生 0。这些并非冷门的边缘 case,而是 C 标准对 atoi 契约的一部分。免费模型 просто 没有实现完整的契约。
单元测试捕获了明显的缺口。它们无法捕获溢出 bug,因为溢出只在非常大的输入下才会出现,而没有人会手动写那么大的输入。
差异化测试的做法是:将相同输入同时送给你的实现和一个可信的参考实现,然后比较输出。对于 atoi,参考实现就是同名标准库函数。比较是机械性的,所以你可以不费吹灰之力地跑上百万次输入。
以下程序生成随机字符串,分别喂给两个实现,并报告第一次不匹配。
// diff_test.cpp
#include <cstdlib>
#include <iostream>
#include <random>
#include <string>
int my_atoi(const char* s);
int main() {
std::mt19937 rng(20260826);
std::uniform_int_distribution<int> len_dist(0, 12);
std::uniform_int_distribution<int> char_dist(32, 126);
for (int i = 0; i < 100000; ++i) {
const int len = len_dist(rng);
std::string s;
for (int j = 0; j < len; ++j) {
s.push_back(static_cast<char>(char_dist(rng)));
}
const int expected = std::atoi(s.c_str());
const int actual = my_atoi(s.c_str());
if (expected != actual) {
std::cout << "Mismatch on input: " << s << "\n";
std::cout << "Expected: " << expected << "\n";
std::cout << "Actual: " << actual << "\n";
return 1;
}
}
std::cout << "All differential tests passed\n";
return 0;
}
g++ -std=c++17 -O2 diff_test.cpp my_atoi.cpp -o diff_test
./diff_test
差异化测试几乎立即就发现了一个不匹配。输入是一个很长的数字字符串,溢出了 int。标准库将结果钳制到实现定义的值,而 my_atoi 则通过有符号整数溢出回绕——这在 C++ 中是未定义行为。
差异化测试将一种不可见的未定义行为变成了一次可见的不匹配。这正是你对一个验证步骤所期望的。
差异化测试告诉你输出不同。消毒剂告诉你为什么。用 UndefinedBehaviorSanitizer 编译同样的实现,然后跑一个针对性的溢出输入。
g++ -std=c++17 -fsanitize=undefined -fno-sanitize-recover=all test_overflow.cpp my_atoi.cpp -o test_overflow
./test_overflow
消毒剂在 result = result * 10 + (*s - '0') 这一行报告了有符号整数溢出。根本原因得到确认:免费模型的补丁缺少溢出检查。
此时,验证阶梯已经产生了三个信号:单元测试失败、差异化测试失败、UBSan 失败。决策显而易见。但在真实工作流中,信号并不总是对齐的。一张决策表消除了猜测。
这张表故意收得很紧。任何一道关卡未通过的补丁都要被打回给模型,同时附上失败的测试作为反馈。模型再获得一次机会,阶梯重新跑一遍。根据我的经验,两到三次迭代通常就能产出一份通过全部三道关卡的补丁。
我在 MonkeyCode 的免费服务器选项上跑了完整的一道阶梯。免费模型访问生成补丁,免费服务器执行编译、差异化测试和消毒剂运行,而不需要占用本地机器。工作流与本地终端相同:克隆仓库、写测试文件、运行命令、读取输出。披露:本文作为 MonkeyCode 产品推广的一部分而准备。
实际优势在于,服务器变成了一个可重复的验证环境。你可以为每个模型生成的补丁运行相同的命令、保存日志,并在迭代之间比较结果。免费服务器不是 CI 系统,但它不需要是。它是一个让补丁赢得合并决策的地方。
差异化测试需要一个可信的参考实现。对于 atoi,标准库就是这个参考。但对于一个新算法,你可能没有参考。
消毒剂只能捕获它们设计用来检测的未定义行为。它们无法捕获技术上完全合法但逻辑错误的 bug。
随机输入生成可能遗漏罕见的分支。固定的种子使运行可复现,但也意味着每次跑的是同一个语料库。
决策表是保守的。它可能拒绝实际上正确但因测试太弱而失败的补丁。当补丁是由模型生成时,这是特性,不是 bug。
不要将这道阶梯用于一次性脚本、一次性迁移或下周就要删除的原型。搭建成本是真实的,收益只在补丁会在代码库中存活数月时才会显现。如果补丁很简单且失败模式无害,快速人工审查就够了。
对于任何涉及用户输入、解析或数值边界的东西,这道阶梯值得付出努力。一个免费模型可以在几秒内生成一个看起来像那么回事的解析器。验证阶梯是将"看起来像"变成"正确"的东西。
如果你想看看一个免费模型的补丁在你的代码上能走多远,免费服务器是一个低风险的地方来跑完全相同的流程。生成一个补丁,写一个差异化测试,让机器来做拒绝的工作。