多评委交叉验证解决的核心问题,是单个模型打分既不稳还偏心。最初用单个大模型当裁判,上线一周就发现评分明显偏高,同一批答案换个模型重评,分数能差出一分多,用户投诉接二连三。后来我引入了 LLM-as-Judge 加多评委交叉验证:多个不同家族的模型独立打分,取中位数,再算方差,方差大就转人工复核。这是平台最有技术含量的部分,下面把实现细节完整拆开,包括评分 prompt 怎么写、并行怎么调、阈值怎么定、成本怎么压。
一、为什么需要多评委交叉
单评委方案的问题不是某一个模型的锅,而是结构性缺陷。模型在给自己家族的输出打分时会不自觉地放水,这叫自我偏好;不同模型对不同题型(创意、技术、数学)的把握也不一样,评分标准天然不齐;再加上推理过程的随机性,同样输入两次打分都可能不一样。这三条叠加在一起,单评委的分数就很难让人信服。当时我们收集到的现象主要有三类:
- 自我偏好:GPT-4 评 GPT-4 输出普遍偏高 0.5-1 分;
- 领域偏见:不同模型对不同类型题目(创意/技术/数学)评分标准不一;
- 随机性:温度 > 0 时同一答案两次评分可能差 0.5-1 分。
针对这三个问题,多评委交叉验证的逻辑是这样设计的:
- 多个独立模型打分 → 取中位数 → 算方差;
- 方差小(< 阈值)= 评分一致 → 取中位数作最终分;
- 方差大(> 阈值)= 评分分歧 → 触发人工复核。
说白了,多评委的本质是把”一个人的判断”升级成”一个小组的投票”,用统计量而不是单个模型的输出来兜底。中位数抗极端值,方差识别分歧,两条一起用,比任何单一指标都稳。
二、整体架构
架构上我们刻意保持简单:一个评分服务入口,三个评委并行,中位数定分,方差分流,全部串成一条直通链路。
评分请求
↓
评分服务
├─ 选 3 个评委(不同家族)
├─ 并行调用 3 个 LLM
│ ├─ GPT-4o 打分 → score1
│ ├─ Claude 打分 → score2
│ └─ DeepSeek 打分 → score3
├─ 中位数算法 → finalScore
├─ 方差检测
│ ├─ 方差小 → 入库
│ └─ 方差大 → 人工复核队列
└─ 写库 + 通知
这个流程跑通后,线上评分一致性从单评委时代的七成出头提升到九成以上,人工复核率稳定压在 10% 以内。用户对分数质疑的工单数量明显下降,这算是整套方案最直接的验收标准。
三、评分 Prompt 设计
评委模型各不相同,但评分标准必须统一,否则三个模型的分数没有可比性。我们把角色设定、评分标准、输出格式、待评估内容全部写进同一个模板,对三个评委一视同仁,差异只体现在模型本身的风格上。
# 角色
你是一名资深的 AI 答案质量评审员。
# 评分标准
- 9-10:与参考答案一致,覆盖所有关键点
- 7-8:整体正确,遗漏次要细节
- 5-6:方向正确但有明显错误
- 3-4:偏离主题
- 0-2:与问题无关
# 输出格式(严格 JSON)
{
"score": 8,
"dimensions": {
"accuracy": 9,
"completeness": 7,
"usability": 8
},
"issues": ["未提及 X 的边界条件"],
"summary": "答案整体可用,但 X 场景不完整"
}
# 待评估内容
问题:{question}
参考答案:{reference}
待评答案:{candidate}
模板里最关键的约束是”严格 JSON 输出”,它直接决定了后面解析层的成功率。我们试过不给格式约束,模型会输出一大段解释文字再把分数藏在里面,解析起来非常痛苦。另外把参考答案也放进模板,是为了让”对错”有据可依,而不是模型凭感觉给分。
四、并行调用实现
三个评委如果串行调用,评分耗时就是三次调用之和,一个批次用户要等一分钟以上,完全不可接受。我们基于 WebFlux 的 Flux 并行发起三个请求,全部返回后统一汇总。
@Service
public class JudgeService {
@Autowired private List<JudgeModel> judges; // 注入 3 个评委
public JudgeResult score(ScoringRequest req) {
// 1) 并行调用 3 个评委
List<JudgeResult> results = Flux.fromIterable(judges)
.flatMap(j -> j.scoreAsync(req)
.subscribeOn(Schedulers.boundedElastic()))
.collectList()
.block();
// 2) 解析分数
List<Integer> scores = results.stream()
.map(JudgeResult::getScore)
.sorted()
.toList();
// 3) 中位数
int median = scores.size() % 2 == 1
? scores.get(scores.size() / 2)
: (scores.get(scores.size()/2 - 1)
+ scores.get(scores.size()/2)) / 2;
// 4) 方差
double variance = variance(scores);
if (variance > VARIANCE_THRESHOLD) {
reviewQueueService.push(req, results);
}
// 5) 汇总多维分
Map<String, Double> dimAvg = averageDimensions(results);
return new JudgeResult(median, dimAvg, results, variance);
}
}
选中位数而不是平均数,是为了抗极端值——万一某个评委抽风给出 2 分,平均数会被明显拉低,中位数几乎不受影响。方差检测放在中位数之后,目的就是识别这种分歧并把请求送进人工复核,而不是悄悄吞掉异常。
五、评委选型策略
评委不是随便凑三个模型就行的。我们的硬约束是跨家族,GPT 家族、Claude 家族、国产模型各自出评委,避免同源的模型相互印证、集体放水。不同评委在不同题型上各有强项,下面这张表是我们的选型依据。
| 评委 | 优势 | 适用 |
|---|---|---|
| GPT-4o | 综合能力强 | 默认评委 |
| Claude-3.5 | 逻辑严谨 | 代码/数学题 |
| DeepSeek | 中文理解强 | 中文场景 |
| 国产大模型 | 性价比高 | 大批量评分 |
默认组合是 GPT-4o 加 Claude 加 DeepSeek,覆盖了综合、逻辑、中文三个方向,实际跑下来评分分歧率在三个候选组合里是偏低的。工程上我们用 Spring 的 @Qualifier 注入不同实现,加新评委只写一个类就行,不用动评分逻辑。
public interface JudgeModel {
String name();
Mono<JudgeResult> scoreAsync(ScoringRequest req);
}
@Service("gpt4o") public class GPT4oJudge implements JudgeModel { ... }
@Service("claude") public class ClaudeJudge implements JudgeModel { ... }
@Service("deepseek") public class DeepSeekJudge implements JudgeModel { ... }
@Configuration
public class JudgeConfig {
@Bean
public List<JudgeModel> defaultJudges(
@Qualifier("gpt4o") JudgeModel gpt4o,
@Qualifier("claude") JudgeModel claude,
@Qualifier("deepseek") JudgeModel deepseek
) {
return List.of(gpt4o, claude, deepseek);
}
}
评委列表通过配置类装配成 Bean,后续如果要按用户选择评委组合,只要动态注入不同的 List 实现,评分核心逻辑一行都不用改。
六、方差阈值设定
方差阈值是整套方案的灵魂参数。设大了,分歧全漏进自动评分;设小了,人工复核队列瞬间爆满。我们不用拍脑袋的经验值,而是拿历史评分数据来定。
// 收集过去 30 天评分数据
// 相同答案不同评委分数的方差分布
// 经验值:90% 情况 variance < 1.5
private static final double VARIANCE_THRESHOLD = 1.5;
// 动态调整:每月统计
public void updateThreshold() {
double p90 = scoreVarianceLog.percentile(0.9);
VARIANCE_THRESHOLD = p90;
}
p90 的思路很直观:过去 30 天里 90% 的正常评分方差都低于这个值,超过的才算异常,值得人工介入。阈值每个月滚动更新,模型升级、评分 prompt 调整后,阈值会自动跟着漂移,不用人工干预。
七、JSON 解析与降级
即使 prompt 里写了严格 JSON,模型偶尔还是会在外面包一层解释文字,甚至输出一段完全没有 JSON 的文本。解析层必须做三级降级,逐级兜底。
public JudgeResult parseResponse(String response) {
try {
return JsonUtil.parse(response, JudgeResult.class);
} catch (JsonParseException e) {
// 降级 1:提取 JSON 块
Pattern p = Pattern.compile("\\{[\\s\\S]*\\}");
Matcher m = p.matcher(response);
if (m.find()) {
try {
return JsonUtil.parse(m.group(), JudgeResult.class);
} catch (Exception ignore) {}
}
// 降级 2:提取第一个数字作为 score
Pattern num = Pattern.compile("\\b(\\d{1,2})\\b");
Matcher m2 = num.matcher(response);
if (m2.find()) {
int score = Integer.parseInt(m2.group(1));
return JudgeResult.simple(score, "JSON 解析失败降级");
}
// 降级 3:标记失败
throw new JudgeParseException("无法解析评分: " + response);
}
}
三级降级把解析失败率从最初的 15% 压到 1% 以内。降级 2 只提取第一个数字,风险是可能取到别的数字,所以我们会给结果打上”降级”标记,让后续的统计和质量监控能识别这类样本,单独分析。
八、成本控制
3 个评委意味着每次评分的成本是单评委的 3 倍,评测规模上来之后账单很吓人。我们做了四档降本策略,每一档都是基于”按需置信度”的思路。
| 策略 | 实现 | 节省 |
|---|---|---|
| 短答案只 1 评委 | 长度 < 200 字符 | 60% |
| 高置信度 1 评委 | 短答案 + 高 temperature | 50% |
| 小模型当评委 | 优先级低的任务用国产小模型 | 70% |
| 结果缓存 | 相同答案+模型 24h 缓存 | 30% |
组合拳打下来,整体成本大约降到全量三评委方案的 55% 左右,评分质量没有明显下降。关键判断是:短答案和低优先级任务不需要那么高的置信度,用单评委足够。代码上的落地很直接,按候选答案长度分流。
public JudgeResult score(ScoringRequest req) {
if (req.getCandidate().length() < 200) {
// 短答案:单评委
return judges.get(0).scoreAsync(req).block();
}
// 长答案:3 评委交叉
return scoreWithMultipleJudges(req);
}
200 字符这条线是统计出来的:长度低于它的答案信息量有限,多评委和单评委的分歧率本来就低,砍掉两个评委对质量影响很小。这一刀是性价比很高的一档。
九、人工复核机制
方差超阈值的请求会进”待复核”队列,需要真人来看。队列表设计上把三个评委的原始输出全部存下来,复核人一眼就能看到分歧点在哪,而不是对着一个孤零零的最终分发呆。
CREATE TABLE judge_review_queue (
id BIGINT PRIMARY KEY,
request_id BIGINT NOT NULL,
candidate TEXT,
reference TEXT,
judge_results JSON, -- 3 个评委的原始输出
variance DECIMAL(4,2),
status VARCHAR(16) DEFAULT 'pending', -- pending/in_review/resolved
final_score INT,
reviewer_id BIGINT,
created_at DATETIME,
INDEX idx_status_created (status, created_at)
);
status 字段支持 pending、inreview、resolved 三态流转,索引建在 (status, createdat) 上,后台按时间倒序拉待办,翻页很顺。管理后台提供一个列表接口,默认按状态筛选。
@GetMapping("/admin/review/list")
public Page<ReviewVO> listPending(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return reviewService.listPending(page, size);
}
复核人改完最终分后状态置为 resolved,系统会回调更新评测结果表。这套闭环跑通后,人工复核不再是个黑盒,谁复核的、改了什么分,全程留痕。
十、为什么不用 reward model
方案评审时有人提议用 reward model 做评分——让模型学会打分,而不是每次推理打分。我们认真评估过,结论是现阶段不划算,成本收益差太多。
| 维度 | LLM-as-Judge | Reward Model |
|---|---|---|
| 训练成本 | 零 | 数十万 + 标注数据 |
| 跨模型泛化 | 强 | 弱(只评训过的领域) |
| 维护成本 | 改 prompt 即可 | 重训 |
| 可解释性 | 强(输出 issues) | 弱(一个分数) |
| 适用 | 中小规模平台 | 大厂核心业务 |
reward model 强在速度快、成本低,但它的泛化边界是训练时见过的领域,换个题型、换个模型家族就要重训,而且只输出一个分数,没有理由可看。我们的用户会追问”为什么给这个分”,LLM-as-Judge 的 issues 字段就是现成的解释。平台规模 1000-10000 答案/天,LLM-as-Judge 完全够用。Reward Model 是大厂游戏。
十一、踩过的坑
这套方案从原型到上线,踩的坑比预想的多。列出来的这几条,每一条都改写过我们的实现,写下来给后来的人。
- JSON 解析失败率高:模型输出多了换行/解释文字。prompt 加”严格 JSON 格式” + 解析层降级。
- 评分漂移:同一答案隔天评分差 1-2 分。温度设 0 + 多次评分取众数。
- 评委超时拖累整批:3 评委任一超时整个任务失败。评委独立超时(30s)+ 失败重试 1 次。
- 评分员泄题:GPT-4 评 GPT-4 答案触发先验。评委必须跨家族。
- 维度分数不稳定:dimensions 字段经常少 1-2 维。prompt 强调”必须输出 3 个维度”。
这些坑的共性是:模型行为不可控,所以代码和 prompt 都要按”模型会出错”来设计,而不是按”模型很听话”来设计。解析降级、超时隔离、跨家族选评委,本质都是在给模型的不可控性兜底。
十二、与 SxS 评测的集成
SxS(Side-by-Side)评测是平台的另一条产品线:多个模型答同一道题,横向比谁答得好。多评委机制天然能嵌进去,每个答案独立评分,再按分数排序。
@PostMapping("/sxs/submit")
public BaseResponse<Long> submit(@RequestBody SxSRequest req) {
// 1) 调 N 个模型生成答案
List<Answer> answers = parallelCall(req.getModels(), req.getQuestion());
// 2) 每个答案独立评分
List<ScoredAnswer> scored = answers.stream()
.map(a -> new ScoredAnswer(a, judgeService.score(toScoringRequest(a))))
.sorted(Comparator.comparingInt(ScoredAnswer::getScore).reversed())
.toList();
// 3) 排序后返回
return ResultUtils.success(sessionService.create(req, scored));
}
集成之后,SxS 的结果直接带分数排序,用户看到的不是孤立的答案,而是有评分依据的横向对比。到这里,多评委方案在平台的落地链路就完整了——从单答案评分到 SxS 对比,用的都是同一套评分内核。
常见问题(FAQ)
Q1:评委能选吗?
能。高级用户可自定义评委组合(如”只用国产模型”),平台按选择调用。
Q2:人工复核要多久?
3 个工作日内。紧急工单标记 priority=high,2 小时内响应。
Q3:评分公平性怎么验证?
平台每月抽 100 个”评委一致”的答案做人工抽检,准确率 < 90% 触发 prompt 调整。