LLM Judge:用大模型做语义评测的实现
AgentScope 2.0 LLM Judge:用大模型做语义评测的实现
上一篇的规则评测能查"价格对不对"“工具调没调”,查不了"答得切不切题"。
这篇补上语义评测:规则先行、LLM 兜底。
代码有 mock 和 live 双模式:mock 完全离线,验证控制流;live 真调模型,验证语义判断力。先跑 mock,一分钱不花。
一个前提说明:本文引用的 5 条输出是 mock 模式的结果,分数是固定的演示值,不是真实裁判打分。 mock 验证的是"哪条样本触发了 LLM、哪条被规则放行",不是语义判断力。真实打分要跑 live。这一点会被反复强调,因为它决定了你能从这份输出里读出什么。
一、环境
# mock 模式:纯标准库,离线,无需任何 Key
python 011_llm_judge.py
# live 模式:真调 DeepSeek 当裁判
export DEEPSEEK_API_KEY="sk-xxx"
python 011_llm_judge.py --live
live 模式用 urllib 直接调 OpenAI 兼容接口,不需要装 openai 包。这样脚本在任何 Python 3.11+ 环境都能跑。
二、规则打分器:四项布尔判断
def rule_score(case, actual):
"""规则打分:四项确定性判断,跑一万遍结果一样,不花钱。"""
return {
"intent_ok": actual.get("intent") == case.get("expected_intent"),
"tool_ok": set(case.get("expected_tools", [])) <= set(actual.get("tools", [])),
"ref_ok": actual.get("cited_ok", False),
"keyword_ok": all(k in actual.get("answer", "") for k in case.get("must_keywords", [])),
}
四个判断都是纯代码,特点是可复现——同一份输入跑一万遍,结果完全一样。
tool_ok 用了子集判断 <=,意思是"预期工具都被调了",多调不扣分。这和上一篇的逻辑一致:多调有正当理由,漏调一定是错。
三、LLM Judge:让它输出 JSON
JUDGE_PROMPT = (
"你是评测裁判。根据用户问题与客服回答,评估回答质量。\n"
"只输出一个 JSON,不要任何解释,格式:\n"
'{"relevance":0到1, "hallucination":0到1, "completeness":0到1, "score":0到1}\n'
"用户问题:{query}\n"
"客服回答:{answer}\n"
"说明:relevance=是否切题;hallucination=是否胡编(越高越差);"
"completeness=是否解决用户问题;score=综合质量分。"
)
三个语义分,各自管一件事:
| 字段 | 含义 | 方向 |
|---|---|---|
| relevance | 是否切题 | 越高越好 |
| hallucination | 是否胡编 | 越高越差 |
| completeness | 是否解决用户问题 | 越高越好 |
hallucination 是反向的,这一点极其容易搞错。
如果汇总的时候写成 (relevance + hallucination + completeness) / 3,那一个"编得越厉害"的回答反而分数越高。这个 bug 很隐蔽,因为代码能跑、分数也像一个分数。
我的建议是给这个字段改个名,直接从源头避免误用:
# 别用 hallucination,改用 hallucination_free
"hallucination_free": 0到1,
语义反转在命名上,比在注释里提醒可靠得多。
四、调用与解析
def llm_judge_live(case, actual, api_key,
base_url="https://api.deepseek.com/chat/completions",
model="deepseek-chat"):
prompt = JUDGE_PROMPT.format(query=case.get("query", ""),
answer=actual.get("answer", ""))
body = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.0,
"max_tokens": 200,
}
req = urllib.request.Request(
base_url,
data=json.dumps(body).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"]
# 模型偶尔会给 JSON 套 ```json 围栏,剥掉
content = content.strip().strip("`")
if content.lower().startswith("json"):
content = content[4:].strip()
parsed = json.loads(content)
parsed.setdefault("score", 0.0)
return parsed
except Exception as e:
return {"relevance": 0.0, "hallucination": 1.0, "completeness": 0.0,
"score": 0.0, "parse_error": str(e)}
四个值得说的点。
temperature=0.0 是让评测有意义的前提。裁判每次打分不一样的话,分数就没有可比性——你改完代码跑出 0.86,再跑一次 0.72,分不清是代码变了还是裁判今天心情不好。
max_tokens=200 够用就好。裁判只需要吐一段 JSON,给多了反而可能让它开始解释。
剥围栏的代码要留着。 即使提示词里写了"只输出 JSON",模型仍有一定概率套 ```````json ````。这是实际会发生的事,不是防御性编程的洁癖。
解析失败记 0 分,不是跳过。 这条是有意的:网络失败或 JSON 坏了,说明这条样本没被审过。如果跳过,它就会以"规则满分"的身份混进达标名单——而这正是本文要讲的那个坑。
宁可错杀,不能让未知样本溜过去。
五、5 条样例的真实输出(mock)
[1] 这款面霜多少钱
规则打分:{intent_ok: True, tool_ok: True, ref_ok: True, keyword_ok: True}
→ ✅ 规则满分,跳过 LLM(省 1 次调用)
[2] 我的订单 AS-001 到哪了
规则打分:{intent_ok: True, tool_ok: False, ref_ok: True, keyword_ok: True}
→ ⚠️ 规则未满分,触发 LLM Judge 二审
→ 裁判(mock)打语义分:relevance=0.88 hallucination=0.1 completeness=0.85 score=0.86
→ ✅ 综合分 0.86 ≥ 0.85,达标
[3] 你们支持哪些支付方式
规则打分:全 True → ✅ 规则满分,跳过 LLM
[4] 积雪草精华适合什么肤质
规则打分:全 True
⚠️ 陷阱:规则满分,但实际「答非所问」——只绕弯说美白淡斑,没正面回答适合什么肤质
→ ✅ 规则满分,跳过 LLM
[5] RAG 怎么切分文本
规则打分:{intent_ok: True, tool_ok: True, ref_ok: False, keyword_ok: False}
→ ⚠️ 触发 LLM 二审
→ ✅ 综合分 0.86,达标
汇总:
样本总数:5
规则直接放行:3(省 3 次 LLM 调用)
LLM 二审:2 次(不达标 0 条)
成本节省:60%
这份输出要读的是第 4 条,不是汇总。
六、第 4 条:四项全对,但它答错了
第 4 条问"积雪草精华适合什么肤质",回答绕了一圈讲美白淡斑,没正面回答。
四项规则全过:
- 意图路由对:确实是产品咨询
- 工具调了:该查的查了
- 引用合法:编号在范围内
- 关键词命中:提到了产品名和成分
每一条都对,但它没回答问题。
这就是规则评测的天花板:它能验证"该做的动作做了没有",验证不了"做了之后有没有解决问题"。
而这类错比编造价格更麻烦——编造价格有明确对照(268 vs 9),一抓一个准;答非所问没有对照,只能靠读一遍才能发现。
七、盲区的成因:模式名里的隐含假设
问题出在这个模式名上:only_on_rule_fail——只有规则没通过才请 LLM。
逻辑很自然:规则能判的不花钱,判不了的才花钱。
但它隐含了一个假设:规则满分的样本没问题。
第 4 条证明这个假设不成立,而且它失效的方式很阴险:
| 样本类型 | 会被谁看 | 错误存活概率 |
|---|---|---|
| 规则不满分 | 被标记 → 人工或 LLM 看 | 低 |
| 规则满分 | 没人看 | 高 |
规则满分恰恰是错误最隐蔽的地方。一个编造价格的回答会被 faithfulness 抓;一个答非所问的回答,四项全绿,安安静静过了。
八、改法:抽检优先抽满分样本
想通这一点,我把抽检配比调成了和直觉相反的样子:
RULE_FAIL_SAMPLE_RATE = 1.00 # 规则不满分:100% 进二审
RULE_PASS_SAMPLE_RATE = 0.05 # 规则满分:抽 5%
为什么反直觉?因为随机抽 5% 的话,两类样本被抽到的概率一样。但满分样本里的错误存活率高得多,所以它的期望收益更高。
算一下成本增量。以本例 5 条样本、3 条满分为例,抽 5% 的期望值是 0.15 条,大约是每 7 批多调一次 LLM。
花 3% 的钱,覆盖一个 100% 的无人区。
这个 3% 是这么来的:原本只审 2 条(40%),加了抽检后平均每批多 0.15 条,调用量从 2.0 涨到 2.15,增幅 7.5%。
九、裁判自己的三个偏心
请了 LLM 当裁判,还有第二层问题:裁判本身不可靠。
业界的公开研究记录过 LLM-as-Judge 的三个系统性偏差。这里只讲工程上的应对,不谈测量方法。
位置偏见
把 A、B 两个答案并排给裁判,问哪个更好。裁判倾向于固定选前面那个或后面那个。
应对:交换顺序跑两次,结果一致才算数。
def judge_pairwise_robust(judge, q, a, b):
"""双序打分:两次一致才采信。"""
first = judge(q, a, b) # a 在前
second = judge(q, b, a) # b 在前
if first == second:
return first
return "tie" # 顺序一换结论就变,判无法区分
成本翻倍。所以只对"分数卡在阈值附近"的样本做,远离阈值的不用。
长度偏见
长回答更容易得高分——信息多、看起来更努力,裁判容易被"内容丰富"迷惑。
应对一:提示词里明确"长度不计分,冗长不加分,简洁不扣分"。
应对二:评测集里故意放几条"短但正确"和"长但啰嗦"的对照样本。如果裁判在这两组上有系统性差异,说明偏见没压住。
第二条比第一条可靠。提示词是"请求",对照样本是"测量"。
自我偏好
用自己的模型当裁判,给它自己生成的答案打分,分数偏高。
应对:裁判和被测用不同家族。 被测是 DeepSeek,裁判就别用 DeepSeek。
这条最容易被忽略,因为"用同一个模型"最省事——一把 Key 搞定两件事。但它恰恰是最不可靠的配置。
十、稳定性怎么验证
temperature=0 能把随机性压到很低,但不是绝对的零。同一输入跑两次,绝大多数一致,偶尔有细微差别(浮点、batching)。
所以我的做法是:
def check_stability(judge, cases, runs=2):
"""同一批样本跑两次,看分数差异。"""
a = [judge(c)["score"] for c in cases]
b = [judge(c)["score"] for c in cases]
diffs = [abs(x - y) for x, y in zip(a, b)]
return max(diffs), sum(diffs) / len(diffs)
判断标准:最大差异超过 0.05,这个裁判就不能用于精细对比。
只能做粗粒度判断(“这批明显比上批差”),不能做"A 版本比 B 版本高 2 分"这种结论。
第一次切 live 要先验什么
从 mock 切到 live,最该验的不是分数好不好看,是裁判的判断和你的一致率。
步骤是这样:
第一步,人工标 20 条。 从真实样本里挑 20 条,你自己逐条判"达标/不达标"。不写理由,只给结论,一条控制在 30 秒内。
第二步,让 live 裁判判同一批。 用同一个阈值。
第三步,算一致率。
agree = sum(1 for h, j in zip(human_labels, judge_labels) if h == j)
print(f"一致率:{agree / len(human_labels):.0%}")
判断标准:
| 一致率 | 含义 |
|---|---|
| > 90% | 裁判可用,直接上线 |
| 80% ~ 90% | 可用,但要知道它偏在哪一侧 |
| 70% ~ 80% | 提示词还得改 |
| < 70% | 这个裁判的判断和人类不是一回事,别用 |
第四步,看不一致的那些。 它们比一致的那些有信息量。
如果偏的方向是"裁判比人宽松"(人判不达标、裁判判达标),说明它会放过问题,阈值要收紧。
如果偏"比人严格",说明误杀多,阈值要放松或者补提示词说明。
这个校准步骤很多人跳过,直接拿裁判分数当结论。结果是——你花了一天优化一个和人类判断不太一致的裁判,然后用它的分数去指导改代码。
校准的核心判断:裁判和人的一致率,必须高于你要验证的改进幅度。
如果你要验证 5 个点的提升,而裁判和人的一致率只有 75%,那这个裁判分辨不出那 5 个点。
十一、常见报错
| 现象 | 原因 | 处理 |
|---|---|---|
| 分数越高,回答越糟 | hallucination 当成正向指标加了 | 改名 hallucination_free |
json.JSONDecodeError | 模型套了 ```json 围栏 | 剥围栏,或重试一次 |
| 两次跑分数差异大 | temperature 没设 0 | 显式传 temperature=0.0 |
| 规则满分的坏样本一直漏 | 只审规则失败的 | 加满分样本抽检 |
| 裁判给自己打分偏高 | 裁判和被测同家族 | 换一个模型家族 |
| 长时间回答总是高分 | 长度偏见 | 提示词压制 + 对照样本测量 |
| 所有分数都在 0.85 附近 | 裁判趋于中庸 | 用相对比较,别用绝对阈值 |
最后一行值得展开:LLM Judge 的分数分布往往很窄,都堆在 0.8 到 0.9 之间。这时候设绝对阈值(“0.85 算达标”)意义不大,因为稍微一动就跨过去了。
能用的口径是:同一个裁判、同一批样本、两个版本之间的相对变化。
十二、接口速查
| 函数 / 参数 | 关键点 | 作用 |
|---|---|---|
rule_score | 四项布尔 | 零成本规则打分 |
JUDGE_PROMPT | 要求 JSON | 语义三评分 |
llm_judge_live | temperature=0.0、剥围栏 | 真调模型 |
mock_judge | 固定返回 | 验证控制流 |
quality_threshold | 0.85 | 达标线 |
RULE_PASS_SAMPLE_RATE | 0.05 | 满分样本抽检比例 |
十三、一句话总结
规则满分是最危险的信号——它意味着没人会去看它。
LLM Judge 的价值不在"比规则更聪明",在"能覆盖规则看不见的地方"。
而这三个偏心提醒我们:裁判的分数只能做相对比较,不能当绝对标准。0.86 不代表"86 分好",只代表"这批里比 0.72 的那条好"。
下一篇讲回流:把评测发现的失败样本,变成提示词里的具体规则。难点不在技术,在"这条失败该改哪一条"这个判断。
代码已通过 py_compile 语法校验。运行环境:Python 3.11+ / agentscope 2.x。mock 模式零依赖离线运行。
更多推荐
所有评论(0)