想象一家叫“PyTorch 烘焙工坊”的小店。师父每天做一款“深度学习蛋糕”,面粉、糖、鸡蛋是张量,搅拌、烘烤、裱花是运算。关键来了:厨房墙上贴着一张自动生长的“配方流水单”——每做一步,就记一笔“什么东西经过了什么加工”。蛋糕出炉后,评委打出一个分数(这就是损失),师父要倒推:如果蛋糕太甜,到底是糖放多了,还是搅拌过度?反向传播就是沿着流水单倒着查账的过程。
流水单不是凭空来的,它是 PyTorch 自动求导引擎 autograd 的杰作。你只需要在前向传播时正常写加减乘除,autograd 就在后台默默帮你记账;等你调用 backward(),它照着链式法则一趟趟把“责任”(梯度)退回给每个食材,告诉你该往哪个方向调。这就是深度学习训练的引擎,也是无数“梯度爆炸”“梯度消失”故事的发生地。
这篇文章用一整个“烘焙工坊”的故事,带你亲手拆开 autograd 的账本,看看“计算图到底怎么建、梯度怎么算、为什么训练时总要 zero_grad()、no_grad 又是省了哪笔功夫”。看完你会发现,所谓自动求导,不过是一套极其聪明的“厨房记账术”。
对 AI 从业者来说,理解 autograd 意味着你能准确诊断训练中的梯度问题、写出正确的训练循环,甚至自定义算子——这是从“调参侠”走向“框架内核玩家”的分水岭。
目录
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
动态计算图:前向传播逐步构建 前向传播时autograd自动记录每个操作,构建计算图 反向传播:梯度沿计算图反向流动 每个grad_fn接收上游梯度,乘以本地偏导,传递给下游 链式法则:dL/dx = dL/dout * dout/dx \u2202L/\u2202w...
计算图机制:一张会“自我销毁”的配方单
烘焙工坊的墙上贴着一张不断生长的配方流水单。师父把面粉和糖倒进搅拌盆,流水单上立刻多一行“面粉、糖 → 面糊”;把面糊送进烤箱,又多一行“面糊 → 蛋糕胚”。每次运算,流水单就长一截,把“谁变成了谁”记得清清楚楚。这张不断生长的流水单,就是 autograd 的计算图(Computational Graph)。
在 PyTorch 里,计算图是记录张量运算关系的有向无环图(DAG)。图的节点是张量(食材),边是运算产生的依赖关系。因为 PyTorch 采用动态建图——代码跑一行,图就长一节——所以你在 if、for 里写的任何控制流都会被如实记进图里,灵活得像师父随手调整配方。这也是 PyTorch 与“先建图后执行”的静态图框架最大的区别。
每次对张量做运算时,PyTorch 会检查输入是否 requires_grad=True:如果要追溯,它就创建一个记录运算类型的节点,并给输出张量挂上 grad_fn 属性——相当于配方单上那行“本食材由什么加工而来”的小字。前向传播结束、调用 backward() 时,autograd 从输出节点出发,沿 grad_fn 反向遍历整张图,一路把梯度传回叶子节点。有个关键细节:一次 backward() 后图会被自动释放,省内存,但也意味着不做设置就不能连续两次 backward()——除非你留下 retain_graph=True 这张“存根”。
把计算图看成一串嵌套的加工函数,正向传播就是一路套下去;反向传播则像沿着这串加工记录倒着查账。一张动态生长、用完即焚的配方单,就是 autograd 记账的起点。
---
- $f_1$ 到 $f_n$正向传播里依次执行的每一步加工操作,搅拌、烘烤、裱花,对应计算图上的每条边。
- 复合符号 $\circ$把函数嵌套起来,先做 $f_1$、再做 $f_2$,一直做到 $f_n$。
- 输入 $x$整条链的起点,对应叶子节点;反向传播时梯度首先填充到这里。
- 整体 $y$模型的输出(蛋糕成品),也是反向传播时“责任”的出发点。
Tensor 追踪:给食材贴上溯源标签
工坊里不是所有食材都要被追责。这包“现成奶油霜”是外面买来的,它的配方不属于工坊,出了问题怪不到工坊头上;而自己打发的淡奶油就不同,每一克糖都要被记录。在 PyTorch 里,“要不要记这一笔”由 requires_grad 开关决定。
张量创建时可以设置 requires_grad=True,表示“请追踪我的所有运算,将来要算我的梯度”。这相当于给食材贴上溯源标签。反向传播只把梯度算给贴了标签的张量。默认情况下,只有叶子节点(leaf tensor)——即用户直接创建的、没参与过任何运算的张量——会被单独要求追踪;由运算产生的新张量,是否追溯由输入决定。更妙的是 requires_grad 有传播性:只要输入中有一个需要梯度,输出张量就自动变成“需要追踪”。就像一道菜里只要用了需要追责的食材,整道菜都要被记账。
用 is_leaf 可以查询一个张量是不是叶子。普通张量的 grad 属性默认是 None;只有在 requires_grad=True 且参与反向传播后,grad 才会被填上梯度。非叶子节点默认不保存梯度(省内存),除非用 retain_grad() 显式保留。师父心里清楚:只有“买进来的原料”才值得长期记台账,中间产物用完即丢。
---
requires_grad 是“或”传播:只要 $x$ 或 $y$ 任一需要梯度,运算结果就自动需要梯度——一道菜里有一种要追责的食材,整道菜都会被记账。- $\text{RG}$
requires_grad的缩写,表示这个张量要不要被追踪。RG 为真就是“贴了溯源标签”。 - $\lor$逻辑“或”运算符,两者中只要有一个为真,结果就为真。
- $\text{out}$运算的输出张量,它的追踪状态由所有输入共同决定。
反向传播:从蛋糕倒推调料配比
蛋糕被评委打了 4.2 分的低分,师父要改进配方。他不会把整个蛋糕扔进垃圾桶重来,而是从分数倒推:蛋糕太甜 → 裱花的糖霜出了问题 → 糖霜里的糖放多了。每一步只问一件事:“这一步的变化,按什么比例影响下一步?”把这些比例一路乘起来,就得到每个原料该负多少责任——这就是反向传播(backpropagation)。
反向传播的数学底座是链式法则。假设损失 $L$ 先算到中间量 $y$,$y$ 再由输入 $x$ 算出,那么 $L$ 对 $x$ 的导数等于两段导数的乘积。autograd 干的活,就是把这条规则自动套用到计算图的每条边上:从输出节点开始,按拓扑序的逆序,把上游梯度乘上每步局部导数,一层层传回叶子。
调用 backward() 触发整个旅程。对标量损失直接 loss.backward();如果损失不是标量,需要显式传入一个与输出同形状的“上游梯度”权重。梯度在图中沿边流动时遵循“多路径相加”原则:如果一个张量被两条路径影响,它的梯度是两条路径梯度之和。这保证了梯度在 DAG 上的守恒,也解释了为什么“分叉再汇合”的模型结构会影响梯度分布。
---
- 上游梯度 $\frac{\partial L}{\partial y}$蛋糕对中间状态的“评价传递系数”,表示 $y$ 变一点,损失 $L$ 变多少。
- 局部导数 $\frac{\partial y}{\partial x}$中间状态对输入的敏感度——“糖多一克,甜度涨几分”。
- 两者相乘上游评价乘以本地敏感度,得到 $x$ 的责任——这是反向传播逐层传递的唯一规则。
梯度管理:攒批评意见也是一门学问
师父试做分批次进行:星期一做 4 个蛋糕,把评委意见记在小本本上;星期二再做 4 个,继续把意见累加进同一页;一周攒齐 100 条意见,才统一调一次配方。这在训练里叫梯度累加(gradient accumulation)。但小本本如果一直不擦,下一轮的批评会记在旧账上,配方周周错上加错——所以每轮结束必须清零账本。
PyTorch 中张量的 grad 是累加的:连续多次调用 backward(),梯度会不断往上加。这本质是特性:小数据集上可以模拟大 batch,或者用梯度累积省显存。但代价是,如果训练循环里忘了 optimizer.zero_grad(),第二次 backward() 的梯度就叠在第一次上面,参数更新被“两次梯度之和”误导,表现为 loss 震荡、不收敛。
标准训练循环四步是:zero_grad()(清空旧账)→ forward()(做蛋糕)→ loss.backward()(记批评)→ optimizer.step()(改配方)。此外还有两个记忆点:backward(retain_graph=True) 表示“查完账先别撕流水单”,方便在同一张图上再次反向传播,在梯度累加和复杂模型调试中很常用,代价是内存上升;默认“用完即焚”则是内存优化——图不保留,中间节点梯度也不保留。
---
- $\theta^{(t)}$第 $t$ 轮迭代的配方参数(权重)。
- $\eta$学习率,控制调整配方的“胆子大小”。
- $g_b^{(t)}$第 $b$ 个 batch 算出的梯度,默认累加到参数
.grad上。 - 相加求和再取负所有 batch 的批评攒成一份,朝着“降低损失”的方向挪一小步。
推理模式:不需要追溯的“快速出餐”
周一的品鉴会来了很多客人,师父连做 50 个蛋糕摆上柜台,客人们只在意好不好吃,没人关心配方怎么调——这时候再记账就是纯粹浪费纸笔。于是师父宣布:“本时段免记账。”这就是 no_grad 模式:出餐速度拉满,厨余账本干干净净。
模型训练完进入推理或评估阶段,只需要前向传播输出预测,不需要反向传播更新参数。用 torch.no_grad() 包住推理代码,会全局关闭计算图构建和 requires_grad 传递,省内存又加速。注意它与 requires_grad=False 不同:后者只是某一块食材不贴标签,而 no_grad 是整个厨房临时进入“免记账”状态。
另一个常用工具是 tensor.detach():它返回一个与原来共享数据但 requires_grad=False 的新张量,相当于“复印一份配方,但复印件上不写原料来源”。这适合把某一步的输出摘出来另作他用,比如写日志、画曲线、拼接特征,避免错误梯度。注意 detach 让张量从当前图分离,原图那部分依然存在。
---
detach 出来的“复印件”与原输入 $x$ 之间梯度断流,导数恒为 0。- $y_{\text{detached}}$经过
.detach()输出的“复印本”,血缘关系已被剪断。 - $\frac{\partial}{\partial x}$对原输入 $x$ 求偏导,表示“复印本对原配方的敏感度”。
- 等于 0梯度断流,这正是“摘出来做日志/画曲线”时不会污染主图的保障。
自定义算子:发明新工具,还得配说明书
工坊总用超市买的模具,但师父心血来潮,自己 3D 打印了一个异形裱花嘴——这就是自定义算子。新工具用起来很爽,但问题来了:账房先生(autograd)不认识这个新工具,不知道“过这个裱花嘴,责任该怎么分摊”。所以师父必须为它单独写一份“说明书”:正向时食材怎么变成成品,反向时不满意度怎么按比例退给每个原料。
PyTorch 允许继承 torch.autograd.Function 来自定义带梯度计算的操作。你需要实现两个静态方法:forward(ctx, *inputs) 定义正向运算;backward(ctx, *grad_output) 定义反向传播时要返回的梯度。ctx 像师徒间的小纸条——正向时把中间结果用 ctx.save_for_backward() 存下来,反向时拿出来算梯度。这就是 autograd 对自定义算子的要求:你不但要会做蛋糕,还要说清楚怎么倒查。
调用自定义算子时用 Function.apply(),它会负责建立计算图节点、管理上下文。假设自定义算子 $y = f(x)$,反向时传入的 grad_output 是上游梯度 $\bar{y}$,backward 要返回 $\bar{x} = \bar{y} \cdot f'(x)$。对多输入算子,backward 要返回与输入同样数量的梯度张量;不需要梯度的输入返回 None 即可。很多底层算子(如矩阵乘法、卷积)内部都有高效的手写 backward:通用 autograd 图能拼装,但手工展开永远性能更高。
---
- $\bar{y}$上游传来的“批评向量”(梯度),形状与输出 $y$ 相同。
- 雅可比矩阵 $J_f(x)$算子 $f$ 在 $x$ 处的“敏感性矩阵”,每行是某个输出对各个输入的偏导。
- 转置乘以 $\bar{y}$把上游批评映射回与输入同形状的“原料批评”,交给上一级。
AI 应用场景
大模型训练中的梯度累积
阶段:模型训练 / 优化器调度
解决的问题:训练大模型时单卡显存放不下目标 batch,传统做法要么缩小 batch 影响收敛,要么堆硬件。利用 autograd “梯度默认累加”的特性,可以把一个大数据集拆成多个微 batch:每步只 forward + backward,梯度自然在小本本上累积,攒够若干步再 optimizer.step() 一次。这不改任何算法,只改记账节奏。
具体案例:训练 7B 参数语言模型时目标 batch 为 128,单卡最多装 16 个样本,于是设梯度累积步数为 8:每 8 次 backward() 才 step() 一次,并用 zero_grad() 在每次 step 前清空旧账,最终收敛效果与大 batch 训练几乎一致。
推理服务性能优化
阶段:模型部署 / 推理性能优化
解决的问题:线上推理只需要预测结果,不需要任何梯度。用 with torch.no_grad() 包住推理代码,autograd 不建图、不分配 grad_fn、不保存中间节点,推理吞吐提升、显存占用下降。传统做法是硬扛耗时或盲目换更贵的 GPU,忽视了“免记账”带来的免费加速。
具体案例:某推荐系统 CTR 预估模型使用 no_grad + 半精度后,P99 延迟从 8ms 降到 5ms,显存占用下降约 40%,模型文件与训练时完全一致,无需重新导出。
自定义算子加速 Transformer
阶段:算子开发 / 模型加速
解决的问题:标准算子库把 Attention 拆成好几个 kernel,中间结果反复写回显存,带宽浪费严重。通过继承 torch.autograd.Function 自定义融合算子,把 QK^T 与 softmax 融合成一个自定义算子,并手写 backward 在反向时现场重建 softmax 的中间矩阵,省掉多块大矩阵的存储——这正是 FlashAttention 等高性能算子的迷你原型思路。
具体案例:在自研推理引擎中实现一个融合的 Attention 算子,前向只输出最终加权结果,反向用 ctx.save_for_backward 保存的 Q、K 重新算局部导数,训练吞吐提升约 18%。
Python 演示
class Value: """A tiny tensor that supports autograd.""" def __init__(self, data, _children=(), _op=''): self.data = data self.grad = 0.0 self._prev = set(_children) self._op = _op self._backward = lambda: None def __add__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data + other.data, (self,), '+') def _add_backward(): self.grad += 1.0 * out.grad other.grad += 1.0 * out.grad out._backward = _add_backward return out def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data * other.data, (self,), '*') def _mul_backward(): self.grad += other.data * out.grad other.grad += self.data * out.grad out._backward = _mul_backward return out def relu(self): out = Value(max(self.data, 0.0), (self,), 'relu') def _relu_backward(): self.grad += (1.0 if self.data > 0 else 0.0) * out.grad out._backward = _relu_backward return out def backward(self): topo, visited = [], set() def build_topo(v): if v not in visited: visited.add(v) for child in v._prev: build_topo(child) topo.append(v) build_topo(self) self.grad = 1.0 for node in reversed(topo): node._backward() def train_one_step(): x = Value(2.0) # input feature w = Value(3.0) # weight b = Value(1.0) # bias out = (x * w + b).relu() out.backward() print("out=%.2f grad_x=%.2f grad_w=%.2f grad_b=%.2f" % (out.data, x.grad, w.grad, b.grad)) if __name__ == '__main__': train_one_step()
out=7.00 grad_x=3.00 grad_w=2.00 grad_b=1.00,可手算验证。self.grad = 0.0每个张量初始背着的责任为零,等反向传播来填。out = Value(self.data + other.data, (self,), '+')前向算结果,同时把“孩子”和运算类型写进配方单。self.grad += 1.0 * out.grad加法反向:把上游责任原样转给两个孩子。self.grad += other.data * out.grad乘法反向:用“对方的值”当敏感系数。topo.append(v)拓扑排序:保证反向时先处理靠后的节点。order.reverse(); order = list(reversed(topo))用逆拓扑序逐节点派发责任,这就是反向传播。for node in reversed(topo): node._backward()autograd 的backward()本质就是这一行循环。x = Value(2.0)叶子节点,模拟训练输入。
常见误区
误区“我把某个张量设成 `requires_grad=False`,它是不是就从计算图里消失了?反正梯度算不到它头上,我随便怎么折腾它都行。”
正解它确实不会收到梯度,也不会产生 grad_fn,但它仍然是计算图中的数据节点,数据流照常穿过。就像一包外购奶油霜不记配方来源,但它依然参与了蛋糕制作,决定最终口感的仍是它的分量。如果对 requires_grad=True 的中间结果做不恰当操作(如原地修改),照样会破坏整条梯度链路。
为何常见:初学者容易把“不计算梯度”误解为“与计算图完全无关”。
误区“我每轮调用 `loss.backward()` 后,梯度应该会自动刷新吧?不然 PyTorch 也太傻了。”
正解不会。autograd 默认把梯度累加,这是为了支持梯度累积特性。训练循环里必须手动调用 optimizer.zero_grad()。好比小本本上的批评不会自己消失,下一轮的新批评会和旧批评叠在一起,配方就会被带偏,loss 震荡不收敛。
为何常见:把“自动求导”误解为“自动管理全部状态”,忽略 .grad 是累加缓冲区而非每次覆盖。
误区“反正模型已经训练好了,加不加 `no_grad` 结果都一样,省那点时间没意义。”
正解结果相同,开销天差地别。no_grad 下不建图、不分配 grad_fn、不保存中间节点,推理吞吐显著提升,显存占用大幅下降。品鉴会不记账,省下的纸笔时间能多出十几炉蛋糕。线上服务每毫秒都在烧钱,这个开关是白送的优化。
为何常见:小模型在小数据上差距不明显,容易让人误以为这个开关没有价值。
误区“`x = x + 1` 不行,那我用 `x += 1` 总可以吧?反正都是把 x 变大一点。”
正解对 requires_grad=True 的叶子节点,in-place 操作会破坏 autograd 保存的旧值,导致梯度错误甚至直接报错。原因是 autograd 在反向时要读取节点创建时的“原始快照”。就像账房先生记的是“加糖前 100g”,你偷偷把秤上的数字改了,账就对不上了。
为何常见:不熟悉 autograd 的“快照”机制,以为就地修改只是省内存的小把戏。
面试题
Q1. 在 PyTorch 中,调用 loss.backward() 时,autograd 主要沿着什么结构传播梯度?
- A.计算图(记录运算关系的DAG)← 正确答案
- B.训练数据集(供模型喂入样本)
- C.神经网络层数(决定网络深浅)
- D.优化器状态(决定学习率高低)
答案:A。反向传播沿着“配方流水单”(计算图)倒查,图里记录了每个张量从哪来,梯度才能一级级传回原料。数据集、层数、优化器都不承担“记录运算关系”的职责。
Q2. 张量的哪个属性记录“我是由什么运算产生的”?
- A.grad_fn(记录本张量由何运算产生)← 正确答案
- B.is_leaf(判断张量是否为叶子节点)
- C.requires_grad(标记是否追踪梯度)
- D.device(标记张量所在设备位置)
答案:A。grad_fn 像配方单上的“本食材由什么加工而来”的小字,是反向传播时沿图回溯的依据。is_leaf、requires_grad、device 分别是身份、记账开关和设备位置,都不是“来源记录”。
Q3. 关于 requires_grad 的传播规则,以下哪些说法正确?
- A.仅叶子能设 requires_grad 为真
- B.输入需梯度输出自动需梯度← 正确答案
- C.新建张量默认需梯度追踪
- D.requires_grad 为假也能前向算← 正确答案
答案:BD。requires_grad 有传播性:只要任一输入需要梯度,输出自动为 True(B)。为 False 的张量照常参与前向运算,只是不记账(D)。叶子不是唯一能设为 True 的张量(A 错);新建张量默认是 False(C 错)。
Q4. 训练循环中每轮忘记 optimizer.zero_grad(),最可能发生什么?
- A.梯度被清零导致更新停滞
- B.梯度累加导致参数更新错乱← 正确答案
- C.计算图被提前释放导致报错
- D.学习率自动衰减到非常小
答案:B。autograd 梯度默认累加,不清零就会把上一轮批评叠到本轮,参数用“两轮之和”更新,loss 震荡不收敛。这就像小本本上的旧批评不擦掉,新批评叠上去,配方越改越偏。
Q5. torch.no_grad() 在什么场景下最有用?
- A.训练阶段加速反向传播
- B.推理阶段省内存并加速← 正确答案
- C.自动重置所有梯度为零
- D.把模型权重冻结不可更新
答案:B。no_grad 让整个厨房进入“免记账”模式,不建图、不存中间节点,适合纯前向的推理阶段。训练需要反向所以不能用它加速;它也不重置梯度、不冻结权重。
Q6. 反向传播结束后,在非叶子中间张量上直接访问 .grad 默认会得到?
- A.自动保存该张量的真实梯度
- B.返回 None 且不产生 grad← 正确答案
- C.永远返回数值 0.0 的梯度
- D.直接抛出一个运行时异常
答案:B。为省内存,PyTorch 默认不保存中间节点梯度,.grad 为 None。想要保留就得调用 retain_grad()。像面糊、蛋糕胚这类中间食材,账房先生记完就忘,除非特意叮嘱。
Q7. backward(retain_graph=True) 的主要作用是?
- A.图不用释放可再次反传← 正确答案
- B.自动清空所有累积梯度
- C.让反向传播速度翻倍提升
- D.仅对叶子节点梯度生效
答案:A。默认一次 backward() 后计算图就像用完的流水单一样被撕掉,retain_graph=True 相当于说“先别撕,我还要再查一遍账”,允许后续再次反传。代价是内存占用上升。
Q8. 自定义 torch.autograd.Function 时,backward 方法收到的第一个参数 grad_output 代表什么?
- A.上游传来的梯度(与输出同形)← 正确答案
- B.前向计算的输出数据本身
- C.损失函数最终标量损失值
- D.模型当前的输入特征张量
答案:A。grad_output 是上游传来的“批评向量”,形状与算子输出一致。backward 要做的是把它通过局部导数映射回输入空间,返回给上一级。它既不是输出数据,也不是最终标量损失。
Q9. 关于 tensor.detach() 与 requires_grad_(False) 的区别,以下哪些说法正确?
- A.detach 返回副本且不传梯度← 正确答案
- B.requires_grad_ 仅改本张量标记← 正确答案
- C.二者完全等价可互换使用
- D.两个操作仅对叶子节点有效
答案:AB。detach() 像复印一份“配方复印件”,副本与原图断开梯度;requires_grad_(False) 则是把当前张量自身的溯源标签撕掉,两者作用对象和效果都不同。它们对非叶子节点同样有效,D 错。
Q10. 下列哪些操作会导致 autograd 报错或产生错误梯度?
- A.在叶子节点上做原地加法← 正确答案
- B.直接对需梯度张量调 numpy← 正确答案
- C.no_grad 模式下梯度图保留
- D.不保留图连续两次 backward← 正确答案
答案:ABD。A 破坏 autograd 保存的原始快照;B 对 requires_grad=True 的张量转 numpy 会直接报错,需先 detach;D 第一次 backward 已把图释放,第二次调用会报“图不存在”。C 描述的是错误认知,并非会引发报错的操作。