欢迎光临
我们一直在努力

Agent 测试实战:单元测试、集成测试与 Mock LLM 策略

摘要:第一季留下了一个明确的空白——第 33 篇说"第一季最大的空白是评估",而评估的前提是测试。Agent 测试的根本困难是:LLM 有随机性,传统断言(assert result == expected)直接失效。同一个 Prompt 跑两次,输出可能不同;你既不能断言精确字符串,也不能简单地"跑通就算过"。本文讲清三件事:Mock LLM 的四种策略(stub / fake / 录制回放 / 真实调用,各自的适用边界与陷阱)、断言的五种替代方案(结构断言、属性断言、语义相似度、LLM-as-judge、快照回归,以及为什么"精确匹配"在 Agent 场景几乎无用)、三层测试的分工(工具层 100% 确定性覆盖、编排层用 fake 跑通、质量层靠评估而非测试)。并给出 flaky 测试的根因分类与处理原则——这是 Agent 测试的头号敌人,而"失败就重跑"是最糟糕的应对方式。

📌 版本声明:本文基于 Python 3.11+、pytest 8.x、pytest-asyncio 编写,撰写时间 2026 年 10 月。核心断言工具零第三方依赖;语义相似度部分需要 embedding 服务(可替换为任意向量接口)。第 34 篇的录制回放(recorded_llm_call)在本文中作为四种 Mock 策略之一被复用。各测试插件的行为与配置项请以官方文档为准。

适用边界:适用于已有 Agent 实现、需要建立测试体系的工程。若你还在原型阶段、每周都在改 Prompt,那么过早投资测试是浪费——Prompt 天天变,测试天天红。建议的介入时机是"Agent 的核心流程稳定、准备接入真实用户"时。

文章目录

    • 一、为什么 Agent 测试不一样:断言失效
      • 1.1 传统断言为什么在这里失效
      • 1.2 三个常见的错误应对
      • 1.3 正确的思路:把"验证输出"换成"验证性质"
      • 1.4 第一季各篇的核心测试点
    • 二、Mock LLM 的四种策略
      • 2.1 四种策略对比
      • 2.2 策略选择的判据
      • 2.3 实现:Fake LLM
      • 2.4 用 Fake 测重试与上下文裁剪
      • 2.5 Mock 的注入层次
      • 2.6 工具调用的测试:重点是参数,不是返回值
    • 三、断言的五种替代方案
      • 3.1 五种断言法
      • 3.2 结构断言:最基础的一层
      • 3.3 属性断言:抓真正的错误
      • 3.4 语义相似度与 LLM-as-judge
      • 3.5 快照回归
      • 3.6 怎么选
      • 3.7 多轮对话的测试:状态累积比单轮更难
      • 3.8 断言强度自检:三个问题
    • 四、三层测试的分工
      • 4.1 Agent 版的测试金字塔
      • 4.2 工具层:必须 100% 确定性
      • 4.3 编排层:用 Fake 跑通全流程
      • 4.4 质量层:这不是测试,是评估
      • 4.5 三层测试的判据汇总
      • 4.6 Golden Set:质量层的地基
    • 五、Flaky 测试:Agent 测试的头号敌人
      • 5.1 四类根因
      • 5.2 为什么"失败就重跑"是最糟的应对
      • 5.3 正确的处理流程
      • 5.4 什么时候可以接受重跑
      • 5.5 CI 分层执行:让测试套件跑得完
    • 六、适用边界与风险提示 ⚠️
      • 6.1 覆盖率在 Agent 项目里的正确用法
    • 七、一个可复制的测试骨架
    • 八、总结
      • 落地自检清单(Checklist)
      • 常见问题(FAQ)
    • 参考资料

一、为什么 Agent 测试不一样:断言失效

1.1 传统断言为什么在这里失效

一个普通服务的测试长这样:

def test_calculate_discount():
assert calculate_discount(100, vip=True) == 80

确定性输入 → 确定性输出 → 精确断言。这个链条在 Agent 上断了:

def test_review_contract():
result = await review_contract(contract_text)
assert result == ??? # ← 填什么?

你没法填。 因为:

原因说明
LLM 随机性 即使 temperature=0,输出也可能因批处理、浮点非确定性而不同
表述多样性 同一个意思有一百种说法,都是"正确"的
模型会更新 供应商可能悄悄更新模型,输出风格变化
正确性是程度问题 "这个审查报告好不好"是连续值,不是布尔值

1.2 三个常见的错误应对

面对断言失效,团队通常会依次尝试以下三种做法,三种都有问题:

错误一:断言"跑通就算过"

def test_agent_runs():
result = await agent.run("审查这份合同")
assert result is not None # ← 几乎没测任何东西

这只能验证"没有抛异常"。一个返回空字符串的 Agent 也能通过。 这是最常见的"假测试"。

错误二:断言精确字符串

assert result == "该合同存在三条高风险条款:…" # ← 改天就红

第一次会通过,然后模型更新、或气温微调、或 Prompt 改了个标点,测试就红了。这类测试会在两周内被全量删除或加上 # type: ignore 式的绕过。

错误三:失败就重跑

pytest –reruns 3 # ← 掩盖问题

这是最有害的一种。它把"Agent 行为不稳定"这个真实问题,包装成了"测试偶发失败"这个看起来无害的现象。 第 5 章会专门讲为什么这种做法危险。

1.3 正确的思路:把"验证输出"换成"验证性质"

核心转变是:

传统测试:验证**输出等于什么**(value assertion)
Agent 测试:验证**输出具有什么性质**(property assertion)

举例,同样是测试合同审查:

传统思路Agent 思路
断言报告文本 == 某字符串 断言报告是合法 JSON 且符合 schema
断言找出 3 条风险 断言每条风险都引用了原文(不凭空编造)
断言风险等级 == high 断言等级枚举值合法 且 高风险条款确实包含"无限责任"字样
断言整体正确 断言与上一次运行相比没有明显退化(回归)

第三条是关键:你无法断言"结论对",但可以断言"结论有依据"。这类性质叫不变量(invariant)——不管模型怎么答,这些性质都必须成立。

def test_every_risk_cites_source(result):
"""不变量:每条风险必须引用原文——防止模型编造。

这条断言**不管模型给出几条风险、风险等级是什么**都成立,
因此它是稳定的、有意义的、且能抓住真正的错误(幻觉)。
"""
for risk in result["risks"]:
assert risk["quote"] in result["source_text"], \\
f"风险引用了原文中不存在的内容:{risk['quote'][:30]}"

💡 这条断言抓的是 Agent 最危险的失效模式——幻觉。 它比"断言输出等于某个字符串"有用得多,因为它不会因为模型换个说法就失败,却能在模型编造内容时立刻报错。找不变量,是写 Agent 测试最重要的技能。

1.4 第一季各篇的核心测试点

第一季每一篇都写过测试代码,但它们是分散的。这里收拢成一张表——这张表就是你建立测试套件时的需求清单:

