评估不是「跑一遍看看」。它要能回答三个问题:通过率是多少、哪一类在失败、这次改动是否真的更好。这个教程写一条能进 CI 的评估流水线。
最终效果
python evals/run.py --dataset evals/golden.jsonl --fail-under 0.85输出:
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.mdCI 中低于阈值直接失败。
数据格式
evals/golden.jsonl,一行一条:
{
"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:统一结果结构
不管底层用哪个框架,先适配成同一种结果:
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适配器:
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:
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 checkspass = all(checks.values())。不要用一个模糊的总分掩盖具体问题:分类错和引用错需要完全不同的修复。
Step 3:主脚本
evals/run.py:
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 评委
规则断言覆盖不了「完整性、可读性、是否回答了问题」。这时用评委,但要校准:
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)三条纪律:
- 评委与被测模型不同家族,减少共同偏见;
- 抽 50 条人工对齐,看评委与人工的一致性;
- 成对比较时随机顺序,并控制长度偏见。
评委分数不能替代规则断言。金额、权限、引用编号这些必须由代码检查。
Step 5:接进 CI
.github/workflows/eval.yml:
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,并抽样回流到黄金集。
常见错误
80% 和 90% 的差异可能只是噪声。至少 50 条,关键场景 100+。
过程错了但答案蒙对,线上迟早出事。多步任务必须看工具与步骤。
这是另一种过拟合。保留一个从未看过的留出集。
共同偏见会让分数虚高。换家族,做人工校准。
验收清单
- 50 条黄金集,覆盖五类场景
- must_call / must_not_call / 引用 / 步数断言
- 通过率 + 置信区间
- 按 tag 拆分
- 失败报告能定位到具体断言
- 评委经过人工校准
- CI 有阈值,低于基线失败
- 线上指标与 trace 关联
小结
- 评估要能回答「多少、哪类、是否真的更好」。
- 规则断言优先,评委补充主观质量。
- 小样本差异可能只是噪声,报告置信区间。
- 接进 CI,才能防止改一个提示词悄悄弄坏另一半场景。
下一篇让线上问题可见:trace 与可观测性。