一文搞懂 AI Agent 评测:手写三层 Eval 框架,把 Agent 质量接进 CI 门禁
一文搞懂 AI Agent 评测:手写三层 Eval 框架,把 Agent 质量接进 CI 门禁
📖 摘要:上线一个 AI Agent 不难,难的是"它到底有没有变好/变坏"。本文从 Agent 相比普通 LLM 更难评测的底层原因讲起,归纳线上 7 类典型失效,提出"确定性 → 启发式 → LLM-as-Judge"三层评估金字塔,并用约 160 行 Python 手写一个可运行的极简 Eval 框架,最后演示如何把它接进 CI 质量门禁,让每次发布都有客观护栏。
🏷️ 关键词:AI Agent,评测框架,LLM Eval,CI 质量门禁,Python
目录
一、背景与痛点
1.1 为什么 Agent 比普通 LLM 更难评测
普通 LLM 的评测相对"干净":输入一个 prompt,输出一段文本,我们拿输出和参考答案比一比就行。但 Agent 不是单次补全,它是一个带状态的循环——会调工具、会跨多轮推理、会把上一步的结果喂给下一步。
这就带来三个评测难题:
- 错误会累积:单步 90% 准确率,10 步连乘后只剩约 35%。一步的小错会在链路末端被放大成"完全答非所问"。
- "对"是多维的:输出格式正确 ≠ 推理正确;推理正确 ≠ 没调错工具;没调错工具 ≠ 没泄露隐私。一个维度过不了都不能算合格。
- 结果非确定性:同一个输入跑两次,可能因为工具返回波动、采样随机性,得到不一样的轨迹。只看单次"运气好"的样本会严重高估质量。
💡 一句话:评 Agent 评的是"过程 + 结果"的组合质量,而不是单条生成的好坏。
1.2 Agent 上线后最常见的 7 类失效
结合生产环境的观察,Agent 跑久了常出现这几类"肉眼难发现、但指标能抓到"的退化(示例数据,仅作演示):
| 失效模式 | 表现 | 能否被低层评估抓到 |
|---|---|---|
| 级联不确定性放大 | 错误在步骤间静默累积 | ❌ 需全链路 |
| 工具可用性伪装 | 工具已降级,但"可用"信号仍绿 | ❌ |
| 分布塌缩 | 输出多样性逐渐收窄 | 部分(分布层) |
| 跨会话一致性漂移 | 相同输入不同会话结果漂移 | ✅ 基线比对 |
| 解释与决策脱节 | 归因说明和真实决策不一致 | ❌ 需语义层 |
| 延迟-正确率倒挂 | 响应越快越不准 | ✅ 阈值层 |
| 代理目标收敛 | 优化可量化代理指标而非真实目标 | ❌ 需 rubric |
注意一个规律:大多数失效(卡死、崩溃、格式违规、幻觉路径、输出残缺)在 Tier1 + Tier2 就能拦住,真正需要"主观质量判断"的只占约 20%。这也是我们设计评估金字塔的依据。
二、Agent 评测的核心范式
2.1 三层评估金字塔:确定性 → 启发式 → LLM-as-Judge
我们把评估器按"独立性"从高到低分成三层,优先用 Agent 伪造不了的检查:
- Tier1 确定性(Externally Observable):JSON 解析失败就是失败,正则不匹配就是不匹配,Agent 无法伪造结果。最硬,最便宜,必须首选。
- Tier2 启发式(Statistically Observable):用嵌入相似度、分布比对、模式匹配等统计手段,Agent 也没法直接控制基线。
- Tier3 共享基座判断(LLM-as-Judge):用另一个模型打分。最灵活但最可被伪造、最不独立,只作为最后 20% 主观判断的兜底。
Tier3 LLM-as-Judge ← 主观质量,最后兜底(~20%)
/ \
/ \
Tier2 启发式(嵌入/分布/模式) ← 统计可观测
/
/
Tier1 确定性(格式/非空/不卡死) ← 最硬、最便宜、首选
工程上,能用 Tier1 就别上 Tier2,能用 Tier2 就别请 Tier3。这能省下大量判断成本,也避免"模型评模型"带来的噪声。
2.2 评测维度:不只是"对不对"
一个成熟的 Agent 评测至少要覆盖 6 个维度,每个给出归一化 [0.0, 1.0] 分数和通过/不通过判定:
- 准确性(Accuracy):有没有正确完成任务。
- 格式合规(Format):输出是否可解析、是否符合 schema。
- 延迟(Latency):是否超过预算阈值。
- 成本(Cost):Token / 调用次数是否超帽。
- 安全(Safety):是否越权、是否泄露、是否产生有害内容。
- 自定义(Custom):业务特有的硬指标。
⚠️ 注意区分 Gate(门禁) 和 Grade(评分):Gate 是二元的"到底做没做对这件事",Grade 是 0~1 的"做得好不好"。低分是信息,不是失败——永远不要把 Grade 硬塞成 Gate。
三、手写最小 Agent Eval 框架(Python 实战)
3.1 环境准备
本框架零外部依赖,纯标准库即可跑(嵌入与 LLM 判断用可替换的桩实现,方便你接自己的服务)。
# 建议 Python 3.10+,无需 pip 安装任何包
python --version # 3.10 或更高
mkdir -p agent_eval && cd agent_eval
touch eval_core.py runner.py ci_gate.py
文件结构(示例数据,仅作演示):
agent_eval/
├── eval_core.py # 评估器基类 + 三层评估器
├── runner.py # 编排器:汇总成健康分
└── ci_gate.py # 质量门禁 + CI 调用入口
3.2 评估器基类与 Tier1 确定性检查
先定义统一接口,所有评估器都返回带分数的结果:
# eval_core.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass
class Score:
dimension: str
value: float # 归一化 0.0 ~ 1.0
passed: bool # 是否过门禁
evidence: str # 失败原因 / 证据,便于调试
class BaseEvaluator(ABC):
"""所有评估器的统一接口:输入输出 + 上下文,产出 Score。"""
dimension: str = "base"
threshold: float = 0.0 # 门禁阈值,>= 即通过
@abstractmethod
def evaluate(self, output: Any, expected: Any, ctx: dict) -> Score:
...
# ---- Tier1:确定性检查(Agent 无法伪造)----
import re
import json
class FormatEvaluator(BaseEvaluator):
"""检查输出是否能被解析为合法 JSON,且含必需字段。"""
dimension = "format"
def __init__(self, required_keys: list[str] | None = None, threshold: float = 1.0):
self.required_keys = required_keys or []
self.threshold = threshold
def evaluate(self, output: str, expected: Any, ctx: dict) -> Score:
try:
data = json.loads(output)
except (json.JSONDecodeError, TypeError):
return Score("format", 0.0, False, "输出不是合法 JSON,解析失败")
missing = [k for k in self.required_keys if k not in data]
if missing:
return Score("format", 0.0, False, f"缺少必需字段: {missing}")
return Score("format", 1.0, True, "JSON 合法且字段完整")
class NonEmptyEvaluator(BaseEvaluator):
"""检查 Agent 没有产出空结果或中途卡死(stale run)。"""
dimension = "completion"
def evaluate(self, output: str, expected: Any, ctx: dict) -> Score:
if not output or not str(output).strip():
return Score("completion", 0.0, False, "输出为空,可能中途卡死")
# 若提供了最后活动时间戳,可在此比对是否超时
return Score("completion", 1.0, True, "输出非空")
3.3 Tier2 启发式检查(嵌入/分布)
Tier2 用"统计可观测"手段。这里用余弦相似度比对当前输出与基线,抓分布塌缩 / 一致性漂移:
import math
from typing import Callable
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return 0.0 if (na == 0 or nb == 0) else dot / (na * nb)
class DriftEvaluator(BaseEvaluator):
"""Tier2:用嵌入相似度比对当前输出与基线,抓漂移/塌缩。"""
dimension = "drift"
def __init__(self, embed: Callable[[str], list[float]],
baseline: list[float], floor: float = 0.6):
self.embed = embed
self.baseline = baseline
self.threshold = floor # 相似度低于 floor 即视为漂移
def evaluate(self, output: str, expected: Any, ctx: dict) -> Score:
sim = _cosine(self.embed(str(output)), self.baseline)
passed = sim >= self.threshold
return Score("drift", round(sim, 3), passed,
f"与基线相似度 {sim:.3f}(阈值 {self.threshold})")
💡
embed是可插拔的:接sentence-transformers、OpenAI Embeddings 或本地模型都行。生产环境把基线存在向量库里,每次评测拉最新滚动基线即可。
3.4 Tier3 LLM-as-Judge 与校准
最后 20% 的主观质量,用另一个模型打结构化分。关键是给 rubric(评分量表)+ 校准,别直接问"打几分":
from dataclasses import dataclass
@dataclass
class RubricItem:
name: str
criterion: str # 明确什么算好、什么算差
class LLMJudgeEvaluator(BaseEvaluator):
"""Tier3:用 LLM 按 rubric 打分,最灵活但最不独立,仅兜底用。"""
dimension = "quality"
def __init__(self, judge_call: Callable[[str, str, list[RubricItem]], float],
rubric: list[RubricItem], threshold: float = 0.7):
self.judge_call = judge_call
self.rubric = rubric
self.threshold = threshold
def evaluate(self, output: str, expected: Any, ctx: dict) -> Score:
# judge_call 返回 0~1;生产可加"多裁判共识"降低噪声
score = self.judge_call(str(output), str(expected), self.rubric)
passed = score >= self.threshold
return Score("quality", round(score, 3), passed, "LLM-as-Judge 主观评分")
⚠️ 校准提示:LLM 打分有偏差(常给高分)。用人工标注集做阈值调优,或采用"多裁判 + 取中值/共识"来压噪声,否则门禁会被它带偏。
3.5 编排器:多维打分汇总成健康分
把三层评估器编排起来,产出一张可追踪的"健康分卡":
# runner.py
from eval_core import BaseEvaluator, Score
class EvalRunner:
def __init__(self, evaluators: list[BaseEvaluator], weights: dict[str, float] | None = None):
self.evaluators = evaluators
self.weights = weights or {e.dimension: 1.0 for e in evaluators}
def run(self, output, expected, ctx: dict) -> dict:
scores: list[Score] = [e.evaluate(output, expected, ctx) for e in self.evaluators]
# 健康分 = 加权平均分;任一 Gate 不通过则整体失败
total_w = sum(self.weights[s.dimension] for s in scores)
health = sum(s.value * self.weights[s.dimension] for s in scores) / total_w
gates_ok = all(s.passed for s in scores)
return {
"health_score": round(health, 3),
"all_gates_passed": gates_ok,
"details": scores,
}
# 使用示例(示例数据,仅作演示)
if __name__ == "__main__":
sample_output = '{"answer": "订单已退款", "order_id": "O-1001"}'
runner = EvalRunner([
FormatEvaluator(required_keys=["answer", "order_id"]),
NonEmptyEvaluator(),
])
report = runner.run(sample_output, expected=None, ctx={})
print(report)
# -> {'health_score': 1.0, 'all_gates_passed': True, 'details': [...]}
四、把 Eval 接进 CI:质量门禁
4.1 设计质量门禁(Gate vs Grade)
门禁只认 Tier1 + Tier2 这类不可伪造的检查。把 all_gates_passed 作为二元闸口:失败就阻断发布,成功才放行。Grade(如 Tier3 质量分)只进报表、不进闸口。
# ci_gate.py
import sys
from runner import EvalRunner
from eval_core import FormatEvaluator, NonEmptyEvaluator
def main() -> int:
# 真实场景从测试集(JSONL)批量读取 case;这里用单条演示
cases = [
('{"answer": "ok", "order_id": "O-1"}', None),
('这不是合法JSON', None),
]
runner = EvalRunner([FormatEvaluator(["answer", "order_id"]), NonEmptyEvaluator()])
failed = 0
for i, (out, exp) in enumerate(cases):
r = runner.run(out, exp, ctx={})
status = "PASS" if r["all_gates_passed"] else "FAIL"
print(f"[case {i}] {status} health={r['health_score']}")
failed += 0 if r["all_gates_passed"] else 1
return 1 if failed else 0 # 非零即阻断 CI
if __name__ == "__main__":
sys.exit(main())
4.2 GitHub Actions 接入示例
把上面的门禁挂到每次 PR / 发布前,零成本获得客观护栏:
# .github/workflows/agent-eval.yml
name: agent-eval-gate
on:
pull_request:
paths: ["agent_eval/**", "prompts/**"]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: python agent_eval/ci_gate.py
# 退出码非 0 自动阻断合并,强制修复后再放行
💡 把
ci_gate.py接入定时任务,还能做夜间回归:每天用同一批 case 跑一遍,画出健康分趋势曲线,第一时间发现"悄悄变笨"。
五、生产避坑
5.1 不要把 Grade 当 Gate
最常见的反模式:把 LLM 主观质量分设为"低于 0.7 就阻断发布"。结果是噪声巨大、误报频繁,团队很快就不信这套评测了。Gate 只用确定性/统计性硬指标,Grade 只做趋势观察。
5.2 评测集要随 Agent 一起演进
评测集不是写完就不动的。Agent 改了 prompt、加了工具、换了模型,都要补对应的 case——尤其是线上真实暴露出的失败样本,要立刻沉淀进回归集。否则评测会"看着绿,实际烂"。
5.3 隐私脱敏:评测日志别泄露敏感字段
评测轨迹里往往含用户真实输入(订单号、姓名、PII)。在落日志/上报前先脱敏(遮蔽、哈希、正则替换),评测系统本身也会变成攻击面。这一点和被评测的 Agent 安全是同一回事。
六、总结
评测 Agent,本质是给"会调工具、会多步推理、非确定性"的系统装一套客观护栏。核心三点:
- 按独立性分层:确定性(Tier1)→ 启发式(Tier2)→ LLM 判断(Tier3),能用底层就别上高层,省成本也降噪。
- Gate 与 Grade 分离:不可伪造的硬指标进门禁,主观质量分只进报表。
- 评测即代码、评测随 Agent 演进:接进 CI,每次发布与夜间回归都有数据说话。
至此,我们的 Agent 工程知识链已经串起 记忆层 → 并行协作 → 协议(MCP/A2A) → 技能(Skills) → 可观测性 → 评测门禁 六个环节。下一步可以往 Agent 安全/Guardrails(如何防越权、防提示词注入)方向继续深入。
觉得有用就点个赞 / 收藏,评论区聊聊你现在的 Agent 是怎么做回归的。
更多推荐


所有评论(0)