评测与可观测:如何证明它变好了
这一篇决定你是「在优化」还是「在瞎改」。
一、为什么 AI 应用必须自建评测
| 传统软件 | AI 应用 |
|---|---|
| 输入确定 → 输出确定 | 同输入可能不同输出 |
| 单元测试断言相等 | 无法断言字符串相等 |
| 覆盖率可度量 | 「语义正确」难以度量 |
| 回归 = 跑测试 | 回归 = 跑评测集 + 人工抽检 |
没有评测集的 AI 项目,本质上是在靠感觉开车。
二、评测金字塔
| 层 | 内容 | 成本 | 频率 |
|---|---|---|---|
| L1 单点断言 | 格式合法、必含字段、禁止词 | 极低 | 每次提交 |
| L2 离线集 | 100–500 条带期望的用例 | 中 | 每次改动 |
| L3 人工评审 | 抽样盲评打分 | 高 | 每次发版 |
| L4 在线指标 | 采纳率、任务成功率、留存 | 低(需埋点) | 持续 |
L1 + L2 是底线,缺了就不用谈优化。
三、建一个真正能用的评测集
四类样本(缺一不可)
| 类型 | 占比 | 作用 |
|---|---|---|
| 常规 | 50% | 反映主流程 |
| 边界 | 20% | 空输入、超长、多语言、歧义 |
| 对抗 | 15% | 注入、诱导编造、越权、有害请求 |
| 回归 | 15% | 历史上出过错的 case,防退化 |
每条用例的结构
json
{
"id": "refund-003",
"type": "常规",
"input": "我上周买的鞋子想退,还能退吗?",
"context_docs": ["policy/refund.md"],
"expected": {
"must_contain": ["7 天", "未穿着"],
"must_not_contain": ["30 天", "随时"],
"format": "text",
"must_cite": true
},
"notes": "考察退货时效,文档里有明确条款"
}关键:期望必须可机器判定。 用「必须包含 / 必须不包含 / 结构断言 / 引用检查」,而不是「回答要好」。
自动评分
python
def check_case(output: str, exp: dict) -> dict:
o = output or ""
r = {"pass": True, "reasons": []}
for kw in exp.get("must_contain", []):
if kw not in o:
r["pass"] = False; r["reasons"].append(f"缺少必备信息: {kw}")
for kw in exp.get("must_not_contain", []):
if kw in o:
r["pass"] = False; r["reasons"].append(f"出现错误信息: {kw}")
if exp.get("must_cite") and not re.search(r"\[\d+\]", o):
r["pass"] = False; r["reasons"].append("缺少引用标注")
return r
def run_suite(suite, fn):
stats = {"total": len(suite), "pass": 0, "fails": []}
for c in suite:
out = fn(c["input"])
res = check_case(out, c["expected"])
if res["pass"]:
stats["pass"] += 1
else:
stats["fails"].append({"id": c["id"], "out": out, **res})
stats["rate"] = stats["pass"] / stats["total"]
print(f"通过率 {stats['pass']}/{stats['total']} = {stats['rate']:.1%}")
return stats四、LLM-as-Judge 的正确用法
用于「语义质量」这类无法用关键词断言的维度。
python
JUDGE = """你是严格评审。评分 1-5:5=完全正确且完整,3=部分正确,1=错误或编造。
【问题】{q}
【参考资料】{ref}
【回答】{a}
只输出 JSON:{{"score": 0, "hallucination": true/false, "reason": "20字以内"}}"""校准流程(不可跳过)
| 步骤 | 做法 |
|---|---|
| 1 | 抽 50 条人工打分(两名标注者) |
| 2 | 算人工间一致性(Cohen's kappa,>0.6 才可用) |
| 3 | 用 judge 打同一批 |
| 4 | 算 judge 与人工的一致性;不一致就改 rubric 重来 |
| 5 | 之后每次改 judge 都要重新校准 |
judge 的四个偏差
| 偏差 | 表现 | 缓解 |
|---|---|---|
| 长度偏好 | 长答案分高 | 明确「简洁不扣分」,或按字数归一 |
| 位置偏好 | A/B 对比时偏爱第一个 | 交换顺序各评一次 |
| 自我偏好 | 偏爱同族模型的输出 | 用第三方模型做 judge |
| 宽松倾向 | 分数普遍偏高 | 给锚点示例(1分/3分/5分各一例) |
五、可观测:每次请求都要能回放
必须记录的结构化 trace
json
{
"trace_id": "req_7f3a",
"ts": "2026-11-06T10:20:31Z",
"user_id": "u_123",
"session_id": "s_9",
"steps": [
{"type": "rewrite", "in": "那能退吗", "out": "上周购买的鞋子能否退货", "ms": 180},
{"type": "retrieve", "query": "鞋子 退货 时效", "top_k": 8,
"hits": [{"doc": "policy/refund.md#3", "score": 0.82}], "ms": 45},
{"type": "rerank", "kept": 4, "ms": 120},
{"type": "llm", "model": "qwen2.5-7b", "prompt_tokens": 1520,
"out_tokens": 186, "ms": 2100, "cost": 0.0021}
],
"output": "可以退。签收 7 天内且未穿着…[1]",
"feedback": {"thumbs": "up"}
}| 字段 | 为什么必须有 |
|---|---|
| 检索命中 + 分数 | 判断「答错」是检索问题还是生成问题 |
| token 与成本 | 成本归因、异常检测 |
| 每步耗时 | 性能瓶颈定位 |
| feedback | 把线上投诉变成评测用例 |
三个最小必需的监控面板
| 面板 | 指标 |
|---|---|
| 质量 | 通过率、judge 均分、负反馈率、拒答率 |
| 性能 | P50/P95 延迟、TTFT、超时率 |
| 成本 | 每千次成本、日成本、token 分布 |
告警阈值:负反馈率 > 5%、P95 延迟 > 8s、日成本 > 预算 120%。
六、线上 → 线下的闭环
线上负反馈 / 差评 → 人工确认 → 加入回归评测集 → 下次改动必须跑过这是 AI 产品持续变好的唯一可靠机制。没有这条链路,模型永远在原地打转。
python
def promote_to_regression(trace, correction):
"""把线上失败样本固化为评测用例。"""
return {
"id": f"reg-{trace['trace_id']}",
"type": "回归",
"input": trace["steps"][0].get("in") or trace["input"],
"expected": {"must_contain": correction["must_contain"],
"must_not_contain": correction.get("must_not_contain", [])},
"notes": f"来源线上 {trace['ts']},原输出问题:{correction['why']}",
}七、发版流程(AI 版)
| 阶段 | 检查 |
|---|---|
| 改动前 | 现有评测集通过率基线已记录 |
| 改一处 | 只改一个变量 |
| 本地 | L1 + L2 全跑,通过率不低于基线 |
| 灰度 | 5%–10% 流量,对比在线指标 |
| 观察 | 负反馈率、延迟、成本、新出现的失败类型 |
| 全量 / 回滚 | 超阈值立即回滚(Prompt 与模型都要版本化) |
Prompt 也要版本管理
把 Prompt 存成文件、进 git、每次变更记 CHANGELOG + 评测结果。 靠界面上直接改 Prompt 且无记录的团队,一定会在某天无法解释「为什么昨天还好好的」。