在深度学习圈子里,可复现性这几年已经从一个“论文附录里带个随机种子就行”的小事,变成了一整类让人头疼的工程挑战。很多项目跑第一遍时效果很好,换一台机器、换一个版本、甚至只是隔了一周再跑,指标就开始漂。有人把这类问题统一叫作“AI 的可复现性挑战”,我觉得这个叫法非常准确:它不仅仅是一个学术问题,而是所有训练过模型、交付过模型、或者复现过别人实验的人都会撞上的真实壁垒。
这篇文章我想用自己实际踩过的坑来聊聊这件事:到底什么是可复现性挑战,为什么 AI 实验总是难以复现,以及我们能不能通过一套相对完整的工程手段,把“不可复现”压缩到一个可接受的范围。内容适合做算法研究、模型训练、AI 工程交付的读者,尤其是那些经常要复现论文、迁移训练环境、或者跨团队交接模型的同学。整篇不会给你什么“银弹”,但会把我验证过有价值的操作细节和排查思路全部摊开。
1. 可复现性挑战究竟在挑战什么
1.1 先把“可复现”这个词拆开
我在不少场合听到“你这个实验可以复现吗?”这句话,但问的人和听的人理解的“复现”很可能完全不同。实际上,在 AI 场景里“可复现”至少分三个层面:
第一层是实验可复现,也就是代码能完整跑通,不报错,不崩,最终能得到一条正常的损失曲线。很多开源项目的 README 写得很漂亮,一跑起来就是环境冲突、算力溢出、显存不够、数据路径错误——这层都过不了,后面的就不用谈。
第二层是结果可复现,也就是在相同输入、相同配置下,两次训练出来的模型精度、损失、甚至预测结果基本一致。这一层才是大部分论文复现和内部模型交接的核心痛点。你调好了模型,记录下超参,过两周再训练,发现验证集准确率差了零点几个甚至一个百分点,你会开始怀疑当初的实验是不是“运气好”。
第三层是结论可复现,即换一个人、换一套实现、换一种环境,最终得到的研究结论依然成立。比如原论文说某种采样策略能提升两个点,你用自己的框架实现后确实也能看到正向收益,哪怕具体数字不完全一致。这一层对研究方向来说才是最重要的,但也是最难保证的。
这三层不是一个进阶关系,而是同时存在于日常工作中的三个方位。很多时候我们把“可复现”简单理解为“固定随机种子”,其实只覆盖了第二层很小的一部分。真正完整的可复现性,是从代码、数据、环境、到评估逻辑全套链路的一致性。
1.2 为什么这个问题在当下格外显眼
早些年做传统的机器学习,特征工程 + 树模型 + 参数搜索,实验链路短,数据量少,训练过程几乎是确定性的,随机性主要集中在数据采样和特征选择上,要复现并没有那么困难。但现在的深度学习,尤其是大模型时代,情况完全变了。
模型规模变大意味着训练参数、优化器状态、分布式通信都引入了大量不确定因素;数据规模变大意味着预处理、混洗、数据增强的顺序都可能成为影响结果的因素;计算依赖变复杂意味着同样的代码在不同的 CUDA 版本、不同型号的 GPU 上,即使是相同的“随机种子”也可能给出不同的计算顺序和浮点结果。
再加上超参数搜索、自动调参、提示词工程这些新环节,每一层都在放大方差。以前我们说“实验跑完把参数和种子存下来就行”,现在这句话基本失效了。可复现性已经不是论文作者需要多写一段代码的事,而是整个 AI 研发链条里的一个链条级工程问题。
2. 为什么 AI 实验这么难复现
2.1 随机源比你想的多得多
最常见的误区是把“随机种子”当成万能的。你random.seed(42)、np.random.seed(42)、torch.manual_seed(42)全写上了,以为就稳了,但实际训练里的随机性来源远不止这三个全局随机数生成器。
权重初始化是一次随机,数据加载顺序是一次随机,shuffle是一次随机,dropout是训练过程中每一次前向传播的随机,数据增强里的裁剪、翻转、颜色抖动都是随机,分布式训练的进程间通信和参数分片也会引入随机,甚至多卡训练中 GPU kernel 的并行执行顺序,都可能因为硬件调度的微小差异而产生不同结果。
这些随机源彼此叠加,会让损失曲线的初始震荡产生蝴蝶效应。两个看起来一模一样的训练过程,可能在第一个 step 就出现微小的数值差异,到了第 100 个 step 差距被放大,最终模型收敛到不同的局部最优。所以“固定随机种子”不是无效,但只是必要条件,远远不是充分条件。
你还需要考虑另一个更隐蔽的问题:同样的种子值,在不同版本的库、不同代际的硬件上,生成的随机序列可能不同。我之前在旧环境上跑出的基线,换到新服务器后即使种子完全一致,第一个 epoch 的 loss 也能差出好几个点,这就是随机数生成器实现本身在变化。
2.2 环境差异是看不见的“隐形漂移”
三次方环境问题里,我个人觉得环境差异是最让人挫败的,因为你看不见摸不着,但它时刻在影响结果。
首先是 CUDA 和 cuDNN 的版本。深度学习的底层算子很多都依赖 cuDNN 的卷积算法和注意力实现,不同版本之间的数值精度、算法选择策略都不同。你在 A 机器上可能命中了一种快速卷积算法,在 B 机器上则选择了另一种,浮点累加顺序不一样,结果自然不一样。
其次是 GPU 型号和规格。不同代际的 GPU 不仅算力不同,单个算子内部的并行分组策略也不同。Tensor Core、混合精度、甚至显存带宽,都会微妙地改变数值累积方式。同一套训练代码,在一张老款卡上跑和在最新款卡上跑,损失曲线不可能完全重合。
再有就是依赖库的版本。光 PyTorch 一个框架,从 1.x 到 2.x 就经历了多次算子实现重写、后端融合逻辑调整。你的训练脚本一行不改,框架版本升级后训练出来的模型就已经不是原来的模型了。很多同学在复现旧项目时第一步就栽在这里——不是自己的代码问题,是环境快照已经找不回来了。
2.3 数据和任务本身也在动态变化
可复现性挑战里还有个常被忽略的维度:你所依赖的数据可能根本不是静态的。
在工业场景下,用户行为数据几乎每天在处理管线里发生变动。今天你拉的数据集和下周拉的数据集,看起来字段一样,实际上分布已经偏移。如果你没有给数据文件做版本号或者哈希校验,所谓的复现实验,其实每次跑的底层数据都不同。
在强化学习和在线学习场景中,这个现象更明显。环境、模拟器、策略本身的动态交互,会让同一次训练产生完全不同的轨迹。你设了种子,但模拟器的物理引擎内部可能引入了多线程不确定性;你的智能体在与环境互动的过程中,每一步的微小差异都会被策略放大。想在强化学习实验里做到数值级别的复现,难度比有监督学习高一个数量级。
还有一个容易被忽视的变量是评估过程。不同版本的 tokenizer、不同长度的上下文截断、不同的评估 prompt 模板,都会导致最终汇报的数字漂移。很多团队排查到最后才发现,模型本身没问题,是评估代码在复现时被“顺手优化”了。
3. 实操层面:一套能用起来的复现保障流程
3.1 给环境和数据做“快照”,而不是写一份 requirements.txt
说到环境固定,很多人第一反应是导出一个 requirements.txt。但实测下来纯文本依赖清单远远不够,因为 pip 在解析依赖时还有传递依赖、版本区间、平台差异等问题,不同机器上安装出来的真实环境依然可能不同。
我现在的标准做法是尽量使用容器或完整可复现的环境文件。用 Docker 的话,把基础镜像的 tag、所有 apt 包、pip 包、甚至 CUDA 的安装方式都写死在 Dockerfile 里,构建后给镜像打一个唯一的 tag。不只是记录cuda:11.8这样的版本号,还要记录实际的 driver 兼容性和 cuDNN 版本。
如果你不用容器,那么至少要把 Pipfile.lock 或 poetry.lock 这类锁定文件纳管到项目仓库中,并明确记录构建机器的系统版本、GPU 驱动版本、CUDA 工具包版本。这些信息平时看着无用,出问题时就是救命稻草。
数据同样要做快照。我的习惯是针对原始数据集生成一个 SHA256 校验值,把它写进实验配置里。任何人在复现时,先用校验值确认自己手上的数据没问题。对于本身就动态更新的数据源,就需要在数据管线层做版本管理,可以给每个批次的数据打 tag,再把 tag 记录到实验中。
3.2 固定随机种子的正确姿势和坑
先给一份我常用的 Python 端随机种子设置模板:
import os import random import numpy as np import torch def set_all_seeds(seed: int = 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) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False有几个细节是经验之谈。
PYTHONHASHSEED必须设置在 Python 进程启动之前,在脚本内部设置是无效的,所以通常我会在训练入口的外层 shell 脚本里导出,或者通过容器环境变量注入。
cudnn.benchmark默认为 True 时,cuDNN 会针对输入尺寸动态搜一个最快的算法,这个搜索过程引入的不确定性比你想的大。训练阶段如果追求稳定,就把它关掉。当然这也意味着可能损失一部分训练速度,后面我会专门说这个代价。
在 PyTorch 2.x 中,还可以调用torch.use_deterministic_algorithms(True)来强制所有算子使用确定性的实现。但这个方法需要小心,因为部分算子并没有提供确定性版本,一调用就直接抛异常。对于不支持的算子,官方会提示是哪个操作。我遇到比较多的是某些注意力实现、自定义 CUDA kernel、以及部分原子累加类的操作。
你还会遇到一个新的反差:设置了确定性算法之后,训练不再“随机”了,但你会开始质疑它到底变慢了没有。这个我们实测下来,不同模型差异很大,卷积网络可能只慢 5-10%,Transformer 里的某些算子在非确定性模式下会用更激进的并行策略,强制确定性后可能慢 20% 以上。
3.3 不要追求零方差,要把方差量化出来
这里我想说一个偏理念但非常关键的点:在深度学习里你很难把可复现性做到“绝对相同”,更现实的目标是做到“高置信复现”。
怎么理解?同一份代码、同样的环境,你在相同种子下重复训练两次,可能指标仍然相差 0.2 个点。这 0.2 个点的差异不一定是你的问题,而是单次训练的固有噪声。如果你只看一次实验,你根本无法区分“这个方案真的更优”还是“这次运气更好”。
所以我现在做实验有一个习惯:每个关键配置至少重复三次训练,记录平均数和标准差,而不是只报最好的那一次。这个建议看起来会让工作量翻几倍,但在关键决策节点上非常值得。通过多次重复,你可以把“实现偏差”和“随机噪声”分开看待。
你的实验记录里也应该包含重复次数。比如在模型卡或 README 中写“在固定种子 42 下重复 5 次的平均 F1 为 87.3%,标准差为 0.4%”,这种描述比“准确率 87.5%”专业得多,也诚实得多。
3.4 引入实验管理工具,让“看不见的信息”留下来
最后是工具体系层面的建议。你可以不额外引入组件,但至少要有一套记录实验配置的机制。我个人的做法是把每个实验的完整环境信息、数据版本、代码 commit 号、超参数文件、种子值、重复次数全部存进一条实验记录里。
常用的工具有 MLflow、Weights & Biases、Neptune 之类,选哪个不是重点,重点是它能不能帮你回答一个问题:三个月后,你还能完整还原当初这个实验的一切吗?很多人会忽视提交哈希的重要性,但固定 commit 号才是保证代码一致性的关键,因为训练脚本随时可能被后续迭代改动,不留哈希等于不留时间锚点。
对很多团队来说,容器镜像 + 数据校验 + 提交哈希 + 实验管理工具,四件套就能把复现性的工程基础搭起来了。接下来遇到问题,排查范围会瞬间缩小很多。
4. 常见问题与排查技巧实录
4.1 一张找准方向的问题排查表
我把这几年在复现和排查过程里遇到的高频问题总结成一张表,希望对你定位问题时有帮助。
| 症状特征 | 最常见的可能原因 | 排查思路和解决方法 |
|---|---|---|
| 同一份代码,两次训练损失曲线初始就不同 | 随机种子未完整固定,或数据加载顺序不同步 | 检查全部随机源,尤其 DataLoader 的 shuffle 和多进程种子;固定PYTHONHASHSEED |
| 同一份代码,换了一台机器后指标差异大 | GPU 型号、CUDA/cuDNN 版本或驱动不同 | 对比环境清单,重建容器镜像;优先保证底层环境一致 |
| 固定种子后,部分层仍报“确定性算法不受支持” | 某些算子没有确定性实现 | 查看报错定位具体算子,改用确定性版本或用官方已修复的安全实现 |
| 指标没问题,但数据的 min/max 或特征均值不对 | 数据版本漂移或预处理差异 | 计算原始数据的哈希值,对比与实验记录中的是否一致 |
| 多次训练指标波动很大,标准差很高 | 学习率过高、batch size 过小或训练不稳定 | 优先稳定训练本身,再谈复现;可以降低学习率并增大重复次数 |
| 自己复现自己的代码,过两周结果又变了 | 依赖库被升级,或数据管线发生了变动 | 回退环境,查看依赖锁文件;数据文件做版本管理 |
这张表里最值得反复看的其实是第一行。初学者最容易以为种子写上了就万事大吉,可实际上很多训练脚本里的随机性来自于分布式数据加载时的worker_init_fn没有设置,或者sampler在多个 epoch 之间重新替换了随机状态。
4.2 三个非常隐蔽、我至今还会绊住的底层坑
第一个坑是 cuDNN 的确定性设置并不是全有或全无。torch.backends.cudnn.deterministic = True只对 cuDNN 里标注了支持确定性的卷积、池化等算子有效。像某些自定义的融合 kernel、注意力计算里的flash_attention后端,不在这个设置范围内,你依然会遇到不可复现。解决办法是尽量把模型里所有关键计算都整理出来,逐个验证每个模块在多次前向传播中是不是输出一致的。
第二个坑是 DataLoader 在分布式训练里的随机状态往往会和全局种子脱节。比如你用DistributedSampler配合DataLoader,每个 worker 有自己的随机数生成器,如果你没有给每个 worker 单独设置 seed,那么每个 epoch 的 shuffle 顺序实际上是平移、错位的。这个隐蔽问题会让你的实验在 10 张卡以上的训练中几乎失去可复现性。
第三个坑是混合精度训练下的数值累积。AMP 训练中,梯度在 FP16 和 FP32 之间来回切换,不同的 GPU 在并行结合时的“顺序”完全不同,你想固定都不太容易固定。如果实验允许,精度优先时先关闭 AMP 跑基线,确认结果没问题后再把混合精度打开单独测试方差。
4.3 如何快速判断:这是 bug 还是噪声
排查“不可复现”问题时,第一个动作不该是无头苍蝇一样改代码,而是先做一个区分诊断,判断问题到底出在确定性错误还是正常方差。
我的做法分三步。第一步,在同样种子下连续跑两次,如果损失曲线在前几个 step 就已经分叉,大概率是某个随机源没有固定,或者算子产生了非确定性行为。第二步,把训练步数缩短到几十步,在前 50 步内做两次对比,如果输出差异大,说明底层的确定性就已经失效,不用等到后面收敛阶段再观察。第三步,把数据顺序人为固定,比如关闭 shuffle,用固定的数据迭代顺序,再看两次结果是否一致。如果一致,说明问题出在数据顺序源上;仍不一致,就去查算子实现和库版本。
这一套半小时能跑完的诊断,基本能缩小到三个方面:随机种子泄漏、环境差异、算子非确定性。
5. 沉淀下来的几点实操体会
做了一段时间的可复现性改造之后,我最大的感受是:这件事不能一步到位,也不该强求百分之百的确定性,真正有价值的是把每一层的变量都变成“已知变量”。
我目前的实践原则是这样的。第一,在研发探索阶段,不强制开启确定性模式,因为探索期更重要的是快速验证多个想法,额外的确定性兼容成本反而拖慢节奏。第二,在正式实验、对比实验、模型交付、论文复现这些关键节点,打开完整的环境锁定和重复实验机制,宁可多花时间也要把结论钉得够稳。第三,不要迷信单一指标,我会把完整的一次实验涉及到的命令行、环境变量、依赖版本、数据和代码哈希都记录下来,这些信息平时不起眼,却是事后还原现场的唯一依据。
如果这个项目还能往后扩展,我会往两个方向努力:一个是针对分布式训练的复现性做更细的追踪,因为大规模多卡训练中的通信顺序和梯度累积差异,目前仍然很难在工程层面彻底驯服;另一个是把评估环节纳入复现体系,不仅记录训练配置,也要固定推理端的批次大小、精度模式、后处理逻辑,因为很多时候最后一公里跑偏的其实是评估,而不是训练。
最后再分享一个小技巧:每次拿到别人的项目,先不要急着跑完整训练。先把代码固定在一个 commit 上,准备好环境锁镜像,再执行一个最小步数的训练,连续跑两次,看 loss 曲线是否一致。这一步能帮你避开大量“训练一大半、折腾三周、最后发现是环境问题”的悲剧。可复现性这件事没有魔法,它靠的就是把每一个细节都变得可追溯、可回滚、可对比。只要这些做到位了,AI 实验就会从“碰运气”变成“可控的科学实验”。