ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyTorch实验可复现性实战:从随机种子到依赖锁定与配置归档

PyTorch实验可复现性实战:从随机种子到依赖锁定与配置归档 有件事我印象极深去年跑一个图像分类实验本地训练出来的准确率是91.2%兴致勃勃把代码原样发到另一台服务器结果成了88.7%换到第三台机器又变成了90.1%。训练脚本一字没改数据是同一份预处理好的损失函数、优化器参数、学习率调度全部核对过最后三个平台各出一个数。那阵子我被调参调到头秃后来才明白问题根本不在模型而在PyTorch实验的可复现性没有做扎实。所谓可复现不是大致能对齐趋势就行而是要在可控条件下让结果能逐位复制。这件事涉及三个环节随机种子管理、依赖锁定、配置归档。三个环节单独拿出来都有人讲过但把它们作为一个完整体系来落地是另一回事。这篇文章就把我在项目里实际用的一套方案完整拆开每个环节为什么需要做、怎么做、踩过哪些坑以及一套可以直接抄作业的实验模板。如果你也在为同一个代码跑出不同结果挠头或者想把实验管理得更规范这篇内容应该能帮你省下不少时间。1. 为什么PyTorch实验总是复现不了先看清随机性从哪里来1.1 可复现的三个层次完全一致、数值级一致和结论级一致先说一个容易混淆的点可复现不代表所有场景下都必须逐位相同。我在项目里一般把可复现分成三个层次。第一层是完全复现也叫逐位复现。同一条代码、同一个环境、同一份数据在同一个硬件上跑两次loss曲线、验证集指标、模型权重全都一模一样。这是最理想的也是随机种子、依赖锁定、配置归档整套机制要追求的目标。第二层是数值级一致。每次训练完的参数略有浮点尾数差异loss曲线走势相同最后几个epoch的验证集指标在几个小数点内波动。这种一般是因为GPU并行计算的浮点累加顺序不稳定导致的后续分析结论不受影响。第三层是趋势级一致。模型A和模型B谁好谁坏、哪个超参数更优在不同机器上得出同样结论但具体数值差异比较大。这种结论可复现在调参阶段勉强够用但真到了发论文、部署模型、审计复现的时候远远不够。很多人的实验里问题就卡在第二层和第三层之间。你以为自己设了随机种子实际上只有一行torch.manual_seed(42)结果该变还是变。下面把随机性的来源彻底拆开看。1.2 随机性的真正来源全局随机数、并行浮点与依赖库行为PyTorch实验里的随机性不是单一来源而是多个随机源叠加在同一个全局随机数状态机上。首先是模型初始化。模型的权重、偏置通常用正态分布或均匀分布初始化这里会消耗PyTorch全局RNG的状态。如果你在代码里加了一行无关紧要的操作比如提前跑了一次torch.randn热个身那模型初始化的参数就跟上次完全不一样了——因为全局RNG的状态已经被往前推了一段。其次是数据加载流程。DataLoader做shuffle时会创建RandomSampler它内部要消耗PyTorch的随机数。还有数据增强操作像RandomCrop、RandomHorizontalFlip这些transforms只要没指定generator吃的就是全局RNG。数据采样和增强顺序一变喂给模型的batch就是另一份数据。然后是并行浮点运算。这一条很多人忽略。GPU在做矩阵乘法、卷积这些算子时内部是分块并行累加的。浮点数加法不满足结合律1.0 2.0 3.0跟3.0 2.0 1.0的结果存在微小差异。每个线程块的聚合顺序不同最终数值就可能差一个极小的尾数。A卡和N卡差、V100和A100差、同一块卡跑两次有时也差就是因为硬件调度不完全有确定性。最后是依赖库行为。numpy、pandas、scikit-learn这些库如果用C语言实现底层随机算法在版本间调整过生成的序列就可能不同。我之前踩过一个坑numpy从1.23升到1.26同一个numpy.random.seed(42)生成的随机排列完全不同——所以依赖锁定必须纳入可复现体系它跟随机种子是配套关系。1.3 随机序列的耦合问题为什么设置过一个种子并不够我在排查问题的过程中发现一个特别隐蔽的机制代码中多个随机源共享同一个全局RNG时它们之间是耦合的——任何一个随机操作消耗掉的状态量发生变化后面所有随机操作的结果都会跟着变。举个非常具体的例子。假设训练脚本的执行顺序是torch.manual_seed(42) # 步骤1生成一组随机增强参数 aug_params torch.randint(0, 2, (100,)) # 步骤2模型初始化 model.apply(init_fn) # 步骤3DataLoader取第一个batch for batch in dataloader: train_step(batch)步骤1消耗掉一部分随机状态。如果你在代码里把步骤1的torch.randint改成了torch.rand参数范围相同但消耗的状态量不同那么步骤2初始化出来的模型和步骤3抽到的batch就都会变。哪怕随机种子的值还是42整个训练过程却不再是同一场实验。很多人只在训练脚本开头调用一次set_seed以为万事大吉但训练中途可能有第三方库偷偷消耗了全局RNG或者数据加载子进程的随机种子没处理好全局状态在第一个epoch开始前就已经偏离了。正确的做法是给每一类随机源独立分配种子状态而不是所有人都挤在同一个全局状态上。这正好是下一节要展开的内容。2. 随机种子管理从一行代码到全链路固定2.1 基础一个标准的seed设置函数及其使用范围先给一个我在项目里一直用的基础版本它覆盖了Python标准库、numpy、PyTorch和CUDA几条随机通道。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)注意到几个细节torch.cuda.manual_seed(seed)只设置当前一块卡torch.cuda.manual_seed_all(seed)是设置所有可见GPU多卡环境两者都要写。此外如果代码里用了Python的hash函数、set或str类型做遍历最好在启动训练前设置环境变量PYTHONHASHSEED否则每次进程启动时字符串的哈希随机种子都不同某些依赖set遍历顺序的逻辑会变得不可复现。不过set_seed只是地基。它管住的是主进程的全局RNG数据加载里的子进程、模型初始化里的独立generator、cuDNN的算子选择它统统管不到。所以下面的内容才是真正需要花时间的部分。2.2 关键DataLoader的worker与generator必须单独处理DataLoader在多进程加载模式下num_workers 0每个worker子进程会有自己独立的一份RNG。主进程设置的种子不会自动传给子进程就导致一个问题shuffle顺序每次可能不一样或者说数据增强的随机性不可控。PyTorch官方可复现文档里推荐的做法是同时配置worker_init_fn和generatordef 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(42) train_loader DataLoader( train_dataset, batch_size32, num_workers4, shuffleTrue, worker_init_fnseed_worker, generatorg, )这里解释一下工作原理。torch.initial_seed()返回当前进程PyTorch RNG的初始种子。每个DataLoader worker在启动时会被分配一个基于base_seed worker_id的种子worker_init_fn的作用是在子进程内部把Python自带的random和numpy的随机状态也统一设置到同一个起跑线上避免出现torch的shuffle是确定的但numpy增强随机了这种割裂。单独传入的g torch.Generator()是为shuffle的RandomSampler提供独立的随机流。这么做的好处是不再占用全局RNG随时手动调整generator种子就能控制数据顺序。如果你想做相同模型参数、不同数据顺序的对照实验这个独立generator就是开关。我再补充一点实测经验num_workers从4改成8会导致每个worker被分配的数据切片方式不一样最终也可能影响训练数值。因此做可复现实验时num_workers、batch_size、pin_memory、prefetch_factor这些数据加载参数都应该一并固定不能只盯着种子。2.3 关键cuDNN、CUDA的确定性开关不能漏光把进程里的RNG锁住还不够PyTorch底层的CUDA算子选择也是个随机源。cuDNN在卷积层会针对不同的输入尺寸做自动调优选择它认为最快的卷积算法。问题在于算法选择结果在不同设备、不同驱动版本、不同cuDNN版本下可能不一样用了不同算法浮点累加顺序就不同训练结果自然不同。标准做法是关掉自动调优强制走确定性算法torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False这里有个坑benchmark True和deterministic True不能同时开。逻辑上它们就是矛盾的一个追求最快、一个追求确定同时开并不会有又确定又快的效果反而可能报错。从PyTorch 1.8开始还有一套更严格的开关torch.use_deterministic_algorithms(True)它会让所有非确定性算子直接抛RuntimeError。比如某些插值操作、某些稀疏算子、atomicAdd相关的累加操作一碰上就会报错。这个开关在开发阶段非常适合用来暴露问题打开它跑一遍训练哪里报错哪里就是不兼容确定性的地方然后逐个处理。但在正式训练时不要盲目开启它因为有些算子改成确定性版本之后性能损耗很大如果不影响结论在数值级一致这个标准下接受也可以。2.4 进阶模型初始化、分布式训练中的种子设计除了全局种子模型初始化和分布式训练还需要更细的颗粒度控制。模型初始化方面如果代码里用nn.init.kaiming_uniform_这类函数可以显式传入自己的generator让初始化随机流与数据加载随机流解耦init_generator torch.Generator() init_generator.manual_seed(0) for name, param in model.named_parameters(): if param.dim() 1: nn.init.kaiming_uniform_(param, generatorinit_generator)这样做的好处是模型初始化永远只从init_generator里取随机数跟训练过程的数据shuffle完全无关。哪怕你在训练前多跑了一些调试代码也不会污染模型参数。分布式训练场景则不太一样。每个进程需要拿到不同的初始种子否则所有进程的shuffle顺序和初始化参数都一致丧失多样性。常用做法是在基础种子上叠加rank偏移rank dist.get_rank() set_seed(42 rank)之后再按rank设置数据分片。分布式场景里还要注意DDP的广播、梯度聚合顺序。梯度AllReduce默认是异步多线程累加聚合顺序在不同步数间变化也会引入微小数值抖动。要完全确定化需要设置torch.distributed.barrier()配合固定rank顺序代价是性能下降实际项目中多数人会在这一步选择接受数值级一致。3. 依赖锁定锁定Python包、CUDA运行时与系统库3.1 requirements.txt锁不住传递依赖一个真实的翻车案例我一开始做依赖管理时习惯在项目根目录放一个requirements.txt里面写死直接依赖的版本比如torch2.1.2、numpy1.24.0。本以为版本号锁住了就万事大吉。后来有一次我把训练代码从自己的机器迁到另一台机器结果损失曲线形状一样但收敛点差了不少。排查了大半天发现是numpy的间接依赖——底层BLAS库不同。第一台机器上是OpenBLAS第二台机器环境里安装了Intel MKL矩阵乘法的累加顺序不一样前向传播的浮点结果就出现微小漂移几十个epoch之后被放大了。问题的根源在于requirements.txt里只锁直接依赖pip install时会按照依赖树去拉取当前能拿到的最新传递依赖版本。今天装可能是1.2.0三个月后装就变成1.2.5行为可能已经静默改变了。要真正锁定必须把整棵依赖树的最终版本全部固定下来。3.2 pip freeze与conda env export正确使用和平台陷阱最直接的完整锁定方式是pip freeze它会列出当前环境所有已安装的包及精确版本。pip freeze requirements_frozen.txt使用时有两个经验。第一最好在全新创建的虚拟环境里生成避免把开发环境里的多余包一并记录下来。第二这个文件只能用于相同平台。Windows、Linux、macOS下的包名和构建号不同比如nvidia-cudnn-cu12这类包在不同系统上就不一样直接拷贝过去安装会失败。conda env export效果类似但它连conda包的构建信息一起记录比如numpy1.24.0py311hdd5b1c8_0这样的字符串。构建串的好处是连编译配置都锁死了但这也意味着跨平台迁移非常脆弱。conda用户更推荐的另一个命令是conda list --explicit spec-file.txt生成的spec-file.txt是纯URL列表形式安装时用conda create --name myenv --file spec-file.txt能精确恢复到同一套构建产物。如果想在pip生态里拿到带哈希校验的完整锁定文件可以用pip-toolspip-compile requirements.in --generate-hashes --output-file requirements_lock.txt这样生成的锁文件里每个包都带SHA256哈希安装时加--require-hashes参数可以防止包被替换或篡改也保证不同时间安装出来的环境内容一致。3.3 版本配套PyTorch、CUDA运行时与GPU驱动的记录方法PyTorch的安装方式会直接决定CUDA运行时版本。比如pip install torch2.1.2cu121和pip install torch2.1.2cu118虽然是同一个Torch版本但底层运行时和算子行为可能不同。所以锁定依赖时必须把PyTorch的安装源和CUDA后缀也一起记下来。我一般会把这些信息写进一个环境信息文件import platform import torch info { python_version: platform.python_version(), torch_version: torch.__version__, torch_cuda_version: torch.version.cuda, cudnn_version: torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None, cuda_available: torch.cuda.is_available(), gpu_name: torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, }注意区分两个概念nvidia-smi显示的Driver Version / CUDA Version是GPU驱动支持的CUDA版本torch.version.cuda是PyTorch包里实际链接的CUDA运行时版本。两者可以不同而且很多情况下就是不同。真正的运行时版本由PyTorch安装包自带或conda里的cudatoolkit决定。所以做可复现笔记时两条信息都要留驱动版本决定能跑什么样的CUDA运行时版本决定算子实际行为。3.4 终极方案Docker镜像下的隔离环境依赖锁定的最终形态是Docker镜像。镜像不仅锁定Python包还把操作系统、系统库、BLAS、cuDNN、CUDA运行时全部打包在一起。这样不管在哪台机器上跑只要容器能启动内部环境就完全一致。我的做法是做一个基础训练镜像Dockerfile大概长这样FROM pytorch/pytorch:2.1.2-cuda12.1-cudnn8.9-runtime WORKDIR /workspace COPY requirements_lock.txt . RUN pip install --require-hashes -r requirements_lock.txt COPY . .之后每次训练都从同一个镜像启动容器。宿主机的驱动版本由NVIDIA Container Runtime透传进来一般要求驱动不低于镜像内CUDA版本的对应下限否则容器起不来。这样哪怕宿主机之间驱动型号不同只要都能拉起同一个CUDA镜像训练环境也基本一致。当然Docker镜像构建本身也有原地重建不等于相同结果的问题——基础镜像的tag可能会被动更新所以更严格的团队会给镜像数字摘要digest保证FROM pytorch/pytorchsha256:xxxx。个人项目不一定要做到这一步但至少基础镜像的tag要写成明确的版本号不能写latest。4. 配置归档让每次实验都有完整的身份信息4.1 argparse不够用用YAML与dataclass管理配置很多项目的配置管理是从argparse开始的。跑实验时敲一长串命令行参数全靠一次性输入。问题在于实验做完之后想回看当时跑的是什么配置只能翻终端历史或者翻shell脚本非常不可靠。后来我转向了YAML配置文件加dataclass的方案。配置放到文件里一份文件对应一次实验天然可追踪。from dataclasses import asdict, dataclass import yaml dataclass class TrainConfig: lr: float 1e-3 batch_size: int 32 epochs: int 100 model_name: str resnet18 optimizer: str adam scheduler: str cosine seed: int 42 data_dir: str ./data exp_name: str baseline classmethod def from_yaml(cls, path): with open(path, r) as f: data yaml.safe_load(f) return cls(**data) def to_yaml(self, path): with open(path, w) as f: yaml.dump(asdict(self), f, default_flow_styleFalse)dataclass的好处是默认值集中管理、类型明确配合IDE的自动补全用起来舒服。如果项目配置更复杂可以换pydantic它自带类型校验yaml里写错类型能立刻暴露。4.2 实验目录设计配置、元数据与模型必须同框配置归档不只是存一份yaml文件那么简单最核心的原则是一次实验的所有信息必须落在同一个目录里谁也别想跑。我习惯的实验输出结构是这样experiments/ └── exp_20250101_120000/ ├── config.yaml ├── metadata.json ├── requirements_frozen.txt ├── model_best.pt ├── logs/ └── metrics.jsonconfig.yaml是本次实验最终生效的配置注意不是训练过程中实时读取的那份而是保存进输出目录的这份。如果后续手动调整过配置就以输出目录里的为准。metadata.json自动记录环境与代码状态import json import platform import subprocess import torch def make_metadata(): git_commit subprocess.check_output( [git, rev-parse, HEAD] ).decode().strip() dirty subprocess.check_output( [git, status, --porcelain] ).decode().strip() ! return { timestamp: __import__(datetime).datetime.now().isoformat(), git_commit: git_commit, git_dirty: dirty, hostname: platform.node(), python_version: platform.python_version(), torch_version: torch.__version__, cuda_version: torch.version.cuda, cudnn_version: torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None, gpu_name: torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, } def check_git_clean(): dirty subprocess.check_output([git, status, --porcelain]).decode().strip() if dirty: raise RuntimeError(Git working tree is not clean, commit your changes first.)这里git_dirty字段特别关键。训练实验一般持续很久中途改代码是常有的事。如果不做检查模型训练到一半代码变了实验记录就失去了意义。我的项目里会在训练启动前强制调用check_git_clean()有任何未提交改动直接中止训练。刚开始觉得这个流程很烦后来发现它救过我很多次——有一次就是某个文件里多了一行调试代码要不是有这个检查整个实验结果又作废了。4.3 代码与数据版本git commit、脏状态检查与数据清单模型权重存档的时候不能只保存state_dict。我保存checkpoint时会打包整套上下文torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch, best_metric: best_metric, config: asdict(config), metadata: metadata, }, f{exp_dir}/model_best.pt)这样哪怕别人只拿到一个.pt文件也能看到对应的配置、代码版本、运行环境。配合config.yaml和metadata.json整个实验的身份信息才是完整的。数据版本也要记录。最简单的方式是在配置里写data_dir然后在metadata.json里额外保存一份数据目录的文件清单摘要或者直接保存对应数据文件的哈希。如果用的是公开数据集记录下下载日期和下载来源就行。数据一旦更新过旧代码和新数据搭在一起实验结果同样不具备可比性。5. 实战模板一套可复现实验的完整骨架5.1 项目结构与入口脚本前面几章分别讲了机制现在把它们组合成一个可以直接抄作业的项目骨架。my_experiment/ ├── train.py ├── configs/ │ └── baseline.yaml ├── src/ │ ├── data_loader.py │ ├── model.py │ └── utils/ │ ├── seed.py │ └── metadata.py ├── requirements_lock.txt ├── Dockerfile └── run.sh入口脚本run.sh负责创建实验目录并触发训练#!/bin/bash set -euo pipefail EXP_NAMEexp_$(date %Y%m%d_%H%M%S) EXP_DIRexperiments/$EXP_NAME mkdir -p $EXP_DIR python train.py \ --config configs/baseline.yaml \ --exp_dir $EXP_DIR注意set -euo pipefail不要省尤其是set -u能防止未定义变量导致的脚本行为不一致这在分布式环境里很常见。5.2 核心代码train.py 的确定性写法训练主进程的核心代码关键点按顺序来import argparse import random import numpy as np import torch from src.utils.metadata import check_git_clean, make_metadata, save_config from src.utils.seed import seed_worker def main(): parser argparse.ArgumentParser() parser.add_argument(--config, requiredTrue) parser.add_argument(--exp_dir, requiredTrue) args parser.parse_args() check_git_clean() config TrainConfig.from_yaml(args.config) set_seed(config.seed) torch.use_deterministic_algorithms(True) generator torch.Generator() generator.manual_seed(config.seed) train_loader DataLoader( train_dataset, batch_sizeconfig.batch_size, shuffleTrue, num_workers4, worker_init_fnseed_worker, generatorgenerator, ) model create_model(config.model_name) optimizer build_optimizer(model, config) scheduler build_scheduler(optimizer, config) save_config(args.exp_dir, config) metadata make_metadata() with open(f{args.exp_dir}/metadata.json, w) as f: json.dump(metadata, f, indent2) for epoch in range(config.epochs): train_one_epoch(train_loader, model, optimizer, scheduler) val_loss, val_acc evaluate(model, val_loader) torch.save(..., f{args.exp_dir}/model_best.pt) if __name__ __main__: main()这个代码里随机种子的全链路是set_seed管住主进程全局RNGgenerator给数据排序一条独立随机流seed_worker保证每个加载子进程内部一致use_deterministic_algorithms(True)在训练早期把非确定性算子全部暴露出来。配置先加载、后验证、再保存输出目录里留下了完整副本。5.3 一键验证两次实验自动比对结果模板搭好之后如何确认可复现真正生效我的做法是连续跑两次完全相同的实验然后自动比对。写一个简单的校验脚本读取两个实验目录里的metrics.json比较每个epoch的验证损失和指标import json def load_metrics(exp_dir): with open(f{exp_dir}/metrics.json) as f: return json.load(f) exp1 load_metrics(experiments/exp_20250101_120000) exp2 load_metrics(experiments/exp_20250102_120000) for (m1, m2) in zip(exp1[val_acc], exp2[val_acc]): if m1 ! m2: print(fepoch diff: {m1} vs {m2}) break else: print(metrics match exactly.)实际跑下来的观察是只要环境完全相同、确定性开关全开、数据加载独立种子管理好验证集指标可以做到完全一致差一个小数点都不飘。如果只保证半套方案比如漏了generator很多场景下差别会出现在第一个epoch之后损失的浮动量级在1e-4以内但最终val指标可能差0.1%到0.5%不等。有一个细节很多人忽略即使torch.use_deterministic_algorithms(True)不会报错也不代表所有数值都是确定性的。它只保证PyTorch和CUDA算子层面的确定性但第三方自定义算子、部分autograd.Function、以及C扩展里的原子操作它管不到。所以验证这一步必须做而且要在真实的训练流程上做不能只在玩具代码上验证。6. 踩坑实录可复现性问题排查手册6.1 常见问题速查表现象常见原因处理建议设置了seed两次训练结果仍不同DataLoader的worker子进程没有接管种子或shuffle的generator未固定使用worker_init_fn和独立generator换了GPU型号结果变了GPU并行浮点求和顺序不同非确定性算子导致数值漂移开启deterministicTrue仍不一致则接受数值级一致同一台机器时隔一天结果不同环境依赖被升级过或代码有未提交修改用pip freeze/Docker锁定环境训练前check_git_clean()开了benchmarkTrue和deterministicTrue报错两者互相冲突固定benchmarkFalse仅用deterministicTrue错误提示operator is not deterministic跑了非确定性算子被use_deterministic_algorithms(True)拦住替换非确定性实现或对该部分绕行换了numpy版本后随机序列变化底层随机算法或实现变化锁定numpy版本并冻结完整依赖树多卡训练结果不稳定DDP梯度聚合顺序不一致固定rank顺序、限制并行归约或接受数值级一致Python里set遍历顺序每次不同PYTHONHASHSEED未固定启动命令带PYTHONHASHSEED0或固定值6.2 排查技巧与实战中的经验教训当复现失败时我的排查顺序是从最外到最内、从环境到代码。第一步对比两次实验的requirements_frozen.txt和metadata.json。先排除代码版本不一致、依赖版本漂移、硬件状态不同这些外部因素。这一步能解决掉至少一半的问题。第二步打开torch.use_deterministic_algorithms(True)重新跑一次。让它把代码里所有非确定性算子直接暴露出来然后逐个处理。之前有一个项目在数据预处理里用了torch.nn.functional.interpolate的align_cornersFalse模式这个模式在CUDA上非确定性开了严格模式之后立刻暴露换了一版实现就稳定了。第三步如果严格模式下没有报错但结果仍然不一致就要分模块排查。做法是把模型加载、数据加载、训练循环分别冻结先用手动固定好的初始权重只跑数据加载并打印每个batch的统计量看是否一致再固定数据只跑前向和反向逐层打印中间张量是否一致。通过二分法缩范围很快能定位到底是shuffle打乱了、还是某个算子数值波动、还是多进程调度造成了差异。最后我想讲一个在项目真正落地的建议可复现性不是靠自觉维持的而是靠流程。种子、依赖、配置这三件套必须天然嵌入项目的启动流程中——训练一开始就自动生成实验目录、自动保存依赖清单、自动记录代码commit、自动做脏状态检查。不做任何额外操作全程零成本落地。只有这样可复现性才不会在项目一忙起来的时候被忘到脑后。根据我个人的实操体会随机种子、依赖锁定和配置归档这套组合拳最容易被忽略的其实是配置与产物同步保存这一步。种子和环境锁住了丢失了配置文件一样等于白干。下次你再遇到代码没变结果变了的怪事不妨先看一眼实验目录里有没有今天讲到的这三个文件——大概率能从里面直接找到答案。
返回列表