大模型或深度学习模型在部署推理时,复现性经常被忽视。很多人以为只要固定了 torch.manual_seed 就能得到一致的输出,但真正跑起来后,同一张显卡上两次推理的Logits仍然可能在小数点后几位出现差异,极端情况下甚至会改变argmax结果。这个差异的根源在于现代深度学习框架和GPU计算栈中存在多层随机性:Python哈希、NumPy随机数、框架内部算子选择、cuDNN卷积算法、数据加载顺序等。要彻底解决这个问题,需要理解每一层随机性的来源,并逐一关闭。

一、为什么只设Seed不够:随机性来自哪里
深度学习推理链路中的随机性并不是由模型本身的参数引起的。模型加载完成后权重是固定的,前向计算理论上应该是确定性的。问题在于底层计算库为了速度而引入了一些非确定性的算法选择。
首先是Python层。Python的字典遍历顺序、字符串哈希以及标准库random模块都依赖全局随机种子。如果推理脚本中使用了random模块进行采样或数据增强,不固定random.seed就会导致结果变化。其次是NumPy,它在数据预处理、padding、mask生成中常被用到,numpy.random也必须固定。框架层面,PyTorch和TensorFlow在初始化某些算子、生成dropout mask、选择卷积算法时会使用各自的随机数生成器。最后是GPU计算库,cuDNN为了加速可能在不同运行中选择不同的卷积实现,这些实现虽然数学上等价,但浮点运算顺序不同,结果会有微小差异。只有逐层关闭这些随机源,推理才能稳定。
一个常见的误区是,开发者只在脚本开头调用了torch.manual_seed(42),却发现结果依然不稳定。这是因为torch.manual_seed只影响PyTorch CPU端的随机数生成,不会自动同步到CUDA端,也不会控制NumPy或Python标准库,更不会改变cuDNN的算法选择策略。缺少其中任意一个环节,复现链条就会断裂。因此固定Seed不是一次函数调用,而是一个系统配置过程。
二、PyTorch中固定Seed的标准配置
在PyTorch推理脚本中,建议在模块加载之后、模型初始化之前统一调用一个set_seed函数。这个函数应覆盖Python、NumPy、PyTorch CPU和CUDA的随机种子,并显式指定cuDNN的确定性模式。
以下是一个可直接使用的配置函数:
import random
import numpy as np
import torch
def set_seed(seed: int = 42):
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.benchmark = False
torch.backends.cudnn.deterministic = True
# 让PyTorch在检测到非确定性算法时给出警告,而不是直接报错
torch.use_deterministic_algorithms(True, warn_only=True)
其中torch.backends.cudnn.benchmark = False的作用是关闭cuDNN的运行时性能基准测试。如果保持True,cuDNN会在第一次遇到某种输入形状时自动尝试多种卷积算法,选择速度最快的那个,这个选择过程可能受到GPU状态和当前运行环境影响,导致不同次运行使用不同算法。torch.backends.cudnn.deterministic = True则要求cuDNN只使用确定性的实现。两者通常同时设置,前者从源头避免算法搜索,后者作为兜底约束。
需要注意,开启确定性算法会降低推理速度,因为限制了一些高性能但非确定性的优化路径。对于推理任务而言,如果业务对结果一致性的要求高于延迟,建议保持开启。如果性能敏感且能接受极小概率的浮点差异,可以选择仅固定种子而保留benchmark=True,但这并不能保证绝对可复现。
三、TensorFlow的确定性推理配置
TensorFlow的随机性控制更依赖环境变量和框架API的组合。只调用tf.random.set_seed(42)并不能完全消除GPU上的非确定性,因为底层cuDNN和部分算子仍然可能选择不同实现。需要在导入TensorFlow之前设置环境变量TF_DETERMINISTIC_OPS和TF_CUDNN_DETERMINISTIC。
下面是一个TensorFlow推理脚本的推荐配置顺序:
import os
import random
import numpy as np
import tensorflow as tf
# 环境变量必须在导入TensorFlow前设置
os.environ['PYTHONHASHSEED'] = '0'
os.environ['TF_DETERMINISTIC_OPS'] = '1'
os.environ['TF_CUDNN_DETERMINISTIC'] = '1'
def set_seed(seed: int = 42):
random.seed(seed)
np.random.seed(seed)
tf.random.set_seed(seed)
环境变量TF_DETERMINISTIC_OPS=1会让TensorFlow尽可能使用确定性算子实现,TF_CUDNN_DETERMINISTIC=1则直接告诉cuDNN使用确定性算法。需要注意的是,这些环境变量必须在进程启动时、在import tensorflow之前设置,否则框架初始化时可能已经读取了配置,后续再修改不会生效。如果使用命令行启动,也可以写成export TF_DETERMINISTIC_OPS=1的形式先行导出。
TensorFlow 2.8及以上版本中,TF_DETERMINISTIC_OPS逐步被tf.config.experimental.enable_op_determinism()取代,但环境变量仍然被广泛支持。对于新项目,建议在代码中显式调用以下API:
tf.config.experimental.enable_op_determinism()
该调用会强制所有算子使用确定性实现,遇到不支持确定性的算子时抛出错误,能帮助快速定位问题。
四、数据加载和多进程带来的额外随机性
推理阶段如果使用PyTorch的DataLoader并且设置了num_workers > 0,每个worker进程会继承主进程的随机种子,但这并不意味着它们的行为是一致的。多个worker之间的数据读取顺序、数据增强操作仍然可能受到进程调度的影响。即使推理本身不需要数据增强,只要数据集读取顺序或batch组装顺序发生变化,模型输出也可能出现不同。要解决这个问题,需要为DataLoader配置worker_init_fn和固定的generator。
以下是一个可复现的数据加载示例:
def seed_worker(worker_id):
worker_seed = torch.initial_seed() % (2 ** 32)
np.random.seed(worker_seed)
random.seed(worker_seed)
g = torch.Generator()
g.manual_seed(0)
loader = DataLoader(
dataset,
batch_size=32,
shuffle=True,
num_workers=4,
worker_init_fn=seed_worker,
generator=g,
)
这里torch.Generator负责控制数据打乱顺序,worker_init_fn负责为每个worker进程设置独立的随机种子,从而保证多进程环境下数据读取顺序也能复现。如果num_workers=0,则不需要设置worker_init_fn,只要固定generator即可。
此外,还需要注意Python字典的遍历顺序。Python 3.7之后字典保持插入顺序,但如果推理代码中构造字典时依赖set或哈希遍历,结果可能受PYTHONHASHSEED影响。设置PYTHONHASHSEED=0可以消除字符串哈希随机化带来的顺序差异。虽然这在推理中影响较小,但在复杂预处理流程中仍然值得固定。
五、完整的可复现验证模板
配置完成后,需要用同一输入进行多次推理,比较输出是否完全一致。下面这个脚本会加载一个简单的模型,固定所有随机源,连续运行三次推理,并计算输出差异。如果配置正确,差异应当为零。
import random
import numpy as np
import torch
import torch.nn as nn
def set_seed(seed: int):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
torch.use_deterministic_algorithms(True, warn_only=True)
set_seed(42)
model = nn.Sequential(
nn.Linear(64, 128),
nn.ReLU(),
nn.Linear(128, 10)
).cuda()
model.eval()
x = torch.randn(4, 64, device='cuda')
outputs = []
with torch.no_grad():
for _ in range(3):
outputs.append(model(x))
diff_01 = torch.abs(outputs[0] - outputs[1]).max().item()
diff_02 = torch.abs(outputs[0] - outputs[2]).max().item()
print(f"max diff between run1 and run2: {diff_01:.10f}")
print(f"max diff between run1 and run3: {diff_02:.10f}")
assert diff_01 == 0.0 and diff_02 == 0.0, "推理结果不可复现"
print("所有推理结果完全一致")
这里使用model.eval()关闭了Dropout和BatchNorm的随机性,同时with torch.no_grad()阻止了梯度计算,更贴近实际推理场景。如果模型包含随机性层,例如推理时仍启用Dropout,那么即使固定了种子,输出也可能会因为随机丢弃而改变。所以在推理阶段一定要调用eval()模式,并避免使用任何随机操作。
还应注意,GPU型号、驱动版本、CUDA版本和框架版本的变化都会影响底层算子实现。可复现性通常是指同一软件和硬件环境下的一致,而不是跨平台的一致。切换环境后即使所有种子相同,结果也可能不同,这是浮点计算和底层库实现差异造成的,不能通过种子配置解决。