LLM 裁判适合扩大评测覆盖面,但它的判决不等于客观事实。本文用 Node.js 搭建一条可复现的评估流水线,并通过换位一致性、多裁判一致性和人工抽检决定结果能否被采用。
先识别三类常见偏差
让模型在答案 A 和答案 B 中选出更好的一项,看似简单,实际至少有三类风险。
第一是位置偏差。相同答案只因放在 A 或 B,就可能得到不同判决。一次随机换位只能让整体分布更公平,不能识别单个样本是否稳定,因此更可靠的做法是对每个“样本—裁判”组合执行两次互换评测。
第二是长度偏好。更长的回答通常包含更多术语、步骤和解释,容易显得完整,但冗长并不代表正确。评分规则应明确:除非任务要求详细说明,否则不得仅因篇幅、格式或语气加分。
第三是自洽性不足。模型即便声称置信度很高,也可能在换位后推翻自己的结论。置信度是裁判生成的文本,不应直接当作概率使用;应优先观察可计算的一致性。
| 风险 | 可观测信号 | 流水线措施 |
|---|---|---|
| 位置偏差 | 换位后胜者改变 | 双向换位并统一映射答案 ID |
| 长度偏好 | 长答案不成比例地获胜 | 明确任务量表,要求引用具体依据 |
| 自洽性不足 | 同一裁判前后矛盾 | 标记不稳定,不让其进入稳定票池 |
| 单裁判偏差 | 不同模型结论分裂 | 多裁判聚合并设置复核阈值 |
评测集先于裁判提示词
数据集应保存输入、任务量表、候选答案和稳定 ID,而不是只保存 A、B。A、B 是每次评测临时生成的位置标签。还应准备一小部分人工确认的 gold 样本,用于校准流程,而不是用它反复修改提示词直到“刷高分”。
[
{
"id": "sum-001",
"input": "将下面内容压缩为一句话:缓存降低重复计算,但会带来失效与一致性问题。",
"rubric": ["保留收益与代价", "不得引入原文没有的结论", "一句话完成"],
"answers": {
"base": "缓存可以提升性能。",
"new": "缓存通过减少重复计算提升效率,但需要处理失效和一致性问题。"
},
"gold": "new"
}
]
数据集最好覆盖事实问答、摘要、代码解释和拒答等真实任务,同时记录来源、版本及生成参数。不要在裁判提示词中暴露模型名称、价格或“新旧版本”等信息,否则评测会混入品牌和预期偏差。
用 Node.js 实现盲测与多裁判聚合
下面示例使用 Node.js 20、OpenAI 官方 SDK 和 Chat Completions API。两个答案会以匿名 A/B 出现;每个裁判执行两种位置;只有两次选择映射到同一答案 ID 时,才产生一张稳定票。
先保存前述数据为 evalset.json,然后安装依赖:
npm init -y
npm install openai
export OPENAI_API_KEY="你的密钥"
export JUDGE_MODELS="gpt-4o-mini,gpt-4.1-mini"
node evaluate.mjs
创建 evaluate.mjs:
import fs from 'node:fs/promises';
import OpenAI from 'openai';
const client = new OpenAI();
const items = JSON.parse(await fs.readFile('evalset.json', 'utf8'));
const models = (process.env.JUDGE_MODELS || 'gpt-4o-mini')
.split(',').map(x => x.trim()).filter(Boolean);
const seed = process.env.EVAL_SEED || '2025-01';
function hash(text) {
let h = 2166136261;
for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
return h >>> 0;
}
async function judge(model, item, swapped) {
const ids = swapped ? ['new', 'base'] : ['base', 'new'];
const payload = {
task: item.input,
rubric: item.rubric,
answerA: item.answers[ids[0]],
answerB: item.answers[ids[1]]
};
const response = await client.chat.completions.create({
model,
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: '你是盲测裁判。仅按任务和量表判断,不推测答案来源;不得仅因长度、格式或语气加分。输出 JSON,字段为 winner(A、B 或 tie)、confidence(0 到 1)、rationale(具体判决依据)。'
},
{ role: 'user', content: JSON.stringify(payload) }
]
});
const raw = JSON.parse(response.choices[0].message.content);
if (!['A', 'B', 'tie'].includes(raw.winner)) throw new Error('非法 winner');
const winner = raw.winner === 'tie' ? 'tie' : ids[raw.winner === 'A' ? 0 : 1];
return { winner, confidence: raw.confidence, rationale: raw.rationale };
}
function group(rows, key) {
const out = new Map();
for (const row of rows) {
const k = key(row);
out.set(k, [...(out.get(k) || []), row]);
}
return out;
}
const records = [];
for (const item of items) {
for (const model of models) {
const first = hash(`${seed}:${item.id}:${model}`) % 2;
for (const swapped of [first, 1 - first]) {
const result = await judge(model, item, Boolean(swapped));
records.push({ itemId: item.id, judge: model, swapped: Boolean(swapped), ...result });
}
}
}
const summaries = [];
for (const [itemId, rows] of group(records, x => x.itemId)) {
const stableVotes = [];
let inconsistent = false;
for (const pair of group(rows, x => x.judge).values()) {
if (pair.length === 2 && pair[0].winner === pair[1].winner) {
stableVotes.push(pair[0].winner);
} else inconsistent = true;
}
const counts = {};
for (const vote of stableVotes) counts[vote] = (counts[vote] || 0) + 1;
const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1]);
const tied = ranked[1] && ranked[0][1] === ranked[1][1];
const winner = !ranked.length || tied ? 'review' : ranked[0][0];
const agreement = stableVotes.length ? ranked[0][1] / stableVotes.length : 0;
const sampled = hash(`${seed}:audit:${itemId}`) % 10 === 0;
const needsReview = inconsistent || agreement < 0.67 || winner === 'tie' ||
winner === 'review' || sampled;
summaries.push({ itemId, winner, agreement, inconsistent, sampled, needsReview });
}
const mean = xs => xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
const goldRows = summaries.filter(s => items.find(i => i.id === s.itemId).gold);
const metrics = {
positionConsistency: mean(summaries.map(s => s.inconsistent ? 0 : 1)),
judgeAgreement: mean(summaries.map(s => s.agreement)),
goldAccuracy: mean(goldRows.map(s => s.winner === items.find(i => i.id === s.itemId).gold ? 1 : 0)),
reviewRate: mean(summaries.map(s => s.needsReview ? 1 : 0))
};
await fs.writeFile('results.json', JSON.stringify({ seed, models, metrics, summaries, records }, null, 2));
console.log(metrics);
results.json 同时保留原始判决、换位状态、置信度和理由,便于定位争议。生产环境还应为请求失败增加有限重试,并固定数据集版本、提示词版本、模型名称和运行时间。
用一致性指标划定自动评估边界
位置一致性表示换位前后是否仍指向同一答案;裁判一致性表示稳定票中占比最高的答案获得了多少支持;金标准确率则用于发现整个裁判系统是否偏离人工标准。
这些指标没有适用于所有业务的万能阈值。示例中的 0.67 只是三名及以上裁判时常见的多数线,团队应在独立校准集上确定阈值,并按任务风险调整。医疗、合规或安全回答即使一致,也不应仅凭自动裁判发布。
建议将以下样本送入人工队列:换位结论不一致、裁判票数打平、聚合结果为 tie、一致性低于阈值,以及固定比例的随机抽检。随机抽检不能省略,因为“所有裁判稳定地犯同一个错误”不会被一致性指标发现。
人工复核应看到原始任务、匿名答案、量表和各裁判理由,但最好先独立作答,再查看机器结论,避免被裁判意见锚定。复核结果应回写为新的校准数据,并按错误类型统计,而不是只修改最终胜负。
总结
- 用稳定答案 ID 与双向换位识别位置偏差,而不是相信一次随机排序。
- 通过明确量表和判决依据抑制长度偏好,避免把表达丰富等同于正确。
- 多裁判只聚合换位后仍自洽的票,并完整保存理由与运行配置。
- 同时观察位置一致性、裁判一致性和金标准确率,不把模型置信度当成真实概率。
- 对分歧、平票、低一致性和随机样本执行人工复核;高风险任务始终保留人工决策权。
评论