19.7 推理非确定性分析
当我们将 temperature 设为 0 时,模型进入贪婪解码(Greedy Decoding)模式——数学上就是对输出概率分布执行 temperature=0,输出仍会产生差异。更棘手的是,在自回归生成中,早期哪怕一个 Token 的微小偏差,都会通过条件依赖引发后续生成的指数级发散——这正是蝴蝶效应在语言模型中的体现。
这种"理论确定性"与"实际非确定性"之间的鸿沟,根源并非某个单一因素,而是从模型架构到底层硬件的四层原因层层叠加的结果。本节将逐层剖析这些原因,并通过可运行的代码示例帮助读者建立直观感受。
19.7.1 第一层:架构层——MoE 的专家容量竞争
对于采用**混合专家模型(Mixture of Experts, MoE)**架构的大模型(如 GPT-4、DeepSeek-V3),架构本身就引入了一个根本性的非确定性来源。
专家容量限制(Capacity Limit)。 MoE 架构包含大量独立的专家网络,但每个专家在同一时刻能处理的 Token 数量是有上限的。当 API 服务商将多个用户的请求拼成一个 Batch 以提高 GPU 利用率时,不同请求的 Token 会竞争同一个专家的容量槽位。如果用户 A 和用户 B 的 Token 同时被路由到专家 3,但专家 3 的容量已满,某些 Token 就会被"挤出"并重新路由到备用专家——而备用专家的权重不同,计算结果自然不同。
批次级确定性 vs 序列级确定性。 问题的关键在于:你的请求和谁被分配在同一个 Batch 是随机的——这取决于服务器当时的负载情况,完全不可控。因此,MoE 模型即使在 temperature=0 下也不再具备序列级确定性(单条请求的输出可复现),而退化为批次级确定性(仅当整个 Batch 的组成完全一致时,结果才可复现)。
# 专家容量竞争的概念示意
# 假设一个简化的 MoE 层:2 个专家,每专家容量为 2
import random
def moe_route(tokens, expert_capacity=2):
"""模拟 MoE 路由:所有 token 都想去专家 0,但容量有限"""
expert_0, expert_1 = [], []
overflow = []
for t in tokens:
preferred = 0 # 假设 router 总是偏好专家 0
if len(expert_0) < expert_capacity:
expert_0.append((t, "expert_0"))
else:
expert_1.append((t, "expert_1_fallback"))
overflow.append(t)
return expert_0, expert_1, overflow
# 场景 1:你的请求 ["A1","A2"] 独占 Batch
batch_1 = ["A1", "A2"]
e0, e1, ovf = moe_route(batch_1)
print(f"Batch=[A1,A2]: 溢出={ovf}") # 溢出=[]
# 场景 2:你的请求和别人拼在一起
batch_2 = ["B1", "B2", "A1", "A2"] # B 用户的 Token 先到
e0, e1, ovf = moe_route(batch_2)
print(f"Batch=[B1,B2,A1,A2]: 溢出={ovf}") # 溢出=['A1', 'A2']
# A 的 Token 被迫使用备用专家,计算结果因此改变这段代码展示了 MoE 非确定性的本质:你的输出不仅取决于你自己的输入,还取决于同一 Batch 中其他人的输入。这也是 GPT-4 等 API 产品即便设置了 seed 参数也无法完全保证确定性的根本原因之一。
19.7.2 第二层:算子层——浮点非结合律与动态批次
即便对于非 MoE 架构的 Dense 模型,算子层面的浮点运算特性同样会制造非确定性。许多人误以为这是 GPU 并发下"原子加法"导致的随机性,但实际上 LLM 的前向传播几乎不使用原子加法。真正的罪魁祸首是:动态批次大小改变了算子的归约顺序,而浮点加法不满足结合律。
浮点数的数学原罪。 IEEE 754 浮点数由于精度有限,加法运算不满足数学上的结合律(Associativity):
这不是 Bug,而是有限精度表示的固有特性。以下代码直观演示了这一现象:
# 浮点非结合律演示
a = 1e16
b = -1e16
c = 1.0
result_1 = (a + b) + c # 先算 a+b=0,再加 c → 1.0
result_2 = a + (b + c) # 先算 b+c=-9999999999999999.0,再加 a → 0.0
print(f"(a + b) + c = {result_1}") # 1.0
print(f"a + (b + c) = {result_2}") # 0.0
print(f"差异: {abs(result_1 - result_2)}") # 1.0 —— 100% 的相对误差!在这个极端例子中,仅仅改变加法顺序就导致了 100% 的误差。虽然实际推理中的误差通常小得多(约

