《互联网产品的 AI 架构·平台化与治理》5.1 评测集的治理:版本、来源与污染

评测集一旦被多个团队共用,版本、来源与污染就会变成治理问题。本节定义评测集的三段版本、人工/线上/合成三层来源与配额、训练与调优语料的污染检查、90 天更新节奏,并用可运行代码在示例样本上给出 0 个结构错误、1 次主版本升级与 3 条污染命中的判定结果。

两个团队为什么报出不同的分数

周一的质量例会上,内容团队说 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 在不同运行时可能略小于阈值,统一用万分比整数。
  • 把评测预算留给调用方。重复跑同一份回归是平台浪费,账应记在能力提供方。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查