☰
从零手写微型GPT:亲手搭建大语言模型训练全流程
2026/10/3 4:23:58 网站建设 项目流程

1. 为什么我说“从零开始”不是啃论文,而是亲手搭一台“文字生产机”

前阵子整理GitHub星标列表,翻到一个老朋友的项目名:ai-engineering-from-scratch。就是那句常见的“从零开始AI工程”。说实话,这个标题很容易劝退人,因为很多人一看到“from scratch”第一反应是要去手写反向传播、手写注意力机制、手推傅里叶变换。但我在这个项目里泡了几周之后,最大的体感反而是:从零AI工程不是数学题,而是工程题——你不需要证明什么定理,但你需要亲手让一条数据流水线跑起来,让一个几千行不到的模型学会“说话”,让它的输出从乱码变成能看的句子。这篇文章就是记录我完整走完这套流程之后,觉得最值得写下来的东西。

如果你现在的状态是:会Python,懂一点PyTorch,看过很多Transformer图解但总觉得隔了一层,那你就是我写这篇东西的主要对象。我会带你用微型GPT和小规模语料走一遍“数据切块 → BytePair编码 → 手写模型 → 训练 → 采样推理 → 评估”,把黑盒拆成一堆螺丝钉给你看。模型很小,显存要求很低,普通消费级显卡甚至纯CPU都能跑,但流程每一步都是真实的,不会糊弄你。

1.1 从零到底要手写多少行代码

很多人被“从零”两个字吓到,以为是这种画风:自己实现cuDNN、自己写CUDA kernel、自己从汇编开始写PyTorch。不是的。工程意义上的from scratch,指的是你不用任何现成的大模型库,比如不用transformers里的AutoModelForCausalLM,不用pipeline,不用微调框架,而是自己把下面这几块写出来:

  • 数据读取与采样:把纯文本切成固定长度的token序列
  • Tokenizer:自己训练一个BPE词表,或者复用开源tokenizer
  • 模型结构:Embedding、若干层Transformer Block、输出头
  • 训练循环:前向、loss、反向、AdamW更新、学习率调度
  • 推理采样:temperature、top-p、top-k这一套生成逻辑
  • 评估脚本:Perplexity计算、生成样例对比

放在一起,大概就是800到1200行Python。听起来不多,但这里面每一行你都知道它在干什么,因为是你亲手写出来的。一个很实在的类比:这就像学做饭,不是让你从种麦子开始,但你必须自己洗菜、切菜、开火、调味,最后端出一盘真正能吃的菜。你用的锅和铲是现成的,但流程是你的。

1.2 这套小工程能帮你建立什么能力

我见过很多人学了半年Transformer,能画出注意力矩阵,能解释位置编码,但真要他回答“我的loss已经从5降到4了,下一步怎么办”,他会卡住。为什么?因为缺的是把理论知识转成可运行系统的能力。跑完这个小项目,你至少会建立四种意识:

能力具体表现
数据意识能分辨一份语料适合不适合训练,知道为什么要随机偏移切块,而不是从头顺序切
训练意识看到loss曲线能判断是lr问题、数据问题还是模型容量问题
推理意识知道“生成一句像样的话”不等于“模型学会了语言规律”
评估意识会用PPL、生成盲测这些手段判断模型到底学得怎么样,而不是只看训练loss

这四种意识,恰恰是后面你切换到任何AI方向——不管是微调开源模型、做RAG应用,还是追build a reasoning model from scratch这类推理模型路线——都用得上的底层直觉。

2. 开局选型:我为什么拿“微型GPT”当第一个从零项目

我在确定方案前其实纠结过一阵。当时手头有几个候选:从头写一个纯数学推导的感知机/MLP练手;直接去微调一个开源大模型;还是死磕《build a large language model from scratch》那样的完整复现。后来权衡了一下,选了“微型GPT”。原因也很简单,我可以给你看个对比。

2.1 几条路线的难度、收益和时间成本对比

