《AI Agent 开发入门》16.1 确定性测试

本节把 minagent 推进到可回归的测试层:用 FakeModel 与 pytest 写断言轨迹的测试,断言 Agent 调了哪些工具、调用顺序、参数与最终答复,并演示如何用脚本注入错误、让工具抛异常来制造失败路径。六条用例真跑 pytest -q,输出 6 passed。

16.1 确定性测试

前 15 章我们一直在给 minagent 加能力:循环、工具、记忆、编排、可观测性。能力越多,“这次改动有没有把别的东西改坏"就越难回答。传统软件靠单元测试兜底,但 Agent 的"输出"是一整条轨迹——它可能对、可能错、可能换了条路走对。要把它测住,第一步是让轨迹可复现,而可复现的前提是把模型换掉。

本节把 minagent 推进到可回归的测试层:用 FakeModel + pytest 写「断言轨迹」的测试,断言 Agent 调了哪些工具、顺序、参数与最终答复,并演示两类失败注入。六条用例真跑 pytest -q,输出 6 passed。

16.1.1 为什么必须把模型换成假模型

有人会想:测试嘛,直接调真实模型不就行了?只要满足三个条件,这个想法就破产了:

障碍真实模型FakeModel
可复现同输入两次输出可能不同脚本写死,逐字节一致
成本每次跑都花钱零
速度数百毫秒到数十秒微秒级
边界情况靠提示词碰运气触发脚本里直接写

第一条最致命。假设你写了一个"模型先调 add 再调 mul“的断言,用真实模型跑——今天它可能真的这么走,明天它可能一步直接算出答案。测试时红时绿,你根本分不清是"代码坏了"还是"模型换了个说法”。一个会随机失败的测试,比没有测试更糟,因为它会训练你忽略红灯。

假模型把这层不确定性彻底拿掉。它实现的是同一个 Model 协议(第 1.3 节),所以你在假模型上断言通过的东西,换成真模型时循环逻辑一行不用改——变的只是"模型怎么想”,而"循环怎么转"才是你要回归的部分。

还有一层常被忽略的好处:假模型让你能测"失败"。真实模型几乎不会稳定地给你一个 max_tokens 截断、或稳定地调用一个不存在的工具。而这些恰恰是最该被测的路径。有了脚本,失败变成一行代码就能造出来的东西。

16.1.2 断言什么:轨迹、工具、参数、终值

Agent 的"输出"不只是那一段最终答复,而是一整条轨迹。可断言的东西至少有四类:

断言对象数据来源例子
工具名与顺序轨迹里的 ToolCall[("add", …), ("mul", …)]
工具参数同一个 ToolCall.arguments{"a": 2, "b": 3}
步骤形态RunResult.steps 的 kind["tool_call", "tool_call", "final"]
最终答复RunResult.final"结果是 20。"
模型看到了什么FakeModel.calls每轮几条消息、哪些工具

第 1.3 节立的 FakeModel 会记下每次调用的入参,RunResult 里则留着完整消息列表。两者合起来,能把"Agent 做了正确的事"从主观感觉变成客观断言。先写一个把轨迹里工具调用抽出来的小助手:

def tool_calls_of(result) -> list[tuple[str, dict]]:
    """从轨迹里按顺序取出「工具名 + 参数」。"""
    out: list[tuple[str, dict]] = []
    for m in result.messages:
        for tc in m.tool_calls:
            out.append((tc.name, tc.arguments))
    return out

为什么从 result.messages 里取,而不是从 result.steps?因为 Step.detail 是拼给人看的字符串(add({'a': 2, 'b': 3}) -> 5),要拿回结构化参数还得反解析;而 messages 里的 ToolCall 是原始对象,name 和 arguments 直接可读。展示用 steps,断言用 messages,这个分工从第 3 章就定下了。

16.1.3 一个可跑的测试文件

把测试放进 tests/test_trajectory.py。先备一个注册表工厂和上面那个助手,再写用例:

"""16.1 确定性测试:用 FakeModel + pytest 断言 Agent 的轨迹。"""

from __future__ import annotations

import pytest

from minagent import FakeModel, Message, ModelResponse, Tool, ToolCall, ToolRegistry, run


def make_registry() -> ToolRegistry:
    reg = ToolRegistry()
    reg.add(Tool("add", "两数相加",
                 {"type": "object",
                  "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
                  "required": ["a", "b"]},
                 lambda a, b: a + b))
    reg.add(Tool("mul", "两数相乘",
                 {"type": "object",
                  "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
                  "required": ["a", "b"]},
                 lambda a, b: a * b))
    return reg