图 19-9:浮点数的离散性。以 5 位浮点数为例,0 到 3.5 之间只有 12 个可精确表示的值(星号标记),其余实数只能通过舍入近似。注意越靠近 0 的区域,可表示值越密集——这是浮点数精度分布不均匀的根源。
批次不变性缺失。 推理服务器面对的负载是动态的——每时每刻的并发请求数不同,Batch Size 随时变化。为了追求极致性能,底层算子在不同 Batch Size 下会采用不同的并行切分和归约策略:
| 算子 | 小 Batch(Decoding) | 大 Batch(Prefill) | 后果 |
|---|---|---|---|
| RMSNorm | Split-K:一个归约拆到多核 | 数据并行:一核处理一行 | 归约顺序不同 |
| MatMul | 触发小尺寸 Tensor Core 指令 | 触发大尺寸 Tensor Core 指令 | 中间累加精度不同 |
| Attention | KV Cache 长度短,少切片 | KV Cache 长度长,多切片 | 切片边界处归约不同 |
表 19-2:动态 Batch Size 对算子归约策略的影响。
核心逻辑是:切分方式变了 → 加法顺序变了 → 舍入误差不同 → 结果不同。 这就是为什么同一个模型、同一条输入、同一台机器,仅仅因为两次请求时的并发负载不同,输出就可能产生差异。
# 归约顺序对结果的影响
import struct
def float_to_bits(f):
"""将 float 转为二进制位串,用于观察精度差异"""
return format(struct.unpack('!I', struct.pack('!f', f))[0], '032b')
# 模拟:10000 个小数的求和,改变归约顺序
import random
random.seed(42)
values = [random.uniform(-1, 1) for _ in range(10000)]
# 策略 1:顺序归约(单核)
sum_sequential = 0.0
for v in values:
sum_sequential += v
# 策略 2:分块归约(模拟 4 核并行)
chunk_size = len(values) // 4
chunks = [values[i:i+chunk_size] for i in range(0, len(values), chunk_size)]
sum_parallel = sum(sum(chunk) for chunk in chunks)
# 策略 3:不同的分块大小(模拟不同 Batch Size 触发不同策略)
chunk_size_2 = len(values) // 7
chunks_2 = [values[i:i+chunk_size_2] for i in range(0, len(values), chunk_size_2)]
sum_parallel_2 = sum(sum(chunk) for chunk in chunks_2)
print(f"顺序归约: {sum_sequential:.18f}")
print(f"4 路并行归约: {sum_parallel:.18f}")
print(f"7 路并行归约: {sum_parallel_2:.18f}")
print(f"策略 1 vs 2 差异: {abs(sum_sequential - sum_parallel):.2e}")
print(f"策略 1 vs 3 差异: {abs(sum_sequential - sum_parallel_2):.2e}")运行后可以观察到:三种归约策略得到的结果在小数点后 12~14 位处开始出现分歧。在推理框架中,每一层的矩阵乘法和归一化都会引入这样的微小差异,经过几十甚至上百层的累积,最终的 Logits 差异完全有可能达到影响 Token 选择的程度。
19.7.3 第三层:框架层——cuDNN 默认不保证确定性
深度学习框架为了追求最大化性能,默认不保证计算的确定性。这体现在多个层面:
自动算法选择。 cuDNN 在首次执行卷积或矩阵乘法时,会尝试多种算法并挑选速度最快的——但不同算法的数值行为可能不同。PyTorch 提供了关闭该机制的选项:
import torch
# 强制使用确定性算法(可能降低性能)
torch.backends.cudnn.deterministic = True
# 关闭 cuDNN 的自动调优
torch.backends.cudnn.benchmark = False
# PyTorch 2.0+ 全局确定性模式
torch.use_deterministic_algorithms(True)但即便开启了这些选项,也只是保证了同一硬件、同一软件版本下的确定性。一旦涉及以下场景,确定性仍无法保证:
硬件异构性。 云端推理集群常常混合部署 A100 和 H100。H100 引入了 Transformer Engine 和动态缩放的 FP8 精度,其舍入行为与 A100 不同。即使是同代的 GPU,不同批次的芯片在极端边界情况下也可能表现出微小的数值差异。

