做 AI 工程的人这几年几乎都被同一个问题问过:“现在 API 贵是贵,但至少能跑,你为什么还要自己写模型?”我最初回答这个问题时也只会搬出“理解原理”这种略显空泛的理由。直到去年我认真动手做一个 from scratch 的推理模型实验——不依赖任何大模型框架的预训练权重,从空白工程开始实现数据管线、tokenizer、Transformer 训练和推理服务,才真正意识到:所谓“AI engineering from scratch”,价值根本不在“重新发明轮子”,而在于它会把你的工程判断力逼到一个完全不同的层级。这也是为什么当我看到build a reasoning model from scratch这类话题和《Build a Large Language Model from Scratch》被越来越多人讨论时,我并不意外——大家不是想要自己重新做一个 GPT,而是想在黑盒时代重新夺回对模型的控制感。
这篇文章我尽量不写成原理教科书,而是按我自己动手时的真实路径来展开:先想清楚“为什么值得从零写”,再讲数据与分词器、Transformer 骨架、训练曲线、推理优化,最后把我踩过的坑和一份可落地的路线图完整交给你。适合那些正准备写第一个 from-scratch 项目、或者已经在做但卡在某个环节的工程师。
1. 为什么在满地 API 的时代,我仍然坚持从零写一个模型
1.1 从零开始到底值不值:我的判断标准
我给自己定的判断标准很简单:如果项目的目标只是“把一个任务跑通”,那直接用现成 API 和开源权重就够了;一旦你的目标变成“理解任务为何跑通、为何跑不通、以及能否按自己的意图改造模型”,从零开始就是唯一可靠的路径。
拿我这次做的推理模型来说,我需要模型在给定前提和问题后,不只是输出一个“看起来合理”的答案,还要能在中间生成可解释的推理轨迹。用 API 模型当然可以实现,但我无法探测模型内部对每一步推理的置信度,也无法控制它在某个中间环节是否真正“遵循了前提约束”。这些问题在推理 API 上几乎无解——你只能调整 prompt,然后祈祷。而从零开始训练的小模型虽然绝对能力远不及商用 API,但它的一切行为都是可拆解的:我可以随时插入探针观察某一层 attention 的分布,可以修改解码器让它在推理中途暂停并重试,也可以直接干预采样过程来抑制跳跃性结论。这种“可操控性”是 API 永远给不了你的。
1.2 除了“理解原理”,还有三笔隐性收益
第一笔收益是调试能力。使用黑盒 API 的时候,遇到一个逻辑错误,你大概率只能反复改 prompt;但当你从零写过一个训练脚本,你会自然建立起一套排查直觉:loss 不降先怀疑数据还是模型?重复输出先看采样参数还是训练语料?这种底层判断力会在未来任何 AI 工程任务里持续发挥作用。
第二笔收益是成本结构的变化。推理 API 按 token 计费,做实验时一天几百万 token 的消耗是非常真实的成本压力量。自建一个 1 亿参数的小模型,在单张消费级显卡上训练和推理,主要开销是电费和显卡折旧。对需要大量试错的研究型工作来说,这种“固定成本换边际成本趋近于零”的模式会极大解放你的实验空间。
第三笔收益是架构自由。如果你需要尝试某种新机制——比如给 attention 加一个文档级 mask、自定义 KV cache 的淘汰策略、或者在 loss 里加入推理一致性约束——在别人的代码库上做深度改动,往往比从零开始写一个新工程还要痛苦。因为成熟的代码库有很多隐形的工程假设,你要先逆向推断它们的设计意图,再小心翼翼地在不破坏其他模块的前提下改动。从零写,所有边界都在你自己的掌控范围内。
2. 万里长征第一步:先搞定数据和小尺寸分词器
2.1 别急着上大语料:先建立一个干净的实验环境
很多人一上来就注册去下载几百 GB 的大语料,这其实是最容易掉进坑里的选择。T0 阶段的目标不是刷指标,而是确保代码管线端到端可跑:数据进入模型、loss 正常下降、生成一个通顺的句子。带着这个目标,我的建议是先准备一份 5 万条以内的小型干净语料,比如百科条目、文学作品段落,或者代码文件。我自己的实验语料是大约 3 万条中文百科条目和 2 万条代码片段,总大小不到 300MB,训练一个 1 亿参数模型大约只需要 8 小时。
这份小语料还有另一个作用:它让你能快速做完一个完整的实验循环。数据过长时,单个 epoch 就要跑几天,你会发现自己的时间全都耗在等待和看日志上,根本来不及迭代调参。而小语料允许你在一个工作日内完成“改代码-重训-看结果”的循环,这对排查 bug 和积累经验来说太重要了。
2.2 用 BPE 分词器给语言“切块”
文本无法直接喂进神经网络,得先切成整数 id。这一步的工程选择会直接影响后续训练效率和最终生成质量。我推荐自己训练一个 BPE(Byte Pair Encoding)分词器,而不是直接套用现成的开源分词器,因为 from-scratch 项目讲究的是“每一个组件都可控”。
BPE 的原理其实不复杂:它从字符级开始,反复统计语料中最高频的相邻 token 对,把它们合并成一个新 token,直到达到预设的词表大小。我在实现时把词表大小定在16,000 到 32,000之间。这个区间的理由很实际:词表太小,句子的 token 数会膨胀,训练和推理速度都会受影响;词表太大,则嵌入矩阵参数量会急剧上升,小模型根本学不过来。比如 32,000 词表配合 256 维嵌入,仅 embedding 层就已经是 800 万参数,占到一个 1 亿参数模型的 8%。
| 词表大小 | 平均每 token 覆盖字符数 | 嵌入层参数量 (dim=256) | 适合场景 |
|---|---|---|---|
| 8,000 | 约 1.4 | 200 万 | 极小模型快速验证 |
| 16,000 | 约 2.0 | 410 万 | 1 亿左右参数模型推荐 |
| 32,000 | 约 2.6 | 820 万 | 生成文本自由度要求较高时 |
| 64,000 | 约 3.1 | 1,640 万 | 不建议,参数量挤占 Transformer 层容量 |
在工程实现上要特别注意 BPE 训练时的pre-tokenization 规则。中英文混合语料里,一个常见问题是中文整字被拆成多个子词,导致有效信息密度下降。我的经验是把中文字符视作独立的 pre-token,让 BPE 在更“粗”的粒度上合并;英文则按空格和标点先切词,这样可以显著减少 token 数量,训练速度能提升 30% 以上。
2.3 数据质量的底线:去重、过滤与采样窗口
数据管线很容易被当作“不重要”的环节,但实际训练时大量诡异现象都源于数据问题。我在第一版实验里脚本去重没写好,结果语料里混进了几千条完全重复的记录,直接导致模型对特定片段过拟合,生成时反复输出一样的内容。
我的数据预处理分了四步:首先做精确去重,对所有文本计算哈希,删除完全重复的条目;其次做模糊去重,用字符 n-gram 集合的相似度过滤掉近似重复的内容;然后做规则过滤,去掉含有 HTML 标签、超长 URL、或连续特殊字符的无意义文本;最后做语言检测,确保语料标签与实际语言一致。这四步做完,语料体积通常会缩小 15% 到 30%,但训练稳定性的提升非常明显。
训练样本的构造也要花心思。最朴素的做法是把每条文本单独 padding 到固定长度,但这样做 token 利用率很低,大量计算花在 padding token 上。更高效的方式是流式拼接:把清洗后的文本按顺序拼成一个长流,按固定窗口长度(比如 512 或 1024 token)切块,并强制切块边界对齐到文档边界附近。这样每个训练样本几乎没有 padding,有效 token 利用率可以提升到 95% 以上。同时我会保留一个“文档边界 mask”,在注意力计算时禁止 token 跨文档相互关注,防止模型学到莫名其妙的跨文本关联。
3. 骨架设计:把 Transformer 拆开,我到底在构造什么
3.1 把 Transformer 按网线接口的方式理解
如果说数据处理是仓库管理,那么 Transformer 骨架就是整个工程的承重墙。我不打算逐行贴代码,因为这类代码在很多开源仓库里都能找到;我更想说的是当你要从零实现一个可用的 Transformer 时,哪些模块必须亲手写、哪些地方最容易写错。
一个最小可用的 decoder-only Transformer 由以下几部分组成:token embedding(把整数 id 映射为向量)、位置编码(我用的 RoPE 旋转位置编码,它对序列长度外推更友好)、多层 decoder block(每层包含 masked multi-head self-attention、feed-forward network、RMSNorm 和残差连接),最后是输出投影层。这里没有 encoder,因为自回归语言模型的训练和推理都只需要 decoder 结构。
写代码时最容易糊弄过去的是causal mask 的实现方式。它的作用是保证第 i 个 token 在计算 attention 时只能看到前 i-1 个 token,不能偷看后面的内容。我第一版错误地把 mask 加在了 softmax 之后的输出上,导致模型在训练时能间接获取未来信息,训练 loss 低得离谱,但生成时完全崩坏。正确做法是:把 mask 加到 softmax 之前的 logits 上,将未来位置的注意力分数设为-inf,这样 softmax 后那些位置的权重趋近于零。这个细节如果你自己手写一遍,基本不会再忘。
3.2 我在代码里保留的三个关键 Mask
除了 causal mask,我在代码里还额外保留了另外两个 mask,它们都能直接影响模型行为。
第一个是padding mask,用于标记无效 token 位置,让 loss 计算和 attention 都跳过这些位置。这个 mask 的设计会直接影响 token 利用率,值得花时间写对。
第二个是文档边界 mask。前面说到用流式拼接构造样本时,如果文本跨文档切块,模型可能会注意到“不同文档之间的奇怪拼接点”。我的做法是生成一个布尔矩阵,记录哪些 token 对属于同一文档,把跨文档的 attention 位置全部 mask 掉。实验下来,这个细节对长文本生成的连贯性有明显帮助。
第三个 mask 是我在尝试推理任务时加的“问题-前提混合 mask”。由于我的任务是给定一组前提和问题,让模型生成推理轨迹,我希望模型在编码前提时能看到全部前提 token,而在生成答案时只能看到前面的推理过程。这种非对称的 mask 结构可以显著改善模型在 multi-step reasoning 任务上的表现。如果你只是做普通的对话模型,不需要它;但如果你想构建带推理能力的模型,这会是绕不开的工程点。
3.3 参数量和显卡容量的快速估算
动手写代码之前最好先算一笔账:我的显卡到底能装下多大的模型?我用的估算公式来自实践经验:
训练总显存 ≈ 参数量 × (参数字节数 + 梯度字节数 + 优化器状态字节数) + 激活值与临时缓冲。
以 1 亿参数模型为例,参数量是 1e8。混合精度训练(fp16)下,参数占 2 字节,梯度占 2 字节,Adam 优化器需要保存一阶矩和二阶矩各 4 字节,合计每参数约 12 字节,即 1.2GB。激活值通常占训练峰值显存的 30% 到 50%,所以 1 亿参数模型在 batch size 32、序列长度 512 时,训练峰值大约需要 3 到 4GB 显存。如果你手里只有一张 8GB 显存的消费级显卡,可以把模型规模压到 5000 万参数以下,并把 batch size 调到 16,基本就能跑起来。
| 模型参数 | 混合精度训练理论占用 | 激活值估算 | 推荐最小显存 | 消费级显卡参考 |
|---|---|---|---|---|
| 2000 万 | 240MB | 300-500MB | 2GB | 笔记本老显卡可跑 |
| 5000 万 | 600MB | 1-1.5GB | 4GB | RTX 3050 级别 |
| 1 亿 | 1.2GB | 2-3GB | 6-8GB | RTX 3060 / 4060 级别 |
| 3 亿 | 3.6GB | 6-8GB | 12-16GB | RTX 4070Ti / 4080 级别 |
这个估算只是起步参考,实际激活值会随 batch size 和序列长度线性增长,跑之前最好用一个小 batch 做试算。
4. 训练不是玄学:loss 曲线里藏着所有答案
4.1 warmup、衰减与 batch size 的联动
训练稳定的第一根弦是学习率调度。我的第一版训练脚本直接使用了固定学习率 3e-4,结果 loss 剧烈震荡,甚至出现 NaN。后来我把调度改成warmup + 余弦衰减:前 1% 的步数把学习率从 0 线性升到峰值,然后按余弦函数缓慢降到峰值学习率的 10%。这个做法的直觉是:训练初期模型参数还很“脆弱”,学习率过大会让梯度一步跨过最优区域;等模型逐渐找到合理的方向后,再加大步长加速收敛;后期则用一个较小的学习率做精细打磨。
batch size 对训练稳定性的影响经常被低估。我观察到,在同样学习率下,把 batch size 从 16 提高到 64,模型的最终 loss 能降低 0.3 到 0.5。但 batch size 越大,单步计算越贵,显存也可能不够。一个折中方案是梯度累积:将多个微批次的梯度累加后统一更新参数,用时间换有效 batch size。我在实际项目中把有效 batch size 固定为 128(即 4 个微批次累积一次),训练曲线的平滑度比单批次训练好得多。
4.2 loss 从 5 掉到 1 的典型过程与异常判断
语言模型训练的经典曲线会经历几个阶段。最开始几轮步数,loss 会从 8 以上快速掉到 5 左右,这对应模型在疯狂学习高频词汇和基本语法结构,这段往往只需要几百步。然后进入漫长的缓慢下降期,loss 从 5 一路磨到 2 左右,这时模型开始掌握复杂句式和上下文关联。再往下到 1 附近,通常发生在训练后期或数据量很充分时,对应模型能够非常有把握地预测绝大多数 token。
我判断训练是否正常的关键指标有两个:一是训练 loss 与验证 loss 的差值。差值小于 0.3 属于正常范围,意味着模型还在持续泛化;差值突然拉大到 1 以上,基本可以确定过拟合,需要增加数据量或加大 dropout。二是梯度范数。我每 50 步打印一次 grad norm,正常时它应该稳定在一个较窄的区间;如果突然飙升到之前数值的 5 倍以上,一定要立刻降低学习率,否则接下来几轮很容易引爆 NaN。
4.3 梯度裁剪和日志监控:训练稳定的最后防线
即使做了 warmup 和 batch size 调整,训练过程中仍然可能出现梯度爆炸。我的标准做法是设置max_grad_norm = 1.0,每步更新前对梯度做一次 L2 范数裁剪。这个操作几乎不增加成本,却能把“偶尔一次异常梯度毁掉整个训练”的概率降到极低。
日志监控方面,我强烈建议把训练脚本做成“可观察”的,而不只是在命令行打印几个数字。我会把每一步的 loss、token 级困惑度、学习率、grad norm、当前 token 吞吐量都记录到结构化日志里,然后用一个简单的本地可视化页面实时查看。这个习惯在排查问题时相当高效——有一次训练曲线出现周期性的 loss 尖峰,我正是靠日志发现数据加载阶段存在一个间歇性 shuffle bug。没有日志,这种问题几乎不可能靠肉眼发现。
5. 让模型开口说话:解码策略、温度与 KV Cache 的性能账
5.1 温度、top-k 与 top-p:三个旋钮的直觉
训练完成后,模型输出的其实是下一个 token 的概率分布,真正从分布中“抽”出一个 token 需要解码策略。这里最核心的旋钮是温度(temperature)、top-k 和 top-p。
温度的直觉是:温度越低,概率分布越尖锐,模型越倾向于选择概率最高的 token,输出更保守、更确定;温度越高,分布越平坦,模型更愿意冒险选择低概率 token,输出更多样但也更容易跑偏。我在推理任务中经验值是0.1 到 0.4 之间,太低会让输出陷入保守重复,太高会让回答变得跳跃离谱。top-k 的作用是先把概率最高的 k 个 token 挑出来,把其余全部踢出候选,然后再做采样。top-p 则是选出累积概率超过 p 的最小子集。实际使用时两者通常会组合:先 top-k 截断长尾,再 top-p 做动态过滤。我给出的默认组合是temperature=0.3, top_k=40, top_p=0.9,大多数场景下都能拿到不错的平衡。
5.2 KV Cache 为什么是“免费的午餐”
如果每次生成一个新 token 都重新运行一次完整的 Transformer 前向计算,代价会非常大——因为前文的所有 token 都要被重新计算一遍。KV Cache 的思路简单粗暴:把每一层 attention 中已经算好的 Key 和 Value 向量缓存下来,生成新 token 时只计算新增 token 的 K 和 V,然后跟缓存拼接,大幅减少重复计算。我在实验中用 1 亿参数模型做对比,未开 KV Cache 时每秒只能生成 5 到 8 个 token,开启后能到每秒 40 到 60 个 token,几乎是数量级的差距,而显存增加完全可以接受。
实现 KV Cache 时最需要注意内存管理。当序列长度从 1 增长到 1024,每层的 KV 缓存规模也在增长,如果不对缓存做复用或及时释放,长对话推理很快会撑满显存。我的做法是预分配一个固定的最大缓存容量,并在序列长度变化时复用既有内存块,尽量避免频繁申请和释放。
5.3 显存不够时的推理妥协方案
从零训练出的小模型虽然参数量不大,但在低显存设备上依然可能推不动长序列。我的一个现实场景是在一台 4GB 显存的旧笔记本上跑 1 亿参数模型,开启 KV Cache 后序列长度到 2048 就 OOM。我当时用了三个妥协方案:
- 分块推理:把输入序列切块,分别计算中间层表示再做拼接。这种方式增加了代码复杂度,但能极大降低单次峰值显存。
- 降低精度:推理时用 fp16 已经是常态,如果还紧张,可以考虑 int8。我实测中 1 亿参数模型从 fp16 转 int8 后,显存占用下降 40%,生成质量损失在可接受范围内。
- 限制上下文长度:这是最简单直接的办法。很多任务其实 512 token 的上下文就够用,强行用 2048 反而浪费时间。
6. 从零路上交过的学费:高频踩坑与完整排查链路
6.1 症状一:loss 完全不下降
这是每个 from-scratch 新手最先遇到的“鬼打墙”。我当时的现象是:训练循环跑了两千步,日志里 loss 始终在 8.7 附近纹丝不动。排查链路我按顺序走了一遍:
第一步查数据是否正确喂入。我在某个 batch 后打印了几条 input_ids 和对应的文本,发现一部分 token 被 padding 了,而 loss 计算时没有应用 padding mask,导致模型浪费大量精力去预测无意义的 padding token。修好后 loss 立刻有了下降趋势。第二步查学习率,尝试从 3e-4 调到 1e-4,loss 开始缓慢下降但速度仍不理想。第三步查初始化,最终把残差层最后一部分的初始化比例调小,loss 下降速度恢复正常。如果你也遇到 loss 卡死,请按“数据 → mask → 学习率 → 初始化”的顺序排查,大概率能定位到问题。
6.2 症状二:生成内容全是重复的“XXXX”
模型训练完成后,生成的前几个 token 还正常,但是从某个位置开始就陷入循环,一直输出同一个词。我排查时发现,训练语料里存在一批连续重复度极高的记录,导致模型学到了“当前 token 后面大概率还是同一个 token”的预测偏差。这个问题的修复分两层:数据层我要做更严格的重复度过滤,任何连续 n-gram 重复超过阈值的文本直接删除;解码层我引入repetition penalty机制,对已经出现过的 token 的概率乘以一个惩罚系数(常见的比值是 1.1 到 1.3),有效降低重复概率。两者结合后,重复问题基本被解决。
6.3 症状三:OOM 与训练中断
训练到第几百步直接 OOM 中断,这是显存管理问题。我的排查结果是,模型自带的激活值缓存没有做释放策略,长时间运行后累积占用越来越大。解决方法是改用流式的梯度检查点(gradient checkpointing):计算激活值时只保留必要部分,反向传播时再重算其余部分。这项技术显著增大了计算量,但能把显存峰值降低一半以上。如果你的显存实在紧张,另一个直接手段是把序列长度从 1024 降到 512,token 利用率虽然下降,但实验仍然能继续。
6.4 症状四:评估指标不错但生成质量很差
在一次实验中,模型的 next-token 预测准确率高达 45%,但生成出的句子质量惨不忍睹。我起初怀疑是解码策略问题,调了半天参数没有改善,才意识到是数据级污染:我的验证集和训练集高度重叠,模型在验证集上的高精度只是“背诵”而非“理解”。之后我把训练集和验证集做了严格的文档级去重,重新训练了一轮,生成质量立刻好转。还有一个容易被忽略的原因是 beam search 等解码策略虽然能提升指标型评估,却不一定适合开放式生成,需要根据任务目标灵活选择。
7. 给想动手的人:一份可落地的工程路线图
7.1 建议的最小闭环:从 2000 万参数开始
如果这是一次真正的 from-scratch 项目,我强烈建议你不要从 10 亿参数起步。先做一个 2000 万参数的小模型,用一份 2 万条左右的迷你语料,目标设定为“能生成语法通顺、逻辑基本连贯的短文本”。2000 万参数在普通笔记本电脑上也能正常训练,这能让你在半天内完整走一遍所有工程链路。等你对数据、训练、解码、评估都有了实感,再逐步把参数量提升到 1 亿甚至 3 亿,每一步都建立在已验证的基础上,排查问题会容易得多。
7.2 时间预算与三阶段路线图
我做完这个完整闭环大概花了四周时间,拆分为三个阶段:
第一阶段(第 1 周):构建数据管线和 tokenizer,完成 Mini-Batch 训练的启动脚本。这一周的目标是让“data → model → loss → 反向传播”的链路跑通,不需要追求好的 loss。
第二阶段(第 2-3 周):调训练超参,完善日志与评估模块,把小模型训练到能够稳定生成通顺文本,并加入简单的推理任务测试。这个阶段是知识密度最大的时期,大量踩坑都发生在这里。
第三阶段(第 4 周):推理优化与任务适配,包括 KV Cache、采样策略调优、以及根据任务需求调整 mask 结构和 loss 设计。
这三个阶段的时间分配可以根据你的基础调整,但我不建议压缩第一阶段——数据管线没做扎实,后面所有环节都会不断返工。
7.3 参考资料与进阶路径
如果你想系统性从零学习构建 LLM 的方法,《Build a Large Language Model from Scratch》是一本很好的参考书籍,它按数据准备、模型结构、预训练、微调到部署的顺序完整走了一遍。读过之后,我的建议是立刻动手做一个自己的项目,不需要试图复刻书里的完整体验,只需要抓住“数据、架构、训练、推理”四条主线,做出一个最小可用的模型即可。
如果你有编程基础但没写过神经网络训练脚本,可以先自己实现一个带反向传播的两层 MLP,熟悉梯度下降和 loss 计算之后,再进入 Transformer 语言模型。这一步绕不过去,但也不是很难。
我在整个从零过程中最深的体会是:真正让你成长的并不是“知道 Transformer 有多少层”,而是“当训练曲线异常、生成输出诡异、显存再次溢出时,你能不借助任何黑盒工具,靠一双手和日志把问题一层层挖出来”。这种能力在 AI 工程里会伴随你很久,而且没有任何现成课程可以直接教。希望这篇文章能帮你把第一段路走得顺利一些。