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 与双向换位识别位置偏差,而不是相信一次随机排序。
  • 通过明确量表和判决依据抑制长度偏好,避免把表达丰富等同于正确。
  • 多裁判只聚合换位后仍自洽的票,并完整保存理由与运行配置。
  • 同时观察位置一致性、裁判一致性和金标准确率,不把模型置信度当成真实概率。
  • 对分歧、平票、低一致性和随机样本执行人工复核;高风险任务始终保留人工决策权。