这两年,AI engineering领域最热的方向绕不开一个词:from scratch。从《Build a Large Language Model (From Scratch)》被技术社区反复推荐,到越来越多人尝试build a reasoning model from scratch,复现类似R1那样的长思维链推理,大家的共识越来越清楚——天天调API、套框架,换来的是对模型内部机制越来越薄的直觉。
这篇文章是我用三个月时间,从零构建自己的小语言模型和推理模型的完整记录。不用Transformers库,不加载任何预训练权重,从tokenizer到训练循环全部手写,最后在千万级参数的小模型上,跑通了一条从语言建模、到CoT微调、再到强化学习推理训练的完整链路。如果你已经会写PyTorch、用过HuggingFace,却总觉得大模型是个黑盒;或者你对"推理模型到底是怎么被训练出来的"这件事充满好奇,那这份记录就是我踩完坑之后最想递给你的地图。
1. 我为什么坚持从零手写,而不是继续用框架调参
先说这段经历的起点。之前几年我一直做模型微调和部署,HuggingFace生态用得飞起,fine-tune、量化、推理加速都上手过,自认为对Transformer相当熟悉。直到有一天同事问我:这段attention的耗时为什么和序列长度呈二次方增长?能不能在不引入FlashAttention的情况下自己写一个融合版本?我愣了一下,发现自己连QKV投影的shape变化都要现翻文档。
那次之后我做了个决定:把手头项目放一放,花三个月时间,不开任何"现成模型",把一个大语言模型从空白搭起来。参考书我主要用《Build a Large Language Model (From Scratch)》(Sebastian Raschka著),配合几个开源课程的代码对照读。项目名就叫ai-engineering-from-scratch,很直白——就是要搞清楚每一步到底发生了什么。
1.1 一个关于注意力层的追问,暴露了我的底牌
我当时的真实水平是:能跑通训练脚本,能调超参,能部署服务,但模型内部每一步算的是什么,基本靠猜。transformers库把一切封装得太好了,embedding层、attention mask、KV cache、layer norm,全是一行调用的事。出了问题,我看错误日志的时间比看论文的时间还多。
"从scratch"这件事最直接的价值就是逼你把手伸进泥里。当你自己写causal mask、自己处理softmax的数值溢出、自己调试首次生成的一堆乱码时,你才会真正理解那些框架层背后的设计决策。build a logic model from scratch(我这里特指逻辑推理模型)的热潮里,很多人都以为难点在模型结构,真正上手才发现,推理模型的难点在采样循环、奖励设计和数据格式上——这些恰恰是框架藏得最深的部分。
1.2 我给自己划定的"从零"边界
"从零构建"如果不划边界,很容易变成某种偏执。我做之前先把范围写清楚了:
- 手写范围:BPE tokenizer、词嵌入、多头自注意力、前馈网络、LayerNorm、训练循环、采样器、GRPO更新逻辑。
- 不手写范围:autograd自动微分、CUDA kernel、底层矩阵乘法。AdamW优化器我单独手写了一遍,纯粹为了检验自己对梯度更新的理解。
- 目标模型:参数规模10M到60M之间,训练数据自己采集并清洗,最后在一套小的数学/逻辑多选题数据集上做CoT + SFT + GRPO。
这里要特别说清楚:我不是反框架派。生产环境该用vLLM用vLLM,该用HuggingFace pipeline就用pipeline。"从零"是教学工具,目的是把直觉补上。等你真正手写过一遍,再回去看框架的源码,你会发现自己第一次能读懂那些抽象层到底在帮你做什么。
2. 动手前必须拿下的三项底层能力,否则后面全是翻车现场
很多人一上来就照教程写Transformer,结果卡在第一个loss就是NaN,然后就开始怀疑人生。我踩过的顺序是:先补底层能力,再写模型。三项里缺任何一项,后面都会花双倍时间补课。
2.1 数学基础:矩阵形状、softmax数值稳定和梯度直觉
做这个项目,你不需要把《深度学习》里的公式全部推导一遍,但有三样东西必须"过手":
第一,每一层的shape变化。输入是(B, T, C),经过embedding变成(B, T, d_model),attention之后还是(B, T, d_model),但Q/K/V的维度怎么拆分、多头怎么reshape和permute,这些必须闭着眼睛写得出来。我写代码期间一半的bug都是shape写错。
第二,softmax的数值稳定。这个坑我踩得特别深:第一版cross entropy在fp16精度下直接溢出,loss变成inf。正确做法是softmax前先减去最大值:scores - scores.max(dim=-1, keepdim=True)。原理很简单,指数函数在负数区间不会溢出。
第三,梯度从loss反向流到每一层的shape直觉。你不需要手算每个偏导,但至少要能回答"这层的梯度为什么是那个shape"。
我建议正式动手前先干一件小事:用纯numpy写一个两层神经网络,手动实现一次反向传播,哪怕不看答案抄一遍也行。这个半天工程能帮你省掉后面两周的排错时间。
2.2 数据管线:清洗时间远超写模型结构的时间
这是我觉得整个项目里最反直觉的一点:写模型结构用了大概一周,清洗数据用了将近两周。很多人跟着教程搭完模型就开训,结果模型生成的全是碎片,第一反应是改结构、调超参,其实问题根源在数据。
我的数据管线长这样:原始收集了大约2.4GB英文文本加少量中文,经过重复行过滤、超短行过滤、语言检测、文档切分之后,剩下1.7GB左右。然后用其中大概5万个代表性句子训练BPE tokenizer,词表大小选8192。这里有个容易被忽视的教训:如果数据集里有一条新闻被重复了几百次,小模型会直接把这条新闻背下来,而不是学语言规律。另外,验证集一定要提前切好,否则训练集里泄漏了验证样本,你看到的loss全是虚的。
2.3 算力预算与实验纪律
我实际用的是一张RTX 4090 24GB,再加一些云GPU按需实例。刚开始千万不要规划什么130B大模型,老老实实从千万级参数起步。我给自己排了三组实验:
| 实验线 | 参数量 | 序列长度 | 每步batch | 预计时长 |
|---|---|---|---|---|
| baseline | 10M | 256 | 32 | 1~2小时 |
| 主力模型 | 33M | 512 | 32 | 8~12小时 |
| 压力测试 | 60M | 512 | 16 + 梯度检查点 | 1~2天 |
实验纪律也很重要:固定随机种子、每个配置一个独立文件夹、每100步记录一次loss、每个实验结束必须导出生成样例。这不是强迫症——三周之后,你根本回想不起哪个配置跑出了最好的loss,那时候你就知道日志和文件夹命名多值钱了。
3. 最小可用LLM的完整拆解:从BPE到能输出"像样句子"
这一节是整个项目最核心的部分。我按实际动手顺序拆开讲,每个模块都附上关键代码和当时的判断逻辑。
3.1 Tokenizer:为什么我坚持自己训练BPE
很多人觉得tokenizer是现成的东西,直接pip install tiktoken不就行了。但从scratch的角度看,加载GPT-2的tokenizer只是调用,不是理解。自己训练BPE会让你明白vocab_size、未登录词、special token到底意味着什么。
我用的BPE核心逻辑是这个,整体思路就是反复找最高频的相邻token对、合并、编码成新token:
import collections from typing import List def get_stats(ids: List[int]): counts = collections.defaultdict(int) for pair in zip(ids[:-1], ids[1:]): counts[pair] += 1 return counts def merge(ids: List[int], pair, new_idx: int): new_ids = [] i = 0 while i < len(ids): if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]: new_ids.append(new_idx) i += 2 else: new_ids.append(ids[i]) i += 1 return new_ids实际训练前需要先做预分词(把文本切成字母数字片段和标点),然后在预分词后的整数序列上反复执行上面两步。我保留了<|begin_of_reason|>和<|end_of_reason|>两个占位token,专门为后面的推理阶段用,这在后面会提到。
这里有个选型的坑:vocab过大的话,embedding层会吃掉小模型一半以上的参数。对小项目来说,8192到16000都是合适的区间。词表大小本身不需要卷。
3.2 自注意力与Transformer:从公式到可运行代码
接下来是重头戏。我强烈建议先写单头的自注意力,确认理解后再扩成多头。核心是这几件事:Q、K、V从哪里投影来、为什么要除以sqrt(d_k)、因果掩码怎么加。
import torch import torch.nn.functional as F def causal_self_attention(x, w_q, w_k, w_v, w_o): B, T, C = x.shape d = w_q.shape[1] # 每个head的维度 q = x @ w_q # (B, T, d) k = x @ w_k v = x @ w_v scores = q @ k.transpose(-1, -2) / (d ** 0.5) mask = torch.tril(torch.ones(T, T, device=x.device)).view(1, T, T) scores = scores.masked_fill(mask == 0, float("-inf")) weights = F.softmax(scores, dim=-1) out = weights @ v return out @ w_o注意除以sqrt(d_k)的原因:如果不做这个缩放,当d_k变大时attention分数的方差会跟着变大,softmax会过早饱和,梯度就消失了。这是transformer原论文里刻意设计的细节,自己写一次才会真正记住。
整体结构我选了GPT式的pre-norm:每个block先LayerNorm再进attention/FFN,残差连接包在外面。实测下来,pre-norm在小规模训练里比post-norm稳定得多,学习率稍微大一点也不太容易炸。位置编码用的是可学习参数,最大序列长度设了1024。
写到这里有个体会:你在框架里点一个model.generate()觉得云淡风轻,但自己写gen循环时,会第一次意识到每生成一个token都要重新跑一遍前面的上下文,KV cache的空间换时间意义就是这样暴露出来的。这个直觉在后面看推理框架源码时价值连城。
3.3 训练循环、损失曲线与"能不能说人话"的评判标准
模型结构写完,训练循环反而简单:cross entropy损失、AdamW优化器、warmup 500步 + cosine学习率衰减。我实际的训练曲线大体是这个节奏:
| 训练步数 | 验证loss | 生成效果 |
|---|---|---|
| 2000 | 6.9左右 | 输出的全是词碎片 |
| 10000 | 5.6左右 | 能拼出常见短词 |
| 30000 | 4.7~5.0 | 出现语法残缺的短句 |
| 50000 | 4.4~4.6 | 常见句式成形 |
第一次生成出来一堆乱码是正常的,这几乎是必经之路。评估模型别只盯着loss,准备一组固定prompt,每次训练到检查点时用同样的prompt采样几段,对比看有没有改善。千万不要每次换prompt,否则你没法判断哪个改动真正有效。
"能说人话"的标准在从零项目里要放低:它不需要达到GPT-4水平,只要明显学会语言的基本形状,比如空格、词频、标点位置开始合理,就可以进入下一阶段。
4. 从语言模型到推理模型:CoT数据、SFT与GRPO的实操记录
语言模型能生成句子,离"会推理"还有一座山。这部分是build a reasoning model from scratch的核心,也是我认为整个项目里信息密度最高的三个月。
4.1 推理模型到底把"推理"放在了哪里
观察推理模型的输出你会发现,它在给出答案之前会先写一大段内部推理,类似"先看条件A,再看条件B,所以..."。R1路线的基本配方可以概括为:长思维链CoT + 可验证的奖励 + 强化学习。
这里有个很关键的理解:预训练阶段模型学会的是"接续上文生成",而不是"推理"。推理能力很大程度是后训练阶段被奖励信号逼出来的。我做了对照实验:直接把CoT数据喂给模型做SFT,模型学会的是输出格式,它知道应该先写"思考"再写"答案",但在训练集范围外并不会主动展开多步推理。只有用GRPO这类RL方法,让模型在采样中尝试不同的中间步骤,并用答案正确性做奖励,它才会逐渐内化"多写几步思考能提高答对概率"这个因果。
所以我的结论是:推理从零复现的难点不在Transformer结构,而在RL训练系统本身。
4.2 在自己模型上复现CoT效果的三个步骤
第一步,构建小规模CoT数据集。我用的是数学选择题和简单逻辑题,格式统一为「问题、思考、答案」三段。思考部分控制在3~6步,总共约8000条,一半是手写模板生成的,另一半从公开数据集过滤得到。思考步骤不要写太长,太长模型在RL阶段会学到凑字数。
第二步,SFT教格式。训练3个epoch,学习率降到1e-4,context拉长到1024以容纳思考过程。这一步不追求推理变强,只追求一件事:模型稳定输出"思考"再"答案"的格式。评估指标用一个格式合规率:生成结果里有没有正确出现结尾的答案标记。合规率达到90%以上再进RL,否则后面奖励信号会被格式问题搅浑。
第三步,GRPO强化学习。GRPO的核心是对于同一个问题采样一组回答,组内归一化收益,再用归一化后的优势更新策略,同时用KL约束让它不要偏离SFT模型太远。示意代码如下:
def compute_advantages(rewards): # rewards: (group_size,) mean = rewards.mean() std = rewards.std() + 1e-4 return (rewards - mean) / std # 对每个问题采样k条轨迹,记录policy和ref的logprob # ref是冻结的SFT模型,policy是从SFT模型复制出来继续训练的 def grpo_loss(logprobs, ref_logprobs, rewards, kl_beta=0.02): norm_rewards = compute_advantages(rewards) ratio = torch.exp(logprobs - ref_logprobs) kl = torch.exp(ref_logprobs - logprobs) - (ref_logprobs - logprobs) - 1 policy_loss = -torch.mean(torch.clamp(ratio, 0.8, 1.2) * norm_rewards) return policy_loss + kl_beta * kl.mean()奖励设计我调了很多版,最后稳定到:答案正确+1.0;格式错误(没写思考段或没写答案标记)-0.5;超长截断-1.0。这里最想强调的一点是:不要给"思考过程质量"打主观分,一旦模型发现过程分可以被凑出来,它就会用各种方式hack,后面细说。
4.3 用GRPO做推理微调时我踩到的坑
这一小节全是真金白银的教训,每一条都花了我至少一整天。
坑一:reward hacking。模型很快就学会在思考段里反复重复题目文字,最后随便蒙一个答案。因为格式正确能拿到基础分,重复文字能凑够长度,模型成了"复读机"。对策就是我前面说的:格式相关只给罚分不给正分,唯一正分来源是答案正确。
坑二:奖励稀疏。开放题没法自动判分,模型采样半天也不知道自己对没对,学得很慢。我的解法是RL阶段只用选择题和简单计算题,答案可以程序化校验;开放题留在SFT阶段处理,不放进RL。
坑三:KL系数失控。kl_beta设太小,模型一两个小epoch后输出就开始退化、词频失衡。我最后把kl_beta固定在0.02到0.05之间,并且给ratio加了clip(0.8, 1.2),能显著减缓退化。
坑四:采样长度失控。RL早期模型为了凑满分疯狂输出垃圾,2000个token都不够。给生成设max_new_tokens=512,超长直接截断并按格式错误扣分。
走完这趟,我的体感是:推理从零构建的日常工作,80%在数据和奖励工程,20%才是模型和训练技巧。外界看那些复现项目总觉得难点在"模型有多聪明",实际全在工程细节里。
5. 关于算力、成本和"值不值得从零开始"的实在话
最后聊点计划之外的实在话。很多读者卡在第一步就放弃了,理由是"我没有那么多GPU"。这个心结值得打开。
5.1 没有A100也能练起来
我一直用本地RTX 4090,辅助用一些云GPU按需实例,配合Colab和Kaggle的免费配额做极小实验。核心诀窍是用bf16混合精度训练,一个30M参数模型在512序列长度下,显存占用能压到8GB以内。大致参考:
| 参数量 | 序列长度 | 精度 | 显存(含Adam状态) | 参考训练时长 |
|---|---|---|---|---|
| 10M | 512 | fp32 | 约3GB | 1~3小时 |
| 33M | 512 | bf16 | 约6GB | 8~14小时 |
| 60M | 1024 | bf16 + 梯度检查点 | 约10GB | 1~2天 |
工程上的小习惯很省事:数据加载器记得开num_workers和pin_memory;loss不要每步都写盘,100步写一次;检查点至少留两个,防止训练中途炸了直接回到解放前。别小看这些细节,一次训练跑十几个小时,因为没存检查点而从头再跑的感觉,这辈子不想体验第二次。
5.2 该停止较劲的时候就停止:从零构建的边界
诚实地说,我最后训练出来的模型,单纯从能力上讲没有任何生产价值,它跑不过任何开源小模型。但这正是我推荐每个想做AI engineering的人都走一遍的原因:它的价值是直觉。当你亲自调试过一次梯度爆炸、一次NaN loss、一次RL reward hacking之后,你再排查线上模型的问题,路径会清晰得多,因为你脑内有完整的因果模型,而不是一堆黑盒调用。
做完基础版之后还有很多扩展方向:手写RoPE旋转位置编码、RMSNorm、GQA分组查询注意力、KV cache、投机采样。每补一个模块,对推理框架的理解就深一层。但注意,从零构建是学习工具,不是生产方案;如果你的目标是做一个产品,请立刻回到框架上。
最后说点题外话。这个项目给我最大的回报,不是那个能生成"像样短句"的小模型,而是后来排查模型退化问题时,我几乎不需要翻文档就能猜出是数据环节出了问题,还是哪一层的数值溢出了。如果你也准备花三个月做这件事,我的建议只有一个:把每次实验的loss曲线和生成样例完整存下来,三个月后回看,你会感谢那个愿意从头搭起的自己。