Agent 工程体系 · 第 86/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 测试与重放:模型替身、工具 Stub、确定性、录制和回归
Agent 测试不能只断言“最终回答是否包含某句话”。一个 Agent 通常要经历多轮模型调用、工具选择、参数生成、工具执行、错误恢复、状态更新,甚至还包括 Agent 之间的交接。最终文本只是这条执行链的一个投影,无法单独说明中间步骤是否正确。
因此,Agent 测试需要同时解决五个问题:
- 模型替身:如何在不依赖真实模型的情况下,测试编排逻辑和错误路径。
- 工具 Stub:如何模拟外部工具,使测试可控、可重复并能主动注入故障。
- 确定性:哪些结果必须逐字相同,哪些结果只能按结构或语义判断。
- 录制和重放:如何把一次真实执行保存下来,并在代码变更后离线复现。
- 回归:如何判断模型、Prompt、工具、Memory 或编排代码的变化是否破坏了已有行为。
OpenAI 的 Agent 评测文档将 trace、grader、dataset 和 eval run 作为不同层次的评测面:调试阶段先观察完整 trace,行为稳定后再沉淀为可重复的数据集和评测运行。(developers.openai.com)
一、先定义被测试的对象:Agent 是一个带外部效应的状态机
将 Agent 简化为一次函数调用:
其中 是用户输入, 是最终输出。这种模型适合测试纯函数,却不适合测试 Agent,因为 Agent 的执行过程还会读写状态、调用工具并受到外部环境影响。
更准确的模型是:
其中:
- :第 步开始时的 Agent 状态;
- :模型在该步产生的决策,例如文本输出或工具调用;
- :工具返回值或工具异常;
- :环境因素,例如时间、随机数、数据库状态、网络结果;
- :该步骤产生的事件;
- :Agent 的状态转移逻辑。
一次完整运行可以表示为:
这里的 称为轨迹,也就是一次运行中模型调用、工具调用、工具结果、状态变更、交接和终态的有序记录。
例如,订单退款 Agent 可能产生如下轨迹:
用户输入
-> 模型决定查询订单
-> 调用 get_order(order_id="A100")
-> 返回订单状态=已支付
-> 模型决定检查退款资格
-> 调用 check_refund(order_id="A100")
-> 返回 eligible=true
-> 模型决定发起退款
-> 调用 create_refund(order_id="A100", amount=19900)
-> 返回 refund_id="R900"
-> 最终回复“退款已提交”
这个例子中,最终回复正确并不代表执行正确。以下行为都可能是缺陷:
- 没有先查询订单,直接根据用户输入发起退款;
- 查询了订单,却把
order_id传成了用户昵称; - 退款金额不是订单实际金额;
- 工具返回超时后,Agent 重试了一个非幂等操作;
- 工具已经成功,但模型没有识别成功结果,又重复发起退款;
- 最终文本声称退款成功,但工具只返回了“已受理”。
所以测试对象不是单一的文本,而是:
这四项不是简单相加的数学评分,而是四类必须分别观察的性质。
二、测试隔离的基本结构:替换模型,模拟工具,保留编排
一次 Agent 测试至少包含四个可替换边界:
flowchart LR
I[测试输入] --> O[Agent 编排器]
O --> M[模型接口]
O --> T[工具接口]
O --> S[会话与 Memory]
M --> O
T --> O
S --> O
O --> R[最终结果]
O --> Tr[结构化轨迹]
Tr --> G[断言与 Grader]
1. 模型接口
模型接口负责根据当前消息和工具定义产生下一步决策。测试时可以替换为:
- 固定模型替身:按预先写好的序列返回结果;
- 规则模型替身:根据输入匹配规则,动态生成结果;
- 录制模型替身:从历史记录中按请求指纹返回响应;
- 真实模型:用于集成测试、抽样测试和线上回归。
2. 工具接口
工具接口负责访问外部世界,例如数据库、支付系统、搜索服务或内部 HTTP API。测试时不能让单元测试直接访问真实生产系统,而应注入 Stub、Fake 或 Replay 工具。
3. 状态与 Memory
会话历史、用户画像、短期记忆和长期记忆也属于输入的一部分。如果测试只固定用户问题,却不固定 Memory,测试结果仍可能变化。
4. 轨迹收集器
收集器把每次模型调用和工具调用转换为结构化事件。它不是“打印日志”,而是测试判断所依赖的数据源。
三、模型替身:测试的是编排,不是再次测试模型
模型替身是对真实模型接口的可控替代实现。它可以返回预先指定的文本、结构化输出或工具调用。
模型替身的作用不是假装模型很聪明,而是把模型行为固定下来,从而隔离以下问题:
- 工具是否正确注册;
- 工具调用参数是否被正确解析;
- 工具结果是否正确放回上下文;
- 错误是否触发重试或降级;
- Agent 是否在正确条件下结束;
- 状态是否按预期更新。
3.1 一个最小的模型协议
下面使用一个框架无关的 Python 示例。模型只返回两种决策:
from dataclasses import dataclass
from typing import Any, Literal
@dataclass
class ModelDecision:
kind: Literal["tool_call", "final"]
name: str | None = None
arguments: dict[str, Any] | None = None
text: str | None = None
模型接口可以抽象为:
class Model:
def complete(self, messages, tools) -> ModelDecision:
raise NotImplementedError
固定模型替身保存一个响应队列:
class ScriptedModel(Model):
def __init__(self, decisions: list[ModelDecision]):
self.decisions = list(decisions)
self.calls = []
def complete(self, messages, tools) -> ModelDecision:
self.calls.append({
"messages": messages,
"tools": tools,
})
if not self.decisions:
raise AssertionError("模型替身没有预设下一步决策")
return self.decisions.pop(0)
它的关键性质是:每次调用都消耗一个明确的决策。如果 Agent 多调用了一次模型,测试会立即失败,而不是静默返回一个看似合理的答案。
3.2 为什么不能只固定最终输出
假设测试只配置模型替身返回:
退款已提交,退款单号为 R900。
那么以下错误都可能通过测试:
1. Agent 没有调用任何工具。
2. Agent 调用了错误的工具。
3. Agent 使用了错误订单号,但最终文本仍然是固定字符串。
4. Agent 发起了两次退款。
更有价值的模型替身应返回工具调用:
model = ScriptedModel([
ModelDecision(
kind="tool_call",
name="get_order",
arguments={"order_id": "A100"},
),
ModelDecision(
kind="tool_call",
name="check_refund",
arguments={"order_id": "A100"},
),
ModelDecision(
kind="tool_call",
name="create_refund",
arguments={"order_id": "A100", "amount": 19900},
),
ModelDecision(
kind="final",
text="退款已提交,退款单号为 R900。",
),
])
这样,编排器必须按顺序处理每个中间结果,测试才能观察到真实的状态转移。
3.3 模型替身的三种严格程度
严格队列
按调用顺序逐个返回响应。
优点是能检测额外调用、漏调用和顺序变化;缺点是对消息格式变化敏感。
适合:
- 单元测试;
- 重试逻辑;
- 工具编排;
- 状态机测试。
请求匹配
根据请求的规范化内容查找响应:
class MappingModel(Model):
def __init__(self, mapping):
self.mapping = mapping
def complete(self, messages, tools):
key = canonical_request(messages, tools)
try:
return self.mapping[key]
except KeyError:
raise AssertionError(f"未录制的模型请求: {key}")
适合:
- 并发执行;
- 允许某些无关字段变化;
- 录制重放。
规则模型
例如当工具结果中 eligible=false 时返回人工转接,否则发起退款。
适合:
- 大量属性测试;
- 故障矩阵;
- 不想为每个分支手写完整响应序列的场景。
但规则模型不能证明真实模型会做出同样决策。它验证的是编排器对该决策的处理能力,而不是模型本身的可靠性。
四、工具 Stub:不是“返回一个假数据”,而是模拟协议和故障
工具 Stub是测试中对真实工具的替代实现。一个合格的 Stub 至少应明确:
- 输入参数是否合法;
- 调用次数是否符合预期;
- 返回值的结构和语义;
- 是否修改外部状态;
- 在什么条件下抛出什么错误;
- 重试时是否返回相同结果。
订单退款示例:
class ToolStub:
def __init__(self):
self.calls = []
self.refunds = []
def get_order(self, order_id: str):
self.calls.append(("get_order", {"order_id": order_id}))
if order_id != "A100":
return {"found": False}
return {
"found": True,
"order_id": "A100",
"status": "paid",
"total_amount": 19900,
"currency": "CNY",
}
def check_refund(self, order_id: str):
self.calls.append(("check_refund", {"order_id": order_id}))
return {
"order_id": order_id,
"eligible": True,
"reason": None,
}
def create_refund(self, order_id: str, amount: int):
self.calls.append((
"create_refund",
{"order_id": order_id, "amount": amount},
))
if any(r["order_id"] == order_id for r in self.refunds):
raise RuntimeError("duplicate_refund")
refund = {
"refund_id": "R900",
"order_id": order_id,
"amount": amount,
"status": "submitted",
}
self.refunds.append(refund)
return refund
这里有一个重要区别:
- Stub 只返回
{"refund_id": "R900"},只能测试成功路径; - Stub 维护
self.refunds,才能暴露重复退款; - Stub 记录
calls,才能断言工具选择、参数和调用次数。
4.1 Stub、Fake、Mock 和 Replay 的边界
这些术语经常混用,但测试关注点不同:
- Stub:提供预设响应,重点是让被测代码继续运行;
- Fake:提供一个简化但有内部状态的实现,例如内存数据库;
- Mock:除了返回响应,还记录调用并验证交互契约;
- Replay:从历史录制中恢复某次真实交互。
同一个测试替身可以同时具备多种性质。例如上面的 ToolStub 既是 Stub,也是带状态的 Fake,还可以作为 Mock 验证调用。
4.2 工具 Stub 必须模拟失败
真实系统中的重要缺陷通常只在失败路径出现。至少应覆盖:
class FailingToolStub(ToolStub):
def __init__(self, fail_on: str):
super().__init__()
self.fail_on = fail_on
def check_refund(self, order_id: str):
self.calls.append(("check_refund", {"order_id": order_id}))
if self.fail_on == "check_refund":
raise TimeoutError("refund_check_timeout")
return super().check_refund(order_id)
然后测试以下性质:
check_refund 超时
-> Agent 可以重试查询
-> 不应调用 create_refund
-> 达到重试上限后应进入可解释的失败终态
如果是非幂等工具,例如扣款、发货或创建退款,Stub 还必须模拟“请求已到达但响应丢失”的情况:
create_refund 请求成功写入外部系统
-> 网络超时
-> Agent 不知道是否成功
此时直接重试可能造成重复操作。正确的测试不是断言“必然重试”,而是验证 Agent 是否使用幂等键、查询原操作状态,或转入人工确认。
五、确定性:固定随机数并不等于 Agent 确定
确定性是指在相同测试输入、相同依赖行为和相同运行条件下,系统产生可重复的结果。
对普通函数,确定性通常意味着:
对 Agent,必须把隐含输入也纳入条件:
其中:
- :用户输入;
- :模型版本及其响应;
- :Prompt 和工具定义;
- :工具及其返回值;
- :会话与 Memory;
- :时间、随机数、网络等环境;
- :并发调度和执行顺序。
只有这些变量全部固定,才有理由期待严格重现。
5.1 确定性的分层
字节级确定性
要求 JSON、轨迹或最终字符串完全相同。
适合:
- 序列化格式;
- 工具参数;
- 状态快照;
- 录制文件;
- 签名或缓存键。
结构级确定性
不要求字段顺序或措辞完全一致,但要求结构相同。
例如:
{
"status": "submitted",
"refund_id": "R900"
}
可以忽略 JSON 字段顺序,但不能忽略 status 和 refund_id。
语义级确定性
允许模型使用不同措辞,但要求语义满足约束:
必须说明:
- 退款已提交,而不是已经到账;
- 包含退款单号;
- 不承诺具体到账时间。
最终文本不应使用简单字符串全等断言,而应使用结构化输出、规则检查或独立 Grader。
5.2 为什么 temperature=0 不是完整解决方案
即使模型采样参数被设置为尽可能确定,以下因素仍可能造成差异:
- 模型服务端版本变化;
- 请求路由到不同模型副本;
- 工具返回顺序变化;
- 当前时间不同;
- 搜索或数据库数据变化;
- 并发任务完成顺序不同;
- Prompt、工具 Schema 或消息拼接顺序变化。
因此,模型调用的确定性应该通过模型替身或录制重放实现;真实模型只适合做统计意义上的质量评测,而不适合承担所有单元测试。
六、规范化:重放前必须定义哪些差异可以忽略
录制和重放依赖请求匹配。直接对原始请求做字符串比较通常不可靠,因为请求中可能包含:
- 时间戳;
- 请求 ID;
- trace ID;
- token 使用统计;
- 不影响语义的字段顺序;
- 动态系统消息;
- 工具结果中的延迟字段。
可以定义规范化函数:
import json
from copy import deepcopy
def canonical_request(messages, tools):
payload = {
"messages": deepcopy(messages),
"tools": deepcopy(tools),
}
# 删除测试中不影响语义的动态字段
for message in payload["messages"]:
message.pop("request_id", None)
message.pop("timestamp", None)
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
但删除字段必须有依据。若 timestamp 被 Agent 用来判断“是否超过退款期限”,就不能删除;若工具 Schema 的描述文字会影响模型决策,也不能把它当成无关字段。
因此,规范化实际上定义了一个等价关系:
表示两个请求虽然字面不同,但在当前测试目标下应视为相同。等价关系定义过宽会掩盖回归,定义过窄会造成大量无意义失败。
七、录制:保存的不只是最终回答
录制是把一次真实执行中的输入、依赖交互、轨迹和结果持久化。最小录制单元应包含:
{
"case_id": "refund-A100",
"input": {
"user_text": "请帮我退掉订单 A100"
},
"environment": {
"timezone": "Asia/Shanghai",
"now": "2026-08-20T10:00:00+08:00"
},
"agent": {
"name": "refund-agent",
"prompt_version": "refund-v12",
"code_version": "git:abc123"
},
"model": {
"provider": "openai",
"model": "model-id",
"request_hashes": ["..."]
},
"events": [
{
"type": "model_decision",
"name": "get_order",
"arguments": {"order_id": "A100"}
},
{
"type": "tool_result",
"name": "get_order",
"output": {
"status": "paid",
"total_amount": 19900
}
}
],
"expected": {
"terminal_status": "submitted",
"required_tools": ["get_order", "check_refund", "create_refund"]
}
}
录制的价值在于保存事实,而不是保存一个人工总结。人工总结“这次退款成功”无法回答:
- Agent 是否使用了订单真实金额;
- 是否多调用了一次工具;
- 是否在工具超时后错误重试;
- 哪一版 Prompt 导致了参数变化。
7.1 录制边界
常见有三种录制方式:
只录模型调用
工具仍访问测试环境。
适合测试 Prompt 或模型替换,但不能离线复现工具数据变化。
只录工具调用
模型仍使用真实模型。
适合测试工具协议和外部数据固定,但模型输出仍有随机性。
模型和工具都录
模型、工具、时间和 Memory 全部固定。
适合确定性的回归测试,也是最接近“重放”的形式。
7.2 隐私与数据治理
轨迹可能包含模型输入输出和工具输入输出。OpenAI Agents SDK 的 tracing 文档明确说明,generation_span() 和 function_span() 可能保存敏感数据,可以通过 RunConfig.trace_include_sensitive_data 关闭相关数据捕获;默认值为开启。(openai.github.io)
录制文件因此不能简单当作普通日志处理。至少需要:
- 对用户身份、手机号、地址和订单信息脱敏;
- 对密钥、Cookie 和授权头彻底删除;
- 区分可进入版本库的公开样例与只能存储在受控仓库的生产录制;
- 记录脱敏规则版本;
- 允许按数据主体删除录制。
脱敏不能改变测试语义。例如订单号可能是工具幂等键的一部分,直接替换为随机值可能使录制无法重放。更稳妥的方式是使用稳定映射:
真实用户 U123 -> test-user-001
真实订单 A100 -> test-order-001
同一录制内的引用关系保持不变,敏感值本身不被保存。
八、重放:把外部世界变成可查询的历史
重放是使用录制的依赖响应重新执行 Agent。重放器通常按以下流程工作:
sequenceDiagram
participant C as 测试用例
participant A as Agent
participant M as 模型重放器
participant T as 工具重放器
participant G as 断言器
C->>A: 输入 + 固定状态
A->>M: 规范化模型请求
M-->>A: 录制中的模型决策
A->>T: 工具名 + 参数
T-->>A: 录制中的工具结果
A->>M: 包含工具结果的新请求
M-->>A: 下一步决策
A-->>G: 最终结果 + 新轨迹
G->>G: 比较轨迹、参数、终态和语义
重放有两个容易混淆的模式。
8.1 严格重放
每次模型或工具请求都必须与录制中的请求完全匹配:
请求序号 1:匹配
请求序号 2:匹配
请求序号 3:参数发生变化
测试失败:replay divergence
严格重放适合发现:
- 工具顺序变化;
- 参数字段变化;
- 多余调用;
- 漏调用;
- 上下文拼接变化。
8.2 宽松重放
只根据工具名、关键参数或请求指纹匹配响应。
例如,忽略模型请求中的 token 统计字段,但仍要求:
工具名必须相同;
order_id 必须相同;
amount 必须相同。
宽松重放适合模型服务升级后保留主要行为检查,但它不能替代严格重放。实践中通常需要两套断言:
- 严格轨迹测试,用于编排器和工具协议;
- 宽松语义测试,用于模型或 Prompt 的版本比较。
8.3 重放失败必须区分原因
不能把所有失败都报告成“最终答案不一致”。至少应区分:
ReplayMismatch.ModelRequest
ReplayMismatch.ToolName
ReplayMismatch.ToolArguments
ReplayMismatch.ToolCallCount
ReplayMismatch.ToolResult
ReplayMismatch.TerminalState
ReplayMismatch.SemanticOutput
例如:
{
"type": "ReplayMismatch.ToolArguments",
"step": 3,
"expected": {
"order_id": "A100",
"amount": 19900
},
"actual": {
"order_id": "A100",
"amount": 199
}
}
这个错误直接说明金额单位发生了变化;相比“退款回答不一致”,诊断价值高得多。
九、一个完整的离线测试示例
下面给出一个简化的 Agent 循环:
def run_agent(user_text, model, tools, max_steps=8):
messages = [{"role": "user", "content": user_text}]
events = []
for step in range(max_steps):
decision = model.complete(messages, tools)
events.append({
"type": "model_decision",
"kind": decision.kind,
"name": decision.name,
"arguments": decision.arguments,
"text": decision.text,
})
if decision.kind == "final":
return {
"status": "completed",
"output": decision.text,
"events": events,
}
if decision.kind != "tool_call":
raise AssertionError(f"未知模型决策类型: {decision.kind}")
tool = tools[decision.name]
result = tool(**(decision.arguments or {}))
events.append({
"type": "tool_result",
"name": decision.name,
"output": result,
})
messages.append({
"role": "assistant",
"tool_call": {
"name": decision.name,
"arguments": decision.arguments,
},
})
messages.append({
"role": "tool",
"name": decision.name,
"content": result,
})
return {
"status": "step_limit_exceeded",
"output": None,
"events": events,
}
测试数据和断言:
def test_refund_success():
model = ScriptedModel([
ModelDecision(
kind="tool_call",
name="get_order",
arguments={"order_id": "A100"},
),
ModelDecision(
kind="tool_call",
name="check_refund",
arguments={"order_id": "A100"},
),
ModelDecision(
kind="tool_call",
name="create_refund",
arguments={"order_id": "A100", "amount": 19900},
),
ModelDecision(
kind="final",
text="退款已提交,退款单号为 R900。",
),
])
tool_stub = ToolStub()
result = run_agent(
user_text="请帮我退掉订单 A100",
model=model,
tools={
"get_order": tool_stub.get_order,
"check_refund": tool_stub.check_refund,
"create_refund": tool_stub.create_refund,
},
)
assert result["status"] == "completed"
assert result["output"] == "退款已提交,退款单号为 R900。"
assert tool_stub.calls == [
("get_order", {"order_id": "A100"}),
("check_refund", {"order_id": "A100"}),
("create_refund", {"order_id": "A100", "amount": 19900}),
]
assert len(tool_stub.refunds) == 1
assert tool_stub.refunds[0]["status"] == "submitted"
每个断言验证不同层次:
status验证终态;output验证当前模型替身下的最终输出;tool_stub.calls验证步骤、工具和参数;refunds验证工具状态和副作用。
如果把金额改成 199,最终文本仍可能保持不变,但参数断言会失败。这正是 Agent 测试不能只检查最终文本的原因。
十、故障路径:重试、降级和幂等必须显式测试
10.1 可重试读取操作
对于 get_order 或 check_refund 这类读取操作,超时通常可以重试,但需要限制次数:
第 1 次 check_refund -> TimeoutError
第 2 次 check_refund -> eligible=true
第 3 步 create_refund -> 允许继续
测试应断言:
check_refund 调用次数 = 2
create_refund 调用次数 = 1
最终状态 = submitted
10.2 非幂等写入操作
对于创建退款,以下路径尤其危险:
1. Agent 调用 create_refund(order_id=A100, idempotency_key=K1)
2. 外部系统已经创建 R900
3. 返回响应时网络超时
4. Agent 再次调用 create_refund
如果第二次没有携带相同幂等键,可能创建第二笔退款。
因此,Stub 应记录幂等键:
class IdempotentRefundStub:
def __init__(self):
self.by_key = {}
self.calls = []
def create_refund(self, order_id, amount, idempotency_key):
self.calls.append({
"order_id": order_id,
"amount": amount,
"idempotency_key": idempotency_key,
})
if idempotency_key in self.by_key:
return self.by_key[idempotency_key]
result = {
"refund_id": "R900",
"order_id": order_id,
"amount": amount,
"status": "submitted",
}
self.by_key[idempotency_key] = result
return result
这里的测试重点不是“重试一定成功”,而是:
如果工具协议不支持幂等键,Agent 就不能仅靠 Prompt 避免重复副作用,必须在编排层增加状态查询、人工确认或事务性 Outbox 等机制。
十一、轨迹评测:步骤、参数、效率、恢复和终态分别评分
一次轨迹可以拆成多个评测维度。
11.1 步骤正确性
检查是否选择了必要工具,以及顺序是否满足业务前置条件:
get_order
-> check_refund
-> create_refund
“调用了正确的三个工具”仍不够,因为:
create_refund
-> get_order
可能已经先产生了不可逆副作用。
11.2 参数正确性
检查每个工具调用的参数是否来自可靠上下文:
expected: amount=19900
actual: amount=199
金额、时间、权限范围和用户身份等字段应采用结构化比较,不要把它们埋在自然语言中。
11.3 效率
效率不是简单追求调用次数越少越好,而是要区分必要调用和无效调用:
例如为确认退款状态而调用一次读取工具是必要的;重复三次相同查询则可能是无效步骤。但如果第一次查询超时,第二次重试不应被算作冗余。
11.4 恢复能力
恢复评测检查 Agent 遇到异常后是否:
- 重试可重试操作;
- 不重试不可重试副作用;
- 保留已经获得的状态;
- 向用户说明不确定性;
- 达到上限后进入可诊断终态。
11.5 终态正确性
终态不只是 completed。至少应区分:
submitted 已提交,等待外部系统处理
succeeded 已完成
rejected 业务拒绝
needs_human 需要人工确认
failed 技术失败
unknown 外部副作用状态未知
step_limit 超过最大步骤数
把 submitted 错写成 succeeded 是典型的终态语义缺陷。
十二、真实模型测试与替身测试必须分层
模型替身能保证测试稳定,却无法证明真实模型会产生正确决策。因此测试应分成三层。
第一层:确定性单元测试
依赖全部替换:
模型替身 + 工具 Stub + 固定 Memory + 固定时间
目标是验证:
- 编排状态机;
- 工具协议;
- 参数校验;
- 重试和终态;
- 轨迹记录。
第二层:录制重放回归
使用真实模型和真实工具产生的历史录制,但在 CI 中使用离线响应。
目标是验证:
- Prompt 变更;
- 工具 Schema 变更;
- Memory 拼接变化;
- Agent 代码重构;
- 模型适配层变化。
第三层:真实模型评测
真实调用模型,使用数据集、多个样本和 grader 进行统计评测。目标是观察:
- 工具选择成功率;
- 参数准确率;
- 任务完成率;
- 安全策略违反率;
- 平均步骤数;
- 不同模型或 Prompt 版本的差异。
OpenAI 文档建议在仍处于行为调试阶段时先使用 trace grading;当“什么是好行为”已经明确后,再转为数据集和可重复的 eval runs。(developers.openai.com)
十三、Tracing 与测试轨迹的关系
Tracing是运行时对 Agent 执行过程的结构化观测;测试轨迹是为了断言而保存的执行记录。两者可以共用事件模型,但用途不同:
- tracing 面向调试、监控和生产诊断;
- 测试轨迹面向重放、断言和回归;
- tracing 通常保留更多上下文;
- 测试录制需要更严格的数据脱敏和版本固定。
OpenAI Agents SDK 内置 tracing,会记录模型生成、工具调用、handoff、guardrail 以及自定义事件;一个 trace 表示一次端到端工作流,内部由带有父子关系和时间信息的 spans 组成。(openai.github.io)
SDK 默认会为运行、任务、模型 turn、Agent、模型生成、函数工具、guardrail 和 handoff 等操作建立不同层次的 span;也可以关闭 task 和 turn 层以获得更紧凑的层级。(openai.github.io)
一个最小的 tracing 使用方式如下:
from agents import Agent, Runner, trace
agent = Agent(
name="退款助手",
instructions="只在确认订单和退款资格后发起退款。",
)
async def main():
with trace("refund-regression-case"):
result = await Runner.run(
agent,
"请帮我处理订单 A100 的退款",
)
print(result.final_output)
这段代码的生命周期是:
- 进入
trace(...)上下文,创建一条工作流 trace; Runner.run(...)执行 Agent;- 模型调用和工具调用形成子 span;
- 离开上下文后 trace 完成;
- tracing processor 负责导出记录。
SDK 文档说明,默认的 BatchTraceProcessor 会在后台批量导出 trace,并在进程退出时执行最终 flush;长时间运行的 worker 如果需要在一个任务结束时立即确保导出,可以在 trace 上下文退出后调用 flush_traces()。(openai.github.io)
例如:
from agents import Runner, flush_traces, trace
def run_background_job(agent, prompt):
try:
with trace("background-agent-job"):
result = Runner.run_sync(agent, prompt)
return result.final_output
finally:
# 必须放在 trace 上下文结束之后
flush_traces()
并发场景下,trace 的当前上下文不能依赖一个全局可变变量。SDK 使用 Python contextvar 跟踪当前 trace 和 span,因此并发任务可以自动保持各自的上下文;手动启动和结束 trace 时,则需要正确处理 current trace 的标记和重置。(openai.github.io)
十四、回归测试:比较版本差异,而不是追求永远零差异
回归是判断一次代码、模型、Prompt、工具、Memory 或配置变更是否破坏既有能力。
Agent 回归不能只采用:
旧最终文本 == 新最终文本
更合理的是把结果拆成多个差异集合:
其中:
- :步骤和工具选择变化;
- :参数变化;
- :异常处理变化;
- :终态变化;
- :最终措辞变化。
不同变更允许不同差异:
| 变更类型 | 通常允许 | 通常禁止 |
|---|---|---|
| 文案 Prompt | 文本措辞变化 | 工具参数、终态变化 |
| 工具 Schema | 预期字段迁移 | 未计划的额外副作用 |
| 模型版本 | 语义措辞变化 | 安全策略和关键业务约束退化 |
| Memory 结构 | 上下文格式变化 | 身份、权限、订单绑定错误 |
| 编排代码 | 轨迹重排需有理由 | 重复写操作、错误重试 |
| 性能优化 | 延迟和 token 下降 | 正确性、恢复能力下降 |
每一个被接受的差异都应有原因、评审记录和新的基线,而不能在 CI 中简单把失败样本删除。
十五、常见错误与诊断路径
错误一:模型替身直接返回最终答案
表现:测试全部通过,但线上 Agent 不调用工具或调用错误工具。
诊断:检查测试中是否存在 tool_call 事件和工具调用断言。
修复:让模型替身返回完整的决策序列,并对工具名、参数和次数做断言。
错误二:Stub 永远成功
表现:正常路径覆盖率很高,线上遇到超时、空结果和权限错误就失控。
诊断:统计测试中工具异常返回的比例和类型。
修复:为每个工具定义失败矩阵,包括超时、限流、空结果、部分结果、权限拒绝和未知错误。
错误三:重放只比较最终文本
表现:Agent 多发起了一次退款,但最终文本仍然相同。
诊断:查看 trace 中写操作的调用次数和幂等键。
修复:把副作用工具调用纳入严格轨迹断言。
错误四:把所有字段都做严格比较
表现:无关的时间戳或请求 ID 变化导致大量误报。
诊断:查看 diff 是否只涉及动态元数据。
修复:为测试目标定义规范化规则,但不要删除会影响业务决策的字段。
错误五:把所有差异都归因于模型随机性
表现:工具参数错误、金额单位错误被忽略。
诊断:先固定模型和工具,重新运行;如果仍失败,问题不属于模型采样。
修复:按照模型请求、工具参数、工具结果、状态转移和终态逐层定位。
错误六:录制数据未经版本绑定
表现:同一个录制在不同 Prompt 或工具 Schema 下无法解释。
诊断:检查录制是否记录了 Agent 版本、Prompt 版本、模型标识、工具 Schema 哈希和 Memory 快照。
修复:把这些元数据作为录制协议的一部分,而不是存放在测试文件名中。
十六、发布前的最小闭环
一个可工作的 Agent 测试闭环应满足以下数据流:
真实运行
-> 结构化 trace
-> 选择代表性案例
-> 脱敏并固定版本
-> 生成 replay fixture
-> 本地严格重放
-> CI 回归
-> 真实模型抽样评测
-> 接受或拒绝新基线
其中最重要的不是“录制更多样本”,而是让每个失败都能回答三个问题:
- 哪一步第一次偏离了预期?
- 偏离来自模型决策、工具参数、工具结果还是状态逻辑?
- 这个差异是预期变更,还是未授权回归?
当 Agent 测试具备模型替身、工具 Stub、确定性控制、录制重放和分层回归后,测试对象就从“最后一句话像不像”变成了可观察、可复现、可诊断的状态转移系统。最终回答仍然重要,但它必须放在完整轨迹、工具副作用和业务终态之中判断。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent Judge 与人工评审:Rubric、偏差、校准、一致性和仲裁
- 下一篇:Agent 延迟与成本:TTFT、步骤、Token、工具耗时、预算和降级
- 延伸:Agent 轨迹与工具评测:步骤正确性、参数、效率、恢复和终态
- 延伸:Agent 发布与版本治理:模型、Prompt、Tool、Memory、灰度和回滚
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论