今年我把大量业余时间投进了一个叫ai-engineering-from-scratch的个人项目。简单说就是给自己立了条规矩凡是跟 AI 相关的环节能自己动手实现的绝不直接调封装好的接口。从手写张量运算开始到训练一个微型语言模型再到把模型量化部署成一个能用的服务整个过程走下来收获最大的不是某个模型效果变好了而是终于弄明白了一个大语言模型从数据集到上线中间到底经历了什么出了问题时该往哪个方向查。这篇文章就围绕这个项目把我踩过的坑、验证过确实有效的路线、以及现在还保留在收藏夹里的参考材料整理出来。不吹不黑直接上干货。适合已经会用 PyTorch、会调 Transformers 库但对底层实现还有点心里没底的人也适合准备进入大模型应用开发、想从底层建立手感的新手。看完你至少能少走我一半的弯路。1. 为什么我决定从零开始学AI工程先别急着调API1.1 框架用久了心里不踏实先说个真实感受。我最早做 NLP 项目时基本就是transformers库一把梭加载模型、写个训练循环、调参数、完事。模型跑不动就换更大的显卡效果不好就换更强的预训练权重。直到有一次模型在验证集上的损失怎么都降不下去我把学习率调低、把层数减少、把 dropout 拉满全都没用。当时我能做的只有百度搜索关键词然后挨个试网上流传的玄学改法。那天之后我开始反思我不是在用 AI而是在调一个巨大的不透明的黑盒子。梯度怎么传播的、学习率策略在内部到底起了什么作用、显存为什么爆、推理为什么慢这些问题我都答不上来。而这些问题恰恰是 AI 工程里最核心的问题。框架用久了AI 工程变成了一种配置工程这对工程师来说是非常危险的。一旦遇到模型异常、数据泄漏、显存瓶颈这类问题不懂底层就只能靠猜而靠猜的代价是极其昂贵的。所以我才决定做from scratch这件事。不是去重写 PyTorch也不是去发明新一代算法而是把现代语言模型里最关键的模块一个个抽出来用最朴素的方式实现一遍。注意力机制自己写、分词器自己写、训练循环自己写、量化脚本自己写。写完之后再回到框架里你会发现自己不是在使用框架而是在理解框架心态完全不一样。1.2 从零手搓到底要学什么范围怎么划刚开始我也很迷茫因为AI 工程这个范围太大了。做 CV 的需要懂卷积、数据增强、分布式训练做推荐的需要懂特征工程、Embedding、在线学习做 NLP / LLM 的需要懂分词、Transformer、微调、推理优化。如果不能明确边界这个项目很可能变成一个永远完不成的大坑。我的做法是先把全链路画成一个流程图再从里面选出必须亲手做一遍的核心节点。我这里所谓的流程图并不是画给别人看的那种而是写给自己确认用的清单。我的清单大致是数据处理分词器、批次构建、数据采样模型构建Embedding、多头注意力、前馈网络、层归一化训练系统损失函数、反向传播、优化器、学习率调度推理系统自回归生成、KV Cache、采样策略部署优化量化、批处理、服务化接口模型进阶在基础模型之上尝试先思考、再回答的推理行为每个节点只追求能跑通、能解释、能量化效果不做过度扩展。比如我不会去手写 CUDA kernel因为那已经超出一般的 AI 工程范围属于系统底层优化跟当前目标不对齐。先把上层的每一个工程环节摸透等将来遇到 perf 瓶颈时再下沉也不迟。这个范围划完之后我给自己定了三个里程碑第一用 NumPy 手写一个能训练 MNIST 的迷你神经网络第二用 PyTorch 从零实现一个小型 GPT在几十 MB 的数据上训练出能生成文本的模型第三对第二个模型做量化压缩和推理加速部署成一个 HTTP 服务。后面所有的工作都围绕这三个里程碑展开。2. 我的学习路线把黑盒拆成白盒的六个阶段2.1 六个阶段一层层揭开隔层我给自己制定了递进式路线不是上来就写 Transformer而是按照造一台机器的顺序去推进。如果你也想照这条路走我建议你严格按顺序来不要贪快。第一个阶段是 Python 编程与数据操作。重点不是语法而是对数据到底长什么样有直觉。列表、字典、字符串处理、文件读写配合collections.Counter做词频统计这样后面写分词器时不会发怵。第二个阶段是手写张量与自动求导。现阶段不建议直接上 PyTorch而是先用 NumPy 自己实现一个极简的张量类只支持标量、向量、矩阵的加法乘法以及对某个参数的梯度记录。这个阶段的核心任务是理解反向传播到底在做什么而不是把所有细节都搞完。我做了一个非常粗糙的自动求导只有几十行代码但它让我第一次看懂了链式法则在程序里如何落地。第三个阶段是手写 MLP 和简单 CNN。用自己写的自动求导工具去训练一个识别手写数字的模型把梯度下降、过拟合、正则化这些基础概念体验一遍。很多人跳过这个阶段直接学大模型但我强烈不建议。因为大模型也依赖同样的数学机制如果连一个隐藏层的 MLP 都调不明白后面遇到 Transformer 的异常会更难定位。第四个阶段是学习和实现注意力机制。注意力是任何现代语言模型的灵魂它做的事本质上就是根据相关性从其他位置上取信息。先手写单头注意力再扩展成多头最后加上因果掩码。第五个阶段是把注意力堆成 Transformer实现一个微型 GPT。从 token 化文本开始训练一个几百万参数的小模型让它生成看起来像模像样的句子。第六个阶段是推理优化与部署。把训练好的权重量化成半精度或整数精度加上 KV Cache写一个流式生成接口再封装成一个所有人都能通过 HTTP 调用的服务。每个阶段我都有对应的完成标准。比如第四阶段的完成标准不是理解了注意力公式而是写出一个能跑、能反向传播的多头注意力模块并在小数据集上训练收敛。这样就不会陷入漫无目的的看论文状态。2.2 参考书怎么用读一遍不如改一遍在我动手的过程中最常被问到的一句话是你到底用什么学的我的答案很直接《Build a Large Language Model From Scratch》这本书给了我很重要的路线参考。这本书从数据准备开始一步步搭建类似 GPT 的结构包含分词、注意力、训练、微调等完整内容。书里的代码风格非常朴素没有花里胡哨的高级封装很适合配合这个项目来理解每一个环节。但我还得提醒一点读这本书千万不要只停留在读上。我第一次读的时候以为看懂了结果合上书自己写连masked_fill都写错了位置。后来我换了个策略把书里的每段代码都当成参考实现然后关掉示例代码自己重新实现一遍遇到卡壳再回去对照。这样读一遍书等于自己写了三遍代码效果完全不一样。除了这本书我还会配合看一些原始论文和开源项目。书不会覆盖所有细节比如混合精度训练、KV Cache 的显存优化、量化感知训练这些工程问题需要从零散的源码和文章中补全。我的经验是以书为主线建立骨架再以论文和源码为枝叶填充细节最后用自己的代码验证理解。3. 从零手搓一个微型语言模型的完整流程3.1 先解决词的问题手写 BPE 分词器语言模型处理的是 token不是原始字符串。所以从零搭建模型第一步就是解决怎么把一段话切成 token。按空格切词虽然简单但会得到一个巨大的词表而且无法处理没见过的新词所以现代模型普遍使用字节对编码BPE。BPE 的核心思想是先把文本看成 UTF-8 字节序列然后反复统计相邻字节对的出现频率把最高频的对合并成一个新符号直到词表达到目标大小。手写 BPE 时最关键的数据结构是一个计数用的字典。首先是统计相邻字节对频率def get_stats(ids): counts {} for pair in zip(ids, ids[1:]): counts[pair] counts.get(pair, 0) 1 return counts然后在每一轮迭代中找到频次最高的 pair把它的两个 id 合并成一个新的 iddef merge(ids, pair, new_id): result [] i 0 while i len(ids): if i len(ids) - 1 and ids[i] pair[0] and ids[i1] pair[1]: result.append(new_id) i 2 else: result.append(ids[i]) i 1 return result这个过程初看很简单但真正实现时有一个坑随着合并的进行id 长度会变化新的高频 pair 必须基于上一轮合并后的序列重新统计。如果理解错了合并顺序就会乱掉分词结果会有问题。我当时就是在这里犹豫了很久后来意识到每次合并后全局重新统计才是标准做法而不是在一个固定列表上连续改。3.2 核心模块手写多头注意力的几个细节注意力模块是整个模型中最容易写错、也最值得亲手实现的部分。常见的公式非常简单Q 和 K 做点积、除以根号 d、加上掩码、Softmax、再乘 V。但工程实现时有几个细节特别值得注意。第一个是因果掩码。语言模型生成时只能看前面的 token不能偷看后面的内容。实现方式是把未来位置的值设成负无穷经过 Softmax 之后概率趋近于零。我在第一次实现时用的掩码矩阵是对的但维度 broadcast 没对齐结果训练损失直接乱跳。排查了很久才发现是掩码形状错误。手动实现时建议把每一步的张量形状打印出来别嫌麻烦。第二个是缩放因子的作用。除以sqrt(d_k)不只是为了让数值范围更好看而是防止点积结果过大导致 Softmax 进入饱和区梯度变得极小。如果不缩放模型在小数据集上也能跑但训练会明显变慢损失曲线的尾巴还会抖动。第三点是多头注意力中头的意义。多头不是简单地叠加多个注意力结果而是让每个头学到不同的相关性模式。有的头可能关注前一个词有的头可能关注句法成分。虽然我们在代码层面只是把 d_model 切成几段分别计算但正是这种参数独立性让模型表达力变强了。手写时我建议先按循环每个头的方式写一版跑通后再改成矩阵并行版这样对分块的印象会特别深刻。第四点是残差连接和层归一化的位置。Transformer 每一层结构是注意力 - 残差 - 层归一化 - 前馈网络 - 残差 - 层归一化。残差连接让梯度有一条高速公路可以直达底层层归一化稳定每一层的激活分布。顺序如果搞错了模型的训练稳定性和最终效果都有明显差距。3.3 训练循环里的关键决策学习率、批次和损失模型搭完了训练循环也不像想象中那么简单。我最初写的训练循环只是取数据、算 loss、反向传播、更新结果模型一直不收敛。后来我逐项排查发现真正影响训练效果的是几个细节随机种子是否固定、数据是否被随机打乱、学习率调度是否合理、梯度有没有做裁剪。学习率我很推荐使用 warmup cosine 的调度方式。一开始用一个很小的学习率热身让参数更新不会太激进然后再逐步降到接近零让模型在后期做好精细调整。我用的配置大致是warmup 步数约占总步数的 3% 到 5%峰值学习率在1e-3到3e-4之间具体看模型规模和 batch size。批次大小也很关键。我的显卡资源有限单批放不了太多样本就用梯度累积来模拟更大的批次。注意梯度累积不是直接改batch_size而是在多个小批次上累计梯度然后再做一次优化器更新。梯度累积的步数需要根据显存实测来定我试过累积 4 步效果最稳。数据处理上我加了一条保护线验证集绝对不参与训练而且验证集的数据顺序每次都要固定。有一次我偷懒直接在数据类里给训练集和验证集用了同一个 shuffler结果验证损失一直往下掉训练损失却不降后来才发现是验证集里混进了训练样本数据泄漏的坑差点没把我逼疯。训练过程中我习惯每 20 个 step 打印一次 loss 和当前学习率每 200 个 step 在验证集上算一次困惑度。损失曲线的形态非常有信息量如果训练 loss 下降但验证 loss 上升那是过拟合如果训练 loss 不降那多半是学习率太高、数据有泄漏或者模型结构写错了。3.4 采样策略怎么让模型说得像人话模型训练完之后生成阶段同样有很多坑。语言模型是逐步生成 token 的每次预测下一个 token 的概率分布然后从分布中采样。如果每次都选概率最大的 token就是贪心解码虽然稳定但容易重复、死板。如果每次都纯随机采样句子会变得前言不搭后语。我实测下来配合使用temperature、top-k 和 top-p效果最好。Temperature 控制分布的尖锐程度温度小于 1 的时候概率集中到高概率 token 上生成的文本更保守温度大于 1 的时候分布更平缓文本更大胆。Top-k 是只从概率最高的 k 个 token 中采样避免给那些几乎不可能的 token 太多机会。Top-p 则是动态选择累计概率超过阈值的 token 集合比 top-k 更灵活。我常用的一个组合是temperature0.8, top_k40, top_p0.9。这个组合在我训练的几百万参数小模型上生成结果既有一定的多样性也不会很快陷入重复循环。如果你发现模型总是重复同样的句子可以适当把 temperature 调高一点或者增大 top-p 阈值如果发现上下文不连贯则降低 temperature 会更有效。4. 从生成到推理小模型也能学会思考链4.1 推理模型与传统模型到底差在哪里当我把基础语言模型跑通之后我注意到行业里的讨论热点开始转向另一个方向让模型在回答之前先做一段内部推理再给出最终答案。传统语言模型是输入问题立刻输出答案而推理模型的生成结果是先输出一系列思考过程再输出最终结论。这个过程类似于人在纸上打草稿草稿越长越有机会通过逐步演算推导出正确答案。从工程角度看两者最主要的差别不在模型结构而在训练方式。一个普通模型在训练时看到的是问题 - 答案的配对而一个具备推理能力的模型在训练时会看到问题 - 思考过程 - 答案的完整链路。思考过程被当作可学习的文本参与训练模型因此学会了在回答前主动进行推理。另一个关键点是强化学习在这种训练中的应用。为了让模型学会找到更好的思考路径社区里出现了很多从零构建推理模型的项目。核心做法是让模型对同一个问题生成多条不同的推理路径和答案然后根据答案是否正确以及推理路径是否合理给予不同强度的更新信号。做得好的路径会得到正向强化做得差的路径会被抑制。这个过程不依赖人工标注的标准推理文本而是通过模型自身的探索来进化成本低很多效果却可能很好。我个人的立场是如果你想搞懂 modern LLM 的完整能力边界从生成到推理这一步非常值得复现。它也是从会做 Next Token Prediction向会解决问题进阶的关键一步。4.2 我在小模型上复现推理能力的实操尝试理论清晰之后我决定在自己的小模型上进行尝试。我先准备了一批带思考链的训练数据。数据来源不是去爬什么高不可攀的内容而是自己写规则生成对于数学加减法、字符串反转、简单逻辑题先生成问题再人工写一段分步骤的思考文本最后给出答案。数据量控制在几万条以内一方面是因为我算力有限另一方面也足以验证小模型是否能学到推理行为。训练时我用的是监督微调在已经训练好的基础模型之上用这批带思考链的数据继续训练。关键技巧是思考过程的特殊 token 分隔。我在文本中插入[THINK]和[ANSWER]两个特殊标记让模型明确知道哪里是思考区、哪里是答案区。推理时生成到[ANSWER]之前的部分就是模型的思考过程。实测结果很有意思。一个参数量不到两百万的小模型经过几千步有思考链数据的微调之后面对简单加法问题时确实会先输出类似先把十位和个位分别相加然后处理进位这类的过程文本再给出最终答案。虽然思考过程经常有废话但正确率比起微调前有明显提升。不过也有明显的局限性模型在陌生题型上基本不具备泛化推理能力它只是记住了遇到问题要先输出一段过程的模式。这说明 from scratch 复现推理模型不是简单加数据就行可能还需要引入更多的强化学习策略。但作为工程入门走一遍这个流程已经让我很清楚地理解了思考链的本质它不神秘只是把未显式建模的中间计算过程变成可训练的文本序列。做这一步时还有一个非常重要的提醒数据合规和版权问题永远不能绕开。不要从不明渠道下载所谓的全套数据或者盗版资源自己写规则生成、使用合规的开源数据才是能长期复用的做法。模型能力可以慢慢提升合规底线是绝对不能突破的。5. 工程化落地模型能跑只是第一步5.1 量化、KV Cache 与推理加速在本地把模型训练出来之后真正让人头疼的是怎么让它跑得更快、占用资源更少。我一开始直接用 PyTorch 逐 token 生成文本结果慢得离谱。后来才发现我连最基础的 KV Cache 都没有加。KV Cache 的核心思想非常朴素生成下一个 token 时前面所有 token 的 Key 和 Value 矩阵其实已经算过了没必要重新计算。把这些结果缓存起来每次生成只需要算新 token 的相关部分推理速度能提升一个量级。我的小模型加上 KV Cache 之后生成 200 token 的耗时直接降为原来的三分之一。除了 KV Cache量化是另一个大头。训练好的模型参数默认是 32 位浮点数占内存大、计算慢。把权重转成 16 位、8 位甚至 4 位整数就能非常显著地压缩模型体积。我做的是一种比较简单的训练后量化方法先统计每一层权重的数值范围然后把浮点数映射到整数范围推理时再反量化回浮点数。对于我的小模型从 FP32 压到 INT8 后模型体积减小了接近四倍生成结果的质量几乎没有肉眼可见的下降。量化过程里最容易踩的坑是校准。直接拿模型权重的 min/max 做映射有时候会被离群点带偏导致量化后某些层输出异常。我当时用一批验证集数据观察每层的激活分布选择合理的截断范围之后再量化效果立刻稳定了很多。简单说量化不是简单的数学映射它需要数据来辅助确定映射参数。5.2 微调阶段的数据配比与防灾难性遗忘工程化之后我开始尝试在自己的基础模型上做微调。初始阶段最容易犯的错误是用单一任务的数据把整个模型反复训练结果只要训练步数稍微多一点模型原有的通用能力就被大幅破坏了。这个现象有一个专门术语叫灾难性遗忘我一开始天真地以为只要学习率够小就没事后来发现远远不够。解决灾难性遗忘我验证有效的方法有三个。第一是混入通用数据微调数据中至少保留 30% 到 50% 的基础语料让模型一边学习新任务一边维持旧知识。第二是降低微调阶段的学习率比预训练阶段低一个数量级。第三是使用 LoRA 这类参数高效微调方法只训练一小部分附加参数原有参数尽量不动。LoRA 的原理是把要更新的权重矩阵分解成两个低秩矩阵训练时只更新这两个小矩阵推理时再合并回原权重。这样不仅显存占用大幅降低而且对原有参数的扰动很小。我在实验中发现LoRA 加上混合数据能把新任务的表现提升不少同时依然能流畅生成普通文本效果比全量微调稳定得多。数据配比这件事没有万能公式但有一个通用的排查方法每次微调完成后在上一个版本模型的测试集上做一次回归评测如果得分明显下降就说明数据配比或学习率可能有问题。我前前后后调整了三次配比最终确定了一个能兼顾两边的比例这个经验值只对我的场景有效但添加回归验证集这个方法对任何人都有用。5.3 从单机实验到服务化部署模型调好之后要把模型给别人用就得做服务化部署。我没有引入过于复杂的架构而是用了一个非常简单的思路把模型加载到一个常驻进程里通过 HTTP 接口接收文本请求生成结果后返回。这样做的好处是模型只在启动时加载一次不用每次请求都重新初始化。这里有一个非常容易踩的坑服务进程必须设置超时机制。我的模型虽然只有几百万参数但在无 GPU 的环境下生成一段长文本仍然可能耗时几秒。如果客户端超时时间设得太短就会频繁报错。解决办法有两个一是用流式返回模型每生成一个 token 就推给客户端一部分二是把生成请求丢到任务队列里异步处理客户端去轮询结果。我实际项目中先用了流式返回交互体验好很多。另外多进程服务一定要小心模型副本占用的显存或内存。如果使用gunicorn这类多进程服务器每个 worker 都会加载一份模型副本。我试过默认配置结果服务启动后内存直接翻了几倍。解决办法是根据实际内存限制 worker 数量或者改用多线程。这个细节不亲自部署一次是很难从文档里学到的。6. 我踩过的坑问题排查与避坑速查表6.1 训练不收敛三个最常见的元凶我的模型第一次训练时loss 一路降不下去我很确定自己代码没有语法错误就把锅甩给了数据量太小。后来我系统排查了一遍发现根本原因和学习率有关我用了条很低的学习率导致模型几乎在原地踏步。把学习率从1e-4提到1e-3之后loss 立刻开始下降。检查学习率绝对是排查不收敛的第一动作。第二个常见元凶是数据顺序没有打乱。如果每个 epoch 内样本顺序完全不变模型会学到顺序上的伪特征训练 loss 也能降但验证 loss 非常不稳定。打乱数据之后问题基本就消失了。第三个元凶是初始化。使用 PyTorch 默认初始化的 Transformer在小模型上训练时有时会遇到严重的梯度异常。我后来把每个线性层的权重标准差按照隐藏层维度的倒数来缩放也就是非常流行的 small initialization 技巧训练稳定性明显提升。不要小看这几行初始化代码它对整个训练过程影响巨大。还有一个容易被忽略的元凶损失函数和标签的对齐。我在实现交叉熵损失时一开始把 labels 设置成了形状不匹配的 tensorPyTorch 没有直接报错而是静默广播了导致模型学到了一个非常奇怪的分布。发现这个问题后我在训练循环里加了一个assert检查张量形状遇到形状不一致的情况立刻中断。6.2 显存爆炸与训练速度过慢的排查思路当我把模型从几百万参数扩到一两千万参数时显存瞬间告急。第一个原因是注意力矩阵的平方复杂度序列长度为 512 时注意力矩阵是 512 乘以 512显存开销随序列长度平方增长。解决办法是把最长序列控制在 256或者改用梯度累积减少单批显存峰值。第二个原因是优化器状态比想象中更占内存。Adam 优化器本身要保存每个参数的动量变量和方差变量再加上模型参数和梯度实际显存占用接近参数的 7 到 12 倍。所以不要看到模型只有几百万参数就觉得内存肯定够用。混合精度训练是很好的缓解手段但要注意梯度缩放和 loss scaling否则很容易出现溢出导致 loss 变成 NaN。我在排查显存问题时写了一个简单脚本利用 PyTorch 的显存统计工具在训练循环前后打印reserved和allocated内存很快就定位出哪个模块占用了大头。这种可视化统计方法比肉眼猜测高效得多。如果你也遇到 OOM建议先用这个思路搞清楚内存分配在哪里再决定是减小序列长度、减小 batch还是换一种注意力实现。6.3 常见问题速查表问题现象可能原因排查方向解决方案训练 loss 不降学习率过低或过高打印每个 step 的 loss 和学习率使用 warmup cosine 调度调峰值学习率训练 loss 下降但验证 loss 不降数据泄漏或过拟合检查验证集来源观察训练/验证差距重建验证集混入通用数据验证 loss 剧烈震荡数据顺序未打乱 / batch 太小检查数据加载器是否 shuffle开启 shuffle或增大批次推理速度极慢没有 KV Cache打印每 token 生成耗时实现 KV Cache复用历史 Key/Value模型生成总是重复采样策略太保守观察生成文本的重复率调高 temperature禁用重复 n-gram量化后效果暴跌校准数据不足 / 离群点影响对比量化前后每层输出分布用验证集做校准设置合理的截断范围服务启动后内存翻倍多进程加载多份模型副本查看进程内存占用限制 worker 数或改用线程这些坑都是我实际踩过的。第 4 条 KV Cache 的坑尤其隐蔽因为模型小的时候速度差异不明显一旦把序列长度拉长差距立刻显现出来。第 6 条量化校准则直接关系到模型能不能在低资源环境下部署值得多花时间仔细调。最后再分享一个我现在仍然在用的习惯每跑一次实验之前先把随机种子固定好然后把数据处理流程写成可复现的脚本任何一步出错都能从缓存重放。这个习惯在 ai-engineering-from-scratch 项目的后半段帮了我大忙因为改动一旦多了你根本无法判断效果变化到底来自数据、模型版本还是训练参数。固定种子、缓存数据、记录每次实验的配置这三件事坚持做下来整个项目才真正算得上工程而不是一次性的代码练习。如果你也打算从零开始走一遍 AI 工程我建议你从写一个最简单的手写数字分类器起步再一步步走到语言模型和部署。这条路不易但走完之后的底气是任何框架封装都给不了的。