GPT 这类自回归(autoregressive,逐个往后猜下一个词)模型像一个游标:fetch 一行、再 fetch 一行,第 i 行必须等第 i-1 行回来。掩码扩散换了个写法:先 INSERT 一条定长记录、所有字段全是 NULL,然后跑若干轮批量 UPDATE,每轮把最有把握的几个字段填成实值,直到没有 NULL。文本不是"写出来"的,是从一张空画布上显影出来的。
痛点是一面顺序依赖的硬墙:自回归生成 N 个 token 就得做 N 次前向,因为第 i 个 token 的输入里含着第 i-1 个。加再多 GPU 也压不成 10 步——延迟被串行步数锁死,不是被算力锁死。
扩散的思路来自图像生成,但文本是离散的,加高斯噪声没有意义("猫"加一点噪声不会变成"狗")。D3PM(Austin et al., 2021,arXiv:2107.03006)给出了可用的离散版本:噪声不是抖动而是遗忘——前向过程按噪声水平 t 把一部分真 token 换成 [MASK],而 [MASK] 是吸收态(absorbing state,进去出不来);t=0 是原文,t=1 全是 mask。反向过程训一个 Transformer,去掉 causal mask——这是和 GPT 最本质的结构差别:它能同时看见左右两边,一次前向把所有 [MASK] 位置的分布全预测出来。
训练目标更让人意外。MDLM(Sahoo et al., NeurIPS 2024,arXiv:2406.07524)证明这套扩散的 ELBO 可以化简成一个加权的 masked LM 损失:
L(θ) = Et~U(0,1] [ (1/t) · Σi∈被mask的位置 −log pθ(xi | xt) ]
逐项读:t 是本次采样的 mask 比例(即噪声水平);xt 是挖掉 t 比例后的序列;括号里就是"预测被挖掉的字"——和 BERT 的训练任务一模一样,只是 mask 比例每个样本随机抽,不是固定 15%。那个 1/t 权重是关键:t 很小时只挖掉几个字、上下文几乎全在,样本太容易、信号太稀疏,1/t 把它放大,让各噪声水平对总损失的贡献被摆平——否则模型只擅长"几乎没挖空"的简单情形,而采样恰恰从 t=1 开始。换句话说:训练扩散语言模型,就是训练一个能承受任意 mask 比例的 BERT。LLaDA(Nie et al., arXiv:2502.09992)用这条路把掩码扩散推到 8B,在 in-context learning 上做到与同规模 LLaMA3 可比。
import torch from transformers import AutoTokenizer, AutoModelForMaskedLM name = "bert-base-chinese" # 任何 masked LM 都能当"一步去噪器" tok = AutoTokenizer.from_pretrained(name) mdl = AutoModelForMaskedLM.from_pretrained(name).eval() L, STEPS = 16, 8 # 画布长 16,但只跑 8 步(AR 要 16 次前向) ids = torch.full((1, L), tok.mask_token_id) # t=1:整张画布全 [MASK] ids[0, 0], ids[0, -1] = tok.cls_token_id, tok.sep_token_id for s in range(STEPS): masked = (ids == tok.mask_token_id) if not masked.any(): break with torch.no_grad(): logits = mdl(input_ids=ids).logits # 双向:一次预测所有槽位 prob, pred = logits.softmax(-1).max(-1) # 每槽的最佳 token + 置信度 prob[~masked] = -1 # 已定稿的不再改(吸收态) k = max(1, int(masked.sum()) // (STEPS - s)) # 剩余 mask 均摊到剩余步 for i in prob[0].topk(k).indices: # 置信度最高的 k 个 → 写实 ids[0, i] = pred[0, i] print(s, tok.decode(ids[0])) # 看文本从噪声里显影
自回归像固定拓扑排序的任务调度:顺序写死,没得选。扩散像一个能任意挑任务的调度器:哪个槽位最有把握就先做。听上去是调度器的胜利——但真实调度器都有这个毛病:一直先捡简单任务,把关键路径节点拖到最后。等你终于去做它,周围全已定稿,选择空间被挤没了。
掩码扩散的 ELBO 实际上是在所有生成顺序上取期望——它学的是一个 any-order(任意顺序)模型,自回归只是其中一种固定顺序的特例。采样时最常用的策略是 置信度优先解码(confidence-based unmasking):每轮把预测熵最低、也就是模型最笃定的几个槽位定稿。直觉上很合理:先确定能确定的,让它们给后面当上下文。
ICML 2026 Outstanding Paper《The Flexibility Trap》(Ni et al., arXiv:2601.15165)指出这里有个结构性毛病。推理任务里真正的决策点恰恰是高熵 token——"因此"/"所以"/"设 x =",它们熵高正是因为下面挂着好几条都合法的推理分支。置信度优先会系统性绕开它们,先把周边确定无疑的词填满;等绕回来,左右文已把这个位置夹死,几条分支只剩一条还说得通。
作者的修法很简洁:RL 训练时强制按左到右顺序探索(把自回归顺序当训练脚手架),推理时完全保留双向注意力和并行解码。这套 JustGRPO 在 GSM8K 上 89.1%、MATH-500 上 45.1%(生成长度 256)。结论反直觉但干净:更大的假设空间配一个贪心的顺序选择器,可以比更小的假设空间更差。
import torch from transformers import AutoTokenizer, AutoModelForMaskedLM tok = AutoTokenizer.from_pretrained("bert-base-chinese") mdl = AutoModelForMaskedLM.from_pretrained("bert-base-chinese").eval() M = tok.mask_token # 中间两格留给"因此/所以"这类连接词,两端是确定的算式 text = f"小明有 3 个苹果又买了 5 个, {M} {M} 他一共有 8 个。" ids = tok(text, return_tensors="pt").input_ids with torch.no_grad(): p = mdl(input_ids=ids).logits.softmax(-1) ent = -(p * p.clamp_min(1e-9).log()).sum(-1)[0] # 每个位置的预测熵 for i in (ids[0] == tok.mask_token_id).nonzero().flatten(): top3 = tok.convert_ids_to_tokens(p[0, i].topk(3).indices) print(f"槽位{i.item()} 熵={ent[i]:.2f} 候选={top3}") # 熵高的槽位 = 有多条合法分支的决策点; # 置信度优先策略恰恰把它们排在最后 —— 这就是"灵活性陷阱"
自回归解码是教科书级的 N+1 查询问题:要 512 个 token 就发 512 次串行请求,每次付一个完整往返。纯扩散是另一极端:一次全发出去,但要重试很多轮,且每轮都得把整批重算。块扩散是中间那个所有 DBA 都会告诉你的答案——分页批处理:按 256 一页,页内并行、页间顺序。
纯掩码扩散有两个硬伤,都不在数学上而在工程上。一、必须定长:得先 allocate 一张固定长度的画布,而真实请求的输出长度事先不知道。二、KV cache 失效:双向注意力意味着每个 token 都依赖所有其他 token,上一轮的 key/value 下一轮全部作废——自回归省时间的最大法宝没了。
Block Diffusion(Arriola et al., ICLR 2025,arXiv:2503.09573)两刀都解了:把序列切块,块之间自回归(前块已定稿,可缓存、可想生成多少块就多少块),块内部扩散(并行去噪)。块大小成了连续旋钮:块=1 退化成纯自回归,块=全长退化成纯扩散。
DiffusionGemma(Google DeepMind,2026-06,Apache 2.0 开放权重)走的就是这条路:基于 Gemma 4 的 26B 级 MoE、每步只激活约 4B 参数,一次并行去噪 256 token 的块。官方口径:解码比 Gemma 4 快约 4×,单张 H100 上超过 1000 tokens/s。
速度到底从哪来?全篇最值得记住的一句:延迟 ≈ NFE(网络前向次数)× 单次前向耗时。自回归的 NFE 等于 token 数,一步都省不掉;块扩散的 NFE = 块数 × 块内去噪步数,而块内步数远小于块长(256 个位置可能只跑三四十步)。注意它没减少 FLOPs——每次前向算的是整块 256 个位置,总计算量不降甚至略升。省掉的是"必须等上一个 token 回来"的那 N−1 次往返。
为什么这笔买卖划得来?因为自回归解码时 GPU 其实在等内存:batch=1 下每生成一个 token 都要把全部权重从显存搬进计算单元,算力大半空转(memory-bound)。块扩散把闲着的算力拿去一次处理整块,把 memory-bound 的解码推回 compute-bound——和"把 N+1 查询改成一条批量 SQL"同理:往返次数才是瓶颈。
import math def nfe_ar(n): # 自回归:一个 token 一次前向,无法并行 return n def nfe_block(n, block=256, steps=32): # 块间顺序、块内并行去噪 return math.ceil(n / block) * steps N = 512 for fwd_ms in (10, 14): # 单次前向耗时(MoE 稀疏激活后更小) a, d = nfe_ar(N), nfe_block(N) print(f"前向{fwd_ms}ms | AR {a}次={a*fwd_ms/1000:.2f}s | " f"块扩散 {d}次={d*fwd_ms/1000:.2f}s | 加速{a/d:.1f}x") # 调 block/steps 看旋钮效果:block=1 就退化成自回归。 # 关键:块扩散每次前向算的是"整块 256 个位置",FLOPs 并没少, # 省掉的是 N-1 次"必须等上一个 token"的往返。算力换延迟。
自回归像单主强一致写入:顺序明确、可缓存、每步都看得见前面已提交的全部内容,代价是写入串行。扩散像多主并发写:吞吐高,但同一轮里各槽位互相看不见对方这轮写了什么,于是可能写出"每格单看都合理、合起来不一致"的组合。
并行解码要付的根本税就藏在这个类比里:模型一次前向给出的是每个 mask 位置的边缘分布 p(xi | xt),而你一次定稿 k 个位置,等于悄悄假设这 k 个位置在当前上下文下条件独立。可语言里相邻位置强相关——"纽"和"约"各自单看都可能是别的字,一起看才只有一种读法。k 越大,这个假设离真实联合分布越远。所以"并行度 ↔ 质量"是一条真实存在的曲线,不是实现问题;靠置信度门限自适应控制 k,就是在这条曲线上找工作点。
逐轴说清楚。似然:自回归的链式分解 log p(x) = Σ log p(xi|x<i) 是恒等式,给的是精确值,能直接用来打分、比困惑度、做重排序;扩散的顺序是随机的,只能给一个对顺序取期望的下界(ELBO),两者的 perplexity 不可直接比大小。可控性:扩散天生支持双向约束——给定开头和结尾填中间、固定格式槽位、从右往左补全,这些自回归要靠 fill-in-the-middle 专门训练才勉强做到;LLaDA 还报告它缓解了反转诅咒(reversal curse,学了"A 是 B"却答不出"B 是 A"),那正是单向训练留下的伤。延迟:扩散赢,代价是定长画布和 KV cache 重建。
什么时候不要用,说实话:需要精确似然去打分或比模型时;追求长链推理的极限正确率时(上面那组数字还在差距里);以及需要逐 token 流式输出时——块内 token 不按左到右定稿,"打字机效果"这个 UX 契约得重新设计。目前的主流判断不是替代,而是互补并融合:块扩散本身就是两极之间的插值,块大小就是那个滑块。
import torch from transformers import AutoTokenizer, AutoModelForMaskedLM tok = AutoTokenizer.from_pretrained("bert-base-chinese") mdl = AutoModelForMaskedLM.from_pretrained("bert-base-chinese").eval() # 双向约束填空:前缀和后缀同时给定 —— 自回归看不见右边,天生做不到 M = tok.mask_token prompt = f"缓存击穿的常见解法是 {M} {M} {M} {M} ,代价是写入延迟变高。" ids = tok(prompt, return_tensors="pt").input_ids slots = (ids[0] == tok.mask_token_id).nonzero().flatten().tolist() for _ in range(len(slots)): # 一次只定稿 1 个 → 绕开条件独立假设 with torch.no_grad(): p = mdl(input_ids=ids).logits.softmax(-1)[0] left = [i for i in slots if ids[0, i] == tok.mask_token_id] if not left: break best = max(left, key=lambda i: p[i].max().item()) # 最有把握的槽位先写 ids[0, best] = p[best].argmax() print(tok.decode(ids[0], skip_special_tokens=True)) # 把循环改成"一轮定稿全部 4 个",就能亲眼看到条件独立的代价