路线核心内容适合人群学到的东西时间成本
手写MLP/感知机纯numpy实现反向传播刚入门的新手梯度、损失函数,但离大模型很远2~3天
调包微调开源LLM下载模型、准备数据、调参有业务需求的开发者工程流程,但内部机制仍接近黑盒半天~2天
复现微型GPT从数据到推理全手写想真正理解LLM机制的人数据、分词、架构、训练、评估全链路3~6周
纯理论推导Transformer公式推到细节做研究的人理论扎实,但容易和工程脱节1~3个月

我最终选微型GPT,因为大模型的很多核心机制在小模型上一模一样地存在:BPE词表、因果注意力、位置编码、AdamW、学习率warmup、温度采样……你不需要等训练完一个几十B参数的模型才能验证理解,你的小模型几个小时就给反馈。这就像学游泳,你当然可以看很多游泳理论,但真正让你学会的是在浅水区扑腾。

2.2 消费级显卡也能跑的最小配置

这里给一个我实测过的组合,大家可以直接参考:

  • 模型参数:n_layer=6、n_head=6、n_embd=384,参数量大约1700万
  • 词表大小:8000到16384之间
  • 序列长度:256
  • 训练batch:16到32
  • 优化器:AdamW

显存占用粗算。参数量1700万,如果用bf16权重是34MB,如果用fp32是68MB,AdamW优化器状态大约是参数的8到12倍,所以总体大概在400到600MB。即便你的显卡只有8GB显存,训练这个小模型也绰绰有余。我甚至试过在纯CPU上跑,把层数降到4、序列长度降到128,照样能出一版“能看”的文本生成Demo,只是一个epoch要久一点,晚上丢下去跑,早上起来看结果就是了。

3. 数据与分词:先让模型“吃”到高质量文本,再谈训练

刚开始做这个项目时,我犯过一个典型错误:先把模型结构写好,训起来,然后才发现数据管线是脏的。后来才理解,训练数据的处理质量,决定了模型退化的下限。数据不好,模型结构再先进也白搭。

3.1 数据管线设计:随机偏移切块是好东西

一般公开语料下载下来是一个巨大的纯文本文件,几千万字那种。你不能直接把整个文件塞给模型,得按固定长度切成样本,比如256个token一段。但直接从头顺序切有个明显问题:模型只会学到文档开头的语法风格,对中后段的结构感知很弱。实际上,我采用的是随机偏移的方案:每次从文档中随机选一个起始位置,然后连续取256个token,不到长度就丢弃,避免制造大量只含几行文字的残缺样本。

此外,还要做train/val分割。我的做法是留出2%的文本作为验证集,验证集不参与训练。这个验证集的作用不是比分高,而是用来监视模型有没有过拟合到训练集的文本模式。还有一个小细节:打乱前要固定随机种子,否则你每次跑出来的数据顺序都不同,对比实验就没法做了。

3.2 BPE分词的工程细节

分词方式是整个项目里最容易让新手懵的一环。常见的方案有两种:一种是用tiktoken直接复用OpenAI已经训练好的词表,省事,但词表有5万多个token,对小模型来说嵌入层会吃掉大量参数,很不划算;另一种是用sentencepiece自己训练一个BPE词表,控制在几千到一两万大小。我走的是后者。

BPE的核心逻辑其实很朴素:先按单个字符切分,然后反复把出现频率最高的相邻字符对合并成一个新token。比如“喜欢”两个字经常挨着出现,它们就可能被合成为一个token,这样模型不需要每次都用两个位置去“记忆”这个词,而是用一个token的embedding就能表达。把这个过程迭代到词表达到预设大小为止。工程上建议词表先设8000,不够再加,不要一上来就搞50000词表,否则你训练的大部分时间都在更新embedding矩阵。

中文场景还要额外说一句:BPE对中文的粒度会拆成偏旁/单字/双字词混在一起,只要你词表够大,效果是能接受的。如果只想快速跑通,你也可以用“按字切分”这种暴力方案,但就是有点浪费序列长度。我建议还是花一个下午把BPE跑通,因为这才是真实LLM使用的方案。

4. 从头写模型:三周我理解的Transformer实现

