大模型推理输出不稳定?采样参数与硬件浮点误差全解析
2026/9/17 0:26:41 网站建设 项目流程

最近在做模型效果评估的时候遇到一件让我琢磨了一阵子的事:同一份测试集、同一个 checkpoint、同一条 prompt,前后两次跑推理,拿到的输出居然不完全一样。一开始我以为是代码里有隐藏的随机操作没注意到,后来排查了一圈才发现,这个现象在大模型推理里其实非常正常,甚至可以说,如果你从来没遇到过,反而有点反常。

这篇内容就是想把“推理阶段同一样本 LLM 输出结果不同”这件事彻底拆开讲清楚。我会从数学模型层面的随机性来源讲起,再逐层拆解到采样参数、输入侧 padding、KV Cache、浮点数精度这些容易被忽略的细节,最后给出我在实际项目里总结的一套稳定复现和排查方法。适合正在学习大模型原理、做模型评估、或者上线 LLM 服务的同学参考。

1. 先别急着怀疑模型坏了:LLM 推理输出背后的概率机制

1.1 你看到的输出,本质是从概率分布里“抽”出来的

大多数第一次接触 LLM 的朋友会下意识把大模型当成一个“输入一句话,输出一句话”的确定性函数:同一个输入,结果就应该一样。但如果你看过模型源码或者自己手动加载过一次权重,就会发现完全不是这么回事。

LLM 的核心是预测下一个 token 的概率分布。模型内部实际上是一个巨大的条件概率函数 P(y_t | x, y_1, y_2, ..., y_{t-1}),也就是给定前面的输入 x 和已经生成的历史 token,计算词表 V 中每个 token 出现的概率。每一步生成都是在整个词表上做一次概率分布预测,分布里包含的可能不是某一个 token 一枝独秀,而是很多 token 都有不小的概率。

举个例子,输入“今天天气真”,模型可能认为“好”的概率是 0.45,“棒”的概率是 0.2,“不错”的概率是 0.15,剩下 0.2 分布在其他几千个 token 上。这时如果直接用 argmax 取概率最大的那个,得到的一定是“好”。但如果用了采样(sampling),结果就变成“从整个分布中按照概率抽样”,这次抽到“好”,下次也可能抽到“棒”。

所以同一样本输出不同,首先要去查的就是:你的解码策略是不是采样模式。这一步可以直接解释掉 80% 以上的“结果不稳定”现象。

1.2 解码策略的分岔路口:贪心搜索与随机采样

推理阶段常用的解码方式大致可以分成两派。

一派是贪心搜索(Greedy Search),每一步都取概率最高的 token,因此如果模型计算完全确定,输出就是确定的。这种方式适合要求稳定输出的场景,比如信息抽取、结构化数据生成。缺点是容易陷入重复和呆板,缺乏多样性。

另一派是随机采样(Sampling),按照概率分布随机抽 token。为了让采样质量更高,又衍生出了 temperature、top_p、top_k 这些控制参数。采样模式天然带有随机性,即使模型权重、输入完全相同,只要随机种子或者底层随机状态不一致,输出就可能不同。

还有介于两者之间的 beam search,它每一轮保留 top-K 条概率最高的路径,最终输出得分最高的一条。理论上 beam search 是确定的,但如果实现里对相同分数的路径做了不稳定排序,或者浮点计算有细微差异,也可能出现不同。

我自己的经验是,遇到“输出结果对不上”,第一步就是看一眼代码里 generation 的配置:

# HuggingFace transformers 常见的参数 outputs = model.generate( input_ids, do_sample=True, # 开启采样 temperature=0.8, top_p=0.9, top_k=50 )

只要do_sample=True,结果就天然具备随机性,这不是 bug,而是设计如此。你想完全复现,就要把do_sample=False或者temperature=0,才能让输出变成贪心结果。

1.3 为什么有些场景下“随机”反而是刚需

聊到这里,可能有人会问:既然随机性会导致结果不稳定,为什么还要用采样?

