🏠 总目录📚 本教程 15b · 怎么测不确定的系统
📑 本页目录(点开跳转)

15b · 怎么测一个同样输入不同输出的系统

67 分钟 | ⭐ assert reply == "..." 写不出来,那还能断言什么 | 🔨 三段代码不联网、不花钱、可直接跑


🎯 一句话

把系统切成「确定的」和「不确定的」两部分,分别用不同办法测 —— 真正不确定的只有模型那一次调用,其余九成代码照常测就行。

02 章列过「同样输入不同输出」的三个后果:缓存、测试、复现全变难。缓存在 04 章讲了,复现在 15 章讲了。你在 02 章读到「测试断言写不出来」时可能想过「那到底怎么测」—— 答案在这一章。


🧱 一、先破掉那个心理障碍:不确定的只有一格

很多人一看见「模型每次回答都不一样」,就得出结论:这东西没法写测试,然后整个项目一行测试都不写。⭐ 这个推理错在把一格的性质推给了整栋楼。

这部分代码 确定吗 怎么测
路由状态码、扣配额、成本计算(04cost_of 正常测,和普通 Web 应用没区别
缓存 key(模型/system/温度/文档 id 变一个,key 就得变) ⭐ 而且必须测 —— 漏一个字段就是把 A 的答案发给 B
租户过滤:换个租户查出 0 行 08 章已有范例(毒丸测试,按表自动生成)
SSE 帧怎么拼、队列幂等、重试白名单 正常测(05 · 11 · 本章第二节)
⚠️ 模型返回的那段文字 只有这一格 第三节:断结构、断范围、断不变量

判据一句话:这段代码的输出,是不是只由它的输入决定? 是 → 和不确定性没有任何关系,用你本来就会的方式测。 不是 → 它只可能来自四样东西之一:模型 · 当前时间 · 随机数 · 外部网络

⚠️ 后三样比模型更常被忽略:它们在普通 Web 里也让测试变脆,只是 AI 应用把注意力全吸到第一样上去了 —— 症状是「测试偶尔红一次,重跑就好」,然后所有人学会了重跑。

确定的那一半怎么测本章不占篇幅,它和你写过的任何测试一样。只提醒一个形状:测「配额扣到上限」时,除了断言第 101 次被拒,还要断言被拒的那一次没把计数也加上去 —— 漏掉它,线上就是「用户超限后每被拒一次计数还涨一次」,而功能测试全绿,因为「被拒了」这个现象是对的。


🔌 二、接缝在哪:04 章已经留好了

不用你发明 —— 04 章第一节FakeLLM 就是它,那一章也写清了三个用途(测试/CI · 本地开发 · ⭐ 故障演练:故意抛 429、故意超时、故意返回半截 JSON),还留了 self.calls = [] 这个钩子(测试里可以直接断言)。往上一层,03 章那行 app.dependency_overrides[...] 是同一件事的入口侧版本。

本章从「假客户端有了,测试怎么写」往下接。而写这类测试时,第一个真正的坑不是模型,是时间

04 章那个重试器的签名是 call_with_retry(fn, tries=4, base=0.5, cap=8.0, sleep=time.sleep, rng=None)。⭐ sleeprng 出现在参数列表里不是风格问题,是这个函数可不可测的分界线:写死成 time.sleep() 的话,一个「重试三次」的用例要真睡 3.5 秒(几十个这样的用例,整套测试就没人在本地跑了),退避序列藏在函数肚子里断言不出来,而全抖动(04 章讲了为什么要有)会让「等了多久」每次都不同 —— ⭐ 测试里想要确定,就把随机数也注入进来。

# test_failure_paths.py —— 故障路径:让假客户端故意抛 429、故意超时
# pip install pytest ;跑法 pytest -q test_failure_paths.py
import pytest


class RateLimited(Exception): pass      # 上游 429
class BadRequest(Exception): pass       # 400:参数错、超长


RETRIABLE = (RateLimited, TimeoutError, ConnectionError)     # 04 章那张表的白名单


def call_with_retry(fn, tries=4, base=0.5, sleep=None, rng=None):
    """04 章那个重试器的精简版,放这儿只是为了让本段能单独跑,本章不再解释它。
    ⭐ 要看的是 sleep 和 rng 在【参数列表里】—— 它俩就是本章要用的那道缝。"""
    sleep = sleep or (lambda s: None)
    rng = rng or (lambda hi: hi)        # ⚠️ 真实版是全抖动,测试里换成「取上界」
    for i in range(tries):
        try:
            return fn()
        except RETRIABLE:
            if i == tries - 1:
                raise
            sleep(rng(base * 2 ** i))


class ScriptedLLM:
    """⭐ 按脚本演故障:给一串「这次抛什么」,None 表示这次成功。"""

    def __init__(self, script):
        self.script, self.calls = list(script), 0

    def complete(self, prompt):
        self.calls += 1
        exc = self.script.pop(0) if self.script else None
        if exc:
            raise exc
        return "(假模型)第 %d 次才成功" % self.calls


def test_retries_then_succeeds():
    llm = ScriptedLLM([RateLimited("429"), TimeoutError("read timeout"), None])
    assert "成功" in call_with_retry(lambda: llm.complete("hi"))
    assert llm.calls == 3              # ⭐ 断言【调了几次】,不是断言返回的文字


def test_non_retriable_is_called_once():
    llm = ScriptedLLM([BadRequest("400 prompt 超长")])
    with pytest.raises(BadRequest):
        call_with_retry(lambda: llm.complete("hi"))
    assert llm.calls == 1              # ⚠️⚠️ 少了这一行,重试四次也照样"通过"


def test_backoff_grows_and_test_does_not_sleep():
    waits = []
    llm = ScriptedLLM([RateLimited("429")] * 3 + [None])
    call_with_retry(lambda: llm.complete("hi"), sleep=waits.append)   # ⭐ 假 sleep
    assert waits == [0.5, 1.0, 2.0]    # 0 秒跑完,还看得见完整的退避序列

实跑 pytest -q 输出 3 passed(全程没联网、没花钱、没真睡)。

⚠️⚠️ assert llm.calls == 1 是这一节最容易漏的一行:只写 pytest.raises(BadRequest) 的话,一个把 400 也重试四遍的实现同样能让测试变绿 —— 它最后确实抛了 BadRequest

推广开来:测故障路径时,除了断言「发生了什么」,还要断言「调了几次」。 上游被多调一次,在测试里是一个数字对不上,在线上是多付一次钱


🧪 三、对不确定的输出,能断言的有四类

不能写 assert reply == "生成的原文" —— 这是本章的起点。但「不能断言文字」不等于「不能断言」:

类别 断什么 例子
① 结构 是不是我说好的那个形状 JSON 解得开、必需字段一个不少
② 范围 有没有跑出合理区间 回答长度、引用条数、token 数、耗时上界
③ 不变量 无论它说什么,这几句必须永远成立 引用的来源必须都在检索结果里、不该出现的没出现
⭐⭐ ④ 确定的那部分 输出再飘,这些必须一模一样 记账条数、feature 字段、状态流转、日志字段

第三类最能抓到真实事故:「模型引用了一个不在检索结果里的文档 id」在页面上是一条点不开的引用链接,在代码里就是一行 set(cites) <= set(doc_ids)。 ⭐⭐ 第四类最容易被忘,因为一想到「测 AI」就只盯那段文字 —— 可计费、日志字段、状态机在同一次调用里也全发生了,而它们是确定的

# test_uncertain_output.py —— 输出每次都不一样,但这四类断言每次都成立
# pip install pytest ;跑法 pytest -q test_uncertain_output.py
import json
import random

DOCS = {"d1": "退款政策", "d2": "配送时效", "d3": "发票开具"}
BANNED = ("系统提示", "sk-")          # 不该出现在回答里的东西


class DriftyLLM:
    """故意做成不确定的假模型:措辞、长度、引用条数和顺序每次都不一样。"""

    def __init__(self, seed=None):
        self.rng = random.Random(seed)

    def answer(self, question, doc_ids):
        n = self.rng.randint(1, len(doc_ids))
        cites = self.rng.sample(list(doc_ids), n)          # ⭐ 条数、顺序、措辞全随机
        tone = self.rng.choice(["简单说,", "这样理解:", "答案是——", ""])
        pad = "。" + "补充说明" * self.rng.randint(0, 3)
        return json.dumps({"answer": tone + question + "的处理方式如下" + pad,
                           "cites": cites}, ensure_ascii=False)


def check(raw, doc_ids):
    """前三类断言。⭐ 一条都不涉及【具体文字】。"""
    obj = json.loads(raw)                                  # ① 结构:解得开
    assert set(obj) == {"answer", "cites"}                 # ① 字段不多不少
    assert 10 <= len(obj["answer"]) <= 200                 # ② 范围:长度
    assert 1 <= len(obj["cites"]) <= len(doc_ids)          # ② 范围:条数
    assert set(obj["cites"]) <= set(doc_ids)               # ⭐ ③ 不变量:不许编来源
    assert not any(b in obj["answer"] for b in BANNED)     # ③ 不该出现的没出现
    return obj


def test_two_hundred_random_outputs_all_pass():
    llm, seen = DriftyLLM(seed=7), set()
    for i in range(200):
        raw = llm.answer("订单%d" % (i % 5), DOCS)
        check(raw, DOCS)
        seen.add(raw)
    assert len(seen) > 100          # ⭐ 顺便证明它【真的】每次都不一样


def test_the_deterministic_part_is_identical():
    rec = []
    for seed in (1, 2):               # 两个种子 → 回答文字必然不同
        check(DriftyLLM(seed).answer("订单0", DOCS), DOCS)
        rec.append({"feature": "qa", "ok": True})
    # ⭐⭐ 记账条数、feature、状态 —— 回答再怎么飘,这几样必须一模一样
    assert rec == [{"feature": "qa", "ok": True}] * 2


if __name__ == "__main__":
    llm = DriftyLLM(seed=7)           # ⚠️ 一个实例问 200 次;每次新建实例会拿到同一个种子
    xs = {llm.answer("订单0", DOCS) for _ in range(200)}
    print("同一个问题问 200 次,出现了 %d 种不同的回答" % len(xs))

实跑 pytest -q 输出 2 passed;直接 python 跑打印 同一个问题问 200 次,出现了 134 种不同的回答

这就是全部论点:同一个问题的 200 次回答里有 134 种不同文字,assert 相等 毫无活路 —— 而那四类断言 200 次全部成立,因为 check() 里没有一行提到具体措辞,它只问「形状对不对、有没有超出范围、不变量还成不成立」。

⚠️ 一个反直觉的补充:temperature=0 不代表输出确定。 它只是让采样取概率最高的 token,模型版本、浮点累加顺序、上游负载都可能让结果变化。「设成 0 了所以可以断言相等」是错的 —— 那只让它不那么容易变,而这更糟:测试会绿很久,然后在某个和你无关的日子里突然全红。


🛑 读到这里可以停 —— 前半章讲完了(约 32 分钟)。 后半章还有:⚠️ 对模型输出做快照测试,是个陷阱 · 测试金字塔在 AI 应用里不是金字塔 · 和《模型上线之后》/ Evals 那几章的分界 · CI 里怎么不烧钱:靠结构,不靠记性 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。

🛑 读到这里可以停 —— 前半章讲完了(怎么切、接缝在哪、能断言什么)。 后半章还有:快照测试为什么是陷阱 · 金字塔在 AI 应用里的形状 · 和 Evals / 《模型上线之后》的分界 · CI 里怎么不烧钱 回来的时候不用重读,直接从下一节接着看就行。


📸 四、⚠️ 对模型输出做快照测试,是个陷阱

快照测试(snapshot / golden file):第一次跑把输出存成文件,以后每次比对,不一样就红。测 UI 结构和序列化格式很好用,用在模型输出上则是本章最想拦住的一件事

  1. 它把「第一次跑出来的东西」当成了正确答案 —— 没人审过那段文字,一段 500 字的回答谁会逐句读?
  2. temperature > 0 时它每次都红,于是第二天起这个测试的日常就是 --update-snapshots
  3. ⚠️ 就算 temperature=0 它也会飘(见上一节):模型版本、模板改一个字、多召回一条都会 —— 一次改动几十个快照要更新,人只会闭着眼批准

💀 最终形态最危险:快照被无脑更新,于是它从不失败,所有人却以为「输出是被测着的」—— ⭐⭐ 这比根本没有测试更糟,因为它占着「已经测过」这个位置,让人不再去写上一节那四类断言。

判据还是第一节那条:这段输出是不是只由输入决定? 是 → 快照很好用;不是 → 去写不变量。

所以快照该用在确定的那一侧渲染出来的 prompt 模板(⭐ 最值 —— 提示词是最容易被人随手改一句、然后没人发现的东西)、API 响应的字段结构、SSE 帧的字节形状、03 章那个统一错误响应的 JSON。


🔺 五、测试金字塔在 AI 应用里不是金字塔

普通应用的金字塔「底宽顶窄」,理由只有一条:越靠上越慢。⭐ AI 应用给顶层又加了两条:每跑一次是真钱,而且它天生 flaky(同样输入不同输出 → 顶层断言先天不稳)。结论不是「顶层再收窄一点」:

⭐⭐ 真上游那一层已经不该待在金字塔里了。它不是塔尖,是塔旁边单独立的一根旗杆 —— 它红了不许挡住任何人合并。

什么时候跑 里面有什么 真模型 允许它红
① 单元 每次提交 纯函数、状态码、配额、成本、缓存 key、SSE 帧、租户句柄、重试白名单 红了就是有 bug
② 集成 每次提交(慢的挪到合并前) 真数据库 + 假模型:登录 → 提问 → 落库 → 计费走一条链路 ❌ 同上
③ 真上游冒烟 单独的、手动或定时触发的作业 3–5 个用例,只验「接得通、格式对得上、解析器吃得下」 花钱 允许,不阻塞合并

第三档为什么只验那三件事:它存在的唯一理由是回答假客户端永远回答不了的问题 —— 上游今天还长这个样子吗? 供应商改了字段名、改了错误码、废弃了一个参数,你的假客户端一无所知,因为它是照着你以为的样子写的。⚠️ 这是 FakeLLM 唯一的结构性缺陷,只能靠真调一次来补。

一个测试要不要联真模型,问这一句:它红的时候,是我的代码错了,还是模型今天心情不好? 后者就不该进 CI —— 它红了没有人能修,而没人能修的红灯,一周之内会被所有人学会忽略。


🚧 六、和《模型上线之后》/ Evals 那几章的分界

一个用例红了,你的下一个动作是 那它属于 在哪
去改代码(少了个 fallback、字段名写错、租户条件漏了) 软件测试 本章
去改 prompt / 换模型 / 调评分器 效果评测(Evals) 智能体 14 · 全景 11
去看线上分布变了没、要不要重训、AB 怎么切 上线之后的监控与实验 《模型上线之后》

判据和 15 章第一节是同一条:看处置动作。 那一章用它划「服务健不健康 vs 模型准不准」,这一章用它划「代码对不对 vs 模型好不好」。⚠️ 15 章问过的那个交界例子 ——「模型没按格式返回 JSON」—— 在这里正好分成两半:代码因此抛异常、用户看到 500 → ⭐ 本章的活,你缺一个「解析失败就重试一次 / 降级成纯文本」的分支,而且这个分支必须有测试(用第二节 ScriptedLLM 那个形状把它点着);代码稳稳接住了、只是内容不够好 → 那是 Evals 的活。

⚠️⚠️ 别把它们放进同一个 CI 作业。 一旦混在一起,某天「模型今天不太行」会挡住所有人合并;两周之内团队一定会把整个作业设成允许失败 —— 于是真正的软件 bug 也一起被放过去了。这个演化几乎是必然的,和 15 章「告警噪声」是同一个机制。

📋 站内还有一条边界16 章手工体检单(上线前、人来做),本章是自动化测试(每次提交、机器做)。它那些「怎么验证」有一半该被本章接管(A 的 token 请求 B 的资源 id → 404/403同一个 Idempotency-Key 投三次账上一笔造个 400 → 一次就进死信超大 body → 413),另一半只能手工(手机流量看线上是不是逐字手动触发告警确认手机响了把备份真的恢复到临时库)。⭐ 分界很干净:能在你自己的进程里重现的写成测试;必须离开你的进程才能验的,留在 16 章。


💸 七、CI 里怎么不烧钱:靠结构,不靠记性

规则很简单:默认全走假客户端,真实调用只在一个单独的、手动触发的作业里。 难的是「默认」怎么保证 —— ⚠️ 靠「大家记得设那个环境变量」是不行的,总有人新写一个测试忘了。两条结构性做法:① 让真实客户端在测试环境里根本构造不出来(缺条件当场抛);② 装一条断网闸

# test_no_real_calls.py —— 让「不小心真调上游」在测试里变成【当场炸】
# pip install pytest ;⚠️ 这段用了 fixture,跑法是 pytest -q test_no_real_calls.py
import socket

import pytest


class NetworkBlocked(RuntimeError):
    pass


@pytest.fixture(autouse=True)          # ⭐ autouse:不用每个测试自己记得挂
def no_network(monkeypatch):
    def deny(*a, **k):
        raise NetworkBlocked("测试里不许联网 —— 你是不是漏了一个假客户端?")
    monkeypatch.setattr(socket.socket, "connect", deny)
    monkeypatch.setattr(socket.socket, "connect_ex", deny)


def test_a_missed_fake_blows_up_at_connect_time():
    with pytest.raises(NetworkBlocked):        # ⭐ 不管谁写的代码,走到这一步就红
        socket.socket().connect(("example.com", 443))

实跑 pytest -q 输出 1 passed。⭐ 断网闸的回报比你想的大:它顺手还会抓到三类和模型无关的东西 —— 测试里偷偷连了真数据库、某个库 import 时去拉远端配置、埋点 SDK 在后台往外发,这三样都是「测试偶尔红一次,重跑就好」的常见来源。

那个真实调用的作业怎么设(🗓️ 各家 CI 语法不同,要看的是形状):单独一个 workflow,手动触发或每天定时一次 —— ⚠️ 绝不要挂在 PR 上,任何人开个 PR 就能花你的钱,而且 fork 的 PR 本来也拿不到 secret;给它自己的 key 和日预算12 章那三层护栏对它同样适用,⭐ CI 也是一个会失控的调用方);⭐ 允许失败、不阻塞合并(它红了多半是上游在抖);⭐⭐ 它也要记账04 章记账表的 feature 传成 ci_smoke —— ⚠️ 不记的话,某天成本涨了你会先去怀疑用户)。


