刚从LMArena刷完榜单,又在GitHub上刷到Build a Large Language Model from Scratch的代码仓库,说实话,这两年“大模型”概念已经被聊到有点烂大街了,但真正愿意沉下心从零把训练流程走一遍的人,还是少数。多数人都在调API、套RAG、跑微调脚本,模型内部到底发生了什么,反而成了黑盒。
我自己花了大概两个月时间,用一个单卡消费级GPU,从数据清洗开始,到分词器、继续预训练、指令微调、推理部署,完整走了一遍“从零构建LLM”的流程。这篇文章不是理论复述,就是把我实际跑通的过程、踩过的坑、以及为什么有些步骤必须这么做,原原本本写出来。如果你也想搞懂ai-engineering到底在“工程”什么,这篇应该能给你省不少时间。
1. 项目整体设计与思路拆解
1.1 “从零构建LLM”到底在构建什么
很多人第一次看到“from scratch”会以为是要从矩阵乘法开始手写神经网络。实际不是。这里的“从零”指的是:不使用任何现成的预训练模型权重、不依赖transformers库里的AutoModel.from_pretrained直接加载模型,而是自己搭建模型结构、自己处理数据、自己写训练循环,用公开的原始文本语料,训练出一个真正能生成文本的语言模型。
这个项目的核心构成其实可以拆成四块:
- 数据工程:原始语料获取、清洗、去重、采样配比
- 分词器(Tokenizer):自己训练BPE词表,而不是直接加载GPT-2的tokenizer
- 模型结构:基于Transformer的decoder-only架构,从零实现前向传播和反向传播
- 训练与对齐:先做语言建模预训练,再做指令微调,最后简单做偏好对齐
这也是为什么我会觉得这条路值得走一遍——你调API只能看到输入输出,但自己走完这套流程后,看到“模型输出质量差”时,你会本能地分析到底是数据问题、词表问题、训练步数不够,还是采样参数不对。这种判断力是纯调API喂不出来的。
1.2 为什么坚持从零实现而不是直接微调开源模型
关于“为什么不做LoRA微调,非要自己从头训”这个质疑,我听过太多次了。直接微调Llama-3-8B当然更省算力、更容易出成果,但意义完全不同:
| 维度 | 直接微调开源模型 | 从零预训练 |
|---|---|---|
| 算力需求 | 单卡24GB可跑 | 单卡24GB只能训小模型 |
| 对模型理解 | 停在“改参数”层面 | 需要理解每一步在干什么 |
| 数据要求 | 几万条指令即可 | 需要GB级原始文本 |
| 可解释性 | 黑盒微调 | 能观察到loss曲线与生成能力的对应关系 |
| 上手门槛 | 相对较低 | 需要完整工程链路 |
这里不是说微调路线不好,而是“从零”有一个别的路径替代不了的价值:你会亲眼看到模型从输出乱码到输出连贯句子的全过程。那个过程带来的认知更新,是读十篇论文都换不来的。
1.3 需要什么样的前置基础
坦白说,这个项目有一定门槛,但没到遥不可及的程度。我自己的情况是:Python基础扎实,会用PyTorch写简单的CNN和RNN,但此前没有真正碰过Transformer实现细节。如果你跟我当时的状态差不多,完全可以直接上手。
需要补的知识点大概有这几个:
- Python/PyTorch:至少要能看懂
nn.Module、nn.Embedding、nn.Linear这些组件 - Word Embedding基础:知道词嵌入是什么,怎么从one-hot变成稠密向量
- 注意力机制的基本概念:不需要推导全部数学公式,但要理解Q、K、V三个矩阵是干嘛的
- 训练常识:学习率、过拟合、loss曲线这些基本概念要知道
很多教程会说“必须吃透Attention Is All You Need才能动手”,我不太认同。更好的路径是:先跑通一个极小的模型(比如参数量在1000万级别),再回头对照论文理解设计原因。直接啃论文硬核推导,很容易在入门阶段就劝退自己。
2. 核心细节解析与实操要点
2.1 数据工程:语料进模型前的关键一步
数据质量决定了模型上限,这个说法在预训练阶段体现得极其明显。刚开始我图省事,直接从网上拖了一份几个GB的爬虫文本,简单按换行符切分就扔进模型训练了。结果训练到一半发现loss曲线非常不稳定,生成结果里各种乱码和重复文本,排查了很久才发现是原始数据里混了大量HTML标签和乱码符号。
后面的做法是重新清洗处理,整个数据pipeline大概是这样的:
- 去重:MinHash + LSH做近似去重,把互联网上重复的文本去掉
- 清洗:去掉HTML标签、控制字符、过短的文本片段
- 过滤:按语言识别过滤掉非中文/英文内容
- 标准化:统一标点符号为全角或半角,避免同一个词因标点不同被切分成两个token
这里有一个很多人不知道的细节:文本中的重复模式会严重影响模型生成质量。比如一个数据集里如果有大量以“点击这里了解更多”结尾的段落,模型很快就会学会把这句话接在各种内容后面。所以数据清洗阶段的去重和过滤,不仅仅是脏活累活,更是决定模型“学坏”还是“学好”的关键。
2.2 Tokenizer:自己训练BPE词表的三个参数
Tokenizer是整个pipeline里最容易被忽视但实际上极其影响模型效果的部分。词表大小、合并次数、是否做中文预分词,这些参数直接决定模型“看”输入文本的粒度。
我按GPT-2的做法,用BPE算法自己训练了词表,核心参数就三个:
- 词表大小:我选的是32000,跟Llama-2保持一致。太小的词表会把低频词拆得过碎,导致序列过长增加计算量;太大的词表会让Embedding矩阵占大量显存。32000是个比较平衡的选择。
- 最小频率:只在语料中出现个位数的token会被过滤掉,避免词表里塞满噪音
- 是否预分词:中文场景下我会先按字做切分再叠加BPE,这样对中文新词的处理会更灵活
Tokenizer训练完了,第一件要做的事是验证。看看分词之后的文本是什么样子,高频词是不是合理,英文单词是不是被完整保留下来了。我犯过一个很蠢的错误:忘记给tokenizer加特殊token<|endoftext|>就开训了,结果模型根本不知道怎么结束一段文本,生成的回复永远在续写。
2.3 模型架构:一个极简Decoder-Only Transformer
我实现的模型参考了GPT-2和nanoGPT的结构,是最标准的decoder-only架构。核心组件就六个:
- Token Embedding层:把token ID映射为向量
- 位置编码层:我用的是可学习的绝对位置编码,简单有效
- 多头自注意力层:核心中的核心,计算每个token和其他token的关联
- 前馈网络层:每个token独立经过两层全连接,增加非线性表达能力
- 层归一化:稳定训练过程
- 输出层:预测下一个token的概率分布
模型配置方面,参考了小规模实验的标准配置。我的参数是:12层Transformer、8个注意力头、768维隐藏层、32000词表,参数量约1.2亿。这个规模用一张4090加梯度累积可以跑,但想跑的更快还是建议用小一点的配置起步。
提示:第一次实现注意力层时,建议先在玩具数据上验证形状正确,再放大规模。注意力矩阵的形状错误非常隐蔽,编译时不会报错,直到训练时显存直接爆掉或者一步不收敛。
3. 实操过程与核心环节实现
3.1 训练小模型的完整代码骨架
模型定义部分我用PyTorch从零实现,包括自注意力、前馈层结构。为了避免代码过长,下面只保留最核心的训练循环部分,整体结构参考nanoGPT的思路:
import torch import torch.nn as nn import torch.nn.functional as F class CausalSelfAttention(nn.Module): def __init__(self, config): super().__init__() assert config.n_embd % config.n_head == 0 self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd) self.c_proj = nn.Linear(config.n_embd, config.n_embd) self.n_head = config.n_head self.n_embd = config.n_embd def forward(self, x): B, T, C = x.size() qkv = self.c_attn(x) q, k, v = qkv.split(self.n_embd, dim=2) k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) y = F.scaled_dot_product_attention(q, k, v, is_causal=True) y = y.transpose(1, 2).contiguous().view(B, T, C) y = self.c_proj(y) return y class TransformerBlock(nn.Module): def __init__(self, config): super().__init__() self.ln_1 = nn.LayerNorm(config.n_embd) self.attn = CausalSelfAttention(config) self.ln_2 = nn.LayerNorm(config.n_embd) self.mlp = nn.Sequential( nn.Linear(config.n_embd, 4 * config.n_embd), nn.GELU(), nn.Linear(4 * config.n_embd, config.n_embd) ) def forward(self, x): x = x + self.attn(self.ln_1(x)) x = x + self.mlp(self.ln_2(x)) return x class GPT(nn.Module): def __init__(self, config): super().__init__() self.config = config self.token_embedding = nn.Embedding(config.vocab_size, config.n_embd) self.position_embedding = nn.Embedding(config.block_size, config.n_embd) self.blocks = nn.ModuleList([TransformerBlock(config) for _ in range(config.n_layer)]) self.ln_f = nn.LayerNorm(config.n_embd) self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) def forward(self, idx, targets=None): B, T = idx.size() assert T <= self.config.block_size tok_emb = self.token_embedding(idx) pos_emb = self.position_embedding(torch.arange(T, device=idx.device)) x = tok_emb + pos_emb for block in self.blocks: x = block(x) x = self.ln_f(x) logits = self.lm_head(x) loss = None if targets is not None: loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1)) return logits, loss这段代码里有两个细节值得说明。第一,我用了PyTorch 2.0的F.scaled_dot_product_attention,它内部自动处理了causal mask,不需要手动构造上三角矩阵,省了很多麻烦,速度也比手动实现的Attention快。第二,残差连接使用的是Pre-LayerNorm(先归一化再进注意力层)而不是Post-LayerNorm,这在现代GPT实现里基本是标配,能显著提升训练的稳定性。
3.2 训练过程的参数选择与日志分析
训练时我用的参数是:batch size 32、序列长度256、学习率3e-4、AdamW优化器、权值衰减0.1,训练步数约5万步。数据量的话,经过清洗后大概剩了2GB纯文本,中文和英文混合。
观察loss曲线是一件很有意思的事。我记录了几个关键节点的情况:
- 第500步左右:loss开始从初始的10.9往下掉,生成结果还是纯乱码
- 第5000步左右:loss降到3.2附近,模型开始输出有空格分隔的英文单词,中文还是乱码
- 第2万步左右:loss降到2.6左右,模型能生成语法基本正确的片段,但内容依然没有逻辑
- 第5万步结束:loss稳定在1.9左右,能生成一小段语义连贯的文本
这个过程给我的冲击挺大的:你亲眼看着一个“什么都不会”的网络,一步步变得“有话想说”,而且每一阶段的表现都能对应上loss曲线的位置。如果你之后要判断模型的训练是否正常,就可以拿这些数字做参照。
3.3 四个关键训练技巧
混合精度(AMP):建议使用,能在几乎不掉精度的情况下省30%-40%显存。实现方式也很简单:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for batch in dataloader: with autocast(): logits, loss = model(batch['input_ids'], batch['target_ids']) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()梯度累积:如果你的GPU显存不够大,不要强行加大batch size。保持单卡batch size为8,然后累积4个step的梯度再更新参数,效果等同于batch size 32。
学习率预热:前1000步把学习率从0线性升到3e-4,之后用余弦退火慢慢降低。这个操作不是为了花哨,而是为了防止训练初期梯度爆炸把模型参数推到一个坏区域,之后很难恢复。
检查点保存:每1000步保存一次模型权重和优化器状态。我中途遇到过断电解码崩溃的情况,如果没定期存checkpoint,整个训练就白跑了。
4. 常见问题与排查技巧实录
4.1 Loss不下降或直接变成NaN
Loss从一开始就不降,或者训着训着变成NaN,这种情况第一次碰到的概率相当高。我给大家整理一个排查顺序:
- 检查数据是否异常:用一个小批量数据(比如64条样本),测试前向传播是否正常。如果小批量loss在合理下降,说明代码问题不大,是数据里有脏数据
- 检查是否存在“死神经元”或梯度爆炸:把
torch.norm(model.parameters())打印出来看看。如果梯度范数超过10,基本就是梯度过大,需要调低学习率或者加梯度裁剪 - 检查Embedding层是否有nan:我碰到过一次,最后定位到是数据里有NaN token,输入模型后污染了梯度
- 检查学习率:3e-4对大多数模型是合适的,但如果你的batch size很小,学习率也需要相应调低
注意:NaN问题不要靠“降低学习率”硬扛。如果小数据测试正常、大数据训练NaN,多半是数据里有问题,排查数据比硬调超参数更有效。
4.2 显存OOM的几种解决办法
显存溢出是这个项目里最常被反复讨论的问题,我按优先级排序分享一下:
- 降低batch size:最直接的办法
- 开启梯度检查点(gradient checkpointing):用时间换空间,代价是训练速度慢20%-30%
- 混合精度训练:上文提到的AMP
- 削减序列长度:把序列长度从512降到256,显存占用几乎减半
我个人的建议是:优先混合精度+梯度累积,这两个组合能解决90%的显存问题。梯度检查点适合你在极端的显存环境下做最后挣扎,平时没必要开。
4.3 生成质量不好,重复、答非所问怎么排查
模型训练结束后在推理阶段表现异常,有几种情况值得注意。
生成大量重复文本的原因可能是:
- 训练步数不足,模型还没学会分布的真实多样性
- 文本数据里本身重复片段太多,模型学会了“复读机”模式
- 采样参数问题:temperature设得太低,概率分布被压得太尖锐
答非所问多数是因为这个模型本质上只是“续写器”。如果你问“今天天气怎么样”,它只会按训练数据的文本风格续写,不会有意识地“回答”问题——因为它没有见过“问题-答案”配对数据。
所以后续做指令微调是很有必要的。这一步不需要从零训,在预训练模型基础上用几千条指令对数据做微调,就能让模型学会“回答问题”而不是“续写”。
4.4 训练速度太慢,怎么优化
如果你跟我一样用单卡训练,速度只能是相对指标。实测下来我的1.2亿参数模型在4090上大约每秒处理3000个token,5万步大概要跑3-4天。
几个加速手段按收益排序:
- 确认用的GPU是否支持TF32:在Ampere及以上架构上,PyTorch默认可能没用TF32,手动开启能带来接近2倍的速度提升
- 把
torch.compile()打开:PyTorch 2.0的编译优化能带来大约20%-40%的提速 - 检查数据加载是否成为瓶颈:尽量用
DataLoader的num_workers参数,GPU等待数据的时间减少非常明显 - 降低验证频率:如果每100步就跑一次验证,实际上验证占了大量时间。我改成每500步验证一次
还有一个容易被忽略的点:在代码里顺手可视化一下GPU利用率和Dataloader耗时,你会发现瓶颈往往不在计算而在于IO。这个排查思路其实适用于所有深度学习训练。
5. 评测与扩展方向:从语言模型到推理模型
5.1 怎么评测一个“从零训练”的模型
预训练阶段的模型跟微调后的模型,评测方式很不一样。我分三部分来做:
- 语言建模指标:Perplexity(困惑度)。这个指标越低说明模型预测下一个token越准确,但我个人认为它只能作为参考,不能反映语义质量
- 生成质量人工评测:给模型几个固定prompt,人工看生成文本的流畅度、连贯性、多样性
- 下游任务评测:我设计了一个极简的“知识问答”测试集,用指令微调后的模型跑一下,统计回答准确率
这里提醒一点:不要拿小模型的评测结果和大模型比。参数量1.2亿的模型跟7B甚至70B的模型,能力差距是数量级的。跟谁比?跟同参数量级的模型比,或者说跟“上一个训练步数的自己”比,这才是从零训练这个项目的意义。
5.2 从“语言模型”到“推理模型”的关键一步
最近“Reasoning Model”这个词很火,其实在从零训练的语境下,更现实的问题是:怎么从预训练模型得到“会答题”的模型。这就要走对齐(Alignment)流程了,核心分三步:
- SFT(监督微调):用“问题-标准回答”的数据对继续训练模型,让模型学会以问答格式输出
- RM(奖励模型):训练一个模型给生成的回答打分,这个分数的人类偏好数据来拟合
- RLHF:用奖励模型作为反馈信号,通过PPO等强化学习算法优化生成策略
我完整跑通了SFT阶段,RLHF部分因为算力限制做得比较粗略。但这一段经历给了我很具体的感受:模型能力的涌现,不是某一个步骤突然发生的,而是每微调一步都能清晰看到变化。SFT之后,模型从“写出一段话”变成了“针对问题写一段话”,这种可控性带来的成就感,比看benchmark数字变化来得真实得多。
5.3 从1.2亿参数扩展到更大模型的扩展路线
这篇文章里阐述的方法论,完全适用于更大规模的模型。如果后面有条件用多卡训练7B或13B模型,有几个工程上的扩展点:
- 分布式训练框架:从单卡DDP扩展到DeepSpeed ZeRO或FSDP,解决显存不够分的问题
- 数据并行策略:数据并行+张量并行组合,是7B以上规模的标准方案
- 训练数据规模:从“GB级”上升到“TB级”,数据清洗和去重逻辑需要重新设计
- 评估体系:需要引入更系统化的评测集,比如MMLU、C-Eval这类公开基准
我个人实际体会是,你完整跑过一次从零训练以后,再看那些大模型的技术报告,整个就是豁然开朗的状态——那些关于数据配比、阶段学习率、对齐策略的表格,每一个数字背后都有你能在小型实验里直观感受到的因果关系。这种理解深度,配着“我终于看懂了”的满足感,应该就是这条路线最大的回报。
还有一个很实际的收尾建议:做完预训练之后,记得把整个实验环境、代码、数据清洗脚本、训练日志全部归档好。我自己当时就是没做好版本管理,后来想复现某个结果,硬是花了两个晚上才对齐环境和参数。这个项目里那种“差一个随机种子结果就完全不一样”的体验,值得你从一开始就认真对待。
我从一开始想训练跟GPT-3一样大的模型,到后来老老实实把1.2亿参数的小模型从头跑通,中间经历了无数次想放弃的时刻。但回头看,这恰恰是这个项目最值得的地方——每一步都踩在地上,每一行代码都知道为什么这么写,每一次loss波动都知道去哪里排查。这种亲手搭起一砖一瓦的踏实感,是任何现成API都给不了的。