
如果你已经开始用 PyTorch 写模型那你大概率每天都会碰到loss.backward()。大多数教程都会告诉你“这是自动求导你不需要自己算梯度”但很少有人讲清楚这行代码背后到底发生了什么。结果就是一旦 loss 是 NaN、梯度突然消失、retain_graphTrue报错、或者你想实现一个自定义算子就只能靠猜。我一直觉得autograd 是 PyTorch 最核心、也最容易被当黑盒的一部分。这篇文章就不打算做浮于表面的功能介绍而是带你从计算图、反向传播、梯度的流向开始把backward()的每一步拆开看。你会弄明白requires_grad、grad_fn、.grad这几个东西的真实身份也会理解为什么更新模型参数时要用torch.no_grad()。更关键的是文末我整理了实际训练里最容易踩的坑和排查技巧可以当工具文收藏。1. 先从一张计算图开始autograd 到底在帮你算什么1.1 前向传播构建的是一张有向无环图很多人在初学 PyTorch 时会有个错觉loss.backward()是一个“自动求导引擎”在 loss 出来之后才开始“从 loss 出发去计算梯度”。严格来说这个说法不够准确。autograd 的实际工作方式是你在执行前向传播的同时就动态构建了一张计算图。用一段极简代码来说import torch x torch.tensor([2.0], requires_gradTrue) w torch.tensor([3.0], requires_gradTrue) b torch.tensor([1.0], requires_gradTrue) y w * x # y.eval() 6一个乘法节点 z y b # z.eval() 7一个加法节点 loss z.sum() # 标量当你执行y w * x时PyTorch 不只是算出一个数值6它同时创建了一个记录“这个值是怎么来的”的节点——MulBackward。接着z y b继续记录AddBackwardloss z.sum()记录SumBackward。这些节点串起来就是一张记录前向运算顺序的图x - w - y - z - loss。那张图在内存里并不是一个抽象的“数据结构”它就体现在每个 Tensor 的属性上。打印一下你就懂了print(x.grad_fn) # None因为 x 是叶子 print(y.grad_fn) # MulBackward0 object at ... print(z.grad_fn) # AddBackward0 object at ... print(loss.grad_fn) # SumBackward0 object at ...叶子x、w、b是我们自己创建的所以没有grad_fn非叶子节点y、z、loss都是由运算产生的它们各自的grad_fn就代表“哪个反向函数负责计算我的梯度”。沿这条链回溯PyTorch 就能在backward()调用时逐个节点地倒推梯度。1.2 为什么requires_gradTrue只控制叶子节点requires_grad很容易被人误解成“这个 Tensor 是否参与计算”实际上它的准确含义是“是否需要为这个 Tensor 计算梯度”。如果你把requires_gradTrue放在w、b上那么所有从它们运算得到的 Tensor 都会默认requires_gradTrue因为它们的梯度需要继续往后传。而如果你有某个中间量不想让梯度流过正确做法是用x.detach()截断或者把它包在torch.no_grad()里。注意requires_grad不是一个永久的 mark它会在每个新张量上动态传递。提示requires_gradTrue只表示“这个张量本身有资格获得梯度”而最终能不能真的得到梯度还要看它是否在从某个标量loss到它的反向路径上。1.3 一个能帮助记忆的类比反向传播是流水线上往回找“谁贡献了多少”把计算图想象成一家工厂的流水线。原料是x、w、b每个工位运算节点都会对上一环节的产出做加工最终产出loss。正向的时候流水线从左走到右只传产品或中间件。反向传播则是把这些中间产品重新走回去每个工位同时向它的所有上游分发“这东西最终影响的敏感度”。具体来说从loss出发先算出它对z的敏感度也就是∂loss/∂z这个值再经过z工位算出∂loss/∂y和∂loss/∂b再往前传得到∂loss/∂x和∂loss/∂w。注意一个关键点某个节点可能被多个下游使用比如loss (y1**2).sum() (y2**2).sum()x被两套运算共用。反向传播在这种情况下会分别计算两条路径上loss对x的贡献然后把它们相加。这与链式法则完全一致也是后面梯度累加机制的基础。2. 反向传播的三条铁律与设计逻辑2.1 第一条梯度默认会累加到.grad而不是覆盖第一次写训练循环的人几乎都踩过这个坑一轮更新参数后不zero_grad()下一轮backward()梯度直接变成原来的两倍模型训练发散。这其实是autograd 有意设计的行为因为很多场景下你需要对多个 batch 梯度求和或者对同一份参数在不同分支上回传的梯度做累加。PyTorch在Tensor.backward()的时候会把这个 Tensor 的梯度grad累加进每个非叶子/叶子的.grad属性里而不是重新计算后覆盖。所以训练循环的标准顺序必须是optimizer.zero_grad() # 第一步清空上一步遗留的梯度 loss.backward() # 第二步反向传播将计算出的梯度累加到 .grad optimizer.step() # 第三步根据 .grad 更新参数调换到zero_grad()放在backward()之后也行但绝大多数优化器 API 的设计就是让你先清空。如果你写成backward()之后zero_grad()那这次的梯度就没了相当于没有更新。实操提醒如果在backward()之前忘了zero_grad()得到的不是“梯度更准”而是“上一轮和这一轮梯度求和”。尤其是 batch_size 较小时这种错误不会立刻让你的 loss 飙到 NaN而是让训练曲线出现奇怪的锯齿状波动。2.2 第二条loss.backward()只对标量有效非标量必须提供 “gradient” 参数源码里torch.autograd.backward(tensors, grad_tensorsNone, retain_graphNone, create_graphFalse)其中的grad_tensors参数就是为“非标量输出”设计的。如果你直接对outputs model(x)形状是[batch, num_classes]调用outputs.backward()会报错RuntimeError: grad can be implicitly created only for scalar outputs为什么会这样因为对一个[batch, num_classes]的输出严格来说“梯度”不是一个向量而是一个雅可比矩阵每个输出分量对每个输入分量都有偏导。默认情况下PyTorch 要你指定一个与outputs同样形状的gradient张量作为“上游传来的梯度种子”。然后它通过雅可比-向量积JVP/VJP的方式计算出loss对参数的梯度。换言之autograd 内部并不真正妄图构造完整雅可比矩阵而是实现了一个“向量-雅可比积”的算子。你传入的gradient就相当于给每个输出分量一个权重相当于是某个假定的上层 loss 对输出的偏导。out torch.tensor([1.0, 2.0, 3.0], requires_gradTrue) # 不能直接 out.backward() # 需要提供一个同样形状的“上游梯度” out.backward(torch.tensor([0.1, 0.2, 0.3])) print(out.grad) # 输出 [0.1, 0.2, 0.3]这正好解释了为什么模型训练里都写loss.backward()因为loss已经是标量。如果你想对非标量调用backward()最常见的做法是先loss outputs.mean()或者loss outputs.sum()转成标量。2.3 第三条默认反向只计算一次多次调用要显式retain_graphTrue每次loss.backward()autograd 会沿着计算图从loss一路回传到叶子完成梯度计算后它会释放这张计算图或者更准确地说释放反向过程中的中间缓冲区。这是为了节省内存——对于训练场景一批数据算完了图就没有用了。但如果你需要在一个循环里多次backward()例如某些 GAN 训练判别器要基于同一份特征图计算多次 loss或者你想在同一个 loss 上既反传又再次计算梯度第二次调用就会出现RuntimeError: Trying to backward through the graph a second time, but the buffers have already been freed. Specify retain_graphTrue when calling backward the first time.解决办法是在第一次backward()时传retain_graphTrue。但这是有代价的图不会被释放内存开销变大。所以只在确实需要时才开用完记得关掉或者把能算的先算完。3. 实操案例从一个模型训练循环看懂 autograd 的全部关键动作3.1 搭建一个极简模型并手动观察梯度流下面我构造一个最简单的线性回归模型只用两个参数w和b。这个例子里没有用nn.Linear目的是让你能直接看到参数在backward()前后的.grad值是怎么变化的。import torch torch.manual_seed(0) # 真实模型y 2x 1 true_w torch.tensor(2.0) true_b torch.tensor(1.0) # 随机生成训练数据 x_data torch.linspace(-1, 1, 100).reshape(-1, 1) # [100, 1] y_data true_w * x_data true_b 0.05 * torch.randn_like(x_data) # 初始化待训练参数 w torch.tensor([0.0], requires_gradTrue) b torch.tensor([0.0], requires_gradTrue) # 简单前向模型 y_hat w * x b def model(x): return w * x b # 训练参数 lr 0.1 steps 3 for step in range(steps): # 前向计算 pred model(x_data) loss ((pred - y_data) ** 2).mean() # 关键先清空梯度再反向 if w.grad is not None: w.grad.zero_() if b.grad is not None: b.grad.zero_() loss.backward() if step 0: print(fstep {step}, w.grad {w.grad}, b.grad {b.grad}) # 手动更新参数 with torch.no_grad(): w - lr * w.grad b - lr * b.grad print(fstep {step}, loss {loss.item():.6f}, w {w.item():.4f}, b {b.item():.4f})这里你会在第一步就看到w.grad和b.grad是一个标量 Tensor约等于梯度。这不是 PyTorch 在backward()时“返回”梯度而是PyTorch把梯度“写到”了张量自带的.grad属性上。3.2 为什么更新参数时要套torch.no_grad()代码里更新参数用了with torch.no_grad(): w - lr * w.grad。如果你去掉这个上下文管理器会发生什么w - lr * w.grad是一个“减法和赋值”的 in-place 操作。在没有no_grad的情况下w是一个requires_gradTrue的张量lr * w.grad也是一个有 grad 的张量两者相减会得到一个新的张量而把新的张量 in-place 赋值回w时会导致原有计算图上的叶子 Tensor 值被修改进而在下次前向时产生混乱。从语义上讲更新参数这个动作本身不应该被纳入计算图——你不想让“用什么学习率、减去多大梯度”也变成需要反向传播的一环。no_grad()就是做这个边界用的。如果你用的是optimizer.step()优化器内部也早就用torch.no_grad()包住了参数更新逻辑。所以你不会看到它暴露出来但它确实存在。3.3 训练循环里的标准模板和常见误区实际做深度模型时你不会手写w.grad更新而是用nn.Module配合优化器model torch.nn.Linear(1, 1) optimizer torch.optim.SGD(model.parameters(), lr0.1) for x, y in dataloader: optimizer.zero_grad() pred model(x) loss ((pred - y) ** 2).mean() loss.backward() optimizer.step()这个模板里最值得玩味的是optimizer.zero_grad()的位置。它必须在backward()之前因为在backward()之后梯度已经算好optimizer.step()要用。如果zero_grad()放到step()之后等于清空了刚算出来的梯度这轮更新就作废了。另外很多人会混淆model.eval()和torch.no_grad()。eval()只影响 dropout、batchnorm 等模块的前向行为不关闭梯度计算no_grad()则不改变模型 mode只是关闭自动求导以节省内存。推理时尽量两者都做但这是两个独立概念别混用。4. 升级打法非标量、高阶导、autograd.grad 与自定义反向4.1torch.autograd.grad和backward()的区别除了Tensor.backward()你还会遇到torch.autograd.grad(outputs, inputs, grad_outputsNone, create_graphFalse, retain_graphFalse)。两者都是反向计算但区别在于backward()把梯度写入到inputs的.grad属性中再通过optimizer.step()更新。torch.autograd.grad()直接返回一个梯度张量元组不写入.grad属性。比如你想在训练过程中额外输出某个中间层的梯度又不想污染参数缓冲就可以用torch.autograd.grad()grad_magnitude torch.autograd.grad( outputsloss, inputsfeature_map, retain_graphTrue )[0]这种方式特别适合调试、分析梯度范数或者在算法里需要“提取梯度值”但不要影响原有反向流程时。4.2 非标量反向与雅可比-向量积的直接体验前面提到非标量backward()必须传入gradient。再举一个直观的例子假设你的model输出三个值你想让第一个输出的贡献更大就可以把gradient设置成[1, 0, 0]用向量积实现“对第一个分量求导”。import torch x torch.tensor([1.0, 2.0, 3.0], requires_gradTrue) def simple_fun(x): return x ** 2 # 每个元素独立平方 out simple_fun(x) out.backward(gradienttorch.tensor([1.0, 0.0, 0.0])) print(x.grad) # 相当于只计算了第一个分量的梯度2 * x[0] 2其余为0这里的gradient就是雅可比-向量积中的“向量”。平时你不写它是因为标量 loss 相当于向量长度为 1默认上游梯度就是1.0。4.3 用create_graphTrue实现二阶导高阶梯度比如正则化里用到的梯度惩罚、元学习中的 meta-gradient需要让“反向传播本身也变成可微的”。此时backward()里必须传create_graphTrue它会保留反向过程中额外构建的计算图从而让你对梯度再进行一次反向。一个很常见的例子是“梯度惩罚”gradient penalty先算出某个输出的梯度然后继续对参数求二阶导。x torch.randn(4, requires_gradTrue) loss (x ** 3).sum() first_grad torch.autograd.grad(loss, x, create_graphTrue)[0] second_grad torch.autograd.grad(first_grad.sum(), x)[0] print(second_grad) # 6 * x因为 d/dx (3x^2) 6x如果你想只用loss.backward(create_graphTrue)那要小心这会保留整张图的内存代价不小。只在确实需要高阶导时才打开。4.4 自定义torch.autograd.Function当你有反向公式时当 PyTorch 内置的算子无法覆盖你的需求时比如自定义量化、自定义科学计算函数你就需要自己实现前向和反向。这并不神秘只是告诉 autograd“前向你按我的来反向你按我给的公式来”。一个经典例子实现一个“平方操作”的自定义算子来替代x**2。import torch class Square(torch.autograd.Function): staticmethod def forward(ctx, x): # 保存前向需要用到的变量供反向使用 ctx.save_for_backward(x) return x ** 2 staticmethod def backward(ctx, grad_output): (x,) ctx.saved_tensors # d(x^2) / dx 2x return grad_output * 2 * x x torch.tensor(3.0, requires_gradTrue) y Square.apply(x) y.backward() print(x.grad) # 6.0注意几个细节backward()接收的grad_output是上游传来的梯度你需要返回一个与forward输入数量对应的梯度这里x对应的梯度是2x * grad_output如果有多个输入就返回多个梯度组成的元组。ctx.save_for_backward()只用来保存前向的 Tensor不要保存进入自动内存后可能被修改的变量。自定义Function的另一个价值是节省内存你可以在forward里不保存较大的中间结果在backward里重新计算。这是一种用时间换空间的优化策略。5. 新手最容易踩的坑detach、inplace、内存与速度5.1detach()到底切掉了什么很多人的直觉是“detach 会让数据断开与模型的关联”但其实detach()只是让新的 Tensor 与计算图脱离具体表现是返回的新 Tensor 与原 Tensor 共享数据存储内存相同。requires_grad变成False。grad_fn变成None。如果只是取数值x.detach().cpu().numpy()是为了 convert to numpy如果你想记录一个“与图无关”的副本或者做某个分支的判断也可以用 detach。需要特别小心的是.detach()和.clone()组合使用还是.detach()单独使用如果之后你要修改这个张量且不希望影响原图要x.detach().clone()如果不改直接用x.detach()就行内存更省。5.2 in-place 操作为什么容易破坏计算图PyTorch 在构建计算图时默认假设每个 Tensor 的值在反向传播前不会被改变或者说它通过版本计数来检查。你在两个操作点之间对同一个输入做了 in-place 改写比如x torch.tensor([1.0], requires_gradTrue) y x * 2 x.add_(1) # 破坏了 y 与 x 之间的依赖关系 z x * 3 z.backward()此刻反向时y x*2的梯度需要知道原来的x1但x已经被改成2。PyTorch 一般会抛出 “one of the variables needed for gradient computation has been modified by an inplace operation” 一类的报错。这正是 autograd 的自我保护机制防止悄悄算错梯度。常见的引爆点是torch.relu(x, inplaceTrue)。如果这个x同时在别的分支上被使用inplace 改写就会出问题。所以一般除非你能确定这个张量没有其他分支依赖否则谨慎用inplaceTrue。某些模型结构里把ReLU的inplaceTrue设成False反而更稳。技巧将torch.autograd.set_detect_anomaly(True)开启后PyTorch 会在反向传播报错时提供更多调试信息指出问题出在哪个 forward 操作。但开启后运行会明显变慢别在生产环境长期开着。5.3 梯度爆炸与梯度裁剪的正确时机反向传播算出梯度后在optimizer.step()之前可以对梯度进行裁剪。最常用的是torch.nn.utils.clip_grad_norm_(parameters, max_norm)它按所有参数梯度的整体范数进行缩放还有clip_grad_value_它直接对梯度值的每个元素做 clamp。典型的 LSTM 训练里梯度爆炸很常见optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step()裁剪的位置必须在backward()之后、step()之前因为step()读的是.grad。如果你把它放在zero_grad()后面就白裁了。5.4 常见报错与排查速查表我把这几年经常见到的 autograd 相关报错整理成一个表方便你在跑代码时快速对照报错高发点典型提示原因解决方法backward()对非标量grad can be implicitly created only for scalar outputs输出不是标量没有指定上游梯度将输出.mean()/.sum()或传入与输出形状一致的gradient。多次 backward 图已释放Trying to backward through the graph a second time每轮反向后计算图默认释放在第一次backward()前设置retain_graphTrue但要评估内存开销。in-place 修改variable needed for gradient computation has been modified by an inplace operationadd_()、relu(..., inplaceTrue)破坏了图中张量避免对参与自动求导的张量做 in-place 修改或改用clone()。梯度值全部为 None.grad为 Nonerequires_gradTrue的参数没有参与 loss 计算或已被no_grad包裹检查参数是否在前向中使用确认反向路径上没有detach()。梯度 NaNloss is nan/grad contains nan学习率过大、数据包含异常值、梯度爆炸、loss 中有 log(0)先用小样本定位开启anomaly检测适当 clip gradient降低 lr。5.5 省内存与加速的几个 autograd 技巧torch.no_grad()推理或计算 metrics 时关闭自动求导能明显减少显存占用。torch.inference_mode()比no_grad更激进也会关闭自动求导适合纯推理但在一些情况下不能再创建需要梯度的张量。减少retain_graphTrue的使用如果一定要多 loss 合并可以先算两部分 loss 之和再统一 backward或者用accumulate gradients的方式。在模型 forward 里少用不必要的中间拼接减少图中的节点数量但通常以代码可读性和正确性优先。5.6 用grad_fn和 hook 来调试模型不求人小技巧当你搞不清某个 tensor 是不是来自某个运算时就直接打印它的grad_fn。它会直接告诉你它是由哪个操作产生的、是不是叶子。想查看中间层梯度不想改模型可以给目标 Tensor 注册一个 hookdef print_grad(tensor): def hook(grad): print(fgrad shape: {grad.shape}, mean: {grad.mean().item()}) tensor.register_hook(hook) return tensor这个 hook 会在反向传播到该 Tensor 时被调用不会阻塞原本的梯度更新属于调试利器。抛开这么多细节我自己在实际项目中最大的体会是autograd 的报错几乎从不“胡写”它非常精准地指出了你哪里破坏了计算图的边界。与其对着报错吓一跳不如先记住第 2 节里的三条铁律很多问题都能自查。如果你现在正在写训练代码那不妨在第一次碰到坑的时候多打印一下x.grad_fn和x.is_leaf往往一眼就能看到问题在哪。最后再分享一个小习惯把每次训练循环里的zero_grad、backward、step三个动作写在同一处并且顺序固定别让它们被某个 if 分支拆开。训练过程后期一旦出现梯度问题能少很多排查时间。