Agent AtlasAI Agent 知识库
06.05教程 · Tutorials

从零搭评估流水线

JSONL 黄金集、轨迹断言、LLM 评委、bootstrap 置信区间、报告生成与 CI 门禁。

评估不是「跑一遍看看」。它要能回答三个问题:通过率是多少、哪一类在失败、这次改动是否真的更好。这个教程写一条能进 CI 的评估流水线。

最终效果

bash
python evals/run.py --dataset evals/golden.jsonl --fail-under 0.85

输出:

text
pass rate: 0.84  (95% CI 0.71–0.93)
by tag:
  refund        0.91
  boundary      0.73
  insufficient  0.88
  injection     0.60
avg steps: 4.2
p95 latency: 8.4s
failures: 8
report -> evals/report.md

CI 中低于阈值直接失败。

数据格式

evals/golden.jsonl,一行一条:

json
{
  "id": "refund-014",
  "input": "订单 A1024 签收 9 天了,还能退吗?",
  "context": {"user_id": "u_1", "now": "2026-10-30"},
  "expected": {
    "must_call": ["search_policy"],
    "must_not_call": ["create_refund"],
    "answer_contains": ["超过 7 天", "人工"],
    "citations": ["refund_policy"],
    "max_steps": 6
  },
  "tags": ["refund", "boundary", "policy"]
}

关键点:

  • 不存「标准答案全文」,存可验证的断言;
  • must_not_call 和 must_call 一样重要;
  • 多步任务限制 max_steps,防止绕路;
  • tags 用于拆分失败类型。

50 条怎么分配

类型数量例子
顺利路径20标准退款、订单查询
边界10金额刚好等于上限、第 7 天
信息不足8缺订单号、文档没有答案
对抗6网页注入、越权请求
历史失败6线上真实出过的问题

Step 1:统一结果结构

不管底层用哪个框架,先适配成同一种结果:

python
from dataclasses import dataclass, field


@dataclass
class Trace:
    answer: str
    tool_calls: list[dict] = field(default_factory=list)  # [{"name": ..., "args": ...}]
    citations: list[str] = field(default_factory=list)
    steps: int = 0
    tokens: int = 0
    latency_ms: int = 0
    error: str | None = None

适配器:

python
def run_agent(case: dict) -> Trace:
    result = agent.run(case["input"], context=case.get("context", {}))
    return Trace(
        answer=result.answer,
        tool_calls=[{"name": c.name, "args": c.args} for c in result.tool_calls],
        citations=result.citations,
        steps=len(result.steps),
        tokens=result.tokens,
        latency_ms=result.latency_ms,
    )

如果结果结构不统一,评估脚本会变成一堆 if/else。先把适配层写清楚。

Step 2:断言

evals/checks.py:

python
from __future__ import annotations


def check_case(case: dict, trace) -> dict[str, bool]:
    exp = case.get("expected", {})
    called = [c["name"] for c in trace.tool_calls]

    checks: dict[str, bool] = {}

    must_call = exp.get("must_call", [])
    checks["must_call"] = all(name in called for name in must_call)

    must_not = exp.get("must_not_call", [])
    checks["must_not_call"] = all(name not in called for name in must_not)

    contains = exp.get("answer_contains", [])
    checks["answer"] = all(s in trace.answer for s in contains)

    citations = exp.get("citations", [])
    checks["citations"] = all(any(c.startswith(x) for c in trace.citations) for x in citations)

    max_steps = exp.get("max_steps")
    checks["steps"] = max_steps is None or trace.steps <= max_steps

    checks["no_error"] = trace.error is None

    return checks

pass = all(checks.values())。不要用一个模糊的总分掩盖具体问题:分类错和引用错需要完全不同的修复。

Step 3:主脚本

evals/run.py:

python
from __future__ import annotations

import argparse
import json
import random
import statistics
from pathlib import Path

from .checks import check_case
from .runner import run_agent


