返回博客列表
技术文章LangSmithLangGraphAgent自动化测试DatasetCI/CDAI

LangSmith 进阶:把 Agent 测试变成可重复的实验

2026年09月🇨🇳 中文

本文所涉及的代码:https://github.com/LiuSandy/agent-playground/tree/tutorial-03

上一篇里,我们给 Agent 接入了 LangSmith Tracing。一次请求经过哪些节点、调用什么工具、每一步输入输出是什么,都能在 Trace 中看到。

Tracing 解决的是解释一次运行的问题。它能告诉我们这一次为什么成功、为什么失败,却不能直接回答另一个更重要的工程问题:修改 Prompt、模型或路由规则之后,Agent 的整体表现是变好了,还是把原来能做对的事情改坏了?

手动问几个问题不够。测试样本每次不同、判断标准藏在人的脑子里、失败记录也不会自动保留下来。今天觉得“回答还可以”,下周换一个模型后,已经很难记得旧版本到底表现如何。

所以这一篇的目标不是再给 Agent 加一个功能,而是给它建立一套可重复的行为实验

固定一组任务与期望
        ↓
运行当前版本 Agent
        ↓
分别检查答案、路由和工具
        ↓
保存为一次 Experiment
        ↓
与修改前的版本比较

LangSmith 的 Dataset、Evaluator 和 Experiment,分别承担测试样本、评分规则和实验记录三个角色。


Agent 测试不是普通单元测试的放大版

先把最容易混淆的地方讲清楚:这篇文章做的 Agent 测试,不能替代普通单元测试;普通单元测试也无法覆盖 Agent 的整体行为。

两者面对的是不同问题:

对比项普通单元测试Agent 行为测试
测试对象一个函数、类或确定性模块模型、Prompt、Graph、工具组成的完整系统
输出特征同一输入通常得到唯一结果表述和执行路径可能变化
判断方式精确相等、异常类型、状态变化是否满足一组可接受条件
关注范围局部逻辑是否正确最终结果和中间决策是否合理
运行成本快,通常不依赖外部服务需要模型调用,有延迟和费用
失败含义通常指向明确代码缺陷可能来自模型、Prompt、路由、工具或数据

比如 calculator_tool("15 + 23 * 7") 是否返回 176,适合用单元测试精确判断;search_tool 遇到未知关键词是否返回“未找到”,也应该使用单元测试。

但用户问“帮我计算 15 + 23 × 7”时,Agent 是否理解这是数学任务、是否选择计算器、是否正确传参、拿到结果后是否形成可靠回答,这是一条完整行为链,单测某个工具无法证明它能正确走完。

反过来也一样。如果 Agent 最后碰巧回答了 176,不代表计算器实现一定正确。模型可能自己算出了答案,绕过了本该调用的工具。只看最终结果,会把过程中的错误隐藏起来。

两层测试应该同时存在

在当前项目里,我们把测试分成两层:

底层:普通单元测试
  ├── calculator_tool 的计算与异常处理
  ├── search_tool 的匹配规则
  ├── Evaluator 的评分逻辑
  └── 数据转换与分数聚合

上层:Agent 行为测试
  ├── Router 是否选对模式
  ├── Agent 是否选对工具
  ├── 最终回答是否包含关键事实
  └── 修改前后整体表现是否退化

底层单元测试帮助我们确认“零件没有坏”,上层行为测试帮助我们确认“零件组装起来之后仍然完成了任务”。两者是互补关系。


我们做测试,到底想得到什么

Agent 测试的目的不是证明模型“足够聪明”,也不是追求一个看起来漂亮的总分。我们真正需要的是一份可以长期维护的行为基线。

它要回答四个问题。

修改有没有造成回归

我们可能只是改了一句 System Prompt,却影响了工具选择;也可能只调整 Router 的关键词,却让原本简单的问题进入昂贵的 Deliberative 路径。

固定 Dataset 后,每个版本面对相同任务。新版本如果在旧用例上退步,Experiment 会直接把差异暴露出来。

失败发生在哪一层