def test_asserts_tool_order_args_and_final():
    model = FakeModel([
        [ToolCall("c1", "add", {"a": 2, "b": 3})],
        [ToolCall("c2", "mul", {"a": 5, "b": 4})],
        "结果是 20。",
    ])
    result = run(model, make_registry(), "(2+3)*4 等于多少?", system="计算助手")

    assert tool_calls_of(result) == [("add", {"a": 2, "b": 3}), ("mul", {"a": 5, "b": 4})]
    assert [s.kind for s in result.steps] == ["tool_call", "tool_call", "final"]
    assert result.steps[-1].detail == "结果是 20。"
    assert result.final == "结果是 20。"


def test_asserts_what_the_model_saw():
    model = FakeModel([[ToolCall("c1", "add", {"a": 1, "b": 2})], "3"])
    run(model, make_registry(), "1+2?", system="计算助手")

    first_msgs, first_tools = model.calls[0]
    assert len(first_msgs) == 2                                   # system + user
    assert [t["name"] for t in first_tools] == ["add", "mul"]
    assert len(model.calls[1][0]) == 4                            # + assistant + tool
    assert len(model.calls) == 2

第一条用例一次性钉住四件事:工具顺序(先 add 后 mul)、参数({"a": 2, "b": 3})、步骤形态(两步工具加一步收敛)、终值。任何一处被改动改坏,这条断言都会立刻变红。注意第二条 mul 的参数是 {"a": 5, "b": 4}——那个 5 是脚本里写死的,代表"模型看到加法结果后填进来的值";假模型省掉的是模型怎么想,保留的是循环怎么转。

第二条用例断言的是模型视角:首轮它看到 2 条消息(system + user)和两个工具的 schema;第二轮多出 assistant 与 tool 两条,变成 4 条。model.calls 把"循环有没有把工具 schema 传下去"“历史有没有按预期增长"这些内部行为变成了可断言事实。

16.1.4 注入失败:脚本造错、工具抛异常

只测"顺利路径"的测试是残缺的。Agent 真正的价值在于出错时还能收拾,所以失败路径必须覆盖。两条最常见的失败注入方式:

其一,让工具抛异常。 工具本身写错了(除零、越界、外部接口挂了)是常态,测试要证明循环不会因此崩掉:

def test_tool_exception_is_reflowed():
    reg = make_registry()

    def div(a, b):
        return a / b

    reg.add(Tool("div", "两数相除",
                 {"type": "object",
                  "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
                  "required": ["a", "b"]},
                 div))
    model = FakeModel([[ToolCall("c1", "div", {"a": 1, "b": 0})], "无法计算。"])
    result = run(model, reg, "1/0 等于多少?")

    assert "ZeroDivisionError" in result.steps[0].detail
    assert result.final == "无法计算。"

div(1, 0) 必然抛 ZeroDivisionError,但 ToolRegistry.call 会把它包成 {"error": ...} 回灌给模型,而不是让循环崩掉。断言同时验证了两件事:错误确实出现在轨迹里,且模型在收到错误后仍能收敛。

其二,用脚本注入一个反常响应。 比如模拟模型被 max_tokens 截断——直接构造一个 ModelResponse 塞进脚本(脚本的第三种元素):

def test_injected_max_tokens_response():
    resp = ModelResponse(
        message=Message(role="assistant", content=""),
        stop_reason="max_tokens",
        usage={"input_tokens": 12, "output_tokens": 0},
    )
    result = run(FakeModel([resp]), make_registry(), "hi")
    assert result.steps[-1].kind == "final"
    assert result.messages[-1].content == ""

还有两条兜底用例,验证"模型不收敛"和"模型点名了不存在的工具"这两个护栏:

def test_unknown_tool_is_reflowed():
    model = FakeModel([[ToolCall("c1", "search", {"q": "x"})], "我换个方式回答。"])
    result = run(model, make_registry(), "查一下")

    assert "未知工具 search" in result.steps[0].detail
    assert result.final == "我换个方式回答。"


def test_non_convergence_raises():
    model = FakeModel([[ToolCall("c1", "add", {"a": 1, "b": 1})]])
    with pytest.raises(RuntimeError):
        run(model, make_registry(), "loop")

最后这条很重要:脚本只给了一次工具调用、没有终止文本,Agent 必然还要再问一次模型,而脚本已空——FakeModel 于是抛 RuntimeError。它测的是"循环确实会因为没收敛而失败”,而不是碰巧跑对了。 如果哪天有人把"脚本耗尽"改成静默返回空响应,这条用例会立刻抓住。

