这两年AI圈最不缺的就是各种热词,但我留意到一个挺有意思的搜索趋势——《build a reasoning model from scratch》、AI Engineering from scratch。越来越多的人不满足于调API、套框架,而是想亲手把模型从零搭一遍。甚至有朋友跑来问我:现在开源模型一堆,为什么要自己从零折腾?
我入这行快十年,从传统机器学习一路做到大模型训练和推理工程,我的答案很明确:从零构建一次AI工程,不是为了证明自己能造轮子,而是要把黑盒打开。数据怎么清洗、词表怎么切、模型为什么梯度爆掉、loss为什么震荡、推理时为什么总是复读——只有亲手踩过这些坑,你才真正有底气说一句"我懂AI工程"。这篇文章我把自己做"从零AI工程"的完整思路、实操流程和踩坑记录整理出来,适合想深入理解Transformer训练机制、准备做推理模型微调/从零预训练、或者单纯想搞懂AI背后工程细节的读者,没有数学基础也能读懂大部分内容。
1. 整体设计:从零开始到底在"造"什么?
1.1 从零构建不等于重复造轮子
先说个容易劝退的问题:自己从零干,究竟图什么?现在HuggingFace上随便一个开源模型都比我本地能训练出来的强,对吧?
从实用主义角度,确实如此。但从学习和技术掌控角度,我建议你把"从零构建"拆成两个层次来看:
- 模型层从零:从随机初始化参数开始,自己设计词表、自己写Transformer、自己跑预训练/后训练。这条路的上限极低、成本极高,训练一百小时可能只达到GPT-2入门水平。但它的价值在于把"神经网络如何学习语言"这件事彻底讲明白了。
- 工程管线从零:不依赖一键脚本,而是自己搭数据管道、训练框架、评估闭环、推理服务。这条路的实用价值非常高,哪怕最终训练的是开源模型底座,这套工程能力也是通用资产。
我做这个项目时按下述思路规划:小模型完全从零预训练,用来验证原理;同时把从零搭建的训练/评估/推理管线,直接复用到更大型号上做微调。这样既避免了"纯造轮子被性价比劝退",又拿到了完整的工程掌控力。
1.2 推理能力从哪来:思维链不是玄学
搜索热词里频繁出现 "build a reasoning model from scratch",为什么大家突然对"推理模型"这么感兴趣?因为从2024年开始,行业发现一个规律:模型的"聪明程度"不只取决于模型大小,还取决于它思考的时间。
传统模型像脱口秀演员,看到问题直接给答案,快但容易出错。而推理模型在输出答案之前,会先内部生成一段"思考过程"——把问题拆解、试错、验证,最后才给出结论。这套机制在论文里的名字是思维链(Chain of Thought),工程上实现它有两种主流方式:
- 数据方式:在训练数据里加入大量包含"分步推理过程"的样本,让模型模仿这种思考方式。磁力数据不需要多豪华,但格式要统一。
- RL方式:让模型自己生成多条推理路径,用结果是否正确的奖励信号强化/惩罚路径。这是DeepSeek-R1类模型的核心手段,训练成本很高,但泛化能力更强。
我在从零项目里用的就是第一种,因为它实现简单,且能直观看到"模型学会推理"这件事发生在哪个阶段。
1.3 方案选型:微调底座还是完全预训练
很多人在动手前卡在了方案选型上。我直接给结论:
| 维度 | 从零预训练小模型(本项目) | 微调开源底座(生产力路线) |
|---|---|---|
| 硬件需求 | 单张消费级显卡(8-16G显存)即可 | 显存越大越好,最好多卡 |
| 时间成本 | 几十小时到几天 | 几小时到几天 |
| 可控性 | 完全可控,每个细节都清楚 | 受制于底座能力边界 |
| 学习价值 | 极高,适合理解原理 | 中等,偏重工程经验 |
| 生产可用性 | 低 | 高 |
我这种"曲线救国"打法,对你个人的成长收益更大:先在500M甚至70M参数的微型模型上把整个流程跑通,再带着"为什么不这样设计"的直觉去微调大模型,遇到问题时的排查速度完全不一样。
2. 数据准备:先喂对,再喂饱
2.1 数据质量优先于数据规模
把AI模型比作一个人的话,预训练阶段是塑造常识,后训练阶段是塑造性格。而这个"常识"和"性格"很大程度由训练数据决定。
第一次做从零项目时,我的直觉是"多找点数据",结果甩进去好几个G的网页文本,模型训练出来语义不清、语法混乱,说三句就开始重复。事后排查才发现:数据里有大量重复段落、乱码、纯数字表格。后面我强制给自己上了一条规则:训练数据先小后大,先能过质量关,再谈数量关。
清洗数据这里有几个动作,是真正实操中在产出:
- 按文档频率做全局去重,删除大量重复出现的段落(比如网络上的热门段子、SEO垃圾站内容)。
- 清洗超长无空格字符串、表情符,过滤文档中超过80%是数字或标点的行。
- 如果是中文数据,要单独处理:统一简繁体、用规则识别并删除源码片段(一堆代码会让模型学到奇怪的循环结构)。
- 最后做一遍困惑度筛选,用一个小型参考模型打分,把"不像人类写的"数据剔除。
这一轮完成之后,如果你的训练数据量只剩原来一半,不用心疼,这很正常。喂模型不需要"实惠量大",需要"干净营养"。
2.2 构造推理样本:思维链怎么写进训练集
从零构建推理模型时,样本质量比样本数量更容易决定成败。我用的最小可行格式长这样:
问题:一个笼子里有鸡和兔,共35个头、94只脚。问鸡和兔各有多少只? 思考过程: 假设35只全是鸡,那么脚数应该是 35×2=70 只。 实际脚数94只,比假设多了 94-70=24 只。 每把一只鸡换成一只兔,头数不变,脚数增加2只。 需要换 24÷2=12 只,即兔有12只,鸡有 35-12=23 只。 答案:鸡23只,兔12只。这种数据教给模型的核心不是数学,而是一种"先分解再计算"的路径。我建议你至少准备2000条这种带思维链的样本,内容不要局限于数学,还可以写代码解释、逻辑判断题、常识推理题,领域越杂越好。
把思维链写进样本时注意两点:
- 思考过程不要"跳步"。一旦跳步,模型就学会用直觉拼凑答案,而不是走完整推理链路。
- 答案必须是确定性的,一个样本只对应一个标准答案。如果样本答案模棱两可,模型会学着"两头堵",这是灾难。
2.3 tokenizer:把文本切成模型能消化的单位
很多人做从零模型最容易忽略的一步:tokenizer。
模型看不懂字,只看得懂数字。tokenizer就是把文本切成一串数字ID,本质是"字/词/子词 → 索引"的映射字典。最常见的算法是BPE(字节对编码),思路很朴素:
从单字节开始,统计文本中最常出现的相邻字符对,把它们合并成一个新token,反复迭代。比如"机器学习"会被拆成["机", "器", "学", "习"],合并之后变成["机器", "学习"],再合并可能变成["机器学习"],词汇表就这样一步步长出来。
实操时不必自己写BPE,直接复用一个现成实现就行,但有两个参数需要自己定:
- 词表大小(vocab_size):我习惯设在8000~16000之间。模型越小,词表越不要贪大,否则embedding矩阵占大部分参数量,纯属浪费。
- 特殊token:
<|pad|>(填充)、<|endoftext|>(文档分隔)、<|human|>和<|assistant|>(后训练用)都要在词表里预留位置,并且永不出现在文本切分结果中。
Tokenizer的训练数据要和模型训练数据保持一致,它只负责"分词"不出"词义",词义由后面的神经网络负责学习。
3. 模型与训练实操:核心环节逐段拆
3.1 选一个 decoder-only 的迷你 backbone
模型架构选择上,我的建议是最少花哨的decoder-only Transformer,这已经是行业验证过的标准答案。为什么要选它?因为:
- 它是因果语言模型,天然适配"给定上文,预测下一个token"的训练目标。
- 推理模型本质上还是语言模型,只是数据里多了思维链。先训好"说话能力",再训"思考能力",这是最平滑的路径。
- 开源生态完善,相关库直接可用,排查问题时容易找到资料。
我的迷你版配置单大致是这样:
# config.py model_config = { "vocab_size": 12000, # 词表大小 "max_seq_len": 512, # 最大序列长度 "hidden_size": 384, # 隐藏层维度 "num_layers": 8, # 层数 "num_heads": 8, # 多头注意力头数 "dropout": 0.1, # 正则化 "activation": "gelu", # 激活函数 }这个配置的参数量大概在70M~100M之间,单张家用显卡就能训练,跑一轮验证迭代也就几分钟,非常适合作为调试起点。如果你的显卡性能更好,可以再把hidden_size提到512、层数提到12,收益依然正向。
3.2 位置编码:模型知道词的先后顺序吗
Transformer本身不像RNN天然有顺序信息,所以必须给每个token"注入"位置信息。我重点说下旋转位置编码(RoPE),理由是:
- 它是Llama和Mistral都在用的方案,行业验证充分。
- 它对长序列外推更好——训练时序列512tokens,预测时可以外推到1024tokens,效果优于绝对位置编码。
- 实现不复杂,在attention计算前给Q和K矩阵做一次旋转变换就行。
用代码理解:
import torch import math def apply_rope(q, k, seq_len, head_dim, theta=10000.0): # 生成频率 inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2).float() / head_dim)) # 生成位置索引 pos = torch.arange(seq_len, dtype=torch.float32, device=q.device) freq = torch.einsum("i,j->ij", pos, inv_freq) # [seq_len, head_dim/2] # 构造旋转角度:奇偶对用同一角度 angles = torch.cat([freq, freq], dim=-1) # [seq_len, head_dim] cos = angles.cos() sin = angles.sin() # 对q/k做旋转 q1 = torch.stack([q[..., ::2], q[..., 1::2]], dim=-1).reshape(q.shape) # 简化写法:用复平面旋转实现 q_complex = torch.view_as_complex(q.reshape(*q.shape[:-1], -1, 2)) freq_complex = torch.view_as_complex(freq.unsqueeze(0)) q_rot = torch.view_as_real(q_complex * freq_complex).reshape(q.shape) k_rot = torch.view_as_real(torch.view_as_complex(k.reshape(*k.shape[:-1], -1, 2)) * freq_complex).reshape(k.shape) return q_rot, k_rot这个代码是手写理解版,实际生产中可以直接用库里的现成实现。重点是理解RoPE的本质:它让"位置差异"变成一个可微的旋转角度,使得相对位置信息被直接编码进注意力分数里。
3.3 训练循环与超参数:一份可以直接抄的配置
模型和数据就位之后,真正费心的是训练超参数。我试过几十组配置,最后稳定产出的组合是:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | AdamW | 带权重衰减的Adam,配合loss收敛更稳 |
| beta1 / beta2 | 0.9 / 0.95 | Adam动量参数,PyTorch默认即可微调 |
| 学习率 | 峰值3e-4(预训练)/ 1e-5(SFT微调) | 预训练可略高,微调必须低 |
| 调度器 | warmup 10% + cosine退火 | 前10%步数梯度更新用于稳定启动 |
| batch_size | 32 ~ 64 | 小模型batch可以略微激进 |
| 梯度累积 | 根据显存调整 | 等价增大batch_size |
| 权重衰减 | 0.1 | 对embedding和norm层一般不加 |
| 梯度裁剪 | 1.0 | 防止梯度范数爆炸 |
| 混合精度 | fp16 + GradScaler | 显存直接减半,速度翻倍 |
训练循环骨架我用PyTorch写的话长这样:
for step, batch in enumerate(train_loader): input_ids = batch["input_ids"].to(device) labels = batch["labels"].to(device) with torch.autocast(device_type="cuda", dtype=torch.float16): logits = model(input_ids, labels=labels) loss = logits.loss optimizer.zero_grad() scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() lr_scheduler.step() if step % 100 == 0: writer.add_scalar("loss", loss.item(), global_step=step)别小看这几个细节:
- warmup不能省。刚开始训练时模型的参数分布和真实分布差距极大,学习率给大了容易直接把loss打磨到坏点,回不来。
- 梯度裁剪务必加。Transformer在小batch下很容易出现梯度尖峰,裁剪1.0就能稳定住。
- embedding层不加权重衰减,因为embedding就是查表,正则化反而会让它失去表达能力。
3.4 日志、评测与断点续训:工程化的底气
训练日志一定要记录完整,这不是为了好看,是为了事后排查。我每次训练都记录四样东西:
- step步数、loss、lr、grad_norm,这四个是最基础的状态量。
- 每个epoch的验证集loss,用于判断是否过拟合。
- 每500步做一次free text generation(输入"法国的首都是",看它能不能接出"巴黎"),这个能直观感受模型进度。
- 每1000步存一次checkpoint,包括优化器状态。
说到checkpoint,两个容易踩坑的地方提醒一下:
- 只存模型权重可能让你之前的学习率进度白费。续训时优化器状态必须一起存,否则Adam的二阶动量丢失,学习率调度重头开始,会有很长一段"假进度"。
- 模型命名里一定要带step数。我遇到过训练到8000步时发现6000步的checkpoint最好,如果没有step标记,那个版本早被覆盖了。
老生常谈但重要:保存checkpoint的磁盘要留足空间,我见过因为磁盘满了、模型直接崩溃的例子,训练几千步白费的那种痛,体验一次就能记一辈子。
4. 评估与踩坑实录:把"会聊天"变成"会推理"
4.1 loss居高不下?先排查这五件事
训练中loss迟迟降不下去,不要急着调模型,先按下面顺序排查,我90%的问题都出在这些环节:
- dataloader喂进去的数据是不是对的。先用一个batch打印input_ids和解码后的文本,确认tokenize和decode没有错位。尤其是labels错位会导致模型根本无法学习。
- loss是不是真的在降。很多人看loss是平滑后的图,被波动误导。我习惯同时保存原始loss和滑动平均loss,对比着看趋势。
- 学习率是否过低或过高。峰值3e-4训不动,就试1e-3;如果loss直接炸掉出现NaN,那就是太高了。
- batch_size小,梯度噪声大。梯度累积到64等效batch,loss曲线会立刻平滑很多。
- 模型够大吗。极小模型(参数量低于20M)确实可能学不会人类语言,别对它要求太高。这也是我一直强调"先用大一点模型做冒烟测试,再把模型缩小观察"的原因。
如果这些都排查完还在3.x附件徘徊,那大概率是数据问题。去检查你的数据集里有没有大量新闻标题、网址列表这种"无信息量文本",模型学到的全是噪音,loss自然下不去。
4.2 复读机问题:解码策略比想象中重要
训练loss只有4.0,但生成文本时模型疯狂复读,这是所有从零项目都会遇到的事。我第一次遇到时以为是模型练坏了,结果后来发现训练没毛病,是解码策略没调好。
模型的推理过程有两个阶段:训练阶段"预测下一个token的概率分布";推理阶段"从这个概率分布里挑一个token"。如果每次都选概率最高的那个,文本会很机械甚至卡在循环里。我采用的解码策略组合是:
- temperature = 0.7:控制概率分布的尖锐程度,稍微留一点随机性。
- top_p = 0.9:只从累计概率前90%的token里采样,避免小概率词拉低质量。
- repeat_penalty = 1.1:对已经出现过的token做一点惩罚,打断复读循环。
- max_new_tokens:限制生成长度,防止无限生成。
在我实测中,temperature=0.7+top_p=0.9这对组合能同时保证生成质量和多样性。如果你发现生成的内容逻辑明显变差,优先把temperature调回0.5检查。
4.3 显存不够怎么办:三板斧降内存
从零训练最让人崩溃的就是显存溢出(CUDA OOM)。我的经验是三步走:
- 第一板斧:梯度检查点。把模型前向传播时每层的中间激活值丢掉,反向传播时再重新计算。这是用时间换显存的最优雅方案,开启后显存占用能降30%-50%。
- 第二板斧:混合精度(fp16/bf16)。训练时用16位代替32位表示梯度,显存减半,现代显卡几乎零损耗。
- 第三板斧:减小序列长度。很多人512序列塞不下就咬死不放,反过来想:你的任务真的需要512个token吗?指令微调128甚至64都能干活,预训练阶段先把数据切成适配目标长度的文本就行。
如果还不行,就用梯度累积,效果等同于调小batch_size。切记:batch_size不能调到1以下——梯度噪声大到模型根本学不会。
4.4 怎么判断模型真的学会了推理
训练结束后我最喜欢做的一件事是:拿一套"模型没见过的问题"测试它。注意是同类题型但数字和题干细节要换,不然可能测的是记忆不是泛化。
怎么测才更有说服力?我按难度分了三档:
- 基础档:常识问答,比如"太阳从哪边升起"。模型能答对,说明预训练学到了基本知识。
- 推理档:上文中那种鸡兔同笼题,换成完全不同的数字。模型还能按"假设—比较—调整"路径得出正确结果,说明思维链不是死的,而是学会了一种通用方法。
- 提防档:故意把题干改成有歧义或信息缺失的问题。模型要是能主动说"条件不足,无法确定",而不是硬编一个答案,那才说明它对"推理"本身有了一点点元认知。
个人经验是,很多从零项目在前两档都能过关,第三档全军覆没。能把"条件不足"这种拒绝回答学会的模型,数据里一定要有大量的类似样本,比例至少10%以上,否则模型学不会"我不知道"。
这次从零做AI工程,我最深的体会是:AI工程不是一个单向的"训练-部署"流水线,而是一个"数据—模型—评估—再补数据"的闭环。以前用开源模型时总觉得模型是一个"完成品",自己从零走了一遭才发现,推理能力不是凭空冒出来的,是数据里一点一点编织进去的。在做完这个项目之后,再去看各种官方技术报告里写的数据配置和训练细节,感受完全不一样——那些报告里轻描淡写的clean数据、mix比例、训练稳定措施,每一个背后都是真金白银的显卡时和无数次的失败调试。
最后分享一个实操小技巧:我在训练到中期时,会把一段固定的评测集"冻结"下来,每次参数变动后必测一遍。表面上看是给自己找麻烦,实际上这个固定评测集就是模型的"回归测试"——一旦哪次改动让某个能力意外退化,我立刻就能定位到是哪一步引起的。这个习惯我从训练模型一直延续到做模型微调,几乎不踩"改进一个能力废掉另一个能力"的坑。