第 20 章 质量保障 Harness 与评测体系

0 阅读8分钟

第 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:评测四维指标

图 1:评测四维指标

维度指标衡量什么获取方式
质量任务成功率 / 正确率任务完成得好不好人工标注 / LLM-as-Judge
工具工具正确率 / 调用有效率工具调对没有、有没有白调工具调用日志
成本单任务 token / 费用贵不贵用量统计(第 23/24 章)
性能P50/P95 延迟快不快追踪系统(第 21 章)

20.1.3 质量指标的两个评分方法

# 方法一:规则评分(可判定场景,零成本)
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 评测集的三要素

一个好的评测集 = 问题 + 期望 + 判定方式

EVAL_SET = [
    {
        "input": "查一下 A123 的物流",
        "expected": {"status": "shipped"},
        "judge": "rule",              # 规则判定
    },
    {
        "input": "帮我写个退货说明",
        "expected_criteria": "语气得体、包含退货流程、无虚假承诺",
        "judge": "llm",               # 模型判定
    },
    ...
]

20.2.2 评测集构建路径:从 0 到 100 条

不要幻想一步到位,按阶段迭代:

图 2:评测集构建路径

图 2:评测集构建路径

阶段条数来源目的
起步20~30 条真实高频问题 + 历史故障案例先跑通流程
扩充50~80 条加边界/异常/多轮场景覆盖长尾
完善100+ 条线上漏检案例回填 + 分场景分层建立基线

关键原则:评测集来自真实数据(线上日志、用户反馈、故障案例),不是拍脑袋编的(呼应第 18 章失败样本库)。

全书评测集数量约定:起步 20~30 条(快速验证),基线 ≥50 条(上线门槛),完整回归 100+ 条(版本发布)。各章提到的"20 条""50 条""100 条"分别对应这三个阶段,按团队成熟度和场景复杂度逐步扩充。

20.2.3 自动化回归:改动的"安全网"

每次改动(提示词/参数/模型/Skill)都要跑全量评测集:

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:基线对比流程

图 3:基线对比流程

基线(v1.0)   任务成功率 82%
   ↓ 改提示词
v1.1 评测      任务成功率 88%  → 提升 6pp ✅ 可上线
   ↓ 改模型
v2.0 评测      任务成功率 79%  → 下降 3pp ❌ 回滚

对比基线是评测的"坐标系"——没有基线的分数没有意义。

20.3.2 防退化:评测门禁(CI 化)

把评测接入开发流程,像代码测试一样做门禁:

图 4:评测门禁 + 灰度

图 4:评测门禁 + 灰度

改动提交 → 自动跑评测集 → 对比基线
  ├─ 分数 ≥ 基线 → 通过,可发布
  └─ 分数 < 基线 → 拦截,人工排查

评测门禁是第 18 章"学习闭环刹车片"的工程化——让"越改越烂"在发布前就被拦住。

20.3.3 灰度发布:线上验证的最后一道闸

评测集是"模拟考试",灰度是"小范围真考":

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 评测体系架构

┌──────────────────────────────────────────────┐
│ 评测集(EVAL_SET)                             │
│  20 条起步 → 50 条 → 100 条(真实数据回填)      │
├──────────────────────────────────────────────┤
│ 评测引擎(evaluate_all)                       │
│  规则评分 → LLM-as-Judge → 汇总指标             │
├──────────────────────────────────────────────┤
│ 基线仓库(baseline.json)                      │
│  记录每次版本的分数字段                        │
├──────────────────────────────────────────────┤
│ 门禁(CI)→ 灰度(10%/50%/100%)→ 监控          │
└──────────────────────────────────────────────┘

20.4.2 评测引擎实现(可复用)

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)}

设计要点

  1. 门禁双条件:通过率退化拦截 + 成本超限拦截(呼应第 23 章成本控制)——"质量没降但贵了"也该拦。
  2. 首次运行建基线:没有基线不拦截(第 20.3.1 原则)。
  3. 逐条留档per_case 供退化时定位具体 case。

20.4.3 接入 CI 门禁

# 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 条 + 回归误报排查

常见问题

  1. "评测集从哪来":从真实数据来——线上日志、用户反馈、历史故障(20.2.2 三阶段)。
  2. "LLM-as-Judge 分数不稳":同一回答两次评分不同。对策:多次评分取均值 + 评分标准写"可判定"(呼应第 17 章硬规则)。
  3. "回归报退化,但感觉没改坏"(误报):评测集覆盖不全或评分抖动。对策:逐条查是哪些 case 退化;把误报 case 从评测集剔除或修正判定方式。
  4. "评测集被过拟合":改动只提升评测集、线上反而变差。对策:评测集定期换新(掺入新的线上案例)+ 保留独立的"盲测集"(从不上线前展示)。
  5. "改一处,评测全崩":改动影响面大(如换了主模型)。对策:分层评测(基础能力 / 场景任务 / 端到端),定位影响层。

解决方案速查表

现象根因解决方案
没评测集不知从哪建真实数据三阶段
分数不稳评分抖动多次取均值 + 可判定标准
回归误报覆盖不全逐条排查 + 修正 case
过拟合评测集固化定期换新 + 盲测集
一改全崩无分层分层评测定位

实战提示

  1. 先建基线再动代码:任何优化前先跑一次评测定基线(20.3.1)。
  2. 评测集要"活着":线上漏检案例持续回填,别让评测集脱离真实。
  3. 门禁自动化:评测接入 CI,低于基线自动拦截——别靠人肉记得跑。
  4. 灰度是最后一道闸:评测过了不代表线上没问题,小流量验证不可省。
  5. 四个维度一起看:别只看成功率,成本/延迟/工具正确率一起评,防止"变准了但贵到没人用"。