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_livetemperature=0.0、剥围栏真调模型
mock_judge固定返回验证控制流
quality_threshold0.85达标线
RULE_PASS_SAMPLE_RATE0.05满分样本抽检比例

十三、一句话总结

规则满分是最危险的信号——它意味着没人会去看它。

LLM Judge 的价值不在"比规则更聪明",在"能覆盖规则看不见的地方"。

而这三个偏心提醒我们:裁判的分数只能做相对比较,不能当绝对标准。0.86 不代表"86 分好",只代表"这批里比 0.72 的那条好"。

下一篇讲回流:把评测发现的失败样本,变成提示词里的具体规则。难点不在技术,在"这条失败该改哪一条"这个判断。


代码已通过 py_compile 语法校验。运行环境:Python 3.11+ / agentscope 2.x。mock 模式零依赖离线运行。

Logo

更多推荐