《AI Agent 开发入门》附录 B.FakeModel 脚本与测试夹具速查

本附录讲透 FakeModel 的脚本语法与测试夹具用法:三种脚本元素(str、ToolCall 列表、ModelResponse)的语义对照,如何脚本化工具调用收敛、注入错误、制造死循环与格式漂移,如何用 model.calls 断言调用序列,以及把真实响应录成 JSON 夹具离线重放的可运行 pytest 片段。

FakeModel 是卷一最常被复用的一个类:它是一个实现 Model 协议的后端,接收一份「脚本」,按顺序吐出响应,并把每次调用的入参记下来供断言。它是测试夹具 + 教学骨架,不是「假装在调模型」——真实供应商适配器实现同一个接口,两者可互换。

这一页把 FakeModel 的脚本语法、四种常见脚本模式、model.calls 断言法,以及「录一段真实响应、离线重放」的夹具格式全部压成速查。所有 pytest 片段都在本机 Python 3.14.6 + pytest 9.1.1 上实际跑过。

B.1 构造与契约

class FakeModel:
    def __init__(self, script: list[Any], name: str = "fake-1") -> None: ...
    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse: ...
成员类型说明
scriptlist[Any]脚本列表,逐项消费,见 B.2
namestr后端标识,默认 "fake-1",便于多模型对比
callslist[tuple]每次 complete 追加 (消息列表, 工具 schema 列表) 二元组

complete() 每次被调用时:先记一条 calls,再取脚本的下一项。脚本耗尽就 raise RuntimeError("FakeModel 脚本已耗尽:Agent 没有收敛")——这是有意的,脚本没给出终止回复,等于 Agent 没收敛,测试应当失败而不是静默通过。

B.2 三种脚本元素

脚本里每一项只能是下面三种之一,语义各不相同:

脚本项类型产生的响应典型用途
文本strModelResponse(message=Message(role="assistant", content=文本)),stop_reason="end_turn"收敛轮:给出最终答复,循环结束
工具调用list[ToolCall]ModelResponse(message=Message(role="assistant", tool_calls=...), stop_reason="tool_use")中间轮:让 Agent 去执行工具
原样返回ModelResponse就是它本身注入 stop_reason="max_tokens"、自定义 usage、错误响应

关键区别:str 一定收敛,list[ToolCall] 一定继续,ModelResponse 由你完全控制。任何其它类型(None、dict、裸 ToolCall)都会 raise TypeError("不认识的脚本项")。

最容易踩的坑:工具调用轮必须写成列表,哪怕只有一个调用。ToolCall("c1", ...) 是错的,[ToolCall("c1", ...)] 才对。

B.3 四种常用脚本模式

B.3.1 工具调用 → 文本收敛

最常见的两步脚本:先发调用,拿到结果后再给文本。

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

reg = ToolRegistry()
reg.add(Tool("add", "两数相加",
             {"type": "object",
              "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
              "required": ["a", "b"]},
             lambda a, b: a + b))

model = FakeModel([[ToolCall("c1", "add", {"a": 2, "b": 3})], "答案是 5。"])
result = run(model, reg, "2+3?")
assert [s.kind for s in result.steps] == ["tool_call", "final"]
assert result.final == "答案是 5。"

B.3.2 注入错误

用 ModelResponse 原样返回一个带 stop_reason="max_tokens" 的空回复,模拟「模型被截断」。

from minagent import Message, ModelResponse

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

B.3.3 制造死循环

连续发工具调用、永不给文本,就能触发两种护栏:脚本耗尽,或 max_steps 超限。

# 护栏一:脚本耗尽
model = FakeModel([[ToolCall("c1", "add", {"a": 1, "b": 1})]])   # 只有调用,没有终止文本
try:
    run(model, reg, "loop")
except RuntimeError as e:
    assert "脚本已耗尽" in str(e)

# 护栏二:max_steps 超限
calls = [[ToolCall(f"c{i}", "add", {"a": 1, "b": 1})] for i in range(20)]
try:
    run(FakeModel(calls), reg, "loop", max_steps=3)
except RuntimeError as e:
    assert "超过 3 步" in str(e)

B.3.4 制造格式漂移

「格式漂移」指模型点名了一个不存在的工具、或给了对不上的参数。这不会中断循环——ToolRegistry.call 会把错误转成 {"error": ...} 回灌。

# 模型点名了一个没注册的工具 search
model = FakeModel([[ToolCall("c1", "search", {"q": "x"})], "我换个方式回答。"])
result = run(model, reg, "查一下")
assert "未知工具 search" in result.steps[0].detail
assert result.final == "我换个方式回答。"

参数对不上同理:func 收到未声明的关键字会抛 TypeError,被 call() 包成错误 JSON 回灌,轨迹里能看到 TypeError。

B.4 用 model.calls 断言调用序列

model.calls 是「模型视角」的黑匣子:每项是一个 (消息列表, 工具 schema 列表) 二元组,记录了每一轮模型看到了什么。这让「循环有没有把工具 schema 传下去」「历史有没有按预期增长」从主观判断变成可断言事实。

