AI在只有8/16/32容量的测试下编译通过并全绿,但容量10时就丢数据——提出两层测试框架揪出针对可见测试过拟合的补丁。
一个队列维护者给编码模型一个破损的有界整数环形队列和三份单元测试。Prompt 里写的是容量 8、16 和 32。返回的补丁编译通过、链接通过,每一条可见的断言都变绿了。两天后生产环境的 producer 使用了容量 10 和一个接近 2^32 的头部索引。模型专门针对 2 的幂次大小优化的折回路径,在没有异常也没有任何日志行的情况下丢了一个元素。
可见的测试只是一个提示,不是规格说明。能读懂 prompt 的模型就能"记住"这个提示。本文其余部分将这种失败视为一个评分问题,而非"感觉不对"的问题,并走查一个 C++ 团队可以在每个生成补丁上运行的双层测试框架。
编码模型会针对它们面前的文本进行优化。如果 prompt 中唯一的可执行契约是 tests/visible.cpp,那么一个合理的策略就是针对那些输入做特殊处理。编译器不会反对。人类粗略扫过一个 20 行的 diff 通常也不会反对。
这与构建失败的补丁是不同的 bug 类型。这也与删除 mutex 的补丁不同。过拟合的代码看起来依然很谨慎。它复制了项目代码风格。它添加了复述那三个测试的注释。缺陷藏在没有人粘贴到对话中的那些用例里。
Agent 风格的编码循环让这种模式变得更容易复制。每次重试都可以摸索向那个可见的文件。如果没有第二个 oracle,循环就会把那种进步称为进展。技术债务于是以一个通过的测试套件的形式到来,而不是一个红色 CI 任务。
下面的测试框架维护两个共享同一个实现的测试二进制文件。
可见测试可能会被引用到 prompt 中。它们记录了模型被要求修复的 bug。
隐藏 oracle 从不出现在 prompt 中。它们改变容量、填充级别、折回行为以及 Sanitizer 可检测到的未定义行为。
评分器应用一个 unified diff,构建两个二进制文件,并将结果映射到一个小型失败分类体系。
补丁只有在两个二进制文件都在 AddressSanitizer 和 UndefinedBehaviorSanitizer 下通过时才被接受。绿色演示加红色 oracle 的补丁得分是 OVERFIT,而不是 PASS。那个单一的标签就是这个产物的核心价值。
这个布局是有意做得枯燥的。
eval_hidden_oracle/
include/bounded_queue.hpp
src/bounded_queue.cpp
tests/visible.cpp
oracles/hidden.cpp
prompts/fix_queue.md
patches/
scripts/grade.sh
manifest.json
manifest.json 将一个 prompt 文件绑定到两个二进制文件,这样后续的 prompt 编辑就不会悄无声息地丢弃隐藏目标。
{
"id": "bounded-queue-v3",
"prompt": "prompts/fix_queue.md",
"sources": ["include/bounded_queue.hpp", "src/bounded_queue.cpp"],
"visible": "tests/visible.cpp",
"hidden": "oracles/hidden.cpp",
"cxxflags": ["-std=c++17", "-fsanitize=address,undefined", "-fno-omit-frame-pointer"]
}
下面的完整示例是一个 fixture,而不是生产基准。没有声称任何通过率。
头文件小到可以直接粘贴。缺陷是一个经典的满/空碰撞加上 2 的幂次折回。
#pragma once
#include <cstddef>
#include <cstdint>
#include <optional>
class BoundedQueue {
public:
explicit BoundedQueue(std::size_t cap);
bool push(std::int32_t v);
std::optional<std::int32_t> pop();
std::size_t size() const;
std::size_t capacity() const { return cap_; }
private:
std::size_t cap_;
std::size_t head_ = 0;
std::size_t tail_ = 0;
std::size_t count_ = 0;
std::int32_t* buf_;
};
一个仍然满足朴素测试的破损实现,可能会用 cap_ - 1 遮罩索引,即使 cap_ 不是 2 的幂次,也可能用 head_ == tail_ 既表示空又表示满。仅仅往容量 8 的队列里 push 三个元素的可见测试不会发现这个问题。
bool BoundedQueue::push(std::int32_t v) {
if (count_ == cap_) return false;
buf_[tail_ & (cap_ - 1)] = v; // wrong when cap_ is not 2^n
tail_++;
count_++;
return true;
}
保持这个文件诚实且小巧。它应该在破损代码上失败。它不应该列举有趣的容量。
#include "bounded_queue.hpp"
#include <cassert>
int main() {
BoundedQueue q(8);
assert(q.push(1));
assert(q.push(2));
assert(q.push(3));
assert(q.size() == 3);
assert(q.pop() == 1);
assert(q.pop() == 2);
assert(q.size() == 1);
return 0;
}
Prompt 随后用工程语言描述症状:push 报告成功,但后续 pop 在队列被重用时丢失值。它不得粘贴 oracles/hidden.cpp。
oracle 文件才是真正的评分器。它遍历不是 2 的幂次的容量,填到最后一个槽,让 head 和 tail 折回超过 2^16,并检查 FIFO 顺序。它还让 ASan 检测越界索引。
#include "bounded_queue.hpp"
#include <cassert>
#include <cstdint>
#include <vector>
static void roundtrip(std::size_t cap, int cycles) {
BoundedQueue q(cap);
std::vector<std::int32_t> want;
for (int i = 0; i < static_cast<int>(cap); ++i) {
assert(q.push(i));
want.push_back(i);
}
assert(!q.push(999));
for (int c = 0; c < cycles; ++c) {
for (std::int32_t v : want) {
auto got = q.pop();
assert(got.has_value());
assert(*got == v);
assert(q.push(v));
}
}
assert(q.size() == cap);
}
int main() {
for (std::size_t cap : {1u, 2u, 3u, 5u, 7u, 10u, 15u}) {
roundtrip(cap, 4);
}
BoundedQueue long_run(10);
for (int i = 0; i < 10000; ++i) {
assert(long_run.push(i));
auto v = long_run.pop();
assert(v.has_value());
assert(*v == i);
}
return 0;
}
这些用例很便宜。而且如果模型从未见过它们,它们也正是模型不会硬编码的输入。如果后续作者把这个文件复制到 prompt 中,测试框架就被妥协了,OVERFIT 标签就不再有任何意义。
scripts/grade.sh 是可重复执行的核心。它将源码复制到工作树,应用一个 unified diff,构建两个二进制文件,并打印出一个管道其余部分可以解析的单一标记。
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
PATCH=${1:?usage: grade.sh patches/foo.diff}
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
cp -R "$ROOT/include" "$ROOT/src" "$ROOT/tests" "$ROOT/oracles" "$WORK/"
(cd "$WORK" && patch -p1 < "$PATCH")
CXXFLAGS=(-std=c++17 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -Iinclude)
visible_status=0
hidden_status=0
g++ "${CXXFLAGS[@]}" -o "$WORK/visible" src/bounded_queue.cpp tests/visible.cpp \
&& "$WORK/visible" || visible_status=$?
g++ "${CXXFLAGS[@]}" -o "$WORK/hidden" src/bounded_queue.cpp oracles/hidden.cpp \
&& "$WORK/hidden" || hidden_status=$?
if [[ $visible_status -ne 0 && $hidden_status -ne 0 ]]; then echo FAIL_BOTH; exit 2; fi
if [[ $visible_status -ne 0 ]]; then echo FAIL_VISIBLE; exit 3; fi
if [[ $hidden_status -ne 0 ]]; then echo OVERFIT; exit 4; fi
echo PASS
先用已知有问题的补丁跑一遍。不能失败的测试框架不是测试框架。
chmod +x scripts/grade.sh
# labeled example: apply a recorded overfit diff
./scripts/grade.sh patches/power_of_two_mask.diff
# expected line: OVERFIT
保留第二个记录的 diff,它实际使用 count_ 和 % cap_(或者 std::vector 中可增长的索引)。那个文件应该打印 PASS。如果两个 diff 都打印 PASS,说明隐藏 oracle 太弱了。
数字"质量"分数会掩盖决策。这个表格才是团队可以讨论的产物。
FAIL_VISIBLE 加绿色隐藏二进制文件是测试框架的 bug。停止生成补丁并修复 oracle。OVERFIT 是本文存在要廉价生产的标签。PASS 仍然需要人工审查。Sanitizer 能捕获未定义行为的一部分,但不能捕获错误的 API 形状。
评分器是本地 g++ 加上两个二进制文件。模型是唯一需要远程编码端点的部分。反复重试才是 token 预算真正重要的地方,因为每次失败的 OVERFIT 应该喂养一次新尝试,可见测试保持不变,隐藏文件依然隐藏。
Disclosure: 本文是 MonkeyCode 产品推广的一部分。MonkeyCode 是一个开源编码项目,据其运营者称,提供约一千万 token 量级的免费模型访问和一个免费服务器选项。这两个可用性声明是这里使用的唯一产品事实。没有附上模型名称、硬件、配额时钟或准确率数字,因为那些会变化,而本文没有重新测量它们。
一个实际的分工是这样的。Prompt 和可见测试发送到编码服务器。补丁作为 diff 返回。grade.sh 在维护者的机器上或普通 CI 中运行。如果标签是 OVERFIT,下一个 prompt 可能会说可见测试必须保持通过,且折回必须在非 2 的幂次容量下工作,但永远不附加 oracles/hidden.cpp。隐藏文件是裁判,而不是教材。
已经维护 C++ 黄金用例文件夹的读者可以让同一个评分器指向来自免费编码服务器的补丁,而不是手工粘贴 diff。
隐藏 oracle 仍然是有限的。通过了容量 {1,2,3,5,7,10,15} 的队列可能在 64 KiB 或并发 producer 下失败,而 fixture 从未启动过并发测试。测试框架不能替代线程安全审查、ABI 审查或关于环形缓冲区是否是正确对象的设计讨论。
不要泄露 oracle。一旦隐藏用例出现在 prompt 中,模型就可以针对第二个文件过拟合,分类体系就崩溃成"两个测试都在聊天里"。对待 oracle 泄露要像对待编程竞赛中的测试用例泄露一样。
不要在未沙箱化的环境中在挂载着密钥的笔记本上运行不受信任的生成补丁。ASan 不是监狱。在一次性目录或容器中应用 diff。上面的脚本使用 mktemp;那是一个开始,而不是对恶意输入的安全边界。
不要在 PASS 时自动合并。该标签的意思是"我们费力隐藏的用例没有失败"。便宜的代码在 oracle 落后于产品时依然会积累债务。
当变更只是注释编辑或没有运行时的构建系统调整时,跳过它。当没有编译器工具链且无法运行 Sanitizer 时,跳过它。当规格说明是交互式 UI 且没有可执行属性时,跳过它。当团队会把隐藏文件粘贴到每个 prompt 中"帮助模型"时,跳过它。到了那个地步,第二层就是走过场。
只使用编译器评分的团队还没有完成。编译清洁的过拟合是本 fixture 要命名的这种情况。已经维护大型属性式测试套件的团队可以用那个套件替换 oracles/hidden.cpp,并保持相同的退出分类体系。结构是通用的。环形缓冲区不一定是。
他们需要一个模型不被允许读取的测试。一旦那个文件存在,每个后续的 prompt 变更、服务器变更或重试策略都可以用相同的三个标记评分:FAIL_BOTH、OVERFIT、PASS。演示可以保持绿色。Oracle 仍然有一票。