某 C++ 崩溃报告分类服务依赖单一模型的高置信度分数将 ASan heap-use-after-free 误分到 stack_overflow,导致修复延误两天;解决方案是引入本地规则引擎投票,模型只提供候选排名。
一个 C++ 服务每周处理数千份崩溃报告。这些报告以 JSONL 文件的形式到达。每条记录包含回溯栈(backtrace)、 sanitizer 备注、平台标签以及 last_frame 字符串。一位维护者将一个免费模型端点接入到分类(triage)阶段。模型读取顶部八帧并返回一个桶(bucket)以及置信度分数。一个星期二早晨,它将一份 ASan heap-use-after-free 报告发送到了 stack_overflow 桶。分数是 0.97。桶的分类是错的。这份报告在堆损坏队列之外滞留了两天。修复被延迟,原因是路由系统遵从了一个单一的高置信度数值。
该端点和免费服务器选项来自 MonkeyCode 的免费层。披露:本文是作为 MonkeyCode 产品推广的一部分而撰写的。免费端点仅用作排序层,从未作为真相来源。
这次失败不是 prompt 问题,而是决策设计问题。即使是一个有用的单一模型,当本地确定性规则与其不一致时,也不应被允许独自路由。团队用一个投票机制替换了单一答案的设计。本地规则引擎保留最终否决权。模型提供排序后的候选列表。路由器仅在两个来源达成一致时才会自动路由。
参考测试框架不是生产代码。它是一个轻依赖的 C++17 程序,读取两个扁平化文件。crash_reports.tsv 每行存放一条报告。model_rankings.tsv 存放免费模型对同一报告 ID 的候选排序。
崩溃报告字段为:
模型排序字段为:
bucket1|bucket2|bucket3
免费服务器在另一台主机上生成了排序文件。本地机器仅运行构建和投票器。
本地规则有意做得非常小。高精确率比高召回率更重要。四个匹配器在一个组合字符串上工作,该字符串由 sanitizer、signal 和 frame0 组成:
std::string localRule(const CrashReport& r) {
std::string text = r.sanitizer + " " + r.signal + " " + r.frame0;
if (text.find("heap-use-after-free") != std::string::npos ||
text.find("double-free") != std::string::npos) return "heap_corruption";
if (text.find("stack-overflow") != std::string::npos) return "stack_overflow";
if (text.find("SEGV") != std::string::npos ||
text.find("null") != std::string::npos) return "null_deref";
if (text.find("pthread") != std::string::npos ||
text.find("deadlock") != std::string::npos) return "lock_order";
return "unknown";
}
这些规则不是一个分类器。它是一个否决机制。当它们返回 unknown 时,模型仍可以路由该报告,但决策会被标记为 model_only。当它们返回一个桶时,模型必须同意,否则该报告将进入人工审核。
决策函数提出三个问题。
本地规则是否匹配?
本地桶在模型排序中出现在哪个位置?
模型的最高分数有多强?
测试框架使用的表格:
重要的行是最后两行。高置信度的模型答案不会覆盖一个明确的本地规则。它只会将报告升级给人工处理。较低置信度的分歧会被隔离。
完整程序读取两个 TSV 文件,应用本地规则,对模型排序评分,并为每条报告写入一个决策。
#include <algorithm>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
struct CrashReport {
std::string id;
std::string sanitizer;
std::string signal;
std::string frame0;
};
struct RankedCandidate {
std::string bucket;
double score;
};
struct ModelRank {
std::string id;
std::vector<RankedCandidate> candidates;
};
std::vector<std::string> split(const std::string& s, char delim) {
std::vector<std::string> parts;
std::stringstream ss(s);
std::string item;
while (std::getline(ss, item, delim)) {
parts.push_back(item);
}
return parts;
}
std::vector<double> splitScores(const std::string& s, char delim) {
std::vector<double> parts;
std::stringstream ss(s);
std::string item;
while (std::getline(ss, item, delim)) {
try { parts.push_back(std::stod(item)); } catch (...) { parts.push_back(0.0); }
}
return parts;
}
std::string localRule(const CrashReport& r) {
std::string text = r.sanitizer + " " + r.signal + " " + r.frame0;
if (text.find("heap-use-after-free") != std::string::npos ||
text.find("double-free") != std::string::npos) return "heap_corruption";
if (text.find("stack-overflow") != std::string::npos) return "stack_overflow";
if (text.find("SEGV") != std::string::npos ||
text.find("null") != std::string::npos) return "null_deref";
if (text.find("pthread") != std::string::npos ||
text.find("deadlock") != std::string::npos) return "lock_order";
return "unknown";
}
std::string decide(const std::string& local, const ModelRank& mr) {
if (local == "unknown") {
if (mr.candidates.empty()) return "quarantine";
if (mr.candidates[0].score >= 0.65) return "auto_route:model_only";
return "manual_review";
}
int pos = -1;
for (std::size_t i = 0; i < mr.candidates.size(); ++i) {
if (mr.candidates[i].bucket == local) {
pos = static_cast<int>(i);
break;
}
}
if (pos == 0) {
if (mr.candidates[0].score >= 0.60) return "auto_route:agreed";
return "manual_review";
}
if (pos > 0) return "manual_review";
if (!mr.candidates.empty() && mr.candidates[0].score >= 0.85) {
return "manual_review:strong_model_disagreement";
}
return "quarantine";
}
int main(int argc, char** argv) {
if (argc != 4) {
std::cerr << "usage: crash_voter reports.tsv rankings.tsv decisions.tsv" << std::endl;
return 1;
}
const char tab = 9;
const char nl = 10;
std::ifstream reports(argv[1]);
std::ifstream rankings(argv[2]);
std::ofstream decisions(argv[3]);
std::vector<CrashReport> reps;
std::string line;
while (std::getline(reports, line)) {
if (line.empty()) continue;
std::vector<std::string> f = split(line, tab);
if (f.size() < 4) continue;
reps.push_back({f[0], f[1], f[2], f[3]});
}
std::vector<ModelRank> ranks;
while (std::getline(rankings, line)) {
if (line.empty()) continue;
std::vector<std::string> f = split(line, tab);
if (f.size() < 2) continue;
ModelRank mr;
mr.id = f[0];
std::vector<std::string> buckets = split(f[1], '|');
std::vector<double> scores = splitScores(f.size() > 2 ? f[2] : "", '|');
for (std::size_t i = 0; i < buckets.size() && i < scores.size(); ++i) {
mr.candidates.push_back({buckets[i], scores[i]});
}
ranks.push_back(mr);
}
for (const auto& rep : reps) {
std::string local = localRule(rep);
std::string decision = "missing_rank";
for (const auto& mr : ranks) {
if (mr.id == rep.id) {
decision = decide(local, mr);
break;
}
}
decisions << rep.id << tab << local << tab << decision << nl;
}
return 0;
}
g++ -std=c++17 -O2 crash_voter.cpp -o crash_voter
./crash_voter crash_reports.tsv model_rankings.tsv decisions.tsv
一个最小的 crash_reports.tsv:
crash-01 heap-use-after-free SIGSEGV __asan_report_load8
crash-02 stack-overflow SIGSEGV __asan_stack_malloc_0
crash-03 heap-use-after-free SIGSEGV __asan_report_load8
一个对应的 model_rankings.tsv:
crash-01 heap_corruption|stack_overflow|unknown 0.93|0.04|0.03
crash-02 stack_overflow|heap_corruption 0.98|0.02
crash-03 stack_overflow|heap_corruption 0.91|0.07
前两行自动路由。第三行变为 manual_review,因为模型和本地规则不一致,即使分数是 0.91。
Prompt 调优改变了模型说的话。它没有改变路由器在得到错误答案时被允许做什么。投票机制引入了一个第二来源的独立信号。本地规则审计成本低廉。它们不需要嵌入向量、不需要训练集、不需要重训练任务。模型仍然对规则无法命名的报告有用。测试框架记录了分歧路径,而不是盲目相信分数。
规则集是静态的。新的 sanitizer 消息或架构会产生 unknown 并落入 model_only 路由。那条路径仍然需要监控。测试框架假设模型排序文件已经过验证。真实的端点可能返回空响应体、格式错误的列表或重复的候选。示例还使用了原始分数阈值。拥有标注崩溃数据的团队应该用小评估集来调优这些阈值,而不是直接采用默认值。
如果没有人类拥有 manual_review 和 quarantine 队列的所有权,不要使用这个系统。该系统可能将错误的模型输出隐藏在无人阅读的队列中。不要将其用于自动响应,如寻呼、回滚或补丁执行。如果崩溃桶 ID 会馈送给下游工具并修改状态,也不要使用模型路由。这是一个分类辅助工具,不是安全控制。
如果免费模型层是在独立免费服务器上生成排序候选的最便宜方式,那么这个测试框架将把该输出转化为可审计的投票。本地规则才是让 0.97 的分数不至于让人漏掉堆损坏 bug 的保障。