因为语言生成任务本身是“一个输入,多种合理输出”的问题。比如你让模型写一段宣传文案、写一首诗、或者给一个开放性问题做头脑风暴,如果每次都返回同一句话,那模型生成能力就被浪费了。采样的意义在于让模型在概率分布中“有选择地探索”,增加输出的多样性和丰富度。

日常开发中,我一般这样区分使用场景:

  • 稳定性优先(抽取、分类、翻译、代码生成)→ 关闭采样,用贪心或固定 seed
  • 多样性优先(文案写作、对话交互、头脑风暴)→ 开启采样,并调节 temperature 和 top_p
  • 评测对比(AB 两个模型或多个 checkpoint 对比)→ 要么全关采样,要么固定所有随机因素,否则结果没有可比性

理解了这一层,后面讲的所有“输出来源差异”才能落到实际场景里去判断该不该处理。

2. 温度、top_p、top_k、seed:这四个参数是怎么联手制造“随机”的

2.1 温度不是“热度”,是概率分布的形态开关

temperature(温度)是采样模式下影响最大的参数。它的数学本质很简单:在 softmax 层里,把模型输出的 logits 除以 T 再做归一化。

softmax 的标准形式是:

softmax(z_i) = exp(z_i) / Σ_j exp(z_j)

加了温度之后变成:

softmax(z_i / T) = exp(z_i / T) / Σ_j exp(z_j / T)

当 T = 1 时,分布不变;T 越小,分布越“尖锐”,高概率 token 的概率被进一步放大;T 趋近于 0 时,分布逐渐退化成 one-hot,也就是 argmax 贪心;T 越大,分布越“平坦”,低概率 token 被抽中的概率变大,输出越随机。

从工程角度看,temperature=0通常并不是真的做除法,而是框架在内部直接切成贪心路径。HuggingFace 和 vLLM 在检测到temperature=0时会自动使用 argmax 替代采样。但如果你设置的是temperature=1e-8这种“微量非零”的值,框架还是走采样逻辑,只是在几乎所有情况下都抽到概率最大的 token,理论上仍有极小概率出现不同。

所以做实验的时候,想要绝对稳定,建议直接设置do_sample=False,不要依赖“温度极低”来近似贪心。

2.2 top_p 和 top_k:候选词的闸门

top_k 的思路最简单:每次只保留概率最高的 K 个 token,把它们重新归一化,再采样。K 越小,候选越少,输出越保守;K 越大,候选越多,越容易出现冷门词。

top_p(也叫 nucleus sampling)稍微绕一点:把 token 按概率从高到低排序,然后从累计概率超过阈值 p 的那一批 token 里采样。比如 top_p=0.9,表示只保留累计概率达到 90% 的那一小撮 token,剩下的低概率尾巴直接砍掉。

这两个参数本身不直接制造随机性,但它们决定了“采样池”的大小。池子越大,采样结果的分布越宽,同一样本多次运行时输出差异越明显。换句话说,如果你发现两次输出差异太大,除了种子问题,还可能是 top_p 或 top_k 设置得太宽松。

我踩过的一个实际坑是:微调后的对话模型在开放域问答里表现很好,但每次输出都不一样,用户反馈“同一个问题问两次,答案前后矛盾”。最终定位到是配置里同时开了 temperature=0.95、top_p=0.95、top_k=60,随机性叠加后非常强。后来把 top_k 关掉(设为 0 或 None)、top_p 降到 0.85、temperature 降到 0.7,输出稳定性明显提升,多样性也没有损失太多。

2.3 随机数种子:能固定一部分随机,但不是全部

很多人在代码里加了torch.manual_seed(42)之后发现结果还是对不上,此时容易陷入重启循环“这 seed 到底有用没用?”

实际上,seed 只对你当前进程里调用随机数生成器的顺序和初始状态有影响。如果模型推理过程中有多个随机源,比如 dropout 在推理模式本来应该关闭,但某些实现不小心开了;或者采样时用了 NumPy 的np.random,而你不小心在别处先调用了一次np.random,导致采样时拿到的是“偏移后的随机流”——这些都会让同一个 seed 得到不同结果。

