
1. 为什么“手搓”大语言模型要从结构开始如果你和我一样对ChatGPT、Claude这些大语言模型LLM背后的原理感到好奇并且不止于调用API而是想亲手“造”一个出来那么恭喜你你找对地方了。很多人一上来就想搞懂几万亿参数、复杂的分布式训练结果往往在第一步——理解模型到底长什么样——就卡住了。这就像想盖摩天大楼却连一张清晰的结构图纸都没有后续的砖瓦数据、施工训练、装修部署都无从谈起。所以我们这篇内容就聚焦在“模型结构”这张最核心的图纸上。为什么是结构因为它是大语言模型的骨架和灵魂。它定义了模型如何“思考”如何接收你的问题如何在内部一步步处理信息又如何生成你看到的答案。理解了结构你就理解了LLM能力的边界和上限。今天我们不谈那些动辄千亿参数的庞然大物我们就从一个最经典、最核心的“Decoder-Only”的Transformer架构开始把它从图纸变成可以运行的代码。我会带你一步步拆解用最直白的语言和代码让你看清每一个部件的模样和作用。2. 大语言模型的“心脏”Transformer架构精讲要理解现代大语言模型Transformer是你绕不开的基石。它于2017年由谷歌团队在《Attention Is All You Need》这篇论文中提出彻底改变了自然语言处理的格局。简单来说它用“注意力机制”完全取代了传统的循环神经网络RNN和卷积神经网络CNN解决了长距离依赖和并行计算的难题。2.1 注意力机制模型如何“聚精会神”想象一下你在读一本复杂的小说要理解当前这句话你可能需要回想前面几页甚至几章的情节。注意力机制就是让模型具备这种“回想”和“聚焦”的能力。它不是一个模糊的概念而是一套精确的数学计算。核心公式Scaled Dot-Product Attention这个公式是注意力机制的核心Attention(Q, K, V) softmax(QK^T / sqrt(d_k)) V别被吓到我们一步步拆解Q (Query - 查询)代表当前需要被处理的词或位置发出的“询问”。比如模型在处理“苹果”这个词时它会问“我和句子里的其他词有什么关系”K (Key - 键)代表句子中所有词的“身份标识”。每个词都有一个Key用来和Query进行匹配。V (Value - 值)代表每个词所携带的“实际信息内容”。计算过程匹配度计算 (QK^T)计算当前Query和所有Key的匹配分数点积。分数越高说明当前词和那个词的关系越密切。缩放 (除以 sqrt(d_k))为了防止点积结果过大导致softmax梯度消失用一个基于Key向量维度d_k的因子进行缩放这是一个非常实用的技巧。归一化 (softmax)将所有匹配分数转化为概率分布总和为1。这样模型就知道应该“分配”多少注意力给每个词。加权求和 (乘以 V)用上一步得到的注意力权重对所有的Value进行加权求和。最终输出就是一个融合了上下文相关信息的新的向量表示。多头注意力 (Multi-Head Attention)这是Transformer的另一个神来之笔。与其只做一次注意力计算不如把输入向量投影到多个不同的“子空间”即多个头在每个子空间里独立进行注意力计算最后把结果拼接起来。这好比让多个专家从不同角度语法、语义、情感等同时分析句子最后综合所有人的意见使得模型能捕捉更丰富的关系。import torch import torch.nn as nn import math class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads 0, “d_model must be divisible by num_heads” self.d_model d_model # 模型总维度例如 512 self.num_heads num_heads # 注意力头的数量例如 8 self.d_k d_model // num_heads # 每个头的维度例如 64 # 定义线性变换层用于生成Q, K, V self.W_q nn.Linear(d_model, d_model) # 输入d_model维输出d_model维 self.W_k nn.Linear(d_model, d_model) self.W_v nn.Linear(d_model, d_model) self.W_o nn.Linear(d_model, d_model) # 输出投影层 def forward(self, query, key, value, maskNone): batch_size query.size(0) # 1. 线性投影并分头 # 输入形状: (batch_size, seq_len, d_model) # 经过线性层后形状不变然后重塑为 (batch_size, seq_len, num_heads, d_k) # 最后转置为 (batch_size, num_heads, seq_len, d_k) 以便并行计算每个头 Q self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) K self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) V self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) # 2. 计算缩放点积注意力 # Q * K^T 的形状: (batch_size, num_heads, seq_len_q, seq_len_k) scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: # 将mask中为True的位置需要被掩盖替换为一个极小的负数这样softmax后概率接近0 scores scores.masked_fill(mask 0, -1e9) attn_weights torch.softmax(scores, dim-1) # 在最后一个维度seq_len_k上做softmax # 3. 应用注意力权重到V上 # 输出形状: (batch_size, num_heads, seq_len_q, d_k) context torch.matmul(attn_weights, V) # 4. 合并多头输出 # 转置回 (batch_size, seq_len_q, num_heads, d_k)然后合并最后两个维度 context context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) # 5. 最终线性投影 output self.W_o(context) return output, attn_weights注意上面的代码中mask参数至关重要。在训练时我们通常使用“因果掩码”Causal Mask确保模型在预测第t个词时只能看到1到t-1的词而不能看到未来的词这是生成式模型的核心约束。2.2 前馈网络与残差连接稳定训练的“双保险”注意力层之后通常会接一个前馈神经网络Feed-Forward Network, FFN。它本质上是两个线性变换夹着一个非线性激活函数如ReLU或GELU作用在每一个位置token上独立且相同。class PositionwiseFeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout0.1): super().__init__() self.linear1 nn.Linear(d_model, d_ff) # 扩张例如 512 - 2048 self.linear2 nn.Linear(d_ff, d_model) # 收缩回原维度 2048 - 512 self.dropout nn.Dropout(dropout) self.activation nn.GELU() # 现代LLM常用GELU比ReLU更平滑 def forward(self, x): # x形状: (batch_size, seq_len, d_model) return self.linear2(self.dropout(self.activation(self.linear1(x))))但仅仅堆叠层数模型会面临梯度消失或爆炸的问题。Transformer引入了残差连接Residual Connection和层归一化Layer Normalization。残差连接将某一层的输入直接加到其输出上即输出 F(输入) 输入。这创建了一条“高速公路”让梯度可以直接回流极大地缓解了深层网络训练难题。层归一化对单个样本的所有特征进行归一化与批归一化BN不同使其均值为0方差为1。这稳定了每层的输入分布加速训练。通常一个Transformer子层如注意力层或FFN层会包装成如下形式class SublayerConnection(nn.Module): 残差连接后接层归一化。 def __init__(self, size, dropout): super().__init__() self.norm nn.LayerNorm(size) self.dropout nn.Dropout(dropout) def forward(self, x, sublayer): # 核心思想x Dropout(Sublayer(LayerNorm(x))) # 注意先归一化再经过子层再加回原始输入 return x self.dropout(sublayer(self.norm(x)))2.3 位置编码为无序的模型注入顺序感自注意力机制本身是对位置不敏感的打乱输入词的顺序其输出的两两关系权重不变。但语言是有顺序的“猫追老鼠”和“老鼠追猫”意思完全不同。因此我们需要位置编码Positional Encoding。最经典的是使用正弦和余弦函数来生成固定编码class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000, dropout0.1): super().__init__() self.dropout nn.Dropout(pdropout) pe torch.zeros(max_len, d_model) position torch.arange(0, max_len).unsqueeze(1) # (max_len, 1) # 计算除数项10000^(2i/d_model)使用对数空间计算更稳定 div_term torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) # 偶数维度用sin pe[:, 1::2] torch.cos(position * div_term) # 奇数维度用cos pe pe.unsqueeze(0) # (1, max_len, d_model) 方便广播 self.register_buffer(pe, pe) # 注册为缓冲区不参与训练 def forward(self, x): # x形状: (batch_size, seq_len, d_model) x x self.pe[:, :x.size(1)] # 只取前seq_len个位置编码 return self.dropout(x)这种编码的好处是对于任意固定的偏移量kPE(posk)可以表示为PE(pos)的线性函数这使得模型能够轻松学习到相对位置信息。现代大模型如GPT-3更多使用可学习的绝对位置编码或更复杂的相对位置编码如RoPE, ALiBi但正弦余弦版本是理解其思想的绝佳起点。3. Decoder-Only当今大语言模型的绝对主流原始的Transformer包含编码器Encoder和解码器Decoder两部分用于机器翻译等序列到序列任务。但当今绝大多数的大语言模型GPT系列、LLaMA、PaLM等都采用了Decoder-Only架构。为什么编码器 vs. 解码器的核心区别编码器采用双向注意力在计算某个词的表示时可以同时看到它左边和右边的所有词。适合做“理解”类任务如文本分类、情感分析。解码器采用带掩码的自注意力即上文提到的因果掩码只能看到当前词及左边的词。适合做“生成”类任务如文本续写、对话。大语言模型的核心能力是自回归生成给定一段上文预测下一个最可能的词如此循环往复。这天然契合解码器的工作模式。因此舍弃编码器堆叠多层解码器就成了最简洁、最有效的架构选择。一个Decoder-Only的Transformer层通常包含一个带掩码的多头自注意力层和一个前馈网络层每层外面都包裹着残差连接和层归一化。4. 从零搭建一个微型Decoder-Only Transformer理论说了这么多是时候动手了。我们将搭建一个超小规模的Decoder-Only模型它虽然“玩具”但五脏俱全能让你彻底理解数据是如何流动的。4.1 定义单个解码器层首先我们定义一个解码器层它包含掩码多头注意力和前馈网络。class DecoderLayer(nn.Module): def __init__(self, d_model, num_heads, d_ff, dropout): super().__init__() self.self_attn MultiHeadAttention(d_model, num_heads) self.feed_forward PositionwiseFeedForward(d_model, d_ff, dropout) self.sublayer1 SublayerConnection(d_model, dropout) # 用于注意力子层 self.sublayer2 SublayerConnection(d_model, dropout) # 用于前馈子层 self.dropout nn.Dropout(dropout) def forward(self, x, mask): # x形状: (batch_size, seq_len, d_model) # mask形状: (batch_size, 1, seq_len, seq_len) 或 (batch_size, seq_len, seq_len) # 第一个子层带掩码的自注意力 attn_output, _ self.self_attn(x, x, x, mask) x self.sublayer1(x, lambda _x: attn_output) # 使用lambda包装 # 第二个子层前馈网络 x self.sublayer2(x, self.feed_forward) return x4.2 组装完整的Decoder-Only模型接着我们将多个解码器层堆叠起来加上词嵌入、位置编码和最后的输出层。class MiniGPT(nn.Module): def __init__(self, vocab_size, d_model512, num_layers6, num_heads8, d_ff2048, max_seq_len512, dropout0.1): super().__init__() self.d_model d_model self.token_embedding nn.Embedding(vocab_size, d_model) self.positional_encoding PositionalEncoding(d_model, max_seq_len, dropout) self.decoder_layers nn.ModuleList([ DecoderLayer(d_model, num_heads, d_ff, dropout) for _ in range(num_layers) ]) self.final_norm nn.LayerNorm(d_model) # 输出层将隐藏状态投影回词汇表大小用于计算每个词的概率 self.lm_head nn.Linear(d_model, vocab_size, biasFalse) # 一个常用技巧将输出层的权重与嵌入层的权重共享可以减少参数量并可能提升效果 # self.lm_head.weight self.token_embedding.weight self.dropout nn.Dropout(dropout) self._init_parameters() def _init_parameters(self): 参数初始化对训练稳定性很重要 for p in self.parameters(): if p.dim() 1: nn.init.xavier_uniform_(p) def forward(self, input_ids, maskNone): # input_ids形状: (batch_size, seq_len) # 1. 词嵌入 x self.token_embedding(input_ids) # (batch_size, seq_len, d_model) # 乘以sqrt(d_model)是一种缩放帮助稳定训练 x x * math.sqrt(self.d_model) # 2. 加入位置信息 x self.positional_encoding(x) x self.dropout(x) # 3. 如果没有提供mask生成一个因果掩码 if mask is None: batch_size, seq_len input_ids.shape # 生成一个下三角矩阵包含对角线True表示允许注意力 causal_mask torch.tril(torch.ones(seq_len, seq_len)).type(torch.bool) causal_mask causal_mask.unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, seq_len) mask causal_mask # 4. 逐层通过解码器 for layer in self.decoder_layers: x layer(x, mask) # 5. 最终层归一化 x self.final_norm(x) # 6. 投影到词汇表得到每个位置对每个词的未归一化分数logits logits self.lm_head(x) # (batch_size, seq_len, vocab_size) return logits4.3 生成因果掩码与推理过程模型的前向传播forward用于训练或计算损失。但在推理生成文本时我们需要一个自回归的循环过程。def generate_text(model, tokenizer, prompt, max_new_tokens50, temperature1.0, top_k50): 使用模型自回归地生成文本。 model: 训练好的模型 tokenizer: 分词器用于将文本转为id以及将id转回文本 prompt: 起始文本 max_new_tokens: 最多生成的新token数量 temperature: 温度参数1.0增加随机性1.0增加确定性 top_k: 只从概率最高的k个token中采样 model.eval() # 切换到评估模式 with torch.no_grad(): # 不计算梯度节省内存和计算 # 将提示文本编码为token id input_ids tokenizer.encode(prompt, return_tensorspt) # 形状: (1, seq_len) generated input_ids for _ in range(max_new_tokens): # 获取当前序列的logits。注意每次生成新token都需要用完整的当前序列 logits model(generated) # 形状: (1, current_seq_len, vocab_size) # 取最后一个位置的logits即基于当前所有上文预测的下一个token next_token_logits logits[:, -1, :] / temperature # 形状: (1, vocab_size) # Top-k采样 if top_k is not None: indices_to_remove next_token_logits torch.topk(next_token_logits, top_k)[0][..., -1, None] next_token_logits[indices_to_remove] -float(Inf) # 将logits转换为概率 probs torch.softmax(next_token_logits, dim-1) # 形状: (1, vocab_size) # 根据概率采样下一个token id next_token_id torch.multinomial(probs, num_samples1) # 形状: (1, 1) # 将新生成的token拼接到序列后 generated torch.cat([generated, next_token_id], dim-1) # 如果生成了结束符如果有的话可以提前停止 # if next_token_id.item() tokenizer.eos_token_id: # break # 将生成的token id序列解码回文本 generated_text tokenizer.decode(generated[0].tolist()) return generated_text这个简单的生成函数展示了自回归的核心每次预测一个词将其加入上下文再预测下一个如此循环。5. 模型结构设计中的核心权衡与实战心得搭建一个能跑的模型只是第一步。要让模型真正强大需要在结构设计上做大量细致的权衡。这里分享几个关键点。5.1 模型规模的三要素深度、宽度与注意力头模型的能力很大程度上由三个维度决定深度Depth层数num_layers。层数越多模型的表示能力和非线性变换能力越强但训练也更困难更容易过拟合。现代大模型动辄数十甚至上百层。宽度Width隐藏层维度d_model。决定了每层能承载的信息量。维度越大模型容量越大。注意力头数Num Heads在总维度d_model固定的情况下头数决定了模型并行关注不同模式的能力。通常d_model需要能被num_heads整除。经验法则在计算资源有限的情况下增加深度通常比增加宽度更能提升模型能力在一定范围内。头数一般设置为8、16等确保每个头的维度d_k d_model / num_heads不要太小如低于64否则注意力机制可能无法有效工作。5.2 前馈网络维度模型容量的“放大器”前馈网络的中间维度d_ff通常设置为d_model的4倍如512 - 2048。这是一个经过大量实验验证的“甜点”。它远大于d_model为模型提供了强大的非线性变换空间是模型参数的主要组成部分之一。有时也会看到2倍或更大的设置需要根据具体任务和规模调整。5.3 层归一化的位置Pre-LN vs. Post-LN在我们上面的代码中我们采用了Pre-LayerNorm (Pre-LN)结构即在子层注意力、FFN之前进行层归一化。这是当前LLM的主流选择如GPT-3、LLaMA。# Pre-LN: x Sublayer(LayerNorm(x)) output x self.dropout(sublayer(self.norm(x)))另一种是原始论文中的Post-LayerNorm (Post-LN)在子层之后进行归一化。# Post-LN: LayerNorm(x Sublayer(x)) output self.norm(x self.dropout(sublayer(x)))区别与选择Post-LN原始Transformer使用。在非常深的网络中如100层训练初期可能不稳定需要精细的权重初始化和学习率预热。Pre-LN将归一化放在残差分支里使得主通路恒等映射更顺畅梯度流动更好训练更稳定更容易堆叠深层网络。因此对于我们要“手搓”的模型强烈推荐使用Pre-LN。5.4 位置编码的演进从绝对到相对我们实现的正弦余弦编码是绝对位置编码。但它有局限性训练时见过的最大长度限制了推理时的长度。因此现代大模型多采用相对位置编码。RoPE (Rotary Position Embedding)通过旋转矩阵将绝对位置信息融入注意力计算中的Q和K向量。LLaMA、GPT-NeoX等模型使用。它具有良好的外推性即能处理比训练时更长的序列。ALiBi (Attention with Linear Biases)不在嵌入中加位置信息而是在注意力分数上直接加一个与相对距离成负比例的偏置。方法更简单外推性极佳。对于初学者理解绝对位置编码足以入门。但在设计一个严肃的模型时选择RoPE或ALiBi是更优的选择。5.5 实战中的“坑”与技巧初始化至关重要使用Xavier或Kaiming初始化方法如我们代码中的_init_parameters来初始化线性层和嵌入层的权重这是训练能够顺利开始的保证。错误的初始化可能导致梯度爆炸或消失。梯度裁剪Gradient Clipping在训练循环中计算完梯度后使用torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)将梯度范数裁剪到一个阈值内可以防止训练因梯度爆炸而崩溃。学习率预热Learning Rate Warmup训练初期模型参数是随机初始化的直接使用较大的学习率可能导致不稳定。通常在前几千个训练步中将学习率从0线性或余弦增加到预设值能显著提升稳定性。检查你的掩码因果掩码错误是生成乱码的常见原因。务必确保掩码是下三角矩阵并且在推理时正确传递。一个简单的检查方法是用一个很短的序列如“Hello world”让模型生成看它是否只是在重复输入或产生完全随机的输出。理解你的输出模型forward返回的logits是未归一化的分数。要得到概率需要对其应用softmax。计算损失如交叉熵损失时PyTorch的nn.CrossEntropyLoss已经内部处理了softmax所以直接传入logits和标签即可不要手动加softmax。6. 超越基础现代大语言模型的结构演进我们搭建的“MiniGPT”是一个最简化的Decoder-Only模型。而像GPT-4、Claude、LLaMA这样的先进模型在基础结构上做了大量优化RMSNorm替代LayerNorm。去掉了中心化减均值只做缩放计算更简单效果相当甚至更好被LLaMA等模型采用。SwiGLU / GEGLU激活函数替代标准的ReLU/GELU在前馈网络中使用。通过门控机制如Swish(x) * (xW)来增加非线性被证明效果更优。分组查询注意力GQA与多查询注意力MQA为了提升推理时的内存效率和速度让多个注意力头共享同一份K和V向量。这能显著减少KV缓存的大小是推理优化的关键技术。MoEMixture of Experts架构不是每个输入都激活所有参数。模型内部包含多个“专家”子网络每层有一个路由机制只为当前输入激活少数几个专家。这能在参数总量巨大的情况下保持实际计算量可控是通往万亿参数模型的关键路径之一。理解这些演进能让你从“知其然”到“知其所以然”明白工业级模型是如何在基础Transformer之上进行极致优化的。动手实现一遍哪怕是最小的模型你对Transformer的理解也会远超仅仅阅读论文。下一步你可以尝试用这个模型结构在一个小数据集如TinyStories上进行训练观察它的学习过程。然后再逐步引入更现代的技术如RoPE、RMSNorm等。模型结构是地基打牢了后面关于数据、训练、评估、部署的大厦才能稳固地建立起来。