“答案错了”只是现象。真正原因可能是:

  • Router 选错模式
  • 模型没有调用工具
  • 调用了错误工具
  • 工具正确,但模型忽略了工具结果
  • 路径和结果都对,只是表达方式不同

因此我们把答案、路由和工具拆成三个指标,而不是只生成一个笼统的“质量分”。指标拆开后,分数不仅能告诉我们有问题,还能提示应该先检查哪里。

两个版本是否可以公平比较

如果 v1 测数学题、v2 测搜索题,两次结果没有可比性。只有 Dataset 和 Evaluator 保持不变,变化集中在 Agent 本身,才能判断修改是否有效。

这和实验中的控制变量是同一个思路:固定问题,固定评分,只改变被测版本。

真实问题能不能永久留下来

线上或手动调试时发现的失败,不应该修完就忘。确认正确行为后,把这条 Trace 加入 Dataset;以后每次实验都会重新检查它。

Dataset 因此不只是测试数据,更像 Agent 的“问题记忆”:系统曾经在哪些地方失败,后续版本就不能再犯同样的错误。


为什么测试要分成答案、路由和工具三层

当前 Hybrid Agent 的执行过程不是单纯的“输入 → 输出”,而是:

用户问题
    ↓
Router:Reactive 还是 Deliberative?
    ↓
模型:是否需要工具、调用哪个工具?
    ↓
工具返回观察结果
    ↓
模型生成最终回答

每一层都会影响下一层。只检查最终答案,会出现两个问题。

第一,错误可能被偶然正确的答案掩盖。比如数学题没有调用计算器,但模型恰好心算正确;当前样例通过了,换成更复杂表达式就可能失败。

第二,最终答案失败时无法快速定位。路由错误、工具错误和回答错误都表现为同一个红色分数,最后仍然要逐条翻 Trace。

所以我们定义三个最小指标:

指标它保护的行为失败后优先检查
answer_correctness回答包含任务要求的关键事实工具结果、总结 Prompt
route_correctness简单任务走 Reactive,复杂任务走 DeliberativeRouter 规则
tool_correctness该调用时调用正确工具,不该调用时不调用工具描述、System Prompt

这三个指标并不完整,但它们刚好覆盖当前 Agent 最主要的决策链,而且都可以用确定规则判断。第一版测试体系最重要的是稳定和可解释,而不是一开始就把所有质量维度塞进去。


Dataset 是一份行为契约

普通单元测试常写“输入 A,输出必须等于 B”。Agent 输出是自然语言,如果要求整段文字逐字相等,测试会非常脆弱:换一个标点、调整一句话顺序,也会被判失败。

因此 Dataset 记录的不是唯一标准答案,而是可接受行为的边界

{
  "inputs": {
    "question": "帮我计算 15 + 23 × 7"
  },
  "outputs": {
    "answer_keywords": ["176"],
    "expected_mode": "reactive",
    "expected_tools": ["calculator_tool"]
  }
}

这条用例表达了三个约束:答案必须包含 176,任务应该进入 Reactive,执行过程应该使用计算器。至于回答是“结果为 176”还是“计算结果是 176”,不属于这条测试关心的范围。

这种设计的核心是:只固定必须正确的部分,允许无关表达自然变化。

用例按能力边界设计,而不是随便收集问题

第一版 Dataset 只有七类场景,但每一类都对应一个明确风险:

类型为什么要测主要期望
简单计算验证精确任务不会让模型心算Reactive + calculator
计算异常验证工具失败后不会编造结果能解释除零错误
信息查询验证信息类问题会使用搜索Reactive + search
直接回答验证简单对话不会滥用工具Reactive + 无工具
复杂调研验证复杂问题进入规划路径Deliberative + search
多步骤任务验证计划能覆盖多个子目标回答覆盖多个对象
无结果查询验证缺少资料时能够诚实失败明确说明未找到

这里同时包含正常路径、边界情况和错误情况。原因很简单:只测试“最容易成功的问题”,得到的只是演示效果,不是系统可靠性。

