LLM-as-a-Judge:用 Rust 实现一套 LLM 评估系统
代码仓库:langchainrust · 全部可跑,文末有真实测试数据
一、为什么需要 LLM 评估
LLM 应用落地后,绕不开一个问题:输出到底好不好?
改了一版 prompt,换了模型,接了 RAG,加了个 Agent--每次改动都需要量化"是变好了还是变差了"。靠人眼看几百条不现实,得自动评估。
评估这件事,业界把它拆成两类:
- 有参考评估(reference-based):有标准答案,拿模型输出跟它比。简单、客观,但很多任务没有标准答案(翻译、摘要、开放式问答)。
- 无参考评估(reference-free):没有标准答案,按有用性、安全性、连贯性等维度直接评。灵活,但更难,通常需要"懂语义"的判官。
传统自动指标(BLEU、ROUGE、精确匹配)在有参考场景用了几十年,但它们都是字面比对:模型答"法国的首都巴黎",参考是"巴黎",意思对,字面不等,直接判错。这对 LLM 这种"话多但意思对"的输出是灾难。
于是有了 LLM-as-a-Judge:用一个 LLM 当阅卷老师,让它读懂语义后打分。这篇讲怎么在 Rust 里实现一套完整的 LLM 评估系统,从最笨的字面匹配,一直到带偏差缓解的 LLM 裁判。
二、评估的核心抽象
评估就一个 trait--给 (输入, 模型预测, 参考答案),吐一个 0.0--1.0 的分数:
rust
#[async_trait]
pub trait Evaluator: Send + Sync {
async fn eval(
&self,
input: &str, // 原始输入
prediction: &str, // 模型预测
reference: &str, // 参考答案
) -> Result<Score, EvalError>;
fn name(&self) -> &str;
}
被测对象(LLMChain / Agent / 任何东西)抽象成 Predictor:
rust
#[async_trait]
pub trait Predictor: Send + Sync {
async fn predict(&self, input: &str) -> Result<String, EvalError>;
}
EvalRunner 把它们串起来:遍历数据集 -> 每条样例让 predictor 预测 -> 用所有评测器各打一次分 -> 汇总成 Report(逐条得分 + 各评测器平均分)。这个设计让"被测对象"和"打分标准"完全解耦,同一个数据集可以换不同评测器跑。
下面看四种评测器,从笨到聪明。每个评测器后面都附一个真实跑出来的例子(GLM-5.2 裁判 + LocalEmbeddings),直观看它的局限。
三、十种评测器:从字面到语义到规则到 RAG
按打分依据分五类:
| 类别 | 评测器 | 依据 | 成本 |
|---|---|---|---|
| 字面 | ExactMatch | 字符串相等 | 0 |
| 字面 | StringDistance | Levenshtein 编辑距离 | 0 |
| 语义 | EmbeddingSimilarity | 向量余弦相似度 | 0 |
| 语义 | LLMAsJudge | LLM 读懂语义打分 | 每条调一次 LLM |
| 规则 | ContainsKeyword | 是否包含关键词 | 0 |
| 规则 | RegexMatch | 正则匹配 | 0 |
| 规则 | LengthCheck | 长度范围 | 0 |
| 经典NLP | Bleu | n-gram 精度 + 短句惩罚 | 0 |
| 语义 | PairwiseJudge | LLM 成对比较(二选一,带位置消偏差) | 每次调 2 次 LLM |
| RAG | Faithfulness | 拆陈述逐条验证能否从上下文推导(幻觉检测) | 每条陈述调 1 次 LLM |
下面逐个看十种(前四种带真实例子,后六种带构造例子)。
1. ExactMatch -- 精确匹配
最笨:两个字符串去空格后相不相等。相等 1.0,不等 0.0。
rust
pub struct ExactMatch;
#[async_trait]
impl Evaluator for ExactMatch {
async fn eval(&self, _input: &str, prediction: &str, reference: &str) -> Result<Score, EvalError> {
let matched = prediction.trim() == reference.trim();
Ok(Score::new(if matched { 1.0 } else { 0.0 })
.with_label(if matched { "match" } else { "mismatch" }))
}
fn name(&self) -> &str { "exact_match" }
}
适用 :事实性问答、分类任务(答案空间小且固定)。致命伤:答"法国的首都巴黎"对"巴黎" -> 0 分,冤枉。
真实例子(翻译场景):输入 "It's raining cats and dogs.",参考答案"倾盆大雨"。
| 预测 | 内容 | ExactMatch | StringDistance | EmbeddingSim | LLMAsJudge |
|---|---|---|---|---|---|
| A(意译,对) | 大雨滂沱 | 0.0000 | 0.0000 | 0.7500 | 1.0000 |
| B(直译,错) | 正在下猫和狗 | 0.0000 | 0.0000 | 0.5000 | 0.1000 |
ExactMatch 给 A、B 都是 0--A "大雨滂沱"意思明明对,但字面 ≠ "倾盆大雨"就 0 分,冤枉。翻译这种"正确答案有无数种说法"的场景,字面相等几乎永远不成立,ExactMatch 全面失效。
2. StringDistance -- Levenshtein 编辑距离
编辑距离(Levenshtein):把一个字符串改成另一个,最少要动几下手。三种动作,每下算 1 分,取所有改法里步数最少的:
- 替换一个字(巴->东)
- 删除一个字
- 插入一个字
举例:
| 预测 -> 参考 | 怎么改 | 距离 |
|---|---|---|
| 巴黎 -> 巴黎 | 不用改 | 0 |
| 巴黎 -> 巴厘 | 换 1 个字(黎->厘) | 1 |
| 巴黎 -> 东京 | 换 2 个字 | 2 |
| 巴黎 -> 巴黎人 | 插 1 个字(人) | 1 |
距离是绝对数(0 到"较长串长度"之间),当不了分数,所以要归一:除以 max(两串长度) 压成 0~1 的"差异度",再用 1 减翻成"相似度"--改得越少越像,不改就是 1.0,推倒重写就是 0。
相似度 = 1 - 编辑距离 / max(两串长度)
| 预测 -> 参考 | 距离 | max长度 | 相似度 |
|---|---|---|---|
| 巴黎 -> 巴黎 | 0 | 2 | 1.0 |
| 巴黎 -> 巴厘 | 1 | 2 | 0.5 |
| 巴黎 -> 东京 | 2 | 2 | 0.0 |
rust
fn levenshtein(a: &str, b: &str) -> usize {
// 经典 DP,两个滚动数组省内存
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let (m, n) = (a.len(), b.len());
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr: Vec<usize> = vec![0; n + 1];
for i in 1..=m {
curr[0] = i;
for j in 1..=n {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
// score = 1.0 - levenshtein(pred, ref) as f64 / max_len
比 ExactMatch 柔和(有连续值),但本质还是比字面。拼写纠错场景好用,语义判断不行。
真实例子(代码场景) :输入"写反转字符串的 Python 函数",参考答案 s[::-1]。
| 预测 | 内容 | ExactMatch | StringDistance | EmbeddingSim | LLMAsJudge |
|---|---|---|---|---|---|
| B(循环,对) | for i in range(...): result += s[i] |
0.0000 | 0.0870 | 0.8769 | 0.5000 |
| C(错) | s.reverse() |
0.0000 | 0.0909 | 0.7500 | 0.0000 |
B 是对的、C 是错的,但 StringDistance 给两者几乎一样的低分(0.087 / 0.091)--字面距离根本分不出对错。它只知道"跟参考差几个字",不知道"这代码能不能跑"。
3. EmbeddingSimilarity -- 嵌入余弦相似度
把预测和参考答案都 embed 成向量,算余弦,从 [-1, 1] 映射到 [0, 1]:
rust
let p = self.embeddings.embed_query(prediction).await?;
let r = self.embeddings.embed_query(reference).await?;
let sim = cosine_similarity(&p, &r);
let v = ((sim + 1.0) / 2.0).clamp(0.0, 1.0);
终于不看字面了,看语义像不像。但它有两个问题:
- 是"像不像",不是"对不对":模型答了句和参考很像但事实错误的话,照样高分。
- 无判别力的中间值:不相关的两句话余弦≈0,归一后是 0.5。这个 0.5 不是"半对",是"我不知道"--对决策没帮助。
嵌入质量决定一切。本文测试用的是纯 Rust 的 LocalEmbeddings(词袋 hash,粗粒度);生产环境应上 BGE/E5 这类神经网络嵌入。
真实例子(RAG 幻觉场景):输入"公司年假多少天?",参考答案"年假 15 天"。
| 预测 | 内容 | ExactMatch | StringDistance | EmbeddingSim | LLMAsJudge |
|---|---|---|---|---|---|
| A(忠实) | 员工年假为 15 天 | 0.0000 | 0.7000 | 0.8780 | 1.0000 |
| B(幻觉) | 员工年假为 20 天,可累积 | 0.0000 | 0.3571 | 0.7372 | 0.0000 |
B 是幻觉(编了"20 天"),但 EmbeddingSim 给它 0.7372 ,跟忠实的 0.878 差不多--因为两者都含"年假""天"。它只看像不像,分不出幻觉 。这是 RAG 场景的致命伤:幻觉回答放出去会误导用户,而嵌入相似度完全检测不出来。这也是 Ragas 框架专门做 faithfulness 指标的原因。
4. LLMAsJudge -- LLM 当裁判
前三个都是尺子:量字面、量距离、量向量。都不懂"意思"。
LLM-as-a-Judge 直接请一个 LLM 当阅卷老师:把题目、参考答案、学生回答打包给它,让它打分。它能读懂语义,知道"答对没答对",还能评"有没有跑题、有没有胡说、是否安全"。
这个概念来自两篇 2023 年的论文(注:以下论文信息凭记忆,具体 arXiv 编号建议自行核实):
- LMSYS 的 Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena (Zheng et al., 2023):用 GPT-4 当裁判评估聊天机器人,提出 MT-Bench(多轮基准)和 Chatbot Arena(竞技场)。关键发现:GPT-4 当裁判与人类判断一致率约 80%(单点)/ 90%(成对),成对比较比单点更接近人类。
- G-Eval(Liu et al., 2023,微软):提出用**思维链(CoT)**让裁判先写分析再给分,显著提升一致性。
真实例子(摘要场景,无参考):给一段关于光合作用的长文,模型生成摘要,把原文当 reference。B 摘要把"植物"写成了"动物"、说"发生在根部"--事实错误。
| 预测 | 内容 | ExactMatch | StringDistance | EmbeddingSim | LLMAsJudge |
|---|---|---|---|---|---|
| A(好摘要) | 光合作用是植物利用阳光将二氧化碳和水转化为葡萄糖和氧气... | 0.0000 | 0.3688 | 0.9567 | 0.7000 |
| B(差摘要) | 光合作用是动物利用阳光制造食物的过程,只发生在根部。 | 0.0000 | 0.1125 | 0.7521 | 0.0000 |
EmbeddingSim 对好坏摘要都给高分(都从原文衍生、共享大量词),分不出好坏。LLMAsJudge 看出 B 有事实错误,直接给 0;A 给 0.7。前三个评测器在无参考场景给的全是无意义分数,只有 LLM 裁判能真正评语义质量。
5. ContainsKeyword -- 关键词包含
检查预测是否包含指定关键词。all_required=true 全部命中才 1.0,false 任一命中即 1.0,可配大小写敏感。
rust
pub struct ContainsKeyword {
keywords: Vec<String>,
all_required: bool,
case_sensitive: bool,
}
// 全部命中(all_required)或任一命中 -> 1.0,否则 0.0
适用 :检查回答是否提到必备要素(如"年假""天")、合规词检测。局限:只看在不在,不看对不对--"年假 20 天"含"年假""天"照样 1.0,测不出数字错。
例子 :关键词 ["年假", "天"],all_required=true:
| 预测 | 命中 | 分数 |
|---|---|---|
| 员工年假为15天 | 全部 | 1.0 |
| 不清楚 | 无 | 0.0 |
6. RegexMatch -- 正则匹配
预测是否符合给定正则。命中 1.0,不命中 0.0。
rust
pub struct RegexMatch(Regex);
// self.0.is_match(prediction) -> 1.0 / 0.0
适用 :格式校验(手机号、邮箱、日期)、结构化输出检查。局限:只验格式不验语义--"12345678901"符合 11 位但未必是真号。
例子 :正则 \d{4}-\d{2}-\d{2}(日期):
| 预测 | 匹配 | 分数 |
|---|---|---|
| 2026-07-14 | 是 | 1.0 |
| 2026/7/14 | 否 | 0.0 |
7. LengthCheck -- 长度范围
预测字符数是否落在 [min, max]。在范围内 1.0,否则 0。min/max 可选(只卡上限或下限)。
rust
pub struct LengthCheck { min: Option<usize>, max: Option<usize> }
// let n = prediction.chars().count(); 满足 min/max 约束 -> 1.0
适用 :摘要字数控制、防超长/过短、简单护栏。局限:长度对≠内容对--废话凑字数也 1.0。
例子 :min=10, max=50:
| 预测 | 字数 | 分数 |
|---|---|---|
| 光合作用是植物...(30字) | 30 | 1.0 |
| 好 | 1 | 0.0(太短) |
8. Bleu -- n-gram 精度
经典机器翻译指标。统计预测与参考的 n-gram(默认到 4)重叠比例取几何平均,再乘短句惩罚(BP):预测比参考短就扣分。
rust
// 每阶:matches = 预测 n-gram 在参考中的出现次数(clip 截断)
// p_n = matches / total; BLEU = BP * exp(mean(ln p_n))
// BP = 1 if plen>rlen else exp(1 - rlen/plen)
适用 :翻译/生成与参考的重合度(精度侧)。局限 :只看 n-gram 不懂语义;中文须开 with_char_level(true)(按字切,否则整句一个 token);短句默认因高阶 n-gram 缺失归零,可开 with_smoothing(true)。
例子 :Bleu::new().with_char_level(true).with_max_n(2):
| 预测 | 参考 | 分数 |
|---|---|---|
| 猫坐在垫子上 | 猫坐在垫子上 | 1.0 |
| 猫坐在垫子上 | 狗跑到院子里 | 0.0 |
9. PairwiseJudge -- 成对比较
不给绝对分,拿两个回答 A/B 让裁判二选一。关键:交换 A/B 顺序跑两次,两次都选同一个才算真赢,否则判平局--消除"裁判偏爱排在前面的"位置偏差。
rust
// v1 = ask(A在前, B在后); v2 = ask(B在前, A在后)
// (First, Second) => AWins // 两次都选 A
// (Second, First) => BWins // 两次都选 B
// 其他 => Tie // 位置偏差,不敢判
适用 :模型对比(A vs B 谁好)、排序;成对比较与人类更接近(MT-Bench:成对 ~90% 一致 > 单点 ~80%)。局限:每次调 2 次 LLM;保守策略使 Tie 偏多。详见第五节。
真实例子 :忠实回答 vs 幻觉回答 -> 判 Tie(交换后裁判两次选择不一致,位置消偏差正确判平局,而非瞎选)。
10. Faithfulness -- 忠实度(幻觉检测)
RAG 专用。把回答拆成原子陈述,逐条问裁判"能否从上下文推导",通过率即忠实度。
rust
// claims = split(prediction) // 按标点拆,可 with_llm_split 用 LLM 拆复合句
// 并发 verify 每条 claim -> 能否推导; score = 支持数 / 总数
适用 :RAG 幻觉检测--回答有没有超出检索内容编造。局限 :每条陈述调 1 次 LLM(已 join_all 并发优化);只验"忠于上下文",上下文本身错则查不到(需 Factuality);空预测默认 1.0(没编=忠实),可 with_empty_score 改。
真实例子 (详见附录 A):幻觉回答(编"20 天")忠实度 0 ,忠实回答 1.0,完美区分。
以上十种:1-3 是字面尺子,4 是语义裁判,5-7 是规则护栏,8 是经典 NLP 指标,9-10 是 LLM 驱动的进阶评测。下面先看 LLMAsJudge 的 Rust 实现,以及如何缓解它固有的偏差。
四、LLMAsJudge 实现:单点打分
数据结构
rust
pub struct LLMAsJudge<M: BaseChatModel> {
judge: M, // 当裁判的 chat 模型
rubric: String, // 评分标准
max_score: u8, // 满分值,默认 10
}
泛型 M: BaseChatModel--任何实现框架 BaseChatModel 的模型都能当裁判(OpenAI、Ollama、Anthropic、GLM 等)。with_rubric / with_max_score 可覆盖默认。
构造裁判 prompt(轻量 CoT)
rust
fn build_prompt(&self, input: &str, prediction: &str, reference: &str) -> (String, String) {
let system = format!(
"你是一个严格、公正的评估员。请根据以下评分标准对待评估的回答打分。\n\n\
评分标准:\n{rubric}\n\n\
打分范围:0 到 {max}(0 = 完全错误或无关,{max} = 完全正确)。\n\n\
要求:先在 reason 字段写出简短分析,再在 score 字段给出分数。\n\
只输出一行 JSON:{{\"reason\":\"...\",\"score\":N}}",
rubric = self.rubric,
max = self.max_score
);
let user = format!(
"输入:\n{input}\n\n参考答案:\n{reference}\n\n待评估的回答:\n{prediction}\n\n请评估。",
);
(system, user)
}
reason 字段排在 score 前面--逼裁判先写分析再下结论。这是 G-Eval 的核心思想:先推理再打分,比直接甩个数字一致性高得多。
G-Eval 的进阶做法 :原论文不是直接取裁判输出的数字,而是让模型输出 1--5 的评分 token,取每个候选 token 的对数概率(logprob)加权求和 :
score = Σ(score_i × P(score_i))。这比硬取一个数字更平滑、更稳定,但实现要拿到模型 logprob,比较复杂。本文用"先 reason 后 score 的单步 JSON"做轻量近似,工程上够用。
调裁判 + 解析分数
rust
#[async_trait]
impl<M: BaseChatModel> Evaluator for LLMAsJudge<M> {
async fn eval(&self, input: &str, prediction: &str, reference: &str) -> Result<Score, EvalError> {
let (system, user) = self.build_prompt(input, prediction, reference);
let result = self.judge
.chat_with_system(system, vec![Message::human(user)])
.await
.map_err(|e| EvalError::PredictorError(e.to_string()))?;
let raw = result.content;
let value = parse_score(&raw, self.max_score)
.ok_or_else(|| EvalError::ParseError(format!("无法解析分数: {}", truncate(&raw, 200))))?;
Ok(Score::new(value).with_label("llm_judge"))
}
fn name(&self) -> &str { "llm_as_judge" }
}
分数解析:三级回退(工程重点)
你 prompt 里写了"只输出 JSON",但真实 LLM 经常前面带句"好的,我的评估如下:"再给 JSON,或干脆不按格式回。别信任 LLM 的输出格式,层层兜底:
rust
fn parse_score(raw: &str, max_score: u8) -> Option<f64> {
let max = max_score as f64;
let n = extract_json_score(raw) // 1. 先试 JSON {"score":8}
.or_else(|| find_number_after_keyword(raw, "score")) // 2. 再试 "score:8"
.or_else(|| find_number_after_keyword(raw, "分数")) // 或 "分数:8"
.or_else(|| first_number(raw))?; // 3. 最后兜底:第一个数字
Some((n / max).clamp(0.0, 1.0)) // 归一到 0~1,超了 clamp
}
- JSON 解析 :找
{到},serde_json解析取score字段(as_f64()同时认整数和浮点) - 关键词回退 :定位
score/分数,取其后第一个数字 - 纯数字兜底:扫描取第一段连续数字
三级都失败就报 ParseError。生产里你还可以加一层降级:裁判解析失败时回退到 EmbeddingSimilarity,别让整条评测挂掉。
五、成对比较与位置偏差缓解
单点打分有个问题:让裁判给一个回答打 7 分还是 8 分,它自己都不稳定。但如果你给它看两个回答问"哪个更好",它判得更准--这是 MT-Bench 论文的核心发现(成对比较一致率 90% > 单点 80%)。
竞技场(Chatbot Arena)就是成对比较:两个模型答同一题,裁判二选一。
成对比较的坑:位置偏差
裁判有位置偏差 :A、B 两个回答摆出来,它倾向于选排在前面的那个。直接问一次不可信。
缓解:交换顺序跑两次
把 A、B 顺序换一下再问一次。两次都选同一个,才算真赢;不一致,判平局。这一招直接把位置偏差的影响压下去:
rust
pub enum Verdict { AWins, BWins, Tie }
pub struct PairwiseJudge<M: BaseChatModel> {
judge: M,
rubric: String,
}
impl<M: BaseChatModel> PairwiseJudge<M> {
/// 交换 A/B 顺序跑两次,消除位置偏差。
async fn compare(&self, input: &str, a: &str, b: &str) -> Result<Verdict, EvalError> {
// 第一次:A 在前
let v1 = self.ask(input, a, b).await?;
// 第二次:交换,B 在前
let v2 = self.ask(input, b, a).await?;
Ok(match (v1, v2) {
(Verdict::AWins, Verdict::AWins) => Verdict::AWins, // 两次都选前者=>
(Verdict::BWins, Verdict::BWins) => Verdict::BWins, // 两次都选后者=> 但第二次后者是 A
_ => Verdict::Tie, // 不一致 => 位置偏差,判平局
})
// 注:第二次交换后,返回值要映射回原始 A/B,这里简化展示思路
}
async fn ask(&self, input: &str, first: &str, second: &str) -> Result<Verdict, EvalError> {
// 构造 prompt:输入 + first + second,让裁判选"第一个更好"/"第二个更好"/"平局"
// ... 调 self.judge.chat_with_system,解析返回
todo!()
}
}
(上面是设计示意,展示交换顺序消偏差的思路;ask 的 prompt 构造和返回解析跟 LLMAsJudge 类似。)
六、LLM 裁判的偏差全家桶
位置偏差只是其一。LLM 当裁判有一堆已知偏差,做评估系统必须知道:
| 偏差 | 表现 | 缓解 |
|---|---|---|
| 位置偏差 | 成对比较偏好靠前的 | 交换顺序跑两次,不一致判平局 |
| 冗长偏差(verbosity bias) | 偏好更长的回答,即使没信息 | rubric 里加"简洁性"维度;或对长度做归一 |
| 自我偏好(self-enhancement) | GPT 当裁判偏好 GPT 的输出 | 跨模型族当裁判(GPT 判 Claude,或集成多个裁判) |
| 权威偏差(authority bias) | 被输出里"自信的语气"带偏 | rubric 强调"只看内容,不看语气" |
| 打分不稳 | 同输入跑两次分数不同 | 温度设 0;self-consistency(多次采样取均值/众数) |
Self-consistency(自洽性采样)
针对打分不稳:同一输入让裁判跑 N 次(温度 > 0),取 N 个分数的均值或众数。代价是 N 倍调用,但分数稳定得多。关键场景值得。
多裁判集成
用一个裁判怕它有偏见?用多个不同模型族的裁判(GPT + Claude + GLM)各打一次,取均值或投票。能抵消单个模型的自我偏好。代价是 N 倍成本。
七、综合真实测试:四种评测器放一起比
把四种评测器放一起,跑三道"法国首都"题(题目"法国首都?",标准答案"巴黎"):
| 样例 | 模型预测 | ExactMatch | StringDistance | EmbeddingSim | LLMAsJudge |
|---|---|---|---|---|---|
| 正确 | 巴黎 | 1.0000 | 1.0000 | 1.0000 | 1.0000 |
| 错误 | 伦敦 | 0.0000 | 0.0000 | 0.5000 | 0.0000 |
| 改写 | 法国的首都巴黎,位于法国北部 | 0.0000 | 0.1250 | 0.6667 | 1.0000 |
真实数据。裁判模型用 GLM-5.2(阿里云),EmbeddingSim 用
LocalEmbeddings(词袋 hash)。17 项测试全过。
逐行看:
- 正确:四个全给 1.0,没分歧。
- 错误(伦敦 vs 巴黎):字面类给 0(对);EmbeddingSim 给 0.5(不是判错,是"无信息"--词袋里两词没共享词,余弦 0 归一到 0.5);LLM 裁判给 0(懂语义,明确判错)。
- 改写--分水岭 :ExactMatch 0.0(冤枉)、StringDistance 0.125(冤枉)、EmbeddingSim 0.667(勉强)、LLMAsJudge 1.0(读懂意思,满分)。
三个尺子全把"意思对但话多"判成错,只有裁判看懂了。这就是 LLM-as-a-Judge 的价值。第三节里四个场景(翻译/代码/RAG/摘要)已经分别展示了各评测器的典型局限,这里是一组干净的综合对比。
八、业界框架对比
这套东西不是只有我们在做。Python 生态已经有一堆成熟框架,设计思路值得借鉴:
| 框架 | 语言 | 特点 | 评估方式 |
|---|---|---|---|
| LangChain Evaluators | Python | LangChain 自带,CriteriaEvaluator、PairwiseStringEvaluator 等 |
LLM-as-Judge 为主,集成在链里 |
| Ragas | Python | RAG 专用,faithfulness(忠实度)/ answer_relevancy / context_precision | LLM-as-Judge,针对 RAG 痛点设计指标 |
| DeepEval | Python | 类 pytest 风格,assert 式断言,有指标打分 |
LLM-as-Judge + 规则,CI 友好 |
| Promptfoo | JS/Python | YAML 配置驱动,对比多个 prompt/model 的输出 | 支持多种评估,包括 LLM-as-Judge |
| OpenAI Evals | Python | OpenAI 官方,标准化评估任务 | 模板化,支持多种打分 |
| Inspect AI | Python | UK AISI 出品,偏安全/能力评估 | 主题化评估,评分器可组合 |
Ragas 的 faithfulness 指标值得单独说 :它不是简单打分,而是先把模型回答拆成原子陈述,再逐条判断每条陈述能不能从上下文/检索内容里推导出来,最后算"可推导的陈述占比"。这种把模糊的"好不好"拆成可验证的子问题的思路,比直接问裁判"这个回答忠不忠实"靠谱得多。
对比下来,Rust 生态在这块基本是空白--这也是本文实现的意义:把这套评估能力搬进 Rust 的 LangChain 实现。
九、rubric(评分标准)设计
LLM 裁判好不好用,一半取决于 rubric 写得好不好。差的 rubric("评估一下回答质量")让裁判瞎打;好的 rubric 具体、可操作、分维度。
差的 rubric:
评估回答的质量,打 0-10 分。
好的 rubric(分维度,每条可判定):
正确性(权重 50%):回答是否事实准确、是否与参考答案核心意思一致。
完整性(权重 30%):是否完整回答了输入的问题或指令,有无遗漏。
简洁性(权重 20%):有无冗余、跑题、无关内容。
判分锚点:
- 9-10:三个维度都好,可直接采用
- 6-8:基本正确但有瑕疵(啰嗦/小遗漏)
- 3-5:部分正确,有事实错误或严重跑题
- 0-2:完全错误或无关
几个原则:
- 分维度:正确性、完整性、安全性分开打,别混成一个分。最后可加权合成。
- 给锚点:每个分数段配个例子("什么样的回答给 9 分,什么给 5 分"),裁判才有参照。
- 明确否定:"不要因回答长度加分""不要被自信语气影响"。
- Likert vs 0-10:1-5 的 Likert 心理学上更稳(人区分 7 分和 8 分很难),但 0-10 粒度细。看场景。
十、裁判模型选择与对齐验证
选谁当裁判
MT-Bench 论文的核心结论:只有强模型能当好裁判。GPT-3.5 当裁判的一致率明显低于 GPT-4。弱模型自己都答不对,让它判别人就是瞎判。
实践上:
- 关键评估:用你能拿到的最强模型(GPT-4 / Claude Opus 级别)。
- 日常迭代:可以降一档省成本,但心里有数它会漏判一些。
- 跨模型族:要防自我偏好,换不同厂商的模型当裁判。
对齐验证:裁判可不可信?
新接一个裁判模型,先验证它跟人类判断的一致率,别盲目信任:
- 抽 100--300 条样例,人工标注一遍(最好多人标,算标注者间一致率,确认标准本身清晰)。
- 让裁判跑同批,算一致率 (agreement rate)或 Cohen's Kappa(扣掉随机一致的部分)。
- 一致率 > 0.8 算可用;< 0.7 要么换模型,要么改 rubric。
这一步很多人跳过,直接信裁判--然后被偏差坑了都不知道。