断言含义
[t["name"] for t in model.calls[0][1]] == ["add"]首轮就暴露了 add 的 schema
len(model.calls[0][0]) == 2传了 system 时首轮是 system + user
len(model.calls[1][0]) == 4第二轮多了 assistant + tool 两条
len(model.calls) == 2总共决策了两轮就收敛
model = FakeModel([[ToolCall("c1", "add", {"a": 1, "b": 2})], "3"])
run(model, reg, "1+2?", system="sys")
msgs, tools = model.calls[0]
assert [t["name"] for t in tools] == ["add"]
assert len(msgs) == 2
assert len(model.calls[1][0]) == 4
assert len(model.calls) == 2

B.5 把真实响应录成夹具离线重放

真实 API 不可控也不该在测试里跑。做法是:跑一次真实调用,把「每一轮模型响应」录成一份 JSON 夹具;之后测试只加载夹具,零网络、零凭据、逐字节可复现。

B.5.1 夹具 JSON 结构

{
  "name": "add_two_numbers",
  "turns": [
    {"kind": "tool_call",
     "call": {"id": "c1", "name": "add", "arguments": {"a": 2, "b": 3}}},
    {"kind": "text", "content": "结果是 5。"}
  ]
}
字段说明
name夹具名,会传给 FakeModel(name=...)
turns[].kind"text" / "tool_call" / "response",对应 B.2 的三种元素
turns[].calltool_call 项:{"id", "name", "arguments"}
turns[].contenttext 项:文本内容
turns[].stop_reasonresponse 项可选,默认 "end_turn"
turns[].usageresponse 项可选,token 统计

B.5.2 夹具加载器

import json
from pathlib import Path
from typing import Any

from minagent import FakeModel, Message, ModelResponse, ToolCall


def load_fixture(path: str | Path) -> FakeModel:
    data: dict[str, Any] = json.loads(Path(path).read_text(encoding="utf-8"))
    script: list[Any] = []
    for turn in data["turns"]:
        kind = turn["kind"]
        if kind == "text":
            script.append(turn["content"])
        elif kind == "tool_call":
            c = turn["call"]
            script.append([ToolCall(c["id"], c["name"], c["arguments"])])
        elif kind == "response":
            script.append(ModelResponse(
                message=Message(role="assistant", content=turn.get("content", "")),
                stop_reason=turn.get("stop_reason", "end_turn"),
                usage=turn.get("usage", {}),
            ))
        else:
            raise ValueError(f"未知夹具项:{kind}")
    return FakeModel(script, name=data.get("name", "fixture"))

重放时和普通假模型完全一样:

model = load_fixture("fixtures/add_two.json")
result = run(model, reg, "2+3?")
assert result.final == "结果是 5。"
assert model.name == "add_two_numbers"

B.6 可直接跑的 pytest 片段

把下面的测试存进 tests/test_agent.py,pytest 直接跑。它们覆盖收敛、错误回灌、两种护栏、调用断言与夹具重放。

import json
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"}}},
                 lambda a, b: a + b))
    return reg


def test_tool_then_text_converges():
    model = FakeModel([[ToolCall("c1", "add", {"a": 2, "b": 3})], "答案是 5。"])
    result = run(model, make_registry(), "2+3?")
    assert [s.kind for s in result.steps] == ["tool_call", "final"]
    assert result.final == "答案是 5。"


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_script_exhausted_raises():
    model = FakeModel([[ToolCall("c1", "add", {"a": 1, "b": 1})]])
    with pytest.raises(RuntimeError):
        run(model, make_registry(), "loop")


def test_error_reflow_does_not_interrupt():
    reg = make_registry()

    def boom(a, b):
        raise ValueError("除零")

    reg.add(Tool("div", "相除", {"type": "object", "properties": {}}, boom))
    model = FakeModel([[ToolCall("c1", "div", {"a": 1, "b": 0})], "无法计算。"])
    result = run(model, reg, "1/0")
    assert "ValueError" in result.steps[0].detail
    assert result.final == "无法计算。"


def test_calls_sequence():
    model = FakeModel([[ToolCall("c1", "add", {"a": 1, "b": 2})], "3"])
    run(model, make_registry(), "1+2?", system="sys")
    msgs, tools = model.calls[0]
    assert [t["name"] for t in tools] == ["add"]
    assert len(msgs) == 2
    assert len(model.calls[1][0]) == 4

跑起来的结果是 5 passed。夹具重放再加一条即可:

def test_fixture_replay():
    model = load_fixture("fixtures/add_two.json")
    result = run(model, make_registry(), "2+3?")
    assert result.final == "结果是 5。"

B.7 真实模型下会怎样

真实供应商适配器和 FakeModel 实现同一个 Model 协议,所以上面的测试换掉后端就能跑真实调用——但代价是不确定性:真实模型的措辞、stop_reason、token 数每次都不同,直接断言 result.final == "..." 必然失败。

正确姿势是两层测试:确定性回归用 FakeModel 夹具(快、免费、可复现),真实调用只做「冒烟」——断言「有答复、没有异常、usage 非空」,不比对具体文本。第 16 章的评测集与第 18 章的真实 API 冒烟就按这个分层设计。卷一默认全程走假模型,真实调用只在末尾、且需你明确同意才消耗额度。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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