当前 search_tool 使用 mock 数据,也是有意为之。真实搜索结果会随时间变化,测试失败后很难区分是 Agent 退化,还是外部数据变了。固定工具输出可以减少变量,让第一版 Experiment 更专注于 Router、工具选择和回答行为。

Dataset 为什么需要版本

Dataset 名称使用 hybrid-agent-regression-v1。一旦参考标准发生明显变化,创建 v2,而不是悄悄覆盖旧数据。

因为 Experiment 的结论依赖当时的测试基准。如果后来修改了旧用例,却仍拿新分数和旧分数直接比较,比较结果就失去了意义。Dataset 版本记录的是“当时我们认为什么行为是正确的”。

配套代码已经准备好七条用例,首次创建 Dataset 时运行:

uv run python -m scripts.create_dataset

Dataset 也可以从 Dashboard 手动添加,或者从失败 Trace 中沉淀。创建方式不重要,重要的是每条用例都经过人工确认,知道它为什么存在、保护什么行为。


Evaluator 把“我觉得可以”变成明确规则

有了 Dataset,还需要把人的判断转成可重复执行的评分标准。

当前三个 Evaluator 的逻辑很短:

def answer_correctness(outputs, reference_outputs):
    # 最终回答是否包含全部关键事实
    ...


def route_correctness(outputs, reference_outputs):
    # Router 结果是否等于期望模式
    ...


def tool_correctness(outputs, reference_outputs):
    # 实际工具集合是否等于期望工具集合
    ...

代码本身不是重点,重要的是这三个规则为什么这样设计。

答案使用关键词包含,而不是全文相等。 我们关心事实正确,不限制模型必须用哪种句式。

路由使用精确匹配。 当前 Router 只有 Reactive 和 Deliberative 两种结果,这是确定决策,不需要模糊评分。

工具比较集合。 Deliberative 路径可能多次调用同一个搜索工具。只要没有漏掉必要工具、也没有使用意外工具,重复调用不应该让“工具选择是否正确”变成失败。调用次数和路径效率可以作为另一项指标单独评估。

为什么暂时不用 LLM-as-Judge 做门禁

帮助性、完整性、表达质量很难用关键词覆盖,最终确实可能需要 LLM-as-Judge。但第一版质量门禁优先使用确定性规则,原因有三个:

  • 同样输入得到同样分数,回归结果稳定
  • 不增加额外 Judge 模型费用
  • 失败原因直观,容易调试

LLM-as-Judge 更适合评价“建议是否有帮助”这类主观问题,可以先作为观察指标,不急着决定 PR 能否合并。下一篇会专门讨论规则评估、人工评估和 LLM-as-Judge 应该如何组合。


Experiment 的意义是比较,不是得到一个孤立分数

LangSmith 的 evaluate() 会让同一个 Agent 跑完整个 Dataset,再用三个 Evaluator 为每条运行评分:

results = evaluate(
    run_hybrid_agent,
    data="hybrid-agent-regression-v1",
    evaluators=[
        answer_correctness,
        route_correctness,
        tool_correctness,
    ],
    experiment_prefix="hybrid-agent-baseline",
)

完整实现见 tutorial-03 分支。运行命令是:

uv run python -m scripts.run_evaluation

第一次运行得到 baseline。之后修改 Prompt、模型或 Router,再对同一个 Dataset、同一组 Evaluator运行一次,才能进行公平比较。

假设 Router v2 的路由正确率提高了,但答案正确率下降了,这说明修改确实让更多问题进入 Deliberative,却不一定改善最终结果。单看一个综合平均分,很容易错过这种指标之间的此消彼长。

所以 Dashboard 中应该先回答:

  1. 哪个指标发生变化?
  2. 变化集中在哪一类用例?
  3. 对应 Trace 从哪个节点开始偏离预期?

Evaluator 负责从整批结果中筛出问题,上一篇介绍的 Trace 负责解释单条问题。评估和可观测性在这里形成闭环。


从测试用例到运行结果

理解 Dataset、Evaluator 和 Experiment 的职责后,我们把它们串成一次完整的测试。整个过程分为四步:设置测试用例、创建 Dataset、运行测试,以及在 LangSmith 中查看结果。