16.1.5 跑起来:pytest -q

把上面六条用例放进 tests/,在项目根目录跑(conftest.py 让 tests/ 能 import 到根目录的 minagent 包):

$ pytest -q
......                                                                   [100%]
6 passed in 0.07s

-q 只报一行摘要,绿灯时最省心。要看每条用例的名字,用 -v:

tests/test_trajectory.py::test_asserts_tool_order_args_and_final PASSED  [ 16%]
tests/test_trajectory.py::test_asserts_what_the_model_saw PASSED         [ 33%]
tests/test_trajectory.py::test_tool_exception_is_reflowed PASSED         [ 50%]
tests/test_trajectory.py::test_unknown_tool_is_reflowed PASSED           [ 66%]
tests/test_trajectory.py::test_injected_max_tokens_response PASSED       [ 83%]
tests/test_trajectory.py::test_non_convergence_raises PASSED             [100%]

这六条覆盖了收敛、工具顺序与参数、模型视角、工具异常回灌、未知工具回灌、以及不收敛护栏。它们的全部运行时间不到 0.1 秒,且永远免费。 这就是把模型换成假模型换来的东西:一套可以挂在每次提交前、随时跑、绝不误报的回归网。

16.1.6 别只断言最终答复

初学者最容易写出的测试长这样:

result = run(model, reg, "计算 (2+3)*4")
assert result.final == "结果是 20。"

这条断言能过,但它几乎没有价值——因为最终答复对了,不代表 Agent 做对了。脚本是我们自己写的,终值当然对。真正要回归的是过程:

只断言终值同时断言轨迹
换了工具也能过工具名一变就红
参数填错也能过(结果碰巧对)参数一变就红
少走一步、多绕两圈看不出来步骤形态一变就红
把错误路径悄悄走通看不出来失败注入用例会抓住

举例:如果哪天有人把注册表里 add 的实现错写成"返回 a-b",只断言终值的用例照样通过(因为终值是脚本写死的),而断言"add 被调用且参数为 {a:2,b:3}“的用例也可能通过——因为工具实现变了但调用没变。要抓住这个 bug,得再加一条断言工具返回值的用例:assert "5" in result.steps[0].detail。这正是"轨迹断言"的层次感:终值、调用、参数、返回值,一层比一层细,按需叠加。

一句话原则:断言你真正在乎的东西,而不是最容易断言的东西。 Agent 的价值在过程,测试就该盯着过程。

16.1.7 本节小结

  • Agent 测试必须把模型换成假模型,否则不可复现、要花钱、有随机性——一个会随机失败的测试比没有测试更糟。
  • 可断言的四类对象:工具名与顺序、工具参数、步骤形态、最终答复;展示用 steps,断言用 messages。
  • FakeModel.calls 记录模型视角,用来断言"循环把什么喂给了模型”。
  • 失败路径必须覆盖:工具抛异常(错误回灌)、脚本注入反常响应、点名未知工具、脚本耗尽不收敛。
  • 六条用例不到 0.1 秒跑完、永远免费,可以挂在每次提交前当回归网。

16.1.8 真实模型下会怎样

上面的测试换掉后端就能跑真实调用——run 一行不用改。但真实模型下,result.final == "结果是 20。" 这种断言必然失败:措辞每次不同,stop_reason、usage 也都变。所以真实模型上只能做冒烟测试:断言"有答复、没有异常、usage 非空",不比对具体文本。

正确的姿势是两层测试:确定性回归用 FakeModel(快、免费、可复现),真实调用只做冒烟(验证"接口通、格式对")。第 18 章末尾的可选真实 API 冒烟就按这个分层设计。

还有一个真实模型特有的陷阱:工具调用可能并发。假模型脚本里一次只发一个 ToolCall,断言"顺序"很干净;真实模型常在一轮里同时发多个调用(第 4.3 节),此时"顺序"这个概念本身就需要重新定义——是脚本里的书写顺序,还是执行完成的顺序?写并发相关的断言时,这一点必须先想清楚,否则测试会变得脆弱。

一句话:确定性测试回答"逻辑对不对",冒烟测试回答"接口通不通"。前者是回归网,后者是保险丝,两者不可互相替代。下一节 16.2 评测集与打分 会把这套思路从"单条断言"升级成"一批用例 + 一个分数"。

阅读导航:上一节:15.3 成本与用量核算 · 下一节:16.2 评测集与打分 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距