篇目核心测试点测的是什么性质
22 Pipeline 阶段顺序、中间态正确传递 流程不跳步
23 MapReduce 部分失败时成功结果被保留 失败是数据不是异常
24 Supervisor 停滞检测触发、终止不依赖 LLM 循环必然终止
25 黑板 冲突可见、陈旧写被拒绝 并发正确性
26 编排引擎 单边遍历上限、编译期校验非法图 循环可控
27 通信协议 因果排序而非时间戳、并发同键只执行一次 幂等与顺序
28 中间件 执行顺序、fail_fast 按中间件生效 顺序可预测
29 事件驱动 幂等 acquire 原子占位、位点提交时机 不重复不遗漏
30 状态管理 非法转移被拒、深拷贝、恢复后跳过已完成 状态正确与恢复省成本
31 网关 三维度限流、熔断三态、降级永不失败 治理有效性
32 Serverless handler 是纯函数、步骤幂等、批次大小约束 可测性与超时预算

观察这张表会发现一个规律:第一季所有值得测的东西,没有一条是"断言模型输出了什么",全部是"断言系统具有什么性质"。

这不是巧合——它恰恰说明 Agent 测试的重心应该放在哪:模型输出不可控,但系统对输出的处理方式是可控的,后者才是测试的领地。

💡 这张表还有个实用价值:它是"测试覆盖自检表"。 如果你的系统用到了某个模式,就去看对应那一行——那条测试有没有写。比如用了事件驱动却没测"并发同键只执行一次",那么幂等在最容易失效的地方是裸奔的。


二、Mock LLM 的四种策略

2.1 四种策略对比

策略做法保真度成本确定性适合
① Stub(固定响应) 直接返回一个写死的字符串 低 零 ✅ 只关心"拿到响应后代码怎么走"
② Fake(规则生成) 按输入规则生成响应 中 零 ✅ 需要根据输入产生不同响应
③ 录制回放 重放真实调用记录 高 零 ✅ 回归测试(第 34 篇)
④ 真实调用 真的调 LLM 最高 钱 + 时间 ❌ 评估、冒烟、录制阶段

四者是互补关系,不是四选一。 一个健康的测试套件里四种都会出现。

2.2 策略选择的判据

你在测什么用哪种
重试逻辑、超时处理、错误分支 ① Stub 或 ② Fake(要能精确控制响应)
状态转移、编排流程 ② Fake(需要不同输入产生不同输出)
“改了代码后行为有没有变” ③ 录制回放
“模型的实际质量如何” ④ 真实调用(但这属于评估,不是测试——见第 6 章)

第四条值得强调:真实调用在测试里的定位是评估和录制,不是日常回归。日常回归用真实调用会导致:测试慢(几十秒)、要花钱、结果不稳定。

2.3 实现:Fake LLM

Fake 是四种策略里最需要设计的——Stub 太简单,录制回放第 34 篇已实现,真实调用不需要设计。

"""Fake LLM:按规则生成响应,可精确控制行为。

设计要点:
1. **可注入场景**(正常/超时/限流/畸形),用于测试错误分支。
2. **响应与输入相关**,否则无法测试"不同输入走不同分支"。
3. **记录所有调用**,便于断言"调用了几次、传了什么"。
"""
from __future__ import annotations

import asyncio
import json
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable

@dataclass
class CallRecord:
"""一次调用记录。"""
model: str
messages: list[dict]
params: dict[str, Any]

class FakeLLM:
"""规则驱动的假 LLM。**零成本、完全确定、可注入异常。**"""

def __init__(self) –> None:
self.calls: list[CallRecord] = field(default_factory=list) if False else []
self._handlers: list[Callable[[str], str | None]] = []
self._scenario: str = "normal" # normal / timeout / ratelimit / malformed

# ———- 场景注入 ———-

def set_scenario(self, scenario: str) –> None:
"""切换行为场景。**测试错误分支的关键。**"""
assert scenario in {"normal", "timeout", "ratelimit", "malformed"}
self._scenario = scenario

# ———- 响应规则 ———-

def on(self, predicate: Callable[[str], bool],
responder: Callable[[str], str]) –> None:
"""注册一条规则:输入满足条件时,用 responder 生成响应。

**后注册的优先级更高**(便于测试里覆盖默认规则)。
"""
self._handlers.insert(0, (predicate, responder))

# ———- 调用入口 ———-

async def chat(self, model: str, messages: list[dict],
**params) –> str:
"""模拟一次 LLM 调用。**签名与真实客户端保持一致。**"""
record = CallRecord(model=model, messages=messages, params=params)
self.calls.append(record)

if self._scenario == "timeout":
await asyncio.sleep(params.get("mock_timeout_s", 999))
if self._scenario == "ratelimit":
raise RuntimeError("429 rate limited")
if self._scenario == "malformed":
return "{ 这不是合法 JSON"

prompt = messages[–1]["content"] if messages else ""
for predicate, responder in self._handlers:
if predicate(prompt):
return responder(prompt)
return f"[fake:{model}] 默认响应(输入 {len(prompt)} 字)"

# ———- 断言辅助 ———-

def call_count(self) –> int:
return len(self.calls)

def last_prompt(self) –> str:
return self.calls[–1].messages[–1]["content"] if self.calls else ""

def prompts_containing(self, keyword: str) –> list[str]:
"""断言"某段信息有没有被放进上下文"——第 24 篇上下文裁剪的核心检查。"""
return [c.messages[–1]["content"] for c in self.calls
if keyword in c.messages[–1]["content"]]

三个设计要点:

① self.calls 记录所有调用。 这让你可以断言"调用了几次"和"上下文里有什么"。前者用于测试重试与缓存,后者用于测试第 24 篇的上下文裁剪——比如断言"给网络专家的上下文里没有数据库指标"。

② 场景注入是测试错误分支的唯一实用方式。 录制回放无法模拟超时(录的是结果不是过程),而 set_scenario("timeout") 可以。

③ on() 的规则注册顺序是"后注册优先"。 这样测试里可以先注册一个宽泛的默认规则,再针对具体用例覆盖它。

2.4 用 Fake 测重试与上下文裁剪

两个真实场景的测试示例:

import pytest

from fake_llm import FakeLLM

@pytest.mark.asyncio
async def test_retry_stops_after_max_attempts():
"""重试:限流后重试至上限,然后放弃(不无限重试)。"""
llm = FakeLLM()
llm.set_scenario("ratelimit")

with pytest.raises(RuntimeError):
await call_with_retry(llm, max_retries=3)

# **关键断言:重试次数正好是 1 + 3,不是无限**
assert llm.call_count() == 4

@pytest.mark.asyncio
async def test_expert_context_excludes_irrelevant_metrics():
"""上下文裁剪:给网络专家的参数里不能出现数据库指标(第 24 篇)。"""
llm = FakeLLM()
llm.on(lambda p: "链路" in p, lambda p: '{"layer_ok": true, "findings": []}')

await network_expert.diagnose(
llm,
context={"network_metrics": {"latency_ms": 800},
"db_metrics": {"slow_queries": 42}}, # ← 不该出现在网络专家的输入里
)

prompt = llm.last_prompt()
assert "latency_ms" in prompt # 相关信息在
assert "slow_queries" not in prompt # **无关信息被裁掉了**

第二个测试守的是第 24 篇最核心的一个洞察:把无关上下文喂给专家,它会开始怀疑错误的方向——“给网络专家看数据库指标,它就会开始怀疑数据库”。这个错误不会报错,只会让结论跑偏,因此必须用测试锁住。

2.5 Mock 的注入层次

Mock 可以注入在三个层次,选哪层很重要:

