用三阶段门控(编译检查、ASan/UBSan、差分测试 oracle)验证 AI 生成的 Content-Disposition 头解析器,三轮迭代后覆盖 2 万 case,失败案例集中在语法边界处。
背景:为何要做 Header 解析器
Content-Disposition 看起来很简单:一个 type token,后面跟以分号分隔的参数,如 filename="report.pdf"。陷阱在于 RFC 9110 中定义的 quoted-string 语法与 RFC 6266 的结合。引号内部可以有空格、制表符、obs-text 字节(0x80-0xFF)以及反斜杠转义。转义引号不会终止字符串。未终止的字符串是语法错误。
这个语法足够小,可以通过差分测试进行全面验证,但又足够棘手,生成的代码容易遗漏边界情况。这正是适合做单次案例研究的规模。
目标与 Gate 契约
目标不是做一个完美的解析器,而是做一个通过可复现 gate 来获得生产环境访问资格的解析器:模型作为作者,gate 作为审查者。
Gate 分为三个阶段,按顺序执行:
-Wall -Wextra -Werror 和 sanitizers 进行编译。我使用 MonkeyCode 的免费模型端点进行代码生成,并使用其免费服务器选项来运行 gate。披露:本文是 MonkeyCode 产品推广的一部分。
契约是一个函数:
#pragma once
#include <string>
#include <string_view>
#include <utility>
#include <vector>
struct Disposition {
std::string type;
std::vector<std::pair<std::string, std::string>> params;
};
// Parses a Content-Disposition header value (RFC 6266).
// Returns false on any syntax error. Quoted-string escapes are decoded.
bool parse_disposition(std::string_view value, Disposition& out);
参考 Oracle 是一个手写的扫描器,约 120 行。其核心是 quoted-string 规则:
static bool parse_quoted(std::string_view v, size_t& i, std::string& out) {
++i; // skip opening quote
while (i < v.size()) {
char c = v[i];
if (c == 0x22) { ++i; return true; } // closing quote
if (c == 0x5C) { // backslash
if (i + 1 >= v.size()) return false; // dangling escape
char n = v[i + 1];
unsigned char nu = static_cast<unsigned char>(n);
if (n != 0x09 && n != 0x20 && !(nu >= 0x21 && nu <= 0x7E) && nu < 0x80)
return false; // invalid quoted-pair
out.push_back(n);
i += 2;
continue;
}
unsigned char u = static_cast<unsigned char>(c);
bool ok = (c == 0x09 || c == 0x20 || c == 0x21 ||
(u >= 0x23 && u <= 0x5B) || (u >= 0x5D && u <= 0x7E) || u >= 0x80);
if (!ok) return false;
out.push_back(c);
++i;
}
return false; // unterminated
}
生成器从同一语法生成随机值:一个 type token、零到三个参数,每个值要么是 token,要么是带随机转义和 obs-text 的 quoted-string。差分工具链从 stdin 读取行并比较两个解析器:
while (std::getline(std::cin, line)) {
Disposition a, b;
bool ok_a = parse_disposition(line, a);
bool ok_b = parse_disposition_oracle(line, b);
if (ok_a != ok_b || (ok_a && !(a.type == b.type && a.params == b.params))) {
std::cout << "MISMATCH: " << line << std::endl;
++mismatches;
}
}
Gate 脚本将各部分串联起来:
#!/usr/bin/env bash
set -euo pipefail
g++ -std=c++17 -Wall -Wextra -Werror -fsanitize=address,undefined -c model.cpp -o model.o
g++ -std=c++17 -fsanitize=address,undefined diff_main.cpp oracle.cpp model.o -o diff_gate
python3 gen_cases.py 42 5000 | ./diff_gate
模型的扫描器对 quoted-string 没有输入结束状态。像 filename="abc 这样的值被接受为有效。它还将转义引号视为结束引号,因此包含转义引号的 filename 被错误解码,而真正的结束引号被当作垃圾内容消费了。两个失败共享同一个根本原因:扫描器匹配的是字符而非状态。
补丁添加了长度检查,修复了未终止字符串的情况。但矫枉过正:空 quoted-string(filename="")被拒绝,quoted-string 内部的 obs-text 字节也被拒绝。模型通过添加更严格的条件修复了一个边界 bug,而那个条件在另一个方向上是错误的。
我用不同的种子重新运行了 20,000 个 case:仍然 0 个不匹配。Sanitizers 在所有轮次中都保持干净。这个解析器为这一单一职责赢得了生产访问资格。
所有五类不同的失败都发生在边界状态:
模型在快乐路径上并不差。它对语法规则结束处的状态是盲目的。这是一个有用的先验:审查模型编写的解析器时,先审查终止符情况,而不是提示词中的示例。
在 MonkeyCode 的免费服务器选项上运行 gate,以两种实际方式改变了迭代循环。首先,一个会话的首次编译明显慢于后续运行,所以我把所有差分 case 批处理到一个进程中,而不是每个 case 单独生成。其次,因为轮次成本低廉,我停止打磨提示词,开始将每个提示词视为 gate 来修改的初稿。一轮失败只需几分钟,而不是一次会议。
Gate 脚本也有硬超时,因此挂起的解析器会导致本轮失败,而不是消耗整个会话。这是一个工作流选择,而非产品指标,但这是 gate 与保姆的根本区别。
Gate 证明了在生成的输入上与 Oracle 的等价性。它不证明 RFC 一致性,而且生成器只探索了我编码的语法。如果 Oracle 是错的,gate 就会自信地错。
在以下情况不要使用这个循环:
先写 Oracle。它是循环中最有价值的产物,而且它迫使你在模型看到提示词之前先指定语法。
为契约写提示词,而非为实现。接口和 gate 定义成功;模型填充中间部分。
每次补丁后重新运行完整语料库。第二轮修复了旧的失败却引入了新的;只测试失败的 case 会导致回归。
模型失败集中在语法边界。先审查终止符、转义和空输入。
免费基础设施改变了迭代的经济性。当一轮成本只需几分钟时,你会运行更多轮次,写更短的提示词。
最终的解析器约 80 行,只做一件事。审查它的 gate 约 120 行,也将审查下一个解析器。这种不对称——小代码、可复用的 gate——是我会复制到任何项目的部分。如果有需求,我可以发布完整的 gate 脚本和生成器作为后续。