🔄 换个栈怎么对应

概念 Python(pytest,本板块主栈) Node(Jest / Vitest) Go(testing)
一个用例多组输入 @pytest.mark.parametrize test.each ⭐ 表驱动:自己 for _, tc := range tests
替身注入 dependency_overrides / 直接传参 vi.mock / 手动注入 定义 interface,测试传假实现
冻结时间与随机数 ⭐ 把 sleep / rng 写成参数 vi.useFakeTimers() 传一个 clock 接口进去
断网 monkeypatch socket.socket.connect nock.disableNetConnect() 自定义 http.RoundTripper
只在某些时候跑 打标记 + -m "not costly" test.skipIf / 分组 testing.Short() + go test -short
快照 靠第三方插件 ⚠️ 内置 toMatchSnapshot() 无内置,golden file 手写

这张表要看出的是:「替身注入」在三个栈里长得完全不同(override / mock / interface),但它是同一件事 —— 把不确定的那一格换成可控的。而 ⭐ 越是没有魔法的那一栏越不容易骗自己:第四节那个「快照被无脑更新」的坑,在有内置快照的栈里发生得最频繁


🔗 这一章连到哪里

去哪 为什么
04 · 调用层 本章的接缝全在那一章FakeLLM 的三个用途、call_with_retry(sleep=, rng=) 那两个参数;缓存判据和重试白名单也在那里,本章只当被测对象,不重讲
08 · 认证会话与多租户 ⭐ 站里已有的「确定的那部分」最佳范例:毒丸测试(每张租户表断言「换个租户查出 0 行」,按表自动生成
15 · 可观测性 那一章解决「不确定性」的复现那一半,本章解决测试那一半;共用同一条判据:看处置动作
16 · 上线前检查单 ⭐ 那是手工体检单,本章是自动化测试。第六节列了哪些条目该被本章接管、哪些只能手工
17 · 实战与挑战项目 ⭐ 项目 ② 验收里那条「A 上传的文档 B 绝不会检索到 —— 要写一个自动化测试反复跑,不能靠人工点」就是本章的活
《模型上线之后》 ⭐⭐ 那一套管「模型准不准」(离线好不等于线上好、漂移、AB、重训),本章管「你的代码对不对」
智能体工程 14 · 评测 Evals ⭐ 那是评 Agent 效果不是软件测试:三类评分器、pass@k vs pass^k、「评产出不评路径」。改 prompt 用它,改代码用本章

✅ 检查点

  1. 「模型每次输出都不一样,所以没法测」错在哪?判据是哪一句?除了模型,还有哪三个来源会让输出不由输入决定?
  2. 04 章重试器的签名里为什么要有 sleeprng?写死成 time.sleep() 会有哪三个后果?
  3. 测「不该重试的错」时,只写 pytest.raises(BadRequest) 为什么不够?
  4. 对不确定输出能断言的四类是什么?哪一类最能抓事故、哪一类最容易被忘?
  5. 为什么「temperature=0 所以可以断言相等」是错的?快照测试为什么最终比没有测试更糟?
  6. 第三档「真上游冒烟」为什么只验那三件事、又为什么不许挡住合并?
  7. 本章、Evals、《模型上线之后》怎么分?为什么绝不能塞进同一个 CI 作业?
  8. CI 里不烧钱的两条结构性做法是什么?真实调用的作业为什么不能挂 PR 上、为什么也要记账?
👀 答案
  1. 错在把一格的性质推给了整栋楼。判据 ⭐ 「这段代码的输出,是不是只由它的输入决定?」 另外三个来源是时间、随机数、外部网络,症状是「测试偶尔红一次,重跑就好」。
  2. 因为它们是这个函数可不可测的分界线:① 一个「重试三次」的用例要真睡 3.5 秒;② 退避序列藏在函数肚子里断言不出来;③ 全抖动让「等了多久」每次都不同 —— 本章传「取上界」,于是断言得出 [0.5, 1.0, 2.0]
  3. 因为把 400 也重试四遍的实现同样会抛 BadRequest。还要断言 ⭐ llm.calls == 1 —— 除了断言「发生了什么」,还要断言「调了几次」,多调一次在线上是多付一次钱。
  4. 结构 / 范围 / ⭐ 不变量 / ⭐⭐ 确定的那部分不变量最能抓事故(编来源在页面上就是点不开的引用链接,在代码里是一行 set(cites) <= set(doc_ids));确定的那部分最容易被忘(记账条数、feature、状态流转)。
  5. temperature=0 只是取概率最高的 token,模型版本、浮点累加顺序、上游负载都会让结果变,⭐ 于是测试会绿很久然后某天突然全红。快照更糟是因为被无脑更新后它从不失败,⭐⭐ 却占着「已经测过」这个位置
  6. 因为它唯一的理由是回答假客户端答不了的问题:上游今天还长这个样子吗。不许挡合并,是因为它每跑一次真花钱天生 flaky;判据 ⭐ 「它红的时候,是我的代码错了,还是模型今天心情不好?」
  7. 处置动作改代码 → 本章改 prompt / 换模型 → Evals看漂移 / 重训 / AB → 《模型上线之后》。⚠️ 混在一起,两周内团队会把作业设成允许失败 —— ⭐ 真正的软件 bug 也一起被放过去了
  8. 真实客户端在测试环境里根本构造不出来;② ⭐⭐ 断网闸。不挂 PR 是因为 ⚠️ 任何人开个 PR 就能花你的钱,fork 的 PR 也拿不到 secret;要记账(featureci_smoke)是因为 ⭐ 不记的话某天成本涨了,你会先去怀疑用户

🛑 可以停在这里

走神救援

⭐⭐ 核心命题:把系统切成「确定的」和「不确定的」两部分,分别用不同办法测。 路由、扣配额、成本、缓存 key、租户过滤、SSE 帧、队列幂等、重试白名单全是确定的,真正不确定的只有模型返回的那段文字 —— 别把一格的性质推给整栋楼。判据:输出是不是只由输入决定? 另外三个会让它不确定的来源是时间、随机数、网络,症状是「测试偶尔红一次,重跑就好」。接缝不用你发明:04 章的 FakeLLM 和 03 章的 dependency_overrides 就是它。第一个坑不是模型是时间call_with_retry(sleep=, rng=) 那两个参数是这个函数可不可测的分界线 —— 写死的话一个「重试三次」的用例要真睡 3.5 秒、退避序列断言不出来;把 sleep 换成 waits.append、随机数换成「取上界」,0 秒跑完还能断言出 [0.5, 1.0, 2.0]。⚠️⚠️ 测不该重试的错只写 pytest.raises 不够(把 400 重试四遍照样变绿),必须再断言 calls == 1除了断言「发生了什么」,还要断言「调了几次」对不确定输出能断言四类结构范围、⭐ 不变量(引用的来源必须都在检索结果里)、⭐⭐ 确定的那部分(记账条数、feature、状态流转)。实跑:同一问题问 200 次出现 134 种不同回答,assert 相等 毫无活路,四类断言 200 次全过。⚠️ temperature=0 不代表输出确定,这个错更糟因为测试会绿很久然后某天突然全红。⚠️ 快照测试是陷阱,💀 被无脑更新后它从不失败,⭐⭐ 比没有测试更糟,因为占着「已经测过」这个位置 —— ✅ 只该用在 prompt 模板、字段结构、SSE 帧形状这些确定的东西上。金字塔不是金字塔:顶层不只是慢,还真花钱天生 flaky,所以 ⭐⭐ 真上游那层是塔旁边一根旗杆,红了不许挡人合并;三档 = 单元/集成(真库 + 假模型)/真上游冒烟(单独作业、3–5 个用例、只验接得通格式对解析得了、允许失败)—— 它唯一的理由是补假客户端补不了的洞上游今天还长这个样子吗。⭐ 判据:它红的时候是我的代码错了,还是模型今天心情不好? 分界看处置动作改代码 = 本章改 prompt = Evals看漂移/AB =《模型上线之后》;⚠️⚠️ 绝不能混进同一个 CI 作业。16 章是手工体检单:越权、幂等、400 进死信、413 该被本章接管,手机流量验流式、告警触发、备份恢复只能手工CI 不烧钱靠结构不靠记性:真实客户端构造不出来 + ⭐⭐ 断网闸(顺手还抓到偷连真数据库、import 时拉远端配置、埋点 SDK 外发);真调作业 ⚠️ 绝不挂 PR 上,要给自己的日预算、允许失败、⭐⭐ 也要记账

下一节 👉 16-上线前检查单.md

打卡记录保存在你的浏览器里,首页能看到总进度