层次做法测到了什么没测到什么
函数层 替换你自己封装的 call_llm() 你的业务逻辑 真实 SDK 的行为(重试、超时、参数校验)
SDK 层 替换 SDK 的 client 方法 你的逻辑 + 部分 SDK 行为 HTTP 层的细节
HTTP 层 指向 Mock 服务(第 34 篇 5.6 节) 全部,含 SDK 的真实行为 无

拍板建议:日常用函数层(快、简单、稳定),但需要专门补一组 HTTP 层的测试来验证"真实 SDK + 真实错误码"的组合。因为超时和 429 的处理逻辑往往在 SDK 内部,你在函数层 Mock 掉 SDK,就把这部分行为也一起 Mock 掉了——测了个寂寞。

下面这条时序图展示了两个层次的差异:

上游 API

真实 SDK

Mock 层

Agent 逻辑

测试

上游 API

真实 SDK

Mock 层

Agent 逻辑

测试

#mermaid-svg-W1v2Oj4iFSr2QiD0{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .error-icon{fill:#552222;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .marker.cross{stroke:#333333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-W1v2Oj4iFSr2QiD0 p{margin:0;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-W1v2Oj4iFSr2QiD0 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-W1v2Oj4iFSr2QiD0 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .sequenceNumber{fill:white;}#mermaid-svg-W1v2Oj4iFSr2QiD0 #sequencenumber{fill:#333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .messageText{fill:#333;stroke:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .labelText,#mermaid-svg-W1v2Oj4iFSr2QiD0 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .loopText,#mermaid-svg-W1v2Oj4iFSr2QiD0 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-W1v2Oj4iFSr2QiD0 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .noteText,#mermaid-svg-W1v2Oj4iFSr2QiD0 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .actorPopupMenu{position:absolute;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-W1v2Oj4iFSr2QiD0 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-W1v2Oj4iFSr2QiD0 .actor-man circle,#mermaid-svg-W1v2Oj4iFSr2QiD0 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-W1v2Oj4iFSr2QiD0 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

函数层 Mock:跳过 SDK

SDK 的重试与超时逻辑未被执行

HTTP 层 Mock:保留 SDK

SDK 的真实重试逻辑被执行

调用业务流程

call_llm() 被替换为 Fake

返回规则生成的响应

调用业务流程

真实 SDK 调用

发往 Mock 服务

返回 429

抛出 SDK 定义的异常

应用层错误处理

2.6 工具调用的测试:重点是参数,不是返回值

工具调用是 Agent 特有的一个测试难点,它有两类失效,且第二类是主要的:

失效表现发现难度
选错工具 该查数据库的却去查了网络 容易(断言工具名即可)
参数生成错误 工具选对了,但参数填错或编造 难(返回值可能依然"成功")

为什么第二类更危险:假设 Agent 要调用 query_order(order_id="ORD-123"),但它编造了一个 order_id="ORD-999"。工具会正常执行并返回空结果——没有报错、没有异常、测试也可能通过,只是答案是错的。

所以工具调用的测试重点不是"返回值对不对"(那是工具层的事),而是**“传进去的参数对不对”**:

@dataclass
class ToolCall:
"""工具调用记录。**用于断言 Agent 选了什么工具、传了什么参数。**"""
name: str
args: dict

class RecordingToolRegistry:
"""记录工具调用,不真正执行。**用于测试 Agent 的决策而非工具本身。**"""
def __init__(self) –> None:
self.calls: list[ToolCall] = []

def register(self, name: str, fn=None, *, result=None):
def _wrap(**kwargs):
self.calls.append(ToolCall(name=name, args=kwargs))
return result if result is not None else f"[mock:{name}]"
return _wrap

@pytest.mark.asyncio
async def test_agent_passes_real_order_id_not_invented():
"""参数必须来自上下文,不能凭空编造。"""
tools = RecordingToolRegistry()
agent = build_agent(tools={"query_order": tools.register("query_order", result={"status": "ok"})})

await agent.run("ORD-123 这个订单现在什么状态?")

assert tools.calls[0].name == "query_order"
# **关键断言:参数值必须出现在用户的问题里**
assert tools.calls[0].args["order_id"] == "ORD-123"

"参数值必须出现在用户问题里"这条断言可以泛化成一个通用检查:

def assert_args_grounded(call: ToolCall, context: str) –> None:
"""参数值必须能在上下文里找到出处——防编造。

适用范围:ID、日期、金额、名称这类"事实型"参数。
**不适用于**:模型自己生成的自由文本(比如摘要内容)。
"""
FACTUAL_KEYS = {"id", "order_id", "user_id", "date", "amount", "name"}
for key, value in call.args.items():
if key in FACTUAL_KEYS or key.endswith("_id"):
assert str(value) in context, \\
f"参数 {key}={value!r} 在上下文中找不到出处,可能是编造的"

⚠️ 这条检查有个重要前提:它只适用于"事实型"参数。 如果参数值是模型生成的自由文本(比如"给客户回复的草稿内容"),那它本来就不该在上下文里。误用这条规则会产生大量假失败——判断标准是"这个值是用户提供的,还是模型产生的"。


在这里插入图片描述

图1:Mock LLM 四种策略——Stub、Fake、录制回放、真实调用的保真度与适用边界


三、断言的五种替代方案

3.1 五种断言法

既然不能精确匹配,用什么替代?五种方法,按强度和成本排序:

#方法做法强度成本稳定性
1 结构断言 断言输出符合 JSON Schema / 字段齐全 低但稳 零 ✅✅
2 属性断言(不变量) 断言某些性质必然成立 中高 零 ✅✅
3 语义相似度 embedding 余弦相似度 > 阈值 中 低 ✅
4 LLM-as-judge 用另一个模型判断是否符合要求 高 中 ⚠️
5 快照回归 与上次运行对比,人工确认差异 中 低 ✅

3.2 结构断言:最基础的一层

import jsonschema

RISK_REPORT_SCHEMA = {
"type": "object",
"required": ["risks", "summary"],
"properties": {
"risks": {
"type": "array",
"items": {
"type": "object",
"required": ["clause_index", "level", "reason", "quote"],
"properties": {
"level": {"enum": ["low", "medium", "high"]},
"clause_index": {"type": "integer", "minimum": 0},
},
},
},
"summary": {"type": "string", "minLength": 10},
},
}

def test_report_matches_schema(result):
"""结构断言:**不管内容对不对,格式必须先对。**

这一层能抓住大量实际错误:模型返回了散文而不是 JSON、
枚举值拼错、缺字段。**它是最便宜且最不该省的一层。**
"""
jsonschema.validate(result, RISK_REPORT_SCHEMA)

💡 结构断言的价值被严重低估。 它看起来只是"验格式",但实际能抓住 Agent 最常见的失效之一——模型没按要求的格式输出。而格式错误会导致下游解析失败,这是生产事故的常见起点。每个有结构化输出的 Agent 都应该有 schema 断言。

3.3 属性断言:抓真正的错误

1.3 节已经给了"每条风险必须引用原文"的例子。再给三个实用的不变量:

def test_no_invented_clause_numbers(result, source):
"""不变量:引用的条款编号必须在原文范围内——防幻觉。"""
max_index = count_clauses(source)
for risk in result["risks"]:
assert 0 <= risk["clause_index"] < max_index