更隐蔽的是 GPU 算子的非确定性。在 GPU 上做并行矩阵运算时,浮点数累加的顺序不是固定的,同一份数据在不同线程调度下计算出来的浮点结果可能有一个非常微小的差异。这个差异在逐 token 生成时会被放大,因为下一步的语言模型预测拿到的是上一步那个“有一丁点不同”的隐藏状态,几步之后就可能选出完全不同的 token。

所以更准确的说法是:seed 能固定逻辑层面的随机序列,但固定不了硬件层面的浮点不确定性。这在 CPU 上通常不是问题,在 GPU 上就需要额外手段(下面会讲)。

2.4 一组对比:同样参数下,配置差异如何影响稳定性

我把常见配置下的稳定性直观列一下,方便对照:

配置是否稳定说明
do_sample=False / temperature=0基本稳定贪心路径,输出确定性最高
temperature=0.7, top_p=0.9随机常见生成配置,稳定性一般
temperature=1.0随机性较大标准 softmax 采样,分布未压缩
temperature=0.1较稳定但极小概率仍会变
beam search, num_beams=4基本稳定但浮点误差可能导致不稳定
采样 + seed 固定部分稳定代码逻辑内可复现,硬件层面不保证

这张表是我多次实验后的经验总结,不是官方定义,但足够帮你在排查问题时快速定位方向。

3. 比采样更隐蔽的变量:batch padding、KV Cache 和浮点数运算

如果说采样是明面上导致输出随机的“主犯”,那这一节要讲的三个因素就是妥妥的“帮凶”。它们在代码里很难一眼看出来,而且往往在你想复现别人的实验结果时突然冒出来给你一拳。

3.1 同一条样本,放进不同 batch,结果竟然变了

这个现象我第一次遇到时真的很困惑:单独跑一条样本,输出稳定;和其他样本拼成一个 batch 跑,输出就不一样了。更奇怪的是,batch 里的其他样本顺序一换,结果又变了。

原因出在 padding 和 attention mask 上。LLM 训练时通常以 batch 为单位,一个 batch 里的样本长度不一,需要把短的样本 pad 到和最长样本一致,然后用 attention mask 告诉模型哪些位置是真实 token、哪些位置是 padding token。推理时,很多框架为了高速利用 GPU,也会把多条请求拼在一起做 batch 推理。

问题在于,padding 的位置虽然被 mask 掉,但在很多实现里,padding 位置的 embedding 向量依然会被灌进注意力计算(只是最终被 mask 遮住),这会产生一个极其微小的数值噪音。另外,attention 的计算里分母需要对整行做 sum,不同 batch 大小、不同 padding 形状会导致浮点累加顺序不同,结果自然有细微差异。

这种情况下,表现在最终输出上,可能是相同的,也可能是某个概率本来就很接近的 token 位置突然成了分水岭,导致输出从“很好”变成“还不错”。

所以在做严格对比实验时,我会把每条测试样本单独推理,或者保证所有对比实验使用完全相同的 batch composition(相同样本、相同顺序、相同 padding 策略),否则你没法判断分数差异来自模型能力还是 batch 构造差异。

3.2 KV Cache 截断对长文本推理的影响

第二个隐蔽变量是 KV Cache。生成阶段每个 token 都要关注历史所有 token 的 Key 和 Value,KV Cache 就是把之前算好的 K、V 缓存下来,避免重复计算。对于超长输入,有些框架会做 KV Cache 逐出(eviction)或压缩(比如 H2O、StreamingLLM 这类方法),把部分历史 token 的 KV 丢弃。

一旦发生丢弃,后续 token 的注意力计算就只基于剩余的部分历史,结果和“完整历史”肯定不一样。更麻烦的是,不同框架、不同版本的 KV Cache 策略可能不同,同一份 prompt 在不同框架里推理结果不同,很可能就是这个原因。

如果你只是做一般长度的文本生成,KV Cache 截断未必会触发;但如果你在做长文档 summarization 或者多轮对话这类长上下文场景,建议先确认你用的推理框架是否默认开启了 KV Cache 淘汰,以及它的触发条件是什么。

