Chapter 02

评估指标设计:准确率之外的世界

生成式任务没有"标准答案",但这不意味着无法度量。本章讲清楚不同任务该用什么指标、每种指标的盲区,以及为什么好的评估永远是"软指标 + 硬约束"的组合。

任务类型决定指标选择

第一原则:先分清楚任务类型,再选指标。同一个模型在不同任务上要用不同的尺子。

任务类型典型例子推荐主指标辅助指标
分类 / 抽取情感分类、意图识别、NERAccuracy / F1混淆矩阵、每类 Recall
结构化输出JSON 生成、函数调用Schema 合法率字段准确率、类型正确率
翻译 / 改写英译中、文风改写BLEU / chrF / COMET长度比、人工抽检
摘要新闻摘要、会议纪要ROUGE-L / BERTScoreFaithfulness(LLM Judge)
开放问答知识问答、闲聊LLM-as-Judge 打分语义相似度、毒性检测
代码生成函数实现、SQL 生成pass@k(执行测试)编译通过率、CodeBLEU
RAG带引用的问答Faithfulness + Answer RelevancyContext Recall/Precision
Agent多步工具调用任务完成率Trajectory match、步数
关键观察 只有"分类"和"代码"能用单一数字判定对错。其他所有任务都需要多指标组合——任何声称"一个指标搞定一切"的方案都要警惕。

指标家族一:精确匹配类

Exact Match(EM)

最朴素,最硬。预测字符串与参考完全一致才算对。

def exact_match(pred, ref):
    return pred.strip().lower() == ref.strip().lower()

适用:短答案抽取(who/when/where)、分类 label、选项题。盲区:空格、大小写、标点都会让它判 0,所以常要配合规范化

规范化 EM

import re, string

def normalize(s):
    s = s.lower()
    s = re.sub(r"[%s]" % re.escape(string.punctuation), "", s)
    s = re.sub(r"\b(a|an|the)\b", " ", s)
    s = " ".join(s.split())
    return s

def em_normalized(pred, ref):
    return normalize(pred) == normalize(ref)

Substring Match / Contains

放宽版:预测包含参考即可。常用于"答案混在解释里"的场景。

def contains(pred, ref):
    return normalize(ref) in normalize(pred)
Contains 会把 "不是北京" 判为正确答案 "北京"。对否定/反义敏感的任务要慎用。

指标家族二:基于 n-gram 的相似度

BLEU — 机器翻译的老祖宗

BLEU(Bilingual Evaluation Understudy)计算预测中有多少 n-gram 出现在参考里,并做长度惩罚。

计算直觉
1-gram precision + 2-gram precision + 3-gram + 4-gram 的几何平均,再乘以长度惩罚因子(预测太短会被惩罚)。
典型值
翻译任务 30-40 算不错,60+ 很好,90+ 可疑(可能背答案)。
from sacrebleu import corpus_bleu

refs = [["the cat is on the mat", "a cat sits on a mat"]]
hyps = ["the cat sits on the mat"]
bleu = corpus_bleu(hyps, refs)
print(bleu.score)  # 例如 67.4

ROUGE — 摘要任务的主力

ROUGE(Recall-Oriented Understudy for Gisting Evaluation)和 BLEU 互补——BLEU 看 precision,ROUGE 看 recall:"参考里的 n-gram 有多少被预测命中"。

变体计算适用
ROUGE-11-gram overlap粗略词覆盖
ROUGE-22-gram overlap短语连贯性
ROUGE-L最长公共子序列句子结构 — 摘要最常用
ROUGE-Lsum段落级 LCS长文摘要
from rouge_score import rouge_scorer

scorer = rouge_scorer.RougeScorer(["rouge1", "rouge2", "rougeL"], use_stemmer=True)
scores = scorer.score(
    target="小明今天去了公园看展览",
    prediction="小明去公园看了展览",
)
print(scores["rougeL"].fmeasure)  # 约 0.73

BLEU/ROUGE 的致命盲区

❌ 它们测不出的

  • 同义词:"车" vs "汽车" 算不同
  • 语序等价:主动/被动句式变换得分下降
  • 事实错误:可以通顺胡说而得高分
  • 省略但不影响意思的精简

✅ 它们擅长的

  • 字面覆盖度(术语、命名实体)
  • 快速、便宜、无需 GPU
  • 可重复、跨团队可比
  • 作为"快筛"指标排除明显差案例

指标家族三:语义相似度

BERTScore

用预训练模型(BERT/RoBERTa)做 token 嵌入,然后对齐预测与参考的 token,计算余弦相似度。解决了 BLEU/ROUGE "字面匹配" 的局限。