图 19-10:不同浮点格式的典型值与特性对比。FP16 提供 10 位尾数精度,而 FP8-E4M3 仅有 3 位尾数,可表示的数值密度天差地别。混合使用不同精度的硬件必然导致舍入行为不一致。
分布式通信延迟。 在张量并行(TP)或流水线并行(PP)部署中,多个 GPU 需要通过 All-Reduce 等集合通信操作合并中间结果。网络延迟的抖动可能导致数据到达和聚合的顺序发生微小变化——这又是浮点非结合律发挥作用的场景。
编译器优化。 现代编译器(如 CUDA 的 NVCC、PyTorch 的 TorchInductor)会对计算图进行指令重排(Instruction Reordering) 和算子融合(Operator Fusion),这些优化可能改变浮点运算的执行顺序。例如,编译器可能将分开的乘法和加法融合为融合乘加(FMA) 指令——FMA 用更高精度保留中间结果,比先乘后加更精确,但这也意味着有 FMA 和没有 FMA 的执行路径会产生不同的结果。
19.7.4 第四层:Tie-Breaking—— 误差引爆全面发散
前三层机制产生的浮点误差通常极小——BF16 下约
何时发生 Tie。 当两个候选 Token 的 Logits 差距小于浮点误差的量级时,微小的数值扰动就可能翻转它们的排名。考虑如下场景:
import torch
# 模拟:两个 token 的 logits 非常接近
logits_run1 = torch.tensor([3.14159265, 3.14159271])
logits_run2 = torch.tensor([3.14159271, 3.14159265]) # 扰动 ~6e-8
print(f"Run 1 选择: token_{torch.argmax(logits_run1).item()}") # token_1
print(f"Run 2 选择: token_{torch.argmax(logits_run2).item()}") # token_0
print(f"Logits 差异: {abs(logits_run1[0] - logits_run2[0]):.2e}") # ~6e-8
# 更真实的场景:vocabulary 中有多个近似等概率的候选
vocab_size = 32000
torch.manual_seed(42)
logits = torch.randn(vocab_size)
# 人为制造两个 token 的 logits 几乎相同
max_idx = torch.argmax(logits).item()
logits[max_idx + 1] = logits[max_idx] + 1e-7 # 仅差 1e-7
# 加入 ~1e-7 量级的随机扰动(模拟不同归约顺序)
perturbation = torch.randn(vocab_size) * 1e-7
logits_perturbed = logits + perturbation
original_choice = torch.argmax(logits).item()
perturbed_choice = torch.argmax(logits_perturbed).item()
print(f"\n原始选择: token_{original_choice}")
print(f"扰动后选择: token_{perturbed_choice}")
print(f"选择是否翻转: {original_choice != perturbed_choice}")蝴蝶效应的放大机制。 一旦第
下面的伪代码概括了从浮点误差到输出发散的完整因果链:
初始状态:两次运行共享相同的 prompt
→ 第 1-50 步:logits 差异 ~1e-7,但最大值唯一,argmax 一致,输出相同
→ 第 51 步:logits[token_A] = 5.2831940,logits[token_B] = 5.2831938
差距仅 2e-7,恰好落入浮点误差的翻转区间
Run 1 选择 token_A,Run 2 选择 token_B
→ 第 52 步起:两次运行的上下文完全不同,输出全面发散这就是为什么非确定性的表现如此"两极化":大多数时候输出完全一致,但偶尔会在某个位置"分叉",之后的内容面目全非。
19.7.5 四层原因的交互关系
四层原因并非孤立存在,而是呈现出一种漏斗式的因果关系:
| 层级 | 原因 | 产生的效果 | 典型影响量级 |
|---|---|---|---|
| L1 架构层 | MoE 专家容量竞争 | 不同的 Token 走不同的专家路径 | 权重级差异(巨大) |
| L2 算子层 | 浮点非结合律 + 动态归约 | 同一算子不同运行产生不同结果 | |
| L3 框架层 | cuDNN 非确定性 + 硬件异构 | 不同算法/硬件的数值行为不同 | 与 L2 叠加 |
| L4 Tie-breaking | Logits 平局时 argmax 翻转 | 微小误差触发完全不同的 Token | 从 1 Token 发散到全文 |
表 19-3:四层非确定性原因的交互关系。
L1 是最强的非确定性来源——它直接改变了参与计算的权重,产生的差异远大于浮点舍入误差。这也是为什么使用 MoE 架构的 API 服务(如 GPT-4)的非确定性比使用 Dense 架构的本地部署更加显著。
L2 和 L3 是 Dense 模型非确定性的主要来源——即使不涉及 MoE,动态 Batch Size 和框架层的非确定性也足以产生
L4 是放大器——前三层产生的微小差异,如果遇到 Logits 平局,就会被放大为完全不同的 Token 序列。
19.7.6 实践建议:如何应对非确定性
完全消除推理非确定性在工程上极为困难(尤其是 MoE 架构),但以下措施可以显著降低其影响:
固定 Batch 组成。 如果使用自建推理服务,可以将请求隔离到独立的 Batch 中(而非与其他请求拼批),消除 L1 层面的非确定性。代价是 GPU 利用率下降。
固定归约策略。 vLLM 等框架提供了类似 enforce_eager=True 的选项来关闭动态优化,部分缓解 L2 层面的问题。对 KV Cache 的 Attention 切分,应采用固定切片大小(而非固定切片数量),确保无论 KV 长度如何变化,归约边界都一致。
框架确定性模式。 如前所述,PyTorch 的 torch.use_deterministic_algorithms(True) 可以强制使用确定性算法,但会带来性能损失。
温度微调。 与其使用 temperature=0,不如设置一个极小的温度值(如
面向非确定性的系统设计。 最务实的建议是:在系统层面接受非确定性的存在,将其作为设计约束而非需要消除的 Bug。评估模型输出时关注语义一致性而非逐字匹配,使用模糊匹配、语义相似度等鲁棒的评估指标。
19.7.7 小结
本节揭示了一个违反直觉但极其重要的事实:temperature=0 并不意味着确定性输出。 非确定性的根源贯穿四个层级——MoE 架构的专家容量竞争产生批次级随机性、浮点非结合律与动态归约策略导致算子结果微妙变化、深度学习框架默认不保证确定性、而 Logits 平局则将微小的数值差异放大为完全不同的生成序列。
理解这些机制有两层意义:一是在工程实践中,帮助我们在"追求确定性"和"追求性能"之间做出明智的权衡;二是在系统设计中,提醒我们将非确定性视为大模型推理的固有特性,在评估、测试和上层应用中采用鲁棒的设计模式。
至此,我们完成了第 19 章的全部内容,也为第五篇"推理篇"画上了句号。从第 17 章探讨如何在推理时间内提升模型能力(推理时间缩放),到第 18 章训练天生擅长"思考"的推理模型,再到本章深入模型部署的工程优化——KV Cache 管理、批处理调度、投机采样、模型量化、推理引擎选型,直至本节揭示的推理非确定性本质——这条从"能力"到"效率"再到"可靠性"的主线,构成了大模型从实验室走向生产环境所必须跨越的完整技术栈。