模型部分是整个项目里最“硬”的一段。但说真的,当你把每个模块拆开看,它没有想象中那么玄。一个因果语言模型输入是一串token id,输出是下一个token的概率分布。中间做这些事:token变成向量,向量之间通过注意力交换信息,通过MLP做非线性变换,重复若干层,最后映射到词典大小的概率。

4.1 模块拆分:Embedding、注意力、MLP、输出头

我用PyTorch写的话,结构大概是这样的:

class SmallGPT(nn.Module): def __init__(self, vocab_size, n_embd, n_layer, n_head, block_size): super().__init__() self.token_embedding = nn.Embedding(vocab_size, n_embd) self.position_embedding = nn.Embedding(block_size, n_embd) self.blocks = nn.ModuleList([ TransformerBlock(n_embd, n_head) for _ in range(n_layer) ]) self.ln_f = nn.LayerNorm(n_embd) self.lm_head = nn.Linear(n_embd, vocab_size, bias=False) def forward(self, idx): B, T = idx.shape tok = self.token_embedding(idx) pos = self.position_embedding(torch.arange(T, device=idx.device)) x = tok + pos for block in self.blocks: x = block(x) x = self.ln_f(x) logits = self.lm_head(x) return logits

里面最有信息量的是注意力模块。真正的因果注意力要保证预测第T个token时只能看到前T-1个token,不能看到未来的信息。实现方式是用一个上三角掩码矩阵,把未来位置的注意力分数设成负无穷。我第一次看别人代码时觉得这不过瘾,后来自己手写了一遍,才意识到如果不做这个掩码,模型在训练时相当于“作弊”:它在预测目标时偷看了答案,loss会很低,但一旦生成时没有未来信息可用,就立刻原形毕露,输出的全是无意义内容。

4.2 为什么维度和残差连接最坑

三周调试下来,我遇到的bug里七成都是维度问题。B(batch)、T(序列长度)、C(embedding维度)三者一旦错位,各种报错。这里给新手朋友一个建议:刚开始调试时,在每一个关键节点print(x.shape),用肉眼确认数据形状的变化。等到你能够流畅写出每个模块的shape转换,Transformer这块就算入门了。

残差连接这块也值得提醒。每个TransformerBlock内部通常有两个残差子层:第一个是注意力块,输入加输出;第二个是MLP块,输入加输出。残差的意义在于让梯度有一条高速公路直接回传到浅层,避免深层网络梯度消失。很多人写代码时把残差顺序搞反,比如先LayerNorm再残差还是先残差再LayerNorm,各有各的流派,但工程上最简单稳定的就是x = x + sublayer(norm(x))这种PreNorm形态。显存占用低,训练也稳。

5. 训出第一个会输出短句的模型:训练配置与调参

模型写好后,最兴奋的就是第一次训练。但说实话,第一次训出来的东西往往很垃圾。这很正常,关键是你面对垃圾输出时有没有一套判断和调整的方法。

5.1 训练脚本的关键参数与代码骨架

我采用的是一套被反复验证过的配置,你可以直接抄作业:

  • 优化器:AdamW,权重衰减0.1
  • 学习率:峰值3e-4,配合warmup
  • 学习率调度:前500步线性warmup,之后cosine退火到最低1e-5
  • 梯度裁剪:max_norm设为1.0
  • 混合精度:支持的话开bf16

