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

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

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

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

python 复制代码
def test_calculate_discount():
    assert calculate_discount(100, vip=True) == 80

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

python 复制代码
def test_review_contract():
    result = await review_contract(contract_text)
    assert result == ???      # ← 填什么?

你没法填。 因为:

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

1.2 三个常见的错误应对

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

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

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

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

错误二:断言精确字符串

python 复制代码
assert result == "该合同存在三条高风险条款:..."    # ← 改天就红

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

错误三:失败就重跑

bash 复制代码
pytest --reruns 3        # ← 掩盖问题

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

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

核心转变是:

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

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

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

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

python 复制代码
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 篇已实现,真实调用不需要设计。

python 复制代码
"""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 测重试与上下文裁剪

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

python 复制代码
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"。工具会正常执行并返回空结果------没有报错、没有异常、测试也可能通过,只是答案是错的。

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

python 复制代码
@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"

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

python 复制代码
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 结构断言:最基础的一层

python 复制代码
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 节已经给了"每条风险必须引用原文"的例子。再给三个实用的不变量:

python 复制代码
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

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

python 复制代码
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 快照回归

python 复制代码
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 篇事故二:重跑污染偏好
python 复制代码
@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,让测试用例读起来像剧本:

python 复制代码
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 场景需要调整:

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

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

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

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

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

python 复制代码
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:

python 复制代码
@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 长什么样:

json 复制代码
{
  "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 为什么"失败就重跑"是最糟的应对

bash 复制代码
pytest --reruns 3      # ❌

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

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

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

5.3 正确的处理流程

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

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

根因 确定性化手段
真实 LLM Fake / 录制回放 + temperature=0
依赖时间 注入 Clock 接口,测试里用假时钟
并发顺序 asyncio.Event 显式同步,而非 sleep
共享状态 fixture 隔离 + 每次清空存储
python 复制代码
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 什么时候可以接受重跑

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

python 复制代码
@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 + 真实调用 分钟~小时 每日夜间
ini 复制代码
# pytest.ini
[tool:pytest]
markers =
    l1: 快测,零外部依赖
    l2: 编排测,Fake LLM
    l3: 保真测,HTTP 层与录制回放
    l4: 评估,真实调用(非门禁)
addopts = -m "not l4"          # 默认不跑评估
bash 复制代码
# 本地开发:只跑 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)------故意改坏被测代码,看测试会不会红。

python 复制代码
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 覆盖 评估集覆盖了多少真实场景 对应"用户实际会怎么用"
bash 复制代码
# 分支覆盖率(比行覆盖率更有意义)
pytest --cov=my_agent --cov-branch --cov-report=term-missing

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

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


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

text 复制代码
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:

python 复制代码
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 跑通主流程。这三样能在半天内建起来,覆盖大部分真实风险。其余的按需加。


参考资料

  1. pytest 官方文档 ------ fixture、标记、参数化与插件机制:https://docs.pytest.org/
  2. pytest-asyncio 官方文档 ------ 异步测试配置与模式:https://pytest-asyncio.readthedocs.io/
  3. pytest-rerunfailures ------ 失败重跑插件(本文建议谨慎使用 ):https://github.com/pytest-dev/pytest-rerunfailures
  4. JSON Schema 官方文档 ------ 结构化输出校验规范:https://json-schema.org/
  5. jsonschema(Python)------ Schema 校验实现:https://python-jsonschema.readthedocs.io/
  6. Zheng, L. 等 --- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena (LLM 裁判的偏差与准确率分析):https://arxiv.org/abs/2306.05685
  7. Anthropic 官方文档 ------ 评估方法与 LLM-as-judge 实践建议:https://docs.anthropic.com/en/docs/build-with-claude/develop-tests
  8. OpenAI 官方文档 ------ Evals 框架与评估实践:https://github.com/openai/evals
  9. Fowler, M. --- Eradicating Non-Determinism in Tests (消除测试非确定性的经典论述):https://martinfowler.com/articles/nonDeterminism.html
  10. Google 测试博客 ------ Flaky Tests at Google 与确定性化策略:https://testing.googleblog.com/
相关推荐
Solange__1 小时前
物流园区怎么用AI生成3D调度预演?从道路底图、车辆转弯到网页评审
图像处理·人工智能·游戏·3d·ai·自然语言处理·关卡设计
最强小杰1 小时前
gpt-image-2.5-sunburst + OpenAI SDK + Cline 怎么接?完整图生图与局部重绘教程
gpt·ios·ai·iphone
骑着蜗牛撵大象3272 小时前
闲鱼捡漏自动化:关键词加价格上限先粗筛,AI 大模型再做第一轮判定
大数据·人工智能·ai·自动化·二手交易·闲鱼·关键词监控
素男12 小时前
两个月不是六十天
人工智能·agent·self-becoming·ai长期记忆·ai自我介绍
小贺儿开发13 小时前
《流光绘忆》Unity 沉浸式光影互动
科技·unity·ai·程序员·工具·互动·光影
奇牙coding13 小时前
ofox.io 第三方模型 ID `wan-3.0-prime` 一直报 401 怎么办?从鉴权头、签名、区域到请求时间逐项排查
ai
数智园区合作社13 小时前
问数平台怎么把同一个指标锁成一个数
大数据·数据库·人工智能·ai
小豆庄13 小时前
视频敏感信息脱敏:本地推理与云端API的工程取舍
阿里云·ai·视频·纹理贴图