一、两种"测试"是两码事
| 种类 | 测什么 | 要不要真的调 LLM | 工具 |
|---|---|---|---|
| 单元测试(Unit Test) | Agent 的代码是否按预期工作——工具被调,参数正确,分支走对 | 不要,mock 掉 | TestModel / FunctionModel |
| 效果测试(Eval) | Agent 的回答质量是否达标——跨大量用例的表现 | 要,跑真实模型 | 金标数据集 + 评分器 + Logfire |
两者互补:单测保证逻辑不崩,Eval 保证质量不掉。生产系统两者都要有。
二、TestModel:零成本的"模拟 LLM"
TestModel 是 Pydantic AI 的测试专用 Model——完全不调真 LLM,默认行为是:
- 如果 Agent 有
output_type,它自动根据 schema 造一个合法但内容填充的假数据 - 如果 Agent 有工具,它会挨个调一遍(参数也自动生成)
- 返回一个确定的、可断言的结果
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
class Weather(BaseModel):
city: str
temp: int
agent = Agent("openai:gpt-4o", output_type=Weather)
def test_agent_returns_weather():
with agent.override(model=TestModel()):
result = agent.run_sync("what's the weather")
assert isinstance(result.output, Weather)
assert result.output.city == "a" # TestModel 塞的 placeholder
assert result.output.temp == 0
看似傻——值是 placeholder,测什么?测的是:
- Agent 能构造、能 run、不抛异常
- output_type 能合法生成
- 工具能正常被注册和调用
- Deps 注入机制正确
这是 保证不写错 Agent 本身 的冒烟测试。每次重构必跑。
TestModel 自定义输出
from pydantic_ai.models.test import TestModel
custom = TestModel(custom_output_args={"city": "北京", "temp": 20})
with agent.override(model=custom):
r = agent.run_sync("...")
assert r.output.city == "北京"
assert r.output.temp == 20
三、FunctionModel:自己编排"假 LLM"的每一步
TestModel 只能一次性给固定输出。要测多轮对话 / 多步工具调用,得用 FunctionModel——你写一个 Python 函数,模拟 LLM 每一轮应该返回什么:
from pydantic_ai.models.function import FunctionModel, AgentInfo
from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart, TextPart
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o")
@agent.tool_plain
def lookup_price(sku: str) -> float:
return {"A-1": 9.9, "A-2": 19.9}[sku]
def fake_llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# 看到第 1 轮(用户问话) → 决定调 lookup_price(sku='A-1')
if len(messages) == 1:
return ModelResponse(parts=[
ToolCallPart(tool_name="lookup_price", args={"sku": "A-1"}),
])
# 看到第 2 轮(工具已返回) → 给最终答案
return ModelResponse(parts=[
TextPart(content="A-1 的价格是 9.9 元"),
])
def test_lookup_flow():
with agent.override(model=FunctionModel(fake_llm)):
r = agent.run_sync("帮我查 A-1 多少钱")
assert r.output == "A-1 的价格是 9.9 元"
这相当于手动编排 LLM 的每一步推理。能测:
- 工具调用顺序是否符合预期
- 工具返回值被正确地塞回 messages
- 多轮决策逻辑(下面讲)
四、capture_run_messages:断言工具被正确调用
这是一个很轻量的断言工具——把 run 期间所有消息捕获下来,让你检查:
from pydantic_ai import capture_run_messages
from pydantic_ai.models.test import TestModel
def test_lookup_gets_called():
with capture_run_messages() as messages:
with agent.override(model=TestModel()):
agent.run_sync("...")
# 检查工具确实被 TestModel 调用了
tool_calls = [
part for msg in messages
for part in msg.parts
if part.part_kind == "tool-call"
]
assert any(c.tool_name == "lookup_price" for c in tool_calls)
生产场景里这是最有价值的断言——你可以测"当用户问 XXX,Agent 应该调用 YYY 工具,参数为 ZZZ"。
五、agent.override:同时替换 deps / model / toolsets
前面已经见过多次,完整用法:
with agent.override(
model=TestModel(), # 模型替换
deps=mock_deps, # 依赖替换
toolsets=[mock_toolset], # 工具集替换
):
r = agent.run_sync("...")
override 是上下文管理器——离开 with 自动还原。支持嵌套。
六、pytest 集成:完整测试文件长这样
# tests/test_order_agent.py
import pytest
from pydantic_ai import capture_run_messages
from pydantic_ai.models.test import TestModel
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.messages import ModelResponse, ToolCallPart, TextPart
from myapp.agents import order_agent, Deps
@pytest.fixture
def mock_deps():
class MockDb:
async def fetch(self, sql, *args): return [{"id": 1, "total": 99.0, "status": "paid"}]
return Deps(db=MockDb(), user_id=1)
@pytest.mark.asyncio
async def test_basic_run(mock_deps):
"""冒烟测试:Agent 能跑,结构化输出合法"""
with order_agent.override(model=TestModel(), deps=mock_deps):
r = await order_agent.run("我的订单")
assert r.output is not None
@pytest.mark.asyncio
async def test_calls_list_orders(mock_deps):
"""断言:处理'我的订单'问题时会调用 list_orders 工具"""
with capture_run_messages() as msgs:
with order_agent.override(model=TestModel(), deps=mock_deps):
await order_agent.run("我的订单有哪些")
tool_names = {p.tool_name for m in msgs for p in m.parts if p.part_kind == "tool-call"}
assert "list_orders" in tool_names
@pytest.mark.asyncio
async def test_order_not_found_recovers(mock_deps):
"""用 FunctionModel 模拟:LLM 先查了一个不存在订单,收到 ModelRetry 后改查另一个"""
calls = {"i": 0}
def fake(messages, info):
calls["i"] += 1
if calls["i"] == 1:
return ModelResponse(parts=[ToolCallPart("get_order", {"order_id": 9999})])
if calls["i"] == 2:
return ModelResponse(parts=[ToolCallPart("get_order", {"order_id": 1})])
return ModelResponse(parts=[TextPart("订单 1 已支付")])
with order_agent.override(model=FunctionModel(fake), deps=mock_deps):
r = await order_agent.run("...")
assert "已支付" in r.output
pip install pytest pytest-asyncio,在 pyproject.toml 加 [tool.pytest.ini_options] asyncio_mode = "auto"——就不用每个测试前面写 @pytest.mark.asyncio。
七、Eval:当"输出质量"也要被持续测
单元测试保证"Agent 不崩"。但 Agent 的输出质量——"回答有没有切中要害"、"总结是否准确"——只能用真实模型 + 人工/模型评分来衡量。这就是 Eval。
Eval 的基本结构
- 金标数据集(Golden Set):几十到几千个"输入→期望输出"对。领域专家手动标注或从历史日志筛选。
- Agent 批跑:对每条输入跑 Agent,得实际输出。
- 评分器(Scorer):用规则、BLEU、语义相似度,或"LLM-as-judge"模型判官给分。
- 指标聚合:计算平均分、分布、失败案例列表。
- 回归追踪:每次改代码/换模型都跑一遍,得分不能跌。
最朴素的 Eval:规则判断
GOLDEN = [
{"q": "我的订单 1 状态?", "must_contain": ["paid", "99"]},
{"q": "订单 9999 怎样?", "must_contain": ["不存在"]},
]
async def run_eval():
passed = 0
for case in GOLDEN:
r = await order_agent.run(case["q"], deps=real_deps)
ok = all(k in r.output for k in case["must_contain"])
passed += int(ok)
print("✓" if ok else "✗", case["q"])
print(f"通过率: {passed}/{len(GOLDEN)}")
用 pydantic_evals:Pydantic 官方 Eval 框架
pydantic_evals 是配套子包,结构化做 Eval:
from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import IsInstance, LLMJudge
cases = [
Case(
name="order_1",
inputs={"q": "我的订单 1 状态?"},
expected_output="订单 1 已支付,金额 99",
),
Case(
name="order_missing",
inputs={"q": "订单 9999?"},
expected_output="订单 9999 不存在",
),
]
dataset = Dataset(
cases=cases,
evaluators=[
IsInstance(str),
LLMJudge(
rubric="答案是否准确回答了用户问题,且用词自然。",
model="openai:gpt-4o",
),
],
)
async def target(inputs: dict) -> str:
r = await order_agent.run(inputs["q"], deps=real_deps)
return str(r.output)
report = await dataset.evaluate(target)
report.print(include_input=True, include_output=True)
Report 里会有每 case 的通过/失败、分数、LLM 评分理由。对接 CI 之后,每次合主干自动跑一遍,回归一眼看出。
LLMJudge:用 LLM 给 LLM 打分
LLM-as-judge 是 2024 年以来最成熟的质量评估方法之一——用一个强模型(通常 gpt-4o)按 rubric 给答案打分。比全靠人工省时间,质量比纯规则高。
LLMJudge(
rubric=(
"评估 Agent 的回答质量,0-10 分。"
"考虑:① 事实准确(从提供的订单数据看是否匹配);"
"② 语言自然;③ 是否漏掉关键字段。"
),
model="openai:gpt-4o",
include_input=True,
include_expected_output=True,
)
八、Logfire 追踪:一览每一次 run 的全貌
开发调试 / 生产排障最强的工具是 Logfire(Pydantic 自家的观测平台)。只要加一行,Agent 的每次 run 都会被自动 trace:
import logfire
logfire.configure(token="...") # 一次性配置
logfire.instrument_pydantic_ai() # 全局开启 trace
# 现在所有 Agent.run 自动上报到 Logfire
r = await agent.run("...")
在 Logfire UI 里你能看到:
- 每次 run 的完整消息链(user / assistant / tool call / tool return)
- 每一步的 token、延迟、成本
- 失败的栈、重试历史
- 跨 Agent 的父子调用关系
第 10 章会更系统地讲 Logfire 的生产部署,这里先用起来。
九、CI 集成的落地模板
GitHub Actions 示例——单测不跑真实 API,Eval 在专门的作业跑并用秘钥:
# .github/workflows/agent.yml
name: agent-tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -e . pytest pytest-asyncio
- run: pytest tests/ -v # TestModel/FunctionModel,零 API 调用
eval:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # 只在主干跑
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -e . "pydantic-evals[logfire]"
- run: python -m evals.run_all # 金标数据集跑一遍,失败率过高退出非零
十、八个常见坑
- 单元测试里调真实 LLM:慢、贵、不稳定,CI 炸成筛子。一律用 TestModel/FunctionModel。
- FunctionModel 里忘检查 messages 长度:循环发一样的 ToolCallPart,Agent 卡在循环里。
- capture_run_messages 在 run 之外用:拿到的 messages 是空。必须包住 run。
- override 忘了
with:agent.override(model=TestModel())单独一行——不生效,还是用真实模型。 - Eval 金标只有正例:漏测边界情况(空输入、超长输入、SQL 注入类提示)。金标要涵盖"Agent 应该拒答/澄清"的案例。
- LLMJudge 没固定 model:今天用 gpt-4o,明天换 claude,分数不可比。评分用的模型要锁定并写进报告。
- Eval 阈值太死:要求 100% 通过——LLM 有随机性,永远过不了。用 "≥ 90% 通过 + 历史均值不回落" 的弹性阈值。
- 生产数据直接进 Eval:敏感信息泄漏。必须脱敏后再入金标集。
十一、本章小结
① 单元测试用 TestModel / FunctionModel——零 API 调用,测结构、流程、工具调用顺序。
②
agent.override + capture_run_messages 是测试最常用的一对组合拳。
③ Eval 用真实模型 + 金标数据集 + LLMJudge——回归质量要和功能一样被 CI 盯住。
④ 单测每 PR 跑,Eval 主干跑;两者都写进
pytest,形成一套可持续的质量闸门。