from bert_score import score

cands = ["猫坐在垫子上"]
refs  = ["猫躺在毯子上"]
P, R, F = score(cands, refs, lang="zh", verbose=False)
print(F.item())  # F1 约 0.93 — 语义近即使字面不同

嵌入余弦相似度

更轻量:直接用 embedding 模型算句向量余弦距离。

from openai import OpenAI
import numpy as np

client = OpenAI()

def embed(text):
    r = client.embeddings.create(model="text-embedding-3-small", input=text)
    return np.array(r.data[0].embedding)

def cosine(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

sim = cosine(embed("发票开具流程"), embed("如何申请发票"))
print(sim)  # 约 0.87
语义相似度的陷阱 "北京是中国首都" 和 "北京不是中国首都" 的余弦相似度高达 0.95。嵌入相似度对否定、数字、实体替换极不敏感。只能当粗筛,不能当主指标。

指标家族四:业务约束

这是常被低估的一类,但往往是生产环境最重要的。它不是"评判好坏",而是"确保没有离谱错误"。

Schema 合法率
JSON 是否能被 json.loads 解析;字段是否齐全;类型是否匹配 pydantic model。生产 API 对接这一项失败等于 500。
格式规则
是否使用了指定的 XML 标签;是否只输出一个单词;是否包含所需的段落结构。
禁止项检查
是否泄露了 system prompt;是否包含 PII;是否提到竞争对手;是否给出了违禁建议。
长度 / 成本
输出是否超过 token 上限;是否异常短(可能是 refusal)。
引用校验(RAG)
引用的 source_id 是否真的在提供的上下文里;每个断言是否都有引用。
# 硬约束的组合示例
import json
from pydantic import BaseModel, ValidationError

class TicketReply(BaseModel):
    category: str
    urgency: int  # 1-5
    reply: str

def validate_reply(raw: str) -> dict:
    results = {}
    # 1. JSON 合法
    try:
        obj = json.loads(raw)
        results["json_valid"] = True
    except json.JSONDecodeError:
        results["json_valid"] = False
        return results
    # 2. Schema 合法
    try:
        t = TicketReply.model_validate(obj)
        results["schema_valid"] = True
    except ValidationError:
        results["schema_valid"] = False
        return results
    # 3. 业务规则
    results["urgency_in_range"] = 1 <= t.urgency <= 5
    results["reply_length_ok"] = 20 <= len(t.reply) <= 500
    results["no_competitor"] = "竞品名" not in t.reply
    return results
生产经验 80% 的"奇怪 bug"都是硬约束失败。先把硬约束这张网织密,再去调生成质量。硬约束失败应该让整个 eval 分为 0,而不是被其他指标平均稀释掉。

指标家族五:LLM-as-Judge

用另一个更强的模型给输出打分。第 4 章会单独深讲,这里只给全貌:

组合策略:软指标 + 硬约束

一个成熟的 eval 系统通常长这样:

┌───────────────┐ 输出 ──▶ │ 硬约束(门禁) │ ─ 任何一条失败 → 总分 0 └───────┬───────┘ │ 全通过 ▼ ┌───────────────┐ │ 快速软指标 │ BLEU/ROUGE/BERTScore/Embed │ (全量跑) │ 用于对比迭代、趋势监控 └───────┬───────┘ │ ▼ ┌───────────────┐ │ LLM-as-Judge │ 贵,采样跑(如 20%) │ (采样跑) │ rubric 多维度 └───────┬───────┘ │ ▼ ┌───────────────┐ │ 人工抽检 │ 最贵,3-5% 流量 │ (月度) │ 校准 Judge 是否跑偏 └───────────────┘

一个完整的指标卡例子

客服回复任务的指标卡(Scorecard):

维度指标类型阈值
格式JSON 合法率硬约束≥ 99.5%
格式字段完整率硬约束= 100%
安全未泄露系统 prompt硬约束= 100%
相关性Answer Relevancy(Judge)软指标≥ 4.2 / 5
准确性Factual Accuracy(Judge)软指标≥ 4.5 / 5
语气Tone match(Judge)软指标≥ 4.0 / 5
简洁长度 50-300 字符硬约束≥ 95%
成本p95 token监控< 800
指标卡 vs 单一分数 新手喜欢"总分 87 分"式的单一指标,但它隐藏了失败模式。成熟团队一定是多维度独立跟踪——某一维异常下跌会被立刻发现,而不是被平均值掩盖。

本章小结

下一章:Golden Dataset 方法论——再好的指标打在错的数据上也是白打。