
想真正搞懂 AI engineering而不是只做“调包侠”你迟早要面对 from scratch 这件事。我自己的经历就是最好的例子前两年用人家的预训练模型做微调跑完 demo 也觉得自己会 AI 了直到有一天被一个问题问住——“Attention 里的 Q、K、V 到底怎么生成到你需要的形状的”我当场语塞。从那天起我决定自己从零开始写一个能训练、能推理的小模型把整个 AI 工程闭环亲手跑一遍。这篇文章就是围绕“ai-engineering-from-scratch”的完整复盘适合那些不想停留在“pip install 然后 call API”的工程师想真正理解模型原理、训练流程和落地工程化的人。我会讲清楚四个核心块底层数学与系统基础、亲手实现小型语言模型、如何加入推理能力以及真正生产环境里的工程优化。1. 为什么“从零开始”是理解 AI 工程的唯一路径这句话说出来可能有点绝对但它是我踩过无数坑之后的真实判断。所谓 from scratch不是什么硬件都要自己造而是要求你能够在没有现成深度学习框架辅助的情况下理清一条数据从文本变成 token、再变成概率分布、最后生成新文本的完整链路。很多人学了三个月 Transformer却不知道 embedding 层到底该怎么初始化不知道训练时为什么需要 mask不知道 decode 阶段 temperature 和 top-p 采样为什么能影响回答风格。1.1 把“从零”定义清楚不是造轮子而是拆轮子这里要先消除一个误解现代 AI 工程不可能完全从晶体管开始造也不应该这样做。我理解的 from scratch 是“站在基础库和硬件的肩膀上但把模型架构、训练逻辑、推理策略亲手写出来”。比如你想实现一个简化版 GPT你可以用 PyTorch 和张量操作但你不能直接调用transformers库里的GPT2LMHeadModel然后假装自己会了。你要自己写nn.Module自己管理 attention mask自己实现交叉熵损失和权重衰减自己写训练循环和断点续训。这样做的价值我在面试中被反复验证过候选人口头禅是“我们用了 LLaMA 微调”但问他“LoRA 的两个矩阵怎么初始化为什么秩要取那个数”就沉默了。亲手拆过轮子的人至少能讲清楚每个参数从哪里来、到哪里去。1.2 为什么“会调库”和“会工程”是两回事调库解决的是“别人解决的问题如何复用”而工程解决的是“新问题如何建模、如何实现、如何部署”。ai-engineering这个词的落点是工程工程意味着你要在你自己的数据、算力、业务约束下让模型稳定跑起来并达到预期效果。举个例子很多人用 HuggingFace 的Trainer它帮你处理了梯度累积、混合精度、学习率调度看起来很简单但一旦 loss 变 NaN、显存不够、分布式通信卡住你几乎无从下手。而当你从零实现了训练循环你会被迫理解为什么 loss 在某个 step 突然跳变可能是学习率太高还是数据批次里有脏数据为什么 DDP 同步梯度之后效果反而变差可能是 batch size 变大导致需要调学习率为什么同一个 checkpoint 在 eval 时表现不稳定可能和 dropout 没有关闭有关这些问题在我的项目里全部出现过后面我会详细讲排查过程。这里我想说的是如果你没有写过底层训练代码这些问题在你眼里就是“玄学”但对于有 from scratch 经验的人来说它们是可定位、可复现、可修复的工程问题。1.3 热词背后的真实需求想自己造“推理模型”你看现在社区里很多人在搜 “build a reasoning model from scratch”这其实是一个比“从零构建大语言模型”更进阶的诉求。大语言模型LLM擅长生成但“推理模型”要的是在给定问题下逻辑连贯、多步推导、可验证的思维链。想要亲手做一个 reasoning model你不仅要会训练 GPT还要理解数据构造、监督微调、强化学习/偏好优化、测试时计算等一整套方法论。不过别被吓到任何复杂的 reasoning 能力都建立在底层语言模型之上。所以我的路线是先实现一个小规模的语言模型让它能生成通顺的文本再在这个基础上通过“思维链数据 策略优化”诱导它产生推理行为。这条路线非常符合从零开始的渐进逻辑下面的章节就是按这个顺序展开的。2. 地基打牢AI 工程不可跳过的数学与系统知识这部分看起来是最枯燥的但没有它们后面的代码全是空中楼阁。我自己刚开始学的时候也问过不做纯算法研究数学需要学到什么程度现在我可以给出一个相对务实的答案——不需要你能证明定理但必须能看懂公式、推导梯度形状、理解为什么矩阵乘法会占这么多显存。2.1 线性代数与概率读论文的敲门砖在 transformer 模型里你天天打交道的操作是(B, T, C) (B, C, T)这样的张量乘法。如果不知道“矩阵乘法的形状匹配规则”你连 attention score 怎么从[B, H, T, T]变成概率都搞不清。我复习时用的方法是边推导边写代码对于 attention手动写出 QK^T 的形状变化、除以 sqrt(d_k) 后做 softmax、再乘 V 的过程。这个过程用几行 numpy 就能模拟做完之后你对nn.MultiheadAttention内部发生了什么就有画面感了。概率方面重点不是啃测度论而是理解语言建模本质是最大化下一个 token 的 log 概率对应交叉熵损失采样时用的 temperature 是分布缩放系数top-p 是概率累积截断KL 散度用于度量两个概率分布的差异这是 RLHF 里惩罚项的理论基础。2.2 系统基础内存、算力与并行不止是运维的事AI 工程和其他软件工程最大的区别是你写的代码要特别关心内存布局和访存效率。我在训练一个 1 亿参数模型时就遇到过 32GB 显存依然爆掉的情况原因是我没有用混合精度也没有做 gradient checkpointing。你需要知道一个普通 fp32 参数占 4 字节1 亿参数就是 400MB但训练时还要存梯度、优化器状态Adam 额外两倍以及中间激活值。算下来一个 batch 的激活值可能比参数还大很多倍。所以你必须理解为什么混合精度能把训练显存降一半因为在 fp16 下很多张量占用 2 字节但主权重仍用 fp32 累积更新为什么梯度累积能解决显存不足因为它用小 batch 多次前向反向累积一定步数的梯度后再更新参数为什么张量并行能把一个大模型放到多卡因为它把矩阵乘法的行或列分到不同显卡上每个卡只算一部分。这些知识在“用 from scratch 训练一个模型”的过程中会被无限放大。我从一个盲目batch_size64往 GPU 里塞的新手到学会先估算显存、再定 batch size、最后调整混合精度策略的老手只差几次 OOM 的教训。2.3 必备工具链和参考资源既然是工程导向工具链也要提前列出来。我这里不推荐用重型框架而是建议你自己用 PyTorch 纯手写模型代码配合以下工具tiktoken做 BPE 分词或者干脆自己写一个简单的字符级分词器适合小模型numpy做数值验证有时我写自定义算子会先用 numpy 跑通形状torch.profiler定位训练瓶颈tensorboard或wandb记录 loss、梯度范数、学习率等指标。至于参考资源谷歌上搜 “build a large language model from scratch” 可以找到很多优质博客和开源课程但注意不要下载任何非授权的网盘资料直接去作者官方页面或 GitHub 仓库看原始版就好这也是我们 respect 开源的方式。3. 从零实现一个小型语言模型数据、架构、训练三项工程当你有了一定数学和系统基础接下来就是最兴奋的部分亲手写出一个能“说话”的模型。我不会直接贴一个几千行完整库的地址而是按工程顺序拆解我实现一个 3000 万参数规模的字符级/子词级 GPT 的完整过程。这里我用的是 PyTorch代码片段是可运行的核心逻辑。3.1 准备数据文本如何变成 token 流我们先用一个公开的纯文本语料比如莎士比亚作品或者某个中文小说集注意版权选开放的作为起步。第一步是分词字符级是最简单的方式把每个字符映射到一个整数。这样初始词表大小大概只有几千非常适合小成本实验。# character-level tokenizer chars sorted(list(set(corpus))) vocab_size len(chars) stoi {ch: i for i, ch in enumerate(chars)} itos {i: ch for i, ch in enumerate(chars)} encode lambda s: [stoi[c] for c in s] decode lambda l: .join([itos[i] for i in l])接下来把整段文本切成固定长度比如 block_size256的训练样本。注意每一个样本不仅要拿“输入序列”还要拿“目标序列”——就是输入整体右移一位的结果。PyTorch 的Dataset里可以直接返回x和yy[i]表示给定x[:i]后要预测的下一个 token。我当时犯过一个新手级错误忘了做随机的序列偏移采样导致模型每批看到的文本永远从同一位置开始结果训练到后面 loss 下降很慢数据多样性不足。正确做法是每次随机选一个起始索引再取连续的 block_size 字符。3.2 写一个极简但完整的 Transformer Block这里我不带nn.Transformer而是自己实现一个 decoder-only 的 block。最核心的点是 masked multi-head self-attention。为什么要 mask因为在预测第 t 个 token 时模型不能看到 t 之后的 token否则就“作弊”了。实现时我们用torch.tril生成下三角矩阵把未来位置置为-inf。def scaled_dot_product_attention(q, k, v, maskNone): d_k q.size(-1) scores q k.transpose(-2, -1) / math.sqrt(d_k) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn torch.softmax(scores, dim-1) return attn v在nn.Module里我会把每个 block 组织成LayerNorm - Attention - 残差连接 - LayerNorm - MLP - 残差连接。这和在很多博客里看到的 Pre-LN 结构一致。Pre-LN 比 Post-LN 更好训练尤其是对深模型loss 会更稳定。一个小经验初始化时最后的输出层权重不要太大否则初始 loss 会异常高。可以借鉴前人做法把残差分支的最后一个 Linear 层以小标准差初始化这样模型一开始的表示近似恒等映射训练更稳定。3.3 训练循环那些文档里不会告诉你的细节训练一个语言模型loss 就是交叉熵。不要自己去for s in batch循环计算直接用nn.CrossEntropyLoss它默认会对所有 token 位置求平均。具体输入要排成[B*T, vocab_size]的 logits 和[B*T]的目标索引。我写的极简训练循环大致长这样def train_step(model, x, y, optimizer): logits model(x) B, T, C logits.shape loss F.cross_entropy(logits.view(B*T, C), y.view(B*T)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() return loss.item()注意clip_grad_norm_这行很多入门教程里没有强调但对于从零实现的模型梯度范数爆炸是常态。不 clip训练曲线直接飞走。我见过 loss 从 2.3 猛跳到 3.5 然后再也没回来就是没加 clip。训练过程中我最看重的两个指标不是 loss 本身而是训练 loss 和验证 loss 的差差值持续变大就是过拟合信号梯度范数如果经常触发 clip 到 1.0说明学习率太高或模型初始化有问题。另外一定要定期保存 checkpoint不要只存模型权重还要存 optimizer 的状态、当前 epoch、学习率。否则中断后想恢复训练却只能从零开始这个坑我踩过不下三次。3.4 让模型“开口说话”自回归生成训练完成后模型就是一个概率分布生成时要用采样策略。最基本的是model(x[-1])拿到最后一个 token 的 logits然后从概率分布中采样。但直接采样随机性大这里引入 temperature 和 top-p。def generate(model, idx, max_new_tokens100, temperature1.0, top_p0.9): for _ in range(max_new_tokens): logits model(idx) logits logits[:, -1, :] / temperature probs torch.softmax(logits, dim-1) sorted_probs, sorted_indices torch.sort(probs, descendingTrue) cumsum torch.cumsum(sorted_probs, dim-1) keep cumsum - sorted_probs top_p sorted_probs[keep] 0 sorted_probs sorted_probs / sorted_probs.sum(dim-1, keepdimTrue) next_token torch.multinomial(sorted_probs, num_samples1) idx torch.cat([idx, next_token], dim-1) return idx这个函数我已经用了不知道多少回。temperature 越低分布越尖锐生成的文本越保守top-p 则裁掉尾部小概率 token避免出现乱码。如果你想让模型有“个性”可以从 temp0.7、top_p0.9 开始调。训练了大约一个小时后我的那个小模型已经能生成语法基本正确的英文句子虽然语义还很傻但这已经证明了你手中的代码是一条完整的数据-模型-生成流水线。镇上很多人听到“自己在训练大模型”觉得不可思议其实本质就是把这一套流程放大。4. 从“语言模型”到“推理模型”思维链、偏好优化与测试时扩展很多人在 from scratch 构建语言模型之后就以为万事大吉但真正让他拉开差距的是“推理能力”。为什么现在的模型能解数学题、能做代码题不是因为它们参数量更大而是因为他们经过了针对推理的训练范式升级。这一章我讲如何在你手写的小模型上开始做 reasoning model 的工程改造。4.1 思维链数据构造推理能力的燃料一个只知道接下一个 token 的模型不会自动学会“因为...所以...”。它需要见过大量包含推理过程的文本。所谓 chain-of-thoughtCoT数据就是在每个问题的答案中插入一条显式的中间推导步骤。形式大致如下问题小明有3个苹果他又买了5个然后吃了2个还剩几个 推理小明原来有3个买了5个后是358个。吃了2个后是8-26个。 答案还剩6个。我在自己的数据构造过程中发现纯手工写太慢但一开始又不想用其他大模型去生成怕数据有噪声。折中方案是先用规则模板生成大量简单算术和逻辑推理数据再人工审核一小批高质量样本作为种子。我的经验是数据条的多样性比绝对数量更重要。如果你只有几万条千篇一律的“应用题”模型学会的是机械套路而不是泛化的推理。4.2 监督微调让模型学会输出思维链在预训练语言模型基础上用上面的 CoT 数据做监督微调SFT。这一步对你的 from scratch 训练循环来说只需要改数据集其余代码几乎不变。唯一要强调的是如果你的数据格式是“问题推理答案”最好在推理开头加入特殊分隔符比如reasoning和/reasoning这样后续可以控制是否展示推理过程。训练细节上我建议用较小的学习率比如 1e-5 到 3e-5并配合一个很短的 warmup。因为模型已经预训练过微调时如果学习率太大会把学到的语义特征冲掉。我一开始用了 1e-4结果验证集 loss 不降反升损失严重。调低后效果明显稳定。此外SFT 阶段会在数据里同时包含“直接回答”和“思维链回答”两种样本比例取决于你的业务场景。如果全部强制输出思维链模型可能会在简单问题上过度推理显得啰嗦。我的做法是混合比例约 2:1两倍的输入带思维链一倍的输入强制直接输出答案。4.3 偏好对齐比 RLHF 更轻量但有效的 DPO很多人一听强化学习就头大。其实如果你不是为了发论文从零实现一个轻量级的偏好优化算法未必非要上完整 PPO。DPODirect Preference Optimization是一个非常优雅的替代品不需要显式的奖励模型只需要准备成对数据——同一个 prompt 下的“偏好回答”和“被拒绝回答”。它的核心思路是直接调整策略让偏好回答的概率升高、被拒绝回答的概率降低。DPO 的 loss 长这样伪代码def dpo_loss(policy_logps, ref_logps, preferred_logps, rejected_logps, beta0.1): log_ratio (preferred_logps - rejected_logps) - (ref_preferred - ref_rejected) loss -torch.log(torch.sigmoid(beta * log_ratio)) return loss.mean()这里ref_logps是参考模型通常是 SFT 之后的模型的 log 概率用来约束优化幅度不要偏离参考太远。你的 from scratch 训练循环加一个ref_model和一个beta系数就能跑起来。我在自己的小模型上试过用几百条偏好数据就能看到回答质量的明显变化回答变得更简洁、更少幻觉。4.4 测试时扩展让推理能力随计算增长最后一个方向是最近特别火的研究热点——让模型在推理时通过“思考更多 token”来提升准确率。你不需要训练模型很多次只需要在生成阶段做树搜索或多次采样让模型自己尝试多条推理路径然后选一个一致性最高的答案self-consistency。这属于纯推理阶段的工程不需要额外训练。举个例子同一个 math 问题我让模型采样 8 次每次都生成独立的思维链和答案最后对答案做多数投票。实验下来在小模型上准确率从单次采样的 48% 提升到了 66%。虽然不是质的飞跃但证明了一个关键点推理能力的提升不局限于训练时也可以来自推理时“做得更多”。这一点经常被初学者忽略他们会觉得模型不能答难题就是训练不够其实增加测试时的计算量是最快的性价比手段。5. 训练效率与工程化的硬仗显存、吞吐和稳定性前面我们跑了小模型但真实场景里你要训练上百亿参数模型或者至少要在单卡上尽力压榨性能。这一节说的是我在把 from scratch 案例扩展到更大规模时遇到的工程化难题和解决思路。5.1 混合精度与梯度累积把每寸显存用好当你训练超过 10 亿参数的基础模型时最痛的就是显存。我例行配置是 BF16 混合精度因为 BF16 保留和 FP32 相近的动态范围训练中不太容易出现数值溢出。在 PyTorch 里非常简洁from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for batch in dataloader: with autocast(dtypetorch.bfloat16): loss model(batch) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意我的模型里有一些算子比如某些自定义归一化在 BF16 下可能不太稳定所以我会给它们单独保持 FP32。这需要你把模型代码写得足够模块化这也是 from scratch 带来的好处——你知道哪里是敏感算子。梯度累积也不难核心是“多少步算一次真正更新”。例如你希望总 batch size 为 128但显存只够放 batch size 32那就可以累积 4 次梯度。工程实现上我建议只在第 4 步时执行 optimizer.step() 和梯度清零其余步只累加梯度。同时注意 loss scale 对累积的梯度也有影响简单做的话可以用“平均累积”的方式避免梯度被放大。5.2 分布式训练DDP 不等于一键加速我第一次跑多卡训练时天真地以为 DDP 会自动让训练提升 N 倍。真实情况是DDP 带来的通信开销不可忽视尤其是模型小时反而更慢。它背后的逻辑是每张卡独立做前向和反向然后各卡通过 all-reduce 同步梯度。如果你模型的单卡计算时间过短通信时间占比就会很高吞吐甚至下降。我的实践建议是如果你的单卡 batch 处理时间小于 100 毫秒先考虑加大 batch 再上多卡使用torch.distributed.barrier卡住训练观察各个卡到达同步点的时间差排查慢卡或负载不均DataLoader 要用DistributedSampler按 rank 切分数据否则每张卡拿到相同数据等于白训练。另外我踩过一个很深的坑在 DDP 环境下如果不设置model DDP(model)后调用model.module保存 checkpoint 时会把module.前缀存进去loader 时不一致报 key mismatch。所以保存时最好state_dict去掉前缀加载时保持一致。这类小经验只有实际写训练脚本的人才知道。5.3 推理优化从手动算子到 structured pruning推理阶段和训练阶段是两套思维。训练要吞吐推理要延迟。我在部署自己训练的模型时试过最有效的手段包括权重剪枝和量化将非重要权重置零再用 int8 量化模型体积减小一半以上速度提升 1.5 倍左右KV cache 优化在自回归解码时让所有之前算过的注意力键值缓存下来而不是每步重算。这个优化能让生成速度线性提升batch 动态打包把不同长度的请求按长度分组减少 padding 浪费。每一招看起来都不高深但综合起来能让最终服务的成本腰斩。这也是“AI 工程”的落点——不是训练完就算完而是你能跑多久、跑多快、跑多便宜。6. 给新手的 90 天启动路线与底层避坑清单文章写到这里如果你已经动心要自己动手我给你一条我验证过的 90 天路线按阶段分配大概的时间和任务。注意这条路线不要求你先成为数学博士只需要你边做边补。6.1 路线总览三阶段渐进冲刺阶段时间目标关键产出第一阶段第1-30天手写一个 tokenizer 和单层 attention一个能过测试的 mini transformer第二阶段第31-60天实现完整 GPT 训练循环跑通一个小语料可交互的文本生成 demo第三阶段第61-90天加入 CoT 数据和 DPO 训练做推理与部署优化一个能展示多步推理的小模型服务第一阶段不用贪多重点是把按位置编码、multi-head attention、layer norm等每个模块都用代码实现一遍。第二阶段再引入 masked attention 和训练循环。第三阶段属于提升“推理能力”和“工程品质”。6.2 我在 from scratch 阶段反复踩过的 5 个坑这里给出的每个坑都是我付出过真金白银时间换来的按出现频率排序数据泄漏验证集和训练集没有严格按文本边界切分导致同一个长文本片段既出现在训练又出现在验证loss 曲线看上去很漂亮换真实数据立刻露馅。解决办法是按文档或章节切分而不是随意连续截断。学习率预热不足Transformer 对学习率非常敏感我一开始直接用常数学习率训练 loss 在最初几千步剧烈震荡。最佳实践是 warmup 约占训练步数的 1%-5%然后按余弦或倒数衰减。梯度裁剪被忽略上面已经提过但我还想再强调一次。如果 loss 突然变成 NaN第一件事不是调模型而是查梯度范数并把 clip 值设到不超过 1.0。验证时忘记model.eval()dropout 和 batch norm 在推理时保持训练行为导致输出增加随机性。这个错误看起来低级却非常常见尤其在你手动写训练循环时会更容易漏。用单次生成结果评估模型好坏语言模型采样本身有随机性同一个 prompt 两次生成完全不同的回答。正确做法是至少生成 5 到 10 条样本并配合自动指标比如困惑度、ROUGE和人工抽查再下结论。6.3 写在最后的个人心得做完这个 from scratch 项目之后我最大的变化不是会写多少个模型而是面对任何新的 AI 论文我不会再因为它包装了一堆术语而发怵。我读论文时会不自觉地想如果我要实现它论文的第几节对应我代码里的哪个文件它的训练数据长什么样它的 loss 函数跟我现在用的有什么区别这种“映射感”是我认为 AI 工程能力真正的底色。如果你现在还在到处找“从零构建大语言模型”的资源包不如停下来打开编辑器从第一行import torch开始。这条路比你想的更花时间但它给的回报也是稳定、扎实的。先跑通一个3亿参数都不到的小玩意再谈 Scaling Laws最后你会明白所谓 AI 工程不过是在一层层抽象中找到最值得你亲手写的那一层。