📑 本页目录(点开跳转)
15b · 怎么测一个同样输入不同输出的系统
⏱ 67 分钟 | ⭐ assert reply == "..." 写不出来,那还能断言什么 | 🔨 三段代码不联网、不花钱、可直接跑
🎯 一句话
把系统切成「确定的」和「不确定的」两部分,分别用不同办法测 —— 真正不确定的只有模型那一次调用,其余九成代码照常测就行。
02 章列过「同样输入不同输出」的三个后果:缓存、测试、复现全变难。缓存在 04 章讲了,复现在 15 章讲了。你在 02 章读到「测试断言写不出来」时可能想过「那到底怎么测」—— 答案在这一章。
🧱 一、先破掉那个心理障碍:不确定的只有一格
很多人一看见「模型每次回答都不一样」,就得出结论:这东西没法写测试,然后整个项目一行测试都不写。⭐ 这个推理错在把一格的性质推给了整栋楼。
| 这部分代码 | 确定吗 | 怎么测 |
|---|---|---|
路由状态码、扣配额、成本计算(04 的 cost_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)。⭐ sleep 和 rng 出现在参数列表里不是风格问题,是这个函数可不可测的分界线:写死成 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 结构和序列化格式很好用,用在模型输出上则是本章最想拦住的一件事:
- ⭐ 它把「第一次跑出来的东西」当成了正确答案 —— 没人审过那段文字,一段 500 字的回答谁会逐句读?
temperature > 0时它每次都红,于是第二天起这个测试的日常就是--update-snapshots。- ⚠️ 就算
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 用它,改代码用本章 |
✅ 检查点
- 「模型每次输出都不一样,所以没法测」错在哪?判据是哪一句?除了模型,还有哪三个来源会让输出不由输入决定?
- 04 章重试器的签名里为什么要有
sleep和rng?写死成time.sleep()会有哪三个后果? - 测「不该重试的错」时,只写
pytest.raises(BadRequest)为什么不够? - 对不确定输出能断言的四类是什么?哪一类最能抓事故、哪一类最容易被忘?
- 为什么「
temperature=0所以可以断言相等」是错的?快照测试为什么最终比没有测试更糟? - 第三档「真上游冒烟」为什么只验那三件事、又为什么不许挡住合并?
- 本章、Evals、《模型上线之后》怎么分?为什么绝不能塞进同一个 CI 作业?
- CI 里不烧钱的两条结构性做法是什么?真实调用的作业为什么不能挂 PR 上、为什么也要记账?
👀 答案
- 错在把一格的性质推给了整栋楼。判据 ⭐ 「这段代码的输出,是不是只由它的输入决定?」 另外三个来源是时间、随机数、外部网络,症状是「测试偶尔红一次,重跑就好」。
- 因为它们是这个函数可不可测的分界线:① 一个「重试三次」的用例要真睡 3.5 秒;② 退避序列藏在函数肚子里断言不出来;③ 全抖动让「等了多久」每次都不同 —— 本章传「取上界」,于是断言得出
[0.5, 1.0, 2.0]。 - 因为把 400 也重试四遍的实现同样会抛
BadRequest。还要断言 ⭐llm.calls == 1—— 除了断言「发生了什么」,还要断言「调了几次」,多调一次在线上是多付一次钱。 - 结构 / 范围 / ⭐ 不变量 / ⭐⭐ 确定的那部分。不变量最能抓事故(编来源在页面上就是点不开的引用链接,在代码里是一行
set(cites) <= set(doc_ids));确定的那部分最容易被忘(记账条数、feature、状态流转)。 temperature=0只是取概率最高的 token,模型版本、浮点累加顺序、上游负载都会让结果变,⭐ 于是测试会绿很久然后某天突然全红。快照更糟是因为被无脑更新后它从不失败,⭐⭐ 却占着「已经测过」这个位置。- 因为它唯一的理由是回答假客户端答不了的问题:上游今天还长这个样子吗。不许挡合并,是因为它每跑一次真花钱且天生 flaky;判据 ⭐ 「它红的时候,是我的代码错了,还是模型今天心情不好?」
- 看处置动作:改代码 → 本章;改 prompt / 换模型 → Evals;看漂移 / 重训 / AB → 《模型上线之后》。⚠️ 混在一起,两周内团队会把作业设成允许失败 —— ⭐ 真正的软件 bug 也一起被放过去了。
- ① 真实客户端在测试环境里根本构造不出来;② ⭐⭐ 断网闸。不挂 PR 是因为 ⚠️ 任何人开个 PR 就能花你的钱,fork 的 PR 也拿不到 secret;要记账(
feature传ci_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