3.3 浮点数精度与算子的非确定性:GPU 并行累加的顺序问题

这一节稍微硬核,但值得花两分钟理解。GPU 的矩阵计算是大规模并行的,以 attention 里的缩放点积为例,要把一个向量里的元素累加成一个标量。并行时,多个线程分别算部分和,再逐层合并。不同线程调度顺序下,累加结果在浮点数层面可能不一样。

浮点数的运算是非结合律的:(a+b)+c 的结果不一定等于 a+(b+c),因为中间步骤可能发生舍入误差。所以同样的公式,在不同批次、不同线程调度下,结果可能差个 1e-7 级别。这个极小差异经过多层 Transformer 的矩阵乘法传递后会被放大,最终在 softmax 上变成概率分布里一个很微小的波动。如果此刻 model 的高概率 token 之间本来差距就不大,就可能出现抽样结果翻转。

需要强调的是,这类非确定性在推理阶段大概率不会有肉眼可感知的质量差异,但如果你在做“同输入必同输出”的强一致性要求(比如用 LLM 做语义检索排序、评分卡、或者代理决策),就需要意识到这一点。

4. 几个真实场景里的“输出不稳定”排查案例

前面讲理论,这一节直接用实际案例走一遍完整的排查链路。这些案例都是我在日常开发和复现别人项目时真实遇到过的,踩坑过程比直接给结论更有参考价值。

4.1 案例一:微调后评估,同样的测试集两次分数波动

背景:我在用 LoRA 微调一个 7B 模型做文本分类,评估时用固定测试集跑准确率。第一次跑了 86.3%,第二次重跑变成了 85.9%。

排查过程:

  1. 检查代码里是否固定了评价 seed,发现没有。但即便如此,分类任务如果不采样,理论上结果应该稳定。
  2. 查看生成配置,发现do_sample=True,且temperature=0.9。这是从对话生成脚本里继承下来的配置,评估时忘了改。
  3. 把所有评估统一改成do_sample=False,并固定 batch composition 后,两次结果完全一致。

结论:评估类任务必须关闭采样,否则每次跑出来的指标都是带随机噪声的。

4.2 案例二:线上服务与离线脚本结果不一致

背景:模型在离线脚本里输出“好的”,线上 API 却返回“没问题”,prompt 明明一样。

排查过程:

  1. 对比了 prompt 字符串本身,发现离线脚本会额外加一个系统提示词,线上没有。
  2. 对比采样参数,线上服务的 temperature=0.7,离线脚本是默认的 1.0。
  3. 对比框架版本,线上用的是 vLLM,离线是 HuggingFace transformers,两者的 KV Cache 实现、算子融合策略不同,即使参数一致也可能出现微小差异。

结论:线上和离线结果不一致,先查 prompt 和生成参数,再查框架差异。这个案例最终改成了两套环境都固定同一份参数配置,并在 prompt 里显式加入同样的系统提示词。

4.3 案例三:vLLM 重启后,相同请求输出不同

背景:用 vLLM 部署服务,第一次发请求返回 A,重启服务进程后发相同请求返回 B。

排查过程:

  1. 排除了采样随机性,因为温度设为 0,理论上走贪心。
  2. 排除了输入差异,请求内容完全一样。
  3. 怀疑 vLLM 的 prefix cache 在重启前后状态不同。vLLM 会对请求做 KV Cache 自动缓存,如果前一次请求的部分计算被缓存命中,数值累加顺序和完全重新计算时有差异,导致后续生成出现微小分层。
  4. 使用相同模型权重、相同参数配置在两台机器上分别测试,结果稳定。再确认代码里没有未固定的随机源后,基本可以确定是 GPU 算子浮点非确定性和 cache 机制共同作用导致的极小概率差异。

结论:这种差异概率极低,也不会对文本质量产生明显影响。如果业务对一致性要求极高,可以考虑锁卡、固定 batch shape、并做多次自洽校验。

5. 想要结果可复现,实操层面该怎么做

