隔三差五就有朋友问我“我跑了同一个 PyTorch 脚本换了台机器结果完全不一样是不是代码写错了”大多数情况下代码还真没错问题出在“实验环境”上。做深度学习实验模型初始化、数据打乱顺序、Dropout 的 mask、甚至 CUDA 卷积算子内部的原子操作都会引入随机性。算法本身带随机性不可怕可怕的是你没法控制住它导致实验结论不可信、对比不公平复现别人的结果更是对不上。这篇内容我就从 PyTorch 实验可复现这个方向出发把随机种子、依赖锁定、配置归档这几个关键手段讲透给出一套能直接抄走的实验管理方案。无论你是刚开始用 PyTorch 搭模型的学生还是已经在训练生产模型的研究工程师这套做法都能帮你少踩不少坑。1. 可复现性的本质先搞清楚哪些东西“必须控制”1.1 实验可复现到底是指什么可复现通常分成几个层级。最理想的情况是给你同样的代码、同样的数据、同样的环境你得到完全一样的结果。但实际上深度学习实验里“完全一样”是相当苛刻的因为计算过程涉及大量浮点运算和并行操作哪怕只是换了一张 GPU 型号卷积层的计算顺序都可能不同最终结果在小数点后几位产生差异。现实中更常见、也更有意义的可复现是“统计可复现”同一份代码在不同时间、不同机器上运行模型最终性能指标的波动范围可控排名结论稳定。比如说你用三组随机种子跑实验A 方案平均精度 88.2%B 方案平均精度 87.5%那这个结论是可信的反过来A 方案 88% 一次、B 方案 88.2% 一次单次结果就成了“抛硬币”。所以我在做实验管理时第一件事就是分清楚哪些随机性必须消除哪些随机性应当保留并显式记录。模型初始化种子必须固定数据 shuffle 的种子必须固定Dropout 的随机行为种子必须固定而 BatchNorm 在 GPU 上因为底层算子并行导致的细微浮点差异通常当作可接受的噪声处理。这个边界想明白你才知道种子方案做到什么程度算“到位”。1.2 影响可复现性的四类因素我把平时遇到的复现性问题归纳成四类排查时可照着顺序来第一类是随机源。Python 自带的random、NumPy 的np.random、PyTorch 的torch.Generator、CUDA 后端的随机数生成器这四个各自独立少设一个都不会报错但结果就是不对。很多人只设置了torch.manual_seed结果数据加载部分用的是 NumPy 随机模型初始化又用了别的随机源导致复现失败。第二类是并行与算子实现。DataLoader 的num_workers、CUDNN 的 benchmark 模式、CUDA 卷积的原子操作、Apex 之类的混合精度训练这些都会影响计算顺序。同一个模型在 CPU 上跑和 GPU 上跑浮点运算的累加顺序不一样结果也会有细微差异。这个问题无法靠种子完全解决只能通过设置 deterministic 选项和规范设备环境来降低差异。第三类是依赖环境。PyTorch 版本、CUDA 版本、cuDNN 版本、Python 版本、各个第三方库的版本都会直接影响计算结果。最典型的就是torch1.12和torch2.1对同一个模型的初始化分布实现完全一样但某些算子的底层实现变了结果就对不上。第四类是外部配置。训练超参数、数据预处理方式、随机种子设置这些显性配置还好说最坑的是那些“隐性配置”比如OMP_NUM_THREADS环境变量、.env文件里的开关、命令行里没被记录进日志的参数。这些配置不归档后人复现的时候根本不知道当初是怎么跑的。1.3 复现目标设定平台可依赖性与语义可复现性这里我想多说一句容易被忽视的点可复现还要分“跨平台”和“同平台”。跨平台复现难度极高因为不同 CPU 指令集、不同 GPU 型号、不同操作系统的数学库实现都可能不一样。如果你需要跨机器复现建议固定到同一系列的 GPU 和同样的 CUDA 版本否则不要强求完全一致。同平台复现是大多数情况下能做到的。同一种 GPU、同一个驱动、同一套环境再用下面的种子方案和依赖锁定基本可以精确到每一位输出。我在组里给团队定的目标是同环境精确复现跨环境保证指标趋势稳定。这样既科学又不会让工程师把时间耗费在“为什么这一次和上一次差 0.0001”这种无意义问题上。2. 随机种子四层随机源与一套标准配置方案2.1 Python、NumPy、PyTorch 与 CUDA 的种子设置很多人以为随机种子就是torch.manual_seed(42)一行代码但实际上至少需要处理四个层面的随机源。Python 内置的random库在某些数据处理逻辑里会被用到NumPy 的np.random是数据增强里最常见的随机来源PyTorch 自身维护着 CPU 和 GPU 两套随机数生成器。我建议把种子设置封装成一个函数在实验入口处统一调用不要散落在代码各处。核心代码如下import random import numpy as np import torch def set_seed(seed: int 42) - None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 以下两项是在CUDA卷积、cuDNN算子层面尽量消除不确定性 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False这段代码里的细节值得展开讲torch.manual_seed(seed)同时设置 CPU 和当前 GPU 的种子但在多卡环境下还需要torch.cuda.manual_seed_all(seed)把所有 GPU 的随机数生成器都固定住。torch.backends.cudnn.deterministic True是告诉 cuDNN 选用确定性算法而不是性能最优算法torch.backends.cudnn.benchmark False是禁止 cuDNN 在运行时自动搜索最优卷积实现。这两项一起设置能显著降低 GPU 计算顺序变化带来的不确定性代价是可能损失 5% 到 20% 的训练速度。我自己的习惯是训练阶段可以不开 deterministic但在消融实验和最终报告实验里必须打开。2.2 不要忽略 DataLoader 的随机性数据加载环节是另一个坑。DataLoader里设置了shuffleTrue后PyTorch 会用全局默认生成器来打乱数据顺序。问题在于如果你在训练脚本里创建了多个DataLoader它们会共享同一个默认生成器这会导致两个 DataLoader 的打乱顺序产生耦合复现时很难定位原因。更常见的问题是num_workers大于 1。每个 worker 进程会继承主进程的随机状态但worker_init_fn如果不显式设置不同 worker 的随机序列可能高度相关。标准做法是在worker_init_fn里基于 worker id 重新设置种子from torch.utils.data import DataLoader def worker_init_fn(worker_id: int) - None: seed 42 worker_id np.random.seed(seed) random.seed(seed) train_loader DataLoader( dataset, batch_size64, shuffleTrue, num_workers4, worker_init_fnworker_init_fn )这样保证每个 worker 进程的随机序列彼此独立且固定。另外还有个细节如果 shuffle 使用了你自己创建的torch.Generator那么必须在每次 epoch 里显式调用generator.manual_seed(seed)才能保证跨 epoch 的可复现。我通常会避免依赖“全局随机状态”做数据增强而是尽量给每个随机操作传入独立的生成器代码虽然啰嗦一点但排除问题时能省很多时间。2.3 模型权重初始化与 Dropout 等层的可复现PyTorch 的nn.Module默认初始化其实已经走 PyTorch 全局随机数生成器了所以只要torch.manual_seed设置好模型权重初始化就是确定的。但如果你在forward里使用torch.rand、torch.randn这类操作生成随机噪声或者自定义初始化时用了np.random就又会引入额外随机源。我的经验是除了全局种子之外还要在关键位置使用显式生成器。例如def init_weights(m): if isinstance(m, nn.Linear): gen torch.Generator() gen.manual_seed(2024) nn.init.kaiming_normal_(m.weight, generatorgen)这样可以做到“这个 Layer 的初始化只受我自己指定的种子控制”和全局状态解耦。Dropout 层本身在训练时依赖 PyTorch 全局生成器固定全局种子后行为就是确定的不需要特殊处理。但要注意的是torch.use_deterministic_algorithms(True)这个更严格的开关会强制所有操作都必须有确定性实现某些情况下可能报错不建议默认开启只有在你追求严格逐位复现时才启用。2.4 CUDNN benchmark 和 deterministic 的取舍关于 cuDNN 的这两个开关我多说一点。benchmarkTrue在输入尺寸固定时能通过自动搜索选择合适的卷积算法训练速度确实有提升。但它带来的副作用是每次进程启动后搜索到的“最优算法”都可能不一样即使是同一输入尺寸不同 GPU 型号、不同 cuDNN 版本也可能选择不同的实现路径导致计算结果出现差异。我的建议是分级处理如果只是前期调参、快速验证想法保持benchmarkTrue这时候你关注的是相对性能曲线一旦进入正式实验、需要和别人对比或者存档就把benchmarkFalse和deterministicTrue打开。还有一种折中方案先在 benchmark 模式下跑通流程最后正式实验时再切回 deterministic 模式跑一遍确认结果趋势一致再继续。3. 依赖锁定从“能跑”到“可复现”的关键3.1 conda 环境导出与 pip freeze 的取舍随机种子再完美环境一变照样复现不了。依赖锁定是第二道防线。实际工作中我见过三种常见的依赖管理方式第一种是用pip freeze requirements.txt。这个命令很普及但有个问题pip freeze会导出当前环境中所有包包括依赖的依赖同时它不一定能真实反应“安装时怎么解析的”。在跨平台场景下同一个包在不同系统上可能有不同版本号的本地发布pip freeze只能锁 Python 包版本锁不住系统库。第二种是用conda env export environment.yml。这种方式相对好一些它包含了 conda 渠道、构建来源能锁定得比较完整。但导出的文件是平台相关的里面会写build字符串换到 Linux 上经常还原不出来不过这正是“精确复现”想要的。我的习惯是同时保留两份pip freeze requirements.txt conda env export environment_full.yml然后再单独写一个精简版environment.yml只包含顶层直接依赖name: reproducible-lab channels: - pytorch - conda-forge - defaults dependencies: - python3.10 - pip - pytorch2.1.2 - torchvision0.16.2 - torchaudio2.1.2 - cudatoolkit11.8 - pip: - hydra-core1.3.2 - numpy1.24.4这样做的逻辑是environment_full.yml用于“精确重建”environment.yml用于“快速恢复可运行环境”。实际还原时用完整版成功率高很多尤其是需要和之前完全一致的 CUDA 相关库的时候。3.2 哈希校验锁定到“代码不变”级别pip 提供了哈希校验机制可以在安装时验证包的完整性。做法是使用pip-tools生成带哈希的锁定文件pip install pip-tools pip-compile --generate-hashes requirements.in -o requirements.lockrequirements.in是你手写的直接依赖requirements.lock是解析后的封闭依赖集合每个包都带上了哈希值。安装时用pip install -r requirements.lock这样做的好处是哪怕同一个版本的包在 PyPI 上被人重新上传过哈希对不上就装不上能够防篡改。对于内部实验、审计、合规要求较高的场景这一步很值得做。缺点是要多维护一个.lock文件且升级依赖时稍微多几步操作但相对于“几个月后复现不出来”的代价这点维护成本非常划算。3.3 环境版本记录的自动化依赖锁定的文件要放进 Git 仓库并且和代码改动一起提交。Commit message 里最好标注“实验环境升级到 XX 版本”这类信息。我通常还在项目根目录放一个scripts/dump_env.sh脚本每次启动训练前自动生成环境快照#!/usr/bin/env bash mkdir -p env_snapshots pip freeze env_snapshots/requirements_$(date %Y%m%d_%H%M%S).txt conda env export env_snapshots/environment_$(date %Y%m%d_%H%M%S).yml这个自动化做一次后续所有实验都有环境快照备份。配合下面的配置归档方案任何一个实验结果都能找到对应的代码提交号、依赖环境快照和配置文件。这对论文复现和跨团队协作都是刚需。4. 配置归档把超参数和运行元数据一起存档4.1 用 YAML 管理超参数而不是命令行硬编码我在早期做实验时超参数都是写在命令行里跑完就忘了过两周回来看完全不知道某个结果对应的是哪组参数。后来开始使用配置文件整个体验完全不一样。推荐用 YAML 文件统一管理超参数因为它可读性好还天然支持嵌套结构。一个典型的配置文件长这样data: dataset: cifar10 train_batch_size: 128 val_batch_size: 256 num_workers: 4 model: arch: resnet34 pretrained: false dropout: 0.2 train: epochs: 100 lr: 0.01 momentum: 0.9 weight_decay: 5e-4 lr_scheduler: cosine seed: 42 device: cuda配合 Hydra 这样的配置管理库可以实现命令行覆盖、多配置组合、配置归档一次性搞定。Hydra 会自动生成每次运行的工作目录并把所用的配置以config.yaml形式存下来这解决了我最头疼的“配置丢失”问题。如果你不想引入新技术也可以自己写一个加载逻辑import yaml from dataclasses import dataclass dataclass class Config: seed: int 42 data: dict None model: dict None train: dict None classmethod def from_yaml(cls, path: str): with open(path, r, encodingutf-8) as f: raw yaml.safe_load(f) return cls(**raw)这样配置就是一个数据类访问起来方便替换成别的格式也容易。核心目的是配置文件本身是实验的输入必须和结果一起归档。4.2 给每一条实验日志打上 git hash 和运行时间光有超参数还不够当时用的代码是哪个版本的同样关键。我强烈建议在启动训练时自动记录 Git 提交哈希和运行时间。最简单的办法是在主训练脚本开头加这样的逻辑import subprocess import os import json from datetime import datetime def collect_metadata(): try: git_hash subprocess.check_output( [git, rev-parse, HEAD], stderrsubprocess.DEVNULL ).decode(utf-8).strip() git_branch subprocess.check_output( [git, rev-parse, --abbrev-ref, HEAD] ).decode(utf-8).strip() except Exception: git_hash, git_branch unknown, unknown return { git_hash: git_hash, git_branch: git_branch, run_time: datetime.now().isoformat(), hostname: os.uname().nodename, pid: os.getpid(), }把这些元数据和最终的实验指标一起写入一个run_meta.json存进日志目录。这样做的好处是以后无论你翻到哪个结果文件都能知道“这是哪个代码版本在什么时间哪台机器上跑的”配合配置文件和依赖快照整个实验链条就闭合了。4.3 用日志目录统一归档结果我推荐的一种目录结构如下experiments/ └── run_20250218_153042/ ├── config.yaml # 本次运行的完整配置 ├── run_meta.json # git hash、时间、机器信息 ├── requirements.txt # 本次运行的pip依赖快照 ├── logs/ # 训练日志 ├── checkpoints/ # 模型权重 └── metrics.json # 最终指标、best epoch每次运行都在experiments/下新生成一个带时间戳的目录把所有和实验相关的东西放进去。这个方案比散落在outputs/、runs/里的做法更清晰。我实际开发中还会写一个run_experiment.py脚本统一处理“创建目录 → 保存配置 → 收集元数据 → 训练 → 写指标”的流程团队新成员上手时也不用知道内部细节只需要运行脚本并传入配置名即可。4.4 配置文件与代码同步更新的心态调整配置管理如果做得太死代码一改配置就得改容易让人觉得负担重。我的经验是配置管理不是“一次性做到完美”而是做到“每次改动作都留下痕迹”。你可以允许 config 里有些试验性的临时字段但一旦这个配置产生了你打算保留的结果就要把配置和代码版本一起标记下来。Git tag 是个好工具比如v0.3-exp1-best-acc88标签对应某个配置和代码状态后期回溯非常方便。这个习惯坚持几次实验后收益会特别明显。5. 可复现训练脚本实战一个可以直接改的模板5.1 封装一个通用 Trainer 类把种子、配置、日志目录这些基础能力整合到一个可复现脚本里很有必要。下面给出一个精简但完整的模板基于 PyTorch 常见的训练流程设计适合你直接复制后根据实际任务改造。import logging import time import json import random from pathlib import Path import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader import yaml class ReproducibleTrainer: def __init__(self, config_path: str): with open(config_path, r, encodingutf-8) as f: self.cfg yaml.safe_load(f) self.seed self.cfg.get(seed, 42) self._set_seed() self._prepare_run_dir() self._archived_config() self.device torch.device(cuda if torch.cuda.is_available() else cpu) def _set_seed(self): random.seed(self.seed) np.random.seed(self.seed) torch.manual_seed(self.seed) torch.cuda.manual_seed_all(self.seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False def _prepare_run_dir(self): ts time.strftime(%Y%m%d_%H%M%S) self.run_dir Path(experiments) / frun_{ts} self.run_dir.mkdir(parentsTrue, exist_okTrue) def _archived_config(self): import shutil, subprocess # 记录配置副本 with open(self.run_dir / config.yaml, w, encodingutf-8) as f: yaml.safe_dump(self.cfg, f) # 记录git信息 meta {} try: meta[git_hash] subprocess.check_output( [git, rev-parse, HEAD] ).decode().strip() except Exception: meta[git_hash] unknown meta[created_at] time.strftime(%Y-%m-%d %H:%M:%S) with open(self.run_dir / run_meta.json, w, encodingutf-8) as f: json.dump(meta, f, indent2, ensure_asciiFalse) # 记录依赖 subprocess.run([pip, freeze], stdoutopen(self.run_dir / requirements.txt, w)) def build_model(self): raise NotImplementedError def train_one_epoch(self, model, loader, criterion, optimizer): model.train() total_loss 0.0 for x, y in loader: x, y x.to(self.device), y.to(self.device) optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(loader.dataset) def run(self): model self.build_model().to(self.device) trainset self.build_dataset() loader DataLoader( trainset, batch_sizeself.cfg[data][train_batch_size], shuffleTrue, num_workersself.cfg[data][num_workers], worker_init_fnlambda wid: random.seed(self.seed wid) ) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD( model.parameters(), lrself.cfg[train][lr], momentumself.cfg[train][momentum], weight_decayself.cfg[train][weight_decay], ) print(fRun dir: {self.run_dir}) for epoch in range(self.cfg[train][epochs]): loss self.train_one_epoch(model, loader, criterion, optimizer) print(fEpoch {epoch:03d} loss {loss:.4f}) def build_dataset(self): raise NotImplementedError这个模板的思路是把“可复现机制”和“具体任务”解耦你需要做的就是实现build_model和build_dataset。实际训练逻辑可以自己扩展比如加入验证集评估、学习率调度、模型 checkpoint 保存、早停等但基础的可复现框架已经就绪。5.2 我推荐的项目结构与启动方式实际的深度学习项目再往上一层还需要一个清晰的项目结构project/ ├── configs/ │ ├── default.yaml │ └── experiment_a.yaml ├── src/ │ ├── models/ │ ├── data/ │ └── trainer.py ├── experiments/ │ └── run_xxx/ ├── scripts/ │ └── dump_env.sh ├── environment.yml ├── requirements.lock └── run_experiment.py启动实验的方式是python run_experiment.py --config configs/default.yaml。在这个入口脚本里完成三件事读取配置、初始化 Trainer、启动训练。这样整个实验流程的入口统一、配置统一、日志统一团队成员之间协作时的沟通成本也低很多。我见过很多项目把训练逻辑写满各种 notebook 和散落的 py 文件最后想复现的时候根本找不到“哪个文件才是本次实验的主入口”所以在项目初期就固定好结构特别重要。5.3 从零开始跑通复现流程的详细步骤完整流程大致是这几步创建虚拟环境并锁定基础依赖版本尽量选定一个明确的 PyTorch 版本和 CUDA 版本组合。把上面整理好的set_seed、worker_init_fn、ReproducibleTrainer这套基础模块放进项目里。把实验超参数写到configs/default.yaml。运行入口脚本确认experiments/run_xxx目录下自动生成了完整配置、运行元数据、依赖清单和训练日志。在同一台机器上再次执行同样的命令对比两次的metrics和 loss 曲线确认一致。换一台同型号 GPU 的机器执行再次确认结果保持稳定。这套流程跑通之后你的实验基础设施就具备了基本的可复现能力。后续再遇到“为什么结果不一样”的问题排查范围会被大大缩小要么是代码改了没记录要么是依赖变了要么是硬件差异导致的种子外随机。5.4 实验记录不仅写代码更要写结论我自己还有一个习惯每次实验跑完除了自动记录配置、依赖、日志还会在run_xxx/README.md里手写几行结论比如“这个实验验证了 batch size 从 128 改到 256 后收敛更快但最终精度下降了 0.3%怀疑是学习率没有相应调整”。这些文字记录虽然不像配置文件那样结构规整但在你几个月后回看实验结果时它提供的上下文价值远超所有自动化日志。可复现的终极目标不是把每个数字都固定下来而是让实验链条足以支撑你或者别人做出可靠的判断。6. 公示期与后续工作长期可复现的几条建议6.1 打造一次运行、多年可查的存档习惯有一种场景很常见几个月后你或者同事要复现当初的某个实验。如果当时只留了一个 checkpoint 文件和一个“当时挺厉害的”口头描述这个实验基本就失传了。要让实验长期可查需要把“配置 代码 环境 说明文档”这个四件套绑定在一起放进一个有版本的控制系统里比如 Git 仓库加 Docker 镜像的可选组合。一个比较实用的做法是每个重要实验发布时在 Git 仓库打一个 tag并附带说明文档内容包含实验结论、关键配置路径、运行命令。Docker 镜像是锦上添花合适的时候可以把完整环境打个镜像例如docker build -t exp-repro:2025-02 .这样别人只需要拉取镜像就能直接跑。但这个方案体积大我一般只在准备对外发布或协作方机器环境特殊的时候使用。6.2 数据版本的锁定不能遗漏可复现实验里面还有一个容易遗漏的部分——数据版本。训练数据的版本、预处理后的数据缓存、数据文件和随机种子一样都是影响结果的关键输入。如果数据文件被重新整理过哪怕样本内容一样shuffle 之后的结果也会不同。推荐做法是给数据集文件记录一个哈希值import hashlib from pathlib import Path def file_sha256(path: str) - str: h hashlib.sha256() with open(path, rb) as f: while chunk : f.read(8192): h.update(chunk) return h.hexdigest()在训练脚本启动时把数据目录的版本信息或文件哈希写入run_meta.json。如果数据来自线上拉取则记录远端数据版本号。这样以后排查“数据变化导致结果变化”时就有据可依。我遇到的很多“同样代码结果对不上”的案例最后定位到是数据重新做了预处理导致数据分布细微变化而当时只改了 dump 数据的那段代码没有记录数据版本排查过程极其痛苦。6.3 团队协作时可复现性的最小公约数如果是团队合作可复现性不能只靠某一个成员的自觉。我会建议团队统一好三样东西基础环境镜像或 conda 环境文件、实验目录规范、实验结论记录模板。基础环境一旦确定就不要频繁更换确需升级时要显式记录在变更日志里。实验目录规范保证每个人产出的记录格式一致检索和对比都很方便。结论记录模板则要求每次实验必须写清楚假设、改动、结果、结论四项。这三样加起来团队里的每个人都默认处于同一个可复现工作流中不会出现“你的实验结果和我对不上”这类内耗。我在实际带团队时还要求实验运行脚本接收一个--tag参数强制每条运行记录挂上语义标签比如baseline-resnet34或exp-lr-0.001这样找历史实验时可以直接按标签过滤非常实用。6.4 开源与对外发布时的可复现清单最后讲讲开源或对外发布的情况。如果你的实验结果是用来支撑论文或开源项目可复现的标准会更高。我自己的发布清单如下提供精确到版本的依赖清单同时提供 conda 和 pip 两种格式。提供随机种子设置代码并在 README 中说明推荐的种子配置方式。提供数据获取来源与版本说明大数据集尽量给出文件哈希。提供一键运行脚本脚本内部自动完成环境准备、配置加载和训练启动。写明运行环境包括操作系统、GPU 型号、驱动版本、CUDA 版本。如果可能提供一个小数据集的快速复现 demo保证任何人拿到手能在几分钟内跑通完整流程。一个能快速跑通的小 demo价值远大于一大段说明文档。它让其他人能在低代价下先确认环境没问题再放心去复现完整实验。我在发布开源项目时一定做这个收到的“复现不了”反馈会大幅减少。在我自己的实践里最深刻的体会是可复现性不是实验做完之后补的一个步骤而是实验一开始就要构建的框架。种子设置、依赖管理、配置归档、数据版本记录这些都是“基础设施”不是可选项。你花半小时把基础打好后面每次实验都会自动受益反过来等结果对不上的时候再来补做成本通常高一个量级。PyTorch 本身就在持续更新对复现性的支持但这不意味着你什么都不用管——把随机源管住、把依赖锁住、把配置归档这三件事做好你的实验就有了可信的根基。最后分享一个小技巧每次跑完实验顺手看一眼run_meta.json里记录的 git hash 和你实际代码的git log对不对得上哪怕一次对不上都要立刻查清楚原因因为这种“隐性漂移”往往就是复现失败的起点。