
两年前我刚开始读图神经网络论文时卡在第一页的“消息传递”定义上很久。后来真正动手在推荐项目里跑图模型才意识到整个图深度学习最核心的思想并不复杂让每个节点从它的邻居那里“听”到足够多的信息再把听到的信息和自身的状态整合起来继续传递给下一轮。这个循环在论文里叫消息传递机制英文是 Message Passing。理解了它再看 GCN、GAT、GraphSAGE 这些模型本质上都是在同一个骨架里换不同的消息生成方式、聚合方式、更新方式而已。这篇内容适合三类人看想入门图神经网络但被论文公式劝退的读者已经在用图模型却发现效果不稳定、想搞明白原理的工程师以及准备在推荐系统、分子性质预测、社交网络分析等场景落地 GNN 的从业者。我尽量从直觉讲到数学再到工程上的坑把一个消息传递机制掰开揉碎。1. 先搞清楚消息传递在解决什么问题为什么节点需要“借邻居”1.1 图数据和图像、文本数据的关键区别CNN 和 Transformer 能直接处理的数据有一个共同点结构是规则的。图像是固定尺寸的网格卷积核可以一块一块地扫文本是一维序列可以按照位置编码做注意力。可图数据完全不一样——每个样本里节点的数量不固定边也不固定没有天然的顺序甚至无法把“第几个邻居”这个概念写死。这就带来一个直接后果常规的深度学习模型没办法直接吃到图结构信息。你可以把一个节点的特征单独拿出来丢进全连接网络但那只是孤立地看这个节点完全忽略了这个节点最值钱的信息——它的连接关系。消息传递机制的出现就是为了解决“如何在图结构上做特征提取”这件事。它不对全局顺序做任何假设只对局部邻域做操作就像 CNN 不对整张图片的绝对位置做假设、只对局部像素区域做卷积一样。1.2 一个社交网络例子光看自己是不够的假设现在要预测某个用户是否会购买一款新出的智能手表。只看用户自己的特征——历史购物记录、活跃时间、设备偏好——能拿到一部分信号。但真正强的信号往往来自他的圈子身边三五个好友都在晒新手表他下单概率会明显提高他常互动的几个用户已经加购了同类产品他也很可能感兴趣。这个直觉放到模型里就是把一个用户的特征和他邻居用户的信息融合起来。初始阶段每个节点只有自己的初始特征向量邻居之间互相传递特征和状态经过若干轮后每一个节点的表示里就不仅仅包含它自身的信息还包含它所在邻域的结构信息。模型做分类、回归、链路预测时用的就是这种“外挂”了邻居信息的表示。把这种思想放到分子性质预测里也一样。一个碳原子的化学性质很大程度上取决于它连了哪些原子、以什么键相连放到交通预测里某个路口的拥堵状况需要看相邻路口的车流。可以说凡是数据本身天然是图的地方消息传递都能派上用场。1.3 一次完整的信息循环听消息、合并观点、更新自我消息传递的一轮操作可以拆成三步。第一步每个节点把自己的当前表示打包成“消息”沿边发给邻居第二步每个节点收集所有邻居发来的消息做一次合并第三步节点把自己上一轮的表示和合并结果放在一起经过一个非线性变换得到新一轮的表示。然后大家把新表示作为下一轮的消息继续发出去。这个过程是全局并行的。不需要严格区分谁先发谁后发只要每个节点按照同样的规则从邻居那里取数整个图就能同步完成一轮更新。我常跟人打的一个比方是一栋宿舍楼里每个人都有一个初始立场每轮交流时把自己的立场写在明信片上发给朋友收到明信片的人把大家的新观点简单归纳一下再对照自己原来的立场形成一个新立场下一轮继续寄出。循环几轮后每个人的看法实际上就把整栋楼的舆论格局都打包进去了。图神经网络里说的“第 L 层 GNN”就是对图做 L 轮这样的操作。2. 消息传递的三大组件消息函数、聚合函数、更新函数2.1 消息函数邻居打包信息的快递盒消息函数决定了“一条消息长什么样”。最基础的形式就是邻居节点的表示直接乘一个可学习矩阵再加点东西。用公式说节点 w 发给节点 v 的消息可以写成 m_{w→v} W · h_w这里 W 是一个可学习的参数矩阵。这里有个容易忽略的点同一条消息发给不同邻居时内容是相同的。消息函数里只包含“发送方”的信息不包含“接收方”的信息。什么时候会包含接收方呢比如 GAT 里计算注意力权重时需要考虑目标节点但那是算“这条消息对谁重要”不是改消息本身。更复杂的消息函数还会把边的特征拼进来在分子图里C-C 单键和 CC 双键对应的消息编码方式应当不同这就是在消息阶段把边特征 e_{vw} 也塞进 MLP。设计消息函数时主要考虑的是原始特征维度是不是太高消息要向模型显式暴露哪些结构信号是否需要区分不同关系类型。关系类型多的时候可以给每种边类型单独配一个变换矩阵这也是许多异构图模型的做法。2.2 聚合函数sum、mean、max 到底差在哪每个节点收到的消息数量不同必须设计一个对邻居数量不敏感、对邻居顺序不敏感permutation invariant的函数把这些消息合并成一个向量。这个函数叫作聚合函数。常用的一共三种sum、mean、max。聚合方式数学本质特点适合场景sum对所有消息求和保留邻居数量信息聚集数量越多范数越大计数类信号重要时比如分子里的官能团数量mean对所有消息取平均对节点度数不敏感数值更稳定大多数通用场景默认安全max逐维度取最大值只保留最显著信号能捕捉“最极端”的特征存在某个特征就足以决定标签的场景聚合函数的选择对模型能力影响很大。sum 聚合有个理论上的优势它能区分“有一个邻居具备特征”和“有十个邻居具备特征”这种基数信息在某些任务里是判别性的mean 聚合会把这种信息平均掉。但反过来如果图里的节点度数差异巨大sum 聚合很容易被大度节点带偏mean 则更稳健。我实际跑实验的感觉是如果你不确定任务里“数量”是不是关键信号先用 mean 起步一般不会翻车想追求上限再去试 sum 和 max也可以同时保留多个聚合结果做拼接。2.3 更新函数别忘了你自己上一轮的表示聚合完邻居消息之后还需要把结果和节点自身的表示结合。简单粗暴地直接把聚合结果当作新表示有一个问题每经过一轮节点自身的信息都会被邻居信息稀释一点多层之后节点原始身份几乎就找不到了。所以更新函数通常写成 h_v^{new} σ( W_self · h_v h_agg )这里的 σ 是激活函数W_self 处理自身表示h_agg 是聚合结果。更高级的做法还有很多有人加入残差连接把上一轮表示直接加到输出里有人用 GRU、LSTM 类的结构逐步改造自身表示让更新过程更平滑。更新函数是当前 GNN 论文最卷的组件之一因为消息函数、聚合函数相对定型更新函数却是模型能“消化”新信息的关键环节。我的建议是第一版实现不要在这里花太多心思残差连接加一个 ReLU 就够了之后如果发现深层网络训练不稳再考虑门控或者更复杂的更新。2.4 层数就是感受野L 层网络看到 L 跳邻居图里没有“第 n 个邻居”的概念GNN 的感受野通过层数控制。初始时节点表示只含自身特征跑一层节点看到 1 跳邻居跑两层节点看到 2 跳邻居也就是“邻居的邻居”跑 L 层看到 L 跳以内的所有节点。这一点和 CNN 有很大区别。CNN 要从高分辨率一路池化、叠很多层才能让感受野覆盖整图图的连接结构天然让感受野随着跳数指数扩张——假设平均度数是 d两层 GNN 最多能触及 d² 量级的节点。这既是优点也是问题感受野大说明模型能快速利用远距离信息同时也意味着信息被大量平均节点容易“趋同”。所以别盲目加深具体原因和应对方式在本文第 5 节专门讨论。3. 从消息传递的角度看懂 GCN、GAT、GraphSAGE 的设计取舍3.1 GCN对称归一化解决了什么问题GCN 可以看作消息传递框架里最简单但最经典的一个实例消息就是邻居的表示聚合方式是“带对称归一化系数的加权求和”更新方式是线性变换加非线性激活。归一化是这个模型里最关键的动作。如果不做任何归一化直接对邻居相加那么度数高的节点聚出来的数值会特别大度数低的节点数值特别小梯度在多层叠加后非常不稳定模型很难训练。GCN 采用的归一化方式是 D^{-1/2} A D^{-1/2}这个矩阵里的每一项相当于用两个节点度数的乘积开方做分母。度数的数量级因此被压到了同一个尺度大度节点不会因为邻居多就主导整个聚合结果小度节点也不会被忽视。但 GCN 有个明显的限制所有邻居的权重是固定的、等价的。在真实图里邻居和邻居的语义重要性往往差别很大——你的十个好友对你的影响权重不可能完全一样。要打破这个限制就需要把权重变成模型自己学出来的。3.2 GAT给每条消息配一个注意力权重GAT 的核心改动是在聚合阶段对每条消息计算一个注意力系数。目标节点和某个邻居的表示拼接起来经过一个小型 MLP再通过 softmax 把邻居之间的权重归一化最后加权求和。从消息传递的视角看消息函数和更新函数基本没变变化发生在聚合函数固定系数变为了学习到的注意力权重。这带来的直接好处是模型可以在训练中识别“关键邻居”和“噪声邻居”这在社交网络中尤其有用——关注了几万个账号的大 V 和互动频繁的亲密好友显然不该被同等对待。多头注意力是 GAT 里的另一个实用设计。多个注意力头并行计算让模型可以在不同的表示子空间里分别学习“谁重要”最后把几组结果拼接或取平均。这种做法能显著提升训练的稳定性也让模型更不容易被单个注意力头的偏差带偏。3.3 GraphSAGE采样加三种聚合器是怎么解决大图问题的GraphSAGE 是从工程落地需求出发设计出来的。在真实工业图数据上一个节点可能有几万甚至几十万个邻居全量聚合根本算不动显存立刻就会爆掉。GraphSAGE 给出的方案是训练时随机采样固定数量的邻居比如统一采 25 个用部分邻居代替全部邻居去聚合消息。这个操作把每层计算量从“所有邻居”压成了常数级别整个模型的时间和显存开销都不再随图的规模不可控地增长。GraphSAGE 另外提供了一个很有意思的聚合器集合mean 聚合器、LSTM 聚合器、池化聚合器。LSTM 聚合器看起来有点反直觉——图节点没有顺序为什么能用序列模型实际操作是每次把采样到的邻居随机打乱再喂给 LSTM用随机顺序来近似图的无序性质。池化聚合器则是对每个邻居先做一次变换再逐维度取 max 或 mean。工程上 GraphSAGE 还有另一个隐性优势归纳式学习。新节点加入时不需要重新训练整个模型只要让它沿着学到的消息传递规则和邻居交互就能得到合理表示。这一点对推荐系统、风控场景特别友好新用户、新商品一直在进入系统不可能每次都全量重训。3.4 统一的视野任何消息传递 GNN 都只需回答三个问题把上面几种模型放到同一张表里看结构会非常清楚模型消息函数聚合函数更新函数GCN邻居表示对称归一化加权求和线性变换 ReLUGAT邻居表示注意力权重加权求和线性变换 ReLUGraphSAGE邻居表示可拼接边特征mean / LSTM / max-pool拼接或残差 MLP这个统一的视角就是论文里常说的“消息传递神经网络MPNN”框架每一层操作都可以拆成 Message、Aggregate、Update 三个步骤。读任何一篇新的 GNN 论文时我不急着背公式而是先回答三个问题消息用什么编码邻居怎么聚合自己的表示怎么更新把这三个问题理清楚模型核心思想基本就抓住了。剩下的结构细化、归一化技巧、训练策略都是在围绕这副骨架做文章。4. 把消息传递写成数学公式和代码从邻接矩阵到梯度流动4.1 通用记号与一层消息传递的标准表达式先约定记号图里有 N 个节点初始特征矩阵记作 X ∈ R^{N×F}邻接矩阵记作 A。在第 l 层节点 v 的表示是 h_v^{(l)}。一层消息传递的通用表达式可以写成h_v^{(l1)} Update( h_v^{(l)}, Aggregate( { Message( h_v^{(l)}, h_w^{(l)} ) : w ∈ N(v) } ) )如果换成 GCN 的具体形态加自环后的邻接矩阵是 Ã A I再乘以对称归一化系数就得到每层的矩阵运算式H^{(l1)} ReLU( D^{-1/2} Ã D^{-1/2} H^{(l)} W^{(l)} )这里 D 是度矩阵W^{(l)} 是这一层的可学习参数。注意 GCN 公式里通常还包含自环其实就相当于把“节点自己的消息”也当成一个邻居消息参与聚合。这是一种非常巧妙的写法——更新函数不需要单独区分自己和邻居自环把自身信息自动包含了进去。4.2 一个可跑的最小实现从稀疏邻接矩阵到两层 GCN用 PyTorch 实现一个最小的两层 GCN可以直观地看到消息传递在代码层面的落点import torch import torch.nn as nn import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.W nn.Linear(in_dim, out_dim, biasFalse) def forward(self, x, adj_norm): # x: [N, F] 节点特征 # adj_norm: [N, N] 对称归一化后的带自环邻接矩阵 agg torch.spmm(adj_norm, x) # 聚合邻居消息含自环 out self.W(agg) # 更新函数线性变换 return F.relu(out) class TwoLayerGCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.layer1 GCNLayer(in_dim, hidden_dim) self.layer2 GCNLayer(hidden_dim, out_dim) def forward(self, x, adj_norm): h self.layer1(x, adj_norm) return self.layer2(h, adj_norm) def symmetric_normalize(A): # A: [N, N] 原始邻接矩阵 n A.size(0) A_tilde A torch.eye(n) # 加自环 d A_tilde.sum(dim1) # 度 d_inv_sqrt torch.pow(d, -0.5) # 度数的 -1/2 次方 d_inv_sqrt[torch.isinf(d_inv_sqrt)] 0.0 D_inv_sqrt torch.diag(d_inv_sqrt) return D_inv_sqrt A_tilde D_inv_sqrt # 对称归一化这段代码里最核心的一行是torch.spmm(adj_norm, x)稀疏矩阵乘特征矩阵本质就是同时把所有节点的聚合操作全部算完。对于边数为 E 的图这行操作的时间复杂度是 O(E · F)F 是特征维度和节点数不直接相关这也就是 GNN 比全连接网络能更好处理大图的原因。真实工程中通常不需要手写这个矩阵成熟的图学习库内部提供的卷积层做的就是类似的事。但手写一遍有助于建立直觉当你惊讶于 GCN 为什么“看邻居”这么快时答案就是稀疏矩阵乘法把所有聚合操作向量化了。4.3 反向传播为什么自动完成了图结构的信息扩散理解梯度流动是掌握 GNN 的另一个关键点。假设损失函数来自最后一层的节点输出反向传播更新 W 的时候每个节点的表示 h_v 不仅通过自己的输出影响损失还会通过“作为邻居的消息被别人的聚合使用”这一路径影响损失。这意味着梯度的传播路径完全复用了图的边第一层梯度从中心节点流向它的直接邻居再经过第二层流到二阶邻居。L 层 GNN 的梯度有效传播范围也是 L 跳物理意义非常清晰。这也是 GNN 与传统多层感知机最本质的差异。MLP 的每个样本在梯度上是相互独立的你改一个样本的参数不会影响另一个样本GNN 则天然把样本之间的结构性依赖写进了梯度路径里。训练过程中模型不仅在做特征变换实际上还隐式地把标签信息沿着图结构做了某种“软传播”这也是半监督节点分类任务中少量标签就能取得不错效果的原因。4.4 计算复杂度与显存开销从全图操作到批次化全图 GCN 每一层都要把归一化邻接矩阵和整张特征矩阵乘一遍。节点数几万以内时完全可行一旦到达百万节点、几亿条边中间表示直接撑爆显存矩阵乘法的时间也让人难以接受。所以工业落地时几乎没有模型是直接全图训练。主要的策略有三种邻居采样、图划分、渐进式训练。邻居采样就是 GraphSAGE 的思路每个 batch 只构建一个小型子图。图划分是把全图切成若干块每个 batch 只装载一块。还有个不常被提起的小技巧是把特征和邻接矩阵都转成稀疏存储并且固定 L 层消息传递的中间缓存动态释放不再需要的前层表示能把显存峰值降下不少。5. 工程落地中我反复踩的四个坑过平滑、度数失衡、孤立点与大图采样5.1 过平滑层数堆上去节点全成了“复读机”我第一次把 GCN 从两层加到五层时结果是反直觉的训练损失在下降验证集精度却一路崩掉。把节点 embedding 拉出来可视化后发现第五层所有节点几乎挤在同一个位置节点之间的区分度消失了。这就是图神经网络里最著名的过平滑over-smoothing现象。原因要从消息传递的机制本身去理解每一轮聚合本质上都在做某种平均或加权平均。反复平均会让局部结构信息被稀释高频的结构差异被抹平最终所有表示收敛到同一个方向。谱域的视角下问题等价于多层滤波后低频分量完全压过高频分量。我实际用的缓解方案有这么几条把层数压回两到三层让模型聚焦在局部邻域引入残差连接或者跳跃连接保留早期层的个性化信息对每层输出做类似 PairNorm 的标准化让节点表示之间的方差不至于收缩成零。说句实在话图任务里“更深”不等于“更好”两三层深度在图结构上往往已经覆盖了足够远的信息继续加深只会叠噪声。5.2 度数失衡大 V 和潜水用户的邻居数量差了三四个数量级真实社交网络里一个头部博主和普通用户的关注者数量能差到一万倍。如果消息传递不做归一化大 V 节点的聚合结果里塞满了海量邻居信息特征数值范数特别大普通小号聚合到的消息少范数特别小。模型很容易放大这种数值差距导致训练初期梯度震荡。工程上的处理有几个层次。第一层是度归一化也就是 GCN 用的对称归一化方式所有节点的聚合结果都在同一尺度上第二层是邻居采样把参加聚合的邻居数压到同一个固定数量天然规避度数差异第三层是对度数本身做特征变换比如把对数度数拼进特征向量让模型能“意识到”某些节点就是比别的节点社交范围广必要时可以为大 V 学一套专用权重。我的核心观点是消息传递不是简单的“邻居越多信息越多”而是要有控制地融合。度数信息如果能准确表达给模型反而可能是重要的预测信号不要急着把它抹掉。5.3 有向图、孤立节点和缺失边消息传不过去怎么办真实场景里的图很少是简单的无向图。关注关系是有向的论文引用是有向的资金流向是有向的。消息传递落到有向图上要考虑消息沿着“出边”发还是沿着“入边”收。不同任务差别很大做内容推荐入边代表“谁关注了我”出边代表“我关注了谁”两种方向携带的信息完全不同实践里经常把双向分别聚合再拼接。孤立节点更是常见问题。没有邻居就意味着聚合函数收到空集合此时如果不加自环更新函数退化成一个纯 MLP节点完全不会得到结构信息。我的缓解手段主要有三个把自环加进归一化邻接矩阵保证每个节点至少“有一个自己发给自己”的消息引入一个全局虚拟节点和所有节点相连充当全局信息广播站在节点特征里补充结构统计量比如度、聚类系数让孤立点虽然没有邻域信息也不至于完全失去图感知能力。5.4 大图的采样与分批显存不够消息传递不能全量跑全图 GNN 在大规模数据上遇到的第一个坎就是显存。我用过几个方案排一下优先级方案原理优点缺点邻居采样每 batch 只取固定邻居构建子图显存可控实现直观采样方差大覆盖不完整图划分把全图切成子图分块训练节省通信子图间信息被截断渐进式学习小模型上先训练再逐步扩规模训练稳定流程复杂实操中有两个容易忽略的细节。第一个是采样层的数量要和 GNN 层数保持一致采样深度不对会让深层节点收不到足够远的邻居消息。第二个是 BatchNorm 在采样式训练里可能帮倒忙——每个 batch 的子图节点分布差异极大统计量不稳定经常出现训练和验证表现不一致的情况。我最后的习惯是先在不同子图上对比过 train 和 val 的分布再决定要不要保留归一化层。另外采样覆盖率不是越高越好。提高采样数量会改善覆盖显存压力同步上升需要根据 GPU 容量试出一个中间值。调试时先用固定随机种子固定采样结果能让实验复现性大幅提升。最后再分享一组我在调参过程中沉淀下来的体会。图神经网络让我最受触动的地方是它把“关系”变成了可学习的信息通道而不是靠人为硬编码规则去描述谁影响谁。真正把效果做上去的往往不是用了一个多花哨的聚合器而是把归一化、层数控制、采样策略这些基本功做到位。如果你刚开始接触 GNN我的建议是先别急着复现十篇论文先把一个最简单的两层 GCN 在真实数据上跑通观察 embedding 的可视化变化再逐个把 GAT 的注意力、GraphSAGE 的采样换上去比较它们在不同任务上的行为差异。调试图模型以前可以先用一个密集的小规模子图把模型逻辑验证通过再切换到全图或采样模式能省下大量试错时间。这个顺序比我一开始直接在大图上折腾要高效得多。