前面讲清楚了“为什么不同”,最后分享一套我项目里用得比较顺手的复现和稳定方案。它不保证 100% 绝对一致,但能让你在绝大多数场景下得到可复现的结果。

5.1 固定随机种子的标准写法

代码层面,建议在推理脚本入口统一固定所有随机源:

import os import random import numpy as np import torch def set_seed(seed=42): os.environ["PYTHONHASHSEED"] = str(seed) random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 尽量让 cudnn 使用确定性算法 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False # 开启确定性算法(注意:部分算子会稍微变慢) torch.use_deterministic_algorithms(True, warn_only=True) set_seed(42)

这里最容易被忽略的是torch.backends.cudnn.benchmark = False。如果 benchmark=True,cuDNN 会在第一次运行时自动选择最快的卷积/注意力算子,不同运行可能选到不同实现,结果会有细微差异。同时,torch.use_deterministic_algorithms(True)会强制 PyTorch 优先选择确定性实现,但部分算子(比如某些 attention kernel)不支持时会直接报错,所以加上warn_only=True让它降级为警告而不是中断。

5.2 采样参数检查清单

每次跑实验前,建议先确认这一组配置:

  • do_sample:True 还是 False。希望稳定就 False。
  • temperature:是否等于 0。等于 0 等价于贪心。
  • top_ptop_k:是否关闭或设置了一个足够大的范围。
  • repetition_penaltyno_repeat_ngram_size:这些也会改变每一步 token 的概率,影响最终输出。
  • max_new_tokens:长度不同也会导致后续生成路径不同。

我在团队里放了一个配置模板,每次跑生成任务前复制一份并记录 git commit id 和模型文件 hash,这样即使结果不一致,也能快速定位是代码改了还是模型换了。

5.3 对比实验的规范:固定 batch、固定 padding、固定后端

如果你要做模型对比或者算法消融实验,强烈建议按下面这套规范执行:

  • 单条样本单独推理,避免 batch 内 padding 引入差异。
  • 如果必须 batch 推理,固定 batch 内样本的顺序和 padding 方式,不要让 dataloader 每次 shuffle。
  • 所有对比跑在同一个 GPU 型号和同一个推理框架版本上,不要一台机器 A100、一台机器 4090 做横向对比。
  • 记录每个实验的推理框架版本、CUDA 版本、模型 dtype(fp16/bf16/fp32)。

其中 dtype 的影响很大。同一个模型用 fp16 和 bf16 推理,结果大概率不一样,因为两者的数值范围和舍入方式不同。有些模型在低精度下高概率 token 之间的差距会被抹平,导致采样结果更随机。

5.4 要不要在“绝对一致”上死磕?我的建议

最后聊点个人的判断。追求“绝对一致”在某些场景下是合理的,比如跑自动化测试、写 RAG 管道的确定性评测、或者做需要审计的决策系统。但更多场景下,LLM 的多样性和轻微随机性反而是优势。

我的做法是分层看待:对基线评测和质量回归,统一用贪心解码或固定 seed,保证结果可复现;对线上生成,保留适度的采样随机性,再做规则层面的后处理(比如关键词兜底)来控制质量底线。这样既不会因为每次输出都一样而损失生成的自然度,也不会因为完全随机而让下游系统无所适从。

另外有一个实战小技巧:如果你在调试 prompt,想让模型反馈更稳定,可以把 temperature 从 0.7 降到 0.4,然后同时调低 top_p 到 0.85,往往比直接改 prompt 效果更明显。反过来,如果觉得模型回复太死板、缺乏灵活性,优先调大 temperature,而不是动 top_p,因为 top_p 的变化更容易让输出突然“崩坏”。

说实话,刚开始接触 LLM 推理时,我也被“同一样本输出不同”这件事折腾过不少时间。后来想明白了,这不是模型出 Bug,而是概率生成模型天然的工作方式。真正需要做的不是消灭随机性,而是搞清楚随机性来自哪里、什么时候该控制它、什么时候该利用它。希望这篇文章能帮你在遇到类似问题时少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询