1. 项目概述:为什么要“从零”做AI工程
这两年 AI 工程这个词被聊得很多,但真正敢说“从零手写”的人其实不多。我做的这个 ai-engineering-from-scratch 项目,就是把一个完整的大语言模型推理系统从空目录开始搭起来——不依赖现成的训练框架,不用一键部署脚本,连 tokenizer 都是自己实现的。
很多人听到“从零构建 AI 系统”第一反应是不现实:业界都在用现成框架,自己造轮子不是浪费时间吗?但我做完这个项目之后的体会恰恰相反。当你能徒手写出一个能跑的 attention 机制,能解释训练 loss 曲线为什么在某个点下降变缓,能定位显存溢出是发生在前向还是反向——这时候你再回去用 PyTorch、用 Hugging Face,会发现自己突然“看得见”底层了。工具还是那些工具,但你已经不再是调用者,而是理解者。
这个项目参考了社区里很火的“从零构建大语言模型”学习路线(比如 Sebastian Raschka 那本 Build a Large Language Model from Scratch 的思路),但我在实践里加入了更偏工程的视角:不只是把模型训出来,还要解决推理加速、reasoning 能力注入、稳定性和可复现性这些生产环境里绕不开的问题。整个项目适合三类人:第一类是刚入行、想把 transformer 原理落到代码里的新手;第二类是工作两三年、天天调 API 却被黑盒困住的工程师;第三类是技术管理者——哪怕不写代码,跟着完整流程走一遍,也能建立对 AI 工程成本和风险的正确直觉。
我会把整个搭建过程拆成五个可复现的模块,包括数据准备、模型架构、训练循环、推理优化和 reasoning 能力注入。每一步都给出具体代码思路和踩坑记录,保证你能照着复现,而不是看了一堆原理却不知道从哪里下手。
2. 整体设计思路:先搭骨架,再填血肉
2.1 为什么选择“最小可行模型”路线
从零做一个 AI 系统,最大的陷阱是一上来就想复刻 GPT-4。我见过不少项目死在第一步:还没动笔就开始纠结 4bit 量化选哪种、要不要用张量并行、SFT 数据要不要凑到一百万条。这些全是真实问题,但放在“从零”项目里全是噪音。正确的打开方式是把目标压到最小可行尺寸:模型参数在三五千万左右,训练数据几千万 token,单张消费级显卡能跑完一个完整训练流程。这个规模下你仍然能看到 loss 的完整演化、过拟合的行为模式、学习率的影响曲线,而一次实验只要几个小时而不是几周。
小模型不是大模型的缩水版,而是大模型的“透明版”。你在小模型上观察到的梯度消失、训练不稳定、loss spike,和大规模训练时遇到的问题在本质上同源,但小到你能用肉眼盯住每一步。比如我在训练一个 4 层 transformer 时,发现 learning rate 从 3e-4 调到 1e-3 后掉了 15% 的最终指标——这个敏感度直觉,是看任何论文都换不来的。
2.2 技术选型的取舍原则
整个项目我坚持三条取舍原则。第一,能用标准库实现的绝不上重型依赖。tokenizer 的 BPE 算法用纯 Python 写,训练循环里的数据批次逻辑自己控制,而不是直接调 Trainer——这不是为了炫技,而是为了让你对每个环节有“手感”。第二,只有性能瓶颈时才引入加速手段。比如矩阵乘法在数据量大了以后必然要落到 PyTorch 的 cuBLAS 上,这一步是合理的,但早期验证逻辑用 CPU、小规模跑通就够。第三,每个模块都留一个“检查点”。你随时能打印出 tokenizer 的词汇表、attention 权重的分布、某一层梯度的范数,这样出了问题能顺着链路快速定位。
工具链最终长这样:纯 Python + NumPy 做理解和原型验证,PyTorch 做实际训练和推理,Hugging Face 的 tokenizer 库只用来对比验证自己手写版本的正确性。没有 Weights & Biases 也没有 MLflow,日志用最朴素的 print 加 CSV——在项目初期,工具越少,你离真相越近。
3. 核心细节拆解:数据、模型架构与训练流程
3.1 数据流水线:从原始文本到可训练样本
数据是 AI 工程里最不性感但最决定成败的部分。我在项目里选用了开放的中文语料做预训练数据,大概 2GB 左右的清洗后文本,涵盖新闻、百科、技术文档和书籍几个大类。洗数据的流程值得详细说,因为这是第一个容易翻车的点。
原始数据进来后,第一步是去重和过滤,我按行做了 MinHash 去重,去掉低质量广告文本和长度小于 20 个字符的碎片。第二步是编码,这里有个容易忽略的坑:先分词还是先构建词汇表。正确的顺序是先做字节对编码统计,再用统计出的 merge 规则切分文本,否则词表会和实际分布脱节。第三步是拼接成固定长度序列,我用了 512 token 的上下文窗口,超过窗口的文本按段落边界切分,不足的用特殊填充符补齐。整个数据管线跑完,我额外做了一次抽样可视化——随机打印 50 条训练样本,确认没有乱码、没有过度重复、语义完整。这一步花不了十分钟,但能避免你花几小时训出一个“复读机”。
3.2 模型架构:从零实现一个可解释的 Transformer
模型架构我选了一个经典的 decoder-only transformer,配置是 8 层、8 头注意力、隐藏维度 512,参数总量约 4500 万。为什么不用 encoder-decoder?因为项目重点是训练一个能从零自回归生成文本的模型,decoder-only 架构最直接,也最贴近当前主流大模型的形态。
实现时最关键的是 attention 部分的细节。我在写作时把缩放点积注意力拆成四步:计算 Q、K、V,算 Q 和 K 的点积,除以根号 d_k 做缩放,再过 softmax 后与 V 相乘。缩放那一步很多人会漏,但它不是学术洁癖——当维度变大时点积方差会跟着变大,softmax 会被推向饱和区,梯度就消失了。我在 8 维和 512 维下分别做了实验对比,前者不缩放也能训,后者不缩放直接训不动,这个对比让你对论文里的每个公式都心服口服。
另一个容易出问题的是位置编码。我用的是可学习的绝对位置编码,把位置索引映射成一个可训练的向量,加到输入 embedding 上。这里我建议不要直接用正弦编码的“默认答案”,自己花半小时把两种方案都跑一次小实验,你会发现可学习编码在这个规模下收敛稍快,但外推到更长序列时不如正弦编码稳。知道这个权衡,比记住结论重要。
3.3 训练循环:loss 曲线里藏着所有秘密
训练循环是所有模块里最枯燥但最需要耐心的部分。我实现的训练循环包含几个必须的组件:前向计算、交叉熵损失、反向传播、优化器更新,外加梯度裁剪和 warmup 学习率调度。
交叉熵损失的计算需要特别注意标签的移位。输入是 token 序列的前 511 个,目标是对应的后 511 个,这样每个位置都在预测下一个 token。我在第一次实现时因为移位写错,loss 在 8 左右死活不降,排查了半天才发现是标签错位——这类 bug 不会报错,只会让你白白烧掉算力。
优化器我选了 AdamW,权重衰减只作用于非偏置和非归一化层的参数。学习率调度用的是 warmup + cosine decay:前 2000 步从 0 线性升到峰值 3e-4,然后按余弦曲线衰减到峰值的十分之一。为什么需要 warmup?因为训练初期模型参数是随机初始化的,梯度方向噪声大,一上来用大学习率很容易把参数推到损失函数的“悬崖”上,后面想拉回来就难了。我实际观察过不做 warmup 的实验,前几百步 loss 会出现明显的震荡,虽然最后也能收敛,但最终指标普遍差两个点左右。
梯度裁剪我会把全局梯度范数限制在 1.0 以内。这个看似保守的设置帮我挡住了好几次训练崩溃,尤其是当数据里混入少量异常文本时,梯度范数会突然飙高,如果没有裁剪,几步之内 loss 就会冲到 NaN。
4. 实操过程:从零构建一个小型推理模型的完整步骤
4.1 第一步:环境准备与数据下载
环境这块我给一个具体的清单:Python 3.10、PyTorch 2.1、CUDA 11.8(英伟达显卡驱动 520 以上)、8GB 显存以上的显卡。如果没有独显,用 CPU 跑也不是不行,但要把数据量砍到五分之一,训练时间会拉长到十几个小时,预算有限的同学可以先用 CPU 把代码跑通,再找云 GPU 做正式训练。
数据我用了一个公开的中文语料库,下载后先做一次完整性校验,然后用前面提到的方法清洗。清洗脚本我建议独立成一个文件,因为后面你几乎肯定要反复调整数据,独立脚本能让你不用每次重跑全部流程。我第一版清洗逻辑只做了空白字符压缩,结果训练出来的模型生成文本总带着奇怪的换行符,后来才发现是原始数据里混进了 Windows 风格的 \r\n 换行。
4.2 第二步:手写 Tokenizer 与数据加载
Tokenizer 我用了 BPE 算法,目标词汇表大小设为 8000。实现 BPE 的核心是维护一个词频字典,然后反复合并出现频率最高的相邻 token 对,直到词汇表到达目标大小。我实现了训练和编码两个部分:训练阶段扫描全部语料统计词频,编码阶段按学到的 merge 规则把新文本切分成词表内的 token。
这里有一个细节值得展开:编码时遇到词表外的字符怎么办。我的方案是引入一个特殊的未知 token,同时在预处理阶段把所有生僻字替换为它。替换的阈值我设成了出现次数小于 5 次的字符直接进未知类,这样既控制了词表容量,又不会让训练数据丢失太多有效信息。对比 Hugging Face 官方 tokenizer 在本项目数据上的切分结果,我的实现分出来的 token 序列有 97% 的重合度,剩下的出入主要是 Unicode 规范化策略不同,不影响训练。手写一遍再对比,你对 tokenizer 的内部机制就彻底没有盲区了。
数据加载器我实现了一个简单的有状态迭代器:读入全部编码后的数据,按 512 token 长度切片,然后每个 epoch 重新打乱顺序,用小批量方式喂给模型。批量大小我设为 32,这样每个 batch 大约有 16384 个 token,在 8GB 显存内稳定训练不爆内存。
4.3 第三步:核心模型实现与训练运行
模型实现的代码结构我分成三个文件:model.py 放 transformer 各层定义,train.py 放训练循环和优化器逻辑,config.py 放所有超参数。模块化不是为了好看,而是为了你后面想替换某个组件(比如把绝对位置编码换成 RoPE)时,能精确到行地修改而不是重写整个文件。
训练启动参数我给一组实测好用的:序列长度 512,batch size 32,总步数 20000,学习率峰值 3e-4,warmup 步数 2000,权重衰减 0.1,梯度裁剪上限 1.0。在这组配置下,loss 从初始的约 9.2 逐渐下降到 2.8 左右,训练时长在单张 RTX 4070 上大约四小时。我特意记录了前 500 步的 loss 值,你能看到从 9.2 快速掉到 6.5,然后进入缓慢下降的“平台期”,这是典型的语言模型训练曲线形状。
训练过程中的关键监控指标有三个:loss 值、梯度范数、学习率当前值。我会每 50 步打印一次这三项,写成一行时序数据存到 CSV。等训练结束,把 CSV 拉出来画成图,loss 曲线的形状就是你判断训练质量最直观的依据。如果曲线出现突然向上的尖峰,不用怀疑,大概率是某个 batch 的数据异常或学习率设置过高,这时候先把训练停掉,检查最近一个 spike 对应的原始数据样本,比盲目降低学习率更有效。
4.4 第四步:推理与生成——从“能训”到“能用”
训练结束只是上半场。推理阶段我实现了一个标准的自回归生成函数:输入 prompt 通过 tokenizer 编码,进入模型得到下一个 token 的概率分布,通过带温度的采样选择新 token,把新 token 拼回输入继续迭代,直到生成结束符或达到最大长度。温度参数我默认设 0.7,太低生成内容会变得复读机,太高则变得语无伦次,0.7 是个平衡点。
但我很快发现一个工程问题:纯自回归生成的速度太慢了。生成 100 个 token,在 CPU 上要好几秒,GPU 上虽然快一些,但每一步都重复计算了前面所有位置。解决办法是实现 KV cache——把历史时刻的 Key 和 Value 矩阵缓存下来,新 token 到来时只计算当前步的 Q、K、V,再和缓存拼接。这个优化在 512 序列长度下把推理速度提升了大约三倍,而且实现不难,就是在 attention 层里维护两个缓存张量。如果你打算做更长的生成,KV cache 是绕不开的必修课。
生成质量这里我诚实说:4500 万参数的模型在单句续写上能写通顺,但长文本必然出现逻辑涣散和重复。这不是 bug,是这个参数量下的物理极限。真正有价值的检验是看模型能不能学会你训练数据里的领域模式——我的训练语料里包含 Python 代码片段,模型遇见“def ”开头时能生成缩进正确的代码行,说明它确实学到了语法结构,而不只是背下了文本。
5. 常见问题与排查技巧实录
整个从零搭建的过程,我踩过的坑和身边朋友踩过的坑,我整理成一个排查对照表,方便你遇到同类问题时直接定位。
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| loss 始终不降(卡在 8 附近) | 标签移位错误、数据乱码 | 打印一个 batch 的输入和目标 | 检查序列错位,清洗异常样本 |
| 训练中途 loss 变 NaN | 学习率过高、异常文本、梯度爆炸 | 盯梯度范数曲线 | 降低学习率、开启梯度裁剪 |
| 生成文本只有重复的一句话 | 温度过低、模型欠拟合 | 提高温度到 1.0 测试 | 继续训练或调高温度采样 |
| 显存溢出(OOM) | batch 过大、序列过长 | 逐步调小 batch | 减半 batch,检查是否有张量泄漏 |
| 生成内容语无伦次 | 训练不足、数据质量差 | 抽样看训练样本 | 清洗数据、增加训练步数 |
| tokenizer 编码结果和预期不符 | 词表大小选择不合理 | 打印几个 token 序列 | 调整词表容量、检查合并规则 |
5.1 训练不收敛的深度排查流程
如果 loss 降不下去,我推荐一套标准排查流程而不是瞎调参。第一步,用一个小到可以记住的数据集(比如 1000 条短文本)训练 200 步,如果这个规模下 loss 还降不下去,说明代码有 bug,问题不在数据量。第二步,关闭所有正则化和 drop out,把模型过拟合到一个小样本上,验证模型本身的学习能力没被代码错误锁死。第三步,逐层检查梯度——在前向后打印每一层权重的梯度范数,看是哪一层最先出现梯度消失或爆炸。这套流程我用了很多次,百分之八十的“玄学不收敛”都能定位到具体代码行。
5.2 推理速度慢的两条真实心得
推理慢的问题我第一次遇到时以为是模型太大,后来发现两个隐藏的坑。第一个是没开 torch 的推理模式。评估模式下用 model.eval() 会把 dropout 等训练行为关掉,但如果你不用 torch.no_grad() 包裹生成循环,PyTorch 还会为每个中间张量保留计算图,白吃内存还拖慢速度,加上之后速度能提升 30%。第二个是重复计算导致的低效,也就是前面提到的 KV cache。很多教程不会在“从零构建”阶段讲它,但我强烈建议实现完基本生成功能后立刻补上,因为很多生成类应用的性能瓶颈就在这里。
6. 从模型到推理能力:在小模型上复现 reasoning 的训练思路
最近社区里“从零构建 reasoning 模型”的话题很火,我在基础生成模型跑通后,也尝试往这个方向扩展。所谓 reasoning 能力,通俗讲就是让模型在回答复杂问题时学会“先思考再回答”——把思考过程显式地生成出来,而不是直接给结论。大模型里常见的做法是收集带思维链的样本做监督微调,或者在偏好数据上做强化学习,让模型自己涌现出推理路径。
在小规模模型上复现完整的强化学习流程不现实,但监督微调的思路是完全可行的。我手工构造了大约 5000 条带思维链的问答样本,覆盖数学计算、逻辑推理和代码生成三类任务。每条样本的结构是:问题、思考过程(逐步推导)、最终答案三部分。我在已训练好的基础模型上做了两轮微调,第一轮固定住大部分参数只训练最后的全连接层(LoRA 的简配版思路),第二轮解冻全部参数低学习率训练。
微调之后模型的表现有明显改善:面对简单的算术题,它不再直接给一个可能错误的结果,而是会输出“先算括号内、再算乘除、最后加减”这类推理路径,虽然偶尔中间步骤有误,但形式上的推理结构已经出现。这个阶段让我真正理解了 reasoning 模型为什么有效——数据里显式注入了推理轨迹,模型学习到的不只是答案,而是一种搜索答案的行动序列。如果你想往这个方向深入,下一步可以尝试用拒绝采样生成更多数据,或者用 PPO 做强化学习,但无论走哪条路,监督微调这一步积累的经验都是地基。
7. 项目扩展方向与最终经验总结
做完整个 from-scratch 项目后,我还试验了几个低成本扩展,这里统一分享给你。第一个扩展是继续预训练:用通用语料训练完的模型,再用特定领域(比如医疗文本或法律文书)的数据继续训练,能得到领域表达能力更好的模型。第二个扩展是对话微调:构造简单的指令-回答数据对,让模型学会遵循指令,虽然没有 ChatGPT 那么惊艳,但能让你理解 RLHF 之前模型处于什么阶段。第三个扩展是量化:把模型从 float32 量化到 float16,显存占用减半且指标几乎不掉,这是通往轻量部署的第一步。
在我个人看来,这个项目最大的价值不在于产出了一个能跑的模型,而在于把 AI 工程里的“魔法”还原成了一行行可以理解和调试的代码。做完之后你再去看任何大模型的论文,里面每个抽象名词在你脑中都有了具体的张量形状和计算路径;你再去用任何框架,都会知道它在替你处理什么。
如果你决定自己动手做一遍,我的建议是别一味求快,把每个模块的检查点都跑一遍、把每张 loss 曲线都存下来。这不仅仅是一个学习项目,更是你未来排查生产环境问题时的“直觉数据库”。当你某天在生产集群上看到一个陌生的 loss 曲线,能立刻想起自己在小模型上见过类似形状——那一刻你会发现,从零开始的几个星期,值回了一张训练卡的钱。