两个团队为什么报出不同的分数
周一的质量例会上,内容团队说 content.summarize 的通过率是 94.2%,客服团队说是 91.8%。两边跑的是同一个能力、同一个模型,甚至连提示版本都一致。争执持续了二十分钟,最后发现三处差异:内容团队用的是 eval-7@1.3.0,客服团队还在用 eval-7@1.2.0;前者删掉了 12 条过时样本,后者保留着;客服团队的调优脚本又恰好把其中 30 条样本当成了 few-shot 示例。
这一节讨论评测集作为平台资产的四件事:版本怎么升、来源怎么分层、污染怎么查、多久更新一次。5.2 在此基础上定义门禁,5.3 处理自动化判断不了的部分。
评测集是平台资产
一个能力被三个团队调用时,评测集至少有四类消费者,他们对「稳定」的要求并不相同。
| 消费者 | 用途 | 最怕什么 |
|---|---|---|
| 能力提供方 | 提交级回归、调参对比 | 样本被悄悄改动,历史分数失效 |
| 调用方团队 | 验收、灰度准入 | 评测集只覆盖提供方自己的场景 |
| 平台门禁 | 发布判定、证据留存 | 评测集 id 浮动,判定无法复现 |
评测集的引用形式统一写成 eval-7@1.4.0:id 标识资产,版本标识可比性。3.2 的能力版本三元组只接受 eval-<整数> 形式的 id,正是为了把「哪份评测集」固定在发布记录里。
版本号要回答「分数还能不能比」
评测集版本不是变更次数,而是可比性声明。本章采用下面的默认规则。
| 变更 | 版本位 | 理由 |
|---|---|---|
| 新增样本 | minor | 旧分数仍可在旧子集上复现 |
| 删除样本 | major | 分母变了,历史通过率不可比 |
| 修改标注 | major | 同一输入的正确答案变了 |
| 修改输入文本 | major | 样本已经不是同一条 |
classifyEvalChange 逐条比对前后两个版本,checkEvalBump 检查声明的版本位是否足够。声明 minor 但存在删除、改标注或改输入时,闸门直接拒绝,理由会列出三类变更的数量。
来源分层不是偏好,是风险定价
评测集的来源决定它能证明什么。三类来源缺一不可,但配比需要约束。
| 来源 | 能证明 | 典型失效 | 默认约束 |
|---|---|---|---|
| 人工标注 | 边界、语气、业务规则 | 成本高、覆盖慢 | 至少 30% |
| 线上抽样 | 真实分布、长尾表达 | 含用户隐私、需脱敏 | 不设上限,需可追溯 |
| 合成样本 | 稀有组合、对抗变体 | 分布偏移、自我循环 | 至多 40% |
合成样本必须记录生成器与整数种子,线上抽样必须记录采集批次与日期。没有台账的样本一律视为不可审计,哪怕它的内容看起来完全正常。示例样本的 50 条 case 按 20 条人工、15 条线上、15 条合成组织,得到 40% / 30% / 30% 的来源结构,恰好落在默认约束内。
污染检查要查三个方向
污染不是「样本重复」一个概念。至少要查三个方向,每个方向的处置不同。
| 方向 | 检查方法 | 处置 |
|---|---|---|
| 集内重复 | 归一化后 SHA-256 精确比对 | 删除后加入者,保留最早登记 |
| 训练语料 | 精确哈希 + 编辑距离相似度 ≥ 0.8 | 整条剔除,不能只改措辞 |
| 调优语料 | 同上,另查 few-shot 示例 | 剔除或替换,并重跑受影响版本 |
归一化只抹掉大小写、空白与标点,不改变词序。精确命中用 16 位哈希;近似命中用归一化编辑距离,而不是 n-gram 集合。原因是中文样本里插入一个词会让后半段的 trigram 全部错位,集合相似度会低估重复程度,编辑距离则只损失插入位置附近的分值。
污染检查的输出不是布尔值,而是一组带 case id、方向、参照物与分数的发现。平台据此生成剔除清单;能力团队必须在发布记录里说明哪些样本被剔除、哪些版本需要重跑。示例样本上的检查命中 1 条集内重复、1 条训练语料近似命中与 1 条调优语料近似命中,三条都进入剔除清单。
更新节奏与冻结窗口
评测集不更新会过期,更新太频繁又会让门禁失去基线。本章采用 90 天一次例行复核、每次替换 20% 样本的节奏:50 条样本对应 10 条刷新目标。刷新不是把旧样本全换掉,而是优先替换已经饱和、区分度下降的样本。
评测集治理模块
eval-platform.ts 把版本、来源、污染与节奏写成四组纯函数。所有判定只依赖输入,不读时钟、不读环境变量,因此同一条 case 在任何团队机器上都会得到同一结论。
// file: src/gov/eval-platform.ts
// tier: L2
// verify: node --test test/gov/eval-platform.test.ts
import { createHash } from "node:crypto";
/** 评测集是平台资产,不是某个团队的本地夹具;版本、来源与污染都必须可复核。 */
export type EvalSource = "human" | "production" | "synthetic";
export interface EvalCase {
id: string;
capability: string;
input: string;
expected: string;
source: EvalSource;
tags: readonly string[];
/** 非合成样本追溯到采集批次;合成样本还必须追溯到生成器与整数种子。 */
provenance: { batch: string; collectedAt: string; generator?: string; seed?: number };
}
export interface EvalSet { id: string; version: string; cases: readonly EvalCase[]; updatedAt: string }
export interface EvalPolicy {
minHumanBp: number;
maxSyntheticBp: number;
minCases: number;
maxCapabilityBp: number;
}
/** 万分比口径,避免浮点在门禁与报表之间产生不同的舍入。 */
export const DEFAULT_EVAL_POLICY: EvalPolicy = {
minHumanBp: 3_000, maxSyntheticBp: 4_000, minCases: 40, maxCapabilityBp: 6_000,
};
export interface SourceMix {
total: number;
human: number;
production: number;
synthetic: number;
humanBp: number;
productionBp: number;
syntheticBp: number;
}
export function sourceMix(cases: readonly EvalCase[]): SourceMix {
const total = cases.length;
const count = (source: EvalSource): number => cases.filter((item) => item.source === source).length;
const bp = (value: number): number => (total === 0 ? 0 : Math.round((value * 10_000) / total));
const human = count("human");
const production = count("production");
const synthetic = count("synthetic");
return {
total, human, production, synthetic,
humanBp: bp(human), productionBp: bp(production), syntheticBp: bp(synthetic),
};
}
export type EvalIssueCode =
| "invalid-id" | "invalid-version" | "no-cases" | "below-min-cases"
| "duplicate-case" | "invalid-source" | "missing-provenance"
| "synthetic-without-seed" | "human-share-low" | "synthetic-share-high"
| "capability-concentration";
export interface EvalIssue { path: string; code: EvalIssueCode; detail: string }
const EVAL_ID = /^eval-\d+$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
export function validateEvalSet(set: EvalSet, policy: EvalPolicy = DEFAULT_EVAL_POLICY): EvalIssue[] {
const issues: EvalIssue[] = [];
if (!EVAL_ID.test(set.id)) {
issues.push({ path: set.id, code: "invalid-id", detail: "评测集 id 必须是 eval-<整数>" });
}
if (!SEMVER.test(set.version)) {
issues.push({ path: `${set.id}@${set.version}`, code: "invalid-version", detail: "版本必须是三段语义化版本" });
}
if (set.cases.length === 0) {
issues.push({ path: set.id, code: "no-cases", detail: "评测集不能为空" });
return issues;
}
if (set.cases.length < policy.minCases) {
issues.push({ path: set.id, code: "below-min-cases", detail: `样本数 ${set.cases.length} 低于下限 ${policy.minCases}` });
}
const seen = new Set<string>();
for (const item of set.cases) {
if (seen.has(item.id)) issues.push({ path: item.id, code: "duplicate-case", detail: "同一评测集内 case id 重复" });
seen.add(item.id);
if (item.source !== "human" && item.source !== "production" && item.source !== "synthetic") {
issues.push({ path: item.id, code: "invalid-source", detail: `未知来源 ${String(item.source)}` });
}
if (item.provenance.batch.trim() === "" || !DATE.test(item.provenance.collectedAt)) {
issues.push({ path: item.id, code: "missing-provenance", detail: "缺少采集批次或合法采集日期" });
}
if (item.source === "synthetic" && (item.provenance.generator === undefined || item.provenance.seed === undefined)) {
issues.push({ path: item.id, code: "synthetic-without-seed", detail: "合成样本必须记录生成器与种子" });
}
}
const mix = sourceMix(set.cases);
if (mix.humanBp < policy.minHumanBp) {
issues.push({ path: set.id, code: "human-share-low", detail: `人工标注占比 ${mix.humanBp}bp 低于 ${policy.minHumanBp}bp` });
}
if (mix.syntheticBp > policy.maxSyntheticBp) {
issues.push({ path: set.id, code: "synthetic-share-high", detail: `合成样本占比 ${mix.syntheticBp}bp 高于 ${policy.maxSyntheticBp}bp` });
}
const byCapability = new Map<string, number>();
for (const item of set.cases) {
byCapability.set(item.capability, (byCapability.get(item.capability) ?? 0) + 1);
}
for (const [capability, count] of byCapability) {
const shareBp = Math.round((count * 10_000) / set.cases.length);
if (shareBp > policy.maxCapabilityBp) {
issues.push({
path: `${set.id}/${capability}`, code: "capability-concentration",
detail: `${capability} 占 ${shareBp}bp,超过单能力上限 ${policy.maxCapabilityBp}bp`,
});
}
}
return issues;
}
/** 归一化只抹掉大小写、空白与标点,不改变词序;这样近似重复不会被误判为完全相同。 */
export function normalizeText(text: string): string {
return text.toLowerCase().replace(/[\s\p{P}]+/gu, "");
}
export function textHash(text: string): string {
return createHash("sha256").update(normalizeText(text)).digest("hex").slice(0, 16);
}
function editDistance(left: string, right: string): number {
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let i = 1; i <= left.length; i += 1) {
const current = [i];
for (let j = 1; j <= right.length; j += 1) {
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
}
for (let j = 0; j <= right.length; j += 1) previous[j] = current[j];
}
return previous[right.length];
}
/** 归一化编辑距离比 n-gram 集合更稳:插入一个词不会让后半段的相似度全部归零。 */
export function similarity(left: string, right: string): number {
const a = normalizeText(left);
const b = normalizeText(right);
if (a.length === 0 && b.length === 0) return 1;
return 1 - editDistance(a, b) / Math.max(a.length, b.length);
}
export type ContaminationKind =
| "duplicate-in-set" | "exact-training" | "near-training"
| "exact-tuning" | "near-tuning";
export interface ContaminationFinding { caseId: string; kind: ContaminationKind; reference: string; score: number }
export interface ContaminationCorpus { training: readonly string[]; tuning: readonly string[] }
export interface ContaminationReport {
total: number;
findings: ContaminationFinding[];
contaminated: string[];
clean: boolean;
}
/**
* 污染检查按「精确命中优先、近似命中其次」的顺序报告;
* 同一 case 对同一语料只保留最强的一条结论。
*/
export function checkContamination(
candidate: readonly EvalCase[],
corpus: ContaminationCorpus,
threshold = 0.8,
): ContaminationReport {
const findings: ContaminationFinding[] = [];
const seen = new Map<string, string>();
for (const item of candidate) {
const hash = textHash(item.input);
const duplicate = seen.get(hash);
if (duplicate !== undefined) {
findings.push({ caseId: item.id, kind: "duplicate-in-set", reference: duplicate, score: 1 });
} else {
seen.set(hash, item.id);
}
for (const [kind, source] of [["training", corpus.training], ["tuning", corpus.tuning]] as const) {
if (source.some((text) => textHash(text) === hash)) {
findings.push({ caseId: item.id, kind: kind === "training" ? "exact-training" : "exact-tuning", reference: kind, score: 1 });
continue;
}
const near = nearest(item.input, source, threshold);
if (near !== null) {
findings.push({
caseId: item.id, kind: kind === "training" ? "near-training" : "near-tuning",
reference: near.reference, score: near.score,
});
}
}
}
const contaminated = [...new Set(findings.map((item) => item.caseId))].sort();
return { total: candidate.length, findings, contaminated, clean: contaminated.length === 0 };
}
function nearest(text: string, corpus: readonly string[], threshold: number): { reference: string; score: number } | null {
let best: { reference: string; score: number } | null = null;
for (const [index, item] of corpus.entries()) {
const score = similarity(text, item);
if (score >= threshold && (best === null || score > best.score)) {
best = { reference: `corpus-${index}`, score: Math.round(score * 1_000) / 1_000 };
}
}
return best;
}
export type EvalBump = "major" | "minor" | "patch";
export interface EvalChangeReport {
added: string[];
removed: string[];
relabeled: string[];
rewritten: string[];
requiredBump: EvalBump;
}
/** 删除、改标注、改输入都会让历史分数失去可比性,必须发主版本。 */
export function classifyEvalChange(before: EvalSet, after: EvalSet): EvalChangeReport {
const oldCases = new Map(before.cases.map((item) => [item.id, item]));
const newCases = new Map(after.cases.map((item) => [item.id, item]));
const added = [...newCases.keys()].filter((id) => !oldCases.has(id)).sort();
const removed = [...oldCases.keys()].filter((id) => !newCases.has(id)).sort();
const relabeled: string[] = [];
const rewritten: string[] = [];
for (const [id, oldCase] of oldCases) {
const newCase = newCases.get(id);
if (newCase === undefined) continue;
if (newCase.expected !== oldCase.expected) relabeled.push(id);
else if (normalizeText(newCase.input) !== normalizeText(oldCase.input)) rewritten.push(id);
}
const requiredBump: EvalBump =
removed.length + relabeled.length + rewritten.length > 0 ? "major" : added.length > 0 ? "minor" : "patch";
return { added, removed, relabeled: relabeled.sort(), rewritten: rewritten.sort(), requiredBump };
}
const BUMP_RANK: Record<EvalBump, number> = { patch: 0, minor: 1, major: 2 };
export function checkEvalBump(report: EvalChangeReport, declared: EvalBump): { ok: boolean; reason: string } {
const ok = BUMP_RANK[declared] >= BUMP_RANK[report.requiredBump];
return {
ok,
reason: ok
? `声明的 ${declared} 覆盖 ${report.requiredBump} 要求`
: `删除 ${report.removed.length}、改标注 ${report.relabeled.length}、改输入 ${report.rewritten.length},要求 ${report.requiredBump}`,
};
}
export interface Cadence { reviewDays: number; refreshBp: number }
export const DEFAULT_CADENCE: Cadence = { reviewDays: 90, refreshBp: 2_000 };
function addDays(date: string, days: number): string {
return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
}
export interface CadenceStatus {
setId: string;
dueAt: string;
overdueDays: number;
due: boolean;
refreshTarget: number;
}
export function cadenceStatus(set: EvalSet, today: string, cadence: Cadence = DEFAULT_CADENCE): CadenceStatus {
const dueAt = addDays(set.updatedAt, cadence.reviewDays);
const days = (Date.parse(`${today}T00:00:00Z`) - Date.parse(`${dueAt}T00:00:00Z`)) / 86_400_000;
return {
setId: set.id,
dueAt,
overdueDays: Math.max(0, Math.round(days)),
due: days > 0,
refreshTarget: Math.ceil((set.cases.length * cadence.refreshBp) / 10_000),
};
}
代码里有三个刻意的选择。第一,空评测集在报告 no-cases 后立即返回,避免后续配额检查产生一串噪声。第二,来源占比统一用万分比整数,避免 0.3 与 0.30000000000000004 在不同语言之间产生不同判定。第三,污染检查对训练与调优语料分别报告,因为两者的处置责任不同:训练污染通常要换样本,调优污染还要检查提示里是否夹带了示例。
示例样本上的运行结果
示例样本包含 50 条 case:20 条人工标注、15 条线上抽样、15 条合成。按默认策略校验,结构错误为 0;把其中 1 条标注改掉后,变更分类要求 major,声明 minor 会被拒绝。
| 检查项 | 示例结果 |
|---|---|
| 样本总数 | 50 |
| 来源占比 | 人工 40%、线上 30%、合成 30% |
| 结构校验 | 0 个错误 |
| 新增 1 条样本 | 要求 minor |
| 删除 1 条 / 改 1 条标注 | 要求 major |
| 污染命中 | 集内重复 1、训练近似 1、调优近似 1 |
| 刷新目标 | 10 条 |
这些是示例样本上的运行结果,不是生产统计。真实评测集的样本更多、来源更杂,但版本位、来源占比与污染方向的判定口径不变。
多团队共用的三条规则
第一条,评测集只读,变更走版本。调用方可以提交候选样本,但不能直接改共享评测集;能力团队合并候选、发新版本、通知所有消费者。谁改了什么、为什么改,必须能从版本差异里读出来。
第二条,来源结构写进验收。调用方验收时不仅看总分,还要看评测集是否覆盖自己的场景、人工样本是否足够、合成样本是否超限。只拿一个总分签字的验收,最后会把风险留给线上。
第三条,污染检查前置到采样。不要等评测集发布后再扫污染;线上抽样进入候选池时就先与训练、调优语料比对,把命中项挡在门外。这样剔除成本最低,也不会出现「发布后才发现要重跑三个月」的局面。
小结
评测集治理的核心是让「通过率」成为可比较、可复现、可审计的数。版本规则回答分数还能不能比,来源分层回答这个分数能证明什么,污染检查回答样本是否已经被模型见过,更新节奏回答基线多久需要重置。
示例样本上,50 条 case 的来源结构为 40% / 30% / 30%,结构校验 0 错误;删除与改标注会触发 major,声明 minor 被拒绝;污染检查命中三个方向各一条。把这些判定固化成平台函数之后,多团队共用同一份评测集才不会退化成各说各话。
阅读导航:上一节:4.3 借用、回收与隔离 · 下一节:5.2 自动化回归与门禁 。
常见错误与边界
- 把评测集放在能力团队的仓库里。调用方拿到的是某次提交的快照,不是可复现的资产。
- 只给新增样本升 minor,删除样本却不动版本。分母变了,历史通过率就不能相减。
- 用合成样本撑覆盖率。合成样本超过四成时,评测集容易变成模型自我验证的回音室。
- 认为改措辞不算污染。归一化之后仍是同一条样本,必须按重复处理。
- 发布窗口内静默更新评测集。判定中途换基线,门禁结论无法复现。
- 用浮点占比做门禁。0.3 在不同运行时可能略小于阈值,统一用万分比整数。
- 把评测预算留给调用方。重复跑同一份回归是平台浪费,账应记在能力提供方。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。