一条两边都签了字的样本
一条内容摘要样本在自动评测里通过,进入人工抽检。评审员甲认为摘要漏掉了「仅限站内」这个限制,判 fail;评审员乙认为摘要整体准确,判 pass。两人都签了字,系统把这条样本标成「有分歧」,然后就没有然后了——没有仲裁人,没有时限,样本在队列里躺了九天。
更麻烦的是另外两条。评审员丙连续三个月全部判 pass,复核时发现他的 20 条金标样本里有 6 条本该 fail;评审员丁是这条样本作者的同事,系统没有阻止他接单。前者是校准问题,后者是利益冲突问题。两者都不会被自动评测发现,却会直接改变门禁结论。
这一节定义人工评审的四个动作:抽哪些、派给谁、分歧怎么办、评审员是否可信。自动化负责覆盖,人工负责边界;没有结构的评审,最后会变成「谁声音大听谁的」。
人工评审在门禁里的位置
人工评审不是自动化的补充,而是三类判断的唯一入口:语义边界(摘要是否漏掉限制条件)、业务规则(这句话是否合规)、新型失败(模型错得有没有先例)。这三类判断无法用固定断言覆盖,只能由人看样本。
但人工评审不能替代自动门禁。它的吞吐有限,成本随样本量线性上升,而且评审员本身会漂移。本章的定位是:自动门禁负责「大面积回归」,人工评审负责「抽样确认 + 分歧仲裁 + 评审员校准」,两者共用同一份评测集与同一套证据包。
在链路 A 里,人工评审的位置是「自动通过之后的抽检」与「自动失败之后复核」两条路径。抽检发现漏放,说明自动指标需要补样本;复核推翻自动失败,说明判定规则过严。两条路径都会回流到 5.1 的评测集版本。
分层抽样:高风险全检,常规按比例
抽检率不能一刀切。高风险样本全检,常规样本按比例,低风险样本减半,这样总成本可控,关键场景又不漏。
| 风险层 | 判定依据 | 默认抽检率 | 40 条样本的抽检数 |
|---|---|---|---|
| 高 | 安全、交易、合规相关 | 100% | 10 |
| 常规 | 普通内容与客服 | 10% | 2 |
| 低 | 内部工具、低流量场景 | 5% | 1 |
抽样必须可复现。实现用「种子 + case id」的 SHA-256 排序,取前 N 条;同一份样本、同一个种子,任何机器上都得到同一名单。不能用随机数生成器直接抽,否则复现时要连随机状态一起保存。
高风险层的 100% 是默认值,不是建议值。把它调低需要治理委员会批准,并在抽检计划里写明残余风险。示例样本的 40 条 case 按 10 高、20 常规、10 低组织,抽检率为 10% 时选中 10 + 2 + 1 = 13 条。
双盲派单:作者、冲突与校准三道过滤
双盲不是「不告诉评审员是谁写的」这么简单。派单阶段至少要过滤三类人:作者本人、与作者有利益冲突的人、未通过校准的人。
| 过滤条件 | 规则 | 失败处置 |
|---|---|---|
| 作者 | 评审员 id 不得等于样本作者 | 换人 |
| 利益冲突 | 评审员的 conflicts 列表不得包含作者 | 换人 |
| 能力匹配 | 评审员必须覆盖该样本的能力 | 换人 |
| 校准 | 最近一次校准通过率 ≥ 80% | 换人或先补校准 |
| 人数 | 每条样本恰好两名不同评审员 | 标记缺口,不降低人数 |
两名评审员在提交结论前互相看不到对方身份与结论。系统只暴露样本与评分标准;结论提交后才进入比对。这样做的目的不是保密,而是避免从众:如果评审员知道另一位是资深专家,很容易把自己的判断调过去。
派单也要确定性。候选项按「种子 + case id + 评审员 id」排序,取前两名;这样同一批样本重复派单会得到同一组合,方便复盘「当时为什么是这两个人」。如果合格评审员不足两名,系统标记缺口并进入人工调度,不允许退化成单人评审。
分歧仲裁:一致才落槌
两位评审员结论一致时,样本直接落槌;一致要求升级时,进入仲裁;结论不一致时,也进入仲裁。仲裁不是重新投票,而是由第三个人给出最终标签并写明理由。
| 场景 | 状态 | 下一步 |
|---|---|---|
| 两人都 pass | settled | 归档,计入通过 |
| 两人都 fail | settled | 归档,进入修复清单 |
| 两人都 escalate | needs-arbiter | 指派仲裁人 |
| 一 pass 一 fail | needs-arbiter | 指派仲裁人 |
| 同一评审员提交两条 | invalid | 作废,重新派单 |
仲裁人不能是本案的两名评审员,也不能与作者存在利益冲突;在合格人选中,优先选择校准通过率最高者,同分时按确定性哈希排序。仲裁结论必须带理由,且理由要能对应到评分标准的具体条目;只写「综合判断」的仲裁记录,在季度复盘时无法使用。
仲裁要有时限。本章建议 48 小时内给出结论,超时升级到质量负责人;超时样本不计入「已评审」,避免队列看起来很快、实际积压。示例样本的分歧率为 8%,40 条样本里约 3 条进入仲裁,按每条 15 分钟计算,每周约 45 分钟仲裁工作量。
评审员校准:漏放比误拦更严重
校准用金标样本,也就是已经由多人确认过正确答案的样本。评审员在不知道金标身份的情况下评一遍,系统计算一致率、漏放数与误拦数。
| 指标 | 含义 | 阈值 |
|---|---|---|
| 一致率 | 与金标一致的条数 / 已评条数 | ≥ 90% 为 calibrated |
| 漏放数 | 金标 fail 却判 pass | 必须为 0 |
| 误拦数 | 金标 pass 却判 fail | 记录但不直接阻断 |
| 最小样本 | 参与校准的金标条数 | ≥ 20 |
| 降级 | 一致率 80%–90% | probation,限制接单 |
| 阻断 | 一致率 < 80% 或漏放 > 0 | blocked,暂停接单 |
漏放比误拦严重,是因为漏放会把坏样本放进线上,而误拦只是增加复核成本。校准规则因此把「漏放为 0」设为 calibrated 的必要条件,即使一致率达到 95%,只要漏放一条也只能进 probation。
校准不是一次性动作。评审员连续接单三个月、或者平台更换评分标准、或者某类样本的漏放率上升时,都要重新校准。校准记录进入评审员档案,派单时读取最近一次结果;没有记录的评审员按未校准处理,不能接单。
人工评审模块
eval-review.ts 把抽样、派单、仲裁与校准写成四组纯函数。随机性全部来自种子哈希,不依赖 Math.random,因此抽检名单与派单组合可以逐字节复现。
// file: src/gov/eval-review.ts
// tier: L2
// verify: node --test test/gov/eval-platform.test.ts
import { createHash } from "node:crypto";
/** 人工评审的四个动作:分层抽样、双盲派单、分歧仲裁、评审员校准。 */
export type Risk = "high" | "normal" | "low";
export interface ReviewCase {
id: string;
capability: string;
risk: Risk;
author: string;
}
export interface ReviewPlanOptions {
/** 常规样本的抽检率,万分比。 */
rateBp: number;
seed: string;
/** 高风险全检是默认值,显式传入更低的值需要治理委员会批准。 */
highRiskRateBp?: number;
}
export interface Stratum {
risk: Risk;
population: number;
target: number;
selected: number;
}
export interface ReviewPlan {
selected: string[];
strata: Stratum[];
rateBp: number;
seed: string;
}
function rank(seed: string, value: string): string {
return createHash("sha256").update(`${seed}:${value}`).digest("hex");
}
function targetFor(risk: Risk, population: number, rateBp: number, highRiskRateBp: number): number {
if (risk === "high") return Math.ceil((population * highRiskRateBp) / 10_000);
const effective = risk === "low" ? Math.ceil(rateBp / 2) : rateBp;
return Math.ceil((population * effective) / 10_000);
}
/** 高风险层默认全检;排序只依赖种子与 case id,同一输入总能得到同一份名单。 */
export function planReview(
cases: readonly ReviewCase[],
options: ReviewPlanOptions,
): ReviewPlan {
const highRiskRateBp = options.highRiskRateBp ?? 10_000;
const risks: readonly Risk[] = ["high", "normal", "low"];
const strata: Stratum[] = [];
const selected: string[] = [];
for (const risk of risks) {
const pool = cases.filter((item) => item.risk === risk).sort((left, right) => {
const a = rank(options.seed, left.id);
const b = rank(options.seed, right.id);
return a === b ? left.id.localeCompare(right.id) : a.localeCompare(b);
});
const target = Math.min(pool.length, targetFor(risk, pool.length, options.rateBp, highRiskRateBp));
const picked = pool.slice(0, target).map((item) => item.id);
selected.push(...picked);
strata.push({ risk, population: pool.length, target, selected: picked.length });
}
return { selected: selected.sort(), strata, rateBp: options.rateBp, seed: options.seed };
}
export interface Reviewer {
id: string;
role: "reviewer" | "arbiter";
capabilities: readonly string[];
/** 与该评审员存在利益冲突的作者 id。 */
conflicts: readonly string[];
/** 最近一次校准的通过率,万分比;未校准按 0 处理。 */
agreementBp: number;
}
export interface CaseAssignment { caseId: string; reviewers: [string, string] }
export type AssignmentCode =
| "insufficient-reviewers" | "capability-miss" | "conflict" | "uncalibrated";
export interface AssignmentIssue { caseId: string; code: AssignmentCode; detail: string }
export interface AssignmentPlan {
assignments: CaseAssignment[];
issues: AssignmentIssue[];
blind: true;
}
const MIN_REVIEWER_BP = 8_000;
/** 作者不可评审自己的样本,两名评审员互相看不到对方身份与结论。 */
export function assignDoubleBlind(
cases: readonly ReviewCase[],
reviewers: readonly Reviewer[],
seed: string,
): AssignmentPlan {
const assignments: CaseAssignment[] = [];
const issues: AssignmentIssue[] = [];
for (const item of cases) {
const eligible = reviewers.filter((reviewer) =>
reviewer.role === "reviewer"
&& reviewer.capabilities.includes(item.capability)
&& !reviewer.conflicts.includes(item.author)
&& reviewer.id !== item.author);
const calibrated = eligible.filter((reviewer) => reviewer.agreementBp >= MIN_REVIEWER_BP);
if (calibrated.length < 2) {
const code: AssignmentCode = eligible.length < 2 ? "insufficient-reviewers" : "uncalibrated";
issues.push({
caseId: item.id, code,
detail: `可用评审员 ${calibrated.length} 名,至少需要 2 名`,
});
continue;
}
const ordered = calibrated
.map((reviewer) => ({ id: reviewer.id, key: rank(`${seed}:${item.id}`, reviewer.id) }))
.sort((left, right) => (left.key === right.key ? left.id.localeCompare(right.id) : left.key.localeCompare(right.key)));
assignments.push({ caseId: item.id, reviewers: [ordered[0].id, ordered[1].id] });
}
return { assignments, issues, blind: true };
}
export type ReviewLabel = "pass" | "fail" | "escalate";
export interface ReviewVerdict {
caseId: string;
reviewerId: string;
label: ReviewLabel;
/** 0–100 的自评把握度,只用于排序与审计,不直接决定结论。 */
confidence: number;
}
export type ResolutionStatus = "settled" | "needs-arbiter" | "invalid";
export interface CaseResolution {
caseId: string;
status: ResolutionStatus;
label?: ReviewLabel;
reason: string;
}
/** 两位评审员结论一致才算落槌;任何分歧或升级都必须进入仲裁。 */
export function resolveCase(verdicts: readonly ReviewVerdict[]): CaseResolution {
const caseId = verdicts[0]?.caseId ?? "unknown";
const reviewers = new Set(verdicts.map((item) => item.reviewerId));
if (verdicts.length !== 2 || reviewers.size !== 2) {
return { caseId, status: "invalid", reason: `需要两位不同评审员的结论,实际 ${verdicts.length} 条` };
}
if (verdicts.some((item) => item.confidence < 0 || item.confidence > 100)) {
return { caseId, status: "invalid", reason: "把握度必须在 0–100 之间" };
}
const [left, right] = verdicts;
if (left.label === right.label) {
if (left.label === "escalate") {
return { caseId, status: "needs-arbiter", reason: "两位评审员都要求升级" };
}
return { caseId, status: "settled", label: left.label, reason: "两位评审员结论一致" };
}
return { caseId, status: "needs-arbiter", reason: `结论分歧:${left.label} / ${right.label}` };
}
export interface ArbiterChoice { arbiterId: string | null; reason: string }
/** 仲裁人不能是本案评审员,也不得与作者存在利益冲突;同分时按 id 排序。 */
export function assignArbiter(
reviewCase: ReviewCase,
verdicts: readonly ReviewVerdict[],
arbiters: readonly Reviewer[],
seed: string,
): ArbiterChoice {
const used = new Set(verdicts.map((item) => item.reviewerId));
const eligible = arbiters.filter((reviewer) =>
reviewer.role === "arbiter"
&& reviewer.capabilities.includes(reviewCase.capability)
&& !reviewer.conflicts.includes(reviewCase.author)
&& !used.has(reviewer.id));
if (eligible.length === 0) return { arbiterId: null, reason: "没有符合条件的仲裁人" };
const ordered = eligible
.map((reviewer) => ({
id: reviewer.id,
agreement: reviewer.agreementBp,
key: rank(`${seed}:${reviewCase.id}:arbiter`, reviewer.id),
}))
.sort((left, right) => {
if (left.agreement !== right.agreement) return right.agreement - left.agreement;
return left.key === right.key ? left.id.localeCompare(right.id) : left.key.localeCompare(right.key);
});
return { arbiterId: ordered[0].id, reason: `校准通过率 ${ordered[0].agreement}bp,最高优先级` };
}
export interface GoldCase { caseId: string; expected: "pass" | "fail" }
export type CalibrationStatus = "calibrated" | "probation" | "blocked";
export interface Calibration {
reviewerId: string;
reviewed: number;
agreed: number;
agreementBp: number;
falsePass: number;
falseFail: number;
status: CalibrationStatus;
}
/** 校准只看金标样本;漏放(false pass)比误拦(false fail)更严重。 */
export function calibrateReviewer(
reviewerId: string,
verdicts: readonly ReviewVerdict[],
gold: readonly GoldCase[],
): Calibration {
const goldMap = new Map(gold.map((item) => [item.caseId, item.expected]));
const own = verdicts.filter((item) => item.reviewerId === reviewerId && goldMap.has(item.caseId));
let agreed = 0;
let falsePass = 0;
let falseFail = 0;
for (const item of own) {
const expected = goldMap.get(item.caseId);
if (item.label === expected) agreed += 1;
else if (item.label === "pass") falsePass += 1;
else if (item.label === "fail") falseFail += 1;
}
const agreementBp = own.length === 0 ? 0 : Math.round((agreed * 10_000) / own.length);
const status: CalibrationStatus =
own.length < 20 ? "blocked"
: agreementBp >= 9_000 && falsePass === 0 ? "calibrated"
: agreementBp >= 8_000 ? "probation" : "blocked";
return { reviewerId, reviewed: own.length, agreed, agreementBp, falsePass, falseFail, status };
}
实现里有三个刻意的选择。第一,抽检按风险分层,高风险层默认全检;调低必须显式传参,不能靠默认值悄悄发生。第二,派单先过滤再排序,合格人数不足时标记缺口而不是降低人数。第三,校准把漏放数单独计数,状态判定以「一致率 + 漏放」联合决定,避免高一致率掩盖系统性放水。
示例样本上的运行结果
示例样本包含 40 条 case:10 条高风险、20 条常规、10 条低风险。抽检率 10%,高风险全检。评审员池 4 名,其中 1 名与作者存在冲突、1 名校准通过率仅 70%,实际可用 2 名。
| 检查项 | 示例结果 |
|---|---|
| 抽检名单 | 高风险 10、常规 2、低风险 1,共 13 条 |
| 重复抽检 | 同一 seed 得到同一名单 |
| 合格评审员 | 2 名(冲突与低校准者被排除) |
| 派单 | 每条样本 2 名不同评审员 |
| 双 pass | settled,计入通过 |
| 一 pass 一 fail | needs-arbiter |
| 仲裁人 | 排除本案评审员后,选择校准 9,700bp 者 |
| 校准 19/20 | 9,500bp、漏放 0,calibrated |
| 校准 17/20 | 8,500bp,probation |
| 校准 14/20 | 7,000bp,blocked |
| 金标不足 20 条 | blocked,不允许接单 |
这些是示例样本上的运行结果,不是生产统计。真实评审的样本更多、分歧率更高,但分层抽检、双盲派单、分歧仲裁与校准阈值这四条不变。
人工评审的成本与节奏
按每条样本 8 分钟计算,每周 13 条抽检需要约 104 分钟,加上 3 条仲裁约 45 分钟,合计约 2.5 小时。这个量级可以由两名评审员分担,不需要全职岗位;但如果抽检率提高到 30%,工作量会接近 8 小时/周,必须增加人手或降低常规层比例。
人工评审不改变每次成功交付的成本,但它保护优化收益。三能力编排把每次交付从 4,240 微元降到 3,786 微元(-10.71%),复用回本线是 28.47%;一次漏放导致的线上事故,可能让整条链路的成功率跌回基线以下,把几个月的优化收益一次吃掉。评审成本应该和事故成本放在同一张账上,而不是当成额外开销。
评审节奏也要与发布节奏对齐。发布前一周提高高风险层抽检比例,发布后一周复核自动失败样本;平时维持基础抽检率。固定不变的抽检率会让评审资源在关键时刻不够用。
多团队共用的三条规则
第一条,评审标准由平台托管,能力团队补充领域条目。平台定义通用维度(准确性、完整性、合规性)与评分档位;能力团队只能增加领域规则,不能删除通用维度。这样跨团队的评审结论才有可比性。
第二条,评审员池跨团队共享,但利益冲突必须显式声明。评审员可以在多个能力上接单,前提是完成对应能力的校准;与作者同团队、同项目或存在汇报关系的,必须写进 conflicts 列表。隐式冲突靠自觉,显式冲突才能被系统拦住。
第三条,仲裁结论进入评测集。一次仲裁不只是解决一条样本,它应该产生一条新的金标样本或一条评分标准修订;否则同样的分歧会在下个季度重复出现。仲裁记录要能追溯到评测集版本与门禁证据包。
小结
人工评审的价值不在于「多一个人看一眼」,而在于用结构化流程处理自动化覆盖不了的判断。分层抽样决定看哪些,双盲派单决定谁来看,分歧仲裁决定听谁的,评审员校准决定这个人是否可信。
示例样本上,40 条 case 抽出 13 条,高风险全检;合格评审员只有 2 名,冲突与低校准者被排除;一致结论直接落槌,分歧进入 48 小时仲裁;校准 19/20 为 calibrated,14/20 为 blocked。把这些规则固化成平台函数之后,人工评审才能从「救火」变成可预测的治理环节。
阅读导航:上一节:5.2 自动化回归与门禁 · 下一节:6.1 线上质量信号与抽检 。
常见错误与边界
- 让作者评审自己的样本。作者最了解意图,也最容易为自己的输出找理由。
- 只做单盲。评审员知道对方身份后,容易向资历更高的一方靠拢。
- 分歧样本长期挂着不仲裁。没有时限的分歧等于默认通过。
- 仲裁人由两名评审员之一兼任。自己复核自己的结论,不是仲裁。
- 用一致率单独判定校准。漏放为 0 必须是硬条件,否则高一致率会掩盖放水。
- 校准一次管终身。评分标准、样本分布、评审员状态都会变,校准要定期重做。
- 抽检用真随机。复现时无法还原名单,复盘只能靠记忆。
- 合格评审员不足时退化成单人评审。人数是双盲的底线,缺口应该被看见。
- 把人工评审当成自动化的替代。吞吐不够,最后会变成只抽不看。
- 评审记录不回流评测集。同样的分歧会在下个季度重复发生。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。