设置测试用例

测试用例统一定义在 scripts/create_dataset.pyCASES 中。每条用例由 inputsoutputs 两部分组成:

{
    "inputs": {
        "question": "帮我计算 15 + 23 × 7",
    },
    "outputs": {
        "answer_keywords": ["176"],
        "expected_mode": "reactive",
        "expected_tools": ["calculator_tool"],
    },
}

inputs.question 是交给 Agent 的真实问题;outputs 不是 Agent 必须逐字返回的答案,而是 Evaluator 使用的行为期望:

  • answer_keywords:最终回答必须包含的关键信息
  • expected_mode:Router 应该选择的运行模式
  • expected_tools:执行过程中应该使用的工具

新增用例时,先明确它要保护哪一种行为,再填写对应期望。不要为了扩大数量而加入大量含义相同的问题,也不要把语气、标点等无关表达写进判断条件。

创建 Dataset

先在项目根目录准备 .env,至少填写模型服务和 LangSmith 所需的密钥:

DEEPSEEK_API_KEY=你的模型服务密钥
LANGSMITH_API_KEY=你的 LangSmith API Key
LANGSMITH_TRACING=true
LANGSMITH_PROJECT=agent-playground

然后运行:

uv run python -m scripts.create_dataset

脚本会显式加载 .env,创建名为 hybrid-agent-regression-v1 的 Dataset,并一次性上传 CASES 中定义的七条用例。这个命令用于首次创建当前版本的数据集;调整测试标准时,应创建新的 Dataset 版本,而不是覆盖已经用于历史实验的基准。

运行测试

Dataset 创建完成后,执行:

uv run python -m scripts.run_evaluation

脚本会读取 hybrid-agent-regression-v1,让当前 Hybrid Agent 依次处理其中的用例,再使用 answer_correctnessroute_correctnesstool_correctness 评分。每次运行都会在 LangSmith 中保存为一个 Experiment,名称以 hybrid-agent-baseline 开头。

运行结束后,终端会输出三个指标的平均分。如果任一指标低于脚本设置的门槛,命令会以失败状态退出;无论门禁是否通过,本次实验的逐条运行记录都应以 LangSmith 中保存的 Experiment 为准。

查看结果

运行完成后,进入 LangSmith 左侧的 Datasets & Experiments,打开 hybrid-agent-regression-v1,在 Experiments 页签中可以找到名称以 hybrid-agent-baseline 开头的实验。

总览页先回答“这次测试整体表现如何”。截图中的 Experiment 已完成 7 / 7 runs,错误率为 0%,说明七条用例都成功执行,没有因为接口异常或程序错误中断。但成功执行不等于行为全部正确,三个 Evaluator 给出的平均分分别是:

  • answer_correctness0.71,即 7 条用例中有 5 条满足答案关键词要求
  • route_correctness1.00,说明 7 条用例都进入了预期的 Reactive 或 Deliberative 路径
  • tool_correctness0.86,即 7 条用例中有 6 条使用了预期工具

这组结果说明 Router 的表现符合当前 Dataset 的预期,但答案和工具使用仍有失败用例。总览中还提供延迟、Token 和成本等运行信息,它们不会决定行为是否正确,却能帮助我们观察一次 Agent 改动是否带来了明显的性能或调用成本变化。

点击 Experiment 名称后,可以进入每条测试用例的明细页面:

明细表把 InputsReference Outputs、Agent 的实际 Outputs 以及三个评分结果放在同一行。绿色的 1.00 表示该项符合预期,红色的 0.00 表示不符合预期。这样就能从整体平均分继续定位到具体问题,而不需要凭感觉判断 Agent 的回答。

