最近在调研长文本建模方案时Gated DeltaNet 这个名字反复出现在我的视野里。它总是和 Delta Rule、Gating 绑在一起讨论还被不少技术社区的人是下一代开源大模型可能采用的核心机制标题里的 Qwen 3.5 相关猜测就是在这种背景下冒出来的。先一句话说清楚它的定位Gated DeltaNet 是一种把「精准改写」的 Delta Rule 和「一键清空」的 Gating 组合起来的线性注意力变体本质上是状态空间模型和关联记忆的交叉产物。这篇文章我会从问题出发把架构原理、组合逻辑和 Qwen 3.5 传闻背后的技术动因讲透并且给出一份能直接跑通的最小 PyTorch 实现。无论你是想复现论文还是想给自家模型做长上下文优化这套机制都值得认真看一眼。1. Gated DeltaNet 到底在解决什么问题1.1 标准 Transformer 的病根只存不修标准 softmax attention 很强大但它有个长期被忽略的尴尬点KV 缓存本质上是一个只追加、不修改的“档案库”。每个 token 的 Key 和 Value 一旦写进缓存后面的 token 只能通过注意力权重去选择性读取却没有任何机制去更新、修正或者删除已经存进去的信息。打个比方传统档案管理是一张巨大的纸质登记表。新来的记录只能往下追加如果前面某一行写错了你不能用橡皮擦掉重写只能再补一行“以新记录为准”然后寄希望于后面所有查询的人都能排除掉旧行。标准 attention 做的其实就是这么件事把同一 Key 在不同时间点出现的旧 Value 和新 Value 一起拿来加权平均。当上下文里某个关键信息频繁更新时——比如多轮对话中用户改了地址、代码仓库里某个变量名被重构、文档中某个数字被修订——模型会同时看到旧值和新值最终输出的往往是两者的混合体而不是真正最新的状态。这个问题的代价在短上下文里不明显因为注意力权重会倾向于最新的 token。但一旦上下文变长、关键信息被大量相似 token 包围旧值就会像幽灵一样反复出现。KV cache 本身还在不断膨胀长序列推理时显存和计算成本按平方级增长这就是 Transformer 长文本能力的第一个天花板。1.2 线性注意力只是变成一个累加器为了解决平方复杂度的问题线性注意力的一派做法是用核函数把 softmax 拆开将注意力计算改写为h_t ∑_{i≤t} φ(k_i) v_i^T也就是维护一个全局状态矩阵 S每来一个 token 就把新的 key-value 外积累加进去查询时用当前 query 与 S 做一次线性映射。这样复杂度降到 O(L)看起来很美。但仔细看它的更新规则就会发现S ← S k v^T这是纯粹的累加。累加最大的问题是信息污染。假设同一个 key“用户地址”先后关联过“杭州”和“上海”标准线性注意力会把两个地址的外积都累加进 S之后查询“用户地址”时读出来的是两个地址的叠加向量不是“上海”更不是“最近一次修改后的上海”。如果某个 key 在长文档里出现几十次S 中关于这个 key 的记忆会被几十个不同 value 平均掉越到后面越模糊。这种机制适合处理“信息只增不减”的场景但在真实世界里状态是会变化的。地址会改、权限会变、偏好会换。一个真正好用的记忆系统必须支持 UPDATE而不只是 INSERT 和 SELECT。1.3 Delta Rule 和 Gating 分别补上了哪块短板Delta Rule 的思路来自早期神经网络里的 Widrow-Hoff 学习规则更新量不是简单地叠加新值而是先按当前 key 读出旧记忆再用“新值减去旧读出结果”的误差信号去定向修正记忆矩阵里与这个 key 相关的部分。公式化表达是S ← S β (v_t - S k_t) k_t^T注意看当 S k_t 这个旧读出结果已经约等于 v_t 时误差趋近于零记忆几乎不动只有当旧的预测和新的真实值不一致时才会去修正。这就实现了按 key 的精准改写而不是无脑累加。但 Delta Rule 只能“改”不能“忘”。如果某个 key 对应的信息已经完全过时用户明确切换到新话题我们希望模型能主动清空旧状态而不是每个 key 都改一点点。这时候就需要 Gating一个逐 token、逐通道的遗忘门直接作用在旧记忆矩阵上S ← g_t ⊙ S β (v_t - (g_t ⊙ S) k_t) k_t^T当 g_t 逼近 0旧记忆几乎被整体清空新的 delta 项只在空白的记忆区域写入当 g_t 逼近 1旧记忆完整保留delta 项只修正目标 key 位置的误差。两个机制组合起来模型就同时拥有了“定向编辑”和“主动遗忘”的能力这就是 Gated DeltaNet 的核心吸引力。2. 核心原理拆解精准改写与一键清空如何协同2.1 从关键公式看组合逻辑完整定义一个 Gated DeltaNet 层输入序列 x (x_1, ..., x_L)每个 token 会计算四个投影keyk_t tanh(W_k x_t)queryq_t tanh(W_q x_t)valuev_t W_v x_tgateg_t σ(W_g x_t b_g)状态矩阵 S 的初始值为零维度一般是 d_state × d_modeld_state 远小于序列长度。每一步的递推过程为先对旧记忆做门控衰减S ← g_t ⊙ S。这里 g_t 可以是标量、逐通道向量也可以是逐 head 的向量实验中逐通道最常见。这一步对应“决定忘掉多少旧状态”。用衰减后的记忆对当前 key 做读出read_t S k_t。这一步是把修正的基准定位到当前 key 对应的旧输出。计算 delta 误差并写入S ← S β_t (v_t - read_t) k_t^T。β_t 是写入强度通常用 sigmoid 约束在 0 到 1 之间控制每次更新步子多大。输出则用 query 从最新的 S 中读取y_t S^T q_t后面接 LayerNorm 和残差。把更新顺序定为“先门控、再读取、再改写”这很重要。如果先读取旧记忆再门控那么门控清掉的信息仍然会通过误差项影响这一次写入逻辑上就不干净先衰减再读g_t 接近 0 时 read_t 也接近 0写入项变成 β v k^T等价于在空白状态上重新注册这个 key。这就是“一键清空后重写”的正确含义。2.2 “精准改写”具体精准在哪里我刚开始接触 Delta Rule 时最大的困惑是它和普通线性注意力的外积累加差别不就是多乘了一个误差项吗直到我跑了一个 toy 任务才真正理解差别。假设状态矩阵里存了三条记忆key A 对应 value 1key B 对应 value 2key C 对应 value 3。现在再给一个 key A希望把它更新为 value 9。标准线性注意力会直接把 (A, 9) 的外积累加到原来的 S 上。之后查询 A读到的结果是原来记住的 value 1 和新写入的 value 9 的某种叠加同时由于 A 和 B、C 的 key 并非完全正交这次累加还会轻微污染 B 和 C 的记忆。Gated DeltaNet 的行为不一样它先用 key A 从旧 S 里读出 read 1然后计算误差 9 - 1 8再按 β 缩放后写回 A 对应的位置。如果 β1那么写入后 S 中 A 对应的读出结果就变成 1 8 9几乎精确完成更新同时 B 和 C 所在位置几乎没有被触碰。这种“按 key 定向修改”的能力在很多真实任务里价值巨大例如代码编辑、知识库更新、状态追踪。当然实际情况下 key 不会完全正交不同 key 之间会有串扰所以 β 不可能直接取 1。β 的作用就是控制单次编辑的幅度留出空间让模型在多次迭代中渐进收敛。理想情况下模型会在信息频繁更新的场景学到更高的 β在信息稳定的场景学到较低的 β。2.3 Gating 的“清空”不是简单丢信息Gating 并不是把 g_t 设计成 0 或 1 的硬开关而是通过 sigmoid 输出一个 0 到 1 之间的软门控。这意味着它既能做“一键清空”也能做“部分衰减”。实际训练中更有价值的是部分衰减比如一个长文档里章节 A 的信息已经结束章节 B 刚开始模型不需要把 A 完全丢掉只需要让它的权重逐渐降低避免在查询 B 相关内容时A 的旧记忆跳出来喧宾夺主。门控还有一个容易被忽略的作用控制梯度传播路径。如果 g_t 一直接近 1旧记忆会长期保留误差梯度也能有效地向后传递到较早的 token和 LSTM 的 constant error carousel 思路一致如果 g_t 频繁接近 0历史信息会被快速截断模型更难学习长距离依赖。这也是为什么门控偏置的初始化如此重要——我后面会专门讲踩坑经验。2.4 参数初始化和稳定性的关键经验按照我的复现经验Gated DeltaNet 有四个参数最影响训练稳定性按重要性排序分别是门控偏置 b_g、写入门 β_t、状态维度 d_state、key 和 query 的归一化方式。门控偏置是第一个要盯住的点。b_g 初始值不建议设成 0因为 sigmoid(0)0.5模型一开始就处于“忘掉一半”的状态梯度很难通过门控传到早期 token。我通常把 b_g 初始化在 0.5 到 1.5 之间让初始门控输出在 0.6 到 0.8 左右保证训练早期偏向“保留”让模型先学会利用长期信息再慢慢学会在需要时主动遗忘。β_t 的初始化没有统一标准但一个比较稳妥的做法是把它建模成可学习标量经过 sigmoid 后乘以一个 0 到 1 的缩放系数初始值控制在 0.2 到 0.5。β 太大容易导致写入振荡尤其是同一个 key 被连续更新时误差项可能反复跳β 太小又会让模型学不到新信息只能靠多次重复输入缓慢更新。key 和 query 的 tanh 非线性不是为了模仿 attention 的 softmax而是把向量限制在 [-1,1] 的范围内避免状态矩阵 S 的数值持续膨胀。门控衰减和误差更新虽然能让 S 有界但在长序列中偶尔会出现数值尖峰加上 tanh 可以显著降低这种风险。3. 与 Qwen 3.5 的联系传闻、推测与可落地价值3.1 “Qwen 3.5 会采用 Gated DeltaNet”这个说法从哪来先说明Qwen 3.5 这个称呼在我看来更多是社区对某主流开源模型系列下一代版本的代称不代表官方命名更不代表官方已经确认任何架构细节。但为什么大家会把这个版本号和 Gated DeltaNet 放到一起讨论这个现象本身就很值得拆解。关键背景是长文本模型的军备竞赛已经到了一个尴尬的拐点继续用全注意力堆长上下文训练和推理成本太高完全切换到 Mamba 这类纯状态空间模型又会在需要精确记忆的任务上掉点。于是近一年来主流探索方向明显转向“混合架构”——一部分层保留标准注意力一部分层用线性注意力或状态空间模型替换取两边之长。Gated DeltaNet 在机制上几乎是为这种混合架构量身定做的。它比 Mamba2 多了 delta rule 的定向写入能力比标准线性注意力多了门控遗忘能力而且从形式上看它与 Mamba2 的 SSD 算法有很高的亲缘性工程上可以复用很多 chunk scan、IO 优化的经验。一条合理的技术路线就是新一代大模型在较低层或特定层使用全注意力处理复杂的语义关联在更高层或长上下文层使用 Gated DeltaNet 处理大规模、长距离的状态追踪。如果 Qwen 3.5 确实要重点解决长上下文推理效率问题Gated DeltaNet 被它在内部讨论中被反复提及一点都不意外。3.2 如果真集成收益会在哪三个场景爆发我不会去赌具体版本号但可以分析这类架构一旦进入旗舰级模型最有可能改善哪些实际体验。第一个场景是多轮对话中的信息修订。用户在第一轮说住在杭州第十轮说最近搬到了上海。标准模型在第十轮之后仍然会受“杭州”这个历史记忆的影响因为旧 Key 还躺在 KV cache 里。Gated DeltaNet 的 delta 写入机制可以按“位置”这个 Key 定向修正把旧城市信息替换成新城市同时不影响其他记忆。第二个场景是长文档中的数字、状态和事实更新比如财报修订、配置变更、代码重构。这类任务在普通 benchmark 里很难体现但在真实业务场景中极其常见。第三个场景是推理成本。Gated DeltaNet 的循环状态是固定维度的不随序列长度增长兼顾了长上下文质量和显存开销。当然代价也很明显状态矩阵的表达能力有限不可能完全取代 attention 的细粒度关联建模。所以最可能的落地方案是混合模型而不是把注意力整个拔掉。这也是我在评估消息时比较认可的一个逻辑它是现有技术演进的自然延续而不是一次激进的推倒重来。3.3 现在该不该押注这类架构我的建议是关注但不要急着迁移。Gated DeltaNet 的底层思想和代码实现并不复杂完全可以作为独立模块在你自己的模型上先跑一些小实验验证它对你真实场景是否有效。不要被论文里的长序列困惑度数字迷惑因为这类架构最大的优势恰恰是很多 benchmark 测不出来的“状态更新”能力。最值得做的三个小实验是第一构造一个 KV 更新任务让模型记住若干 key-value 对中间修改某个 key 的值检测模型后续读到的是旧值还是新值这个实验可以快速区分标准线性注意力和 Gated DeltaNet第二在你们自己的长文档问答或者多轮对话数据上做 A/B 测试观察是否出现“旧信息反复横跳”的问题第三对比注意力层替换比例从 25% 到 50% 到 75% 逐档测试损失曲线找到性能和速度的平衡点。这套流程做完你对这个架构的判断一定比追着新闻跑更靠谱。4. PyTorch 最小实现与 toy 实验演示4.1 从零实现一个可运行的 Gated DeltaNet 层理论聊再多不如代码。我给出一个非常精简的实现不追求极致性能只为了让机制透明。d_state 代表关联记忆的状态维度相当于给模型准备了一块黑板Key 决定在黑板的哪个区域写Value 决定写什么内容门控决定要不要先把黑板擦掉一部分。import torch import torch.nn as nn import torch.nn.functional as F class GatedDeltaNet(nn.Module): def __init__(self, d_model, d_state16, gate_bias_init1.0): super().__init__() self.d_state d_state self.proj_k nn.Linear(d_model, d_state, biasFalse) self.proj_q nn.Linear(d_model, d_state, biasFalse) self.proj_v nn.Linear(d_model, d_model, biasFalse) self.proj_g nn.Linear(d_model, d_state, biasTrue) # 门控偏置初始偏置设置决定训练早期是“偏向保留”还是“偏向遗忘” self.gate_bias nn.Parameter(torch.full((d_state,), gate_bias_init)) # 写入强度经过 sigmoid 后限制在 0~1 self.beta nn.Parameter(torch.full((d_state,), 0.5)) self.norm nn.LayerNorm(d_model) def forward(self, x): B, L, D x.shape k torch.tanh(self.proj_k(x)) # (B, L, d_state) q torch.tanh(self.proj_q(x)) # (B, L, d_state) v self.proj_v(x) # (B, L, D) g torch.sigmoid(self.proj_g(x) self.gate_bias) # (B, L, d_state) beta torch.sigmoid(self.beta) # (d_state,), [0, 1] # 状态矩阵 Sd_state 行每行是一个隐形记忆槽 S torch.zeros(B, self.d_state, D, devicex.device) outputs [] for t in range(L): k_t k[:, t] # (B, d_state) q_t q[:, t] # (B, d_state) v_t v[:, t] # (B, D) g_t g[:, t] # (B, d_state) # 第一步门控衰减相当于“决定遗忘多少旧记忆” S S * g_t[:, :, None] # (B, d_state, D) # 第二步先读出当前 key 对应的旧记忆 read_t torch.einsum(bd,bdh-bh, k_t, S) # (B, D) # 第三步用误差项做定向改写 S S beta[:, None] * torch.einsum( bd,bh-bdh, k_t, v_t - read_t ) # (B, d_state, D) # 用 query 读取最新状态 out_t torch.einsum(bd,bdh-bh, q_t, S) # (B, D) outputs.append(out_t) y torch.stack(outputs, dim1) # (B, L, D) return self.norm(y)这个实现的每一步都对应前面公式里的一个操作读代码的时候可以照着说明逐行核对。需要注意由于我用的是循环形式toy 场景下问题不大但实际长序列训练需要改成 chunk 并行扫描形式否则 L 步循环在反向传播时会很慢显存占用也会比理论预期高很多。4.2 组装一个 toy language model 并跑训练有了层我们再套一个 Embedding 和输出投影就是一个可以直接训练的最小模型。为了快速演示机制我用 vocab_size128、d_model64、d_state32 的小配置训练一个简单的 next-token 预测任务。class ToyLanguageModel(nn.Module): def __init__(self, vocab_size128, d_model64, d_state32): super().__init__() self.emb nn.Embedding(vocab_size, d_model) self.encoder GatedDeltaNet(d_model, d_state) self.head nn.Linear(d_model, vocab_size) def forward(self, x): h self.emb(x) # (B, L, d_model) h self.encoder(h) # (B, L, d_model) logits self.head(h) # (B, L, vocab_size) return logits训练循环非常简单用标准交叉熵即可。这里不追求把 loss 压到多低关键是验证前向、反向、更新都能稳定跑通。def train_step(model, optimizer, batch_tokens, vocab_size): x batch_tokens[:, :-1] y batch_tokens[:, 1:] logits model(x) loss F.cross_entropy( logits.reshape(-1, vocab_size), y.reshape(-1) ) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()我实际跑的时候有个经验如果 loss 长时间不降先别急着加轮数而是把门控输出的均值打印出来。理想状态下训练大概几十步之后模型的门控均值会从接近初始保留状态慢慢分化有的通道趋向 0该忘的忘掉有的通道保持高位长期信息保留。如果门控均值一直快速掉到 0.2 以下说明初期信息保留不够梯度传播困难需要把 gate_bias_init 调大如果门控均值几乎全在 0.9 以上说明模型没有学会遗忘这种情况下要检查是否有某些 key 在频繁重复更新导致旧信息长期无法清空。4.3 和标准线性注意力对比一个关键场景为了直观展示 Gated DeltaNet 的价值我构造了一个很小的记忆更新实验先让模型看到若干组 key-value 对然后在一段时间后再次给出相同的 key但对应的 value 已经更新。测试模型在后续查询中能否读出新值而非旧值。标准线性注意力在这个实验中的表现很典型第一次给出 key-value 对时S 正确写入第二次更新同一个 key 时S 再次累加一个外积等到查询时读到的结果相当于新旧 value 的加权混合无法精确得到新值。如果新值出现的位置离查询位置很远旧值的权重甚至会超过新值导致模型完全答错。Gated DeltaNet 在这个实验中的行为完全不同第二次出现相同 key 时门控先把旧记忆做了一次衰减随后 delta 项计算出旧读出值与新 value 的误差直接把新旧 key 关联定向改写。查询时读到的就是更新后的 value旧值的影子被压到很低。这个差异不是一个简单的性能数字能概括的它直接影响模型在真实场景中处理信息修订的能力。5. 常见问题与排查技巧实录5.1 训练不收敛或者 loss 下降极慢这是我被问得最多的问题。大部分情况出在门控偏置初始化上。很多第一版复现会用 PyTorch 默认的 Linear 初始化b_g 初始接近 0门控输出均值大约 0.5等于每一时刻都把旧记忆砍掉一半。在长序列任务里早期 token 的信息经过几十个 token 之后早就被稀释殆尽梯度自然传不回去。排查方法很简单在训练脚本里加一行print(model.encoder.proj_g.bias.mean().item())如果门控均值小于 0.4把 gate_bias_init 调大到 1.0 或 2.0 重跑。另一个常见问题是 β 初始化过大。delta rule 的误差项本身就带着很强的“修正”信号β 如果接近 1同一个 key 连续出现时会出现反复横跳第一次误差 8写入后误差 -2第二次又写成 10。我建议 β 初始别超过 0.5并且把它建模成可学习参数而不是固定超参数让训练过程自己去找每一条记忆通道的最佳写入强度。5.2 状态维度 d_state 到底该设多大d_state 决定了记忆槽的容量它不像 attention 的 head 数那样可以直接对应某种语义。我从实践中得到的经验是模型需要存储的关键关联越多d_state 越要大。像 toy 实验用 16 到 32 就能跑但如果你在做一个需要记住大量实体属性的长文本任务d_state 64 往往不够我见过有效的范围是 128 到 256。d_state 太小的表现是早期训练正常但长序列后半段效果明显变差因为记忆槽被占满不同 key 之间互相串扰。d_state 太大的代价不是精度下降而是状态矩阵 S 变大单步计算量从 O(d_state × d_model) 上升训练循环变慢。没有万能数字只能用你自己任务的验证集去扫 64、128、256 几档。5.3 循环实现显存还是爆了不少人以为用了线性注意力显存就是 O(1)训练时却发现照样爆。原因很简单循环实现的反向传播要穿过多达 L 步的计算图每一步的中间状态 S 都需要保存下来显存复杂度其实是 O(L × d_state × d_model)。推理的时候状态只需要一份显存确实低但训练不是。解决办法只有两条路一是用 gradient checkpointing只保存部分时间步的 S反向传播时重新计算用时间换显存二是改成 chunk 并行 scan参考 Mamba2 的 SSD 思路把序列切块后在块内并行计算块间复用状态。工程上想大规模落地 Gated DeltaNetchunk scan 是绕不开的一步否则很难用它训练超过几十万 token 的长序列。5.4 门控和 delta rule 互相“打架”怎么办门控希望旧记忆快速衰减delta rule 希望基于旧读出值修正记忆这两个机制如果都太激进会出现一种怪现象模型一遇到状态变化就把旧记忆清光然后 delta 项发现 read_t 已经是 0误差等于整个 v_t直接把新值当全新记忆写入。看起来还能工作但实际上模型完全依赖门控没用到 delta rule 的“修正”能力等于降级成一个带遗忘门的线性注意力。想避免这个局面让两者真正协作有两个技巧。第一把门控的初始化调得高一些让模型更晚学会激进遗忘先把 delta rule 的定向改写用好。第二在一些实验里给门控加一个下限比如g_t 0.3 0.7 * sigmoid(...)保证任何时刻至少保留三成旧记忆。这样模型被迫在保留旧记忆的基础上去修正误差而不是一遇到变化就推倒重来。等模型稳定跑通之后再去掉这个下限或者把它调成可学习参数。6. 我踩过的几个坑和最后的建议6.1 别迷信“O(L) 复杂度”这个标签Gated DeltaNet 的推理复杂度确实是 O(L)但这是理论上的。实际工程中单层的计算强度算术强度不如标准 attention 的矩阵乘法高状态维度和模型维度之间的小矩阵乘很容易变成访存受限。我自己的经验是直接对比吞吐量时Gated DeltaNet 并不一定比 FlashAttention 快只有在序列足够长、KV cache 明显成为瓶颈时优势才会显现出来。所以不要在短序列上草率下结论长度不到 8K 的场景老老实实用标准 attention 可能更合适。6.2 遇到状态更新不生效时先看门控我调试过几个多轮对话场景现象是模型明明看到了新信息却在后续回答里仍然引用旧信息。反复排查后问题通常不在 delta rule而在门控模型在信息更新的那一轮把门控输出压得太低旧记忆被清空后新的 key 关联确实写进去了但与此同时其他相关上下文也被一起清掉导致模型后续查询时找不到足够信息。排查方法是把关键 token 位置的门控向量打出来看每个通道的衰减情况。如果整个门控向量都是接近 0 的值说明模型在用“粗粒度遗忘”而不是“细粒度修正”。这种时候我会调整初始门控偏置或者给门控加一点正则鼓励它不要一次性把所有通道全部关闭。6.3 这套思想比特定模型版本更重要坦白说Qwen 3.5 最终会不会采用 Gated DeltaNet或者采用到什么程度没有人能提前打包票。但我个人体会是Delta Rule 加 Gating 的组合思路已经是长文本建模绕不开的方向它把记忆系统从“只读”升级成了“可读写、可遗忘”这个能力本身具备很强的迁移价值。哪怕下一代模型最终选了别的变体你掌握了这套机制后再去读 Mamba2、读其他 gated linear attention 的论文都会轻松很多因为核心概念是相通的。最后再分享一个小技巧如果你要在自己的模型里尝试 Gated DeltaNet建议从混合架构开始而不是把整个模型都换成它。保留一半的 attention 层处理需要精确关联的局部语义用 Gated DeltaNet 层处理长距离的状态追踪往往比全量替换更容易出效果调试起来也方便得多。
阅读完成 · 觉得有帮助?