首页 / 文章 / 如何为你的AI代理构建评估框架(确保其在生产环境中不崩溃)
← 返回
AI技术

如何为你的AI代理构建评估框架(确保其在生产环境中不崩溃)

✍️ zhirenhun 📅 2026/7/11 👁 10 阅读 ⏱ 41 分钟
如何为你的AI代理构建评估框架(确保其在生产环境中不崩溃)

如何为你的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-bookno-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 在手动运行时看起来很棒。这从来都不是问题。问题是它在下一次提示词调整、下一次模型更新、以及下一个做了你没演示过的操作的用户面前是否仍然有效。构建这个框架,你就能真正知道答案。

原文:https://dev.to/ravi3divi/how-to-build-an-evaluation-harness-for-your-ai-agent-so-it-doesnt-break-in-production-1h6p

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

← 上一篇
我把一个仅限Claude Code使用的网页阅读器变成了一个普通的MCP服务器
下一篇 →
在AWS Bedrock上构建生产级AI代理——值得关注的架构与代码决策

📌 相关推荐

结构化输出 vs 工具使用 vs 预填充:2026年从Claude获取JSON
2026/7/11
在AWS Bedrock上构建生产级AI代理——值得关注的架构与代码决策
2026/7/11
我把一个仅限Claude Code使用的网页阅读器变成了一个普通的MCP服务器
2026/7/11
← 返回文章列表