1. 项目概述为什么26M参数的GPT值得你花2小时看到“26M参数”和“GPT”这两个词放在一起很多人的第一反应可能是这能干什么现在动辄百亿、千亿参数的大模型满天飞一个区区两千六百万参数的“小玩意儿”有什么训练的必要这正是这个教学项目的精妙之处——它剥离了所有关于算力的神话和资源的焦虑直指大语言模型LLM最核心的运作原理。这个项目的目标不是让你复现一个能写诗、编程、聊天的ChatGPT而是让你在短短两小时内亲手“捏”出一个能理解字符序列、并基于此生成新文本的微型GPT。这26M参数就像一个精密的钟表机芯虽然体积小但齿轮注意力机制、发条前馈网络、擒纵机构层归一化一应俱全。通过训练它你将透彻理解Token是如何被嵌入成向量的自注意力机制到底在“注意”什么模型是如何通过概率预测下一个词的这些问题的答案远比盲目调用API来得深刻。它适合所有对AI底层原理抱有好奇心但被海量数学公式和庞大工程吓退的开发者、学生甚至产品经理。你不需要八卡A100一台有GPU的消费级电脑甚至用CPU也能跑只是慢点就足够了。这个项目的价值在于“教学”在于“体验”在于让你获得对Transformer架构最直观的、肌肉记忆般的理解。当你看着自己从零搭建的模型从输出乱码到逐渐能拼凑出有意义的单词和短句时那种成就感是无可替代的。接下来我们就拆开这个“钟表”看看每一个零件是怎么工作的。2. 核心架构拆解微型GPT的“五脏六腑”一个完整的GPT模型无论参数大小其核心架构都是Transformer的解码器Decoder堆叠。我们的26M参数版本可以看作是一个高度精简但功能完备的“教学模型”。我们来逐一拆解它的核心组件并解释为什么在这个规模下我们如此设计。2.1 词表与嵌入层从字符到数字世界的桥梁首先模型不认识单词它只认识数字。我们需要一个“词典”把输入的文本比如“hello world”转换成一串数字ID这个过程叫Tokenization分词。对于教学项目为了极致简单我们通常采用字符级Character-level分词。也就是说我们的词表Vocabulary就是所有可能出现的字符集合例如英文小写字母a-z、数字0-9、空格、标点等。假设我们有100个字符那么词表大小vocab_size就是100。为什么用字符级而不是更先进的子词Subword分词如BPEByte Pair Encoding原因很简单简化。字符级分词无需复杂的合并算法词表极小实现直观。虽然它会让模型学习更长距离的依赖关系变得更难因为“hello”需要5个token而不是1个但对于理解原理和在小数据集上快速验证它是完美的选择。在26M参数规模下模型有能力学习字符间的组合规律。嵌入层Embedding Layer就是一个简单的查找表。每个字符ID一个整数通过这个查找表被映射为一个固定长度的稠密向量比如128维。这个向量就是该字符的“分布式表示”它会在训练过程中被不断调整使得语义相近的字符如‘a’和‘A’在向量空间中的位置也接近。2.2 核心引擎Transformer解码器块这是模型的心脏。一个解码器块主要由以下部分组成我们的微型GPT可能会堆叠4到6个这样的块自注意力机制Causal Self-Attention这是Transformer的灵魂。它允许序列中的每个“位置”去查看序列中所有之前的位置因果掩码确保它不能“偷看”未来并计算一个加权和。简单来说模型在预测下一个字符时会问自己“根据我已经看到的这些字符哪一个或哪几个对预测下一个字符最重要” 权重就是通过查询Query、键Key、值Value三组向量计算得出的。在微型GPT中我们通常使用多头注意力比如4个头每个头学习不同方面的依赖关系例如一个头关注语法结构一个头关注词性搭配。前馈神经网络Feed-Forward Network注意力层的输出会经过一个简单的全连接网络通常包含一个放大和缩小的过程例如从128维放大到512维再缩回128维。它的作用是为每个位置的特征提供一次非线性变换和特征混合增加模型的表达能力。层归一化LayerNorm与残差连接Residual Connection这是训练深层网络稳定的关键。每个子层注意力、前馈之前或之后都会应用层归一化将数据分布拉回稳定状态。残差连接则是将子层的输入直接加到其输出上输出 子层(输入) 输入。这有效地解决了深度网络中的梯度消失问题让信息可以畅通无阻地穿越很多层。2.3 输出层从特征到概率经过多个解码器块处理后我们得到了每个位置的一个高级特征向量。最后我们需要将这个向量映射回词表空间。我们使用一个线性层Linear Layer将特征向量的维度如128投影到词表大小100。这个操作会为词表中的每个字符生成一个“分数”logits。然后我们使用Softmax函数将这些分数转换为概率分布。模型预测的下一个字符就是从这个概率分布中采样或取概率最大的那个得到的。参数估算26M参数从哪里来我们来粗略算一下。假设词表大小100嵌入维度128那么嵌入层参数约100 * 128 12.8K。一个解码器块的主要参数在注意力层和前馈层注意力层的QKV投影矩阵和前馈层的两个线性层。如果堆叠6个块每个块参数约4M总共就在24M左右加上最后的输出层总数就接近26M。这是一个非常紧凑但足以演示Transformer核心机制的设计。3. 实战两小时训练流水线全解析理论清晰后我们进入实战环节。这两小时需要高效利用每一步都有其目的和技巧。3.1 环境准备与数据加载10分钟环境推荐使用Python和PyTorch。安装命令极其简单pip install torch。如果你有NVIDIA GPU确保安装了对应版本的CUDA和cuDNNPyTorch安装时会自动匹配。数据选择一个小而经典的数据集。莎士比亚全集、维基百科的某个小条目、甚至是几篇新闻文章都可以。数据量在1MB到10MB之间为宜。太大的数据两小时处理不完太小则模型学不到模式。这里我们以“莎士比亚作品”文本为例。import torch import torch.nn as nn import torch.nn.functional as F import requests # 下载数据 url https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt text requests.get(url).text print(f数据长度: {len(text)} 字符) print(text[:500]) # 预览前500个字符数据预处理构建字符级词表。# 创建字符到索引和索引到字符的映射 chars sorted(list(set(text))) vocab_size len(chars) print(f词表大小: {vocab_size}) print(.join(chars)) stoi {ch:i for i,ch in enumerate(chars)} # 字符 - 索引 itos {i:ch for i,ch in enumerate(chars)} # 索引 - 字符 encode lambda s: [stoi[c] for c in s] # 编码函数 decode lambda l: .join([itos[i] for i in l]) # 解码函数 # 将整个文本编码为张量 data torch.tensor(encode(text), dtypetorch.long) print(data.shape, data.dtype)3.2 模型定义与初始化20分钟现在我们根据第二部分的设计用PyTorch定义模型。这里给出一个高度精简但结构清晰的实现框架。import torch.nn as nn import math class CausalSelfAttention(nn.Module): 带因果掩码的多头自注意力 def __init__(self, embed_dim, num_heads): super().__init__() assert embed_dim % num_heads 0 self.num_heads num_heads self.head_dim embed_dim // num_heads # 通常将Q,K,V投影合并到一个线性层中提升效率 self.c_attn nn.Linear(embed_dim, 3 * embed_dim) # 输出Q, K, V self.c_proj nn.Linear(embed_dim, embed_dim) # 输出投影 # 因果掩码确保位置i只能看到i的位置 self.register_buffer(bias, torch.tril(torch.ones(block_size, block_size)) .view(1, 1, block_size, block_size)) def forward(self, x): B, T, C x.size() # 批大小序列长度特征维度 # 计算Q, K, V qkv self.c_attn(x) q, k, v qkv.split(self.embed_dim, dim2) # 重塑为多头 k k.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) q q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) v v.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) # 注意力计算 (缩放点积注意力) att (q k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1))) att att.masked_fill(self.bias[:,:,:T,:T] 0, float(-inf)) att F.softmax(att, dim-1) y att v # 合并多头输出 y y.transpose(1, 2).contiguous().view(B, T, C) y self.c_proj(y) return y class Block(nn.Module): 一个Transformer解码器块 def __init__(self, embed_dim, num_heads): super().__init__() self.ln1 nn.LayerNorm(embed_dim) self.attn CausalSelfAttention(embed_dim, num_heads) self.ln2 nn.LayerNorm(embed_dim) self.mlp nn.Sequential( nn.Linear(embed_dim, 4 * embed_dim), # 放大 nn.GELU(), # 激活函数 nn.Linear(4 * embed_dim, embed_dim), # 缩小 ) def forward(self, x): # 残差连接 层归一化Pre-Norm结构更稳定 x x self.attn(self.ln1(x)) x x self.mlp(self.ln2(x)) return x class MiniGPT(nn.Module): 我们的26M参数微型GPT def __init__(self, vocab_size, embed_dim256, block_size256, num_layers6, num_heads8): super().__init__() self.block_size block_size self.token_embedding nn.Embedding(vocab_size, embed_dim) self.position_embedding nn.Embedding(block_size, embed_dim) # 位置编码 self.blocks nn.Sequential(*[Block(embed_dim, num_heads) for _ in range(num_layers)]) self.ln_f nn.LayerNorm(embed_dim) self.lm_head nn.Linear(embed_dim, vocab_size) # 语言模型头 # 参数初始化很重要 self.apply(self._init_weights) def _init_weights(self, module): if isinstance(module, nn.Linear): torch.nn.init.normal_(module.weight, mean0.0, std0.02) if module.bias is not None: torch.nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): torch.nn.init.normal_(module.weight, std0.02) def forward(self, idx, targetsNone): B, T idx.shape # 词嵌入 位置嵌入 tok_emb self.token_embedding(idx) # (B,T,embed_dim) pos torch.arange(0, T, deviceidx.device) # (T) pos_emb self.position_embedding(pos) # (T, embed_dim) x tok_emb pos_emb # (B,T,embed_dim) x self.blocks(x) x self.ln_f(x) logits self.lm_head(x) # (B, T, vocab_size) loss None if targets is not None: B, T, C logits.shape logits logits.view(B*T, C) targets targets.view(B*T) loss F.cross_entropy(logits, targets) return logits, loss def generate(self, idx, max_new_tokens): 自回归生成文本 for _ in range(max_new_tokens): # 裁剪上下文到block_size idx_cond idx[:, -self.block_size:] # 前向传播 logits, _ self(idx_cond) # 聚焦最后一个时间步 logits logits[:, -1, :] # (B, C) # 用温度采样增加随机性 probs F.softmax(logits, dim-1) idx_next torch.multinomial(probs, num_samples1) # (B, 1) # 拼接生成结果 idx torch.cat((idx, idx_next), dim1) return idx初始化技巧注意代码中的_init_weights方法。用较小的正态分布std0.02初始化权重是训练Transformer模型的标准做法这有助于在训练初期保持激活值的稳定性。将偏置bias初始化为0也是常见操作。3.3 训练循环与超参数设置80分钟这是最耗时的部分但代码结构很清晰。我们将数据分割成训练集和验证集90%/10%并创建数据加载器。# 分割数据 n int(0.9 * len(data)) train_data data[:n] val_data data[n:] def get_batch(split): 随机获取一个小批量的数据 data train_data if split train else val_data ix torch.randint(len(data) - block_size, (batch_size,)) x torch.stack([data[i:iblock_size] for i in ix]) y torch.stack([data[i1:iblock_size1] for i in ix]) return x, y # 超参数设置这是关键 batch_size 32 # 每次训练输入的样本数 block_size 256 # 模型能处理的最大上下文长度 learning_rate 3e-4 # 学习率Adam优化器的黄金标准 max_iters 5000 # 最大迭代步数控制训练时间 eval_interval 500 # 每多少步评估一次 eval_iters 200 # 评估时使用的迭代次数用于估算平均损失 # 初始化模型、优化器 model MiniGPT(vocab_sizevocab_size, embed_dim256, block_sizeblock_size, num_layers6, num_heads8) print(f模型参数量: {sum(p.numel() for p in model.parameters())/1e6:.2f}M) model model.to(device) # 如果有GPU移到GPU上 optimizer torch.optim.AdamW(model.parameters(), lrlearning_rate) # AdamW是Adam的改进版带权重衰减 torch.no_grad() def estimate_loss(): 估算训练集和验证集的损失 out {} model.eval() for split in [train, val]: losses torch.zeros(eval_iters) for k in range(eval_iters): X, Y get_batch(split) X, Y X.to(device), Y.to(device) _, loss model(X, Y) losses[k] loss.item() out[split] losses.mean() model.train() return out # 训练循环 for iter in range(max_iters): # 每隔一段时间评估一次 if iter % eval_interval 0 or iter max_iters - 1: losses estimate_loss() print(f第{iter}步: 训练损失 {losses[train]:.4f}, 验证损失 {losses[val]:.4f}) # 获取一个批量数据 xb, yb get_batch(train) xb, yb xb.to(device), yb.to(device) # 前向传播计算损失 _, loss model(xb, yb) # 反向传播更新参数 optimizer.zero_grad(set_to_noneTrue) # 清零梯度set_to_noneTrue可以节省内存 loss.backward() optimizer.step() print(训练完成)超参数设置心得学习率3e-4对于Adam优化器这是一个经过大量实践验证的、近乎“万能”的起始学习率。对于我们的微型模型这个值非常安全。批量大小32在GPU内存允许的范围内批量大小越大梯度估计越准训练越稳定。但太大也可能导致泛化能力下降。32是一个兼顾速度和稳定性的常见值。上下文长度256这限制了模型能“看到”多远的过去。对于字符级模型256个字符大约是一段话的长度足以让模型学习到基本的单词拼写和短句结构。最大迭代步数5000在两小时的限制下我们需要估算每一步的时间。在消费级GPU上5000步大约需要60-80分钟留出评估和生成的时间。3.4 文本生成与效果评估10分钟训练结束后最激动人心的时刻到了让模型“开口说话”。我们提供一个起始字符串context让模型自回归地生成后续文本。# 将模型设置为评估模式 model.eval() # 生成文本 context torch.tensor([encode(KING: )], dtypetorch.long, devicedevice) # 以“KING: ”开头 generated_ids model.generate(context, max_new_tokens500)[0].tolist() generated_text decode(generated_ids) print(generated_text)生成策略解析代码中使用了torch.multinomial进行采样。这意味着模型不是永远选择概率最高的那个字符贪婪搜索而是根据概率分布随机采样。这能带来更多样化、更有趣的生成结果但有时也会产生不合逻辑的内容。你可以尝试“温度”Temperature采样。在Softmax之前将logits除以一个温度系数T。T 1如1.2会使分布更平滑生成更随机、更有创造性的文本T 1如0.8会使分布更尖锐生成更确定、更保守的文本。代码中可以这样修改temperature 0.8 logits logits / temperature probs F.softmax(logits, dim-1)另一种高级策略是Top-k 或 Top-p核采样即只从概率最高的k个候选词中采样或从累积概率达到p的最小候选词集合中采样。这能有效避免采样到概率极低的奇怪字符。评估生成质量没有绝对标准但你可以观察字符级连贯性生成的单词看起来像英文单词吗如“helllo”是错的“hello”是对的。语法结构有没有出现大写字母开头、句号结尾的短句上下文一致性如果输入是“KING: ”生成的内容是否像戏剧台词模型是否学到了训练数据莎士比亚的风格4. 避坑指南与性能优化实录在实际操作中你几乎一定会遇到下面这些问题。这里记录了我的踩坑经验和解决方案。4.1 训练不收敛或损失为NaN这是新手最常见的问题。检查初始化确保你按照示例代码进行了正确的权重初始化std0.02。错误的初始化如std过大会导致激活值爆炸梯度变成NaN。检查学习率3e-4对AdamW通常是安全的。如果你手动调整了模型架构如大幅增加embed_dim可能需要微调学习率。一个简单的策略是使用学习率预热Warmup在训练的前几百步将学习率从0线性增加到设定值这有助于训练初期稳定。检查梯度裁剪Gradient Clipping在loss.backward()之后optimizer.step()之前加入一行代码torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。这可以防止梯度爆炸将梯度向量的范数norm限制在1.0以内是训练RNN和Transformer的常用稳定技巧。检查输入数据确保你的输入张量idx和targets的 dtype 是torch.long整数类型而不是浮点数。交叉熵损失函数要求索引是整数。4.2 模型过拟合与欠拟合过拟合表现训练损失持续下降但验证损失在某个点后开始上升。模型“死记硬背”了训练数据而无法泛化到新数据。解决方案增加数据量是最根本的。此外可以尝试Dropout在注意力层和前馈层之后添加Dropout。例如在Block的forward函数中x x F.dropout(self.attn(self.ln1(x)), p0.1)。权重衰减Weight Decay我们使用的AdamW优化器已经内置了权重衰减通过weight_decay参数设置通常为0.01或0.1这相当于L2正则化能有效防止过拟合。早停Early Stopping监控验证损失当其在连续多个评估周期内不再下降时停止训练。欠拟合表现训练损失和验证损失都很高且下降缓慢。模型能力不足无法捕捉数据中的模式。解决方案增加模型容量更多层、更大的embed_dim、延长训练时间增加max_iters或者检查模型架构是否有错误例如注意力掩码是否正确残差连接是否生效。4.3 生成文本质量差输出重复或陷入循环这是采样策略的问题。贪婪搜索总是选最高概率极易导致循环。务必使用采样sampling而非贪婪搜索。同时可以尝试降低温度如0.7或使用Top-p采样如p0.9来平衡生成的质量和多样性。生成乱码或非字符检查你的词表itos和解码函数decode。确保模型输出的索引在词表范围内。有时在生成时模型可能输出超出范围的索引这通常是因为Softmax前的logits有问题或者采样函数出错。4.4 训练速度慢两小时是目标但如果你的机器只有CPU可能会超时。使用GPU这是最大的加速手段。确保你的PyTorch安装了CUDA版本并使用.to(device)将模型和数据移到GPU上。降低精度使用混合精度训练Mixed Precision Training。这可以显著减少GPU显存占用并加快计算。PyTorch中可以使用torch.cuda.amp自动混合精度模块。调整批量大小在GPU显存允许的前提下尽可能增大batch_size。更大的批次意味着更少的迭代步数就能看完一遍数据并且梯度估计更准确。减少评估频率将eval_interval设得大一些如1000步减少验证集上的前向传播次数这些是不更新梯度的纯耗时间。5. 从教学模型到实用化的思考完成这个26M参数GPT的训练后你已经掌握了Transformer语言模型最核心的构建、训练和生成流程。但这只是一个起点。如果你想走向更实用、更强大的模型以下方向值得深入1. 分词器的升级将字符级分词换成子词分词如BPE。这能极大提升模型处理常见单词和未知词的效率。你可以使用Hugging Face的tokenizers库在更大的语料上训练一个BPE分词器然后替换掉项目中的简单字符词表。2. 数据与规模的扩展尝试用更大的数据集如几十MB的文本训练一个参数稍多如100M的模型。你会发现模型开始能生成更长的、语法更正确的段落甚至表现出初步的“主题”一致性。这就是“规模定律”Scaling Law的直观体现更多的数据和参数会涌现出更复杂的能力。3. 引入更先进的架构细节 -旋转位置编码RoPE替换掉简单的可学习位置嵌入RoPE能更好地处理长序列也是LLaMA、GPT-4等主流模型的选择。 -SwiGLU/RMSNorm尝试使用SwiGLU激活函数和RMSNorm层归一化这些是近年来被证明更有效的变体。 -Flash Attention如果你的GPU支持使用Flash Attention实现可以大幅加速注意力计算并降低内存占用让你能处理更长的序列。4. 指令微调Instruction Tuning与对齐我们的模型现在只是一个“续写模型”。要让它能回答问题、遵循指令你需要进行指令微调。这需要收集或构造大量的(指令, 输入, 输出)三元组数据在预训练好的模型基础上进行有监督微调SFT。这之后还可以通过人类反馈强化学习RLHF进一步对齐模型的输出与人类偏好。这个2小时的项目就像给你一张地图和一把钥匙。地图是Transformer的架构图钥匙是亲手运行代码的体验。现在你已经站在了大语言模型世界的大门口门后的广阔天地等待你去探索。真正的挑战和乐趣始于你开始根据自己的想法修改架构处理新数据解决新问题的那一刻。