def bootstrap_ci(values: list[float], n: int = 2000) -> tuple[float, float]:
    if not values:
        return 0.0, 0.0
    rng = random.Random(42)
    means = []
    for _ in range(n):
        sample = rng.choices(values, k=len(values))
        means.append(sum(sample) / len(sample))
    means.sort()
    return means[int(0.025 * n)], means[int(0.975 * n)]


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--dataset", default="evals/golden.jsonl")
    parser.add_argument("--fail-under", type=float, default=0.0)
    parser.add_argument("--out", default="evals/report.md")
    args = parser.parse_args()

    cases = [
        json.loads(line)
        for line in Path(args.dataset).read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]

    rows = []
    for case in cases:
        trace = run_agent(case)
        checks = check_case(case, trace)
        rows.append(
            {
                "id": case["id"],
                "tags": case.get("tags", []),
                "passed": all(checks.values()),
                "checks": checks,
                "answer": trace.answer,
                "steps": trace.steps,
                "tokens": trace.tokens,
                "latency_ms": trace.latency_ms,
                "error": trace.error,
            }
        )

    passed = [1.0 if r["passed"] else 0.0 for r in rows]
    rate = statistics.mean(passed) if passed else 0.0
    low, high = bootstrap_ci(passed)

    by_tag: dict[str, list[float]] = {}
    for r in rows:
        for tag in r["tags"]:
            by_tag.setdefault(tag, []).append(1.0 if r["passed"] else 0.0)

    failures = [r for r in rows if not r["passed"]]
    p95 = sorted(r["latency_ms"] for r in rows)[int(0.95 * len(rows)) - 1] if rows else 0

    lines = [
        f"# Eval report\n",
        f"- cases: {len(rows)}",
        f"- pass rate: {rate:.2f} (95% CI {low:.2f}–{high:.2f})",
        f"- avg steps: {statistics.mean(r['steps'] for r in rows):.1f}" if rows else "",
        f"- p95 latency: {p95 / 1000:.1f}s",
        f"- total tokens: {sum(r['tokens'] for r in rows):,}",
        "",
        "## By tag",
        "",
        "| tag | cases | pass |",
        "| --- | ---: | ---: |",
    ]
    for tag, values in sorted(by_tag.items(), key=lambda x: -len(x[1])):
        lines.append(f"| {tag} | {len(values)} | {statistics.mean(values):.2f} |")

    lines += ["", "## Failures", ""]
    for r in failures:
        failed = [k for k, v in r["checks"].items() if not v]
        lines.append(f"### {r['id']} — {', '.join(failed)}")
        lines.append(f"- tags: {', '.join(r['tags'])}")
        lines.append(f"- answer: {r['answer'][:300]}")
        if r["error"]:
            lines.append(f"- error: {r['error']}")
        lines.append("")

    Path(args.out).write_text("\n".join(lines), encoding="utf-8")
    print("\n".join(lines[:12]))

    if rate < args.fail_under:
        raise SystemExit(f"pass rate {rate:.3f} < {args.fail_under}")


if __name__ == "__main__":
    main()

Step 4:LLM 评委

规则断言覆盖不了「完整性、可读性、是否回答了问题」。这时用评委,但要校准:

python
JUDGE = """你是严格的评审。根据以下标准给回答打分:
- 事实准确性(0-3):每个事实是否有来源支持
- 完整性(0-2):是否覆盖问题的所有部分
- 简洁性(0-1):是否有重复或无关内容

先列出证据与问题,再输出 JSON:
{"score": 0-6, "reason": "..."}"""

def judge(question: str, answer: str, sources: str) -> dict:
    resp = client.chat.completions.create(
        model=JUDGE_MODEL,
        messages=[
            {"role": "system", "content": "只输出 JSON。"},
            {"role": "user", "content": f"{JUDGE}\n\n问题:{question}\n资料:{sources}\n回答:{answer}"},
        ],
        response_format={"type": "json_object"},
        temperature=0,
    )
    return json.loads(resp.choices[0].message.content)

三条纪律:

  1. 评委与被测模型不同家族,减少共同偏见;
  2. 抽 50 条人工对齐,看评委与人工的一致性;
  3. 成对比较时随机顺序,并控制长度偏见。

评委分数不能替代规则断言。金额、权限、引用编号这些必须由代码检查。

Step 5:接进 CI

.github/workflows/eval.yml:

yaml
name: agent-eval

on:
  pull_request:
  workflow_dispatch:

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - name: Run evals
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          MODEL: ${{ vars.EVAL_MODEL }}
        run: python evals/run.py --dataset evals/golden.jsonl --fail-under 0.85
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: eval-report
          path: evals/report.md

成本控制:

  • PR 跑 20 条冒烟集;
  • 每天或发版前跑全量;
  • 固定模型版本与温度;
  • 缓存 embedding / 检索结果。

Step 6:线上评估

线上没有标准答案,看行为:

指标怎么算说明
任务完成率用户确认解决 / 总任务最接近价值
人工接管率转人工 / 总任务过高说明能力不足
重试率重新生成 / 总回答过高说明质量差
投诉率投诉 / 总任务风险信号
P95 延迟端到端体验
单任务成本token + 工具费商业可行性

每个线上失败都要能关联到 trace,并抽样回流到黄金集。

常见错误

评估集只有 10 条

80% 和 90% 的差异可能只是噪声。至少 50 条,关键场景 100+。

只看最终答案

过程错了但答案蒙对,线上迟早出事。多步任务必须看工具与步骤。

反复针对测试集调参

这是另一种过拟合。保留一个从未看过的留出集。

评委和选手同模型

共同偏见会让分数虚高。换家族,做人工校准。

验收清单

  • 50 条黄金集,覆盖五类场景
  • must_call / must_not_call / 引用 / 步数断言
  • 通过率 + 置信区间
  • 按 tag 拆分
  • 失败报告能定位到具体断言
  • 评委经过人工校准
  • CI 有阈值,低于基线失败
  • 线上指标与 trace 关联

小结

  • 评估要能回答「多少、哪类、是否真的更好」。
  • 规则断言优先,评委补充主观质量。
  • 小样本差异可能只是噪声,报告置信区间。
  • 接进 CI,才能防止改一个提示词悄悄弄坏另一半场景。

下一篇让线上问题可见:trace 与可观测性。

读完了?进度会保存在本地浏览器。
内容会持续修订。发现错误或想补充,欢迎反馈。 遇到问题?查排查手册 →