我把大模型当成一个"同样的输入必然给同样输出"的普通函数来用,做了缓存、写了断言固定结果的测试,结果缓存老是不命中、测试三天两头挂,排查半天才明白大模型本质是概率采样、压根不保证每次输出一字不差的深度复盘
这是一次让我对"同样的输入,是不是一定给同样的输出"这个我从未怀疑过的前提,有了刻骨认知的事故。我在系统里接了个大模型(LLM),让它根据输入生成一段结构化结果。我下意识地把它当成一个普通函数来对待——就像 f(x):同样的 x,必然得到同样的 f(x)。基于这个"天经地义"的假设,我做了两件事:一是给它的输出做了缓存(以为同样输入算一次、存下来就能复用);二是写了单元测试,断言"输入这段、输出必须等于那段固定文本"。
可上线和跑测试后,怪事接连不断:那个测试三天两头失败——同样的输入,这次输出和我断言的"标准答案"差了几个字、调整了下措辞,断言就挂了;而我做的缓存逻辑也总是出问题。更让我抓狂的是它毫无规律:有时输出和上次一模一样、有时又冒出个意思相同但用词不同的版本。我一度以为是我代码哪里有随机性、是缓存 key 算错了,查了半天全对不上。直到我把同一段输入连续喂给大模型十几次、把每次输出并排一看,才恍然大悟:同样的输入,大模型每次的输出竟然都略有不同!——我从一开始就错得离谱:大模型根本不是一个"同样输入必给同样输出"的确定性函数。
故障现场:同样的输入,大模型每次输出都不完全一样
我把那个"同输入、不同输出"的现象还原出来,问题一目了然:
# 我的错误假设: 把 LLM 当成确定性函数 f(x), 同 x 必同 f(x)
prompt = "用一句话总结这段文字: ..."
# 同样的 prompt, 连续调用多次, 看输出:
for i in range(5):
print(llm(prompt))
# 输出(每次都略有不同!):
# "这段文字主要讲了 A 和 B。"
# "本文主要阐述了 A 与 B 两点。" ← 意思一样, 措辞不同
# "这段文字主要讲了 A 和 B。" ← 又和第一次一样了
# "文章核心是 A、B 两个方面。" ← 又变了
# ...
# ↑↑↑ 同样输入, 输出时同时不同 → LLM 不是确定性函数!
# 于是我基于"确定性"做的东西全崩了:
# 1) 测试: 断言输出等于固定文本 → 时灵时不灵
def test_summary():
assert llm(prompt) == "这段文字主要讲了 A 和 B。" # 经常 != → 测试挂 ✗
# 2) 缓存: 我曾想"对输出做指纹当 key" 或 "比对两次输出是否一致"
# → 输出不稳定, 指纹对不上 / 比对总不相等 ✗
# 注: 即便设 temperature=0, 多数 LLM 也【不保证】100% 逐字确定
# (浮点累加顺序、并行计算、后端实现等都会引入细微非确定性)
看着那五次"意思相同、措辞各异"的输出,我才彻底明白:大模型本质上是一个概率模型——它在每一步根据概率分布采样下一个词,所以同样的输入,完全可能走出不同(但通常都合理)的措辞路径。它给的是"一个符合要求的答案",而不是"那个唯一确定的答案"。哪怕我把随机性参数 temperature 调成 0、让它尽量选概率最高的词,大多数大模型在工程实现上仍不保证 100% 逐字一致(浮点运算顺序、并行、后端版本等都会带来细微差异)。我却理所当然地把它当成了 f(x)=同样结果 的确定性函数,在这个根本不成立的地基上,盖起了"断言固定输出的测试"和"依赖输出一致的缓存"——地基一塌,上面全垮。我要的是"每次都一字不差",而它给的是"每次都对、但不保证一字不差"。
第一件事:搞懂大模型的非确定性——它是概率采样,不是确定性函数
冷静下来,我去把"LLM 的非确定性"这一课认真补了,才明白我错在了哪个最根本的假设上:
【大模型为什么"同样输入、不保证同样输出"】
LLM 的本质:
- 它是个【概率模型】: 给定上文, 它算出"下一个词"的【概率分布】,
然后从这个分布里【采样】一个词, 如此逐词生成
- temperature 等参数控制采样的"随机程度":
temperature 高 → 更随机、更有创造性、输出更发散
temperature 0 → 尽量选概率最高的词(贪心), 趋于稳定
- 但即使 temperature=0, 多数生产级 LLM 也【不保证】逐字完全一致:
浮点运算的累加顺序、GPU 并行、批处理、后端版本变化……
都会引入细微的非确定性, 可能让某一步选了另一个近似概率的词
→ 一步不同, 后续可能"差之毫厘谬以千里", 整段措辞都变了
关键结论:
LLM 不是 f(x)=确定结果 的【确定性函数】, 而是【概率性生成器】:
- 同样输入, 它给的是"一个合理的答案", 不保证是"那个唯一的、逐字固定的答案"
- 你能依赖的, 是输出的【语义/意图/结构大致正确】,
【不能】依赖输出【逐字逐句完全一致】
我的根本错误:
把一个【概率性、非确定】的东西, 当成了【确定性函数】,
并在这个错误前提上, 建了"断言固定输出""依赖输出一致"的逻辑 → 必崩。
这一下点醒了我:我所有问题的根源,都在一个我从未质疑过的前提上——"同样的输入,一定得到同样的输出"。这个前提对传统的、确定性的函数成立,我用了这么多年、早已当成空气一样的常识;可大模型恰恰打破了它:它是概率采样的产物,同样的输入,给的是"一个对的答案",而非"那个一字不差的答案"。我没意识到自己接入的是一个全新性质的东西,仍用对待普通函数的老习惯去对待它——于是"缓存复用""断言固定输出"这些建立在确定性之上的做法,全都失了根基。不是大模型"不稳定"有毛病,是我用错了它的"说明书",把一个概率生成器当成了确定性函数。
第二件事:正解——别依赖逐字一致,按语义/结构校验,缓存按输入而非输出
找到根因,正解就清晰了:承认大模型输出的非确定性,把一切依赖"逐字一致"的逻辑,改成依赖"语义/结构正确"——测试断言不变量(JSON 能解析、字段齐全、含关键信息)而非固定文本;缓存以"输入"为 key(而非比对输出);要更稳就降 temperature、约束输出格式。让系统建立在大模型真正能提供的保证之上。
# 错误: 断言输出逐字等于固定文本(依赖确定性, 必然时灵时不灵)
def test_bad():
assert llm(prompt) == "这段文字主要讲了 A 和 B。" # ✗ 措辞一变就挂
# 正解1: 测试断言"不变量/语义/结构", 而非逐字文本
def test_good():
out = llm("提取信息, 返回 JSON: ...")
data = json.loads(out) # 结构: 能解析成 JSON
assert set(data) >= {"name", "age"} # 字段齐全
assert isinstance(data["age"], int) # 类型正确
# (要校验语义可再用一个 LLM/规则判断"是否包含关键信息", 而非比字符串)
# 正解2: 缓存以【输入】为 key, 而不是比对/指纹化输出
import hashlib
def cached_llm(prompt):
key = hashlib.sha256(prompt.encode()).hexdigest() # key 来自【输入】
if key in cache:
return cache[key] # 同输入直接复用上次那个答案
out = llm(prompt)
cache[key] = out # 存下来, 锁定一个答案供复用
return out
# ↑ 这样"同输入同输出"是【我用缓存强制实现的】, 而非指望 LLM 天生保证
# 正解3: 要更稳定/可控, 从源头约束:
# - temperature 调低(趋稳, 但仍不保证逐字)
# - 用结构化输出 / function calling / JSON schema 约束格式
# - 给固定 seed(部分 API 支持, 提高可复现性但仍非绝对)
这套做法的精髓,是把"我对系统的依赖",从"大模型给不了的保证(逐字确定)",挪到"大模型能给的保证(语义/结构大致正确)"上:测试不再赌它每次吐出一模一样的字,而是校验"它是不是干了对的事"(JSON 合法、字段齐、含关键信息);缓存不再幻想它天生幂等,而是我自己用缓存把"同输入复用同一个答案"这件事实现出来。这样,大模型的非确定性就不再是 bug,而是被我接纳、并妥善安置的一个特性。不是去消灭它的不确定,而是不在它不确定的地方,押上我系统的确定性。
【和 LLM 的非确定性相处, 几条原则】
1. 别依赖"逐字一致": 它保证语义/意图大致对, 不保证每次一字不差
2. 测试断言"不变量": 能否解析、字段是否齐全、类型/范围、是否含关键信息
—— 而不是 assert 输出 == 某段固定文本
3. 缓存以【输入】为 key: "同输入同输出"由你的缓存实现, 别指望 LLM 天生幂等
4. 要更稳: temperature 调低 + 结构化输出约束(JSON schema/function calling)
+ 固定 seed(若支持)—— 提高可复现性, 但仍非 100% 保证
5. 凡是后续逻辑"假设 LLM 输出可预测/可对账/可逐字比较"的地方, 都要重新设计
6. 心智转变: LLM 是"概率生成器", 不是"确定性函数"——按它的真实性质来用
第三件事:其他"误把非确定的东西当确定来用"的同类坑
顺着"别假设它确定/可预测"这条线,我把系统里同类的坑都排查了一遍,它们都源于"把一个有不确定性的东西,当成了稳定可预测的":
第一个,依赖 LLM 输出的格式永远正确。让它返回 JSON,就假设它一定是合法 JSON,不做解析容错——结果偶尔多句解释、少个括号就崩。要校验+重试+兜底。
第二个,用 LLM 输出做精确对账/去重。拿两次生成结果比对是否一致来判断"有没有变",可它本就不保证一致,对账永远报"变了"。要按语义而非逐字比。
第三个,把"概率正确"当成"一定正确"。LLM 大概率给对答案,但有小概率幻觉/出错,关键决策若全盘信任、不加校验或人审,迟早被那个小概率坑。
第四个,外部 API/网络/时间等本就含不确定性的依赖。把"调用一定成功""耗时一定很短""顺序一定固定"当成铁律,不做超时/重试/乱序处理——本质同样是把不确定当成了确定。
第四件事:确定性函数 vs 大模型,一张表分清能依赖什么
我把"传统确定性函数"和"大模型"的关键差别整理成一张表,这是我现在接入大模型时,提醒自己"能依赖它什么、不能依赖它什么"的依据:
| 维度 | 传统确定性函数 f(x) | 大模型 LLM |
|---|---|---|
| 同样输入 | 必得同样输出 | 输出语义相近,但常不逐字一致 |
| 本质 | 确定性映射 | 概率采样生成 |
| 能依赖 | 输出逐字可预测 | 语义/意图/结构大致正确 |
| 不能依赖 | — | 逐字一致、可精确对账、可指纹去重 |
| 缓存 | 天然可缓存 | 需以输入为 key 自己缓存 |
| 测试 | assert 等于固定值 | assert 不变量/语义/结构 |
这张表让我看清:大模型和传统函数是两类性质不同的东西——我可以依赖传统函数"逐字可预测",但只能依赖大模型"语义大致正确"。把对前者的依赖习惯,原封不动套到后者身上,就会在"逐字一致"这种它给不了的保证上栽跟头。用对它,就得只在它能担保的地方依赖它。
第五件事:我对"大模型输出"的几个想当然
这次事故,本质是我把大模型想当然地当成了一个确定性函数。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "同样输入,大模型必给同样输出" | 它是概率采样,同输入常给语义相近但措辞不同的输出 |
| "temperature=0 就完全确定了" | 趋稳但仍不保证逐字一致(浮点/并行/后端等) |
| "可以断言它的输出等于某段固定文本" | 措辞一变断言就挂;该断言语义/结构不变量 |
| "它输出一致,可以拿来对账/去重" | 本就不保证一致,精确对账必然失效 |
| "它大概率对,就当它一定对" | 有小概率幻觉/出错,关键处要校验/人审 |
| "接 LLM 和调个普通函数没区别" | 性质完全不同,依赖确定性的逻辑都要重设计 |
第六件事:在系统里接大模型时,我现在的自检习惯
现在每当我要在系统里接入大模型、或排查"大模型相关的逻辑时灵时不灵",我都会先按这张图问自己:
这张图的精髓,是"先查我的逻辑是不是偷偷假设了'同输入同输出';是就把依赖从逐字一致改成语义/结构正确"。设计就测试断言不变量、缓存以输入为 key、输出做解析容错、排查就看时灵时不灵是不是因为我把概率生成器当成了确定性函数。这套习惯,让我从"把 LLM 当普通函数"变成了"按概率生成器的真实性质来用它"——核心始终是:大模型本质是概率模型、逐词采样生成,同样输入给的是"一个语义合理的答案"而非"那个唯一逐字固定的答案",即便 temperature=0 也不保证 100% 逐字一致(浮点/并行/后端等带来细微非确定性);它不是确定性函数 f(x),你能依赖它语义/意图/结构大致正确、不能依赖逐字一致/可精确对账/可指纹去重;正解是测试断言不变量而非固定文本、缓存以输入为 key 自己实现幂等、输出做结构与语义校验容错,把系统建在它真能提供的保证上。
我立下的几条规矩
这场"把大模型当确定性函数、缓存测试全崩"的事故,换来了我做 AI 应用时,刻进骨子里的几条铁律:
- 大模型是概率生成器、不是确定性函数:同样输入给的是语义合理的答案,不保证逐字一致。
- 即便 temperature=0 也别指望 100% 逐字确定(浮点累加、并行、后端版本都会引入细微差异)。
- 能依赖它的语义/意图/结构大致正确,不能依赖逐字一致、精确对账、输出指纹去重。
- 测试断言"不变量"(能解析、字段齐、类型对、含关键信息),而非断言等于某段固定文本。
- 缓存以"输入"为 key,"同输入同输出"由我的缓存实现,别指望大模型天生幂等。
- 要更稳:降 temperature、用结构化输出/JSON schema 约束、给 seed,但仍非绝对保证。
- 凡是后续逻辑假设了 LLM 输出可预测/可逐字比较的地方,都要按"它是非确定的"重新设计。
附:我现在给 LLM 调用统一套的"非确定性容错包装"
这是我现在调用大模型时固定套的一层"非确定性容错包装"——它把"承认输出非确定、按结构校验、失败重试、以输入缓存"这几条教训,一次性焊进了一个函数里,让上层代码再也不用直接面对那个会变来变去的原始输出:
import json, hashlib
def robust_llm_json(prompt, schema_keys, max_retry=3):
""" 调 LLM 要 JSON: 以输入缓存 + 解析容错 + 结构校验 + 重试 """
key = hashlib.sha256(prompt.encode()).hexdigest()
if key in cache: # 缓存以【输入】为 key, 由我实现幂等
return cache[key]
last_err = None
for attempt in range(max_retry): # 非确定 → 失败就重试, 别指望一次到位
raw = llm(prompt, temperature=0) # 降随机(趋稳, 但仍不保证逐字)
try:
# 容错: LLM 可能在 JSON 前后多说几句, 抽取出 JSON 段再解析
text = raw[raw.find("{"): raw.rfind("}") + 1]
data = json.loads(text) # 校验"能否解析"这个结构不变量
if not set(schema_keys) <= set(data): # 校验字段齐全这个不变量
raise ValueError(f"缺字段: {set(schema_keys) - set(data)}")
cache[key] = data # 校验通过才缓存, 锁定一个答案
return data
except Exception as e:
last_err = e # 这次输出不合格(措辞/格式偏了), 重试
raise RuntimeError(f"LLM 输出 {max_retry} 次都不合格: {last_err}")
# 上层只依赖"拿到一个结构合法、字段齐全的 dict"(语义/结构层面的保证),
# 完全不依赖"每次返回逐字一样"(LLM 给不了的保证)
这个包装把我这次的教训钉死在了调用层:它不再奢求大模型"每次一字不差"(那是它给不了的),而是只依赖"能解析成结构合法、字段齐全的 JSON"(那是可校验、可重试逼出来的);解析失败/字段缺失就重试,通过校验才缓存,缓存以输入为 key。有了它,大模型的非确定性被牢牢圈在了这一层之内,上层拿到的永远是一个干净、合法、可放心使用的结构——我终于不必再在每个调用点,提心吊胆地面对那个会变来变去的原始字符串。把对一个新事物性质的认知,沉淀成一层稳稳兜住它的代码,这本身就是真正接纳了它的标志。
写在最后
回头看,这场由"把大模型当确定性函数"引发的"缓存不命中、测试乱挂"事故,真正教给我的,远不止"测试断言别写死"这一个技巧。它让我对"当我们接触一个'新事物'时, 最危险的, 不是它哪里难懂, 而是我们不自觉地用'旧事物的脾性'去套它——我们带着一身对旧世界的'常识'(比如'同样的因必有同样的果'), 把它当成想当然的前提, 而没意识到这个新事物, 恰恰在某个根本性质上, 和旧事物截然不同",有了一次刻骨的体会。我栽跟头,是因为我用'确定性函数'这个旧范式, 去套'概率生成器'这个新事物——"同样的输入必给同样的输出", 这个在我过往全部编程经验里都成立的'铁律', 早已内化成我不假思索的前提;我接入大模型时, 压根没想过去问"它还遵守这条铁律吗", 就理所当然地把它当成了又一个 f(x);我没意识到, 我面对的是一个性质全新的东西——它给的是'一个合理的答案'而非'那个确定的答案'; 我用旧地图, 走进了新大陆。这让我领悟到一个关于"新事物、旧范式与隐含前提"的深刻认知:我们对世界的理解, 是由一整套'习以为常、几乎从不拿出来检视'的隐含前提(范式)支撑的; 这套前提在它适用的旧领域里无往不利, 也正因如此, 我们会忘记它只是'前提'而非'绝对真理';当一个新事物出现、而它恰好打破了某条我们视为天经地义的前提时, 真正的陷阱不在于'新事物的特性'本身, 而在于我们仍用旧前提去理解它、并把建立在旧前提上的一整套做法照搬过来——于是处处碰壁, 还百思不得其解;所以拥抱一个新事物, 最关键的一步, 不是急着学它'能做什么', 而是先搞清楚它'打破了我哪条原来视为理所当然的前提'——因为正是那条被打破的前提之上, 藏着我即将踩的所有坑。这给了我一种看待"一切'接触/采用一个新技术、新工具、新范式'之事"时的清醒:每当我开始用一个新事物时,要追问"我正在用什么'旧的常识/前提'来理解它?这个新事物, 是不是恰恰在某条我从未怀疑的前提上, 和我熟悉的旧事物根本不同?"——主动去识别并检视那些被自己当成空气的隐含前提, 看清新事物到底改写了哪一条, 而不是把旧范式不假思索地套上去;"识别并校验自己理解新事物时所用的隐含前提、看清它改写了哪条旧规则",是用对新工具、也是真正拥抱任何新范式的关键。认清大模型是概率生成器而非确定性函数、它打破了"同输入同输出"这条旧铁律、要依赖它的语义而非逐字——这,是我用一次缓存测试全崩的事故,换来的、关于 AI、也关于如何用新眼光看待新事物的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次把大模型接进系统、顺手想给它做缓存或写断言时,先想想"它真的同输入同输出吗?我是不是又把它当成普通函数了?",并把依赖建到它的语义而非逐字上,那我对着那个"同样输入、输出却总差几个字"的诡异现象折腾的大半天,就值了。
—— 别看了 · 2026