def test_high_risk_requires_evidence(result):
"""不变量:高风险必须有具体依据,不能只说"有风险"。"""
for risk in result["risks"]:
if risk["level"] == "high":
assert len(risk["reason"]) >= 20, "高风险需要给出具体理由"

def test_summary_consistent_with_risks(result):
"""不变量:摘要里的数量与实际风险条数一致——防前后矛盾。"""
import re
mentioned = re.findall(r"(\\d+)\\s*条风险", result["summary"])
if mentioned:
assert int(mentioned[0]) == len(result["risks"])

第三条能抓住一类很真实的错误:模型在摘要里说"共 5 条风险",但 risks 数组里只有 3 条。这种前后不一致在 LLM 输出里非常常见,而它会被用户直接看到。

3.4 语义相似度与 LLM-as-judge

当前两种方法不够时(比如要验证"摘要是否涵盖了关键点"),用相似度或裁判:

import math

def cosine(a: list[float], b: list[float]) –> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0

async def assert_semantically_close(embed, actual: str, expected: str,
threshold: float = 0.82) –> None:
"""语义相似度断言。**阈值必须自己校准,不要照抄 0.82。**"""
sim = cosine(await embed(actual), await embed(expected))
assert sim >= threshold, f"语义相似度 {sim:.3f} 低于阈值 {threshold}"

JUDGE_PROMPT = """判断下面的回答是否满足要求。只回答 PASS 或 FAIL,并给出一句话理由。

要求:{requirement}

回答:{answer}

格式:PASS|FAIL —— 理由
"""

async def assert_judge(llm, requirement: str, answer: str) –> None:
"""LLM-as-judge。**用于无法用规则表达的质量判断。**"""
verdict = await llm.chat("judge", [
{"role": "user", "content": JUDGE_PROMPT.format(
requirement=requirement, answer=answer)}
], temperature=0)
assert verdict.strip().startswith("PASS"), f"裁判判定不通过:{verdict}"

使用 LLM-as-judge 的三条纪律:

纪律说明
裁判模型要与被测模型不同 同一个模型评判自己,会系统性偏向自己的风格
temperature 必须为 0 裁判本身不能随机,否则测试会 flaky
裁判 Prompt 要定期验证 裁判也会不准——用一批人工标注的样本定期校验裁判的准确率

⚠️ 第三条最重要也最容易漏。 LLM-as-judge 看起来很美好,但裁判本身的准确率通常在 70%~90% 之间——这意味着它会误判。如果从不验证裁判,你会得到一个"看起来在测质量、实际在随机报错"的测试。建议每季度用 50~100 条人工标注样本校准一次裁判。

3.5 快照回归

def test_prompt_snapshot(regenerate: bool = False):
"""提示词快照:Prompt 变更必须被显式确认。

**这不是"正确性"测试,是"变更感知"测试**——
它保证没有人误改 Prompt,但不判断改得好不好。
"""
from my_agent.domain.contract_review.prompts import REVIEW_SYSTEM_V1
current = REVIEW_SYSTEM_V1
path = Path("tests/snapshots/review_system.txt")

if regenerate or not path.exists():
path.write_text(current, encoding="utf-8")
return

assert current == path.read_text(encoding="utf-8"), (
"提示词已变更。如果是有意修改,请重新生成快照并确认质量未退化。"
)

💡 快照测试在 Agent 项目里比在普通项目里更有价值。 因为 Prompt 是"代码之外的行为定义",它的变更不会引起任何类型错误或测试失败,却会显著改变 Agent 行为。快照让 Prompt 变更变成一个必须显式确认的动作。

3.6 怎么选

#mermaid-svg-PuZ48UHKK6ywsTc3{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PuZ48UHKK6ywsTc3 .error-icon{fill:#552222;}#mermaid-svg-PuZ48UHKK6ywsTc3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PuZ48UHKK6ywsTc3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .marker.cross{stroke:#333333;}#mermaid-svg-PuZ48UHKK6ywsTc3 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PuZ48UHKK6ywsTc3 p{margin:0;}#mermaid-svg-PuZ48UHKK6ywsTc3 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .cluster-label text{fill:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .cluster-label span{color:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .cluster-label span p{background-color:transparent;}#mermaid-svg-PuZ48UHKK6ywsTc3 .label text,#mermaid-svg-PuZ48UHKK6ywsTc3 span{fill:#333;color:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .node rect,#mermaid-svg-PuZ48UHKK6ywsTc3 .node circle,#mermaid-svg-PuZ48UHKK6ywsTc3 .node ellipse,#mermaid-svg-PuZ48UHKK6ywsTc3 .node polygon,#mermaid-svg-PuZ48UHKK6ywsTc3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .rough-node .label text,#mermaid-svg-PuZ48UHKK6ywsTc3 .node .label text,#mermaid-svg-PuZ48UHKK6ywsTc3 .image-shape .label,#mermaid-svg-PuZ48UHKK6ywsTc3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-PuZ48UHKK6ywsTc3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .rough-node .label,#mermaid-svg-PuZ48UHKK6ywsTc3 .node .label,#mermaid-svg-PuZ48UHKK6ywsTc3 .image-shape .label,#mermaid-svg-PuZ48UHKK6ywsTc3 .icon-shape .label{text-align:center;}#mermaid-svg-PuZ48UHKK6ywsTc3 .node.clickable{cursor:pointer;}#mermaid-svg-PuZ48UHKK6ywsTc3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .arrowheadPath{fill:#333333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PuZ48UHKK6ywsTc3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PuZ48UHKK6ywsTc3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PuZ48UHKK6ywsTc3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PuZ48UHKK6ywsTc3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .cluster text{fill:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 .cluster span{color:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PuZ48UHKK6ywsTc3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PuZ48UHKK6ywsTc3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-PuZ48UHKK6ywsTc3 .icon-shape,#mermaid-svg-PuZ48UHKK6ywsTc3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PuZ48UHKK6ywsTc3 .icon-shape p,#mermaid-svg-PuZ48UHKK6ywsTc3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PuZ48UHKK6ywsTc3 .icon-shape .label rect,#mermaid-svg-PuZ48UHKK6ywsTc3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PuZ48UHKK6ywsTc3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PuZ48UHKK6ywsTc3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PuZ48UHKK6ywsTc3 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

能

不能

要

不要

能

不能

简单

复杂

需要断言 Agent 输出

输出是结构化的吗

结构断言 Schema

能找到不变量吗

还要验证内容吗

属性断言 不变量

能表达成语义要求吗

完成

要求复杂吗

快照回归

语义相似度

LLM-as-judge

口诀:能用规则就用规则,规则不行才用模型。 语义相似度和 LLM-as-judge 都很强大,但它们引入了新的不确定性——用它们之前,先确认没有更简单的规则可用。

3.7 多轮对话的测试:状态累积比单轮更难

单轮测试相对简单,但 Agent 的核心价值往往在多轮——而多轮测试的难点是状态在轮次之间累积,错误也会累积。

三个特有的测试点:

测试点断言什么不测会怎样
上下文不丢失 第 3 轮时,第 1 轮提到的信息仍在上下文里 用户反复重复自己说过的话
不累积无关信息 上下文长度受控,旧信息被压缩或淘汰 Token 成本线性上涨(第 30 篇)
会话状态不被任务污染 任务失败后,用户偏好仍在 第 30 篇事故二:重跑污染偏好