训练循环的骨架大致是这样:

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) scheduler = get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=500, num_training_steps=total_steps) for step, (x, y) in enumerate(train_loader): optimizer.zero_grad() logits = model(x) loss = F.cross_entropy(logits.view(-1, vocab_size), y.view(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step()

这里面有个很关键的点:为什么用warmup?因为训练初期模型参数是随机初始化的,梯度方向非常不稳定,如果一开始就用大学习率,很容易把参数冲到“坏区域”,后面怎么训都救不回来。warmup相当于先让模型以很小的步伐试探几步,等梯度方向稳定了再加大步长。这是很多新手最容易忽视的一环,却直接影响最终loss能降到多少。

5.2 从loss曲线读问题

我的loss曲线通常长这样:初始loss在10到11左右,很快就掉到6左右,然后缓慢下行。如果你的初始loss远低于这个值,要警惕,可能是数据泄漏——比如训练集验证集没切干净,或者样本之间存在大量重叠,模型已经在“背答案”了。

如果loss卡住不动,先别急着加大模型,按这个顺序查:

  1. 学习率是不是太小
  2. 数据是不是存在大量重复
  3. 模型容量是不是真的不够
  4. 分词器是不是出了问题,导致很多有效信息被切碎

如果loss突然飙升,常见原因是学习率太大导致参数更新过于激进,或者batch里混入了异常数据。这种时候我的处理是:先中止训练,把那几步batch里的token id还原成文本看看内容是不是有问题,再用a smaller lr重新恢复训练。

5.3 消费级显卡下的小技巧

如果你的显卡只有几GB显存,最实用的三个技巧:梯度累积、bf16混合精度、验证集评估频率降低。梯度累积的意思是,本来一次batch更新一次梯度,现在攒了4个batch才更新一次,等价于把batch size放大4倍。我经常用accumulation_steps=4来模拟32的batch效果,显存占用直接减半。bf16则是靠降低精度换显存和速度,现代显卡基本都支持,跑起来比fp32快不少。

6. 推理与评估:模型能“说话”并不等于真的学懂了

当模型训练完,你会迫不及待去生成句子。这里我建议你冷静一下:生成出像样的句子,只是“看起来”很像样,它是真学到了计算规律,还是只是把训练语料里的高频片段背出来了,这个问题需要用评估来回答。

6.1 采样策略:temperature、top-p、top-k的配合

生成一个token时,模型输出的是整个词表上的概率分布。直接每次取概率最大的token,会出现大量重复的机械句子。我一般采用temperature=0.8、top_p=0.9、top_k=40的组合:temperature越小,概率分布越尖锐,输出越保守;top-k只保留概率最高的40个token,切断长尾;top-p则在累计概率超过0.9处截断,动态调整候选范围。

给你看几个实际生成的效果(我的模型用小语料跑完后的输出):

  • temperature=0.2:句子保守、重复性强
  • temperature=0.8:语句通顺度明显提高,偶尔有意外搭配
  • temperature=1.2:开始出现语法错误,但也能蹦出一些训练语料里完全不存在的新组合

这个调参过程特别有意思,你会直观感受到“概率分布的形状”到底长什么样。这也是采样策略的意义所在:生成是一个概率采样过程,而不是简单的查表。

6.2 用Perplexity和人工盲测评估“学懂”程度

Perplexity(困惑度)是语言模型最常用的指标,公式很简单:

PPL = exp(loss)

训练集loss为3时,PPL大约是20,意思是模型在每一步预测下一个token时,候选空间从8000个词表里缩小到了20个左右。这个数字越小,说明模型对语料的“确定性”越高。

但PPL低并不等于“学懂”。我做过一个实验:拿一批和训练语料风格类似、但完全没见过的文本,去算PPL。如果模型只是背数据,它的训练集PPL会远低于新文本PPL,说明泛化能力差;如果训练集PPL和新文本PPL比较接近,说明模型确实学到了一些通用规律。更直观的评估方式是人工盲测:把模型生成文本和真实人类写的文本混在一起,让朋友猜哪个是AI写的。我试过之后,朋友基本能猜中,但偶尔也会猜错。说实话,这个结果比看loss曲线更让我开心——因为说明我几百行代码已经具备了一些语言的“皮毛”。

7. 一次来自实战的debug:模型只重“的”字,loss却不降

带大家看一段让我印象深刻的排查过程。当时我加了新语料重新训练,第二天起来看到loss卡在5.8左右,怎么都不降,而且生成的样本里几乎每个短句都狂用“的”字,读起来非常别扭。

7.1 排查链路

我当时按下面这个链路一步步查:

  1. 先查tokenizer和词表映射:把生成的token id还原成文本。打开一看,高频位置被“的”字霸占了,而且这种“的”并不是语料里正常的用法,更像分词器把很多字符硬生生归并到了这一个token里。问题原因基本锁定:我换了一个新的BPE词表,但模型初始化时用的还是旧词表的映射文件,两个词表在相同id位置上对应的词完全不同。前面几百步训练其实一直在用错位的映射表学习。

  2. 查数据管线:确认样本切片正常,没有空白文本。

  3. 查loss基线:初始loss应该在10到11,但这次从8附近开始,进一步说明词表有问题——初始loss低意味着预测变得“太容易”。

  4. 查梯度:打印梯度norm,没发现爆炸,排除优化器问题。

最后修复方案就是重新对齐词表映射,然后从头训练。这次之后,loss很快降到3.8,生成的句子不再疯狂堆“的”了。整个过程让我对“模型训练不是一键跑通”这件事有了更深的感触,调试本身就是AI工程的一部分。

7.2 防坑清单

顺手整理一张清单,做这类小项目时值得贴旁边:

常见坑现象对策
词表映射不一致loss下降慢,生成内容单一词高发固定随机种子,训练前验证几个token id对应的文本
学习率太大loss先降后炸加warmup,峰值压到3e-4以内
数据泄漏初始loss过低严格切分train/val,做交叉验证
序列长度过长显存爆炸梯度累积+bf16
验证集参与训练泛化性能虚高训练中绝不触碰验证集

8. 从几百行代码到真正的大模型:这个工程怎么往上爬

你可能会有个疑问:我花了三四周训出一个只会说几句短句的玩具模型,值吗?我的回答是:值,因为这条工程流水线是可以原样放大成真实项目的。

8.1 参数量、数据量与训练规模的scale规则

在真实的LLM训练中,模型参数从百万级到几十亿级,数据量也是成比例增长。业界常讲Chinchilla法则,大意是模型参数越多,需要的训练token越多,比例大概在10比1到20比1之间。我的微型模型1700万参数量,对应几十万到几百万token就够用;但一个7B模型,就需要上百B的训练token。规模大了,工程上的挑战也会变:数据并行、模型并行、分布式通信、断点续训、显存管理这些都会成为新的技能点。但好消息是,你在小工程里亲手建立的数据管线、训练循环、评估逻辑,放大后依然是大项目的地基。

从代码上讲,小工程可以直接替换成更强的组件:把Learnable位置编码换成RoPE,把多头注意力换成GQA或者FlashAttention,把LayerNorm换成RMSNorm,把普通的Softmax换成分布式采样。每一步替换都有对应的论文和开源实现可以参考。正因为你手写过一个能跑的版本,你才能看懂这些组件到底改了什么。

8.2 从生成模型到Reasoning Model:真正的“from scratch”下一站

聊到这儿,就不得不提最近特别热的build a reasoning model from scratch这个方向了。做过生成模型之后你会发现,生成模型擅长“续写”,但不擅长“解题”。让GPT玩具模型接着写一段小说,它勉强能糊弄几句;但你问它“小明有三颗苹果,吃了两颗还剩几颗”,它绝对不会给你算,因为它的训练目标只是预测下一个token,而不是保证事实正确。

Reasoning Model(推理模型)的构建思路,通常是在预训练生成模型的基础上,再引入高质量推理轨迹数据进行训练,再用强化学习等方式优化推理步骤的正确性。这个方向的“从零构建”比普通生成模型更有挑战,但底层依赖的全链路体系——数据构建、词表、训练循环、采样、评估——就是你从小项目里已经跑通的那一套。说白了,从大语言模型到推理模型,跨越的不是代码,而是数据和训练策略。

最后再说一个和《build a large language model from scratch》相关的建议。我这段时间偶尔会在社区里看到有人求这本书的电子版,但我的真实经验是:与其绞尽脑汁找某某网盘资源,不如直接去看作者公开的配套仓库和代码,把一篇篇文档对照着跑一遍。资料散落是正常的,但你真正需要抓在手里的,永远是能跑起来的代码和能复现的实验。我自己后面准备做的扩展方向,就是拿这套微型工程的骨架,尝试加入更多“推理轨迹”类数据,往build a reasoning model from scratch的方向再迈一步。毕竟,从零到一你已经证明过了,接下来要做的无非是一点一点把零件升级成更大的机器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询