一套给大模型功能写的"断言输出完全相等"的单元测试,今天通过明天就挂、同样的输入每次结果还不一样,把我整懵了:一次 LLM 非确定性的深度复盘
那是我刚开始做大模型功能时撞的一面墙:我给一个"让 LLM 把用户问题分类、并生成回复"的功能写了单元测试,按我做了十几年传统软件的习惯,测试就是"给定输入 X,断言输出等于预期的 Y"。可这套测试诡异极了:今天跑全绿,明天跑就红一片;我啥都没改,同一个输入,模型这次返回的回复和上次用词不一样、结构不一样,我断言"输出 == 预期字符串"自然就挂了。更头疼的是,线上有个用户反馈"回答得不对",我拿同样的问题去复现,模型给出的却是另一个(还挺对的)回答——我根本复现不了那个出问题的回答。我一度怀疑是不是哪里有随机 bug,直到我接受了一个让我这个"传统软件人"很不适应的事实,后背发凉:大模型本质上是非确定性(non-deterministic)的——同样的输入,它每次的输出可能不一样。这是因为它在生成每个词时,是从一个概率分布里"采样"下一个词,而采样带有随机性(尤其当 temperature 这个"随机性"参数大于 0 时)。所以"同输入同输出"这个我视为天经地义的假设,在 LLM 这里根本不成立。而我的测试方式("断言输出精确等于某个字符串")、我的复现思路("同样输入应得到同样的错误"),全都建立在这个不成立的假设上,自然全乱套了。问题的根,不是模型有 bug,而是我用对待"确定性系统"的方式,去对待一个"概率性系统"。这篇就把这次"LLM 非确定性、测试与复现困境"的坑,从头到尾复盘一遍。
故障现场:用"断言相等"测一个非确定性的东西
问题不在某行代码,而在用错了"验证范式":
# ✗ 出问题的测试: 用"断言输出精确相等"测LLM(传统软件的测法)
def test_classify_and_reply():
output = llm_classify_and_reply("我想退货")
# ✗ 断言输出等于一个固定的预期字符串
assert output == "类别:退货。回复:好的,请提供您的订单号,我们将为您办理退货。"
# → 今天可能过, 明天模型生成的措辞/结构变了一点点, 就 != 预期字符串 → 测试挂!
# (即使两次回答"意思一样、质量都好", 字符串也不相等)
# 为什么LLM是非确定性的:
# - LLM生成文本: 每一步从"下一个词的概率分布"里【采样】一个词;
# - 采样带随机性(受temperature/top_p等参数控制): temperature越高越随机、越有创造性;
# - → 同样的输入, 多次生成的结果可能在【用词、句式、结构】上不同(即使意思相近);
# - 即使 temperature=0(贪心解码, 尽量取最高概率), 也【不保证】100%一致
# (浮点计算、并行、模型版本、后端实现等因素仍可能带来细微差异)。
# 我撞墙的两件事:
# 1. 测试: 用 assert output == 固定字符串 → 输出一变就挂 → 测试不稳定(flaky);
# 2. 复现: 线上出问题的那次回答, 我用同样输入再跑, 得到的是【另一个】回答 → 复现不了。
# 根本认知错位:
# - 传统软件: 确定性的 —— 同输入同输出, 所以能"断言相等"、能"同输入复现";
# - LLM: 概率性的 —— 同输入可能不同输出, 所以"断言相等"和"同输入必复现"都不成立。
# 关键: LLM是非确定性的(采样有随机性), 同输入可能不同输出; 用"断言精确相等"测它会flaky、
# "同输入复现"也不可靠——必须换一套适应"概率性"的验证和排查方式。
第一次接受"它本来就不确定"时,我这个传统软件人很不适应:"一个'同样输入会给不同结果'的东西,我怎么测它、怎么信它、怎么查它的问题?"这个坑最颠覆的地方在于:它动摇了软件工程里一块最基础的基石——"确定性"(同输入同输出)。我们几十年积累的测试方法(断言相等)、调试方法(复现)、缓存(同输入用缓存),几乎都默认建立在"确定性"之上;而 LLM 抽掉了这块基石,让这些方法集体失灵。下面就来拆解,该怎么和"非确定性"打交道。
第一件事:搞懂 LLM 为什么非确定性,以及它意味着什么
我认真梳理了 LLM 的非确定性,才理解该怎么调整方法。
LLM 为什么是非确定性的? 它意味着什么?
【核心: LLM生成靠"采样"(带随机性), 同输入可能不同输出; 这让"断言相等""同输入复现""缓存"等基于确定性的方法失灵】
1. 为什么非确定性:
- LLM每生成一个词, 是从"下一个词的概率分布"里【采样】;
- 采样的随机性由参数控制: temperature(温度)越高越随机, 越低越确定(趋向取最高概率);
- → 同输入多次生成, 结果可能在用词/句式/结构上不同(即使语义相近)。
2. temperature=0 也不完全保证一致:
- temperature=0 是"贪心解码"(每步取概率最高的词), 大大提高一致性;
- 但仍不100%保证: 浮点运算误差、并行计算顺序、模型版本变更、API后端实现, 都可能带来差异;
- → 别假设"设了temperature=0就和传统函数一样完全确定"。
3. 非确定性让哪些"基于确定性的做法"失灵:
- 测试: "assert 输出==固定值" → 输出一变就挂(flaky); 要改成验证"语义/属性"而非"精确相等";
- 复现: "同输入复现同问题" → 不可靠(再跑可能是另一个输出); 要靠日志记录"当时的实际输出";
- 缓存: "同输入用缓存" → 可行但要接受"缓存的是某一次的结果"(对话类可能不适合缓存);
- 调试: 不能像传统bug那样"稳定复现→单步";要靠"评测集统计"看整体质量。
4. 怎么和非确定性相处:
- 接受它: LLM是概率性的, 别期待它像传统函数一样确定;
- 降低随机性: 需要稳定时, temperature调低(0或接近0);
- 改变验证方式: 用"评测集+质量指标(准确率等)"评估整体表现, 而非单条精确断言;
- 记录现场: 把每次的实际输入输出都记日志, 复现/排查靠"记录"而非"重跑"。
一句话: LLM靠采样生成、有随机性、同输入可能不同输出(temperature=0也不完全保证一致);
这让"断言相等/同输入复现/缓存"等确定性方法失灵; 要接受概率性、降随机性、用评测和日志取代精确断言和重跑。
这套认知,是整个坑的根。为什么非确定性:LLM 每生成一个词是从"下一个词的概率分布"里采样,采样的随机性由 temperature 控制(越高越随机),同输入多次生成结果可能不同。temperature=0 也不完全保证一致:它是贪心解码、大大提高一致性,但浮点误差/并行顺序/模型版本/后端实现仍可能带来差异——别假设它像传统函数一样完全确定。非确定性让哪些做法失灵?测试(断言相等会 flaky→改成验证语义/属性)、复现(同输入复现不可靠→靠日志记录实际输出)、缓存(可行但缓存的是某一次结果)、调试(不能稳定复现→靠评测集统计看整体质量)。怎么相处?接受它是概率性的、需要稳定时 temperature 调低、用评测集+质量指标评估整体而非单条精确断言、记录每次实际输入输出靠记录而非重跑。一句话:LLM 靠采样生成、有随机性、同输入可能不同输出(temperature=0 也不完全保证一致);这让"断言相等/同输入复现/缓存"等确定性方法失灵;要接受概率性、降随机性、用评测和日志取代精确断言和重跑。
第二件事:正解——测语义/属性而非相等,降温度,记日志,用评测集
搞懂了原理,正解就清晰了:测试改成验证"语义/属性/结构"而非精确相等;需要稳定时降低 temperature;全程记录实际输入输出(复现靠日志);用评测集衡量整体质量。
# ====== 正解一: 测"属性/语义", 而非"精确相等" ======
def test_classify_and_reply():
output = llm_classify_and_reply("我想退货")
# ✓ 不断言"输出==固定字符串", 而是验证它该满足的【属性】:
assert "退货" in output # 包含关键信息
assert is_valid_format(output) # 结构/格式正确(如能解析出类别和回复)
assert get_category(output) == "退货" # 分类正确(语义层面)
# 或: 用另一个模型/规则判断"这个回复是否礼貌、是否相关"(LLM-as-judge / 语义断言)
# → 验证"它做对了该做的事(属性/语义)", 而不是"它一字不差地等于某个预期" → 不会因措辞变化而flaky。
# ====== 正解二: 需要稳定/可复现时, 降低 temperature ======
resp = llm.chat(messages, temperature=0) # 分类、抽取等"要稳定"的任务用低温度
# → 大幅提高一致性(但仍不保证100%); 创意类任务(写文案)才用高温度。
# ====== 正解三: 全程记录实际的输入输出(复现靠日志, 不靠重跑) ======
# log: 记录每次请求的 {输入, 完整prompt, 模型版本, 参数, 模型的实际输出, 时间}
# → 线上出问题时, 直接看日志里"当时模型实际返回了什么", 而不是"用同样输入再跑一次(可能跑出不同结果)";
# 非确定系统的"复现", 本质是"找到当时的真实记录"。
# ====== 正解四: 用"评测集 + 质量指标"衡量整体质量(取代单条精确断言) ======
# - 准备一批 (输入, 期望属性/参考答案) 的评测样本;
# - 跑模型, 用指标衡量整体: 分类准确率、回复相关性评分、格式合规率等;
# - → 因为单条是非确定的, 但【在一批样本上的整体质量】是可以稳定衡量和对比的;
# 改了prompt/换了模型, 看评测指标涨了还是跌了, 而非看某一条变没变。
# ====== 几条相处原则 ======
# 1. 接受非确定性: 别期待LLM像传统函数一样"同输入同输出"; 它是概率性的;
# 2. 该稳定就降温度(0); 该创意就高温度; 按任务选;
# 3. 测试验"属性/语义", 不验"精确相等"; 用LLM-as-judge或规则判断质量;
# 4. 复现靠"记录"(日志记下每次实际I/O), 不靠"重跑";
# 5. 质量评估靠"评测集+指标"(整体统计), 不靠"单条断言";
# 6. 缓存要谨慎: 可缓存"确定性强(低温)、可复用"的结果, 但要清楚缓存的是"某一次"的输出。
# 核心: 接受LLM非确定; 测试验语义/属性(非相等)、用LLM-as-judge/评测集; 需稳定就降temperature;
# 复现靠日志记录(非重跑)、质量靠评测集统计(非单条断言); 用适应"概率性"的方法, 别套确定性的老办法。
修复的核心,是"用适应'概率性'的方法,取代基于'确定性'的老办法"。正解一:测属性/语义而非精确相等——不断言"输出==固定字符串",而验证它该满足的属性(包含关键信息、格式正确、分类对、或用 LLM-as-judge 判断质量),不因措辞变化而 flaky。正解二:需要稳定时降低 temperature(分类/抽取用 0,创意任务才高温度)。正解三:全程记录实际输入输出——复现靠"看日志里当时实际返回了什么",而非"用同样输入重跑(可能跑出不同结果)"。正解四:用评测集+质量指标衡量整体——单条非确定,但一批样本上的整体质量(准确率/相关性)可稳定衡量对比。归根结底:接受 LLM 非确定;测试验语义/属性(非相等)、用 LLM-as-judge/评测集;需稳定就降 temperature;复现靠日志记录(非重跑)、质量靠评测集统计(非单条断言);用适应概率性的方法,别套确定性的老办法。
第三件事:LLM 工程里"非确定性"带来的其他挑战
排查后我把 LLM 非确定性带来的其他挑战也系统梳理了一遍。
LLM 非确定性带来的其他挑战
# 1. 测试flaky(本文): 断言相等会因输出变化而挂。→ 验属性/语义、用评测集。
# 2. 难复现: 同输入再跑结果不同。→ 记录每次实际I/O, 靠日志复现。
# 3. 缓存困难: 同输入未必同输出, 缓存可能"锁住"某一次结果。→ 谨慎缓存, 低温/可复用才缓存。
# 4. 回归测试难: 改prompt后, 没法"对比输出是否完全一致"。→ 用评测集对比整体质量指标。
# 5. A/B和迭代难量化: 改动效果难判断。→ 建评测集, 量化指标变化(prompt/模型版本对比)。
# 6. 用户体验不一致: 同样问题不同用户/不同次得到不同回答。→ 接受/或降温度提高一致性。
# 7. 上游模型版本变更: 模型升级后行为变, 原来调好的prompt可能效果变。→ 锁版本/重新评测。
# 8. 把它当确定性API集成: 上下游系统假设它确定 → 出问题。→ 在边界做校验/兜底/容错。
# 共同根源: LLM是"概率性、非确定"的, 而我们的工程方法论(测试/调试/缓存/集成)大多建立在
# "确定性"假设上; 把概率性的东西套进确定性的方法里, 这些方法就会失灵或给出虚假的安心。
# 核心: 把LLM当"概率性组件"对待: 测语义/属性、用评测集量化、记录日志复现、谨慎缓存、锁模型版本、
# 边界校验兜底; 用一套适应"非确定性"的新方法论, 别用确定性时代的老办法生搬硬套。
排查让我把非确定性的其他挑战也梳理清了。一、测试 flaky(本文)。二、难复现(靠日志)。三、缓存困难(谨慎缓存)。四、回归测试难(用评测集对比整体)。五、A/B 和迭代难量化。六、用户体验不一致。七、上游模型版本变更行为变。八、把它当确定性 API 集成。它们的共同根源是:LLM 是概率性、非确定的,而我们的工程方法论(测试/调试/缓存/集成)大多建立在确定性假设上;把概率性的东西套进确定性的方法里,这些方法就会失灵或给出虚假的安心。核心是:把 LLM 当概率性组件对待:测语义/属性、用评测集量化、记录日志复现、谨慎缓存、锁模型版本、边界校验兜底;用一套适应非确定性的新方法论,别用确定性时代的老办法生搬硬套。下面这张图,是这次非确定性坑的成因与解法:
第四件事:确定性系统 vs 概率性系统的方法论对比表
这次踩坑后,我把"对待确定性系统"和"对待概率性系统(LLM)"的方法论对比成一张表。
| 维度 | 确定性系统(传统软件) | 概率性系统(LLM) |
|---|---|---|
| 同输入同输出 | 是 | 否(可能不同) |
| 测试 | 断言精确相等 | 验语义/属性、评测集、LLM-as-judge |
| 复现 | 同输入重跑 | 靠日志记录当时的实际I/O |
| 质量衡量 | 单条用例通过 | 评测集上的整体指标 |
| 缓存 | 同输入用缓存 | 谨慎(缓存某一次结果) |
| 调试 | 稳定复现→单步 | 统计分析→改prompt/参数 |
这张表把两套方法论的差异钉清了。核心是:对待 LLM 这种概率性系统,几乎每一个工程环节(测试/复现/质量/缓存/调试)都要从"确定性时代的做法"切换到"概率性时代的做法"——从"追求精确相等、单条通过、同输入复现"转向"验证语义属性、整体统计、靠记录复现"。它给我的最大启发是:LLM 的到来,不只是多了一个新工具,而是引入了一种"新的计算范式"——从"确定性的、可精确预测的"计算,转向"概率性的、统计意义上可靠的"计算;我们过去几十年沉淀的、建立在"确定性"假设上的整套工程实践,需要被审视、调整、甚至重建,才能适应这个新范式。这让我对身处的技术变革有了体会:当一个底层假设(如"确定性")被打破时,建立在它之上的整套方法论都需要重新思考——不能想当然地把"旧范式的工具和直觉"原封不动地用在"新范式"上(就像我把断言相等用在 LLM 上);"识别出底层假设的改变、并相应地更新自己的方法论",是在技术范式转变期保持有效的关键——也是从"会用新工具"到"真正适应新范式"的跨越。认清 LLM 带来的是从确定性到概率性的范式转变、相应更新整套方法论——是这个坑带给我的认知。
第五件事:非确定性不全是坏事
这次让我换个角度想:LLM 的非确定性,既是挑战,也是它能力的来源。我对比成表。
| 视角 | 非确定性带来的 |
|---|---|
| 挑战(本文) | 测试难、复现难、不可缓存、不一致 |
| 能力来源 | 创造性、多样性、灵活应变(同一意思能多种表达) |
| 该控制的场景 | 分类/抽取/格式化(要稳定)→降温度 |
| 该利用的场景 | 写作/创意/头脑风暴(要多样)→高温度 |
| 本质 | 是特性, 不是缺陷; 要按场景管理 |
这张表让我换了个角度看非确定性。核心是:LLM 的非确定性不全是"要克服的麻烦",它也是"能力的来源"——正是因为它能"从概率分布里采样、有随机性",它才能创造性地、多样地表达(同一个意思换十种说法、写出不重复的文案);如果它完全确定(每次一字不差),它就失去了灵活和创造力;非确定性是它"智能、灵活"的一体两面。它给我的深刻启发是:一个特性的"好"与"坏",往往取决于场景,而非特性本身——非确定性在"要稳定的任务"(分类/抽取)里是麻烦(要降温度控制它),在"要创意的任务"(写作)里是优点(要利用它的多样);"同一个特性,在不同场景下,可能是缺陷也可能是优势"——关键是理解它的本质、并按场景去'管理'它(该抑制时抑制、该利用时利用),而不是简单地把它当成"好"或"坏"。这给了我一种更辩证的工程视角:对待任何特性(尤其是"双刃"的),不要急于贴"好/坏"的标签,而要理解它的本质、看清它在不同场景下的两面性、并学会按场景管理它——"非确定性"该控制就用 temperature 控制、该利用就放开;就像"缓存"该用就用、该失效就失效;"抽象"该屏蔽细节就屏蔽、该穿透就穿透;"辩证地、按场景地驾驭一个特性的两面性",比"笼统地爱它或恨它"成熟得多。辩证地看待非确定性既是挑战也是能力来源、按场景管理特性的两面性——是这个坑带给我的更高层认知。
第六件事:做 LLM 功能时,我现在的验证习惯
现在每当我做一个 LLM 功能,我都会按这张图来安排验证和排查:
这张图的精髓,是"按任务定温度,测语义不测相等,靠日志复现靠评测集衡量"。要稳定就 temperature=0、要创意就高温度;测试验语义/属性而非相等、用评测集衡量整体;排查靠日志记录复现、改动后跑评测集看指标。这套习惯,让我从"用传统软件的方式做 LLM 功能"变成了"用适应概率性的方法做"——核心始终是:LLM 非确定,测语义不测相等、靠日志复现、靠评测集衡量,按任务管理 temperature。
我立下的几条规矩
这场"LLM 非确定性、测试与复现困境"的事故,换来了我做 LLM 功能时,刻进骨子里的几条铁律:
- LLM 是非确定性的,同输入可能不同输出。生成靠采样、有随机性。
- temperature=0 也不完全保证一致。别假设它像传统函数一样确定。
- 测试验语义/属性,别断言精确相等。用 LLM-as-judge、规则、评测集。
- 需要稳定就降低 temperature。分类/抽取用 0,创意任务才高温度。
- 复现靠日志记录实际 I/O,不靠重跑。重跑可能得到不同结果。
- 质量靠评测集+指标统计衡量,不靠单条断言。改动后看指标变化。
- 非确定性既是挑战也是能力来源。按场景管理(该抑制抑制、该利用利用)。
写在最后
回头看,这场由"用断言相等测一个非确定性的东西"引发的困境,真正教给我的,远不止"测 LLM 要验语义、用评测集"这一个技巧。它让我对"当一个我视为'理所当然的前提'被打破时,我必须有勇气和能力去更新建立在它之上的一整套思维习惯",有了一次刻骨的体会。我栽跟头,根源在于我把"确定性(同输入同输出)"这个前提,当成了'计算的天经地义',从未质疑过。它如此深地刻在我做了十几年软件的直觉里——测试就是断言相等、调试就是复现、缓存就是同输入用缓存——以至于当我面对一个"非确定性"的 LLM 时,我下意识地、不假思索地把这套以"确定性"为前提的方法搬了过去,却完全没意识到"那个让这套方法成立的前提,已经不在了"。我用的工具没错,错的是我把它们用在了一个"它们赖以成立的前提已被抽掉"的地方。这让我领悟到一个关于"前提与认知"的深刻认知:我们的很多知识、方法、直觉,都悄悄地建立在某些"我们习以为常、从不质疑的前提假设"之上——"同输入同输出""程序是确定的""网络是可靠的""时间是单向均匀流逝的";这些前提在它们成立的领域里没问题,但当我们进入一个新领域、而那个前提恰好不成立时,整套建立其上的认知就会失效,且我们往往意识不到(因为前提太"理所当然"了,根本想不到去质疑它)。这给了我一种面对新事物时的自觉:进入一个新领域(新范式、新技术、新系统)时,要主动地去审视"我熟悉的那套方法,是建立在什么前提上的?这些前提在新领域里还成立吗?"——尤其要警惕那些"太理所当然以至于我从没意识到它是个'假设'"的前提;"识别并质疑自己的隐含前提",是适应范式转变、不被旧直觉误导的关键认知能力——这一次,被打破的前提是"确定性",而 AI 时代,还会有更多我们习以为常的前提被改写。识别并质疑自己习以为常的隐含前提、在前提改变时更新整套方法论——这,是我用一次 LLM 非确定性的困境,换来的、关于 AI、也关于如何在范式转变中保持清醒的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给 LLM 功能写测试时,放下"断言相等"的执念、转而验证语义和用评测集,那我对着那套今天过明天挂的测试困惑的这段时间,就值了。
—— 别看了 · 2026