Agent 工程体系 · 第 86/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 测试与重放:模型替身、工具 Stub、确定性、录制和回归

Agent 测试不能只断言“最终回答是否包含某句话”。一个 Agent 通常要经历多轮模型调用、工具选择、参数生成、工具执行、错误恢复、状态更新,甚至还包括 Agent 之间的交接。最终文本只是这条执行链的一个投影,无法单独说明中间步骤是否正确。

因此,Agent 测试需要同时解决五个问题:

  1. 模型替身:如何在不依赖真实模型的情况下,测试编排逻辑和错误路径。
  2. 工具 Stub:如何模拟外部工具,使测试可控、可重复并能主动注入故障。
  3. 确定性:哪些结果必须逐字相同,哪些结果只能按结构或语义判断。
  4. 录制和重放:如何把一次真实执行保存下来,并在代码变更后离线复现。
  5. 回归:如何判断模型、Prompt、工具、Memory 或编排代码的变化是否破坏了已有行为。

OpenAI 的 Agent 评测文档将 trace、grader、dataset 和 eval run 作为不同层次的评测面:调试阶段先观察完整 trace,行为稳定后再沉淀为可重复的数据集和评测运行。(developers.openai.com)


一、先定义被测试的对象:Agent 是一个带外部效应的状态机

将 Agent 简化为一次函数调用:

y=f(x)y = f(x)

其中 xx 是用户输入,yy 是最终输出。这种模型适合测试纯函数,却不适合测试 Agent,因为 Agent 的执行过程还会读写状态、调用工具并受到外部环境影响。

更准确的模型是:

(si+1,ei)=T(si,mi,ti,ωi)(s_{i+1}, e_i) = T(s_i, m_i, t_i, \omega_i)

其中:

  • sis_i:第 ii 步开始时的 Agent 状态;
  • mim_i:模型在该步产生的决策,例如文本输出或工具调用;
  • tit_i:工具返回值或工具异常;
  • ωi\omega_i:环境因素,例如时间、随机数、数据库状态、网络结果;
  • eie_i:该步骤产生的事件;
  • TT:Agent 的状态转移逻辑。

一次完整运行可以表示为:

τ=(e0,e1,,en)\tau = (e_0, e_1, \ldots, e_n)

这里的 τ\tau 称为轨迹,也就是一次运行中模型调用、工具调用、工具结果、状态变更、交接和终态的有序记录。

例如,订单退款 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 Quality=Step Correctness+Argument Correctness+Recovery Correctness+Terminal Correctness\text{Agent Quality} = \text{Step Correctness} + \text{Argument Correctness} + \text{Recovery Correctness} + \text{Terminal Correctness}

这四项不是简单相加的数学评分,而是四类必须分别观察的性质。


二、测试隔离的基本结构:替换模型,模拟工具,保留编排

一次 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 至少应明确:

  1. 输入参数是否合法;
  2. 调用次数是否符合预期;
  3. 返回值的结构和语义;
  4. 是否修改外部状态;
  5. 在什么条件下抛出什么错误;
  6. 重试时是否返回相同结果。

订单退款示例:

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 确定

确定性是指在相同测试输入、相同依赖行为和相同运行条件下,系统产生可重复的结果。

对普通函数,确定性通常意味着:

f(x)=f(x)f(x) = f(x)

对 Agent,必须把隐含输入也纳入条件:

R=F(x,M,P,T,S,W,C)R = F( x, M, P, T, S, W, C )

其中:

  • xx:用户输入;
  • MM:模型版本及其响应;
  • PP:Prompt 和工具定义;
  • TT:工具及其返回值;
  • SS:会话与 Memory;
  • WW:时间、随机数、网络等环境;
  • CC:并发调度和执行顺序。

只有这些变量全部固定,才有理由期待严格重现。

5.1 确定性的分层

字节级确定性

要求 JSON、轨迹或最终字符串完全相同。

适合:

  • 序列化格式;
  • 工具参数;
  • 状态快照;
  • 录制文件;
  • 签名或缓存键。

结构级确定性

不要求字段顺序或措辞完全一致,但要求结构相同。

例如:

{
  "status": "submitted",
  "refund_id": "R900"
}

可以忽略 JSON 字段顺序,但不能忽略 statusrefund_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 的描述文字会影响模型决策,也不能把它当成无关字段。

因此,规范化实际上定义了一个等价关系:

r1r2r_1 \sim r_2

表示两个请求虽然字面不同,但在当前测试目标下应视为相同。等价关系定义过宽会掩盖回归,定义过窄会造成大量无意义失败。


七、录制:保存的不只是最终回答

录制是把一次真实执行中的输入、依赖交互、轨迹和结果持久化。最小录制单元应包含:

