1. 从零开始AI工程到底在造什么
我经常被人问一个很实际的问题:现在大模型API都快成共识了,直接调接口不香吗?为什么要自己从零开始写数据管线、模型结构和训练逻辑?我的回答一般很直接:从零开始做AI工程,不是为了证明自己比框架作者聪明,而是为了把黑盒变成白盒。你只有亲手把tokenizer、Embedding、Attention、训练循环一件件拼起来,才能看懂别人开源模型为什么这么设计,才能在loss不降、显存爆炸、生成结果复读机的时候,快速定位到具体环节,而不是像个无头苍蝇一样到处改参数。
这个标题的核心路径,通俗讲就是走一遍“AI工程的完整链路”:准备数据、切词编码、设计模型结构、写训练循环、调损失函数、做推理生成,每一步都不依赖别人封装好的大模型框架。它适合谁?一类是想把技术底子打扎实的学生和转行者,另一类是业务里需要定制模型、却被黑盒折磨的工程师。我见过太多人用HuggingFace几行代码跑通demo就觉得自己入门了,结果一到部署、微调、量化就露馅——因为他对底下的计算图、张量形状和训练行为完全没概念。
我建议第一步把范围收窄到语言模型,原因很朴素:文本数据好找,架构足够公开,训练过程有肉眼可见的反馈信号。就算从字符级模型开始,费用和显存要求都极低,能让你把整套链路跑通好几遍。等你把语言模型的这一套机制吃透,往图像、语音甚至多模态迁移,思路其实是同一个套路:数据入口、特征编码、网络主干、目标函数、推理解码,五个环节分别换皮而已。
还有一个容易被误解的点:从零开始不意味着连PyTorch都禁用。我的做法是“模块要能手写解释,工程上该用框架就用框架”。这就像做菜不需要从种小麦开始,但你得知道面粉负责筋道、酵母负责蓬松,而不是只按菜谱倒材料。下面几节我会把整条路径拆开,把我踩过的坑和现在仍在用的办法都摆出来,你可以直接照做,再按自己场景微调。
2. 核心组件逐个拆开看
2.1 数据入口:tokenizer决定天花板
很多人一上来就写Transformer,却忽略了一个事实:模型手里拿到的并不是文本,而是一串整数ID。tokenizer就是那个把文本变成ID的翻译器,它的设计水平直接决定模型学习的效率。如果你项目刚起步,最简单的方案是字符级tokenizer——直接把每个字符映射成整数,英文26个字母加标点符号,词表可能不到一百个。优点是好调试、不会遇到未登录词,缺点是序列会特别长,训练效率低,模型对词根、拼写规则的学习也很吃力。
我在从零开始做语言模型时,第一版用的就是字符级,跑通之后才换成子词切分。子词里最经典的是BPE,它的逻辑可以理解为“从字母开始,不断把高频相邻片段合并成新词元”。今天各大数据集预训练用的tokenizer基本都是这类思路,词表大小通常在3万到10万之间。实现BPE的词表构建不难,难的是处理训练集里没出现过但推理时冒出来的词——这时候就需要byte fallback机制,把未登录词拆成字节级片段。
做tokenizer最容易犯的错是直接在整篇文本上做统计,没有考虑切分后的序列长度分布。比如你训练数据平均每句话200个token,结果某个业务文档一句话2000个token,模型推理时就会被截断。所以我建议在构建tokenizer时汇报几个统计量:平均序列长度、P95长度、最长样本长度,然后据此决定上下文窗口和填充策略。要把tokenizer想成模型的天花板,词表造得不好,后面结构再先进也白搭。
2.2 嵌入层与位置编码:让模型有语言和坐标的感知
Tokenizer给我们的是一串整数ID,这些ID本身没有语义。嵌入层的作用就是查表,把每个ID映射成一个固定维度的向量,比如768维。模型训练的过程很大程度就是在调这张嵌入表。这个步骤本身没什么数学难度,但有一个细节值得注意:很多现代模型把输入嵌入和输出映射层共享权重。这样做的好处是显著减少参数量,同时有人认为能让“词表向量空间”更稳定。我在小模型上做过对比,共享权重后的收敛速度没有明显劣化,但显存确实省了不少,建议直接从共享方案起步。
再聊位置编码,这是新手最容易忽略的一层。Transformer本身是置换不变的,也就是说“我爱你”和“你爱我”如果不加位置信息,在模型眼里完全一样。早期Transformer用的是三角函数式的绝对位置编码,公式固定,不参与训练。后来的GPT用可学习位置编码,每个位置一个独立的嵌入向量,训练时跟着模型一起更新。再后来的RoPE(旋转位置编码)就更讲究,它把位置信息融进Attention的旋转矩阵里,好处是外推到更长序列时相对更稳。
我实际测试过:在短序列任务上,三者的差距不会特别大;但一旦序列长度超出训练时见过的范围,RoPE和ALiBi这类相对位置编码确实更有优势。所以我现在的建议是,小模型实验直接用可学习位置编码,省事直观;如果你打算把模型拿去处理更长的上下文,从一开始就上RoPE,不要后面再改结构,因为改位置编码往往需要重新训练。
2.3 Attention机制:整个架构的心脏
Attention是Transformer的核心,也是我建议你亲手写一遍的重点。用一句话解释:它让序列中每个位置都能有选择地“看”其他位置,然后汇总信息。具体实现分为三个矩阵:Q(Query)、K(Key)、V(Value)。可以把它理解成图书馆检索系统,Q是你手里的问题,K是每本书的标签,V是书的内容。系统把Q和每个K做相似度打分,归一化成权重,再按权重把V混合起来,得到检索结果。
实际代码里,这一过程的张量形状变化非常容易搞错。输入形状一般是(batch, seq_len, embed_dim),经过线性映射拆成多头后变成(batch, num_heads, seq_len, head_dim)。然后Q与K做点积,算出注意力分数。这里有一个细节:点积之前要把维度做缩放,除以根号下head_dim。为什么?因为head_dim越大,点积分数方差越大,softmax之后会趋于两极分化,梯度容易消失。这个除以根号的细节,很多新手以为只是经验法则,其实是数学上可以推导的稳定性要求。
接着把注意力分数mask掉不需要看的位置。对因果语言模型来说,每个位置只能看自己和前面的token,所以注意力矩阵的上三角要填入负无穷。这里又有一个容易踩的坑:不是软mask置零,而是要设为对应无穷大,这样softmax后权重才是0;如果直接用零代替,等于把过去位置的信息也算进去了,训练可能不会崩,但模型就偷看了未来,那还学个什么?
多头Attention就是把多个这样的注意力头并行跑,每个头有独立的QKV映射,让模型能同时关注不同模式的关系——有的头负责语法搭配,有的头负责指代消解。最后把每个头的输出拼接回去,再过一层线性映射就行。
2.4 前馈网络与归一化:模型里的工作台
Attention做完信息交换,还得有个地方做“思考”,这个位置就是前馈网络。它通常是两层线性映射加一个激活函数,中间层的维度一般是嵌入维度的四倍左右。GPT系列用GELU激活,它在ReLU基础上给负值区域保留了很小梯度,训练更平滑。前馈网络的计算逻辑很简单,但占了Transformer大约三分之二的参数,所以模型的“知识记忆”很大程度是存放在这里的。
再有一个组件容易被忽略:LayerNorm。它的作用是把每个样本的特征向量归一化到均值0方差1,再乘以可学习权重恢复表达力。这一步对深层网络极其重要,能显著缓解梯度消失和训练不稳定。我见过有人贪图快,省掉LayerNorm,结果模型在高学习率下loss直接起飞。这不是说不能省,但必须搭配更保守的初始化策略和更低的梯度更新幅度——麻烦程度远大于保留归一化。
残差连接同样关键。它让信息绕过一个个子层直接向后传递,梯度也能跳过子层回流,深层网络才不会退化。实现残差时注意一个顺序问题:不同代码库有两种范式,先归一化再进子层(Pre-Norm),或者先过子层再归一化(Post-Norm)。GPT风格用的是Pre-Norm,它训练起来更稳定,即使网络加到几十层也不容易崩。这些细节单看都不复杂,但组合在一起才构成一个“能训练起来”的模型。
3. 从零搭一套训练流程:实操记录
3.1 环境与依赖:一张显卡跑通的最小配置
我先说结论:只要把模型参数控制在1000万到3000万这个范围,一张8GB显存的显卡就能痛快训练。我最早做实验用的是四年前的老GPU,8GB显存,照样把一个小型GPT模型从零训到了能生成通顺句子的程度。环境上我推荐直接装PyTorch稳定版,搭配HuggingFace的Datasets库来读数据,但核心训练逻辑全部自己写。
还有一个小建议:先用CPU模式跑一个过拟合小样本测试。把训练数据截成几十条,模型层数设到最小,丢进设备里看能不能跑通、loss能不能降到很低。这一步不花多少时间,但能过滤掉绝大多数结构和维度错误。我习惯在网络构建后、正式训练前打印全部模块的参数形状和数量,再跑一次shape check,把所有张量经过各层后的形状变化列一遍。很多推理时的隐性bug,比如维度对不上、广播错位,在这一步就能暴露。
3.2 数据加载与批处理:滑动窗口别切破语义
语言模型训练数据本质上是一条超长的文本流,不能像图像分类那样直接按样本切分,因为文本有连续性。睁大眼睛:不能简单地把整篇文章拆成不重叠的定长片段,模型会对上下文断裂非常敏感。我在第一次实验时贪省事,用固定长度直接硬切,结果生成出来的句子经常出现语义断裂。改成滑动窗口式切分后,训练数据的样本数量上去了,模型学到的前后文一致性也明显更好。
具体的滑动窗口做法不复杂:把数据集拼成一个大token序列,用一个固定窗口长度seq_len(比如256),每隔step个token(比如128)切一个样本。这样相邻样本之间有50%的重叠,避免了切在句子中间导致的信息浪费。数据加载到训练阶段时,每个batch注意做好padding到统一长度,并记录attention mask;如果用了自带的pad token,就别让它参与loss计算。
我还踩过一个数据层面的坑:不同来源的文本质量参差不齐,直接混入训练会把模型带偏。比如一些带大量HTML标签的网页文本,模型会学会生成奇怪的尖括号。所以数据清洗这步别省,简单做法是过滤掉少于50字符的行、去掉重复段落,再用正则清理各种无意义标记。数据质量永远比模型结构更能决定最终效果,这句话在从零训练场景下基本是铁律。
3.3 损失函数与优化器:交叉熵和AdamW的使用细节
语言模型的损失函数基本就是交叉熵:让模型对每个位置的下一token预测概率尽量高。实现时要特别注意label的形状和偏移。常见做法是输入tokens为input_ids[:, :-1],目标为input_ids[:, 1:],也就是让第i个位置的输出预测第i+1个位置的真实token。用PyTorch的CrossEntropyLoss时,需要把预测logits和label形状对齐,一般reshape成(batch*seq_len, vocab_size)对(batch*seq_len)。
优化器方面,我优先推荐AdamW而不是传统Adam。它们唯一的差别在使用权重衰减的实现方式,AdamW把权重衰减从梯度更新中解耦出来,训练更稳,泛化也更好。使用AdamW有几个绕不开的参数:学习率、betas系数、epsilon。betas默认值是(0.9, 0.999),多数情况不用改;epsilon我习惯设为1e-8,避免出现除以零的数值问题。
还有一个容易被忽略的细节:要不要梯度裁剪?我的建议是加上。从零训练的模型很容易在某些batch出现大幅度权重更新,导致训练震荡。梯度裁剪的本质很简单——把梯度的范数限制在一个阈值内,超过就按比例缩放。常见阈值是1.0,我自己的实验里,这个操作能让大学习率下loss曲线平滑不少。
3.4 训练循环源码:关注结构而不是抄参数
下面给一段简化但能跑的训练核心代码。它剔除了分布式和混合精度的复杂度,只保留了语言模型训练的本质部分:
import torch import torch.nn.functional as F from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, scheduler, device, grad_clip=1.0): model.train() total_loss = 0.0 for step, batch in enumerate(dataloader): input_ids = batch["input_ids"].to(device) labels = batch["labels"].to(device) logits = model(input_ids) # (batch, seq_len, vocab_size) loss = F.cross_entropy( logits.view(-1, logits.size(-1)), labels.view(-1), ignore_index=-100 ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() scheduler.step() total_loss += loss.item() if step % 100 == 0: print(f"step {step}, loss {loss.item():.4f}") return total_loss / max(step, 1)这段代码看着简单,但里面有三个细节值得展开。第一,ignore_index=-100表示跳过不参与loss计算的位置,比如padding部分;你用pad_token_id对应的索引也是一样的效果,但用-100更安全,因为不会和真实词表ID冲突。第二,optimizer.step()之前一定先zero_grad(),否则梯度会在batch间累积,loss曲线会看起来异常震荡。第三,scheduler.step()的位置尽量和optimizer.step()同步,别把学习率更新的顺序搞乱。
训练过程中我会额外打印两类信息:一是当前学习率,二是每个batch的loss。很多人只看最终loss,却不知道学习率是否正确下降。如果warmup阶段loss瞬间下降很快,后面忽然起飞,多半是学习率峰值设太高,或者梯度裁剪没有生效。这套训练循环其实就几十行代码,但我敢说,把它弄懂吃透的人,才算真的入门了AI工程的训练环节。
3.5 超参数怎么选:先小后大,求稳再求猛
超参数选择上,我推荐一条朴素路线:先用一个极小模型,比如2层、128维嵌入,跑通流程;再逐步放大到4层、256维、512维,每个阶段只改一个变量。千万不要一开始就上6000万参数的大模型,那样一旦更新波动,你根本分不清是数据问题、结构问题还是学习率问题。
几个常用经验值供参考:学习率峰值在3e-4到1e-3之间对小型GPT模型比较合适,注意先线性warmup,通常占总训练步数的5%到10%,再按余弦或线性衰减。batch size尽量大,但受显存限制,实际可以先用batch size 32,再靠梯度累积模拟更大的batch。如果loss曲线出现持续震荡,优先把梯度裁剪阈值调低,或者降低学习率,而不是急着动模型结构。
还有一个容易被忽略的点:上下文长度和batch size之间是强耦合的。seq_len翻倍,显存占用也翻倍。我的做法是先用短上下文(比如128)调试训练流程,确认能收敛后,再用更长的256或512做正式训练。不要妄想一次性在训练和推理上都用超长上下文,那是在劝退自己。
4. 推理阶段:让模型真正开口说话
4.1 自回归生成:为什么是一次次蹦token
训练完成后的模型本质上是P(next_token | context)的概率分布。推理时它并不能一次吐出一整句话,而是一个token一个token地往后接:把已生成的token作为新输入,再预测下一个token,循环往复。这种生成方式叫自回归生成,也是GPT系模型的标配。
我刚开始玩模型时总以为“生成”是模型内部有个记忆库,直接检索一句话——不是的。它更像一个接龙游戏,每次只决定下一个词。所以模型生成质量高度依赖上下文内容,你给它几个字,它就顺着概率往下编。这里有个深刻的工程问题:每一步推理都要重新把整个上下文过一遍模型,当上下文很长时,计算量非常夸张,这也是为什么后面要引入KV Cache来加速。
4.2 解码策略:从贪心到采样的取舍
解码策略直接决定生成结果的气质。最朴素的策略是贪心解码:每一步都挑概率最高的token,优点是好理解、速度也快,缺点是容易陷入重复机械的句子。比如你让模型写一篇文章,它可能会不停重复“这是一个很好的问题”这句话。原因是在概率分布里,重复序列往往会有局部最优,贪心算法一旦掉进去就出不来了。
更常用的是随机采样:按概率分布随机抽取token。为了让采样不失控,又发展出几种修正策略。温度参数用于调整概率分布的平滑度,温度越低,分布越尖锐,输出越保守;温度越高,分布越平缓,输出越发散,但内容更丰富。Top-K采样限制只在概率最大的K个token里选,Top-P则按累积概率超过阈值的最小集合来选。我现在做通用对话生成时最喜欢的是Top-P设为0.9加上温度0.7的组合,而写代码这种需要确定性的任务则倾向低温度。
解码策略没有绝对的好坏,只有适配度。它不改变模型本身的权重,但能让同样一个模型输出风格完全不同的结果。你完全可以在不重新训练的情况下把它当成成本为零的“指令优化”。我建议多做几组对比实验,记录不同参数下的生成样本,慢慢就会形成自己的手感。
4.3 KV Cache与上下文取舍:一顿操作猛如虎,显存一看不够用
自回归生成每一步都重新计算整段上下文的Attention,这极其浪费。Attention里面有一项关键计算:对每个位置,都要拿它Q去和所有已出现位置K做点积。如果模型在生成第100个token时,理论上第1到第50个位置的K和V在上一步已经算过一遍,完全没必要重算。KV Cache就是这个思路,把历史位置的K和V缓存下来,每一步只计算新增位置的K和V,再拼接起来算注意力。实现得好,生成速度能提升好几倍。
我踩过一个常见的坑:缓存KV时没有考虑到多头结构,把各层的缓存维度搞混。建议在实现时就约定好缓存字典,key是形如(layer_id, "k")这种带层和类型的标识,value则保存形状为(batch, num_heads, cached_len, head_dim)的张量。调试时先小步验证,用缓存和不用缓存各跑一遍,对比最终生成的token序列是否完全一致;如果不一致,大概率是缓存拼接顺序出错了。
但要清醒:KV Cache并不能缩小显存占用,它其实是用显存换时间。上下文越长,缓存矩阵越大。所以生成时还要考虑是否需要保留很长上下文。如果只是短问答,缓存长度就很低;如果你要模型读完整本书再回答,那KV Cache会占据比你模型参数还大的显存。这时就该考虑换用更小的模型、低精度推理,或者把不需要的历史片段截断。
5. 从语言模型进阶到推理模型
5.1 推理模型和普通LLM有什么不同
近期很多人讨论“从零构建推理模型”。所谓推理模型,和普通语言模型的差别,本质上不是架构革命,而是训练目标和数据分布的变化。普通语言模型的目标是“预测下一段文本”,它拟合的是“一个人看见上文后会写什么”这个分布;而推理模型的追求是“在回答之前,先生成一段内部思考过程,再输出答案”,整个过程强调逻辑连贯、步骤可验证。
为什么这值得单独区分?因为仅仅靠预测下一token,模型可能学会语言流利度,但不会学会“先在草稿纸上演算再写答案”这种结构。推理模型一般会在训练数据中加入大量展示思考步骤的内容,也就是思维链(Chain-of-Thought)。训练时模型先看到一段推理过程,再看到最终答案,本质上还是在做下一token预测——但数据形式决定了它学到的行为模式。
5.2 从零构建推理模型的三个阶段
按我从零到一折腾下来的经验,有三个阶段是必经之路。第一阶段,准备推理轨迹数据。你可以自己标注一批解决数学题或逻辑题的步骤,也可以从已有数据集中筛选含推理过程的样本。第二阶段,在基座语言模型上做指令微调,让模型学会把思考内容和最终答案分清格式,常见做法是在思考过程前后加特殊标记,比如<think>和<answer>之类的分隔符。第三阶段,再用强化学习或拒绝采样来强化正确的思考路径,让模型在搜索不同推理方式时,自然倾向于选择最终答案正确的路径。
这里有个广为流传的误区:推理模型不是靠“设计一个推理算法”做出来的,而是靠“数据重新组织+训练策略”逼出来的。我见过不少人反复修改模型结构,想在Transformer里塞一个显式的推理模块,但各种实验都表明,在这类任务上,训练数据和目标函数的影响远大于结构微调。所以从零构建推理模型,我建议你把80%的精力放在数据构建和训练策略设计上。
5.3 学习路线与资料选型建议
如果你想认真走完这条路,我的推荐路线分三档。第一档,只看原理,能看懂注意力公式就够。这时可以看Transformer原始论文和几篇综述,配合动手跑一下HuggingFace上的开源小模型。第二档,跟着一本书或一套课程完整实现一个小型语言模型。市面上已经有不少专门讲从零构建语言模型的实践书籍,比如《Build a Large Language Model From Scratch》这类,它的内容结构和我上面讲的基本一致:数据→tokenizer→模型→训练→推理→微调。第三档,是把训练好的小模型进一步扩展到推理模型方向,这时需要补充阅读思维链、RLHF/RLVR相关论文,并且准备好一个足够干净的推理数据集。
我想强调一个意见:不要为了“从零”而从零。你的学习目标不是复刻一个GPT,而是拥有“会从零开始制造模型”的能力。所以参考现成项目没问题,照着别人的代码重写也没问题,但重写之后要能回答“为什么这里要缩放注意力分数”“为什么用梯度裁剪”“为什么选择这个损失函数”。只有完成这三问,这个标题对你才算真正落地。
6. 实战高频坑与排查经验
6.1 loss不降甚至飞升:排查的顺序比技巧重要
如果你训练几十步之后loss纹丝不动,最先检查的不是学习率,而是数据链路。用自己拼好的tokenizer切一段文本,手动打印tokens和对应文本,确认切分没有错位。我遇到过因为padding位置参与loss计算导致loss永远降不下去的案例——虽然没有崩,但模型学的东西当中有大量噪声。把ignore_index加上后,loss立刻正常下降。
第二步检查模型能不能先过拟合。取100条样本,把dropout调到0,学习率调大一点,跑上若干步。如果loss能降到很低,说明模型结构和优化器没问题;如果loss卡在某个值不上不下,那很大概率是训练目标写错了,或者标签偏移错了。第三步才是调学习率和优化器参数。这套顺序帮我在从零项目中排查掉至少一半的未知问题,建议你把它背下来。
6.2 显存爆掉:先减序列长度,别急着换显卡
显存不足是所有从零训练玩家必经的一关。这时候别急着换GPU,先按顺序尝试三个方案。第一,减小batch size;第二,减小序列长度;第三,打开混合精度训练,用FP16或BF16存储张量,显存占用能降低近一半。其中序列长度的影响远大于batch size,所以如果减到batch size 4还爆,那一定优先减序列长度。
如果显存还是不宽裕,可以用梯度累积模拟更大batch。这个技巧的核心是多次前向+反向,累积梯度,再统一更新参数。注意PyTorch默认每次backward()后梯度是累积的,所以不用额外的梯度累加操作,只是需要自己控制“每多少步做一次optimizer.step()和zero_grad()”。同时BatchNorm在各数据增强和丢失等都适用,但在Transformer结构里没有BatchNorm,所以不必纠结。
6.3 生成结果复读机与过拟合
训练loss很低,但生成结果老是重复同几个词,这通常是解码策略问题而不是模型问题。先用一个低温度的Top-P采样做测试,比如temperature 0.6、top_p 0.9,如果重复现象消失,说明模型学的分布是没问题的,只是采样参数让概率分布里的小尾巴被放大了。
更麻烦的是过拟合:模型把训练集内容背了下来,生成的时候直接背诵原文。解决思路有几种:增加训练数据多样性、增大dropout、降低模型容量、增加权重衰减。一个小技巧是观察训练loss和验证loss的差距,如果验证loss开始走高而训练loss还在下降,基本就是过拟合了。此时不要急着动数据,先把dropout从0.1提到0.2甚至0.3,往往立刻见效。
6.4 训练可复现性和模型保存
模型训练过程会有大量随机性:参数初始化、数据shuffle顺序、GPU底层算子运算。为了能快速复现一个实验结果,我建议在代码开头固定三个地方:torch.manual_seed、Python标准库的random.seed、以及numpy.random.seed。设置相同的随机种子后,同一个环境跑两遍,loss曲线基本不会出现大偏差。
模型保存方面,我习惯每个epoch结束后保存一个检查点,而不是只在训练结束保存。文件名带上step和loss,比如model_step_5000_loss_1.23.pt。这样一旦后期训练出问题,你可以快速回到之前的稳定状态,而不用从头再训。保存内容至少包含模型权重、优化器状态、学习率调度器状态和当前step,四样缺一不可,不然断点续训时优化器的动量就丢了,等效于换了优化器,训练很容易震荡。
最后再分享一点个人体会。从零开始做AI工程,最大的收获不是做出一个能跑的小模型,而是获得了一种“颗粒度足够细”的调试直觉。后来我在用别人现成框架时,看到一句话、一个参数,心里会条件反射地想一想它到底改变了哪些张量的行为,而不是盲目照抄默认值。如果你也正在这条路上,我建议你给自己定一个小目标:用一个月时间,从采集数据到生成第一个通顺句子,全程手写核心代码,不要跳过任何你觉得“麻烦”的步骤。那些麻烦的部分,才是真正让你脱胎换骨的部分。