第 20 章 质量保障:Harness 与评测体系
本章要解决的问题
Agent 改一版提示词,线上效果是变好还是变差?没有评测体系就只能靠感觉------评测体系怎么搭?
章节大纲
- 20.1 Agent 评测指标体系(任务成功率/工具正确率/成本/延迟)
- 20.2 评测集构建与自动化回归
- 20.3 防退化机制:基线对比与灰度发布
- 🛠 解决方案:评测集从 0 到 100 条的构建路径 + 回归误报排查
20.1 Agent 评测指标体系
20.1.1 为什么 Agent 必须有评测体系
传统软件的测试是确定性 的(输入 → 期望输出),而 Agent 是概率性 的(同样的输入,输出可能不同、质量波动)。没有评测体系,任何改动都是"靠感觉"------而感觉在概率系统里完全不靠谱。
第 1 章 6 概念里说过:Harness(保障)是 Agent 的"质量框架"------评测体系就是 Harness 的核心。
20.1.2 四个核心指标维度
Agent 质量不能只看"答对没有",四个维度缺一不可:

图 1:评测四维指标
| 维度 | 指标 | 衡量什么 | 获取方式 |
|---|---|---|---|
| 质量 | 任务成功率 / 正确率 | 任务完成得好不好 | 人工标注 / LLM-as-Judge |
| 工具 | 工具正确率 / 调用有效率 | 工具调对没有、有没有白调 | 工具调用日志 |
| 成本 | 单任务 token / 费用 | 贵不贵 | 用量统计(第 23/24 章) |
| 性能 | P50/P95 延迟 | 快不快 | 追踪系统(第 21 章) |
20.1.3 质量指标的两个评分方法
python
# 方法一:规则评分(可判定场景,零成本)
def rule_score(output, expected):
return 1.0 if output["status"] == expected["status"] else 0.0
# 方法二:LLM-as-Judge(开放场景)
def judge_score(answer, criteria):
resp = llm_judge(f"""按 1-5 分评价回答质量,判断标准:{criteria}
回答:{answer}
只输出分数。""")
return int(resp.strip())
两种方法结合:能规则判定的用规则(精确、免费),开放性的用 LLM-as-Judge(灵活、有成本)------这是"规则优先、模型兜底"心法在评测中的应用(呼应第 4、13、17 章)。
20.2 评测集构建与自动化回归
20.2.1 评测集的三要素
一个好的评测集 = 问题 + 期望 + 判定方式:
python
EVAL_SET = [
{
"input": "查一下 A123 的物流",
"expected": {"status": "shipped"},
"judge": "rule", # 规则判定
},
{
"input": "帮我写个退货说明",
"expected_criteria": "语气得体、包含退货流程、无虚假承诺",
"judge": "llm", # 模型判定
},
...
]
20.2.2 评测集构建路径:从 0 到 100 条
不要幻想一步到位,按阶段迭代:

图 2:评测集构建路径
| 阶段 | 条数 | 来源 | 目的 |
|---|---|---|---|
| 起步 | 20~30 条 | 真实高频问题 + 历史故障案例 | 先跑通流程 |
| 扩充 | 50~80 条 | 加边界/异常/多轮场景 | 覆盖长尾 |
| 完善 | 100+ 条 | 线上漏检案例回填 + 分场景分层 | 建立基线 |
关键原则:评测集来自真实数据(线上日志、用户反馈、故障案例),不是拍脑袋编的(呼应第 18 章失败样本库)。
全书评测集数量约定:起步 20~30 条(快速验证),基线 ≥50 条(上线门槛),完整回归 100+ 条(版本发布)。各章提到的"20 条""50 条""100 条"分别对应这三个阶段,按团队成熟度和场景复杂度逐步扩充。
20.2.3 自动化回归:改动的"安全网"
每次改动(提示词/参数/模型/Skill)都要跑全量评测集:
python
def run_regression(eval_set, agent_version):
results = []
for case in eval_set:
output = run_agent(case["input"], agent_version)
score = evaluate(output, case) # 按 judge 方式打分
results.append(score)
return {
"avg_score": sum(results)/len(results),
"pass_rate": sum(1 for s in results if s >= 0.8)/len(results),
"per_case": results, # 逐条留档,便于排查
}
回归结果要逐条留档------整体分数下降时,能看出是哪些 case 退化了(呼应第 21 章排错)。
20.3 防退化机制:基线对比与灰度发布
20.3.1 基线(Baseline):没有基线就没有"变好变差"
上线前先跑一次评测,得到基线分数。之后每次改动对比基线:

