这个系列写到了第10篇终于开始动手跑一遍从零训练一个 MiniMind。熟悉我的同学应该知道我习惯先把原理讲清楚再在最小可运行的状态下把它跑起来。这次也一样我会用一段小语料、一个简单的 Transformer 解码器把完整流程从头过一遍包括数据准备、分词、模型构建、训练调参、生成效果观察和常见坑位。适合的人群是已经知道 Transformer 基本概念、想亲手训练一个迷你模型但还没真正动过手以及希望以后能拿小模型快速验证想法的人。1. 项目整体任务从零训练一个MiniMind到底要做什么1.1 为什么要自己动手而不是直接调现成模型很多人第一次接触大语言模型时会想“直接用开源模型不就行了为什么还要自己从零写训练流程”如果目标只是要一个能用的对话机器人确实没必要。但如果你想知道这几个问题——loss 为什么下降、学习率到底在控制什么、训练数据和生成结果之间是什么关系、所谓“记忆”和“泛化”在真实数据上怎么呈现——最直接的办法就是亲手把它跑起来。我自己在入门阶段犯过“理论全懂、代码不会”的毛病。看了很多篇关于自注意力、Transformer 的推导能手动算出 softmax 的结果但一打开编辑器就开始发呆。从零训练一个 MiniMind本质上就是逼自己把“纸面知识”翻译成“会运行的程序”。这个过程比最终训练出来的模型本身更有价值因为你会在调试 shape、观察损失曲线、看生成文本一点点从乱码变成像话的过程中建立起来一种很难替代的“手感”。这次要做的 MiniMind不是大厂产品那种几百B参数的模型而是一个参数在几百万左右、能在普通笔记本上跑完整个训练流程的小型语言模型。它的核心目标是生成下一个 token通过一段规模不大的中文语料展示数据清洗、tokenization、模型搭建、训练、生成和调参的完整闭环。1.2 技术选型不极端但足够小的“迷你”方案我在技术选型上做过比较最后选择了这样一套组合语言与框架Python 3.10 PyTorch 2.x生态成熟反正也离不开。模型结构Decoder-Only Transformer也就是当前主流生成模型的公共底座。数据规模几 MB 的中文文本控制在 10 万到 50 万字符之间。分词方式先用字符级 tokenizer 跑通全流程后续再替换成子词分词器。计算资源单块普通 GPU 可以跑纯 CPU 也能跑只是速度慢一些。选这套方案的逻辑很简单模型越小每一层每个参数的状态越好观察语料越小过拟合和泛化的问题越容易暴露训练时间越短调参试错的成本越低。等你把这一轮跑通了再去接更大规模的数据和模型会轻松很多因为你已经知道里面每一步到底在做什么了。2. 环境、语料与分词准备2.1 环境清单先让“能跑起来”这件事变得简单环境这块我给的最低配置是 Python 3.10 以上安装 PyTorch。如果你有 NVIDIA 显卡按官网命令装 CUDA 版本如果没有安装 CPU 版本也可以。只要模型配置别太夸张CPU 照样能完成整个训练只是速度感人。pip install torch numpy tqdm如果后续想换 BPE 或者 WordPiece再加装对应的分词库。不过第一阶段建议先忍住等字符级版本跑通再考虑否则容易出问题。搭建环境时有个容易踩的坑不同机器上 PyTorch 版本差异可能导致某些 API 行为不一致尤其是masked_fill、scaled_dot_product_attention这类算子。我自己习惯在开头打印一下版本信息方便以后排查。另外如果你用的是实验室服务器确认一下有没有充足显存。不要把 context length 和 batch size 一开始就调得很大否则后面 OOM 的时候你会分不清是代码写错还是资源不够。2.2 语料清洗与字符级分词我这次用的是自己整理的中文语料大约 30 万字。这个体量对 MiniMind 来说已经够用了既不会让训练时间过长也能观察到过拟合现象。语料来源不重要重要的是做一次清洗主要处理这几类脏数据HTML 标签、URL 和多余空行全角半角混乱空白字符过多且没有任何语义的内容乱码和非常低频的特殊符号。清洗的代码不复杂但很必要import re def clean_text(text: str) - str: text re.sub(r[^], , text) text re.sub(rhttp\S, , text) text re.sub(r\s, , text) text text.strip() return text清洗完的文本我通常还会做一次简单过滤只保留中文字符、常见标点和少量英文。这样做的原因是字符级 tokenizer 会把每个字节都当成一个独立 token如果语料里混入太多生僻符号词表会变得很大训练参数都花在不常出现的符号上并不划算。然后是字符级分词器。思路其实很简单把文本中出现的所有字符收集起来创建从字符到整数 id 的映射以及反向映射。def build_char_tokenizer(corpus: str): chars sorted(set(corpus)) stoi {ch: i for i, ch in enumerate(chars)} itos {i: ch for i, ch in enumerate(chars)} return stoi, itos def encode(text: str, stoi): return [stoi[ch] for ch in text] def decode(ids, itos): return .join(itos[i] for i in ids)字符级分词器在处理中文时有天然优势因为常用汉字数量不多大部分语义信息都能保留下来。缺点也很明显就是学习复杂词汇搭配的能力比较弱。但作为第一次跑通全流程它是最直观、最不容易出 bug 的方案。等 MiniMind 跑起来你再用 BPE 或其他分词器做对比理解会更深。3. 模型核心代码手写一个最简Decoder-Only Transformer3.1 最小组成Attention、FFN、残差和LayerNormMiniMind 的模型核心和主流生成模型的结构基本一致输入 token 先经过 embedding 变成向量加上位置信息然后经过多层 Transformer Block最后输出每个位置下一个 token 的 logits。每个 Transformer Block 里通常包含因果自注意力Causal Self-Attention保证模型只能看到当前位置及之前的内容前馈网络MLP通常先升维再降维增强非线性表达能力残差连接解决深层梯度传递问题LayerNorm放在子层前还是子层后会影响训练稳定性。这里我采用 Pre-LayerNorm 写法就是先 norm 再做 attention 或 MLP这在训练深层模型时比原始 Post-LayerNorm 更稳定。至于为什么不用旋转位置编码是因为模型很小直接用可学习位置 embedding 就能跑通少一个运算符多一份安心。3.2 代码实现从Self-Attention到MiniMind先写因果注意力。核心就是把 Q、K、V 从输入中投影出来算点积注意力再在上三角位置填充负无穷防止看到未来信息。import math import torch import torch.nn as nn class CausalSelfAttention(nn.Module): def __init__(self, hidden_size, num_heads, dropout0.1): super().__init__() self.hidden_size hidden_size self.num_heads num_heads self.head_dim hidden_size // num_heads self.qkv nn.Linear(hidden_size, 3 * hidden_size) self.out_proj nn.Linear(hidden_size, hidden_size) self.attn_dropout nn.Dropout(dropout) def forward(self, x): B, T, C x.shape qkv self.qkv(x) q, k, v qkv.chunk(3, dim-1) q q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) k k.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)) / math.sqrt(self.head_dim) mask torch.triu(torch.ones(T, T, devicex.device), diagonal1).bool() att att.masked_fill(mask.unsqueeze(0).unsqueeze(0), float(-inf)) att torch.softmax(att, dim-1) att self.attn_dropout(att) y att v y y.transpose(1, 2).contiguous().view(B, T, C) return self.out_proj(y)接下来是 Transformer Block 和整个模型。MLP 的隐层维度一般取 hidden_size 的 4 倍这是一个性价比很高的经验值不用特别纠结。class TransformerBlock(nn.Module): def __init__(self, hidden_size, num_heads, forward_size, dropout0.1): super().__init__() self.ln1 nn.LayerNorm(hidden_size) self.attn CausalSelfAttention(hidden_size, num_heads, dropout) self.ln2 nn.LayerNorm(hidden_size) self.mlp nn.Sequential( nn.Linear(hidden_size, forward_size), nn.GELU(), nn.Linear(forward_size, hidden_size) ) self.dropout nn.Dropout(dropout) def forward(self, x): x x self.attn(self.ln1(x)) x x self.mlp(self.ln2(x)) return x class MiniMind(nn.Module): def __init__(self, vocab_size, context_len, hidden_size, num_layers, num_heads, dropout0.1): super().__init__() self.context_len context_len self.token_embedding nn.Embedding(vocab_size, hidden_size) self.position_embedding nn.Embedding(context_len, hidden_size) self.blocks nn.ModuleList([ TransformerBlock(hidden_size, num_heads, hidden_size * 4, dropout) for _ in range(num_layers) ]) self.ln_f nn.LayerNorm(hidden_size) self.head nn.Linear(hidden_size, vocab_size) def forward(self, idx): B, T idx.shape tok_emb self.token_embedding(idx) pos_emb self.position_embedding(torch.arange(T, deviceidx.device)) x tok_emb pos_emb for block in self.blocks: x block(x) x self.ln_f(x) logits self.head(x) return logits这里面需要特别注意position_embedding是直接生成0 到 T-1的位置向量所以如果训练时输入序列长度是固定的推理时也要把输入截断到context_len。否则位置 embedding 的索引可能越界。3.3 带上生成函数光有前向还不够还得写一个简单的生成函数。这里用的是经典的自回归生成拿当前 token 序列预测下一个 token然后把它拼回序列继续预测直到生成长度满足要求。torch.no_grad() def generate(self, idx, max_new_tokens, temperature1.0): self.eval() for _ in range(max_new_tokens): idx_cond idx[:, -self.context_len:] logits self(idx_cond) logits logits[:, -1, :] / temperature probs torch.softmax(logits, dim-1) idx_next torch.multinomial(probs, num_samples1) idx torch.cat((idx, idx_next), dim1) return idxtemperature这个参数很关键。它大于 1 时概率分布变得更平滑生成结果更随机小于 1 时模型趋向于选最高概率的 token文本更保守但也更容易重复。训练刚结束我最喜欢做的事就是调 temperature看同一个前缀能生成出多少种不同的续写。4. 训练参数与数据批处理4.1 超参数为什么会选这些超参数这一块我看着最不直观但它直接决定你能不能把模型训起来。我第一次实际跑的配置大概是这样的配置项取值说明vocab_size语料实际字符数通常在 2000 到 5000 之间context_len128模型能看到的上文长度hidden_size192token 向量的维度num_layers4Transformer Block 的数量num_heads4注意力头数需要能整除 hidden_sizebatch_size32每个批次处理 32 条序列learning_rate3e-4AdamW 的常用初始学习率max_steps2000总训练步数为什么是这些值而不是更大或更小hidden_size和num_layers太小模型学不到复杂语义太大CPU 或小 GPU 会跑得很辛苦。context_len设成 128既不会让注意力矩阵太大也能捕捉到一定范围内的上下文关系。learning_rate先用 3e-4这是很多小模型的默认起点之后再根据 loss 曲线调节。如果只想冒烟测试可以把参数缩减到 hidden_size64、layers2、context_len32、batch_size8这样能很快确认代码没写错再升级到正式配置。4.2 DataLoader和损失计算语言模型的训练样本和普通分类任务不太一样。我们不需要显式的标签只需要把一段文本切成“输入序列”和“错位一格的输出序列”。比如输入是[我, 爱, 学]目标就是[爱, 学, 习]模型要做的事是看到前一个字符预测下一个字符。def get_batch(data, batch_size, block_size, device): ix torch.randint(len(data) - block_size, (batch_size,)) x torch.stack([data[i:i block_size] for i in ix]).to(device) y torch.stack([data[i 1:i block_size 1] for i in ix]).to(device) return x, y这段代码是从一维 token 序列里随机抽取若干段连续文本。x是输入y是目标取的是x向后平移一位的内容。因为 Transformer 解码器在结构上已经用 mask 挡住了未来信息所以直接使用整段序列训练没问题不需要额外设置填充。损失函数用交叉熵把模型的输出形状从(B, T, vocab_size)展平到(B*T, vocab_size)再把标签也展平到(B*T,)。loss F.cross_entropy(logits.view(-1, vocab_size), y.view(-1))这里有个容易忽略的细节交叉熵是对所有 token 的损失求平均如果某条内容比较长句子级别的差异会很大。观察 loss 时最好多记录几个平滑值别只看单步剧烈波动。5. 亲手跑一遍训练流程5.1 冒烟测试正式训练前我会先做一轮 20 步的冒烟测试。目的不是训练而是验证数据、模型、损失函数和优化器能串起来同时确认没有 NaN 或 batch 维度错误。import torch.nn.functional as F import torch device cuda if torch.cuda.is_available() else cpu model MiniMind( vocab_sizelen(stoi), context_len128, hidden_size192, num_layers4, num_heads4 ).to(device) optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay0.01) for step in range(20): x, y get_batch(data, 16, 128, device) logits model(x) loss F.cross_entropy(logits.view(-1, len(stoi)), y.view(-1)) optimizer.zero_grad() loss.backward() optimizer.step() print(step, loss.item())这轮跑完如果 loss 在正常下降说明全链路没问题。我见过太多次“模型代码没报错但数据维度错位”的情况全看冒烟测试跑出来的是不是合理数字。5.2 观察 loss 曲线和困惑度正式训练时我常用贝叶斯平均来平滑 loss比如每 50 步求一次平均避免单步抖动干扰判断。还会记录一个困惑度perplexity也就是对 loss 取指数表示模型在每一步候选词中的犹豫程度。一个典型的小模型训练过程会呈现出这样的趋势刚开始 loss 快速下降因为模型在拼命学“哪些字大概率接在哪些字后面”训练中段loss 下降变慢模型开始学句子结构、语义关联后段则容易出现验证集 loss 回升这说明模型开始死记语料中的某些片段。我当时某次训练日志大概长这样step 0 loss 5.31 ppl 202 step 500 loss 2.42 ppl 11.2 step 1000 loss 1.95 ppl 7.0 step 1500 loss 1.76 ppl 5.8 step 2000 loss 1.62 ppl 5.0字符级模型能到 ppl 5 左右已经能生成很多看起来像模像样的句子了。但如果你继续训练太长时间很可能看到 train loss 一路走低、val loss 停止下降甚至反弹那就是过拟合的信号。如果想留一点验证集最简单的做法是在训练前随机抽出一段文本训练过程中不参与梯度更新每隔固定步数跑一次前向记录验证 loss。5.3 训练过程里实际重要的小动作第一个小动作是梯度裁剪。MiniMind 虽然小但在某些语料片段上还是可能出现梯度爆炸常见表现是 loss 突然变成 nan。加上torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)可以大幅降低这种风险。第二个小动作是学习率调度。我建议从简单的线性衰减开始或者用余弦退火。硬编码固定学习率也可以但后期训练容易在局部最优附近振荡。配合 scheduler 后模型后期生成文本的稳定性会好不少。6. 生成效果与期望管理6.1 贪心解码和带温度采样训练完成写好生成函数之后就要面对最有意思的一步看 MiniMind 到底学会了什么。我第一次测试时用了一个很简单的前缀“今天天气”然后分别用贪心解码和温度采样生成后续内容。贪心解码就是每次选概率最大的 token结果通常最稳定但也最容易陷入重复。温度采样则会让输出更丰富有时会出现意想不到的片段。我习惯同时打印两种结果start 今天天气 idx torch.tensor([encode(start, stoi)], devicedevice) output model.generate(idx, max_new_tokens50, temperature0.8) print(decode(output[0].tolist(), itos))一个几十万参数量级的小模型不可能像大模型那样条理清晰地写出长文章但它会开始表现出“语感”上的连续。比如它可能生成“今天天气很好我决定去做一件事这件事不会比想象中简单”这种句子在语法上是通的尽管前后逻辑不一定严密。6.2 迷你模型到底能生成到什么程度对迷你模型有合理预期很重要。它学到的不是复杂推理或广博知识而更多是“统计规律”。它能隐约学会中文常用字的搭配、标点出现的位置、常见句式的结构但遇到没见过的话题时会开始胡编。这种“胡编”并不是坏事。从教学视角看它清楚地向你展示了语言模型生成的本质一切都来自概率分布而不是来自某个“理解模块”。当你看过一次模型从乱码变成连贯文本你会更理解为什么大规模语言模型需要海量语料和超大的参数量也更能认清“涌现能力”这件事在小模型上的边界。7. 从零训练MiniMind时的常见问题与排查7.1 问题速查表这里把我踩过的、以及在本地跑过很多次之后整理出来的问题列成一个表方便你直接对号入座。现象可能原因排查与处理训练 loss 为 NaN学习率太高或者数据里有极端字符把学习率降到 3e-5 再试加梯度裁剪清洗语料生成结果总是一句话循环temperature 太低模型容量不足把 temperature 调到 0.9 到 1.2增大模型或减少训练轮数验证集 loss 后期反弹过拟合减少训练步数提高 dropout增大语料规模显存不足batch_size 或 context_len 太大先调低一半再观察速度与 loss位置 embedding 越界推理输入比训练 context_len 长连续生成时只保留最后 context_len 个 token模型生成字符全是“的”“了”之类语料中高频词过多模型学到的分布不均匀增加低频内容或对损失函数做降权CPU 训练特别慢模型太大、batch 太大换成小配置先把流程跑通再换大配置7.2 至少被坑了三次之后才明白的经验第一次跑通从零训练 MiniMind我最大的收获不是“我成功训练了一个模型”而是“我终于能对着代码解释它的每一个细节了”。这里分享三条我后期一直遵守的经验。第一先把功能最简化再谈优化。不要一开始就想着加学习率 warmup、加 BPE、加分布式训练。把这些装饰全删掉只保留最基础的训练循环等到它稳定产出文本之后再逐项加回去。这样每个改动导致的问题都能被清晰定位。第二训练日志要详细到“能复盘”。我后来习惯每个固定步数记录 loss、learning_rate、当前时间、GPU 占用。没有日志的训练就像没有仪表盘的飞行等出问题再回头看连什么时候开始异常的都不知道。第三用“肉眼可读的输出”验证模型状态。每隔几百步生成一小段文本看输出从乱码到有结构的变化过程。loss 下降只能说明损失函数在优化但生成文本质量让你直观理解模型到底“理解”到了什么程度。如果你也准备从零训练一个 MiniMind我的建议是不要急着追求效果先按最小配置跑通然后再逐步加数据、加模型层数、加词表。这个过程会让你对后面所有更复杂的模型训练都多一份底气。最后分享一个很有效的小技巧把第一次训练的完整代码和配置保存下来即使后来迭代了很多版本这个“最小可运行版本”永远是你排查问题时的安全线。
阅读完成 · 觉得有帮助?