@pytest.mark.asyncio
async def test_context_survives_three_turns():
"""上下文跨轮保留:第 3 轮时仍能引用第 1 轮的信息。"""
llm = FakeLLM()
session = SessionState(session_id="s1")

await agent.chat(session, "我在审查一份采购合同,对方是供应商 A")
await agent.chat(session, "付款条款有什么问题吗")
await agent.chat(session, "那保密条款呢")

prompt = llm.last_prompt()
assert "采购合同" in prompt # 第 1 轮的信息还在
assert "供应商 A" in prompt

@pytest.mark.asyncio
async def test_failed_task_does_not_pollute_session():
"""任务失败后会话状态必须完好(第 30 篇事故二)。"""
llm = FakeLLM()
llm.set_scenario("ratelimit")
session = SessionState(session_id="s1", user_role="法务", turn_index=5)

with pytest.raises(RuntimeError):
await agent.review(session, "某合同文本")

assert session.user_role == "法务" # 偏好没被重置
assert session.turn_index == 5 # 轮次没被改

💡 第二个测试直接对应第 30 篇 1.2 节的真实事故:运维执行"重置任务上下文"时,把用户偏好一起重置了。这个错误的根源是"生命周期不同的数据放在同一个容器里",而测试是唯一能在它复发时立刻报警的手段。

3.8 断言强度自检:三个问题

写完断言后,用三个问题检查它够不够强:

问题如果答"是"说明
① 把被测代码改坏,它会红吗? 不会 断言是摆设(用变异测试验证)
② 模型换一种说法,它会红吗? 会 断言过强,会变得 flaky
③ 它能描述出"失败原因"吗? 不能 排查成本高,加断言消息

问题①是"够不够严",用 6 章的元测试验证。问题②是"会不会太严"——一个只在措辞完全匹配时才通过的断言,本质上是脆弱的。问题③常常被忽略,但一条 assert len(risks) == 3 失败时只显示 assert 2 == 3,而 assert len(risks) == 3, f"预期 3 条风险,实际 {len(risks)}:{[r['reason'] for r in risks]}" 能直接告诉你发生了什么。

理想的断言应该同时满足三条:能真正失败、不因措辞变化而失败、失败时能说清原因。这三条往往指向同一个方向——断言不变量,而不是断言具体值。

多轮测试的一个实用技巧:把一轮对话封装成 fixture,让测试用例读起来像剧本:

async def script(agent, session, lines: list[str]) –> None:
"""按剧本跑多轮对话。**让测试意图一目了然。**"""
for line in lines:
await agent.chat(session, line)

这样上面的测试可以写成 await script(agent, session, ["我在审查…", "付款条款…", "那保密条款呢"])——测试用例本身就成了可执行的文档。

在这里插入图片描述

图2:Agent 测试的五种断言法——结构、属性、语义相似度、LLM裁判、快照回归的选择依据


四、三层测试的分工

4.1 Agent 版的测试金字塔

传统测试金字塔是"大量单测 + 少量集成 + 极少量 E2E"。Agent 场景需要调整:

/\\
/ \\ 质量层:评估(第 36 篇)——不是测试,是度量
/—-\\
/ 编排层 \\ Fake LLM 跑完整流程,验证协作
/———-\\
/ 工具层 \\ 100% 确定性覆盖,零 Mock
/————–\\

关键变化:工具层的测试比例要远高于传统项目。

层传统占比Agent 占比为什么变了
工具层(确定性代码) 70% 60% 工具是纯代码,且工具出错会直接导致 Agent 行为错误
编排层(流程协作) 20% 30% 分支、重试、状态转移的复杂度更高
质量层(评估) 10%(E2E) 10% 但性质从"测试"变成"评估"

4.2 工具层:必须 100% 确定性

工具是 Agent 里唯一完全确定性的部分,因此也是唯一能做传统断言的部分:

def test_parse_clauses_handles_empty():
assert parse_clauses("") == []

def test_parse_clauses_splits_by_numbering():
text = "第一条 甲方义务\\n第二条 乙方义务"
result = parse_clauses(text)
assert len(result) == 2
assert result[0].index == 0
assert "甲方" in result[0].text

def test_parse_clauses_rejects_oversized_input():
"""护栏:超大输入必须被拒绝,不能原样塞进上下文。"""
with pytest.raises(ValueError):
parse_clauses("x" * 1_000_001)

def test_tool_error_is_structured_not_exception():
"""工具失败必须返回结构化错误,而不是抛裸异常(第 23 篇原则)。"""
result = fetch_contract("not-exist")
assert result.ok is False
assert result.error.code == "NOT_FOUND"

为什么工具层必须 100% 覆盖:工具的输出会直接进入 LLM 上下文。一个返回了错误数据的工具,会让模型基于错误信息给出看似合理的错误结论——这是最典型的一类静默失效。

4.3 编排层:用 Fake 跑通全流程

编排层测的是"组件之间怎么协作",不需要真实 LLM:

@pytest.mark.asyncio
async def test_supervisor_escalates_when_no_progress():
"""停滞检测:连续两轮无新发现就必须停止(第 24 篇的三道保险之一)。"""
llm = FakeLLM()
# 让专家永远返回"没找到问题"
llm.on(lambda p: True, lambda p: '{"layer_ok": true, "findings": []}')

state = await supervisor.run(llm, max_rounds=10)

assert state.round_index <= 3, "无进展时应提前停止,不应跑满 10 轮"
assert llm.call_count() <= 4

@pytest.mark.asyncio
async def test_mapreduce_partial_failure_keeps_successful_results():
"""部分失败:600 个任务里 3 个失败,其余 597 个结果必须保留(第 23 篇)。"""
llm = FakeLLM()
llm.on(lambda p: "失败用例" in p, lambda p: (_ for _ in ()).throw(RuntimeError("boom")))
llm.on(lambda p: True, lambda p: "ok")

results = await map_reduce(llm, items=list(range(600)))

ok = [r for r in results if r.ok]
failed = [r for r in results if not r.ok]
assert len(ok) == 597
assert len(failed) == 3

第二个测试守的是第 23 篇的核心结论:失败必须做成 ok=False 的数据而非异常——异常会让 asyncio.gather 中断,导致 597 个成功结果全部丢失。

4.4 质量层:这不是测试,是评估

必须分清的一件事:

测试评估
判据 通过 / 不通过(布尔) 分数(连续值)
频率 每次提交 定期(每日/每周)
目的 防止回归 度量质量
能否阻断发布 能 通常不阻断(作为参考)

把评估当成测试来用是错的——评估分数会波动(因为 LLM 随机性),如果用它做 CI 门禁,会产生大量 flaky 失败。第 36 篇会讲怎么把评估做成质量门禁(用阈值 + 趋势而非绝对值)。

4.5 三层测试的判据汇总

层通过判据失败意味着
工具层 精确断言全绿 代码有 bug,必须修
编排层 Fake 场景下流程正确 协作逻辑有 bug,必须修
质量层 分数在阈值之上且未退化 质量下降,需要分析(不一定是 bug)

4.6 Golden Set:质量层的地基

质量层依赖一个东西:人工标注的标准集(Golden Set)。没有它,评估结果就没有参照物。

