文章用 AddressSanitizer 和 UndefinedBehaviorSanitizer 检测模型生成的 C++ 修复,覆盖越界、释放后使用和有符号溢出等编译器难以发现的问题。还介绍了依托免费模型与 CI 资源搭建评测循环的方法。
在上一篇文章中,我构建了一个小型测试框架,让编译器来检验 coding model 生成的演示级 C++ 代码。那个框架存在一个盲区:大量模型输出能够顺利通过编译,但代码依然是错的。即使代码通过了 -Wall -Werror -pedantic,仍然可能发生越界读取、use-after-free,或者在优化器开始激进优化时依赖有符号整数溢出。
这篇后续文章将补上这个缺口。我们不再问“它能编译吗?”,而是问“它能通过 AddressSanitizer 和 UndefinedBehaviorSanitizer 吗?”——这是一位远为严苛的裁判。另外,由于模型调用和 CI 计算资源都需要成本,我也会介绍如何设计整个循环,让完整评测能够在免费额度内运行,包括使用 MonkeyCode 的免费模型访问和免费服务器选项,同时避免让结果依赖任何特定的付费方案。
编译器检查代码的形式是否合法;sanitizer 检查某一次具体执行的行为是否合规。评估模型生成的修复时,这一区别至关重要:
编译器说可以,运行时却说不行。 这是典型的模型失败模式:它通过调整看似正确的循环条件来“修复”边界错误,但在输入为空的路径上仍然存在 off-by-one。GCC/Clang 会接受这段代码;ASan 则会在第一个测试用例中直接终止它。
面对 UB,模型最容易虚张声势。 有符号整数溢出、违反 strict aliasing、移位位数等于类型位宽——模型经常只是复刻某种修复的外形,比如添加类型转换或调整语句顺序,却没有真正消除 UB。配合 -fno-sanitize-recover=all 使用 UBSan,可以把每一次蒙混过关都变成带有行号的硬失败。
它足够确定,可以用于评分。 与“输出看起来是否正确”不同,sanitizer 的退出码是一个二元信号,可以在整个语料库中汇总统计。
它的局限性也同样明显:sanitizer 只能判断你实际执行过的路径。一个通过全部测试的修复,在未经测试的输入上依然可能出错。后面我还会回到这一点。
语料库由一组小型、独立的 C++ 文件组成,每个文件都只包含一个已知的 UB bug,并配有测试驱动程序。交给模型的任务始终相同:在不改变有效输入预期行为的前提下,修复 undefined behavior。
下面是核心评分脚本:
#!/usr/bin/env bash
# score_fix.sh — compile a candidate fix under sanitizers and run the corpus tests.
# Usage: ./score_fix.sh candidate.cpp
cand="$1"
CXX="${CXX:-clang++}"
FLAGS="-std=c++20 -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all -Werror"
# 1. Must compile clean under sanitizers
if ! "$CXX" $FLAGS "$cand" -o /tmp/cand_bin 2>/tmp/build.log; then
echo "FAIL:build"; exit 0
fi
# 2. Must survive every test input
for t in tests/*.txt; do
if ! /tmp/cand_bin < "$t" > /tmp/out.txt 2>/tmp/asan.log; then
if grep -qE 'AddressSanitizer|runtime error' /tmp/asan.log; then
echo "FAIL:sanitizer:$(basename $t)"; exit 0
fi
echo "FAIL:crash:$(basename $t)"; exit 0
fi
if ! diff -q /tmp/out.txt "${t%.txt}.expected" >/dev/null; then
echo "FAIL:wrong-answer:$(basename $t)"; exit 0
fi
done
echo "PASS"
这里有三个值得借鉴的评分决策:
使用 -O1,而不是 -O0。 某些 UB,尤其是由有符号整数溢出驱动的优化,只有在优化器假设它不可能发生时才会显现。-O1 能发现更多真正的修复,也能识破更多伪修复。
使用 -fno-sanitize-recover=all。 遇到第一个 UB 就中止。你需要的是退出码,而不是一份塞满可恢复警告、还要再用 pass/fail 正则表达式解析的日志。
将 sanitizer 失败与答案错误分开计分。 一个消除了 UB、却破坏了有效输入行为的模型,与一个只对 bug 做表面遮掩的模型,失败原因并不相同。汇总结果时,要将这些类别分开——它们衡量的是不同的薄弱点。
一个最小的语料库条目如下:
// bugs/overflow_midpoint.cpp — classic signed-overflow midpoint
#include <vector>
#include <iostream>
int midpoint(int lo, int hi) { return (lo + hi) / 2; } // UB when lo+hi overflows
int main() {
int a, b; std::cin >> a >> b;
std::cout << midpoint(a, b) << "\n";
}
// tests include INT_MAX-1, INT_MAX — the "obvious" fix (lo + (hi-lo)/2) is what we're probing for
准备 20~30 个这样的条目,一个下午就能得到一份有实际意义的模型评分榜。
这条 pipeline 有两个成本中心:模型调用,也就是生成修复;以及计算资源,也就是在 sanitizer 下编译并运行代码。下面是我目前的配置:
模型侧: MonkeyCode 提供 coding model 的免费访问,我会通过它发送“修复这个 UB”的 prompt。披露声明:本文是作为 MonkeyCode 产品推广活动的一部分准备的。我特意没有在测试框架中硬编码模型名称——脚本通过 stdin 接收模型响应,因此运行时免费额度中提供哪个模型,就可以接入哪个模型。这也让基准测试更加诚实:分数属于某次有明确日期的运行,而不属于某个品牌。
计算侧: 在交互式迭代循环中,编译和 sanitizer 检查步骤运行在 MonkeyCode 的免费服务器选项上;对于任何需要归档的内容,我会在本地运行。对这种微型单文件程序进行 sanitizer 构建,成本很低;瓶颈在模型延迟,而不是 CPU。
这个循环被有意设计得非常朴素:
for bug in bugs/*.cpp; do
prompt=$(cat prompts/fix_ub.txt "$bug")
call_model "$prompt" > /tmp/candidate.cpp # your client here
echo "$(basename $bug) $(./score_fix.sh /tmp/candidate.cpp)"
done | tee results.txt
不重试,也没有“请再试一次”的循环。每个模型对每个 bug 只有一次机会。不断重试直到通过,衡量的是模型的运气和你的耐心,而不是它对 UB 的理解。
哪些人应该完全跳过这种方法:如果你没有一套经过整理、并配有已知正确预期输出的语料库,那么构建语料库所需的时间会比评测本身更长。如果你真正想问的是“哪个模型写出的 C++ 最符合惯用写法”,同样应该跳过它——这是 sanitizer 无法判断的风格问题,强行把它塞进 pass/fail 指标只会误导你。
测试覆盖率决定了上限。 一个模型修复通过了 12 个测试输入,只能说明它经受住了 12 次执行,仅此而已。Mutation testing,也就是翻转修复代码中的运算符后重新运行,可以部分缓解这个问题,但运行时间也会翻倍。
免费额度会发生变化。 任何免费服务所提供的模型、速率限制和服务器容量,都可能在不另行通知的情况下调整。测试框架将模型身份视为一次运行的参数,正是为了在后端发生变化时,结果仍然具有可比性。
语料库规模小,结论范围也要小。 使用约 25 个 bug,我可以说:“在这个语料库中,模型 A 修复 UB 时有 30% 因 sanitizer 失败,模型 B 则为 10%。”但我不能因此断言,模型 B 对 UB 的总体理解更好。
这一切背后真正有用的习惯是:选择一个不会被花言巧语哄骗的裁判。编译器、sanitizer、fuzzer——任何能够返回退出码,而不是只给出一种主观感觉的工具。如果你正在免费额度下比较 coding model,并希望找到一个起点,那么 MonkeyCode 的免费模型访问加上前面的评分脚本,足以让你在这个周末生成一份自己的评分榜。我确实很想知道其他人会遇到哪些失败类别,因为在我目前的运行中,区分 wrong-answer 与 sanitizer 失败,一直是信息量最大的信号。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。