这一两年ai-engineering-from-scratch几乎变成了我朋友圈里的一个暗号。最早是看到有人从零手写一个迷你 Transformer后来是有人把 tokenizer、预训练、微调整个链路自己跑通再后来又有朋友开始折腾“从零构建一个 reasoning model”。说实话任何一个在这个行业里待过一段时间的人看到这些词都会有同一种感觉不想再做调包侠了。所谓 from scratch并不是让你真的从硅片开始造芯片也不是否定现有框架的价值而是把那些被封装好的黑盒重新拆开搞清楚里面每一层到底发生了什么。这篇文章想聊的就是这件事如果你也想从零开始走一遍 AI 工程这条路到底该怎么走、要踩哪些坑、学到什么程度才算真正“会了”。不管你是一线算法工程师、刚入门的学生还是想转行做 AI 的开发者这条路线里的思路和实操细节应该都能给你一些参考。1. 内容整体设计与思路拆解1.1 先搞清楚“从零”的四个层级我在各种技术社区里看过太多人被“从零开始”劝退或者反过来被“从零开始”误导。关键问题是这个词本身就有歧义不同人说的 from scratch 根本不是一个东西。按我自己的理解可以粗略分成四个层级。第一层是“从 API 调用开始”。你注册一个大模型的 API写好 prompt处理返回结果拼装成一个应用。这一层其实不叫 AI 工程叫应用工程但很多刚入行的人就是从这步开始的。第二层是“从开源模型开始”。你下载一个开源权重在本地跑推理做 LoRA 微调接入自己的数据。这是目前绝大多数中小团队的实际状态。第三层是“从预训练开始”。你从随机初始化权重出发自己准备语料自己写训练脚本训练出一个 base model。到这一步已经算是真正进入 AI 工程的核心腹地了。第四层才是真正意义上的“从零”也就是自己设计模型结构、自己实现 attention、自己写 tokenizer、自己设计训练策略不依赖现成的 transformers 库来帮你搭建模型。我建议绝大多数人把目标定在第三层和第四层之间。因为如果你能把一个 100M 到 1B 参数规模的模型从随机权重训练到可用的状态你基本上已经把 AI 工程里最核心的几个环节都过了一遍数据处理、模型搭建、训练稳定化、评估迭代。至于那些“30 分钟跑通大模型训练”的教程看看就好它们通常只是帮你按下了别人的脚本里的启动按钮。1.2 为什么要自己动手走一遍全流程有人会问现在开源生态这么成熟我用现成框架十分钟就能搭一个模型出来为什么还要自己从头写我举一个例子你就明白了。假设你负责给公司做一个客服问答系统方案是用开源模型做微调。你按部就班地加载模型、准备数据、跑训练一切都很顺利。但模型上线后开始出现奇怪的输出——偶尔会把用户的地址信息重复三遍偶尔会在一段回答中间突然插入一个毫无意义的乱码。如果只用现成框架你大概率只能归因于“模型能力不行”或者“数据质量不好”然后陷入反复调数据的死循环。但如果你亲手写过 tokenizer你马上会意识到这些乱码很可能是 token 的合并规则出了问题比如某些词被分成了几个碎片而模型对碎片之间的边界产生了幻觉。你如果手写过 attention你就会知道位置编码在长文本上的衰减特性从而明白为什么输出会在一段话中间突然“失忆”。这就是 from scratch 的价值。它未必让你能做出比开源社区更好的模型但它能让你在问题发生时不至于只能两手一摊。这个行业最贵的能力不是训练一个大模型而是定位一个问题到底出在哪个环节。2. 核心基础模型内部的关键机制与实现要点2.1 Tokenizer一切问题从这里开始如果你要自己做一个模型第一个要动手实现的不是 Transformer而是 tokenizer。很多人觉得 tokenizer 就是一个分词工具无所谓。但训练过大模型的人都知道一句话garbage in, garbage out。tokenizer 做得不好后面的所有工作都是在补救。目前主流方案是 BPEByte Pair Encoding。它的思路其实很朴素最开始把每个字节当成一个 token然后反复统计语料中相邻 token 对的出现频率把最高频的 token 对合并成一个新 token直到 vocabulary 达到你预设的大小。你可能觉得这没什么技术含量但这里有几个参数的选择直接影响最终效果。第一个是 vocab size。太小了一个词会被切成很多碎片序列长度变长训练成本上升太大了词表稀疏很多 token 在训练中根本没出现过几次学习不充分。一般 100M 级别的模型vocab size 设在 8K 到 32K 之间比较合理。第二个是是否区分大小写。如果你做的是英文模型lowercase 会让词表更小、训练更充分但会损失一些区分度。第三个很关键但经常被忽略tokenizer 必须在你自己的训练语料上重新训练而不能直接拿别人训练好的 tokenizer 过来用。我见过有人直接下载一个中英混合模型的 tokenizer 来训练一个纯英文模型结果中文 token 占了词表一大半英文模型的表达空间被白白浪费了。实操上现在很多教程让你直接调tokenizers库几行代码搞定。但我建议你至少在早期手动写一个简单的 BPE 实现哪怕效率很低、只在一个小语料上跑也能让你真正理解“合并规则”“词表增长”“编码与解码的对称性”这些概念。这就像学开车之前先学一下离合器原理虽然你以后开车不用低头找离合器但遇到半坡起步的时候你就知道该干什么了。2.2 Embedding 与位置编码模型怎么“理解”语言的起点Tokenizer 把文本变成了整数序列embedding 层再把每个整数映射成一个向量。这个过程很多人觉得理所当然但有几个细节值得你亲手验证。第一个是 embedding 层和输出层经常共享权重。在 GPT 的原始实现里输入 embedding 和输出投影矩阵是共享的这样可以减少参数量而且效果通常不会变差。但注意如果你不做这个共享模型其实也没有问题只是参数量多了。第二个是位置编码。你从头实现一个 Transformer 时第一个要决定的事是用绝对位置编码、相对位置编码还是 RoPE。绝对位置编码的典型代表是原版 Transformer 里的 sinusoid 编码以及 GPT-2 里的 learned positional embedding。它们的共同问题是模型对位置的泛化能力有限训练时最长序列是 512推理时来了个 600 的序列效果就会明显退化。相对位置编码和 RoPE 则能把位置信息建模得更好。其中 RoPE 已经是当前大模型的事实标准几乎所有主流开源模型都在用它。我自己从零实现过这三类位置编码最大的感受是RoPE 的实现并不复杂核心就是给 query 和 key 的向量乘上一个基于位置旋转的矩阵但理解它为什么要这样设计需要你先搞清楚“点积注意力只看相对位置”这个数学直觉。这个直觉一旦建立你以后学习任何新模型的位置编码方案都会快很多。2.3 Attention从公式到代码的真实距离所有讲 Transformer 的文章都会给你看那个经典公式import torch import torch.nn.functional as F def scaled_dot_product_attention(q, k, v, maskNone): d_k q.size(-1) scores torch.matmul(q, k.transpose(-2, -1)) / (d_k ** 0.5) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn_weights F.softmax(scores, dim-1) output torch.matmul(attn_weights, v) return output这段代码你大概能默写出来但真正工程化的实现远不止这些。有几个问题你只有在实际训练时才会撞上。第一个是带 mask 的 attention 的数值稳定性。如果你直接对 scores 做 softmax而有 mask 的位置填了-inf在 fp16 混合精度下很容易出现 NaN。工程上更好的做法是先masked_fill再减去一个最大值做稳定化或者直接用 PyTorch 原生支持的scaled_dot_product_attention它内部已经帮你处理了这些细节。第二个是 attention 的缓存机制。训练时你用的是完整序列的并行 attention但推理时是逐 token 生成的。如果你不实现 KV cache每生成一个 token 都要重新算一遍前面所有 token 的 key 和 value推理速度会慢上十几倍。这个从“训练模式”到“推理模式”的切换是无数 from scratch 项目卡壳的地方因为模型结构一样但两次的代码路径完全不同。第三个是 attention mask 的类型。causal mask 决定了一个 token 只能看它前面的 token这是 GPT 类模型的核心。但如果你做的是 encoder-decoder 或者多模态模型你会需要 cross-attention mask、padding mask 同时存在。这个我之前就踩过坑——训练时 loss 一直不降查了半天才发现是 padding 位置没有 mask 掉模型在对着无数个padtoken 学习特征。2.4 归一化与残差让训练跑起来的隐形功臣很多人会把注意力都放在 attention 和 MLP 上忽略 LayerNorm 和残差连接。但从业余到专业的分水岭恰恰体现在这些细节上。LayerNorm 的作用是把每个 token 的 hidden state 归一化到均值为 0、方差为 1然后再缩放和平移。这个操作能让每一层的输出分布保持稳定避免梯度爆炸或消失。但同样叫 LayerNorm位置不同效果完全不同。Pre-LN 是在 attention 和 MLP 之前做归一化Post-LN 是在之后做。现代大模型几乎都用 Pre-LN因为它让训练更稳定可以放心把学习率调大。我自己实验过一个 400M 参数的模型用 Post-LN 时训练到第 3 万个 step 就开始出现 loss 震荡换成 Pre-LN 之后一路平顺到底。这个改动就两行代码但效果天差地别。另外有一类是 RMSNorm它去掉了 LayerNorm 里的均值中心化只保留 RMS 缩放。它的计算量更小效果却不输 LayerNorm因此 Llama 系列都用的它。你从零实现时直接上 RMSNorm 就好省一点显存是一点。3. 实操一条龙从数据到训练的核心环节实现3.1 数据准备语料清洗与配比的艺术说实话模型结构你写对了训练代码你写对了数据的质量才是最终决定模型上限的因素。大模型训练圈有一句话模型是炉子数据是菜火候是训练策略。菜不好炉子再好也白搭。第一步是清洗。你从网上爬下来的原始语料需要去掉 HTML 标签、URL、乱码、重复片段。但这里有个很反直觉的点不要清洗得“太干净”。比如很多英文语料里夹杂的一些代码片段和表格如果你都删掉模型对代码和结构化文本的理解能力就会变差。清洗的核心目标是去除“模型学了会变傻”的内容比如重复无效的文本、低质量的机器翻译噪声而不是追求纯粹的“标准语言”。第二步是去重。用 MinHash 做模糊去重是常规操作因为语料里大量重复会导致模型过拟合而且训练效率极低。我去重过一个几十 GB 的语料去重前后差了将近 40%这个比例非常惊人。第三步是配比。训练一个多语言模型时不是简单地按数据量比例混合就完了而是要对低资源语言做上采样upsampling否则英文数据会在训练中完全压制小语种。分享一个实操经验训练流程一定要先在小规模数据上跑通再上全量。很多人直接就拿全部语料开始训结果训练到一半发现数据预处理有 bug几万美元的算力就白花了。我自己的标准流程是先拿总数据量的 0.1% 跑通整个 pipeline确认 loss 曲线正常再扩展到 10%最后才上全量。3.2 用 PyTorch 从零搭建一个迷你 GPT 模型下面给你一个可以直接跑的最小实现骨架。这个模型只有 8.7M 参数但麻雀虽小五脏俱全包含 token embedding、位置编码、12 层 Transformer、RMSNorm、KV cache 推理。我建议你把它当成一个“玩具模型”先在本机 CPU 上跑通再去考虑扩展。import torch import torch.nn as nn import math class RMSNorm(nn.Module): def __init__(self, dim, eps1e-6): super().__init__() self.eps eps self.weight nn.Parameter(torch.ones(dim)) def forward(self, x): rms torch.sqrt(x.pow(2).mean(-1, keepdimTrue) self.eps) return x / rms * self.weight class CausalSelfAttention(nn.Module): def __init__(self, dim, n_heads): super().__init__() assert dim % n_heads 0 self.n_heads n_heads self.head_dim dim // n_heads self.wq nn.Linear(dim, dim, biasFalse) self.wk nn.Linear(dim, dim, biasFalse) self.wv nn.Linear(dim, dim, biasFalse) self.wo nn.Linear(dim, dim, biasFalse) def forward(self, x, kv_cacheNone): B, T, C x.shape q self.wq(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) k self.wk(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) v self.wv(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) # 训练时直接用 PyTorch 的 flash attention省略 mask 细节 y torch.nn.functional.scaled_dot_product_attention( q, k, v, is_causalTrue ) y y.transpose(1, 2).contiguous().view(B, T, C) return self.wo(y) class MLP(nn.Module): def __init__(self, dim, hidden_mult4): super().__init__() hidden_dim dim * hidden_mult self.fc1 nn.Linear(dim, hidden_dim, biasFalse) self.fc2 nn.Linear(hidden_dim, dim, biasFalse) self.act nn.GELU() def forward(self, x): return self.fc2(self.act(self.fc1(x))) class TransformerBlock(nn.Module): def __init__(self, dim, n_heads): super().__init__() self.attn CausalSelfAttention(dim, n_heads) self.mlp MLP(dim) self.norm1 RMSNorm(dim) self.norm2 RMSNorm(dim) def forward(self, x): x x self.attn(self.norm1(x)) x x self.mlp(self.norm2(x)) return x class MiniGPT(nn.Module): def __init__(self, vocab_size, dim256, n_layers6, n_heads8, max_seq_len256): super().__init__() self.token_embed nn.Embedding(vocab_size, dim) self.pos_embed nn.Embedding(max_seq_len, dim) self.blocks nn.ModuleList([ TransformerBlock(dim, n_heads) for _ in range(n_layers) ]) self.norm_f RMSNorm(dim) self.lm_head nn.Linear(dim, vocab_size, biasFalse) # 共享 token embedding 和 lm_head 的权重 self.token_embed.weight self.lm_head.weight self.max_seq_len max_seq_len def forward(self, idx): B, T idx.shape assert T self.max_seq_len tok self.token_embed(idx) pos torch.arange(0, T, deviceidx.device).unsqueeze(0) x tok self.pos_embed(pos) for block in self.blocks: x block(x) logits self.lm_head(self.norm_f(x)) return logits这段代码虽然简化了 KV cache 和推理分支但训练流程已经完整可跑了。你用它训练时loss 下降不太快是对的因为它只有 8.7M 参数你要的是把整个 pipeline 跑通而不是追求效果。3.3 训练策略学习率、Batch Size 与稳定性控制模型写完了接下来就是把训练脚本跑起来。这里我要重点讲几个新手最容易踩的坑。第一个是学习率。Transformer 对学习率非常敏感。经验上8M 到 100M 参数的小模型峰值学习率在 3e-4 到 1e-3 之间比较合适。我见过很多人上来就用 1e-4 训一个小模型结果收敛慢得怀疑人生也有人直接套大模型的 1e-5结果模型根本不动。正确的做法是加一个 warmup 阶段前几百个 step 从 0 线性升到峰值后面再用 cosine 或者线性衰减到 0。第二个是 batch size 与梯度累积的关系。受显存限制你可能一次只能放进去 16 个样本但你的目标 batch size 是 128。那就做 8 步梯度累积每步更新 loss 时把前面几步的梯度加在一起再更新。这里有个细节要注意——梯度累积之后需要除以累积的步数来归一化否则 loss 对学习率的敏感性会被放大。第三个是混合精度。PyTorch 现在用torch.autocast加上torch.compile基本是标配了。但混合精度下偶尔会出现 loss 突然变成 NaN 的情况。我做过的排查方法是先在 fp32 下跑 500 步确认正常再开 autocast如果开了之后出 NaN优先检查 attention mask 里是否出现了-inf然后是 RMSNorm 的 epsilon 是否太小。还有一个很多人会忽略的细节torch.compile在模型规模小的时候可能反而更慢。你的 8M 参数玩具模型用torch.compile编译一次可能要 30 秒而本身训练一步只要 0.1 秒纯属浪费时间。建议当模型超过 100M 参数、训练步数足够多时再启用。3.4 从“能用”到“能推理”RL 与 reasoning model 的构建思路顺着热词里那个“build a reasoning model from scratch”往下说。很多人问我现在已经训练出了 base model也做了 SFT为什么它还是不会推理这个问题的根源在于推理能力不是天然涌现的基础能力而是需要被专门激发和强化的能力。推理模型和普通对话模型的核心区别在于它在给出最终答案之前会先产生一段很长的思考chain-of-thought然后基于思考结果给出回答。训练它的主流路径分两步。第一步是收集高质量思维链数据做 SFT。所谓思维链数据就是“先分析问题再逐步推理解题最后总结答案”这样的完整记录。如果你没有真实的思维链数据也可以让一个强的模型先生成推理路径再用规则或者弱模型筛选质量高的样本这就是蒸馏思路。第二步是强化学习RL阶段。最经典的做法是 PPO但 PPO 在语言模型上实现复杂度比较高而且对超参数极其敏感。相比之下现在很多项目会用更简洁的方案比如 GRPO 或者干脆做“rejection sampling best-of-n”。核心思想是让模型对同一个问题生成多个答案用规则奖励rule-based reward打分然后针对得分高的答案做偏好优化如 DPO。这里的难点在于奖励设计。如果你做的是数学题可以验证最终答案是否正确奖励很明确。但如果你做的是开放性问答没有客观答案奖励就非常难设计这一点是限制 reasoning model 应用到宽泛领域的最大瓶颈。从零实现一个最小规模的 reasoning model我建议这么做先拿一个开源的小模型权重准备一个数学题数据集用规则判断答案对不对再用 DPO 做偏好优化。整个流程可以在单卡上跑完。跑完你会发现一个明显的现象模型在训练之前给出的答案直来直去训练之后它会先写一大段“已知条件”“逐步推导”再给结论。这就是 reasoning 行为被强化出来的外在表现。4. 常见问题与排查技巧实录4.1 训练 Loss 不降先查数据再查代码这是最常见的问题没有之一。我自己的排查顺序是固定的先看训练数据的样本长什么样尤其是文本里是否混入了大量空行、特殊字符、重复片段然后看 tokenizer 把样本切成了什么样子会不会一整段都被切成了一个超长 token最后才回到代码里检查 loss 计算是否正确。有一个我印象深刻的问题一个朋友自己写了 tokenizervocab size 是 5000但他的语料全部是英文。训练时 loss 从一开始就很高然后几乎不动。我帮他一看发现他的 BPE 合并次数不够导致很多高频词被切成了单字符碎片模型要学习的序列变得特别长信息密度极低。这种问题你光看 loss 曲线是看不出来的必须回到数据层面去看。4.2 Loss 突然暴涨梯度灾难还是数据异常训练到中途 loss 突然飙升基本都是两种原因一是学习率过大导致梯度爆炸二是 batch 里混入了异常长或者异常脏的数据。排查时先回滚到最近的 checkpoint把当前 batch 打印出来看看有没有不寻常的样本再把学习率调低一半重跑。这里我分享一个自己长期使用的防炸技巧训练循环里每隔固定步数就检查一次梯度范数如果超过阈值就跳过这次更新并打印警告。代码很简单for step, batch in enumerate(train_loader): loss model(batch) loss.backward() grad_norm torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) if grad_norm 5.0: print(fStep {step}: grad_norm too high, skipping update) optimizer.zero_grad() continue optimizer.step() optimizer.zero_grad()这样训练可以长时间无人值守遇到偶发异常也不会直接炸掉整个训练任务。4.3 显存不够从 Flash Attention 到梯度检查点很多人问我想训练一个 1B 的模型但只有一张 24G 的卡怎么办答案不是去租 8 卡服务器而是先把优化手段用尽。第一个手段是 Flash Attention它能显著减少 attention 部分的显存占用。PyTorch 2.0 之后scaled_dot_product_attention会自动选择 Flash Attention 的实现所以只要你不手写torch.matmul版本的 attention就能自动吃到这个红利。第二个手段是梯度检查点gradient checkpointing它用时间换空间反向传播时不再保留所有中间激活值而是重新前向计算一遍。显存能省 40% 到 60%代价是训练速度慢 15% 到 30%。第三个手段是降低序列长度加梯度累积。很多任务真正的瓶颈不是模型参数而是输入序列太长。一个 2048 长度的序列和 512 长度的序列显存差距是四倍。如果任务允许先把最大长度降下来是性价比最高的做法。当然如果以上手段都用尽了还不够那就老老实实租更多的卡吧。分布式训练从torch.distributed开始学起但那是另一个大话题了。4.4 复现不了别人的训练效果随机性与环境一致性最后一个要聊的坑是复现性。很多 from scratch 项目的教程都是理想状态你把参数照抄过来效果却对不上。原因通常有三类一是 CUDA 的非确定性操作比如某些算子在不同设备上的结果有微小的浮点差异训练过程中会被逐步放大二是数据集的采样顺序不同会导致 model 记住的顺序不同三是框架版本差异你可能用了 PyTorch 2.1但教程写于 PyTorch 1.13光这一个版本差就可能导致训练曲线完全不同。要缓解这个问题可以在训练脚本开头设置几行代码固定随机种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 注意这行会降低速度仅限调试时使用 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False但说实话训练大模型的复现性和图像分类的复现性是很不一样的。图像模型即使复现不了完全相同的精度也能稳定复现在一个很小的误差范围内。而语言模型在长尾行为上的随机性非常大所以不要苛求“完全一样”只要 loss 曲线趋势一致就说明你的实现没有原则性问题。4.5 推理阶段与训练阶段模型行为不一致这个问题很少人提前预警但它几乎必然发生。你训练完一个模型测试的时候发现训练时 loss 正常生成出来的内容却乱七八糟。我说实话这大概率是你推理代码写错了。训练和推理有两个关键差异。第一个是 KV cache。如果你推理时没有正确地复用 cache每次解码都要重新计算全部历史生成速度会非常慢而且每一步计算时因为位置信息不一致结果也可能漂移。第二个是 temperature 和 top-p。训练时模型学习的是概率分布推理时如果 sampling 参数设得过于激进生成的文本就会显得“疯狂”。小模型尤其明显因为它的概率分布本来就不够尖锐你再把 temperature 调到 1.2输出就会像喝醉了酒一样。这里给一个调试技巧推理时先不开采样直接用 greedy decoding即每次取概率最高的 token。如果 greedy 输出还不太烂说明模型本身学到了东西如果 greedy 输出就已经是胡言乱语那大概率是模型欠拟合不要再去折腾推理代码了。5. 除了技术工程习惯与路线建议5.1 日志记录是 AI 工程的第一生产力我说一句可能不太中听的话很多所谓“踩坑经验”其实只要日志写得好就能避免。训练大模型不像写 CRUD 接口出了 bug 可以马上重启调试。一次训练跑三天中途模型崩了你如果没有日志就完全不知道为什么崩的只能重新再来。每次训练任务至少要把这些信息记录下来超参数配置文件包括每个参数的来源、模型结构定义代码的 commit hash、数据集的版本与配比、每一步的 loss 和梯度范数、以及当时的显存使用情况。我自己会用wandb来做可视化训练曲线同时在本地定期保存 checkpoint。更重要的是每次调试完一个 bug都要在项目的LOG.md里记一行写上“问题描述、原因、解决方式”。三个月之后你再回来看这份日志比任何教程都有价值。5.2 从小起步复现一个已有的小模型比自创更有意义如果你是第一次尝试 from scratch我强烈建议你不要一开始就设计一个全新的模型结构而是找一个已有的经典结构比如 GPT-2 的 124M 配置先原样复现出来。原因很简单别人的结构参数是经过调优的你复现时一旦效果和论文对不上基本可以确定是你的代码有 bug但如果你自己发明一个新结构效果不好时你根本无法区分是结构问题还是实现问题。当你完整复现出一个 124M 的 GPT 模型之后你其实就具备了在大模型领域举一反三的基础能力。这时你再去看 Llama 或者 Mistral 的代码会发现它们只是在这个骨架上做了几个局部改动比如 RMSNorm、RoPE、GQA。你会非常自然地理解这些改动的动机而不需要被人按着头灌输“这是最优实践”。骨架你已经搭完了新的结构和新的训练技巧只是在做加法。真正难的地方是从 0 到 1 这一段而这段路的捷径就是照着经典实现逐行重写让模型自己开口说话。等到你亲手训的模型在生成出第一个有语义的句子时你会明白什么叫作真正的从零开始。