Golden Set 长什么样:

{
"id": "case-001",
"input": "审查这份采购合同的付款条款",
"source": "采购合同样本 A",
"expectations": {
"must_mention": ["验收后付款", "逾期利息"],
"min_risks": 1,
"max_risks": 5,
"forbid_hallucination": true
},
"human_label": {
"correctness": 4,
"completeness": 3,
"note": "漏掉了违约金条款"
}
}

注意 expectations 与 human_label 的区别:前者是可以自动检查的硬约束(对应 3.2/3.3 节的结构与不变量断言),后者是人工打分的软指标(用于评估)。Golden Set 同时服务测试与评估两层。

怎么建:四条实践经验

实践说明
从真实日志采样,不要凭空编造 编造的用例不覆盖真实分布,测了等于没测
优先覆盖"曾经出过错"的用例 每一个线上事故都应该变成一条 Golden 用例
规模 50~200 条就够 少于 50 统计上不可靠,多于 200 维护成本陡增
标注要包含"错在哪",不只是分数 只有分数无法指导改进

第三条值得强调:很多团队一开始就想建 1000 条的标准集,结果标注到 200 条就停了(人力耗尽),且因为没有维护,半年后一半用例已经过时。50~200 条 + 持续维护,远好于 1000 条 + 停止维护。

⚠️ Golden Set 最大的风险是"污染":如果你用某个版本的模型输出来辅助标注(比如让模型先生成答案,人工只做确认),那么标准集会偏向那个模型的风格。之后评估其他模型时,分数会系统性偏低。标注必须基于事实(真实合同条款、真实业务规则),而不是基于某个模型的输出。

Golden Set 与录制回放的关系:录制回放是"记录过去的行为"(防止变化),Golden Set 是"记录期望的行为"(判断对错)。前者防回归,后者判质量。两者不可替代。

在这里插入图片描述

图3:Agent 三层测试的职责、判据与失败含义——工具层、编排层、质量层的分工


五、Flaky 测试:Agent 测试的头号敌人

5.1 四类根因

测试时而通过时而失败,比一直失败更糟糕——它教会团队忽略失败。四类根因:

#根因表现正确对策
1 真实调用了 LLM 随模型输出波动 改用 Fake / 录制回放
2 依赖真实时间 超时测试偶发失败 注入时钟,不要用 sleep
3 依赖并发顺序 并发测试结果不稳定 显式等待条件,不用固定 sleep
4 测试间共享状态 单独跑通过,一起跑失败 每个测试独立 fixture

5.2 为什么"失败就重跑"是最糟的应对

pytest –reruns 3 # ❌

这个做法的问题不是"没用",而是它让一个真实问题变得不可见:

掩盖了什么后果
Agent 行为确实不稳定 用户会遇到同样的不稳定
存在并发 bug 上线后在高并发下暴露
测试之间有依赖 掩盖真实的顺序耦合问题

更危险的是它会产生"狼来了"效应:团队习惯了"红了就重跑",某天真正的失败也会被当成 flaky 忽略。

5.3 正确的处理流程

测试 flaky
↓
第一步:确认它是不是真的 flaky(连续跑 10 次,记录失败率)
↓
第二步:定位根因(对照 5.1 的四类)
↓
第三步:修根因(确定性化)
↓
做不到?→ 标记 xfail 并写明原因,**不要静默重跑**

第三步的"确定性化"具体做法:

根因确定性化手段
真实 LLM Fake / 录制回放 + temperature=0
依赖时间 注入 Clock 接口,测试里用假时钟
并发顺序 asyncio.Event 显式同步,而非 sleep
共享状态 fixture 隔离 + 每次清空存储

class FakeClock:
"""可注入的时钟。**测试超时逻辑不能用 sleep。**"""
def __init__(self, start: float = 0.0) –> None:
self._now = start

def now(self) –> float:
return self._now

def advance(self, seconds: float) –> None:
self._now += seconds

def test_budget_expires_without_sleeping():
"""预算超时:**推进假时钟**,测试毫秒级完成且不 flaky。"""
clock = FakeClock()
budget = ExecutionBudget(max_wall_clock_s=60, clock=clock.now)

clock.advance(61)
assert budget.exhausted() is True

💡 "注入时钟"这一招的收益远超超时测试本身。 它让所有与时间相关的逻辑(TTL、退避、限流窗口、缓存过期)都能在毫秒内被测到,而不需要任何 sleep。一个项目的测试套件从 3 分钟降到 10 秒,往往就是靠这一个改动。

5.4 什么时候可以接受重跑

只有一个场景:已知的上游偶发故障(比如真实调用时的网络抖动)。这时的正确做法是只给这一小类测试加重跑,而不是全局开启:

@pytest.mark.flaky(reruns=2, reason="依赖真实网络,偶发超时")
@pytest.mark.integration
async def test_real_call_smoke():
...

关键是 reason 参数——它强制你写下"为什么这个测试允许重跑"。写不出理由的,就不许重跑。 这一条把"偷懒"和"必要"区分开了。

5.5 CI 分层执行:让测试套件跑得完

一个常见的失败模式:测试套件越加越多,最后跑一次要 30 分钟,于是没人愿意在本地跑,CI 也成了摆设。

解法是按速度分层,让快反馈和高保真各司其职:

层级内容时长目标触发时机
L1 快测 工具层 + 状态 + 路由(零 Mock) < 10 秒 每次保存
L2 编排测 Fake LLM 跑流程 < 60 秒 每次提交
L3 保真测 HTTP 层 + 录制回放 < 5 分钟 每次推送
L4 评估 Golden Set + 真实调用 分钟~小时 每日夜间

# pytest.ini
[tool:pytest]
markers =
l1: 快测,零外部依赖
l2: 编排测,Fake LLM
l3: 保真测,HTTP 层与录制回放
l4: 评估,真实调用(非门禁)
addopts = -m "not l4" # 默认不跑评估

# 本地开发:只跑 L1(秒级反馈)
pytest -m l1

# 提交前:L1 + L2
pytest -m "l1 or l2"

# CI:L1~L3
pytest -m "not l4"

# 夜间:全量
pytest

addopts = -m "not l4" 这一行很重要:它让"跑测试"的默认行为是快速的。如果默认跑全量,团队会逐渐养成"跳过测试"的习惯——这比没有测试更糟。

flaky 测试的处理流程(5.3 节的图版本):