本次实验中有两条值得关注的记录:

  1. “搜索量子烤面包机的可靠资料”的路由和工具评分通过,但答案评分失败。这说明 Agent 进入了正确路径,也调用了搜索工具,但最终回答没有包含用例要求的“未找到”关键词。这里既可能需要调整回答,也需要重新确认关键词规则是否准确表达了我们真正关心的行为。
  2. “帮我计算 10 ÷ 0,并说明结果”的路由评分通过,但答案和工具评分失败。实际回答说明了除零没有定义,却没有使用预期的 calculator_tool,也没有命中当前要求的“零”关键词。这条记录同时暴露了工具调用行为与答案判定规则的问题。

因此,测试结果不能只看错误率,也不能只看最终回答。错误率为 0% 只代表程序顺利跑完;Evaluator 分数才说明 Agent 是否遵守了 Dataset 中定义的行为契约。对于失败用例,我们还需要点开对应运行的 Trace,确认模型是否调用工具、工具返回了什么,以及最终回答为什么偏离预期,再决定应该修改 Agent,还是修正测试用例本身。


Agent 测试进入 CI,也不该照搬单元测试策略

普通单元测试通常很快、免费、完全确定,可以在每次提交时全部运行。Agent 测试会调用真实模型,存在费用、限流、网络波动和小概率输出变化,不适合简单地要求“所有样例永远 100% 通过”。

我们为三个指标设置的是最低阈值:

指标当前门槛设计考虑
答案正确率85%自然语言生成存在少量波动
路由正确率90%Router 是确定规则,应更稳定
工具正确率90%工具选择是 Agent 的核心能力

阈值不是行业标准,而是当前项目的初始行为底线。积累更多实验后,再根据真实波动调整。门槛太低无法保护质量,门槛一开始设成 100% 又可能让 CI 因偶发输出频繁失败。

更合理的执行节奏是:

  • 每次提交:运行普通单元测试,不调用模型
  • Pull Request:运行少量核心 Agent 用例,阻止明显回归
  • 重大 Prompt 或模型变更:运行完整 Dataset,并与 baseline 对比
  • 发现真实失败时:先补 Dataset 用例,再修复 Agent

tutorial-03 中已经提供 GitHub Actions 和分数门禁脚本。CI 实现只是把上述策略自动化,真正重要的是先确定哪些行为必须保护、哪些分数允许波动。


Dataset 应该随着失败不断成长

第一版七条用例只是起点。最有价值的测试往往来自系统已经发生过的错误:

发现失败 Trace
    ↓
确认正确行为应该是什么
    ↓
加入 Dataset
    ↓
修复 Agent
    ↓
重新运行 Experiment

每修复一个真实问题,Dataset 就多记录一条行为边界。长期来看,它会比最初手写的一批“理想问题”更接近真实用户如何使用 Agent。

这里要避免两个误区。

不要直接把 Agent 当前输出当成参考答案。 当前输出可能正是问题来源。参考结果必须经过人工确认。

不要为了提高分数删除困难用例。 低分用例是在暴露系统边界。如果暂时无法解决,可以单独标记或不纳入强门禁,但不应该让它从历史中消失。


测试的最终目的,是让 Agent 可以放心演进

前四篇文章形成了一条连续路径:

  • 第 00 篇搭出第一个能调用工具的 Agent
  • 第 01 篇把执行方式扩展为 Reactive、Deliberative 和 Hybrid
  • 第 02 篇用 Trace 看清一次执行的内部过程
  • 这一篇用 Dataset、Evaluator 和 Experiment 固定行为基线

普通单元测试保证工具、解析器和评分函数这些确定性零件正确;Agent 行为测试保证模型、Prompt、Graph 和工具组合后仍然完成用户任务。

我们测试的不是“模型每次说出完全相同的话”,而是三件更有价值的事:它是否做对了、是否走对了路径、修改之后是否退步。

测试也不是在脚本结束时结束。我们还要从 Experiment 的汇总分找到异常维度,从失败用例进入 Trace 找到第一处偏离,最后形成“是否可以发布、为什么、下一步做什么”的明确结论。

有了这套基线和结果闭环,调整 Prompt、替换模型、增加工具时就不再只靠感觉。下一篇会进一步讨论:结果质量、过程效率、成本、安全性这么多维度,应该如何组成一套完整的 Agent 评估方法。