这两年我面试了不少自称“算法工程师”的候选人,简历上写满了各种大模型微调项目的经历,但你让他讲讲Attention的Q、K、V到底是怎么算出来的,十有八九会卡壳。这不是个例,而是整个AI工程圈子的通病——大家越来越像在用黑盒,而不是在造工具。所以我一直觉得,真正值得做一次的项目不是“用LangChain跑通一个Agent”,而是像这个标题说的:ai-engineering-from-scratch,从零手搓AI工程,不依赖任何封装好的框架,从数据到模型,从训练到推理,每一步都用双手去实现、去验证。这篇博文就是我个人在这个方向上的完整实践总结,适合真正想弄懂大语言模型底层逻辑的人,也适合想在有限资源下自己训练一个可运行迷你模型的工程师。
我见过太多人一上来就奔着训练“大模型”去,结果卡在显存、数据、算法细节上出不来。而“from scratch”这个思路恰恰是破局的关键:它不是让你从物理原理开始造芯片,而是让你沿着一条清晰的路径,亲手搭出每一块积木。
1. 项目整体思路拆解:为什么非要“从零开始”
1.1 框架依赖症:AI工程真正的门槛
现在的AI工程生态已经非常成熟,HuggingFace的transformers库几行代码就能加载一个几十亿参数的模型,LangChain封装好了各种链式调用,连微调都有Lora一键脚本。但这种便利带来了一个隐蔽的问题:很多从业者对模型内部的运行机制只有模糊的认知。举个例子,我问过一个人“位置编码为什么要加在Embedding后面”,他的回答是“框架里就是这么写的”。这种状态在工程上非常危险——模型效果不好时你不知道该调数据还是调结构,线上出错了你不知道该排查哪一层。
“from scratch”的思路逼着你把每一个环节都拆开看一遍。tokenizer怎么把文本变成id,embedding矩阵怎么初始化,attention的权重是怎么通过softmax归一化的,loss在反向传播时如何把梯度传回每一层——只有亲手写过这些代码,你才真正拥有“诊断”模型的能力。这不是学术洁癖,而是工程自信。当你的模型训练到一半出现loss震荡时,一个真正理解内部结构的人,是能直接定位到是学习率的问题、数据分布的问题、还是位置编码的数值范围出了问题。
我自己的经验是,花两周时间手写一个迷你Transformer,带来的理解深度超过刷十篇transformer论文。因为你会亲自遇到所有论文里没有写的工程坑:数值稳定性、梯度消失、训练不收敛。
1.2 从零开始的三层路径:算法原理、工程实现、部署落地
“from scratch”听起来容易让人打退堂鼓,但聪明的做法不是一步登天。我把这个项目拆成三个递进的阶段,每个阶段都有明确的目标和产出。
第一阶段是算法原理层。目标是不借助任何深度学习框架的模型层API,从张量操作开始实现一个完整的Transformer。别怕,这个阶段不需要从零写CUDA,用PyTorch的基础张量操作就够了。你得自己实现multi-head attention、feed-forward网络、layer norm、残差连接,还有整个训练循环。这一步做完,你对模型结构、前向传播、反向传播的理解会脱胎换骨。
第二阶段是数据工程层。目标是亲手构建一套完整的数据处理流水线,从原始语料到BPE词表,再到训练样本的批量生成。这个阶段你会意识到,数据清洗、去重、采样策略对最终效果的影响程度,有时比调模型结构更大。而且很多“from scratch”的教程会跳过数据部分,直接给你一个词表文件,这是个巨大的遗漏。
第三阶段是部署推理层。目标是把训好的模型跑通推理流程,理解KV Cache、温度采样、top-p采样这些推理期策略,进一步还可以在CPU上做量化加速。这一层决定了你的模型能不能真正“产品化”。
这三个阶段不是割裂的,而是像盖房子一样,一层叠一层。我在下文的实操部分会完整走一遍这三层里的关键环节。
2. 核心环节拆解与实操要点:先把每一块积木打磨干净
2.1 认识数据:Tokenizer是怎么“读懂”文本的
很多人把Tokenizer当成一个黑盒附件,直接调sentencepiece或者tokenizers库完事。但从零开始做AI工程,你必须亲手实现BPE(Byte Pair Encoding)算法,因为这是理解“文本如何被压缩成序列”的唯一捷径。
BPE的原理并不复杂:先把所有字符拆成单字节或者单字母,然后不断统计相邻token对的出现频率,把出现频率最高的一对合并成一个新token,一直重复到词表达到目标大小。核心参数就是词表大小和特殊token的数量。实际操作中,tokenizer的保存和加载有个容易踩的坑:训练好BPE之后,一定要在词表里保留<unk>、<pad>、<bos>、<eos>这类特殊token的id,否则推理阶段会得到无效的padding或者无法区分句子边界。
在中文语料上做BPE有一点需要特别留意:中文是以字为基本单位的,BPE在初始阶段会把一个字拆成UTF-8字节,词表里会出现大量碎片化的字节组合。我的建议是词表大小设到8000到16000之间,太小会导致拆词过碎、序列过长,太大又会导致词表稀疏、Embedding矩阵过大。我们后面实操部分会给出具体的参考值。
2.2 从零手写Transformer:模型结构的核心拼图
手写Transformer是这个项目最见功夫的一步。我推荐按照“Embedding → 位置编码 → 多头注意力 → 前馈网络 → 层归一化与残差 → 输出层”的固定顺序来实现。这里有几个关键的逻辑必须搞清楚。
首先是位置编码。Transformer没有循环结构,天然不感知顺序,所以必须在Embedding向量上加位置信息。用正弦余弦函数做位置编码的好处是不需要训练参数,且能泛化到比训练时更长的序列。实操时要注意:位置编码的维度必须和Embedding的维度一致,否则没法直接相加。
其次是多头注意力。核心计算是Softmax(QK^T / sqrt(d_k))V,但新手最容易忽略的是缩放因子sqrt(d_k)。如果不做这个缩放,当维度变大时点积数值会非常大,softmax会进入饱和区,梯度变得极小。这就是为什么模型结构相同但loss降不下去的常见原因之一。
在我手写代码的过程中,我还发现一个容易被忽视的细节:在实现causal mask(因果掩码)时,要把注意力分数中未来位置的权重设为负无穷,再喂给softmax。但直接用-inf会有数值风险,安全的做法是设成一个绝对值很大的负数,比如-1e9,同时保证在FP16混合精度训练时不会溢出。
我给出一个迷你多头注意力的参考实现(PyTorch风格),这是整个项目中最核心的代码段:
import torch import torch.nn as nn import math class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_head, dropout=0.1): super().__init__() assert d_model % n_head == 0 self.d_k = d_model // n_head self.n_head = n_head self.w_q = nn.Linear(d_model, d_model) self.w_k = nn.Linear(d_model, d_model) self.w_v = nn.Linear(d_model, d_model) self.out_proj = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): batch_size, seq_len, _ = x.shape Q = self.w_q(x).view(batch_size, seq_len, self.n_head, self.d_k).transpose(1, 2) K = self.w_k(x).view(batch_size, seq_len, self.n_head, self.d_k).transpose(1, 2) V = self.w_v(x).view(batch_size, seq_len, self.n_head, self.d_k).transpose(1, 2) scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float('-1e9')) attn_weights = torch.softmax(scores, dim=-1) attn_weights = self.dropout(attn_weights) context = attn_weights @ V context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1) return self.out_proj(context)这段代码里有一个工程上的小技巧:实现时先把Q、K、V拆成多头维度,用transpose把头的维度换到第二维,这样矩阵乘法时每个头可以并行计算,而不是用for循环逐头计算。别小看这个细节,它直接决定了训练速度。
2.3 训练策略:让损失函数按预期下降
模型结构只是骨架,训练策略才是让模型真正“活过来”的血液。从零开始训练,你需要自己处理四个核心问题:损失函数、优化器、学习率调度、数值稳定性。
损失函数这边,语言模型通常用交叉熵损失,但要注意的一点是:因为一个batch里每条样本的长度不一样,PyTorch的交叉熵默认对所有token做平均,而不是对样本做平均,这会导致长样本主导梯度。你需要手动ignore_index掉padding位置,并且根据非padding token数量对loss做归一化,否则训练会变得很不稳定。
优化器首选AdamW,这一点没什么悬念。AdamW的关键参数是betas=(0.9, 0.95)和weight_decay=0.1。我见过很多人直接用默认的betas=(0.9, 0.999),虽然也能训,但配合warmup时会产生更明显的振荡。学习率调度务必使用“warmup + cosine decay”的组合:前几百步用一个较小学习率逐渐预热,达到峰值后再按cosine曲线衰减。这个设计来源于经验——模型在训练初期参数还处于混乱状态,如果一上来就给大学习率,loss会冲到天上甚至直接产出nan。
数值稳定性方面,除了前面提到的注意力分数缩放,还需要对初始化的方差做控制。常见做法是把Embedding和输出层的标准差设为0.02,权重矩阵的标准差设为0.02/sqrt(2*d_model)。这些参数都来自预训练大模型的实践经验,直接套用能省掉大量调参时间。
3. 实操过程:从零搭建一个可运行的迷你GPT
3.1 环境与工具怎么选
先交代环境。操作系统用Ubuntu 20.04以上,Python版本我推荐3.10,深度学习框架选PyTorch 2.1以上版本,因为新版自带torch.compile加速,训练速度能提升15%到20%左右。显卡方面,如果你是个人学习用,一块RTX 3060 12G显存就够了,不用一上来就追求A100。显存不够时就走CPU训练或者拿小模型在笔记本上跑,后面我会专门讲限制下的训练技巧。
训练框架方面,这个项目刻意避开transformers库,不使用Trainer,这样你才能完全掌控训练循环。但你没必要连数据加载都自己写,用torch.utils.data的Dataset和DataLoader就好。
还有一个重要的工具:tqdm一定要装。训练大模型是小时级别的活,没有进度条你根本不知道目前卡在哪个环节。
3.2 五步搭建一个“迷你ChatGPT”
搭建迷你GPT可以按五个步骤来走,我给你的这套方案是我自己复现过多次的,总训练时长控制在2到4小时以内,非常适合个人实践。
第一步:准备语料。建议选维基百科的中文子集,或者直接用自己的博客文章、开源小说库都行。语料总量控制在50MB到200MB之间,清洗时要做几件事:去HTML标签、统一全角半角符号、去掉空行和文档头尾的噪声文本。不需要做分词,因为BPE会自己处理。
第二步:训练BPE词表。用sentencepiece库训练一个词表,命令如下:
spm_train --input=corpus.txt --model_prefix=spm_bpe \ --vocab_size=8000 --model_type=bpe \ --character_coverage=1.0 --pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3这里有一个值得说透的参数:character_coverage=1.0。英文语料通常用0.9995,因为英文有海量生僻字符;但中文语料如果覆盖率不到1.0,很可能会把一些不常见的汉字拆成字节,导致词表出现大量低频碎片token,影响模型的学习效率。
第三步:搭建模型。我推荐一套适合短序列的迷你GPT结构参数,见下表:
| 参数名称 | 推荐值 | 说明 |
|---|---|---|
| d_model | 256 | 模型宽度,过小则拟合能力不足 |
| n_head | 4 | 注意力头数,需能整除d_model |
| n_layer | 4 | Transformer块的层数 |
| seq_len | 256 | 最大序列长度,短序列训练快 |
| vocab_size | 8000 | 与BPE词表大小对齐 |
| dropout | 0.1 | 防止过拟合,训练后期可降低 |
第四步:实现训练循环。这一步的核心是构造(inputs, targets)数据对,做一个标准的自回归训练:把输入序列右移一位作为target,让模型逐个token预测下一个token。我建议使用梯度累积来模拟更大的batch size,这在小显存场景下几乎是必须的操作,后面5.2节里我会展开讲。
第五步:生成文本。推理阶段和训练阶段有一个重大差异:训练时我们是一次性并行处理整个序列,而推理时需要一个token一个token地生成。生成过程用temperature和top-p采样控制多样性和质量,我常用的组合是temperature=0.8, top_p=0.9。如果你的模型出现“复读机”现象,把temperature调高到1.0以上,同时加入重复惩罚项,惩罚那些已经在已生成序列中出现过的token。
3.3 训练过程盯住哪些指标
训练不是把代码扔进去就完了。你需要实时监控四个关键指标,任何一个出现异常都要立刻干预。
第一个是损失值(loss)。我训练的直觉经验是:在200MB中文语料和上面那套迷你参数下,loss最初会从10左右快速降到5附近,经过2到3小时后能降到3.5到4.0之间。如果loss卡在某个值超过半小时没有明显下降,你得检查是不是学习率已经衰减得太低了,或者数据里有大量重复内容。
第二个是梯度范数(grad norm)。这是被很多人忽略的指标。正常训练时它应该在1到10之间波动。如果你发现grad norm突然冲到50以上,说明出现了梯度爆炸,先把梯度裁剪设成max_grad_norm=1.0,然后去检查数据里有没有异常长的文本,或看attention score是不是出现了很大数值。
第三个是困惑度(perplexity,简称ppl),困惑度等于exp(loss),它比loss更直观。比如ppl=20,意思就是模型在20个候选词里猜下一个词,这个指标能反映生成质量,是你做人工评估前最好的自动参考。
第四个是学习率曲线的实际走向。很多时候代码里写了warmup,但日志里没打出来,你无法确认调度器是否生效。我建议在训练日志里把当前step的学习率一并打出来,确保它确实经历了预热然后再衰减。
4. 从语言模型到推理模型:进阶之路
4.1 为什么纯语言模型不会“思考”
训练完上面的迷你GPT,你会发现它的输出虽然通顺,但基本是“惯性续写”,遇到数学题会一本正经地胡说八道。这就是纯语言模型和推理模型的根本区别。前者擅长拟合语料中的词分布规律,后者则要求在生成过程中执行多步逻辑推理。
你问:“一个农夫有17只羊,除了9只以外都死了,还剩几只?”这其实是个脑筋急转弯,标准答案是9只。但一个纯续写模型只会依据语料中的相似文本片段,生成一句“还剩8只”之类的概率性答案。因为它的训练目标里根本没有“理解语义并进行逻辑判断”这个环节,只有“预测下一个token”。所以想让模型具备推理能力,必须在下游任务中引入专门的训练方式,这就是近期《build a reasoning model from scratch》这类路线要解决的问题。
4.2 推理模型的训练配方:SFT加RL
从零构建推理模型,可以沿用大厂已验证的“三明治”训练方案,这套配方已经被证明是有效的,你在小模型上复现也能体会到它的效果。
第一层是SFT(监督微调)。你需要构造一批高质量的“问题-思维链-答案”三元组数据。关键在思维链部分:它不能是简单的一步答案,而是要让模型学会展示中间推理步骤,哪怕那些步骤在最终答案里用不到。数据数量方面,一万条精编样本的效果往往好于十万条盲目爬来的数据。
第二层是RL(强化学习)。这里我推荐组策略优化这类轻量级算法,它不需要像PPO那样搞一整套价值网络。简单说,你把SFT后的模型当成策略网络,让它对同一个问题生成多个答案,然后设计一个奖励函数为每个答案打分。打分可以从这几方面考虑:答案的语法正确性(可让另一个语言模型来判分)、与标准答案的语义相似度(用向量相似度计算)、以及一个专门针对推理任务设计的格式奖励。
第三层是测试时计算扩展。这一步不用改任何模型参数,纯推理阶段的技术。核心思想是让模型对一个问询生成多条候选推理链,然后通过多数投票来选出最终答案。对于数学题这种有明确正确答案的任务,这个方法能把准确率提升一大截,而且开销只是多跑几次推理而已。我在上面的迷你模型上试过,即使不经过RL,光靠采样加投票,数学类问题的结果也有明显进步。
第三层还有一个更强力的变体——长思维链扩展。它通过prompt技巧让模型“先想清楚再回答”,甚至在需要时让模型自己给自己出子问题,逐步逼近答案。这种方法在小模型上格外有效,因为小模型本身的参数容量不够一步到位,但它可以通过更多的推理步骤弥补。
5. 常见问题与排查技巧实录
5.1 训练不收敛?按顺序排查这四个地方
训练模型最磨人的就是“loss不降或者乱跳”。我总结了一个固定排查顺序,能省你几个晚上。
先看数据。检查数据清洗环节——语料里如果混入了大量空文本、纯标点或者乱码,BPE会把它们编码成无意义的id序列,模型在学习纯噪声。我遇到过一次loss一直卡在7.0左右不降,最后发现是一批下载的语料里夹杂了大量HTML标签,清洗环节忘了去标签。
再看超参数。如果你的学习率大于5e-4,在小模型上几乎必然震荡。我建议起步用3e-4,配合200步预热,这是当前主流中小模型的稳定配置。如果loss曲线是下降的但充满抖动,那多半是batch size太小,需要开梯度累积。
再看数值。loss变成nan,不要先调学习率,先去检查注意力掩码是不是写成了-float('inf')。在FP16混合精度下,-inf经过softmax时可能会产生nan。同时检查Embedding层是否加了sqrt(d_model)的缩放,很多新手会忘记这一步导致前向数值范围失控。
最后看模型结构。如果数据、超参数、数值都正常,但loss在训练中途突然回升,那可能是位置编码的序列长度不够,训练样本超过了模型能处理的最大长度,导致尾部token被迫截断,模型在边界处学不到有效信息。
5.2 显存不足怎么办:三个实操技巧
显存不够是个人做from scratch项目最大的拦路虎,但解决办法其实很成熟,我按性价比排个序。
第一个技巧是梯度累积。假设你的显卡只能塞下batch size=4的样本,但你想要等效batch size=16的效果,那就把batch size设成4,累积4个batch的梯度后再执行优化器step,等效batch就是4×4=16。注意一下,要确保loss用一个非padding token计数来归一化,而不是直接除以batch内的总token数。
第二个技巧是梯度检查点(gradient checkpointing)。这个策略很经典——前向传播时不保存中间激活值,反向传播时再重新计算一次。相当于用时间换显存,训练速度会慢20%到30%,但显存占用直接降一半。在PyTorch里启用很简单:在Transformer块的forward函数外包上torch.utils.checkpoint.checkpoint即可,但要注意batch里的输入必须是梯度要求的叶子张量,否则会报错。
第三个技巧就是混合精度训练,配合torch.cuda.amp使用。它能同时缓解显存瓶颈并加速训练,但需要你在代码里主动检查loss是否溢出。我的习惯是每100个step检查一次loss是否nan或超过了一个大阈值,动态调整scale。
给一个粗略的显存估算公式:模型显存约等于(参数量×4字节)加上(激活值×batch_size×seq_len×d_model×2字节)。一套256宽、4层、词表8000的迷你GPT,大约有2000万参数,float32下模型本身只占80MB,大头全在激活值上,所以batch size和序列长度才是显存的第一决定因素。
5.3 生成质量差?先别急着换模型
模型终于训练完毕,但生成出来的文本逻辑混乱、跑题严重,这时先别去重新训练。我总结了两个优先排查方向:解码策略和过拟合。
解码策略方面,纯贪心解码(每个时刻只取概率最高的token)虽然快,但会产生大量重复词,效果奇差。至少把temperature和top-p采样加上,推荐temperature=0.8, top_p=0.9,这两个参数是控制文本多样性和衔接性的主要旋钮。如果你想进一步抑制重复,可以给已生成token的logits乘以一个衰减系数,或者直接屏蔽top-k里那些重复的候选。
过拟合方面,小模型特别容易把语料背下来,生成时会出现“跟原文一字不差”的现象。判断方法很简单:看看训练loss和验证loss的差距,如果验证loss比训练loss高非常多,说明过拟合了。解法不是马上加数据,而是先把dropout从0.1提到0.2,然后给权重衰减系数加一点,再试试早停。如果你有条件做数据,扩充语料多样性也有效,但见效慢。
6. 个人实践中的几点体会
6.1 我在实操中踩过的三个坑
第一个坑是数据清洗不够彻底。我最初用开源小说语料训练迷你GPT,结果模型经常生成“第一章 第一章 第一章”这种循环,排查后发现是原数据里有很多重复的章节标题,BPE把“第一章”直接编码成了单个高频token,模型学到的就是复制这个token。后来我用MinHash做了一遍文档去重,问题立刻缓解。
第二个坑是推理阶段的掩码和训练阶段不一致。训练时用的causal mask是下三角掩码,推理时初始输入只有一个起始符,如果不加处理,attention会把起始符的位置也当成过去位置,导致生成的第一个token就出现偏差。解决方法是推理时手动构造一个只包含已生成位置的全1掩码。
第三个坑是学习率调度器的step时机。我在训练循环里用的是scheduler.step()按step更新,但很多人会误用成按epoch更新,导致学习率在训练初期就飙升到很大,loss直接飞出天际。这个问题排查起来很隐蔽,因为代码不会报错,只有看日志里的lr曲线才能发现。
6.2 学习路径与资源推荐
如果你想沿着“from scratch”这条路深入,我的建议是按《build a large language model from scratch》这本书的路线来精读和复现。这本书的优点是它完全面向动手实践,章节设置基本就是tokenizer、attention、预训练、微调、部署这条主线,和我在上面的实践路径完全对齐。读这类书有一个阅读方法上的建议:每章读完,不要急着翻下一章,一定要把那章里的代码自己敲一遍。长篇代码直接抄会变成体力劳动,更好的做法是先理解图结构,再自己把网络层写出来,最后对照原书的实现找差异。尤其是attention的实现,通常有十几种细微差别的写法,只有自己写过一遍才能理解为什么有人用view、有人用reshape。
动手能力强的读者,建议直接在github上找那些带完整训练脚本的开源GPT复现项目,把它们作为你的“参考答案”。选项目的时候注意看训练脚本的细节:有没有梯度裁剪、有没有学习率预热、有没有loss日志可视化。这三个细节如果都具备,这个项目大概率是靠谱的。
6.3 这个项目怎么继续扩展
如果你把这套迷你GPT完整跑通了,后面可以往两个方向扩展。一个方向是做垂直领域的from scratch,比如医疗问答、法律文本生成,核心改动在语料清洗和SFT数据构造上,不需要重写模型结构。另一个方向是继续钻研推理能力的强化,把你训练的迷你模型当作“实验田”,尝试自己写奖励函数做强化学习。思路跟前面4.2节里讲的配方一致,区别在于你真的有一个亲手训练的模型,而不是拿现成的开源权重来跑,反馈会更直接。
我个人在实际操作中的体会是:从零手搓一个模型的最大收获,不是最终跑出一个漂亮的ppl数字,而是你在排查train loss不降、处理显存溢出、调生成温度这些过程中建立起来的感觉。那种感觉,是你在生产环境救火时最值钱的东西。遇到任何一个环节出问题时,你不会像以前一样只会换框架、换超参,而是能真正想明白问题出在哪一层。只有亲手把每块积木搭过一遍,你才知道整个系统是怎么站起来的。
最后再分享一个小技巧:无论你训练模型用了多小的算力,都一定要把训练日志完整保存下来,至少包括loss、grad norm、learning rate和吞吐量这几项。等你跑第二、第三个模型时,这些日志就是最好的调参参考书。有时候一个问题卡了很久,回头翻翻上次的日志,一眼就找到差异点了。