让模型评分更稳定的五项工程措施
文章通过固定评分规则、结构化输出、零温度、确定性模拟评委和评委漂移检测,降低模型评估的波动。示例基于 evalgate,重点解决同一输出反复评分导致 CI 随机失败的问题。
AI 深度解读
让一个模型给另一个模型打分,能评估正则表达式难以处理的同理心、回答质量等问题。但作者把它接进 CI 后,同一份输入一次得 0.8,另一次只有 0.55,构建结果跟着反复变化。
作者围绕 evalgate 的 llm-judge 评分器,给出五个办法:固定评分标准、约束输出格式、设置 temperature、用确定性的 mock 测试、监控裁判漂移。它们解决的是评分能否稳定复现;评分是否正确,还需要人工校准。这一区别决定了模型裁判能承担多大的责任。
先固定评分标准,再谈分数稳定
「这个回答好不好」看起来直接,实际给模型留下了太多自由:完整性、简洁程度、语气,哪个更重要?每次调用都可能重新分配权重。
作者把评分标准,也就是 rubric,明确写进提示词,由调用方提供;如果有参考答案,再用它锚定目标。evalgate 用下面的函数组装提示词:
export function buildJudgePrompt(criteria: string, output: string, expected?: string): string {
return [
"You are a strict evaluator. Score how well the RESPONSE satisfies the CRITERIA.",
expected ? `REFERENCE (an ideal answer):\n${expected}` : "",
`CRITERIA:\n${criteria}`,
`RESPONSE:\n${output}`,
'Reply with ONLY compact JSON: {"score": <number between 0 and 1>, "reason": "<short>"}',
]
.filter(Boolean)
.join("\n\n");
}
要看最后一行:它要求返回只含 score 和 reason 的 JSON,并把分数限定在 0 到 1。前面的 criteria 固定评价依据,expected 提供可选参照,最后这行则让结果可以进入程序比较。
不过,要求返回 JSON 只是提示词约束,不能当成接口已经完成了格式校验。
调用时,作者通过 ctx.provider.complete 传入 prompt,设置 temperature: 0。模型选择顺序是 (spec.model as string) || ctx.case.model || "",解析对象则来自 reply.output。
降低 temperature 可以减少采样带来的变化,但作者也承认,批处理和硬件仍可能影响结果。temperature 设为 0,是减少随机性,不是保证每次输出完全一致。
JSON 能解析,还不等于分数合法
模型可能在 JSON 外添加解释,或者套上代码围栏。作者没有直接对完整回复执行 JSON.parse,而是先提取对象,再解析,失败时返回明确结果:
export function parseJudgeReply(text: string): { score: number; reason: string } {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try {
const obj = JSON.parse(match[0]);
const score = Number(obj.score);
if (!Number.isNaN(score)) {
return { score, reason: String(obj.reason ?? "judge returned a score") };
}
} catch {
// 继续进入失败分支
}
}
return { score: 0, reason: `could not parse judge reply: ${text.slice(0, 80)}` };
}
这里要看 Number(obj.score) 后面的判断:它只排除了 NaN,没有检查分数是否真的落在 0 到 1,也没有严格要求输入必须是数值类型。提示词里声明的范围,代码并未完整落实。
这段实现的价值,是把无法解析的回复变成一次可记录的失败:分数为 0,原因中保留回复前 80 个字符,方便排查 CI。
它也有边界。用于提取对象的正则是贪婪匹配,遇到多个 JSON 对象时可能一起抓取并解析失败。另外,「裁判没按格式回答」和「被评回答质量很差」都会得到 0。接入项目时,最好保留独立的失败类型,否则分数下降可能只是输出格式出了问题。
单元测试用假裁判,升级校准用真实样本
如果评分器的单元测试也调用真实模型,就把网络、费用和模型波动一起带进了测试。作者为此加入 mock provider:当 ctx.provider.name === "mock" 时,直接在本地计算分数。
计算采用小写单词集合的 Jaccard 相似度,也就是两组词的交集大小除以并集大小。有 expected 时,公式为 0.7 * refScore + 0.3 * critScore;没有参考答案时,只使用回答与评分标准的重合度 critScore。原因字符串通过 score.toFixed(3) 保留三位小数。
这个 mock 不负责理解语义。它负责让测试离线、稳定地检查评分流程,包括必填标准、参考答案回退和阈值比较。
原文的用例通过 run 调用 llmJudgeScorer,配置 type: "llm-judge"、criteria: "should be correct"、threshold: 0.5,让输出与参考答案都为 paris is the capital of france,最后用 expect(r.passed).toBe(true) 检查是否通过。这能证明测试流程工作,却不能证明真实裁判理解了事实正确性。
另一层检查是黄金样本:保留一小批人工确认过评分的案例,在修改模型或 rubric 后重新运行。作者用原来得 0.9、后来得 0.6 举例,说明应该在评审阶段发现标准漂移。
同时要明确固定裁判模型,避免默认使用 latest。至于具体固定到什么版本,原文没有给出。
我的判断
我认同作者把「测试评分器代码」和「校准模型判断」分开。相比所有测试都调用真实模型,确定性 mock 更适合验证程序逻辑;真实模型则应接受人工样本的持续检查。两者承担的任务不同。
但原文没有给出改造前后的重复运行次数、分数方差、误判率或成本对照。开头的 0.8 与 0.55 是作者的经历,不能据此推算这套方案能提升多少稳定性;0.7 和 0.3 的权重也没有实验依据。
固定标准同样可能固定偏差。作者承认,裁判可能偏爱长回答、接近自身措辞的回答,或者比较中的某个位置。可复现让错误更容易被发现,并不会自动把错误变成正确。
因此,我会把模型分数当作需要校准的信号。若要让它直接决定 CI 是否通过,除了温度和提示词,还要检查解析校验、错误分类,以及阈值是否经过人工样本验证。
能拿来做什么
- 给 AI 回答接入质量检查时,先把
criteria、可选的expected和通过阈值写成可评审配置,避免只留下「回答要好」这样的宽泛要求。 - 在 Node 或 TypeScript 评分器中,分别记录格式解析失败与内容低分,并补上数值类型、有限性和 0 到 1 的范围校验。
- 给评分流程写单元测试时,使用确定性 mock 检查配置、回退和阈值逻辑,把真实模型留给单独的校准检查。
- 修改裁判模型或评分标准前,重跑人工标注的黄金样本,查看分数变化和具体理由,再决定是否调整阈值。