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 评测集与打分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。