从零构建GPT:Python代码拆解Transformer与自回归语言模型
1. 项目概述从“ChatGPT背后的数学与程序”说起最近在GitHub上看到一个名为cccbook/py2gpt的项目这个标题本身就很有意思。它直指一个核心问题我们每天都在用ChatGPT这样的AI工具但有多少人真正理解驱动它的底层逻辑这个项目或者说这个主题试图用Python代码和数学原理去拆解和复现一个类似GPT模型的核心机制。这不仅仅是给研究者看的更是给所有对AI好奇、想知其所以然的开发者和技术爱好者的一份“地图”。我自己在接触大语言模型时也经历过从“调API”到“看论文”再到“动手实现”的过程。这个过程里最大的障碍往往不是代码本身而是那些隐藏在华丽交互界面背后的数学概念和工程权衡。py2gpt这个主题恰好切中了这个痛点。它意味着我们可以用相对轻量的方式去理解一个庞然大物是如何被构建起来的。这不仅仅是学习更是一种“祛魅”——让你明白再复杂的技术也是由一个个可理解的模块组合而成。那么这个内容适合谁呢如果你是一名程序员想超越调用openai.ChatCompletion.create的层面如果你是一名学生希望将深度学习理论落地为具体代码或者你只是一个充满好奇心的技术爱好者想弄明白新闻里天天说的“Transformer”、“注意力机制”到底是个啥——那么跟随这个思路走一遍将会大有裨益。我们将从最基础的数学原理和数据结构出发一步步搭建起一个能够“理解”和“生成”文本的微型大脑。这不是一个生产级的项目而是一个深度学习的“教学模拟器”和“原理验证机”。2. 核心架构与设计思路拆解要理解如何用Python构建一个GPT-like的模型我们首先得抛开对ChatGPT那种全能助手的既有印象回到最本质的问题一个语言模型究竟在做什么简单说它是在计算一个序列的下一个词出现的概率。给定“今天天气很”模型的任务是计算出“好”、“糟糕”、“热”等词作为下一个词的可能性。GPTGenerative Pre-trained Transformer就是干这个的佼佼者。2.1 自回归生成的核心GPT的核心是“自回归”Autoregressive。你可以把它想象成一个极其擅长“接龙”的专家。它每次只根据已经生成的所有上文预测下一个最可能的词或更准确地说token。然后把这个预测出的新词加到上文里继续预测下一个如此循环。这个过程决定了模型的基本工作流它是一个串行的、顺序的生成过程。在设计我们自己的py2gpt时这个特性会贯穿始终影响我们从数据预处理到模型推理的每一个环节。注意自回归生成虽然直观但有一个天然缺陷——无法在生成时“回头看”并修改之前的内容。这会导致一旦开头出现偏差后续可能会一路跑偏。在实际应用中会采用束搜索Beam Search等技术来缓解但最根本的架构决定了它是一次性的单向生成。2.2 Transformer解码器GPT的骨架GPT的模型骨架是Transformer架构但它是Transformer的“解码器”Decoder部分。为什么不用完整的编码器-解码器Encoder-Decoder结构呢像翻译这样的任务需要先“理解”源语言编码再“生成”目标语言解码。但GPT的目标是纯粹的生成它没有独立的“源文本”需要编码它的任务就是基于自己已经生成的部分持续生成下去。因此一个堆叠了多层、带有“掩码自注意力”机制的Transformer解码器就成了最合适的选择。掩码自注意力Masked Self-Attention是这个设计中的关键魔法。在训练时为了模拟“只能看到上文”的自回归特性我们需要确保在预测第i个位置时模型只能“注意”到第1到第i-1个位置的信息而不能“偷看”未来的信息。这个通过一个注意力掩码矩阵来实现它会在计算注意力权重时将未来位置的信息权重设置为负无穷经过Softmax后变为0。这是保证模型行为符合自回归设定的基石。2.3 从词到向量嵌入层的角色计算机无法直接理解文字所以第一步是把文字变成数字。这个过程比简单的索引复杂得多。我们通常使用“词元化”Tokenization将文本切割成更小的单元如单词、子词或字符然后为每个唯一的词元分配一个ID。但ID只是一个离散的标号缺乏语义信息。因此我们需要“嵌入层”Embedding Layer。它将每个词元ID映射为一个高维的、稠密的实数向量例如512维。你可以把这个向量想象成这个词在“语义空间”中的坐标。语义相近的词比如“猫”和“狗”它们的向量在空间中的距离会比较近。这个嵌入层是可学习的意味着在训练过程中模型会不断调整这些向量的位置让它们能更好地服务于最终的预测任务。在我们的实现中这将是一个简单的nn.Embedding模块。2.4 位置编码为序列注入顺序感注意力机制本身是“无序”的它对输入序列中所有词元的关系是并行计算的没有内置的顺序概念。但语言是有严格顺序的“猫追老鼠”和“老鼠追猫”意思截然不同。因此我们必须显式地告诉模型每个词在序列中的位置。Transformer使用“位置编码”Positional Encoding来解决这个问题。它不是通过可学习的参数而是使用一组固定的、基于正弦和余弦函数的公式来生成位置向量然后将其加到词嵌入向量上。这样不同位置就会有不同的、唯一的编码信号。关于为什么用正弦余弦而不是可学习的位置嵌入一个重要的观点是正弦余弦函数能天然地让模型学会相对位置关系即位置m和位置mk的关系是固定的这有助于模型泛化到比训练时更长的序列。3. 核心模块的代码级解析理解了设计思路我们就可以深入到代码层面看看每个核心模块是如何具体实现的。我们将使用PyTorch框架因为它动态图的特点非常适合教学和实验。3.1 词元化与词汇表构建在开始模型之前我们需要处理数据。假设我们有一本小说《三体》的文本作为训练数据。第一步是构建词汇表。import re from collections import Counter class SimpleTokenizer: def __init__(self, text, vocab_size5000): self.text text self.vocab_size vocab_size self.vocab {} self.inverse_vocab {} def train(self): # 1. 基础清洗和分割这里使用简单的空格和标点分割实际中会用更复杂的BPE等算法 words re.findall(r\w|[^\w\s], self.text.lower()) # 2. 统计词频 word_counts Counter(words) # 3. 选取最高频的词元构建词汇表并为特殊词元预留位置 most_common word_counts.most_common(self.vocab_size - 3) # 预留 [PAD], [UNK], [BOS], [EOS] 等 # 4. 构建映射 self.vocab {[PAD]: 0, [UNK]: 1, [BOS]: 2, [EOS]: 3} for idx, (word, _) in enumerate(most_common, startlen(self.vocab)): self.vocab[word] idx self.inverse_vocab {v: k for k, v in self.vocab.items()} def encode(self, sentence): # 将句子转换为ID列表 tokens re.findall(r\w|[^\w\s], sentence.lower()) return [self.vocab.get(token, self.vocab[[UNK]]) for token in tokens] def decode(self, ids): # 将ID列表转换回句子 return .join([self.inverse_vocab.get(i, [UNK]) for i in ids])实操心得在实际的GPT中使用的是字节对编码BPE或类似的子词切分算法。它能很好地平衡词汇表大小和未登录词OOV问题。对于我们这个教学项目简单的单词级分词足以说明问题但要知道工业级实现要复杂得多。另外[BOS]Begin of Sequence和[EOS]End of Sequence这两个特殊词元非常重要它们分别标识了序列的开始和结束是控制生成过程的信号。3.2 自注意力机制的实现这是Transformer的灵魂。我们来拆解它的计算步骤。import torch import torch.nn as nn import torch.nn.functional as F import math class SelfAttention(nn.Module): def __init__(self, embed_size, heads): super(SelfAttention, self).__init__() self.embed_size embed_size self.heads heads self.head_dim embed_size // heads assert self.head_dim * heads embed_size, Embed size needs to be divisible by heads # 通过线性变换生成Query, Key, Value矩阵 self.values nn.Linear(self.head_dim, self.head_dim, biasFalse) self.keys nn.Linear(self.head_dim, self.head_dim, biasFalse) self.queries nn.Linear(self.head_dim, self.head_dim, biasFalse) # 将多个头的输出拼接后做一次线性变换 self.fc_out nn.Linear(heads * self.head_dim, embed_size) def forward(self, values, keys, query, mask): N query.shape[0] # 批大小 value_len, key_len, query_len values.shape[1], keys.shape[1], query.shape[1] # 1. 分割嵌入维度到多个头 values values.reshape(N, value_len, self.heads, self.head_dim) keys keys.reshape(N, key_len, self.heads, self.head_dim) queries query.reshape(N, query_len, self.heads, self.head_dim) # 2. 计算注意力分数 Q * K^T energy torch.einsum(nqhd,nkhd-nhqk, [queries, keys]) # queries shape: (N, query_len, heads, head_dim) # keys shape: (N, key_len, heads, head_dim) # energy shape: (N, heads, query_len, key_len) # 3. 应用掩码如果是解码器 if mask is not None: energy energy.masked_fill(mask 0, float(-1e20)) # 4. 缩放并应用Softmax得到注意力权重 attention torch.softmax(energy / (self.embed_size ** (1/2)), dim3) # 5. 用注意力权重加权Value out torch.einsum(nhql,nlhd-nqhd, [attention, values]) # attention shape: (N, heads, query_len, key_len) # values shape: (N, value_len, heads, head_dim) # out shape: (N, query_len, heads, head_dim) # 6. 拼接多头输出并做最终线性变换 out out.reshape(N, query_len, self.heads * self.head_dim) out self.fc_out(out) return out为什么需要缩放公式中的缩放因子sqrt(d_k)d_k是Key的维度至关重要。点积注意力在d_k较大时点积的结果可能会变得非常大这将Softmax函数推入梯度极小的区域导致训练困难梯度消失。缩放操作就是为了稳定梯度。多头注意力的意义与其用一个头学习一种固定的注意力模式不如用多个头让模型同时关注来自不同表示子空间的信息。比如一个头可能专注于语法结构主谓宾另一个头可能专注于指代关系“它”指代什么。这种并行处理信息的能力是Transformer强大表征力的来源之一。3.3 构建Transformer解码器块一个解码器块通常包含掩码自注意力层、前馈神经网络FFN以及包围它们的层归一化LayerNorm和残差连接Residual Connection。class TransformerBlock(nn.Module): def __init__(self, embed_size, heads, dropout, forward_expansion): super(TransformerBlock, self).__init__() self.attention SelfAttention(embed_size, heads) self.norm1 nn.LayerNorm(embed_size) self.norm2 nn.LayerNorm(embed_size) self.feed_forward nn.Sequential( nn.Linear(embed_size, forward_expansion * embed_size), nn.ReLU(), nn.Linear(forward_expansion * embed_size, embed_size) ) self.dropout nn.Dropout(dropout) def forward(self, x, mask): # 1. 掩码自注意力子层带残差和归一化 attention self.attention(x, x, x, mask) x self.norm1(attention x) x self.dropout(x) # 2. 前馈网络子层带残差和归一化 forward self.feed_forward(x) x self.norm2(forward x) x self.dropout(x) return x层归一化与残差连接的作用这是训练深度网络的关键技巧。残差连接attention x允许梯度直接流过缓解了深度网络中的梯度消失问题。层归一化则对单个样本的所有特征进行归一化稳定了每一层的输入分布加速了训练收敛。Dropout则是一种正则化手段随机“关闭”一部分神经元防止模型过拟合。3.4 位置编码的实现如前所述我们使用正弦余弦位置编码。class PositionalEncoding(nn.Module): def __init__(self, embed_size, max_len5000): super(PositionalEncoding, self).__init__() pe torch.zeros(max_len, embed_size) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, embed_size, 2).float() * (-math.log(10000.0) / embed_size)) 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, embed_size) self.register_buffer(pe, pe) # 这不是可学习参数但需要随模型保存/加载 def forward(self, x): # x shape: (N, seq_len, embed_size) seq_len x.size(1) x x self.pe[:, :seq_len, :] return x这里的div_term计算是关键。公式是PE(pos, 2i) sin(pos / 10000^(2i/d_model))和PE(pos, 2i1) cos(pos / 10000^(2i/d_model))。使用指数函数torch.exp和对数math.log是为了数值稳定地计算10000^(2i/d_model)的倒数。这种编码方式保证了不同位置编码的独特性并且对于固定的偏移量kPE(posk)可以表示为PE(pos)的线性函数这有助于模型学习相对位置。4. 整合模型与训练流程现在我们将所有模块组装起来形成一个完整的GPT-like模型并定义其训练和生成过程。4.1 模型组装class DecoderOnlyGPT(nn.Module): def __init__(self, vocab_size, embed_size, num_layers, heads, forward_expansion, dropout, max_len): super(DecoderOnlyGPT, self).__init__() self.embed_size embed_size self.word_embedding nn.Embedding(vocab_size, embed_size) self.position_encoding PositionalEncoding(embed_size, max_len) self.layers nn.ModuleList([ TransformerBlock(embed_size, heads, dropout, forward_expansion) for _ in range(num_layers) ]) self.dropout nn.Dropout(dropout) self.fc_out nn.Linear(embed_size, vocab_size) # 输出层预测下一个词的logits def forward(self, x, mask): # x: (N, seq_len) seq_len x.shape[1] # 1. 词嵌入 位置编码 out self.dropout(self.word_embedding(x)) # (N, seq_len, embed_size) out self.position_encoding(out) # 2. 通过多个Transformer解码器块 for layer in self.layers: out layer(out, mask) # 3. 通过线性层输出每个位置对词汇表中所有词的“得分”logits out self.fc_out(out) # (N, seq_len, vocab_size) return out4.2 创建因果注意力掩码这是实现自回归特性的关键。我们需要一个下三角矩阵包含对角线使得每个位置只能看到自身及之前的位置。def create_causal_mask(seq_len): # 创建一个形状为 (seq_len, seq_len) 的下三角矩阵对角线为1 mask torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, seq_len) # mask[i][j] 1 表示在计算位置i的注意力时允许看到位置j的信息。 return mask在训练时我们将这个掩码应用到注意力分数的计算上如SelfAttention.forward中所示未来位置的信息就会被屏蔽。4.3 训练循环与损失函数我们使用标准的语言模型训练方式给定一个序列预测下一个词。import torch.optim as optim from torch.utils.data import DataLoader, Dataset class TextDataset(Dataset): def __init__(self, text, tokenizer, seq_len): self.tokenizer tokenizer self.seq_len seq_len self.data tokenizer.encode(text) self.vocab_size len(tokenizer.vocab) def __len__(self): return len(self.data) - self.seq_len def __getitem__(self, idx): # 取一段长度为seq_len1的文本前seq_len个作为输入最后一个作为目标 chunk self.data[idx: idx self.seq_len 1] input_ids torch.tensor(chunk[:-1], dtypetorch.long) target_ids torch.tensor(chunk[1:], dtypetorch.long) # 目标是输入向右偏移一位 return input_ids, target_ids # 训练配置 vocab_size 5000 embed_size 256 num_layers 6 heads 8 forward_expansion 4 dropout 0.1 max_len 100 seq_len 50 batch_size 32 learning_rate 3e-4 epochs 10 device torch.device(cuda if torch.cuda.is_available() else cpu) # 初始化组件 tokenizer SimpleTokenizer(your_text_data) tokenizer.train() dataset TextDataset(your_text_data, tokenizer, seq_len) dataloader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) model DecoderOnlyGPT(vocab_size, embed_size, num_layers, heads, forward_expansion, dropout, max_len).to(device) criterion nn.CrossEntropyLoss(ignore_indextokenizer.vocab[[PAD]]) # 忽略填充位置 optimizer optim.Adam(model.parameters(), lrlearning_rate) # 训练循环 model.train() for epoch in range(epochs): total_loss 0 for batch_idx, (input_ids, target_ids) in enumerate(dataloader): input_ids, target_ids input_ids.to(device), target_ids.to(device) batch_size, cur_seq_len input_ids.shape # 创建因果掩码 mask create_causal_mask(cur_seq_len).to(device) # 前向传播 outputs model(input_ids, mask) # (N, seq_len, vocab_size) # 将输出reshape为 (N*seq_len, vocab_size)目标reshape为 (N*seq_len) loss criterion(outputs.reshape(-1, vocab_size), target_ids.reshape(-1)) # 反向传播 optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) # 梯度裁剪防止爆炸 optimizer.step() total_loss loss.item() avg_loss total_loss / len(dataloader) print(fEpoch [{epoch1}/{epochs}], Loss: {avg_loss:.4f})损失函数解读我们使用交叉熵损失CrossEntropyLoss。模型对序列中每个位置都输出一个vocab_size维的向量logits表示该位置是词汇表中每个词的可能性。目标target是输入序列向右移动一位后的序列。我们计算的是每个位置预测下一个词的损失的平均值。ignore_index参数用于忽略填充符[PAD]对应的位置不参与损失计算。梯度裁剪在训练RNN和Transformer这类深度序列模型时梯度爆炸是一个常见问题。clip_grad_norm_将所有权重梯度的L2范数限制在一个阈值内这里是1.0这是一个非常实用的稳定训练的技巧。4.4 文本生成推理过程训练完成后模型就可以用来生成文本了。生成是一个自回归的循环过程。def generate_text(model, tokenizer, prompt, max_gen_len50, temperature1.0, top_k50): model.eval() with torch.no_grad(): # 编码初始提示 input_ids torch.tensor([tokenizer.vocab[[BOS]]] tokenizer.encode(prompt)).unsqueeze(0).to(device) generated input_ids.tolist()[0][1:] # 去掉[BOS]记录生成的词元 for _ in range(max_gen_len): cur_seq_len input_ids.shape[1] mask create_causal_mask(cur_seq_len).to(device) # 获取模型对下一个词的预测logits outputs model(input_ids, mask) # (1, cur_seq_len, vocab_size) next_token_logits outputs[:, -1, :] / temperature # 取最后一个位置的输出并应用温度调节 # Top-k采样只从概率最高的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 F.softmax(next_token_logits, dim-1) # 根据概率分布采样下一个词元ID next_token_id torch.multinomial(probs, num_samples1) # 如果生成了[EOS]则停止生成 if next_token_id.item() tokenizer.vocab[[EOS]]: break # 将新生成的词元ID添加到输入序列中准备下一次预测 generated.append(next_token_id.item()) input_ids torch.cat([input_ids, next_token_id], dim1) # 解码生成的ID序列为文本 return tokenizer.decode(generated) # 使用示例 prompt 人类历史上 generated_text generate_text(model, tokenizer, prompt, max_gen_len100, temperature0.8, top_k40) print(fPrompt: {prompt}) print(fGenerated: {generated_text})生成策略详解贪婪搜索 vs 采样如果每次都选择概率最高的词argmax就是贪婪搜索结果往往单调重复。采样从概率分布中随机选取能增加多样性。温度Temperaturetemperature参数控制采样的随机性。temperature 1.0使用原始logitstemperature 1.0会平滑分布放大低概率词增加随机性和创造性temperature 1.0会锐化分布让高概率词更高使输出更确定、更保守。Top-k采样这是对采样空间的一种约束。它只从概率最高的k个候选词中采样排除了那些概率极低的“荒谬”选项能在保证多样性的同时提高生成质量。top_k50是一个常用值。自回归循环注意每次生成新词后整个输入序列包括新词会再次送入模型以预测下一个词。这就是“自回归”的体现。5. 关键数学原理深度剖析代码实现背后是坚实的数学基础。理解这些原理才能更好地调参和诊断问题。5.1 注意力机制的数学表达注意力机制的核心公式如下Attention(Q, K, V) softmax( (Q * K^T) / sqrt(d_k) ) * VQ (Query), K (Key), V (Value)这三个矩阵来源于同一个输入X的线性变换X * W_Q,X * W_K,X * W_V。Query代表“我想要什么”Key代表“我有什么”Value是“我实际提供的信息”。Q * K^T计算查询和键的相似度点积得到一个注意力分数矩阵。分数越高表示该Key对应的Value与当前Query越相关。除以 sqrt(d_k)缩放操作防止点积结果过大导致Softmax梯度消失。Softmax将分数矩阵的每一行归一化为概率分布表示对于当前Query应该“注意”各个Key的权重。乘以 V用注意力权重对Value矩阵进行加权求和得到最终的输出。输出中的每个位置都是所有Value信息的加权融合权重由该位置Query与所有Key的相似度决定。为什么有效这种机制让模型能够动态地、根据内容Content-based来决定关注输入的哪一部分而不是像RNN那样依赖固定的顺序。这对于处理长距离依赖关系比如段落开头和结尾的照应至关重要。5.2 前馈神经网络的非线性变换Transformer块中的前馈网络FFN是一个简单的两层全连接网络FFN(x) ReLU(x * W1 b1) * W2 b2。它的作用是在每个位置独立地进行非线性特征变换和升维/降维。forward_expansion参数通常为4定义了中间层的维度是嵌入维度的多少倍。这个“瓶颈”结构先升维再降维被证明能提供更强的非线性表征能力是Transformer模型容量capacity的重要组成部分。5.3 损失函数交叉熵与语言建模我们使用的交叉熵损失本质上是衡量模型预测的概率分布p_model与真实的“one-hot”分布p_true之间的差距。对于语言模型p_true在目标词的位置为1其余为0。Loss - Σ p_true * log(p_model)在训练中我们最小化这个损失即让模型预测的下一个词的概率分布尽可能地向真实的那个词集中。由于Softmax函数将logits转换为概率而交叉熵损失直接作用于logitsnn.CrossEntropyLoss内部包含了Softmax这使得梯度计算更加数值稳定。5.4 反向传播与梯度流在Transformer中梯度需要通过注意力机制、层归一化和残差连接进行反向传播。残差连接它创建了一条“高速公路”让梯度可以直接从深层流回浅层极大地缓解了梯度消失问题。公式y F(x) x的导数是dy/dx dF/dx 1这个“1”保证了即使dF/dx很小梯度也不会完全消失。层归一化它对激活值进行归一化使其均值为0方差为1。这稳定了每一层输入的分布使得网络对学习率、初始权重等超参数不那么敏感训练过程更平滑、更快。注意力机制中的梯度梯度会通过Softmax和矩阵乘法传播到Q、K、V的投影矩阵。多头机制使得梯度来源更加多样化有助于更均衡地更新参数。6. 实战调试与经验心得纸上得来终觉浅绝知此事要躬行。在动手实现和训练这样一个模型时会遇到许多教程里不会细说的坑。6.1 常见问题与排查清单问题现象可能原因排查与解决方法训练损失不下降NaN1. 学习率过高。2. 梯度爆炸。3. 数据中存在异常值或未登录词过多。1. 尝试降低学习率如从3e-4降到1e-4。2. 添加梯度裁剪clip_grad_norm_。3. 检查数据预处理确保词元化合理[UNK]比例不要过高。训练损失下降缓慢1. 学习率过低。2. 模型容量太小嵌入维度、层数、头数不足。3. 批次大小太小。1. 尝试增大学习率或使用学习率热身Warmup。2. 适当增加embed_size、num_layers。3. 在显存允许范围内增大batch_size。生成文本重复、无意义1. 训练不充分epoch太少。2. 温度参数过低或采样策略太贪婪。3. 训练数据量太少或质量差。1. 增加训练轮数观察验证集损失是否已收敛。2. 提高temperature如0.8-1.2或尝试Top-p核采样。3. 使用更大、更干净、更多样化的语料。生成文本无法结束未正确识别[EOS]词元或[EOS]在训练数据中出现频率太低。1. 确保在训练数据中合理插入[EOS]标记如每段结尾。2. 在生成函数中强制设置最大生成长度max_gen_len作为保险。显存溢出OOM1. 序列长度seq_len或批次大小batch_size太大。2. 模型参数量太大。1. 减小seq_len或batch_size。注意力计算复杂度是O(seq_len^2)对长序列非常消耗显存。2. 使用梯度累积Gradient Accumulation来模拟大批次。6.2 参数选择与调优经验嵌入维度 (embed_size): 这是模型表征能力的基石。对于小规模实验词汇表1万256或512是一个不错的起点。它与最终线性层的参数数量embed_size * vocab_size直接相关是内存消耗的大头。头数 (heads): 通常设置为嵌入维度能被整除的数如8或16。更多的头意味着模型可以同时关注更多类型的关系但也会增加计算量。经验上embed_size512时heads8是常见配置。层数 (num_layers): 决定了模型的深度。更深的模型能力更强但也更难训练梯度消失/爆炸、更慢、更耗内存。对于学习性质的py2gpt6层已经足够演示原理。真正的GPT-3有96层甚至更多。学习率: Transformer模型通常对学习率很敏感。Adam优化器下3e-4是一个经典的初始值。**使用学习率热身Warmup**是非常有效的技巧在训练初期如前4000步将学习率从0线性增加到设定值然后再缓慢衰减。这能让模型在初期稳定地进入训练状态。Dropout: 用于防止过拟合。在嵌入层后和每个子层注意力、FFN后都可以加。对于小数据集可以设高一点如0.2对于大数据集可以设低一点如0.1甚至不用。6.3 数据处理的魔鬼细节文本清洗去除无关字符、统一大小写很重要。但对于创意写作保留部分标点风格可能更有益。序列长度训练时seq_len的选择很重要。太短模型学不到长距离依赖太长训练慢且易OOM。需要根据数据特性选择。可以统计训练文本的句子或段落长度分布选择一个覆盖大部分情况的长度如95%分位数。批次生成在DataLoader中由于文本被切成固定长度的片段最后一个不完整的批次需要处理。可以使用drop_lastTrue直接丢弃或者进行填充Padding至相同长度。填充时务必使用[PAD]词元并在损失函数中忽略它们。6.4 超越基础进阶技巧尝试当你的基础模型能跑通后可以尝试以下进阶改进这能让你更贴近现代LLM的实践学习率调度器使用CosineAnnealingLR或带热身的CosineAnnealingWarmRestarts比固定学习率效果更好。权重初始化Transformer参数的正确初始化很重要。可以尝试nn.init.xavier_uniform_或nn.init.normal_(mean0.0, std0.02)后者是GPT系列常用的。更先进的优化器AdamWAdam with decoupled weight decay比标准的Adam通常表现更好因为它将权重衰减与梯度更新解耦。混合精度训练使用torch.cuda.amp进行自动混合精度训练可以显著减少显存占用并加快训练速度几乎不影响精度。检查点与继续训练定期保存模型检查点包括模型参数、优化器状态、当前epoch等以便从中断处恢复训练或进行模型选择。通过这样一个从数学原理到代码实现再到调试心得的完整过程我们不仅仅是复现了一个模型更是建立了一套理解和构建自回归生成式语言模型的方法论。这个微型的py2gpt项目就像一把钥匙帮你打开了理解当今大语言模型奥秘的第一道门。剩下的就是在更庞大的数据、更复杂的工程和更深刻的洞察中继续探索了。