从零构建LLM:单卡GPU预训练到指令微调全流程实战
发布时间:2026/10/2 5:31:52来源:尧图网络
刚从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, dim2) 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_causalTrue) 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, biasFalse) def forward(self, idx, targetsNone): B, T idx.size() assert T self.config.block_size 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.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不下降或直接变成NaNLoss从一开始就不降或者训着训着变成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个token5万步大概要跑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都给不了的。
网站建设高端定制企业官网