1. 开始之前conv.py在YOLO工程中的坐标读源码的习惯因人而异我通常先翻文件引用最多的那个。ultralytics.nn.modules.conv就是这样的存在整个 YOLO 结构里无论是 Backbone、Neck 还是分割 head几乎每个环节都在和这个文件里的算子打交道。它承担的是基础构建块的职责如果把整个网络想象成一面墙这个文件里铺的砖块和粘合砂浆就是一切。最初我以为它只是几个 Conv 类的堆叠真正逐行读完之后发现这里面的设计取舍比想象中有讲究得多。以我读过的一个比较完整版本为例conv.py里的导出清单大致覆盖了这些类类名一句话定位最常见的挂载位置Conv标准卷积 BN 激活的统称几乎无处不在是其他模块的基础件LightConv1x1 卷积接深度卷积的轻量化组合网络中的局部结构替代DWConv深度可分离卷积组数取 gcd轻量化 Backbone 与 NeckDWConvTranspose2d深度可分离的转置卷积分割任务的 upsamplingConvTranspose转置卷积 BN 激活分割 head 的上采样层Focus把空间相邻像素折叠到通道模型输入阶段的下采样GhostConvGhostNet 风格的特征复用结构轻量化变体网络ChannelAttention通道注意力注意力模块的组成件SpatialAttention空间注意力注意力模块的组成件CBAM通道注意力 空间注意力的级联插入到骨干或颈部Bottleneck1x1 压缩 3x3 卷积的残差块C3、C2f 等模块的砖块BottleneckCSP带跨阶段连接的残差结构CSP 系列的骨干模块Concat简单的张量拼接层FPN/PAN 特征融合这张清单基本就是一个 YOLO 系列的算子速查表。有意思的是这些模块虽然用途差异很大但在实现上彼此依赖得很深。比如DWConv直接继承ConvBottleneck内部用的是Conv而BottleneckCSP内部又嵌了一串Bottleneck。所以真正读这个文件第一件事不是钻进某个类里抠实现而是先把这些类的关系网理清楚。1.1 autopad让 padding 的烦恼从源头消失文件开头通常有一个小而关键的辅助函数def autopad(k, pNone, d1): if d 1: k d * (k - 1) 1 if isinstance(k, int) else [d * (x - 1) 1 for x in k] if p is None: p k // 2 if isinstance(k, int) else [x // 2 for x in k] return p我第一次看的时候觉得这小函数有点鸡肋后来才发现它省掉了大量心智负担。卷积操作里最容易出错的就是 Padding 计算而大多数场景做的是“保持宽高不变”也就是所谓的 SAME padding。只要核尺寸是奇数padding 取k // 2就能让输出尺寸等于输入尺寸。举一个具体例子输入是 320x320卷积核是 3x3stride1。如果 padding0输出会变成 318x318但 padding1 时输出回到 320x320。代码里写p k // 2对于 k3 得到 1对于 k5 得到 2刚好对应。真正容易看晕的是空洞卷积分支。d代表扩张率这时候实际感受野对应的等价核尺寸是d * (k - 1) 1不是表面上的 k。比如 k3、d2等价核是 5这时候 padding 应该是 2而不是 3//21。如果直接用朴素的k//2计算 padding空洞卷积的输出尺寸就会被算错feature map 每过一层就缩一圈最后到深层直接对不上。还有一个细节k可能是 int也可能是个 tuple比如(3, 5)的非对称卷积。函数用isinstance做了分支处理保证两种情况下都能正确返回标量或列表。这种防御式写法在框架代码里很常见读的时候值得留意因为后续nn.Conv2d的 padding 参数既支持 int 也支持 tuple。理解了 autopad再往下看各个类的构造函数就会发现几乎所有卷积层的 padding 位置都写的autopad(k, p, d)而不是一个写死的数字。这意味着你不需要替每个模块手工算尺寸只要记住“stride1 且 padding 自动时输出尺寸不变”这一个结论就够了。1.2 为什么这些算子被归到同一个文件第一步忍不住问Concat明明只是一个拼接操作为什么要和注意力模块放在同一个文件里这其实和代码的组织哲学有关。YOLO 系列代码库把网络拓扑里频繁复用的“积木”集中到一个modules目录而卷积相关的一切又单独归到了conv.py为的是让所有基础算子保持一致的实例化风格。在实际使用中Conv、Bottleneck、BottleneckCSP这些模块会被反复 import任何自定义模型都可以直接用from ultralytics.nn.modules.conv import Conv, Bottleneck。这种“一个文件管一种主题”的组织方式配合清晰的__all__导出显著降低了上手的难度。我后来自己写模块时也沿用这套风格每个文件只放同族的算子必要的辅助函数放在文件头部类之间用继承和组合复用避免每一个新模块都从零造轮子。2. 最常用的 Conv 类卷积、批归一化、激活的组装逻辑如果整个conv.py只留一个类那必须留Conv。它是整个 YOLO 系列最核心的基础算子其他模块的绝大部分功能最终都落到它身上。典型实现如下class Conv(nn.Module): default_act nn.SiLU() def __init__(self, c1, c2, k1, s1, pNone, g1, d1, actTrue): super().__init__() self.conv nn.Conv2d(c1, c2, k, s, autopad(k, p, d), groupsg, dilationd, biasFalse) self.bn nn.BatchNorm2d(c2) self.act self.default_act if act is True else act if isinstance(act, nn.Module) else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x))) def forward_fuse(self, x): return self.act(self.conv(x))这个类的写法非常紧凑每个字段的选择都值得展开说。2.1 参数签名与默认值的设计意图构造函数签名是(c1, c2, k1, s1, pNone, g1, d1, actTrue)。这八个参数基本上就是nn.Conv2d的常用参数的子集但又做了几个重要的默认值变化。第一k默认是 1而不是常见 3。这是因为在 YOLO 结构里1x1 卷积出现的频率并不低尤其是各种 CSP 结构的降维层而代码库希望调用 3x3 时显式写清楚减少“以为传了 3 结果用了默认 1”的误解。第二p默认是None而不是 0。这个微妙之处前面已经讲过None触发 autopad让网络层在 stride1 时保持空间尺寸不变。这是整个文件“默认 SAME 输出”的基石。第三g是 groups用来做分组卷积。默认 1 表示普通卷积但它和后面c1 % g 0、c2 % g 0的约束强相关自己写模块时最容易在这里踩坑后面我会专门讲。第四d是空洞率。默认 1意味着不使用空洞卷积。需要增大感受野时可以直接传d2或更大autopad 会自动换算有效核尺寸。最具设计感的是act它做的是“三态判断”actTrue使用类默认激活函数default_act nn.SiLU()act是nn.Module直接使用传入的激活实例actFalse或其他使用nn.Identity()等价于不激活。self.act self.default_act if act is True else act if isinstance(act, nn.Module) else nn.Identity()这个设计让Conv既能当普通“全流程卷积”用也能当一个只做“卷积BN”的底层算子用还能临时换成 ReLU、Hardswish 之类的自定义激活函数。你不需要改类只需要在实例化时传一个act参数。default_act nn.SiLU()还有个容易被忽略的细节它被定义成类属性而不是实例属性。意味着你修改Conv.default_act nn.ReLU()时所有后续实例化的Conv默认激活都会变成 ReLU这是全局性的。读代码时要清楚这一点否则某天调试模型发现所有层的激活都变了却想不起来改过哪里。2.2 biasFalse 背后的 BN 替代逻辑注意nn.Conv2d的 bias 显式设置为False。许多初学者会疑惑卷积层不要偏置不会损失表达能力吗实际上后面紧跟的BatchNorm2d已经承担了“平移”的职责。BatchNorm 在推理阶段的变换是y (x - mean) / sqrt(var eps) * gamma beta其中beta就是平移项和卷积偏置的作用重叠。既然 BN 已经存在卷积层再留 bias 就是冗余参数。更重要的是没有 bias 的卷积和 BN 融合会更干净这也是后续能高效执行forward_fuse的前提。训练过程中由于 BN 的均值/方差统计量一直在更新卷积和 BN 是分开计算的。到了推理部署阶段就可以把“卷积 BN”折叠成单个卷积层得到一组新的等效权值和偏置W_fused W * gamma / sqrt(var eps)b_fused beta - mean * gamma / sqrt(var eps)折叠后不再需要逐层跑 BN节省一次张量遍历和若干浮点运算。forward_fuse拿到的就是折叠后的直接推理路径self.act(self.conv(x))。这里的conv可以替换成融合后的卷积权重而bn完全被吸收。我在实际部署里观察到fuse 之后模型的推理速度提升虽然不是数量级的但在大模型和高分辨率输入场景下节省的时间可以稳定复现。而且为了方便导出和部署代码库里通常都会提供类似model.fuse()的入口方便推理前把所有能融合的模块统一处理。3. 轻量化与信息压缩DWConv、LightConv、GhostConv、FocusYOLO 系列对实时性要求很高所以conv.py里专门收纳了一组轻量化算子。它们的共同思路都是减少参数量和计算量但手段各不相同。3.1 DWConv组数与 gcd 的巧妙选择class DWConv(Conv): def __init__(self, c1, c2, k1, s1, d1, actTrue): super().__init__(c1, c2, k, s, gmath.gcd(c1, c2), dd, actact)它没有重写 forward而是直接继承Conv只是把 groups 改成了math.gcd(c1, c2)。这里的gcd值得细品。分组卷积有一个硬性约束输入通道数c1和输出通道数c2都必须能被 groups 整除。最理想的分组卷积是每组一个输入通道即g c1但这只适合c1 c2的场景。当c1和c2不相等时g c1通常不合法因为c2 % c1不一定为 0。取gcd(c1, c2)可以保证两组整除数同时成立同时让 groups 尽量大从而让每个卷积核服务的通道数尽量少参数最省。举例说明c164, c296gcd32。这时卷积被分成 32 组每组输入 2 个通道、输出 3 个通道。c164, c264gcd64。这时就是严格的 depthwise convolution每个输入通道独立卷积、独立产生一个输出通道。理解了gcd之后再看代码里其他地方出现 DWConv 就不容易懵。这个类本质上是“能分组就尽量分分不了了再退化为普通大组卷积”的权衡结果。3.2 LightConv 与 GhostConv两种不同的省参路线LightConv的思路是把一次大卷积拆成两次小卷积class LightConv(nn.Module): def __init__(self, c1, c2, k1, actnn.ReLU()): super().__init__() self.conv1 Conv(c1, c2, 1, actFalse) self.conv2 DWConv(c2, c2, k, actact) def forward(self, x): return self.conv2(self.conv1(x))先用 1x1 卷积把通道数调整到目标维度再在通道数不变的情况下做深度可分离卷积。它相当于是把空间卷积和跨通道交互解耦了。GhostConv则走上了另一条路一部分特征通过正常卷积生成另一部分通过对已有特征做廉价变换生成最后拼接到一起class GhostConv(nn.Module): def __init__(self, c1, c2, k1, s1, g1, actTrue): super().__init__() c_ c2 // 2 self.cv1 Conv(c1, c_, k, s, gg, actact) self.cv2 Conv(c_, c_, 5, 1, gc_, actact) def forward(self, x): y self.cv1(x) return torch.cat((y, self.cv2(y)), 1)主分支cv1只负责输出一半通道另一半通道由cv2对主分支输出继续做一次 3x3 或 5x5 的深度卷积得到。这种“一半真一半便宜”的方式相比直接用Conv(c1, c2)大约能省一半参数。以c1256, c2256, k3为例结构参数量估算标准Conv256 x 256 x 3 x 3 ≈ 589,824GhostConv256 x 128 x 3 x 3 128 x 5 x 5 ≈ 298,624省下来的参数接近一半而语义信息的损失往往不大因为它复用的是“特征内部的冗余”。我在想替换瓶颈块时做过对比GhostConv 在轻量化模型上确实能平稳换掉普通卷积精度损失大多在 0.5 个点以内属于划算的买卖。两种轻量化方案的取舍可以用一个简单类比LightConv 像“分两步走”先把东西搬到正确维度再做空间加工GhostConv 像“一个师傅带一个学徒”主师傅做正经活学徒用低成本方式复制一份两份合起来当完整产出。明确了这个逻辑你在自己的网络里也不会把它们混为一谈。3.3 Focus把空间的密集信息换到通道维度Focus在 YOLOv5 时代承担了输入层附近的降采样任务class Focus(nn.Module): def __init__(self, c1, c2, k1, s1, pNone, g1, actTrue): super().__init__() self.conv Conv(c1 * 4, c2, k, s, p, g, actact) def forward(self, x): return self.conv(torch.cat((x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]), 1))这种切片方式把一个H x W的输入像棋盘一样分成四份每份尺寸都是原来的二分之一然后在通道方向上拼接使通道数变为原来的四倍最后用一个1x1卷积把通道数调整到目标值。举例输入1x3x640x640经过切片拼接变成1x12x320x320再经过Conv(12, 64, 3)输出就是1x64x320x320。它达到的效果等价于一次 2 倍下采样但没有用 stride2 的普通卷积而是把空间信息显式折叠到通道里让后续卷积能更多地看到“邻域关系”。这个模块有一个容易忽视的前提输入图像的宽高最好都是偶数否则四种切片取到的尺寸可能不一致拼接就会直接报错。实际阅读这个文件时要特别记住这一点尤其是你打算把Focus用到非标准尺寸输入的时候。4. 注意力机制三件套ChannelAttention、SpatialAttention 与 CBAM注意力模块在目标检测里已经是标配conv.py实现的这套 CBAM 属于经典高空版本。它由通道注意力和空间注意力两部分级联而成代码都很短但逻辑密度很高。4.1 通道注意力全局池化加两层 1x1 卷积class ChannelAttention(nn.Module): def __init__(self, channels: int, reduction: int 16): super().__init__() self.pool nn.AdaptiveAvgPool2d(1) self.fc nn.Conv2d(channels, channels // reduction, 1, biasFalse) self.fc2 nn.Conv2d(channels // reduction, channels, 1, biasFalse) self.act nn.Sigmoid() def forward(self, x): y self.pool(x) y nn.ReLU()(self.fc(y)) y self.act(self.fc2(y)) return x * y第一步把每个通道的全局信息压成一个值AdaptiveAvgPool2d(1)会把任意空间尺寸变成 1x1。这样每个通道得到一个全局描述符相当于“这个通道在整个图上平均响应有多强”。接下来是一个两层瓶颈结构先降维到channels // reduction再升回原来的通道数中间过 ReLU。reduction16是默认的压缩比。为什么要压这么狠因为如果直接让每个通道和一个标量全连接参数会有channels x channels在深网通道数动辄数百上千时数量可观。加了瓶颈之后参数变成channels * (channels/16) * 2大幅减小。最后通过 Sigmoid 输出 0 到 1 的通道权重再与原特征逐通道相乘。这个权重就相当于“哪些通道值得被放大、哪些通道应该被抑制”。和 SE-Net 的核心思路一致但这里用两个 1x1 卷积而非全连接让输入输出都是 4D 张量前向传播更顺。4.2 空间注意力平均池化与最大池化的联合class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() padding 3 if kernel_size 7 else 1 self.cv1 nn.Conv2d(2, 1, kernel_size, paddingpadding, biasFalse) self.act nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) y torch.cat((avg_out, max_out), dim1) y self.cv1(y) return x * self.act(y)通道注意力是对“每一层通道”打分空间注意力则是对“每一个像素位置”打分。这里先将输入在通道维度上分别求平均值和最大值得到两个单通道特征图拼成一个 2 通道的特征图再用一个 7x7 卷积压缩成 1 通道。为什么通道维度反而要压缩成 2 通道因为我们要判断的不是哪个通道重要而是哪些空间位置值得关注。平均值可以捕捉全局响应水平最大值可以捕捉局部强响应两者拼接后传给空间卷积相当于同时考虑“平均显著”和“最大显著”两种信号。默认kernel_size7来自 CBAM 原论文的消融实验实验里 7x7 比 3x3 效果更好因为更大的空间感受野可以让注意力关注到一定邻域内的结构而不是一个个孤立的像素。代码里对应的 padding 也是手工算好的7 配 33 配 1。4.3 CBAM 为什么先做通道再做空间class CBAM(nn.Module): def __init__(self, c1, kernel_size7): super().__init__() self.channel_attention ChannelAttention(c1) self.spatial_attention SpatialAttention(kernel_size) def forward(self, x): x self.channel_attention(x) return self.spatial_attention(x)读到这里有人会问既然两个注意力都是乘法先做哪个真的重要吗实际结果是通道注意力先、空间注意力后的组合通常表现得更好。原论文的解释是先通过通道维度的加权明确“哪些特征能说什么”再通过空间维度的加权关注“这些特征出现在哪里”。如果顺序反过来空间注意力在通道还没被校准之前就做了位置筛选输入里噪声通道也会参与空间判断权重很容易被带偏。这里还有一个实现细节CBAM 的 forward 没有把所有操作写成一个长表达式而是先对 x 做通道注意力赋值再传给空间注意力。这个写法会让链式修改变得很直观调试时也能分别在两个阶段打印张量确认注意力权重的分布是否合理。我在复现别人的改进时踩过一个相关的坑如果把ChannelAttention里中间的 ReLU 去掉或者换成别的激活训练曲线经常出现震荡。原结构里 ReLU 的作用是给瓶颈层加一个非线性变换没有它两层 1x1 卷积就等价于一个 1x1 卷积瓶颈结构就失去了意义。所以改注意力模块时第一优先检查的就是非线性层是否完整。5. Bottleneck 家族与跨层拼接从这一节开始进入结构型模块的阅读。Bottleneck是所有残差类模块的基本单元BottleneckCSP是古典 CSP 结构Concat则是连接 Backbone 和 Neck 的胶水。5.1 Bottleneck1x1 压缩、3x3 变换与捷径条件class Bottleneck(nn.Module): def __init__(self, c1, c2, shortcutTrue, g1, k(3, 3), e0.5): super().__init__() c_ int(c2 * e) self.cv1 Conv(c1, c_, k[0], 1) self.cv2 Conv(c_, c2, k[1], 1, gg) self.add shortcut and c1 c2 def forward(self, x): return x self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))残差块的核心是“shortcut”。它先把输入通道从c1压缩到中间维度c_ int(c2 * e)然后用第二个卷积升回目标通道。e0.5意味着中间通道通常是输出通道的一半从而降低计算量。真正的玄机在self.add shortcut and c1 c2。这表示只有当外部传入的shortcutTrue且输入输出通道数相等时才启用恒等映射相加。如果c1 ! c2即使shortcutTrue这条捷径也会被自动关闭。为什么会这样设计因为残差相加要求两个张量的形状完全一致。通道数不同时无法直接相加除非额外加一个 1x1 卷积先对齐维度。YOLO 系列代码选择的是更省事的策略通道不一致就干脆不加捷径而不是引入额外的对齐卷积。这也意味着你在配置网络时如果一层通道数和下一层不一致Bottleneck 会静默退化成普通双卷积结构这在实验对比时要心里有数。k(3, 3)看起来是支持不同核的组合。你完全可以把第二个核改成更大尺寸甚至非对称结构比如k(3, 5)来实现局部感受野的横向延展这在面向特定长条形目标的检测任务里非常有用。5.2 BottleneckCSP一条快速通路的跨阶段设计class BottleneckCSP(nn.Module): def __init__(self, c1, c2, n1, shortcutTrue, g1, e0.5): super().__init__() c_ int(c2 * e) self.cv1 Conv(c1, c_, 1, 1) self.cv2 nn.Conv2d(c1, c_, 1, 1, biasFalse) self.cv3 nn.Conv2d(c_, c_, 1, 1, biasFalse) self.cv4 Conv(2 * c_, c2, 1, 1) self.bn nn.BatchNorm2d(2 * c_) self.act nn.SiLU() self.m nn.Sequential(*(Bottleneck(c_, c_, shortcut, g, e1.0) for _ in range(n))) def forward(self, x): y1 self.cv3(self.m(self.cv1(x))) y2 self.cv2(x) return self.cv4(self.act(self.bn(torch.cat((y1, y2), 1))))这个模块算是我读conv.py时最绕的部分但拆开来看很清晰它把输入拆成了两条路径。路径一x - cv1 - m - cv3。cv1先降维到c_经过 n 个Bottleneck提取深层特征最后用cv3做一次 1x1 变换。这里cv3和cv2实际上都带 biasFalse因为它们后面有统一 BN。路径二x - cv2。这就是 CSPCross Stage Partial的“Partial”含义输入的一小部分直接做一次 1x1 卷积绕过深层串行结构形成一条早期信息速通通道。两条路径在通道维度上拼接得到2 * c_通道过 BN 和 SiLU最后用cv4把通道数变换到c2。这种设计的价值在于深层路径负责高端语义浅层路径保留早期的纹理和细节两者拼接后由1x1卷积融合梯度回传时浅层路径也给了深层梯度一条相对直接的通道。需要注意的是这里的self.m内部含有 n 个Bottleneck而这些Bottleneck各自有自己的 shortcut所以整个 CSP 块的捷径是“深层路径的内部捷径”加“跨阶段的外部拼接”两者并不冲突。5.3 Concat一个参数切换维度位置的胶水层class Concat(nn.Module): def __init__(self, dimension1): super().__init__() self.d dimension def forward(self, x): return torch.cat(x, self.d)这个模块小得几乎没有实现成本但它在 FPN/PAN 里不可或缺。Concat接收的是一个张量列表不是单个张量。默认dimension1表示沿通道维度拼接因为 YOLO 系列特征图的四个维度是(batch, channels, height, width)。为什么单独封装一层而不直接调torch.cat核心原因是模型配置化。很多 YOLO 变体从配置文件中读取结构定义解析器遇到字符串Concat就能创建实例并在构建时把需要拼接的特征图列表传进来。如果到处散落着裸的torch.cat配置文件就很难表达这种跨层连接关系。另外Concat这种封装也让“拼接维度”变成一个可配置参数。常规是dimension1但当你自定义网络决定把拼接放在 batch 维度或空间维度时不需要大量修改代码改一个参数就能实现。6. 读码之后我实测踩过的几个坑代码读得再细上手改的时候还是会遇到问题。这一节记几个我实际碰过的坑前后折腾了不少时间才理顺希望读到这里的你能少走弯路。6.1 修改类默认激活函数影响范围是全局的Conv.default_act nn.SiLU()是类属性这带来的一个直接后果是只要你在某个初始化脚本里写了Conv.default_act nn.ReLU()之后所有新建的Conv实例默认激活都会变成 ReLU包括检测头、neck、Bottleneck 里的卷积层。我第一次踩到这个坑是在实验里想“只把骨干部分的激活换成 ReLU”于是直接在全局改了Conv.default_act结果整个模型的推理行为和导出结构都变了。后来排查半天才意识到这个赋值影响的不是某一个模块而是所有尚未实例化的Conv。正确做法是用参数覆盖在需要换激活的模块实例化处显式传actnn.ReLU()而不是全局改default_act。这个原则越早清楚越好尤其是当你要做激活函数的对比实验时。6.2 groups 参数导致的通道数检查失误分组卷积的约束是c1 % g 0且c2 % g 0。这个约束在定义模型时不会立刻报错因为 Python 类不会检查它只有真正构造 PyTorch 的张量传入nn.Conv2d时才会触发运行时错误。我自己在写一个融合模块时把c148, c264传给了DWConv。gcd(48, 64)16实际 groups 会设为 16每组输入 3 通道、输出 4 通道一切正常。但如果我假设 DWConv 一定是纯逐通道卷积当成gc2来做形状推导那么输出通道数结构就会跟预期完全对不上。这里可以记住一个经验任何修改后的通道数都要在构造时用c1 % g 0 and c2 % g 0做一次断言检查。尤其当你重写 edge 层让中间通道变得不整齐时gcd会自动退化成很小的值DWConv 实际会退化成接近普通分组卷积参数量和速度特征都和你预想的不一样。6.3 forward_fuse 只在推理阶段才是安全的forward_fuse是给融合模型设计的它的前提是“卷积和 BN 已经被折叠成一个卷积”。这里最隐蔽的问题是折叠时机。如果在训练过程中BN 的均值和方差还在不断更新此时对模型执行fuse()并调用forward_fuse得到的结果和正常forward会有明显偏差因为折叠公式使用的是当前时刻的 BN 统计量而不是最终收敛后的统计量。实际使用中我通常严格按照这个顺序训练完成调用model.eval()把 BN 切到推理统计量调用model.fuse()完成结构折叠部署或导出的时候统一走forward_fuse。顺序反了轻则精度波动重则模型导出的权重和验证结果对不上。还有一个容易忽略的点forward_fuse里的self.act(self.conv(x))没有 BN如果你的模型在融合之后还想继续训练或微调就不要再走融合路径。融合是一次性的、面向部署的优化不是用来替代训练路径的。6.4 自建模块时优先复用这个文件里的算子最后说点经验性的建议。如果你要往 YOLO 结构里塞自己的模块尽量用conv.py里的Conv、Bottleneck、Concat作为基件而不是另起炉灶重新写一个裸卷积层。原因不只是代码复用更是因为这里的所有算子已经统一处理了 padding、BN、激活和融合逻辑。你自建的模块只要组合这些基础件天然就能继承它们的训练稳定性和部署友好性。反过来如果你直接在 forward 里裸写F.conv2d加F.relu就意味着需要手工处理 padding、bias 和 BN 融合很容易在各种尺寸和模式下漏掉边界条件。以我自己的项目为例我在某个分割任务里需要并联一个 5x5 的卷积分支实现上就是Conv(c1, c2, k5)padding 自动就对了若干行就接了回去没有引入任何新类。这样的扩展方式既降低了调试成本也更容易和后续的自动模型导出流程兼容。