
1. 为什么我坚持从零开始做AI工程这几年ai-engineering-from-scratch这个概念被反复提起我自己是从调包侠一路过来的最早跑个分类模型用sklearn后来用PyTorch训练个ResNet再后来跟着别人给的代码微调BERT。每次做完项目别人问我这个模型里到底发生了什么我多半只能含糊过去。直到我真正动手从零搭了一个微缩版的语言模型才把Transformer、注意力机制、训练循环、采样策略这些东西从看起来懂变成了讲得清楚。所以我特别能理解为什么现在build a large language model from scratch和build a reasoning model from scratch这类项目会火——大家不是缺一个能跑通的脚本而是缺一条能把整个AI工程串起来的路。这个内容适合谁如果你已经会用PyTorch或者TensorFlow但始终觉得模型是个黑盒如果你想自己动手训练一个真正能出字的语言模型而不是只做推理调用API如果你想搞明白推理模型reasoning model背后的训练逻辑——那这篇文章就是为你准备的。我会从头到尾讲清楚我自己的实操过程包括数据准备、模型结构、训练循环、采样推理以及最后怎么在一个微缩模型上复现让模型学会思考的基本思路。全文没有那种你只需要调用一行代码的魔法每个环节我都会解释为什么这么做、不这么做会踩什么坑。我把整个项目分成了几个阶段先明确目标再准备数据和tokenizer接着设计模型结构和训练流程最后是推理和评估。每个阶段都会有具体的代码片段、参数说明和踩坑记录你照着做就能跑出一个能生成中文句子的小模型同时也能建立起对整个AI工程的理解。2. 动手前必须先想清楚的设计问题2.1 选型思考用什么框架、要不要上分布式很多人一上来就想用最牛的配置动辄几十亿参数、多卡并行训练。但from scratch项目的核心目的不是卷参数而是理解机制。我的建议是先用CPU或者单张消费级显卡跑通一个极小模型再考虑扩展。我最初的选择是PyTorch原因有几点一是它的自动求导和动态图机制对调试友好二是社区资源丰富遇到问题搜起来方便三是HuggingFace生态都是基于它的后续扩展tokenizer或数据集工具都很顺手。TensorFlow也不是不行但它的静态图风格和tf.keras的封装层会让你更难看到内部实现细节——这正好与from scratch的目标相悖。参数配置上我的初始设定是模型参数量约1000万到2000万一个微缩GPT层数4层Transformer decoder隐藏维度256注意力头数4词汇表大小大约1万到2万训练序列长度64到128 tokens这个规模有多小呢大概是一个真正生产级模型的千分之一但在单张GPU上几分钟就能训练一步迭代非常快。这让你能频繁试验不同的超参数更快建立直觉。如果一开始就上大规模分布式训练你根本分不清一个问题到底是数据问题、模型问题还是分布式框架的问题。2.2 数据准备与tokenizer这一步远比想象中重要我特别想强调这一点数据的质量直接决定了模型能不能学会。很多人的from scratch项目失败不是因为模型结构写错了而是数据处理不对。我用的数据是中文维基百科的一个子集大概500MB的纯文本。清洗规则包括去重去掉标题相同的条目过滤去掉过短段落和全是标点的行统一格式全角半角转换换行符统一为|endoftext|分割大小写和特殊字符中文不需要大小写但需要处理数字和英文混排的情况然后是tokenizer。这里有两个选择直接拿BBPE如GPT-2的tokenizer来用或者自己训练一个BPE tokenizer。我选择了后者因为这是从零开始该有的姿态。用HuggingFace的tokenizers库训练一个BPE模型并不复杂但有几个关键参数要注意vocab_size我设了16000。太小会导致很多词被切碎训练效率低太大又会让embedding矩阵占大量空间。min_frequency设为2过滤掉只出现一次的生僻字special_tokens必须包括|endoftext|文本结束符、|pad|填充符、|unk|未知符训练完tokenizer后一定要做一遍抽样检查看看常见词和生僻词分别被切分成什么样。我遇到过的最典型问题是日期2024年5月17日被切成了2024、年、5、月、17、日这没问题但某些网络用语绝绝子被切成单个字导致模型很难学到这个完整词的含义。这类问题只能靠调vocab_size和清洗数据来改善。2.3 模型结构写一个微缩GPT的骨架模型结构我直接参考了GPT-2的decoder-only架构但做了极简化。核心组件有四个Token Embedding Position Embedding词嵌入和位置编码。我用的还是可学习的绝对位置编码而不是RoPE之类相对位置编码原因是实现简单并且小模型上效果差距不明显。Masked Multi-Head Self-Attention注意力机制的核心就是三个矩阵——Q查询、K键、V值。对一个序列的每个token我们要计算它与其他所有token的关联分数然后加权求和。Mask的作用是让模型只能看到当前位置之前的信息不能偷看未来。Feed-Forward Network每个注意力层后面跟着的两层MLP用ReLU或GELU激活。它在引入非线性变换的同时让模型每个位置能独立处理信息。LayerNorm Residual Connection残差连接在深层网络中防止梯度消失LayerNorm则稳定每层的激活分布。代码骨架大致是import torch import torch.nn as nn class MiniGPT(nn.Module): def __init__(self, vocab_size, d_model256, n_layers4, n_heads4, seq_len128): super().__init__() self.token_emb nn.Embedding(vocab_size, d_model) self.pos_emb nn.Embedding(seq_len, d_model) self.blocks nn.ModuleList([ TransformerBlock(d_model, n_heads) for _ in range(n_layers) ]) self.ln_f nn.LayerNorm(d_model) self.lm_head nn.Linear(d_model, vocab_size, biasFalse) def forward(self, idx): B, T idx.shape tok_emb self.token_emb(idx) # (B, T, d_model) pos_emb self.pos_emb(torch.arange(T, deviceidx.device)) x tok_emb pos_emb # (B, T, d_model) for block in self.blocks: x block(x) logits self.lm_head(self.ln_f(x)) # (B, T, vocab_size) return logits写这个的时候有一个容易出错的细节共享embedding和输出层的权重。GPT-2原文里用了权重绑定tied weights也就是token_emb和lm_head共享同一个权重矩阵这能减少参数并改善效果。我在早期版本忘了绑定结果损失一直在高位徘徊后来加上self.lm_head.weight self.token_emb.weight之后收敛快了很多。3. 核心实现训练循环、损失函数与推理采样3.1 训练循环的完整流程训练一个语言模型本质上就是在做一件事给定前文预测下一个token。所以训练样本不是一条条独立的文本而是从语料里滑窗切出来的上文-下文对。具体做法是把整个语料拼成一个长token序列然后用一个长度为seq_len的窗口不停滑动。比如序列长度是128那么第1到128个token作为输入目标就是第2到129个token。PyTorch里我们通常用batch x[:, :-1]作为输入target x[:, 1:]作为标签。我的训练循环长这样for epoch in range(epochs): for batch in dataloader: x, y batch[:, :-1], batch[:, 1:] logits model(x) loss criterion(logits.reshape(-1, vocab_size), y.reshape(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step()这里有一个我踩过无数次的坑没有做梯度裁剪。小模型在训练的早期特别容易出现梯度爆炸一个极端batch的loss能冲到几十然后所有参数都变成NaN。加上clip_grad_norm_之后训练稳定多了。优化器我用的是AdamW初始学习率3e-4warmup步数1000之后用cosine衰减。对于小模型来说这个配置基本是不会出错的通用方案。AdamW里的weight decay权重衰减我设了0.1它和Adam的L2正则不完全一样作用是让权重更小、泛化更好实现方式是在参数更新时直接减去一个衰减项而不是简单加到损失函数里。3.2 损失函数交叉熵里的门道语言模型的损失函数用的是交叉熵CrossEntropyLoss。从直觉上理解就是模型预测的概率分布和真实one-hot分布之间的差距。PyTorch里nn.CrossEntropyLoss会自动做softmax和取对数输入直接是logits不需要自己额外算softmax。但我这里想说的门道是如何处理pad位置。如果整个batch里有些序列因为长度不足而被padding了那么这些位置的目标token是无效的。你需要在损失计算时忽略掉它们。CrossEntropyLoss有一个ignore_index参数可以设置为tokenizer.pad_token_id。如果不设置模型会被迫学会预测pad token白占模型容量还会让损失值虚低。更高级一点的做法是用label_smoothing。我在后期实验中发现在小数据集上开启label_smoothing0.1能让生成的文本不那么机械。它本质上是把真实标签的one-hot分布稍微抹平让模型不要对训练样本过于自信起到一定的正则化作用。3.3 推理采样如何让模型真的写出人话训练完成后推理阶段需要从模型的概率分布里采样生成文本。如果每次都取概率最大的tokengreedy模型会陷入重复循环比如反复输出这家公司成立于2010年总部位于北京。这家公司成立于2010年……。我试过的几种采样策略里最实用的是top-pnucleus采样 temperature缩放。Temperature的作用是控制概率分布的尖锐程度probs torch.softmax(logits / temperature, dim-1)temperature 1.0比如0.7分布更集中大概率token的概率更大输出更保守temperature 1.0比如1.2分布更平坦小概率token也有机会被选中输出更随机Top-p的核心思想是按概率从高到低排序累计概率超过p比如0.9就截断只从这一小撮token里采样。这样既避免低概率token乱入又保留了多样性。我的采样实现def generate(model, tokenizer, prompt, max_new_tokens100, temperature0.8, top_p0.9): model.eval() tokens tokenizer.encode(prompt) input_ids torch.tensor([tokens]).to(device) with torch.no_grad(): for _ in range(max_new_tokens): logits model(input_ids[:, -128:]) next_logits logits[:, -1, :] / temperature probs torch.softmax(next_logits, dim-1) sorted_probs, sorted_idx torch.sort(probs, descendingTrue) cumsum torch.cumsum(sorted_probs, dim-1) cutoff (cumsum - sorted_probs) top_p sorted_probs[cutoff] 0.0 normalized sorted_probs / sorted_probs.sum() next_id torch.multinomial(normalized, 1) next_id torch.gather(sorted_idx, dim-1, indexnext_id) input_ids torch.cat([input_ids, next_id], dim-1) return tokenizer.decode(input_ids[0].tolist())注意这里有个细节因为模型只训练了seq_len128的上下文所以推理时每次只取最后128个token输入模型而不是整个增长序列。如果不做input_ids[:, -128:]这一步长文本生成时输入会越来越长内存消耗暴涨速度也会慢到无法接受。3.4 从语言模型到推理模型的初步尝试热词里提到的build a reasoning model from scratch其实是很多人的下一个目标。我在做完基础语言模型后也追了一把潮流在一个更小的玩具上做了一次简化版的推理能力探索。先说结论纯粹靠自回归预测next token模型学到的是统计共生关系不会产生真正的推理链。要让模型具备推理能力需要在数据层面加入思考过程然后在训练或微调里让它模仿这个过程。我的做法是构造了一个四则运算推理数据集。给模型输入一个算式比如23 * 17 但目标不是直接输出391而是输出一步一步的计算过程23 * 17 23 * 10 23 * 7 230 161 391为了让模型看到并且模仿这种思维链我把这个完整的分解计算过程当作目标文本去训练。效果上模型确实能模仿出类似的中间步骤偶尔能自己想出正确的多步计算。这个实验虽然离真正的推理涌现还很远但让我深刻意识到推理能力很大程度是数据模式的教学结果而不是模型结构自带的魔法。这个思路其实和现在的主流reasoning模型如OpenAI的o系列和DeepSeek-R1是一致的让模型在回答之前生成大量中间推理步骤再用强化学习或者长思维链的数据去引导。在小模型上强化学习的训练稳定性很难控制但思维链数据微调是完全可以跑通的。4. 常见问题与排查技巧实录4.1 训练损失不降先看数据还是先调模型我在第一次训练时遇到的第一个严重问题就是loss死活不降卡在7左右。当时第一反应是改模型结构试了增加层数、调学习率浪费了好几个小时。后来冷静下来排查发现根因出在tokenizer和数据处理上有些文本片段前后拼接时没有加分隔符导致一条样本的中间位置出现上一篇文章的末尾接下一篇文章的开头模型学到的纯属无意义跳跃。修复方式很简单在所有文档之间统一插入|endoftext|标记并把整个长token序列按标记切分为独立样本。损失立刻降到5以下。这个教训总结为排查顺序先检查数据再检查损失实现最后才动模型结构。4.2 生成内容疯狂重复采样参数的锅模型训练好了但生成的内容两三句之后就开始重复。这在小模型上极其常见也是我最常被问到的问题。直接的解决手段就是上面提到的top-p采样和temperature调节但还有一个隐藏原因训练数据的重复率高。我检查了一下语料发现维基百科的列表类页面比如历年电影列表产生了大量高度相似的句子相当于模型在重复学习固定句式。后来我在清洗时去掉了大量表格类和列表类页面重复生成问题明显改善。4.3 显存不足和训练速度过慢小模型理论上显存占用不大但我在batch size设成64、序列长度256时还是把一张8G显存的卡跑爆了。后续我把batch size降到16并开启梯度累积让有效batch size保持64accumulation_steps 4 for step, batch in enumerate(dataloader): loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这样每4个小batch才更新一次参数效果等同于batch size64但显存峰值只相当于batch size16。这是小成本训练里最实用的技巧之一。训练速度方面如果只用CPU慢到怀疑人生。有条件就直接上GPU哪怕是最入门的消费级显卡训练速度都有几十倍的差距。如果在云服务器上可以考虑FP16混合精度训练能让速度和显存占用都优化不少但要注意梯度缩放避免精度溢出。4.4 常见问题速查表问题现象可能原因排查手段损失长时间不降数据未添加分隔符、word embedding和输出层未绑定抽查数据拼接样本检查模型结构训练中出现NaN梯度爆炸、学习率过高添加梯度裁剪降低学习率生成内容无限重复采样策略太贪婪、训练语料过于重复改用top-p采样过滤重复语料显存不足batch size过大、序列过长减小batch size开启梯度累积或混合精度生成结果全是的了等无意义词tokenizer切分粒度过细、vocab太小增大vocab_size或使用更合理的预训练tokenizer模型只学会复制输入序列太短任务过于简单增加序列长度、引入更复杂的文本结构5. 一些真正有用的经验细节前面讲完主流程我再说几个容易被人忽略、但对项目成败影响挺大的细节。第一个是随机种子的固定。from scratch项目里你会频繁对比不同超参数的效果如果不固定种子每次训练结果都不同你很难判断某个改动到底有没有用。我在项目开始时就固定了Python、NumPy和PyTorch的随机种子以及cudnn的确定性模式确保每次实验可复现。第二个是定期保存checkpoint。我设定了每500步保存一次内容包括模型参数、优化器状态、当前step数和loss曲线。有一次训练了3个小时后突然断电如果没有checkpoint心态真的会崩。用PyTorch的话torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), step: step, config: config, }, fcheckpoints/step_{step}.pt)第三个是把损失曲线存下来。我在训练时用matplotlib实时画loss曲线保存到本地。这张图是你判断是否过拟合、学习率是否合适、数据是否存在问题的第一手证据。很多人训练完只看最后的loss值其实曲线的形状信息量大得多比如loss在warmup结束后骤降然后又反弹说明学习率跳变太大比如验证loss还在降但训练loss已经平稳说明模型还没充分训练。第四个和from scratch精神有关不要急着抄别人的代码。我最初也参考了很多开源实现但参考完一定要自己从零复写一遍。因为只有自己把每一行逻辑写出来才会发现那些隐藏在众所周知背后的假设为什么attention要scale by sqrt(d_k)为什么embedding后面要乘sqrt(d_model)为什么没有做causal mask之前不能让训练集里的target信息泄漏到输入里。这些问题不亲手写一遍永远注意不到。最后聊聊扩展方向。跑通这个微缩GPT和思维链玩具之后你可以往几个方向走一是把模型scale up比如训练到1亿参数级别体验一下数据量和算力对效果的边际影响二是加入指令微调instruction tuning构造一批{instruction: ..., output: ...}数据让模型学会遵循指令三是尝试用强化学习比如简化版的policy gradient来鼓励模型产生更长的正确推理步骤这基本上就是reasoning model的最简雏形。我自己在做完这个项目后最大的体会是AI工程并不玄乎它就是把数据、模型、训练、推理这几件事各自拆开然后一个一个搞清楚。只要你不怕慢、愿意从最小的规模开始迭代完全可以从零构建一个能看、能玩、能继续扩展的完整系统。希望这篇记录能让你少走一些我走过的弯路。