
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_layer6、n_head6、n_embd384参数量大约1700万词表大小8000到16384之间序列长度256训练batch16到32优化器AdamW显存占用粗算。参数量1700万如果用bf16权重是34MB如果用fp32是68MBAdamW优化器状态大约是参数的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, biasFalse) def forward(self, idx): B, T idx.shape tok self.token_embedding(idx) pos self.position_embedding(torch.arange(T, deviceidx.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里七成都是维度问题。Bbatch、T序列长度、Cembedding维度三者一旦错位各种报错。这里给新手朋友一个建议刚开始调试时在每一个关键节点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(), lr3e-4, weight_decay0.1) scheduler get_cosine_schedule_with_warmup(optimizer, num_warmup_steps500, num_training_stepstotal_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卡住不动先别急着加大模型按这个顺序查学习率是不是太小数据是不是存在大量重复模型容量是不是真的不够分词器是不是出了问题导致很多有效信息被切碎如果loss突然飙升常见原因是学习率太大导致参数更新过于激进或者batch里混入了异常数据。这种时候我的处理是先中止训练把那几步batch里的token id还原成文本看看内容是不是有问题再用a smaller lr重新恢复训练。5.3 消费级显卡下的小技巧如果你的显卡只有几GB显存最实用的三个技巧梯度累积、bf16混合精度、验证集评估频率降低。梯度累积的意思是本来一次batch更新一次梯度现在攒了4个batch才更新一次等价于把batch size放大4倍。我经常用accumulation_steps4来模拟32的batch效果显存占用直接减半。bf16则是靠降低精度换显存和速度现代显卡基本都支持跑起来比fp32快不少。6. 推理与评估模型能“说话”并不等于真的学懂了当模型训练完你会迫不及待去生成句子。这里我建议你冷静一下生成出像样的句子只是“看起来”很像样它是真学到了计算规律还是只是把训练语料里的高频片段背出来了这个问题需要用评估来回答。6.1 采样策略temperature、top-p、top-k的配合生成一个token时模型输出的是整个词表上的概率分布。直接每次取概率最大的token会出现大量重复的机械句子。我一般采用temperature0.8、top_p0.9、top_k40的组合temperature越小概率分布越尖锐输出越保守top-k只保留概率最高的40个token切断长尾top-p则在累计概率超过0.9处截断动态调整候选范围。给你看几个实际生成的效果我的模型用小语料跑完后的输出temperature0.2句子保守、重复性强temperature0.8语句通顺度明显提高偶尔有意外搭配temperature1.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 排查链路我当时按下面这个链路一步步查先查tokenizer和词表映射把生成的token id还原成文本。打开一看高频位置被“的”字霸占了而且这种“的”并不是语料里正常的用法更像分词器把很多字符硬生生归并到了这一个token里。问题原因基本锁定我换了一个新的BPE词表但模型初始化时用的还是旧词表的映射文件两个词表在相同id位置上对应的词完全不同。前面几百步训练其实一直在用错位的映射表学习。查数据管线确认样本切片正常没有空白文本。查loss基线初始loss应该在10到11但这次从8附近开始进一步说明词表有问题——初始loss低意味着预测变得“太容易”。查梯度打印梯度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的方向再迈一步。毕竟从零到一你已经证明过了接下来要做的无非是一点一点把零件升级成更大的机器。