如何为你的AI代理构建评估框架(确保其在生产环境中不崩溃)
这个智能体通过了所有我手动测试的用例。然后有用户让它"预订更便宜的航班",它愉快地调用了错误航班ID的预订功能,整整三天没人发现——因为我一直在运行的演示从未要求过"更便宜的航班"。
这就是陷阱。当你亲自运行测试智能体时,演示本身就是测试——你输入最顺利的提示词,看着它工作,然后发布上线。但你的用户不会按照你的顺利路径操作,而且你的模型提供商下周二就会静默推送新的检查点。AI智能体评估框架的作用就是告诉你:你的智能体是否真的能工作,更重要的是——上周的提示词调整是否悄悄破坏了你没在关注的航班预订流程。
本教程将构建这样一个框架。完成后你将拥有一个可运行的Python仓库,它能:对智能体在真实任务上的表现进行评分,检查它是否调用了正确的工具及参数,用LLM判断模糊性内容,并在变更导致智能体性能下降时让CI构建失败。没有产品推销,没有框架锁定——这个模式适用于LangChain、LangGraph、原生MCP,或者你自己拼装的任何方案。
你将构建的内容:一个极简的AI智能体评估框架
我们将测试一个拥有三个工具(搜索、获取天气、预订)的微型旅行支持智能体——因为工具调用比自由对话更容易检查。在此过程中我们将构建四个评分器。我称之为"四大评分器",它们清晰地对应着你真正担心的问题:
| 评分器 | 解决的问题 | 工作原理 |
|---|---|---|
| 真实性 | 智能体是否调用了正确的工具和参数? | 确定性代码检查 |
| 路径 | 是否采取了合理的路径,还是循环/无效操作? | 轨迹/步骤效率检查 |
| 评判 | 在没有唯一正确答案时,是否真正完成了目标? | LLM作为评判者+评分标准 |
| 门控 | 这次变更是否让智能体比上周更差? | 聚合分数+CI回归门控 |
真实性和路径评分器成本低、速度快且确定性高。评判评分器灵活但需要校准。门控评分器则是将所有这些从科研项目转变为安全网的关键。
前置条件
- Python 3.10+,熟悉pip install命令。
- pytest(运行pip install pytest)。对于LLM作为评判者的步骤,任何提供商SDK都可以——我们以openai为例,但这个调用是一个可替换的函数,所以Anthropic、本地模型或任何具有聊天端点的方案都适用。
- 一个可以从Python调用的智能体,能返回它调用了哪些工具以及最终答案。如果你的框架还没有暴露这些信息,请捕获它们——大多数框架通过回调或追踪/跨度导出功能提供。
- 无需评估经验。这正是我在发布智能体并意识到"在我的机器上能运行"不是测试策略时,希望已经存在的东西。
为什么手动测试会误导
有三件事让智能体特别不适合肉眼测试。
演示即测试。 你通过运行提示词并修复问题来构建智能体。因此,你运行的提示词本质上就是它已经能处理的那些。你是在批改自己已经写好答案的考卷。
非确定性。 相同的提示词、相同的模型,不同的输出。温度参数、重新排序的检索结果、提供商端的模型更新——任何这些因素都可能在你零代码变更的情况下,让一个通过的运行在明天变成失败。单次手动运行几乎无法告诉你行为分布的任何信息。
静默回归。 这是最致命的。你收紧系统提示词来修复格式问题,结果发现这种措辞也促使模型在预订前跳过了天气检查。没有报错,没有红色日志。智能体只是在你没关注的任务上变差了8%,然后你从用户那里得知。评估框架正是针对这种情况的烟雾探测器。
解决方案不是"增加手动测试",而是建立一组小规模任务,自动评分,每次变更都运行。
智能体评估的三个层次
在评分之前,要知道你在哪个层面评分。智能体运行是一个跨度树——顶层任务、推理步骤和工具调用在中间层,各个组件(检索器、单个工具、子智能体)在叶子节点。你可以在三个层次进行评估,好的框架会使用所有三个层次(Confident AI对此有很好的阐述):
- 端到端——将整个智能体视为黑盒:给定这个输入,最终结果是否成功?这是你的评判评分器。
- 轨迹——查看计划:产生结果的工具调用、重试和交接。是否采取了合理的路径?这是你的真实性和路径评分器。
- 组件——放大到某个叶子节点:这个检索器或工具是否是瓶颈?当更高层次告诉你出了问题,你需要定位具体位置时使用。
从端到端和轨迹开始。只有在需要定位失败原因时才深入到组件层面。以下是跨度树的映射关系:
[端到端] 任务:"除非丹佛下雨,否则预订周五的AA123航班"
│
[轨迹] ├─ get_weather(city="Denver") ← 路径+真实性在此评分
├─ book(flight_id="AA123", ...) ← 真实性在此评分参数
└─ final_answer: "已预订AA123..." ← 评判在此评分
│
[组件] └─ weather_tool延迟/检索器召回率 ← 仅用于定位问题
第一步:从真实失败中构建黄金数据集
下游的一切都依赖于此。你的数据集是一小组带有已知正确预期的任务。遵循两条规则,均来自Anthropic的评估指南:
- 从小规模开始,从真实失败中获取。 "从真实失败中提取20-50个简单任务是一个很好的起点。"不要等待千行数据集。每个bug报告和每次手动检查都是一个测试用例——把它们转化为数据行。
- 使通过/失败明确无误。 设计每个任务时,确保"两位领域专家能独立得出相同的通过/失败结论"。如果你无法判断一次运行是否通过,那么评分器也无法判断。
以下是我们旅行智能体的入门数据集。这是一个纯Python列表——将其放在evals/dataset.py中:
# evals/dataset.py — 黄金数据集。每行是一个可检查的任务。
GOLDEN = [
{
"task_id": "weather-gate-book",
"input": "预订周五的AA123航班,但仅限于丹佛不下雨的情况。",
"expect_tools": [
{"name": "get_weather", "args": {"city": "Denver"}},
{"name": "book", "args": {"flight_id": "AA123", "date": "Friday"}},
],
"success_contains": "已预订",
},
{
"task_id": "search-before-book",
"input": "查找下周一去西雅图最便宜的航班并预订。",
"expect_tools": [
{"name": "search", "args": {"destination": "Seattle"}},
{"name": "book", "args": {}}, # 空值表示不关心具体参数
],
"success_contains": "已预订",
},
{
"task_id": "no-book-when-raining",
"input": "只有在丹佛天气晴朗时才预订周五的AA200航班。(正在下雨。)",
"expect_tools": [{"name": "get_weather", "args": {"city": "Denver"}}],
"forbid_tools": ["book"], # 这里预订是严重失败
"success_contains": "未预订",
},
{
"task_id": "weather-only",
"input": "丹佛的天气怎么样?",
"expect_tools": [{"name": "get_weather", "args": {"city": "Denver"}}],
"forbid_tools": ["book", "search"],
"success_contains": "丹佛",
},
{
"task_id": "ambiguous-needs-search",
"input": "帮我预订去海边便宜的航班。",
"expect_tools": [{"name": "search", "args": {}}],
"success_contains": "选项", # 应该澄清/搜索,而不是盲目预订
},
]
你应该看到:五个任务,每个都有预期的工具调用,有些带有 forbid_tools(禁止发生的调用),以及最终答案的 success_contains 字符串。注意 no-book-when-raining 和 ambiguous-needs-search——这些是快乐路径演示永远不会测试的隐蔽失败案例。这正是关键所在。
我们还需要一个统一的格式来表示 agent 的行为。将其放在 evals/types.py 中:
# evals/types.py — 每个评分器读取的轨迹格式
from dataclasses import dataclass
@dataclass
class ToolCall:
name: str
args: dict
@dataclass
class AgentRun:
task_id: str
tool_calls: list # list[ToolCall],按顺序排列
final_answer: str
集成时你的工作:让你的实际 agent 生成一个 AgentRun。LangChain/LangGraph 通过回调或运行树暴露这些信息;MCP 服务器记录工具调用;手动编写的循环已经拥有这个列表。在本教程中,我们将直接对示例运行进行评分,因此下面的每个代码块都可以在没有 API 密钥的情况下运行。
第2步:评分器 1 — Truth(工具调用正确性检查)
Truth 回答了最便宜、最有价值的问题:agent 是否调用了正确的工具并使用了正确的参数?这是一个基于代码的评分器——快速、免费、客观、可重复。不涉及 LLM。将其放在 evals/graders.py 中:
# evals/graders.py — Truth:所需的工具调用是否存在且正确?
def grade_truth(run, task):
actual = run.tool_calls
# 1. 禁止的调用直接判定为失败
for c in actual:
if c.name in task.get("forbid_tools", []):
return 0.0, f"调用了禁止的工具: {c.name}"
# 2. 每个预期的调用必须出现且参数匹配
for exp in task["expect_tools"]:
hit = next(
(c for c in actual
if c.name == exp["name"]
and all(c.args.get(k) == v for k, v in exp["args"].items())),
None,
)
if hit is None:
return 0.0, f"缺失/不正确: {exp['name']}({exp['args']})"
return 1.0, "所有预期的工具调用都存在且参数正确"
这里面隐藏着两个不同的检查,值得命名它们,因为评估文献中是这样做的:工具正确性(是否调用了正确的工具?)和参数正确性(参数是否正确?)。一个调用 book 的 agent——正确的工具——但使用了错误的 flight_id——错误的参数——是一个会花费真金白银的 bug。grade_truth 能同时捕捉这两者。空的 args 字典意味着"我只关心工具被调用了,而不关心如何调用的",这是保持测试不脆弱的方法。
让我们在一个好的运行和一个有 bug 的运行上测试它:
# try_truth.py — 可运行:对一个好的运行和一个有问题的运行进行评分
from evals.types import AgentRun, ToolCall
from evals.dataset import GOLDEN
from evals.graders import grade_truth
task = GOLDEN[0] # weather-gate-book
good = AgentRun("weather-gate-book",
[ToolCall("get_weather", {"city": "Denver"}),
ToolCall("book", {"flight_id": "AA123", "date": "Friday"})],
"已预订周五的 AA123 航班。")
buggy = AgentRun("weather-gate-book",
[ToolCall("get_weather", {"city": "Denver"}),
ToolCall("book", {"flight_id": "AA999", "date": "Friday"})], # 错误的 ID
"已预订周五的 AA999 航班。")
print(grade_truth(good, task))
print(grade_truth(buggy, task))
你应该看到:
(1.0, '所有预期的工具调用都存在且参数正确')
(0.0, "缺失/不正确: book({'flight_id': 'AA123', 'date': 'Friday'})")
有 bug 的运行预订了错误的航班,评分器以确定性的方式在毫秒内捕捉到了它,且零成本。这一个评分器本可以为我节省那三天时间。
第3步:评分器 2 — Path(轨迹/步骤效率)
Truth 告诉你 agent 最终做了正确的事情。Path 告诉你它没有在过程中胡乱操作。一个调用 search 六次、在 get_weather 上循环、花费 40 秒和 0.30 美元来预订一个航班的 agent,是一个即将发生的生产事故——缓慢、昂贵,并且距离失控账单只差一个重试循环。这里的指标是步骤效率:它是否避免了不必要的步骤、重试和循环?
添加到 evals/graders.py:
# evals/graders.py(续)— Path:高效的轨迹,无抖动
def grade_path(run, task):
expected = len(task["expect_tools"])
actual = len(run.tool_calls)
# 重复的相同调用 = 循环/抖动
seen, duplicates = set(), 0
for c in run.tool_calls:
key = (c.name, tuple(sorted(c.args.items())))
if key in seen:
duplicates += 1
seen.add(key)
if duplicates:
return 0.0, f"{duplicates} 个重复的工具调用 — 循环"
if actual > expected * 2: # 宽松的预算:最小路径的 2 倍
return 0.0, f"效率低下:{actual} 次调用,任务需要 {expected} 次调用"
return 1.0, f"高效:{actual} 次调用,无重复"
expected * 2 的预算故意设置得很宽松——真实的 agent 确实可能需要额外一步来澄清或恢复。你不是在要求最小路径,而是在捕捉病态路径。根据你的 agent 调整乘数;重复调用检查是在你的账单发现问题之前捕捉无限循环回归的部分。
对一个抖动 agent 运行它:
# 对同一个天气调用循环两次的运行进行评分
from evals.types import AgentRun, ToolCall
from evals.dataset import GOLDEN
from evals.graders import grade_path
looper = AgentRun("weather-gate-book",
[ToolCall("get_weather", {"city": "Denver"}),
ToolCall("get_weather", {"city": "Denver"}), # 重复
ToolCall("book", {"flight_id": "AA123", "date": "Friday"})],
"已预订 AA123。")
print(grade_path(looper, GOLDEN[0]))
你应该看到:
(0.0, '1 个重复的工具调用 — 循环')
第4步:评分器 3 — Judge(带评分标准的 LLM 作为评判者)
有些事情你不能用 == 来检查。"agent 的最终答案是否真正实现了用户的目标?"通常没有单一的正确字符串。这就是基于模型的评分器发挥作用的地方:灵活、处理开放式任务、根据评分标准打分。代价是它是非确定性的,并且——请读两遍——在信任它之前必须根据人工标签进行校准。下面会详细说明这个注意事项。
关键的设计举措:将 LLM 调用放在一个可替换的函数中,这样评分器就不与任何提供商绑定。添加到 evals/graders.py:
# evals/graders.py(续)— Judge:LLM 通过评分标准对目标完成情况进行评分
import json
RUBRIC = """你是一个严格的评分者,负责评估 AI agent 的回答。
只有当答案完全实现了用户的目标时,才给 1 分;否则给 0 分。
用户任务:{task}
Agent 的最终答案:{answer}
仅用 JSON 对象回复:{{"score": 0 或 1, "reason": ""}}"""
def grade_judge(run, task, chat):
prompt = RUBRIC.format(task=task["input"], answer=run.final_answer)
data = json.loads(chat(prompt)) # chat() 与提供商无关
return float(data["score"]), data["reason"]
chat 是任何接受提示字符串并返回模型文本的函数。这是 OpenAI 版本——将主体替换为 Anthropic、Gemini 或本地模型,其他什么都不用改:
# evals/judge_client.py — 一个可替换的函数。适用于任何提供商。
def openai_chat(prompt: str) -> str:
from openai import OpenAI
client = OpenAI() # 从环境变量读取 OPENAI_API_KEY
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0, # 尽可能确定性
response_format={"type": "json_object"},
)
return resp.choices[0].message.content
当你调用 grade_judge(run, task, openai_chat) 时,应该看到类似这样的元组:(1.0, 'The agent booked the requested flight after checking weather.')。设置 temperature=0 并强制输出 JSON 格式——你希望评判器尽可能可复现,就像随机过程能做到的那样。
现在来说我承诺过的注意事项,因为跳过它正是团队自欺欺人的方式:未经验证的 LLM 评判器只是一个带有数字的"氛围感"。在将其用于 CI 之前,请亲自手动标注 20-30 次运行,在同样的运行上运行评判器,并检查一致性。如果评判器经常与你意见相左,请修正评分标准——不要发布这个评判器。Anthropic 对此直言不讳:基于模型的评分器"需要对照人类判断进行校准",而人工标注是你校准所依据的黄金标准。将评判器用于模糊的端到端结论;对于所有可以确定性检查的内容,则依赖无需校准的 Truth 和 Path。
第 5 步:评分器 4 —— 门控(封装为 pytest + CI)
到这里,它不再只是一个脚本,而变成了一个安全网。门控负责两项工作:对每个任务运行所有评分器,并在总分低于基线时使构建失败。最后这一点是整个工作的全部意义——正是它捕捉到了静默回归。
在写代码之前,还有一个原则:隔离你的评分。Anthropic 的规则是"每个试验应从干净环境开始,保持隔离"——没有共享状态在运行之间泄露,从而抬高(或压低)你的分数。在实践中:每个任务使用全新的测试夹具,不重复使用会话,没有测试依赖前一个测试。如果你不刻意违背,pytest 会免费为你提供这一点。
将此代码放入 evals/test_agent.py:
# evals/test_agent.py — 门控。在每个 PR 的 CI 中运行。
import pytest
from evals.dataset import GOLDEN
from evals.graders import grade_truth, grade_path
from evals.runner import run_agent # 你的 agent → AgentRun
DETERMINISTIC = {"truth": grade_truth, "path": grade_path}
BASELINE = 0.90 # 发布门槛:总分必须 >= 此值
@pytest.mark.parametrize("task", GOLDEN, ids=lambda t: t["task_id"])
def test_each_task_passes_deterministic_graders(task):
run = run_agent(task["input"]) # 每个任务干净运行 = 隔离
for name, grader in DETERMINISTIC.items():
score, reason = grader(run, task)
assert score == 1.0, f"[{name}] {task['task_id']}: {reason}"
def test_aggregate_no_regression():
scores = []
for task in GOLDEN:
run = run_agent(task["input"])
per_task = [g(run, task)[0] for g in DETERMINISTIC.values()]
scores.append(sum(per_task) / len(per_task))
avg = sum(scores) / len(scores)
assert avg >= BASELINE, f"REGRESSION: {avg:.2f} {BASELINE}"
你应该看到:运行 pytest evals/ -q。每个黄金任务都会显示为一个命名测试(weather-gate-book、no-book-when-raining……),失败的任务会精确打印哪个评分器失败以及原因——[truth] weather-gate-book: missing/incorrect: book({'flight_id': 'AA123'...})。故意破坏你的提示词,观察聚合测试变红并显示分数。那个红色 X 就是你一直缺失的东西。
我特意将 Judge 评分器排除在硬断言门控之外:因为它是不确定的,将其分数视为监控指标(记录日志,在下降时发出警报),而不是会导致构建不稳定的通过/失败判断。确定性评分器负责门控;Judge 负责提供信息。
现在将其接入 CI,使其在每个拉取请求上运行。GitHub Actions,.github/workflows/agent-evals.yml:
# .github/workflows/agent-evals.yml — 在回归时阻止合并。
name: agent-evals
on: [pull_request]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest openai
- run: pytest evals/ -q
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
你应该看到:打开一个修改提示词或升级模型的 PR,agent-evals 检查就会运行。如果 agent 变差了,检查会显示红色,合并被阻止——就像失败的单元测试一样。你的 agent 现在像普通软件一样在 CI 中受到门控,这正是全部意义所在。
结论:停止凭感觉发布 Agent
你现在拥有了将"演示中有效"与"我知道它有效"区分开来的四个组件:一个从真实失败中提取的黄金数据集;一个确定性捕获错误工具和错误参数的 Truth 评分器;一个捕获循环和抖动的 Path 评分器;一个用于模糊目标完成判断的 Judge;以及一个在变更使 agent 变差时使构建失败的门控。
这些都不复杂。这与让你为其余代码编写单元测试的本能相同——只是当测试对象是一个调用工具的非确定性 agent 时,你之前没有合适的框架。现在你有了。
下一步方向:
- 扩展数据集。每个生产事故都成为新的黄金行。目标是 Anthropic 建议的 20-50 个真实失败任务,然后才能信任聚合分数。
- 校准评判器。手动标注一批,测量一致性,修正评分标准直到评判器与你匹配。然后——也只有到那时——才开始信任它的数字。
- 当需要定位失败时,添加组件级评估:单独评估检索器的召回率或单个工具。
- 随时间跟踪分数。每次提交的聚合分数仪表板将你的框架从门控转变为早期预警系统。
你的 agent 在手动运行时看起来很棒。这从来都不是问题。问题是它在下一次提示词调整、下一次模型更新、以及下一个做了你没演示过的操作的用户面前是否仍然有效。构建这个框架,你就能真正知道答案。