#mermaid-svg-tXVljdldoBT5OfA5{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tXVljdldoBT5OfA5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tXVljdldoBT5OfA5 .error-icon{fill:#552222;}#mermaid-svg-tXVljdldoBT5OfA5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tXVljdldoBT5OfA5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tXVljdldoBT5OfA5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tXVljdldoBT5OfA5 .marker.cross{stroke:#333333;}#mermaid-svg-tXVljdldoBT5OfA5 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tXVljdldoBT5OfA5 p{margin:0;}#mermaid-svg-tXVljdldoBT5OfA5 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-tXVljdldoBT5OfA5 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-tXVljdldoBT5OfA5 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-tXVljdldoBT5OfA5 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-tXVljdldoBT5OfA5 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-tXVljdldoBT5OfA5 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-tXVljdldoBT5OfA5 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-tXVljdldoBT5OfA5 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-tXVljdldoBT5OfA5 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-tXVljdldoBT5OfA5 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tXVljdldoBT5OfA5 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-tXVljdldoBT5OfA5 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-tXVljdldoBT5OfA5 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-tXVljdldoBT5OfA5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tXVljdldoBT5OfA5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tXVljdldoBT5OfA5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tXVljdldoBT5OfA5 .edgeLabel .label text{fill:#333;}#mermaid-svg-tXVljdldoBT5OfA5 .label div .edgeLabel{color:#333;}#mermaid-svg-tXVljdldoBT5OfA5 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-tXVljdldoBT5OfA5 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-tXVljdldoBT5OfA5 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-tXVljdldoBT5OfA5 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-tXVljdldoBT5OfA5 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-tXVljdldoBT5OfA5 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tXVljdldoBT5OfA5 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tXVljdldoBT5OfA5 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tXVljdldoBT5OfA5 .cluster-label,#mermaid-svg-tXVljdldoBT5OfA5 .nodeLabel{color:#131300;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-tXVljdldoBT5OfA5 .note-edge{stroke-dasharray:5;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-note text{fill:black;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagram .edgeLabel{color:red;}#mermaid-svg-tXVljdldoBT5OfA5 #dependencyStart,#mermaid-svg-tXVljdldoBT5OfA5 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-tXVljdldoBT5OfA5 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tXVljdldoBT5OfA5 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

CI 偶发失败

连续跑 10 次记录失败率

失败率 0 为环境问题

失败率大于 0

对照四类根因

找到根因

确定性化完成

确属上游偶发

写明 reason 后接受重跑

稳定

疑似Flaky

确认中

已确认

定位中

修复中

标记豁免

禁止直接加重跑那会掩盖真实问题

唯一可接受重跑的情形且必须写明理由


六、适用边界与风险提示 ⚠️

什么时候不需要这套测试体系:

  • 原型阶段、Prompt 每周都在改 → 测试天天红,纯浪费
  • 只有一两个 Agent、无外部副作用 → 冒烟测试就够
  • 团队没有人力维护录制文件 → Fake 比录制回放更适合

四个必须知道的风险:

风险现象对策
假阳性测试 测试全绿但测的不是想测的行为 录制键含全部参数;断言要能真正失败(做一次"变异测试"验证)
测试维护成本 Prompt 一改,十几个测试要更新 Prompt 与断言解耦;优先用不变量而非具体内容
Mock 与真实脱节 Mock 全绿,真实一跑就错 必须保留一组 HTTP 层的真实路径测试(2.5 节)
评估当测试用 分数波动导致 CI 频繁红 评估不做门禁;或用趋势而非绝对值(第 36 篇)

第一个风险值得展开:怎么确认你的测试真的能抓到错误?答案是变异测试(mutation testing)——故意改坏被测代码,看测试会不会红。

def test_the_test_actually_fails():
"""元测试:确认"引用原文"这条断言真的能抓住幻觉。

**做法:喂一个编造的风险,断言必须失败。**
如果它通过了,说明你的断言是摆设。
"""
bad_result = {
"risks": [{"clause_index": 0, "level": "high",
"reason": "该条款存在无限责任风险",
"quote": "这句话在原文里根本不存在"}],
"source_text": "第一条 甲方应按时付款",
"summary": "共 1 条风险",
}
with pytest.raises(AssertionError):
assert_every_risk_cites_source(bad_result)

💡 这类"元测试"应该至少写两三个,覆盖你最重要的不变量。 它能防止一类特别尴尬的情况:你写了 50 个测试,全绿,但因为断言写错了(比如 assert x in y 写成了 assert y in x),它们从未真正生效过。

测试不要测的东西:

  • ❌ 不要断言具体的措辞(“报告里必须出现’建议修改’四个字”)
  • ❌ 不要用真实调用做日常回归(慢、贵、不稳)
  • ❌ 不要为了覆盖率去测 Prompt 的措辞细节
  • ❌ 不要全局开启 –reruns

6.1 覆盖率在 Agent 项目里的正确用法

传统项目追求高行覆盖率,但这个指标在 Agent 项目里会给出误导性的好消息:

现象原因
覆盖率很高(90%+)但线上仍出问题 Mock 掉 LLM 后,代码路径几乎全跑到了——但模型行为完全没测
覆盖率提高后质量没提升 覆盖率统计的是"代码执行了没有",不是"行为对不对"

Agent 项目应该看这三个指标,而不是行覆盖率:

指标怎么算为什么更重要
分支覆盖率 错误分支、重试分支是否被触发 Agent 的复杂度在分支里(超时、限流、部分失败)
不变量覆盖 1.4 节那张表里有多少条已实现 直接对应真实失效模式
Golden Set 覆盖 评估集覆盖了多少真实场景 对应"用户实际会怎么用"

# 分支覆盖率(比行覆盖率更有意义)
pytest –cov=my_agent –cov-branch –cov-report=term-missing

关于"100% 覆盖率"的态度:不要追求。三个理由——① 达到最后 10% 的成本极高(通常是防御性分支和异常兜底);② 覆盖率与质量的相关性在越过某个点(约 70%~80%)后急剧下降;③ 追求覆盖率会导致"为了覆盖而写的测试",这类测试通常断言很弱(比如 assert result is not None),徒增维护成本。

💡 一个实用的替代目标:“错误分支 100% 覆盖,正常路径按需覆盖”。 Agent 的崩溃几乎都发生在错误分支(超时、限流、畸形响应、部分失败),而正常路径的问题通常表现为质量下降(由评估发现,不由测试发现)。把覆盖率预算花在错误分支上,性价比最高。


七、一个可复制的测试骨架

tests/
├── conftest.py # 共享 fixture:fake_llm / fake_clock / settings
├── unit/
│ ├── test_tools.py # 工具层:100% 确定性,零 Mock
│ ├── test_state.py # 状态机与转移
│ └── test_routing.py # 规则路由(不需要 LLM)
├── integration/
│ ├── test_orchestration.py # Fake LLM 跑完整流程
│ ├── test_http_layer.py # **真实 SDK + Mock 服务**(保留 SDK 行为)
│ └── cassettes/ # 录制回放文件
├── eval/
│ ├── test_quality.py # 质量评估(非门禁)
│ └── golden_set.json # 人工标注的标准集
└── snapshots/
└── prompts/ # 提示词快照

conftest.py 的关键 fixture:

import pytest
import pytest_asyncio

from fake_llm import FakeLLM

@pytest.fixture
def fake_llm() –> FakeLLM:
"""每个测试独立的 Fake。**不共享状态**(5.1 节第 4 类根因)。"""
return FakeLLM()

@pytest.fixture
def fake_clock():
from clocks import FakeClock
return FakeClock()

@pytest.fixture(autouse=True)
def _isolated_settings(monkeypatch):
"""自动隔离配置:避免测试之间互相影响。"""
monkeypatch.setenv("ENV", "test")
monkeypatch.setenv("STATE_BACKEND", "memory")
monkeypatch.setenv("LLM_RECORD_MODE", "replay")
from my_agent.config import get_settings
get_settings.cache_clear()
yield
get_settings.cache_clear()

def pytest_collection_modifyitems(items):
"""给慢测试自动打标记,便于 CI 分层执行。"""
for item in items:
if "real" in item.keywords or "eval" in item.nodeid:
item.add_marker(pytest.mark.slow)