{
  "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_ordercheck_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

这里的测试重点不是“重试一定成功”,而是:

相同幂等键相同业务结果\text{相同幂等键} \Rightarrow \text{相同业务结果}

如果工具协议不支持幂等键,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 效率

效率不是简单追求调用次数越少越好,而是要区分必要调用和无效调用:

Efficiency=1redundant stepstotal steps\text{Efficiency} = 1 - \frac{\text{redundant steps}} {\text{total steps}}

例如为确认退款状态而调用一次读取工具是必要的;重复三次相同查询则可能是无效步骤。但如果第一次查询超时,第二次重试不应被算作冗余。

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)

这段代码的生命周期是:

  1. 进入 trace(...) 上下文,创建一条工作流 trace;
  2. Runner.run(...) 执行 Agent;
  3. 模型调用和工具调用形成子 span;
  4. 离开上下文后 trace 完成;
  5. 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 回归不能只采用:

旧最终文本 == 新最终文本

更合理的是把结果拆成多个差异集合:

Δ=(Δstep,Δargs,Δrecovery,Δterminal,Δtext)\Delta = (\Delta_{\text{step}}, \Delta_{\text{args}}, \Delta_{\text{recovery}}, \Delta_{\text{terminal}}, \Delta_{\text{text}})

其中:

  • Δstep\Delta_{\text{step}}:步骤和工具选择变化;
  • Δargs\Delta_{\text{args}}:参数变化;
  • Δrecovery\Delta_{\text{recovery}}:异常处理变化;
  • Δterminal\Delta_{\text{terminal}}:终态变化;
  • Δtext\Delta_{\text{text}}:最终措辞变化。

不同变更允许不同差异:

变更类型 通常允许 通常禁止
文案 Prompt 文本措辞变化 工具参数、终态变化
工具 Schema 预期字段迁移 未计划的额外副作用
模型版本 语义措辞变化 安全策略和关键业务约束退化
Memory 结构 上下文格式变化 身份、权限、订单绑定错误
编排代码 轨迹重排需有理由 重复写操作、错误重试
性能优化 延迟和 token 下降 正确性、恢复能力下降

每一个被接受的差异都应有原因、评审记录和新的基线,而不能在 CI 中简单把失败样本删除。


十五、常见错误与诊断路径

错误一:模型替身直接返回最终答案

表现:测试全部通过,但线上 Agent 不调用工具或调用错误工具。

诊断:检查测试中是否存在 tool_call 事件和工具调用断言。

修复:让模型替身返回完整的决策序列,并对工具名、参数和次数做断言。

错误二:Stub 永远成功

表现:正常路径覆盖率很高,线上遇到超时、空结果和权限错误就失控。

诊断:统计测试中工具异常返回的比例和类型。

修复:为每个工具定义失败矩阵,包括超时、限流、空结果、部分结果、权限拒绝和未知错误。

错误三:重放只比较最终文本

表现:Agent 多发起了一次退款,但最终文本仍然相同。

诊断:查看 trace 中写操作的调用次数和幂等键。

修复:把副作用工具调用纳入严格轨迹断言。

错误四:把所有字段都做严格比较

表现:无关的时间戳或请求 ID 变化导致大量误报。

诊断:查看 diff 是否只涉及动态元数据。

修复:为测试目标定义规范化规则,但不要删除会影响业务决策的字段。

错误五:把所有差异都归因于模型随机性

表现:工具参数错误、金额单位错误被忽略。

诊断:先固定模型和工具,重新运行;如果仍失败,问题不属于模型采样。

修复:按照模型请求、工具参数、工具结果、状态转移和终态逐层定位。

错误六:录制数据未经版本绑定

表现:同一个录制在不同 Prompt 或工具 Schema 下无法解释。

诊断:检查录制是否记录了 Agent 版本、Prompt 版本、模型标识、工具 Schema 哈希和 Memory 快照。

修复:把这些元数据作为录制协议的一部分,而不是存放在测试文件名中。


十六、发布前的最小闭环

一个可工作的 Agent 测试闭环应满足以下数据流:

真实运行
  -> 结构化 trace
  -> 选择代表性案例
  -> 脱敏并固定版本
  -> 生成 replay fixture
  -> 本地严格重放
  -> CI 回归
  -> 真实模型抽样评测
  -> 接受或拒绝新基线

其中最重要的不是“录制更多样本”,而是让每个失败都能回答三个问题:

  1. 哪一步第一次偏离了预期?
  2. 偏离来自模型决策、工具参数、工具结果还是状态逻辑?
  3. 这个差异是预期变更,还是未授权回归?

当 Agent 测试具备模型替身、工具 Stub、确定性控制、录制重放和分层回归后,测试对象就从“最后一句话像不像”变成了可观察、可复现、可诊断的状态转移系统。最终回答仍然重要,但它必须放在完整轨迹、工具副作用和业务终态之中判断。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。