图 3:基线对比流程
erlang
基线(v1.0) 任务成功率 82%
↓ 改提示词
v1.1 评测 任务成功率 88% → 提升 6pp ✅ 可上线
↓ 改模型
v2.0 评测 任务成功率 79% → 下降 3pp ❌ 回滚
对比基线是评测的"坐标系"------没有基线的分数没有意义。
20.3.2 防退化:评测门禁(CI 化)
把评测接入开发流程,像代码测试一样做门禁:

图 4:评测门禁 + 灰度
text
改动提交 → 自动跑评测集 → 对比基线
├─ 分数 ≥ 基线 → 通过,可发布
└─ 分数 < 基线 → 拦截,人工排查
评测门禁是第 18 章"学习闭环刹车片"的工程化------让"越改越烂"在发布前就被拦住。
20.3.3 灰度发布:线上验证的最后一道闸
评测集是"模拟考试",灰度是"小范围真考":
python
def canary_release(new_version, ratio=0.1):
"""10% 流量跑新版,90% 跑旧版,对比线上指标"""
# 线上指标:任务成功率(用户反馈)、成本、延迟
old_metrics = collect_metrics(old_version, window="24h")
new_metrics = collect_metrics(new_version, window="24h")
if better_or_equal(new_metrics, old_metrics):
return "全量发布"
return "回滚到旧版"
灰度节奏:10% → 50% → 100%,每步观察 24~48h(呼应第 24 章 G3 灰度参数)。
20.4 完整实战:搭建一套可落地的评测体系
把 20.1-20.3 串成一个可运行的最小评测体系,从零到有。
20.4.1 评测体系架构
javascript
┌──────────────────────────────────────────────┐
│ 评测集(EVAL_SET) │
│ 20 条起步 → 50 条 → 100 条(真实数据回填) │
├──────────────────────────────────────────────┤
│ 评测引擎(evaluate_all) │
│ 规则评分 → LLM-as-Judge → 汇总指标 │
├──────────────────────────────────────────────┤
│ 基线仓库(baseline.json) │
│ 记录每次版本的分数字段 │
├──────────────────────────────────────────────┤
│ 门禁(CI)→ 灰度(10%/50%/100%)→ 监控 │
└──────────────────────────────────────────────┘
20.4.2 评测引擎实现(可复用)
python
import json, os
class EvalEngine:
def __init__(self, eval_set_path, baseline_path):
self.cases = self._load(eval_set_path)
self.baseline = self._load(baseline_path, default={})
self.results = {}
def evaluate_all(self, agent_fn, version):
"""跑全量评测,返回四维指标"""
metrics = {"quality": [], "tool": [], "cost": [], "latency": []}
per_case = []
for case in self.cases:
r = agent_fn(case["input"]) # 运行 Agent
score = self._score(case, r) # 按 judge 方式打分
metrics["quality"].append(score["quality"])
metrics["tool"].append(score["tool_ok"])
metrics["cost"].append(r["cost"])
metrics["latency"].append(r["latency_ms"])
per_case.append({"case": case["id"], "score": score})
summary = {
"version": version,
"pass_rate": sum(1 for s in metrics["quality"] if s >= 0.8) / len(self.cases),
"avg_quality": sum(metrics["quality"]) / len(metrics["quality"]),
"tool_accuracy": sum(metrics["tool"]) / len(metrics["tool"]),
"avg_cost": sum(metrics["cost"]) / len(metrics["cost"]),
"p95_latency": self._p95(metrics["latency"]),
}
self.results[version] = summary
return summary, per_case
def check_gate(self, new_summary, tolerance=0.0):
"""门禁:对比基线,退化则拦截"""
base = self.baseline.get("latest")
if not base:
return True, "首次运行,建立基线"
if new_summary["pass_rate"] < base["pass_rate"] - tolerance:
return False, f"通过率退化: {base['pass_rate']:.0%} → {new_summary['pass_rate']:.0%}"
if new_summary["avg_cost"] > base["avg_cost"] * 1.2:
return False, f"成本超限: {base['avg_cost']:.3f} → {new_summary['avg_cost']:.3f}"
return True, "通过"
def _score(self, case, result):
if case["judge"] == "rule":
return {"quality": 1.0 if result["output"] == case["expected"] else 0.0,
"tool_ok": result.get("tool_ok", False)}
# LLM-as-Judge(第17章)
judge = llm_judge(result["output"], case["expected_criteria"])
return {"quality": judge["score"], "tool_ok": result.get("tool_ok", False)}
设计要点:
- 门禁双条件:通过率退化拦截 + 成本超限拦截(呼应第 23 章成本控制)------"质量没降但贵了"也该拦。
- 首次运行建基线:没有基线不拦截(第 20.3.1 原则)。
- 逐条留档 :
per_case供退化时定位具体 case。
20.4.3 接入 CI 门禁
python
# ci_gate.py(伪代码:CI 中调用)
def ci_main():
engine = EvalEngine("eval_set.json", "baseline.json")
agent_fn = load_agent_version(os.environ["AGENT_VERSION"])
summary, per_case = engine.evaluate_all(agent_fn, version="v1.1")
ok, msg = engine.check_gate(summary)
if ok:
engine.save_baseline(summary) # 更新基线
print(f"✅ {msg}: pass_rate={summary['pass_rate']:.0%}")
sys.exit(0)
else:
print(f"❌ {msg}")
dump_per_case(per_case) # 导出逐条结果供人工排查
sys.exit(1)
CI 化后的效果:每次改动自动评测,低于基线自动拦截------"越改越烂"在发布前就被挡住(第 18 章学习闭环的刹车片落地)。
20.4.4 从 0 到 1 的上线路径
| 阶段 | 动作 | 产出 |
|---|---|---|
| 第 1 天 | 收集 20 条真实问题 + 标注期望 | 首个评测集 |
| 第 1 周 | 跑基线,记录分数 | baseline.json |
| 第 2 周 | 接入 CI 门禁 + 灰度流程 | 自动化回归 |
| 第 1 月 | 线上漏检案例回填 + 盲测集 | 100 条评测集 |
| 持续 | 每版对比基线 + 灰度验证 | 防退化闭环 |
🛠 解决方案:评测集从 0 到 100 条 + 回归误报排查
常见问题
- "评测集从哪来":从真实数据来------线上日志、用户反馈、历史故障(20.2.2 三阶段)。
- "LLM-as-Judge 分数不稳":同一回答两次评分不同。对策:多次评分取均值 + 评分标准写"可判定"(呼应第 17 章硬规则)。
- "回归报退化,但感觉没改坏"(误报):评测集覆盖不全或评分抖动。对策:逐条查是哪些 case 退化;把误报 case 从评测集剔除或修正判定方式。
- "评测集被过拟合":改动只提升评测集、线上反而变差。对策:评测集定期换新(掺入新的线上案例)+ 保留独立的"盲测集"(从不上线前展示)。
- "改一处,评测全崩":改动影响面大(如换了主模型)。对策:分层评测(基础能力 / 场景任务 / 端到端),定位影响层。
解决方案速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 没评测集 | 不知从哪建 | 真实数据三阶段 |
| 分数不稳 | 评分抖动 | 多次取均值 + 可判定标准 |
| 回归误报 | 覆盖不全 | 逐条排查 + 修正 case |
| 过拟合 | 评测集固化 | 定期换新 + 盲测集 |
| 一改全崩 | 无分层 | 分层评测定位 |
实战提示
- 先建基线再动代码:任何优化前先跑一次评测定基线(20.3.1)。
- 评测集要"活着":线上漏检案例持续回填,别让评测集脱离真实。
- 门禁自动化:评测接入 CI,低于基线自动拦截------别靠人肉记得跑。
- 灰度是最后一道闸:评测过了不代表线上没问题,小流量验证不可省。
- 四个维度一起看:别只看成功率,成本/延迟/工具正确率一起评,防止"变准了但贵到没人用"。