_isolated_settings 这个 autouse fixture 很重要:配置是全局单例(第 34 篇用 @lru_cache),测试之间会互相污染。每个测试前清缓存,是防止第 4 类 flaky 根因的必备动作。

💡 最后一个建议:测试名要写"验证什么性质",不要写"测什么场景"。 对比一下——

  • ❌ test_contract_review_case_1:看不出它在保证什么
  • ✅ test_every_risk_cites_source_text:失败时你立刻知道哪条性质被破坏了

在 Agent 项目里这个区别更重要,因为测试失败时你面对的不是一个简单的数值差异,而是模型的一段输出。一个好的测试名能省下几分钟的排查时间——而在排查 LLM 问题时,几分钟往往不够。


八、总结

回到第 33 篇留下的那个问题——“LLM 有随机性,怎么测?” 本文的答案是三层:

第一,接受"不能断言输出等于什么",改为断言性质。 五种替代方案里,结构断言和不变量断言应该占绝大多数——它们零成本、极稳定,且能抓住最危险的错误(格式错误、幻觉、前后矛盾)。语义相似度和 LLM-as-judge 只在规则无法表达时才用。

第二,Mock 分层,日常用 Fake、回归用录制、SDK 行为用 HTTP 层 Mock。 函数层 Mock 最方便但会跳过真实 SDK 的重试与超时逻辑——所以必须补一组 HTTP 层测试,否则超时处理代码从未被真正执行过。

第三,分清测试与评估。 测试是布尔判据、防回归、能阻断发布;评估是连续分数、度量质量、不做门禁。用评估做 CI 门禁是 flaky 测试的主要来源之一。

关于 flaky,最重要的一句话:"失败就重跑"是把真实问题包装成无害现象。 它掩盖的可能是"Agent 行为确实不稳定"——而这恰恰是用户会遇到的。正确做法是定位四类根因并确定性化,其中"注入时钟"这一招能让测试从分钟级降到秒级。

最后一个提醒:测试体系应该在 Prompt 稳定之后建立。在原型阶段投资测试,得到的只是一堆天天变红的测试和一个"测试没用"的团队印象。时机比完备性更重要。

还有一点值得记住:测试体系的价值不在于"测了多少",而在于"第一次有人改坏代码时,它会不会立刻报警"。 一个只有 20 条不变量断言的测试套件,比一个 200 条 assert result is not None 的套件有用得多——后者永远全绿,也永远无用。

落地自检清单(Checklist)

  • ✅ 没有用精确字符串断言 LLM 输出
  • ✅ 结构化输出都有 schema 断言
  • ✅ 至少三条不变量断言(引用存在、枚举合法、前后一致)
  • ✅ 工具层零 Mock、100% 确定性覆盖
  • ✅ 编排层用 Fake LLM 跑通流程,不依赖真实调用
  • ✅ 有一组 HTTP 层测试保留真实 SDK 行为
  • ✅ 录制键包含全部影响输出的参数(尤其 temperature)
  • ✅ 断言经过变异测试验证(能真正失败)
  • ✅ 提示词有快照测试,变更需显式确认
  • ✅ 时间相关逻辑注入时钟,测试里无 sleep
  • ✅ 未全局开启 –reruns;允许重跑的测试写明了 reason
  • ✅ 配置 fixture 每个测试隔离(清 lru_cache)
  • ✅ 评估不做 CI 门禁(或用趋势而非绝对值)
  • ✅ 测试套件能在秒级跑完(否则没人愿意跑)

常见问题(FAQ)

Q1:Mock 掉 LLM 之后,测试还有意义吗?

有,但测的东西变了。Mock 之后你测的是"给定某个响应,Agent 代码是否正确处理"——这覆盖了解析、分支、重试、状态转移、错误处理。真正没被覆盖的是"模型本身的质量",那属于评估(第 36 篇),不是测试的职责。

Q2:语义相似度的阈值怎么定?

必须自己校准,不能照抄。 做法:准备 20~30 组人工标注的"(可接受 / 不可接受)"样本,跑一遍看相似度分布,取能把两类分开的值作为阈值。不同领域、不同 embedding 模型的合适阈值差异很大。

Q3:LLM-as-judge 的裁判用什么模型?

与被测模型不同的模型,且要足够强。用同一个模型评判自己会系统性偏向自己的输出风格。另外裁判的 temperature 必须为 0,否则你的测试会变得 flaky。

Q4:录制回放和 Fake 该选哪个?

回归测试用录制回放(保证行为不变),错误分支与边界场景用 Fake(需要人为制造超时、429、畸形响应)。两者都该有。日常开发的快速迭代期,Fake 更好用,因为录制文件会随 Prompt 变化而失效。

Q5:测试跑得太慢怎么办?

三个最常见的提速点,按顺序排查:① 把 sleep 换成注入时钟(往往能省下 80% 的时间);② 确认没有测试在真实调用 LLM(检查 LLM_RECORD_MODE);③ 并发执行(pytest-xdist)。第一项的效果通常远超后两项。

Q6:Prompt 改了,测试要全改吗?

取决于断言类型。不变量断言不需要改(“每条风险引用原文"与 Prompt 内容无关);精确内容断言需要改(这也是为什么应该优先写不变量);快照测试需要重新生成并人工确认。这就是为什么 3.6 节强调"能用规则就用规则”。

Q7:评估测试要跑多久一次?

建议每日一次(夜间),而不是每次提交。因为评估要真实调用、耗时较长、且结果有波动。重点是看趋势而非单次绝对值——分数从 0.85 掉到 0.78 需要关注,0.85 和 0.84 的差异没有意义。

Q8:小团队要不要建这么全的测试?

不用全建。最小可用集是:① 工具层的确定性测试(零成本、收益最高);② 一组 schema + 不变量断言;③ 一个 Fake LLM 跑通主流程。这三样能在半天内建起来,覆盖大部分真实风险。其余的按需加。


参考资料

  • pytest 官方文档 —— fixture、标记、参数化与插件机制:https://docs.pytest.org/
  • pytest-asyncio 官方文档 —— 异步测试配置与模式:https://pytest-asyncio.readthedocs.io/
  • pytest-rerunfailures —— 失败重跑插件(本文建议谨慎使用):https://github.com/pytest-dev/pytest-rerunfailures
  • JSON Schema 官方文档 —— 结构化输出校验规范:https://json-schema.org/
  • jsonschema(Python)—— Schema 校验实现:https://python-jsonschema.readthedocs.io/
  • Zheng, L. 等 — Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena(LLM 裁判的偏差与准确率分析):https://arxiv.org/abs/2306.05685
  • Anthropic 官方文档 —— 评估方法与 LLM-as-judge 实践建议:https://docs.anthropic.com/en/docs/build-with-claude/develop-tests
  • OpenAI 官方文档 —— Evals 框架与评估实践:https://github.com/openai/evals
  • Fowler, M. — Eradicating Non-Determinism in Tests(消除测试非确定性的经典论述):https://martinfowler.com/articles/nonDeterminism.html
  • Google 测试博客 —— Flaky Tests at Google 与确定性化策略:https://testing.googleblog.com/
  • 赞(0)
    未经允许不得转载:171主机测评 » Agent 测试实战:单元测试、集成测试与 Mock LLM 策略
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址