我特别喜欢"from scratch"这个词,它有种一意孤行的浪漫。过去一年里,我推掉了所有能推的事,把自己关在书房,用最笨的方式捣鼓出了一个能跑的大语言模型——是真的从零开始:从语料采集、tokenizer训练、模型架构设计到分布式训练和推理部署,全程没有用任何现成的预训练权重。很多人问我这么做值不值,我的回答是:如果你只想把模型跑起来,那有大把现成的东西可以用;但如果你想真正搞懂大模型是怎么工作的,这条路你早晚得走一遍。这篇文章是我整个过程的完整复盘,从思路到实操再到踩坑记录,通通写下来,希望能给也想这么干一次的你一点参考。
1. 内容整体设计与思路拆解
1.1 先搞清楚"从零开始"到底是从哪个零开始
这一步卡住了很多人。"from scratch"这件事,其实没有那么绝对。你是从裸金属和晶体管开始,还是从Python开始,还是从PyTorch开始,完全取决于你这段学习的边界设在哪里。我的边界设定是:不加载任何预训练权重,所有模型参数都从随机初始化开始训练;不调用任何封装好的Transformer实现,模型结构用PyTorch或JAX手写;不做任何迁移学习和微调,从头训练一个完整可用的模型。
确定了边界之后,就要倒推技术栈。这里有个很关键的思维转变:你不是要"复现"GPT,而是要"构建"一个能力边界清晰、你可以完全掌控的模型。这意味着每一步都必须能解释清楚——为什么选择这个架构、为什么用这个trick、为什么从这个数据集开始。我做这个项目的时候,心里很清楚:最终交付的不是一个能打榜的模型,而是我自己对一个语言模型内部机制的理解程度。
另外,先说明白项目目标能帮你省掉很多自我怀疑的时间。我的目标是构建一个参数量在150M到350M之间的decoder-only模型,在OpenWebText这类中等规模语料上训练,能生成出语法基本正确、语义尚可连贯的英文文本。这个目标不高,但足够让训练流程跑通、让推理效果"可见"、让评估体系建立起来。如果你的算力更低,那可以再把目标降到50M参数;如果算力更充裕,可以直接尝试1B以上规模。
1.2 学习路线的倒推设计
我见过太多人一上来就抱着论文啃,结果卡在attention公式里出不来。更现实的做法是:先想清楚最终要交付什么,然后反推每一步需要解决什么问题。我的倒推链条大概是这样的:
要交付一个能跑起来、能生成文本的模型,我就必须有训练好的权重;要训练权重,我就必须有训练代码;要写好训练代码,我就必须先设计好模型结构;要定义好模型结构,我就必须理解tokenizer、embedding、attention、feed-forward、layer norm这些模块;要理解这些模块,我就必须先亲手实现一遍基础版。
于是我把整个项目拆成了六个递进阶段:基础组件重写、tokenizer训练、模型架构搭建、训练循环实现、推理与采样、评估与迭代。每个阶段都有明确的验收标准。比如tokenizer阶段,验收标准不是"BPE算法我会了",而是"我能训练出一个词表大小为8192的tokenizer,并且用它对一段真实文本完成编解码,误差为零"。把模糊的"学会"变成可检验的"做完",这是整个项目推进的关键。
在设计路线时,我还特地控制了一个风险:不要让项目变成无底洞。所以我给每个阶段设了硬性时间盒,基础组件两周,tokenizer一周,模型搭建两周,训练调参三周,推理评估一周。时间到了如果还没做出来,就缩小规模继续,而不是无限延期。实话说,能把一个AI项目做完,靠的不是热情,是这种"到点砍需求"的狠劲。
2. 核心细节解析与实操要点
2.1 Tokenizer:所有模型的起点
如果让我说大模型工程里最容易被低估的组件,tokenizer排第一。很多初学者以为分词就是把句子按空格拆开,实际操作时才发现,能不能训练出一个高效的tokenizer,直接影响模型训练速度和最终语义表达质量。
Tokenizer的核心目标是用有限的词表覆盖尽可能多的文本片段。现在的主流方案是BPE(Byte Pair Encoding)——它本质上是一个贪心的频率合并算法:先把每个字节当成一个符号,然后反复找语料中频率最高的相邻符号对进行合并,直到词表达到预定大小。很多成熟的tokenizer实现里会带一堆优化,但自己写一个最朴素的版本也就三百行代码。
自己实现tokenizer时,有几个点必须注意:
第一,预分词规则决定了合并的边界。最简单的做法是按空格和标点切分,这样"hello."和"hello"不会在后续合并中混在一起。如果你不做预分词直接合并字节,词表会大量浪费在标点组合上。
第二,词表大小要跟模型容量匹配。我用的是8192,对于小模型来说足够,而且训练较快。如果词表太大,embedding矩阵会占据大量显存;如果太小,序列会被切得很碎,上下文长度需求反而增加。
第三,必须处理OOV问题。用BPE时,把所有未知token映射到一个专用的unk_id上是必须的,但更优雅的做法是把词表设计成"任意字节都能拆出来"的形式,也就是确保单个字节的token永远存在。这样理论上不存在真正OOV的输入。
我自己在tokenizer上踩过最大的坑是训练数据没有做去重和清洗就开始合并,结果词表里混入了大量纯数字组合和URL片段,导致文本重构时经常出现奇怪的碎片。后来我加了简单清洗流程,包括去除控制字符、压缩连续空格、统一标点形态,效果立刻好了一个档次。
2.2 位置编码与注意力机制
从零写模型的时候,位置编码是你遇到的第一个"为什么这么设计"的灵魂拷问。Transformer不像RNN有天然的时序结构,所以必须额外给每个token喂位置信息。我采用的是可学习的绝对位置编码——直接初始化一个位置embedding表,让模型在训练中自己调整。这种方式简单直接,对小模型足够,但如果序列长度超过训练长度,推理阶段会出问题。
这里可以做一个更稳妥的选择:使用RoPE(旋转位置编码)。RoPE通过旋转矩阵把位置信息注入attention的Q和K向量里,好处是对序列长度外推更友好,而且干预方式更自然。不过代价是实现复杂度高一些。我最终实现了一个RoPE版本,并且在几个小规模的对比实验里验证了它对long-context生成确实有帮助,尤其是超过训练长度200字左右时,RoPE的困惑度下降比绝对位置编码明显。
而attention部分,我建议初学者一定要亲手写一遍计算图,再做矩阵化实现。Scaled Dot-Product Attention的公式是softmax(QK^T / sqrt(d_k)) V,那个分母sqrt(d_k)不是随便加的。Q和K的内积会随着维度增大而方差增大,如果不做scale,softmax输入就会落入梯度饱和区,训练很容易不稳定。这个细节平时用现成框架时根本不会注意到,只有从零实现时才会真正理解它存在的必要性。
关于多头注意力,值得说的是头的数量和头维度的关系。常见设计是总维度d_model=768,头数12,每头64维。这个比例不是拍脑袋定的,头维度太小表示能力不足,头数太少表示关注模式的多样性不够。我在实验中试过8头×96维,效果一般,最终在速度和效果之间平衡后还是回到了12头×64维。
2.3 模型结构:不只是Transformer堆叠
有了attention基础,整个模型结构就围绕两层逻辑展开:Token Embedding和位置信息进入多头注意力,经过前馈网络和归一化,然后输出下一个token的logits。我采用的block结构是Post-Norm还是Pre-Norm,这个选择值得细说。
早期Transformer都用Post-Norm,也就是在残差连接之后再归一化,训练收敛较慢,对学习率和初始化都很敏感。我现在用的是Pre-Norm,就是先做LayerNorm再做attention和FFN,残差连接不需要过归一化层。Pre-Norm的好处是训练更稳定,但研究社区也发现它可能导致模型底部层的学习信号变弱。实践中,Pre-Norm配合适当的学习率warmup和grad clipping,比Post-Norm省心不是一点半点。
Feed-Forward层的设计也有讲究。标准做法是两层线性加一个非线性激活,中间维度通常是d_model的4倍。我尝试过把GELU换成SwiGLU,一条路是提升效果,但代价是参数增多和实现变复杂。在小模型上,GELU已经很够用,我后来没在FFN的变体上过多纠缠,因为相比这些锦上添花的模块,数据质量和训练稳定性对最终效果影响更大。
最后一个容易被忽略的点是weight tying,也就是让输入embedding和输出logits的线性层共享权重。模型参数量直接从38M降到了26M,而且生成的单词分布更稳定。这个trick是从零实现时非常划算的"免费午餐"。
3. 实操过程与核心环节实现
3.1 硬件评估与软件栈选型
在动手敲代码之前,我花了整整两天盘点手头资源。这个环节不能省,因为它直接决定了你要设计多大规模的模型、用什么样的训练策略。
我的条件是:一张RTX 3090(24GB显存),64GB内存,2TB SSD。考虑到数据加载和梯度计算的开销,这个配置训练150M模型是可以接受的,但batch size和序列长度需要精打细算。如果你只有一张消费级显卡(比如8GB显存),千万不要直接照抄大模型博客里的配置——你的目标应该砍到50M参数,序列长度从1024降到512,甚至用冻结层的方式训练。
软件栈的选择上,我用的是PyTorch + HuggingFace的datasets库做数据加载,tokenizer是自实现的,模型结构也是自实现的,optimizer用了AdamW,并自己写了学习率schedule。这样的组合足够灵活,又不需要从cuDNN层面重新造轮子。不要觉得用PyTorch就不够"from scratch",你的时间应该花在理解模型和训练上,而不是重复制造深度学习框架的基本数学库。
3.2 数据准备与预处理
我用的是OpenWebText的公开子集,大约8GB文本。对于150M模型来说,这个规模其实有点富余,但数据质量永远比数量重要。我需要走一遍笨功夫:按行清洗文本、去重、过滤非英文内容、砍掉超过1000字符的长文本,最后得到一个干净的语料集。
处理流程里有一点容易被忽略:tokenizer训练语料和模型训练语料不能完全一样。tokenizer需要在更大的样本上收敛更好的合并规则,而模型训练语料应该在规模上足够让模型充分过fit。所以我从全部数据中随机抽出5%专门训练tokenizer,剩下的95%留给模型。这个比例没有严格规定,但tokenizer的语料太小,可能让模型总在解码时撞上奇怪的字符组合。
训练数据存储方面,我提前把所有文本转成了token id序列的二进制格式,避免训练时重复做tokenization。400M token的预处理在我的机器上跑了大半天,但如果Figer每次训练循环都临时切分,时间和CPU消耗都会翻好几倍。训练时用到了动态mask和随机拼接策略,这些都能提高训练效率。
3.3 训练循环与超参数调优
训练中最值得反复调的就是学习率、batch size和warmup比例。我的最佳组合是:学习率峰值3e-4,warmup 1000步,线性衰减到1e-5,batch size 64,序列长度512,梯度累积64步。总batch size达到4096个token序列,非常适合一张3090的吞吐量。有人会问,为什么用小batch?我没得选,显存就那么大。但小batch配合梯度累积,效果上接近大batch,只是训练时间会拉长。
warmup是必须的,不是可选。Adam优化器带了二阶动量估计,如果训练初期数据不够就撞上大梯度,容易导致更新方向震荡。1000步warmup让学习率从0线性爬到3e-4,至少在启动阶段不会把loss打飞。
梯度裁剪我也开着,阈值1.0。这个操作看起来土,但对从零训练的模型来说,偶尔出现的异常token和长序列会让梯度过大,不裁剪的话几个step就能毁掉整晚的训练进度。这个教训我得到了太多回。
3.4 评估维度的设定
训练过程中,只看training loss是不够的。我在每5000步跑一次验证集困惑度,并保存checkpoint。模型生成的文本质量要看两个方面:perplexity是一种衡量标准,但更直观的是采样生成的连贯性。要做到后者,我得解析模型输出,提取最后一个token的隐状态,然后做softmax采样,温度设0.8。温度高于1会让文本随机性变大,明显影响可读性,低于0.5会显得很机械。
另一个容易遗漏的评估点是过拟合监控。150M模型在400M token上训练,理论上是不会过拟合的,但如果你的数据清洗不足,模型会开始记忆奇怪的句子片段和重复的模式。这就要靠验证集和生成的多样性来做人工判断。我在训练后半段增加了重复惩罚参数,虽然在一些测试中它会影响表达多样性,但明显改善了生成文本的可接受度。
3.5 渐进式训练策略
一个很实用的建议是分层渐进训练。这个思路有点像造房子:先让地基成型,再盖楼。我在训练过程中,先用96个token的短序列训练2000步,让embedding和attention的基本结构学会表达;然后跳到256个token继续训练5000步;最后才进入512个token的正式训练阶段。这么做的原理是:短序列上的梯度信号更密集,更容易快速建立合理的representation。如果你一上来就用512长度训练,前期收敛速度会明显变慢,而且很容易因为长序列的反向传播路径太长导致训练不稳定。
这也让我的checkpoint命名变得更科学:不同阶段保存的模型用于不同的生成测试,短序列阶段检查基本的语法正确性,长序列阶段检查文本的一致性和主题连贯性。有些checkpoint会被后面阶段覆盖,但我会保留关键的几个,方便回溯问题。
4. 常见问题与排查技巧实录
4.1 Loss不下降的根源分析与修复序列
从零训练模型,遇到的第一座大山就是loss不下降。我在早期试过的一个错误配置是:模型维度太小(128),数据量也小,训练到的模式很快就崩了。这时最容易怀疑的方向就是代码bug,但排查顺序上,我建议从数据侧入手,先看看输入tensor是否包含大量padding。如果padding比例超过50%,注意力大部分时间都在看没有意义的占位符,有效学习信号就会被稀释。我的修复方案是:按长度分桶打包batch,并动态padding到桶内最大长度,而不是padding到固定序列长度。
如果数据没问题,就要查初始化和学习率。有一次我把embedding标准差设成了均匀分布0.2,结果前100步loss直接飙到11以上不回落。换成小标准差高斯初始化后,训练立刻回到正常轨道。从零实现的一个常见坑是你跟论文的初始化方式不一样,而论文里的细节往往只写一句话,实际效果可能天差地别。
4.2 显存不足时可用的8个优化技巧
训练小模型最烦的就是显存不足。我整理了一套非常实战的降显存技巧,按优先级排序:
第一,梯度累积。不增大batch也能扩大有效batch size。第二,混合精度训练。fp16/bf16能把显存直接砍半,但在从零实现时要注意loss scaling和梯度溢出问题。我这里用的bfloat16是RTX 3090上的选项,效果比fp16稳很多。第三,分段反向传播,即activation checkpointing,用时间换空间。第四,减少序列长度。第五,减少batch size并增加梯度累积步数。第六,把优化器状态放到CPU。第七,用小d_model。第八,坚持用权重共享。
这些技巧里,前两个是性价比最高的。我实测在混合精度下,相同训练设定,显存占用从24GB降到14GB左右,而且速度还能提升20%左右。
4.3 生成阶段遇到的无限重复问题
训练完成后最打击人心的阶段是生成测试。第一次成功生成文本时,我看到的是一连串不断重复的单词:the the the the and and and and。这个问题的根源,通常不是模型没学会语言,而是采样策略和训练目标之间的gap。训练时模型学习的是"给定上文,哪个下一个token概率高",但生成时如果你用贪心解码或温度过低,很容易陷入高概率循环。
我试过的有效修复方式:将温度设置为0.8到0.9之间,开启top-p采样(p=0.92),并使用重复惩罚(repetition penalty=1.2)。这三个手段组合起来,生成的文本质量和多样性明显提升。还有一个实用技巧:把训练时加入的mask方式调一调,在训练中随机丢弃部分后续文本位置,可以增强模型对于上下文截断的抗性,进而减少生成时的重复。
4.4 训练到中后期loss突然冲高
如果你发现loss在几万步后猛然从3.1跳到6.7,一般有几种原因:学习率过高导致优化器进入震荡区;数据混入了坏batch;或是梯度累积的梯度没有清零。我的排查顺序是先检查梯度范数日志,如果范数在跳高点突然飙升,就说明是梯度爆炸。解决方案除了梯度裁剪之外,还可以临时降低学习率,或者切到更小的batch继续跑。
另一次我遇到的类似问题跟数据有关。当时数据管道里有一个问题,某些id对应的token在embedding层存在内存访问错位,导致部分batch梯度完全错误。定位这类问题要借助小样本训练测试:随便抽取一个batch,手动验证一遍前向和反向的数值与手算结果是否一致。这个方法虽然笨,但是排查从零实现时最可信的手段之一。
4.5 常见问题速查表
| 现象 | 可能原因 | 修复优先级 |
|---|---|---|
| Loss初期不降 | 学习率过低或warmup缺失 | 调高LR或加warmup |
| Loss初期爆高 | embed初始化过宽/padding过多 | 调整初始化/动态padding |
| 中期梯度爆炸 | LR过高/梯度累积错误/clip缺失 | 打开grad clip/降LR |
| 生成重复 | 解码温度低/无惩罚采样 | 开启top-p+温度+rep penalty |
| 推理长度受限 | 位置编码未做外推 | 改用RoPE/训练时加长序列 |
| 显存不足 | batch过大/未用混合精度 | 梯度累积+bf16 |
5. 扩展思考与实践路线
5.1 从"复现"走向"推理模型"的路径
当你把基础LM从零搭完、训练和评估都稳定之后,方向自然就是推理能力。2024年到2025年,"推理模型"成为AI工程的热词,但说实话,业界对它的定义还没有统一。
推理模型在工程上可以拆解为两步:一是数据侧构建思维链(Chain-of-Thought)训练数据,让模型学会"先推理后回答"的输出模式;二是训练侧引入更精细的强化学习或SFT策略,让符合推理路径的回答被放大。你说这些能不能从零实现?能,但工作量会再翻一倍。我的经验是,不要一开始就上复杂的GRPO或RLHF,先做数据层面的事:把推理步骤显式标注出来,训练模型生成中间推理过程,这就能带来非常大的质量提升。
如果你想把它做成一个完整项目,可以参照这条路:先训练一个中规模的CoT数据生成器(其实用你已有的模型就能做),然后过滤出高质量CoT样本,再做第二轮SFT。之后可以尝试最简单版本的强化学习对齐。一步一步来,不急于求成。
5.2 我为什么坚持不调包训练
说到最后,总有人问我:用HuggingFace的GPT2LMHeadModel直接加载不香吗?香,太香了。但如果你从来没有自己写过一个transformer,你就永远不知道哪些模块是真正难的部分,哪些是库帮你擦掉的细节。作为工程师,我用的工具绝大多数都是现成库,但在AI这个正在飞速演化的领域,你至少要对地基有一条完整的主线认知。
这个项目最珍贵的副产品,是我积累了一批可复用的脚本:tokenizer训练工具、数据管道、模型定义、训练循环、评估脚本、采样脚本。下次我再实验新的架构和训练策略,不用从零开始,所有基础设施都是我的。这种积累带来的快感,不亚于看到模型第一次生成出通顺句子。
5.3 给也想"从零开始"的人两个忠告
第一,别一上来就追求复现论文。先设定一个比论文小一个数量级的目标,让自己在可控范围内把完整链路跑通。链条通了你再放大,每一步都是平滑的scale up。第二,保留一份从零手写的简洁版代码。在你项目越来越复杂之后,那份500行以内的核心版本会是你回顾原理、定位bug最好的参照。它能时刻提醒你:复杂只是表面的,核心永远简洁。
最后再分享一个非常个人化的体会:训练大模型和养植物很像,你每天都在浇水、记录、调整光照,但生长是沉寂的。连续几天loss纹丝不动,你会怀疑是不是哪里坏了。但某天深夜,你会突然看到一条采样文本开始有了语法结构,再过几天,它甚至能在两个句子之间建立逻辑关系。那种感觉无法用参数和loss去量化,但它确确实实是你亲手造出来的东西在长大。我认为这就是"from scratch"真正的魅力所在。