# 你没法给一次掷骰子写单元测试

开发者把一个 LLM 塞进系统，围着它写一个普通的通过/失败测试，眼看着它时灵时不灵，然后要么删掉测试，要么把模型 mock 到毫无意义。两种做法都错。一个概率性组件在结果有波动时并不是坏了——但「它会波动」不是停止测试它的通行证。你只是得改成去测分布，而不是测样本：给一组黄金样例带容差打分、用通过率把关、断言每次都必须成立的不变量，并在随机的内核和它外围确定性的外壳之间画一条硬边界。

- **Author:** Fedor Chishchin
- **Published:** 2026-07-03
- **Tags:** methodology, eval
- **Source:** https://fedorthinks.com/zh/blog/you-cant-unit-test-a-dice-roll

---

有个 bug，我一次又一次地看着聪明的工程师把它发上线。他们把一个 LLM 接进某个功能，然后做了件负责任的事——写测试：输入进去，断言输出恰好是那串预期的字符串。在他们的机器上通过。在 CI 里失败。重跑一次又通过了。于是他们做了两件事之一——删掉测试，或者把模型 mock 成返回一个罐头答案——于是系统里最可能出岔子的那一部分，成了唯一一个完全没有测试的部分。

错的不是那个不稳定的测试。错的是那个框架。**你是在试着给一次掷骰子写单元测试。**

## 对单次运行做断言是用错了工具

单元测试问的是一个关于确定性函数的是/否问题：给定 `x`，我是否恰好得到 `y`？对一个解析器来说这是对的问题，对一个模型来说这是个荒唐的问题——模型是被允许的，而且是 _按设计_ 被允许的，把同一件真事用五种不同的说法讲出来。跑得够多，同一个提示词给你的是一个分布，不是一个值。对着那个分布里的单次抽样做断言几乎什么都告诉不了你；它只是把那次抛硬币挪进了你的 CI 流水线。

这就是为什么 AI 编程的生产力数字那么浑浊。个人感觉快，但[有经验的开发者在真实任务上被测出来是 _更慢_ 的](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/)，团队看到的是[更多的拉取请求、但更长的评审时间和上升的返工率](https://arxiv.org/pdf/2601.18341)。这里头有很大一部分，是人们在用确定性的习惯去校验非确定性的输出，一次一个脆脆的断言，然后被淹死。

> 「它不是确定性的」是关于模型的一句真话，也是关于系统的一个偷懒借口。你没法把骰子钉住。你完全可以约束它被允许落在哪些点上。

## 测分布，别测样本

要做的事，是别再去测某一个正确答案，而开始测行为的形状：

- **给一组黄金样例打分，别去匹配字符串。** 留 30–100 对真实的输入/输出，然后用相似度、评分标准或模型充当裁判来给新的运行打分——而不是精确相等。你衡量的是质量，质量有一个区间；不是同一性，同一性没有区间。
- **用通过率把关，而不是单次一片绿。** 把这个用例跑 _N_ 次，然后要求，比方说，95% 通过。二十次里错一次不是构建坏了——那是骰子在像骰子一样行事。而通过率越过一条线 _才是_ 一个你能抓住的回归。
- **断言那些每一次都必须成立的不变量。** 内容会变；契约不能变。永远是合法的 JSON。永远不泄露另一个用户的数据。永远在 token 预算之内。对那三个必须拒绝的提示词一定拒绝。这些 _就是_ 确定性的，你像测别的任何东西一样测它们。
- **把随机的内核和确定性的外壳拆开。** 模型外围的路由、解析、校验、重试和兜底都是普通代码——把它们狠狠地做单元测试，同时把模型 mock 掉。把概率性的测试留给那唯一一个真正是概率性的边界。

## 归根结底

非确定性不是跳过测试的理由——它是换一种方式测试的理由。一个你按不住的模型，恰恰是你系统里最需要一根被量化过的牵绳的那一部分，因为[「它通常能用」正是不可靠软件从内部感受起来的样子，一直到它突然不能用的那一刻](/zh/blog/smarter-but-not-more-reliable)。

**别再对着一次走运的投掷做断言。给一组黄金样例带容差打分、用通过率把关、把不变量锁死，然后像你的命全系于此一样去给那个确定性的外壳写单元测试——因为在生产环境里，正是那个外壳在托着骰子。**
