
这两年做大模型相关的项目被问得最多的问题之一就是为什么GPT、Llama、Mistral这些模型长得越来越像而早年NLP里常见的Encoder-Decoder框架反而很少出现在生成式大模型底座里答案其实不复杂因为大家最终都倒向了Decode-Only Transformer架构。简单说Decode-Only架构就是只有解码器、没有编码器的Transformer变体模型只做一件事根据已经看到的所有内容预测下一个Token。听起来好像比机器翻译时代的编码器-解码器结构更简单但它恰恰是当前ChatGPT类产品、开源大模型、以及各类生成式应用的基础底座。这篇文章我会从架构动机、因果掩码、位置编码、训练范式、推理优化和踩坑记录几个角度把我自己的工程经验完整分享出来适合刚入门Transformer、想自己动手搭小型语言模型或者在做推理加速时遇到瓶颈的读者。1. 为什么大模型最后都收敛到了Decode-Only这一条路上1.1 一个被名字误导的模块很多人第一次接触Transformer看的是《Attention Is All You Need》那篇论文里面最显眼的是编码器和解码器两个堆叠结构。后来看到GPT只用Decoder就会下意识觉得“Decoder应该是用来输出的半成品”实际上这个理解完全跑偏了。Decode-Only里的“解码器”已经不是原来那个依赖Cross-Attention、需要Encoder提供KV的Decoder。它把原本Encoder负责的“理解输入”职责也一并承担了模型内部没有额外的编码器模块只有单向的因果自注意力、前馈网络、层归一化和残差连接。换句话说它是一个“自己读序列、自己生成序列”的完整体而不是某条流水线的后半段。我刚开始自己写Transformer时也犯过这个混淆把Cross-Attention硬塞进一个小型GPT模型里结果训练速度慢了一倍生成质量也没有提升。后来才意识到在纯生成场景下所有信息本来就已经被统一编码进同一串Token序列里了再单独设置一个Encoder去“处理源端”反而破坏了信息流动的简洁性。1.2 从任务适配到通用生成架构收敛的必然为什么大模型几乎都倾向于Decode-Only而不是继续沿用机器翻译时代那套Encoder-Decoder我个人的理解是通用性。Encoder-Decoder的优势在于序列到序列的强对齐比如翻译时源语言和目标语言结构差异很大需要专门的编码过程去“理解”源句。但这种结构天然就把任务限制成了“一种输入、一种输出”。而大语言模型的目标是成为通用任务求解器无论你是让它续写代码、回答问题、做摘要、还是角色扮演本质上都可以被表达成“给定一段文本继续生成后文”。Decode-Only正好是最简洁的统一表达方式不需要为每种任务单独设计输入和输出接口。另外工程上Decode-Only也更好做。模型结构单一没有Encoder和Decoder两套参数体系预训练、微调、推理的代码路径都能保持同一套实现。我做过多任务微调实验Encoder-Decoder模型常常需要额外处理Encoder端的memory分配而Decode-Only模型只需要把任务描述和示例拼进Prompt训练和推理完全一致非常干净。1.3 自回归生成一句话解释它的运作方式Decode-Only模型使用自回归方式生成内容。所谓自回归就是每次只预测下一个Token然后把这个Token接回输入序列末尾再用更新后的序列预测再下一个Token。举个例子模型输入是“今天天气”它会根据这4个Token计算概率分布挑出概率最高的“很”作为预测结果接着输入变成“今天天气很”再去预测“好”如此循环直到生成结束符或达到最大长度。整个过程是串行的这也是Decode-Only推理时速度受限制的根本原因。这种设计的好处是训练过程可以完全并行。因为在训练时我们已经知道了完整的文本可以把所有位置的预测同时算出来不需要像推理那样一个个向后滚。这一点是理解Transformer训练效率的关键也是后续谈因果掩码的基础。2. 因果掩码与位置编码Decode-Only的两个地基2.1 因果掩码到底“遮”了什么Decode-Only最核心的机制是因果自注意力Causal Self-Attention也就是每个位置只能看到当前位置及之前的内容不能偷看未来的Token。实现方式是在注意力分数矩阵上叠加一个上三角掩码矩阵。假设序列长度为4注意力分数矩阵是一个4x4的矩阵第i行第j列表示位置i对位置j的注意力权重。训练时如果不做任何处理位置2就能看到位置3和位置4的信息这就等于“作弊”了。加上掩码后矩阵右上部分全部被替换成负无穷大经过Softmax后这些位置的注意力权重会变成0。为什么一定要这样因为做预测时位置0到位置i-1是已知的位置i及之后是未知的。如果训练时允许模型看到未来那么模型学到的是一个“作弊函数”而不是真正理解语言规律推理时就不可能复现训练时的表现。我自己实现这个掩码时踩过一个坑直接在注意力分数上套布尔掩码然后做Softmax时负无穷的位置因为浮点精度问题偶尔会留下很小的非零残差。后来老老实实用一个上三角矩阵从1到0做乘法而不是加法再配合float32的稳定处理效果才稳定下来。从实现视角来看训练阶段因果掩码主要目的是并行计算所有位置。它的代价是多消耗一部分显存用于存储上三角掩码或对应的布尔矩阵序列越长这部分浪费越大。推理阶段则完全不同因我们已经知道总长度只需要维护一个不断增长的KV缓存掩码部分只需要处理新增的那个位置所以每次解码的注意力计算是轻量级的。2.2 位置编码没有它Token就是一堆乱序的集合Transformer没有循环结构它本身不感知Token出现顺序。如果去掉位置编码模型看到的“我爱学习”和“学习爱我”完全一样因为词袋顺序被洗掉了。所以位置编码是Decode-Only架构里另一块地基。当前最主流的位置编码方式是RoPE旋转位置编码Llama系列、Mistral、Qwen这些大模型基本都用它。RoPE的思路很巧妙把位置信息编码到查询向量和键向量的夹角里两个Token距离越远向量夹角越大注意力分数就会自然衰减。它不像早期Transformer的绝对位置编码那样直接把一个位置向量加到Token向量上而是通过旋转隐空间中的向量来实现。我测试过几种位置编码直观感受是绝对位置编码实现最简单但外推性差ALiBi主打长度外推但有些任务上精度略降RoPE综合表现最好尤其是配合NTK缩放后可以支持更长的上下文。下面是我在实际对比时用过的简单对照表。位置编码方式外推能力训练稳定性实现复杂度主流应用绝对位置编码差较高低早期BERT、原始TransformerALiBi较强较高低部分长短文本模型RoPE中可配合缩放较高中GPT系、Llama系、Qwen、DeepSeek2.3 长度外推RoPE不是万能的很多人以为用了RoPE就可以无限长文本这是一个大误区。RoPE本身只是在训练长度范围内表现良好一旦推理长度超过训练长度注意力分数会变得异常生成质量迅速下降。我记得有一次用一个4K长度训练的模型去生成16K的文档前4K内容正常过了训练边界之后就开始出现重复片段和逻辑断裂。后来排查发现问题出在RoPE的旋转频率上训练时模型没有见过那么大的位置索引旋转角度变化得太快导致相对位置关系紊乱。解决方案有几种一是做位置插值把超出范围的位置索引压缩到训练范围内二是用NTK-aware缩放调整旋转频率的基数三是在微调阶段引入更长的序列。这些方案各有利弊位置插值简单但会损失细节NTK缩放需要调参直接延长训练序列成本最高但效果最自然。实际项目中我常用NTK搭配短序列微调修复速度快效果也最接近预期。3. 训练阶段的真实面貌补全任务、损失掩码与数据配比3.1 Decode-Only的训练目标就是“补全”Decode-Only模型的预训练目标只有一个给定左边所有Token预测右边下一个Token。这听起来简单但它的学习能力非常强因为语言本身就是一种高度结构化的信号模型通过海量文本的“完形填空式”学习能逐步掌握语法、事实、逻辑、甚至代码运行规则。训练时采用的损失函数是交叉熵损失。假设模型在位置t的真实Token是词表里第v个词模型预测的概率分布是p那么损失就是 -log p[v]。整个序列的损失是所有位置的损失平均值。值得强调的是这里说的“所有位置”是训练数据中的每个Token并不只计算句子结尾或某些特殊位置。这意味着一个长度为2048的样本一次前向计算就能产生2047个预测点监督信号密度非常高。这也是Decode-Only训练效率高的原因之一。3.2 损失掩码不是每个Token都值得训练在预训练阶段所有Token一视同仁。但进入指令微调阶段情况就变了。普通做法是把Prompt和Answer拼接成一段文本如果还是对所有Token算损失模型很可能在训练时过度优化Prompt的续写能力忽略了真正需要学习的答案部分。正确做法是给损失函数加一个掩码只计算Answer部分的Token损失Prompt部分的损失置为0。这么做能让模型把有限的参数容量集中在真正有用的信息上收敛更快表现也更稳定。我之前做SFT时漏掉损失掩码结果模型学会了“复述问题”对答案本身却没学会准确生成。加了掩码之后效果立刻改善。对于代码类的微调数据有时候我还习惯把注释部分也做掩码这样模型会把更多容量用去学代码逻辑本身训练出来的能力更精简。3.3 训练工程要点数据打包、学习率与显存取舍预训练阶段最常碰到的一个问题是句子有长有短如果按句子为单位padding到固定长度短样本会浪费大量计算。所以现在主流做法是序列打包把多条短文本首尾相接塞进固定长度的样本里样本之间用特殊分隔符隔开。这个过程要注意别让一条样本跨越两条不同文档的边界否则模型会学到“文档之间无缝衔接”的错误模式。我在训练小型模型时通常用这样的配置序列长度2048、批量大小32、学习率峰值3e-4、前1000步做warmup使用余弦衰减。如果显存不够先考虑梯度累积再考虑梯度检查点。梯度累积只是把优化器更新分成几次不节省总计算量但能解决显存不足的问题梯度检查点则是用额外前向计算换显存具体取舍要看你是计算密集还是显存紧张。数据配比也会明显影响模型能力。如果训练数据里90%是代码模型代码能力会很强但通用对话能力会退化。我一般按7:2:1的比例混合通用文本、代码和数学数据再用少量高质量指令数据做最后阶段的微调。这个比例不是公式需要根据下游任务不断调整。4. 推理优化的实战KV Cache、Prefill/Decode 与显存账本4.1 推理周期两头分Prefill 与 DecodeDecode-Only模型推理时有一个明显的“两头”结构。用户输入Prompt之后模型先对所有输入Token并行计算一遍自注意力得到每个位置对应的KV向量这个过程叫Prefill。它计算密集GPU算力利用率很高。之后模型进入逐Token生成的Decode阶段每次只计算最后一个位置期间只更新KV Cache前文所有历史KV都直接从缓存里读不需要重复计算。很多人不理解KV Cache为什么能加速核心原因就是它省掉了重复计算。没有缓存的话生成第100个Token时需要重新把所有前99个Token的K、V向量计算一遍复杂度从O(n^2)退化得更严重显存也会成倍增长。有了缓存每次解码只需要计算第100个Token本身的K、V并追加到缓存里时间复杂度降为O(n)。4.2 KV Cache的显存怎么估算KV Cache不是免费的它占用的显存可以精确计算。对于一个Transformer模型KV Cache大小公式是KV Cache大小 2 × 层数 × 注意力头维度 × 序列长度 × 批量大小 × 数据类型大小这里的“2”是因为每层都要分别存K和V。如果你用的是多头注意力那么注意力头维度等于单个头的维度如果是多头查询分组注意力GQAK和V的维度会更小这也是Mistral、Llama 2/3这些大模型引入GQA的原因。举个例子一个12层、隐藏层维度768、序列长度2048、批量大小32的模型KV Cache大约为2 × 12 × 768 × 2048 × 32 × 2字节 ≈ 2.4GB这只是一种估算方式实际还要加上模型参数、激活值、优化器状态等。做长文本推理时KV Cache会成为显存开销的大头。所以我通常建议先把KV Cache的占用算清再去决定最大并发batch是多少。如果盲目往上堆并发很容易出现显存溢出。4.3 并行策略与流式输出的取舍在推理场景Decode阶段单纯串行但可以通过批量处理多个请求来利用GPU的并行能力。批量的意思是一次解码多个用户的多个序列GPU把同一时刻所有请求的下一步Token一起算出来。批量越大计算单元的利用率越高但每个请求的Latency也会略有增加。这里需要根据业务需求的实时性来权衡对话类应用通常倾向于小批量、低延迟离线批量生成任务则适合大批量、高吞吐。流式输出也是Decode-Only推理的一个独特优势。因为生成是逐Token进行的模型每算出一个Token就可以立刻吐给前端用户看到的是一个字一个字蹦出来的效果实际体验比等整段生成完再显示要自然得多。我在做一个对话机器人项目时首Token延迟从3秒压到不到0.5秒用户反馈满意度提升非常明显这就是Prefill优化和流式输出叠加的效果。5. 从零搭一个1.5亿参数Decode-Only模型过程复盘5.1 模型配置的选择逻辑纸上谈兵再多不如直接动手跑一个。我最近用一个小型Decode-Only模型做实验配置大概是这样的12层Transformer隐藏层维度7688个注意力头每头维度64词表大小大约1.5万上下文长度1024。粗略算下来参数在1.4到1.6亿之间。为什么选这个规模因为单卡训练速度快可以反复试错也方便调试各种工程细节。如果一开始就上7B甚至13B模型一个bug可能要浪费几天的算力。先把小型模型跑通理解每个环节的行为再迁移到大模型这是最稳妥的路线。这个规模下我还专门把注意力改为GQAK和V的头数降到4个。虽然模型变大不多但KV Cache的显存占用直接少了一半推理速度也有可感知的提升。5.2 训练脚本与关键实现核心训练循环并不复杂重点在于掩码和位置编码的处理。这里我贴一段简化后的PyTorch风格实现方便你理解主干逻辑import torch import torch.nn as nn import torch.nn.functional as F class CausalSelfAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() self.n_heads n_heads self.head_dim d_model // n_heads self.wq nn.Linear(d_model, d_model) self.wk nn.Linear(d_model, d_model) self.wv nn.Linear(d_model, d_model) self.wo nn.Linear(d_model, d_model) def forward(self, x, kv_cacheNone): bsz, seq_len, d_model x.shape q self.wq(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) k self.wk(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) v self.wv(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) if kv_cache is not None: k torch.cat([kv_cache[0], k], dim2) v torch.cat([kv_cache[1], v], dim2) kv_cache (k, v) scores q k.transpose(-2, -1) / (self.head_dim ** 0.5) # 因果掩码右上三角置为负无穷 mask torch.triu(torch.ones(seq_len, seq_len, devicex.device), diagonal1).bool() scores scores.masked_fill(mask, float(-inf)) attn F.softmax(scores, dim-1) out attn v out out.transpose(1, 2).contiguous().view(bsz, seq_len, d_model) return self.wo(out), kv_cache实际训练时我会把RoPE函数放在注意力之前对q和k进行旋转。RoPE实现有现成库也可以手写核心思路是按位置计算角度并构造旋转矩阵然后对向量做旋转。5.3 观测损失与中间现象训练过程中我发现损失曲线有几个典型阶段刚开始几千步损失下降非常快这是因为模型在学词法和基础共现规律随后下降变缓进入语法结构和常识学习阶段等到损失趋于平台期再继续训练收益就变得很有限。这时候更需要关注的是验证集困惑度而不是单看训练损失。还有一个值得注意的现象在小规模数据上训练时模型很早就出现过拟合表现为训练损失持续下降验证集损失却不降甚至上升。解决办法包括加大Dropout、增加数据量、降低模型容量、以及加强权重衰减。我一般会把Dropout设到0.1左右并且用早停机制防止浪费时间在小数据上调不出好效果。6. 解Bug记录位置编码失效、重复生成与显存抖动6.1 位置编码在长文本上的隐性失效前文已经提过一次长度外推问题但这块在实际项目中非常容易踩坑值得单独展开。有一次我在一个文档摘要项目里把推理时的最大生成长度从2048调到了4096结果生成内容从第3000个Token开始明显崩坏。好看的是崩溃的并不是语法错误而是语义上的自我重复和话题漂移。排查过程我先检查了KV Cache是否有越界问题确认无误后开始怀疑位置编码。把RoPE的旋转角度打印出来发现超过训练长度后位置索引对应的旋转角度增长过快相邻Token的相对角度差变得异常大模型把这种异常理解成一种不该出现的信息注意力分布也就跟着乱了。修复方法是采用NTK-aware缩放扩大RoPE的base值让旋转频率变得更平缓。调参时我把base从10000调大到600000再用一段长文本做少量微调效果明显改善。这个过程说明位置编码不是“加了就完事”它与训练长度、缩放策略、微调数据共同决定模型能支持的有效上下文。6.2 生成阶段的重复退化贪心解码不是好选择Decode-Only模型在自动回归生成时如果直接每次都取概率最大的Token很容易陷入重复循环。比如模型反复生成“这家咖啡店很好很好很好很好”这样的内容乍一看通顺其实已经完全退化。原因在于语言模型建模的是条件概率当生成的序列已经很长时某些高频词组的条件概率会被不断放大导致模型在局部最优里打转。解决手段主要有三类温度采样、Top-k/Top-p采样、以及重复惩罚。策略作用注意事项温度参数调整概率分布平滑度温度过高输出天马行空过低趋于贪心Top-p采样只从累计概率超过阈值的Token中采样能保留多样性但不能完全解决重复重复惩罚对已出现Token的概率打折扣惩罚过重会伤害自然表达我实际调参时一般先用温度为0.7到0.9、Top-p为0.9的组合如果出现重复再叠加一个保守的重复惩罚系数。需要提醒的是不同任务适合的采样参数差异很大代码生成倾向于保守创意写作则需要更低的温度之外适度增加随机性。模型训练数据多样性越差退化问题越严重。6.3 显存OOM与解码速度的平衡Decode-Only模型训练和推理中最常见的崩溃就是CUDA Out of Memory。除了把批量调小还有一些很难查的小坑。第一梯度检查点开启后如果同时再做动态形状缩放可能导致中间激活的形状不匹配报错信息又隐晦原因是某些库版本对检查点函数限制比较多。第二混合精度训练时Loss容易出现NaN尤其是FP16下计算长序列时K和V向量会因为数值溢出出现问题。我的处理方式是使用BF16它的指数范围更大对大模型训练更友好。推理阶段的显存优化除了GQA和KV Cache外我还用过量化方案把权重从FP16量化到INT8或INT4。效果很显著比如推理显存占用可以下降到原来的四分之一但精度损失有限。如果做大规模并发服务量化几乎是必须走的路不过量化后的模型要重新跑一遍评测确认下游任务不掉点。还有一个容易被忽略的瓶颈Prefill阶段虽然算力利用率高但如果Prompt特别长中间激活值也会很大。长Prompt场景下我会优先考虑FlashAttention系列它既降低显存占用又提升计算速度。引入FlashAttention后我此前好几个卡在OOM边缘的项目都顺利跑起来了。回忆起这些调试过程最大的体会是Decode-Only架构的代码骨架非常简洁真正费时间的是在不同运行阶段维护好数值稳定性、显存边界和采样策略的平衡。只要你亲手把一个小模型从零训练起来再把它推到推理服务里对架构的理解会立刻上一个台阶。遇到位置编码失效、重复生成或者显存溢出时不要急着换大模型先按文章里的排查链路走一遍很多时候问题比想象中简单。希望这篇记录能帮你少走几步弯路把时间省下来去解决更核心的业务问题。