“AI工程”这个词这两年算是被说烂了,但真正上手时你会发现,市面上绝大多数内容是教你调API、装框架,真正讲清楚一个模型从数据到推理全链路怎么搭起来的却很少。我最近把项目标题定为“ai-engineering-from-scratch”,核心就一句话:不用任何现成大模型,不靠黑盒推理接口,从零开始做一个可以训练、可以推理、可以评估的微型模型。这篇文章就是整个项目的复盘,包括数据、tokenizer、Transformer架构、训练策略,以及最后用强化学习思路让一个小模型具备基础推理能力的过程。项目规模控制在20M参数左右,单张消费级显卡就能跑通,适合想彻底搞懂大模型原理的工程师、刚转行做算法的朋友,以及不满足于“调包”的深度学习爱好者。
很多人问我:市面上已经有那么多开源模型了,直接拿来做微调不香吗?香,但理解不了原理。就像你天天开车,不一定知道发动机怎么点火、变速箱怎么换挡。真出了问题,只能停在路边等救援。“从零构建”就是那个把你按在发动机旁边,逼你拆开看一遍的过程。
1. 项目定位:为什么要做“从零构建”这件事
1.1 从调包到懂原理之间差什么
调API和做AI工程,看起来都是“跟模型打交道”,实际是完全不同的两个世界。调API时你关心的是提示词怎么组织、温度参数怎么设置,模型内部对你来说就是个黑盒子。而AI工程关心的是:数据分布对不对、tokenizer词表合不合理、模型参数量怎么分布、学习率曲线为什么是这个形状、梯度什么时候会爆炸。
从零构建最大的价值,是把整个链路的所有错误都亲手踩一遍。你会发现,模型不work的原因千奇百怪:数据里混了不可见字符、tokenizer出现了OOV、embedding初始化不合适、学习率太大导致loss直接飞到NaN、验证集和训练集分布没对齐……任何一环出错,模型就废了。这些坑,光看文档是看不出来的,只有跑起来、崩溃、修复、再崩溃,才会真正变成你自己的经验。
我见过不少同学,模型结构背得滚瓜烂熟,能默写Transformer公式,但一旦让他从一份txt文本训练出可用的生成模型,就完全不知道从哪里下手。原因很简单:知识是线性的,工程是网状的。从零构建的价值不是让你重复造轮子,而是让你拥有拆轮子的能力。
1.2 目标边界:20M参数能做什么
做这种项目,最怕的就是目标定得太离谱。我见过有人一上来就要复现GPT-3,1750亿参数,单卡跑不起来就抱怨硬件,最后项目烂尾。正确的做法是把目标压缩到可以“一个人、一张卡、几天内完成”的程度。
我这边的项目边界是这样定的:
- 数据集:TinyShakespeare,大约1MB的莎士比亚文本,切分后约100万token。公开可下载,稳定,句子结构清晰,非常适合用来观察语言模型的生成效果。
- 模型结构:标准decoder-only Transformer,6层、6个注意力头、d_model=384、FFN中间维度1536,整体约2000万参数。
- 训练阶段:先做自回归语言建模,让模型学会“接话”;再做一轮带奖励信号的微调,让模型在简单符号推理任务上有稳定表现。
- 硬件环境:单张RTX 3090 24GB就够。没有3090的话,用16GB显存也能跑,只是batch要小一点。
为什么选莎士比亚数据集?两个原因。第一,它是纯文本,没有复杂的结构化标签,词表相对封闭,适合跑通全流程。第二,它的语言有很强的文体特征,训练几个小时就能看到模型生成出像模像样的“莎翁腔”,成就感拉满。如果你手头有其他垂直领域文本,比如代码、法律文书、病历,也可以把后面这套流程原封不动搬过去。
关于“reasoning model”的热度,我想多说一句。最近大家都很关注OpenAI o1、DeepSeek R1这类推理模型,但它们的核心突破其实不在模型结构,而在训练策略——大规模强化学习让模型学会在推理时分配更多计算。所以这个项目第二部分,我会在一个很小的自回归模型上尝试加入强化学习信号,让模型完成类似“数字排序”这种简单推理任务。基座虽小,但原理一条不落。
2. 数据与Tokenizer:模型的第一道关口
2.1 数据清洗比想象中更关键
做AI工程的第一课就是:模型吃进去的是数据分布,你喂进去的是垃圾,后面所有努力都只是在给垃圾装修。数据清洗这件事,听起来没什么技术含量,但项目里80%的诡异问题,追溯到底都是数据问题。
TinyShakespeare原始文件本身算是干净的,但我还是走了完整的数据预处理流程:
- 读入文本后,统一把
\r\n替换为\n,消除Windows换行符差异。 - 过滤掉所有控制字符,只保留可打印字符和换行。
- 检查文本中是否有重复的段落。虽然莎士比亚文本不会有,但如果你换用自己的数据,这一步必须做——重复数据会导致模型过拟合,生成时无限复读。
- 按9:0.5:0.5的比例切成训练集、验证集、测试集。注意切分时要按文本顺序切,不要随机打乱后再切,否则句子前后文关系会断裂。
清洗完的纯文本还要转成模型能吃的东西。最朴素的做法是字符级编码,但直接喂给模型不是最优解,所以需要用分词器把文本切成更合理的token序列。这个东西太重要了,我单独说。
2.2 从零实现一个BPE分词器
字符级tokenizer的实现很简单:建一个字典映射每个字符到整数ID,然后把文本逐字符编码。但字符级tokenizer有致命问题——上下文窗口变得非常“短”。比如上下文长度是128个token,如果按字符切,这128个token只覆盖了大约128个英文字符,可能连一句完整的台词都覆盖不了,模型能学到的东西极其有限。
所以项目里我用了BPE(Byte Pair Encoding)分词器。BPE的思路很朴素:先从单个字符开始,然后统计相邻字符对的出现频率,把最高频的相邻对合并成一个新token,重复这个过程,直到词表达到预设大小。这样,“the”这种常见词会被合并成一个token,模型一眼就能看到整个单词,效率提升非常明显。
核心合并逻辑的代码其实很短,你自己实现一遍就懂了:
def get_pair_stats(ids): stats = {} for pair in zip(ids, ids[1:]): stats[pair] = stats.get(pair, 0) + 1 return stats def bpe_merge(ids, pair, new_idx): newids = [] i = 0 while i < len(ids): if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]: newids.append(new_idx) i += 2 else: newids.append(ids[i]) i += 1 return newids实际操作时,先在训练集上统计字符对频率,迭代执行merge操作,得到一份merge记录。这份merge记录就是tokenizer的“词表”,后续训练和推理都必须用同一份,绝不能换。
词表大小怎么选?在TinyShakespeare上,我试过512、1024、2048三个档位。512的词表偏小,很多词根词缀拆不开;2048词表在100万token的数据上有不少低频token几乎学不到;最终选了1024。经验法则:词表大小不要超过数据规模的0.1%~0.3%,否则大量token会因为频率太低沦为噪声。
这里必须提醒一个坑:tokenizer和模型是强绑定的。训练时用了A词表,推理时如果加载了B词表,轻则出现一堆UNK,重则生成内容彻底崩溃。我的做法是给每个tokenizer版本加一个hash值,并在训练配置里记录,保证任何一次实验都能回溯到正确的分词版本。
3. 模型架构搭建:从零手写Transformer
3.1 自注意力机制的工程实现
Transformer核心就是自注意力。理解它最直接的方式是,把它看成“让序列里的每个token都向其他token询问:我该关注谁?”。
具体来说,每个token会生成三个向量:Query(我找什么)、Key(我是什么)、Value(我有什么)。然后计算Query和所有Key的点积,得到相似度分数,经过softmax变成权重,最后用权重加权求和所有Value,得到这个token的新表示。
在代码里,缩放点积注意力就这几行:
def scaled_dot_product_attention(q, k, v, mask=None): d_k = q.size(-1) scores = q @ k.transpose(-2, -1) / math.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) weights = F.softmax(scores, dim=-1) return weights @ v, weights注意那个除以sqrt(d_k)。为什么除?因为当维度变大时,点积的数值会变得很大,softmax会迅速进入饱和区,梯度接近于零。这就好比一群人同时大声说话,你反而一个字都听不清。除以sqrt(d_k)相当于把音量压低到合适区间,让注意力权重有区分度。
另外,decoder模型必须加因果掩码。因为自回归模型只能看到当前token之前的token,不能“偷看”后面的答案。初始化时在注意力矩阵的上三角位置填负无穷,softmax后这些位置会变成0,模型就“看不见”未来了。
3.2 多头注意力、残差、归一化与FFN
多头注意力就是把注意力过程分成多个头并行做。每个头有不同的Q、K、V投影矩阵,相当于开会时分了几个小组,每个小组关注不同的维度:一个组盯着句子的主谓结构,另一个组盯着修辞关系,最后把各组的结论拼在一起。这种“各看各的,再汇总”的机制,让模型表达能力大幅提升。
一个标准的TransformerBlock包含四部分:多头注意力、残差连接、层归一化、前馈网络。代码骨架如下:
class Block(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model), ) self.ln1 = nn.LayerNorm(d_model) self.ln2 = nn.LayerNorm(d_model) def forward(self, x, mask=None): x = x + self.attn(self.ln1(x), self.ln1(x), self.ln1(x), attn_mask=mask)[0] x = x + self.ffn(self.ln2(x)) return x残差连接解决的是深层网络的梯度传播问题——让梯度可以“抄近路”直接流回浅层;层归一化解决的是每层输出分布漂移的问题。这两个设计是训练深层Transformer的基石,缺一个,二十层以上的网络基本训不动。在小模型上,它们仍然重要,但作用没大模型那么“救命”。
3.3 参数量手动计算
“20M参数”是怎么来的?我带你手算一遍。假设词表大小1024,d_model=384:
- Token embedding矩阵:1024×384 = 393,216,约0.39M
- 每层自注意力:Q、K、V三个矩阵,每个都是384×384 = 147,456,三个就是442,368;注意力输出投影矩阵又是147,456。加上bias,每层注意力约0.59M
- 每层FFN:第一层升维384→1536,参数384×1536 = 589,824;第二层1536→384,又是589,824。加上bias约1.18M
- 6层加起来:(0.59 + 1.18)×6 ≈ 10.6M
- 最后的LM Head:1024×384 ≈ 0.39M(如果和embedding共享权重就不计)
- 再加上位置编码、层归一化参数,总参数落在20M上下
这里有一个参数分布陷阱:如果词表太大,embedding和LM Head会占据大量参数,挤压Transformer主体的容量。所以我特别控制词表大小,让模型参数尽量集中在“计算”的部分。这也是为什么很多专业模型都用subword而不是整个单词列表。
另一个跟参数量直接相关的概念是显存占用。我们常说20M参数很小,但实际训练时显存开销远远超过80MB(参数本身)。AdamW优化器要为每个参数保存一阶矩和二阶矩,相当于三倍参数内存,这就有240MB了。真正吃显存的是中间激活值——每层都会保存前向计算的中间结果用于反向传播。block_size越长、batch越大,激活值占用越恐怖。所以显存不够时,优先减batch_size,而不是减模型参数。
4. 训练工程:让Loss真正掉下去的实操细节
4.1 损失函数与优化器选型
语言模型用的是交叉熵损失。为什么不能用MSE?因为模型的输出是一个词表大小的概率分布,交叉熵天然适合评估两个分布的差异,而MSE是按回归任务设计的,对概率分布不敏感。如果你用MSE去训练分类/生成任务,梯度信号会变得很弱,模型学得极慢。
优化器我选AdamW。相比传统Adam,AdamW把权重衰减从L2正则中解耦出来,只对参数本身做衰减,不对自适应动量做衰减。在Transformer这类模型上,AdamW的效果明显更稳,而且现在几乎成了标配。关键超参数:学习率3e-4,weight_decay=0.1。
optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)这里需要提一个所有新手都会犯的错:不设weight_decay或者设得太大。不设weight_decay,模型在长训练下容易过拟合;设成0.5以上,模型权重被削得太狠,loss下不去。0.1是Transformer里被广泛验证的安全区间。
4.2 学习率调度与warmup
学习率是整个训练过程中最敏感的超参数。两种典型失败模式:学习率太大,loss直接冲上NaN;学习率太小,loss降得极其缓慢,几个小时后还在原地打转。
项目里我用的是“warmup + 余弦退火”策略。warmup阶段(前500~1000步),学习率从接近0线性升到峰值;之后按余弦曲线逐渐下降到接近0。为什么需要warmup?因为在训练的最初几步,梯度方向极其不稳定,相当于你在一片完全陌生的区域里跑,如果一开始就冲刺,很容易跑偏甚至掉沟里。先小步试探,等模型大致找到方向了再加速,效率反而更高。
调度逻辑可以直接用PyTorch的LambdaLR:
def lr_lambda(step): if step < warmup_steps: return step / warmup_steps progress = (step - warmup_steps) / (max_steps - warmup_steps) return 0.5 * (1 + math.cos(math.pi * progress)) scheduler = LambdaLR(optimizer, lr_lambda=lr_lambda)配合峰值学习率6e-4,在5000步的训练中,loss能稳定地从初始的~8降到~1.5左右。如果是全量微调阶段,学习率建议降到1e-4量级,因为基座已经学好了,更新太猛会把原有能力冲坏。
4.3 训练循环的标准配置
小模型训练,最合适的单次batch大小是32个序列、每个序列128个token。但24GB显存可以一次喂64个序列,为什么我还是坚持用32?因为batch太小梯度噪声大,batch太大又不利于泛化,32是折中。如果显存不够,就用梯度累积,模拟大batch:
for step in range(max_steps): x, y = get_batch('train') logits, loss = model(x, y) loss = loss / accum_steps loss.backward() if (step + 1) % accum_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()注意梯度裁剪。这个操作看似简单,其实是训练稳定性的保障:如果某个batch里出现极端样本导致梯度爆炸,裁剪会把梯度的范数限制在1.0以内,防止一次更新把模型权重推飞。我在项目里实测过,不加梯度裁剪,1000步内必出现一次loss尖峰;加了之后,曲线平滑很多。
另外说下混合精度。20M参数的小模型理论上不需要AMP,但在实验阶段我还是开了torch.cuda.amp.autocast,因为后面的RL微调阶段需要更快的迭代速度。开了AMP之后要小心fp16的loss scale问题,如果日志里出现大量NaN,先关了AMP跑200步看看,排除模型本身的问题再说。
4.4 监控、日志与验证
只看训练集loss是训练模型的大忌。我要求每次实验同时记录训练loss和验证loss。训练loss下降、验证loss也下降,说明模型真的在学;训练loss下降、验证loss不降甚至上涨,说明过拟合了,需要加dropout或提前停止。
日志记录方面,我用的是最朴素的方式:每100步打印一次step、loss、lr、当前时间,并定期保存checkpoint。有条件的可以接wandb或tensorboard,但对于这个项目规模,纯文本日志完全够用。关键是养成分阶段保存checkpoint的习惯,每500步存一个,这样万一后面某个时段模型训崩了,可以从最近一个正常点恢复,不用从头再来。
5. 推理生成与推理能力增强
5.1 自回归生成流程
训练完的模型是个“预测下一个token”的机器。生成文本时,把初始提示词编码成token序列,让模型预测下一个token的概率分布,采样一个token拼到序列末尾,再把新序列喂回模型,重复这个过程,直到生成足够的token或遇到结束符。
完整实现如下:
@torch.no_grad() def generate(model, idx, max_new_tokens, temperature=0.8, top_k=50, top_p=0.92): model.eval() for _ in range(max_new_tokens): idx_cond = idx if idx.size(1) <= block_size else idx[:, -block_size:] logits, _ = model(idx_cond) logits = logits[:, -1, :] / temperature # top-k 过滤 if top_k is not None: v, _ = torch.topk(logits, min(top_k, logits.size(-1))) logits[logits < v[:, [-1]]] = -float('Inf') # top-p 过滤 if top_p is not None: sorted_logits, sorted_indices = torch.sort(logits, descending=True) cum_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1) sorted_mask = cum_probs - F.softmax(sorted_logits, dim=-1) > top_p sorted_logits[sorted_mask] = -float('Inf') logits = torch.zeros_like(logits).scatter_(-1, sorted_indices, sorted_logits) probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat([idx, idx_next], dim=1) return idx这里有几个要点。第一,自回归生成必须限制上下文长度,只把最后block_size个token喂给模型,否则序列无限变长,显存爆炸。第二,我用的是采样而非argmax,因为argmax会退化成复读机,随机性才能生成多样化的文本。
5.2 采样策略:温度、top-k、top-p
生成这一步,超参数对结果的影响比很多初学者想象的大得多。核心是温度T,内部实现是softmax(logits / T)。T=1保持原样;T>1时概率分布变平坦,低概率token也有机会被选中,生成更“发散”;T<1时分布变尖锐,模型更倾向高概率token,生成更“保守”。
调温度的心得:文学生成用T=0.8,代码/逻辑任务用T=0.6甚至更低。温度太低会陷入重复循环,生成内容死板;温度太高,模型会往句子里乱塞毫无关联的词,看起来像病句集锦。
top-k和top-p是两道过滤器。top-k只保留概率最高的k个token参与采样,防止月度极低的“垃圾token”被选中;top-p是动态截断,保留累积概率达到0.92的那些token,比top-k更灵活。两者一起用效果稳定,实测在微型模型上,top-k=50、top-p=0.92是比较好的组合。
5.3 让小型模型具备基础推理能力:一个RL微调扩展
聊到“build a reasoning model from scratch”,我们得把目光从生成流畅文本转向有目标的推理。直接用自回归训练出来的模型,可以写出通顺的句子,但让它解决“排序三个数字”这种简单任务,大概率是瞎猜。原因很简单:语言模型学的是“下一个字符是什么”,不是“答案是什么”。推理能力需要额外的训练信号来引导。
这里我用了一个极简的强化学习思路,和大模型领域的GRPO(Group Relative Policy Optimization,组相对策略优化)思想一脉相承。任务是:给模型输入[2, 1, 3] sort ->,期望输出[1, 2, 3]。训练时,模型生成一段回答,如果回答完全正确,给正奖励;错误,给负奖励。然后用策略梯度方式更新模型,增加正确回答的概率、降低错误回答的概率。
伪代码如下:
# 生成一组候选回答 rollouts = model.generate(prompts, n_samples=4) # 按正确性打分 rewards = [1.0 if is_correct(r) else 0.0 for r in rollouts] # 计算组内相对优势 baseline = np.mean(rewards) advantages = [r - baseline for r in rewards] # 对模型输出的logp计算policy gradient损失 loss = -sum(advantage * logp for advantage, logp in zip(advantages, log_probs))这个思路看着简单,但有几个必须注意的工程细节。第一,基线设置很关键,这里用的是组内平均奖励。直接把原始奖励当优势,会导致即使全部回答正确,模型还是强行调整概率,训练不稳定。第二,rollouts必须带dropout,保持探索多样性,否则模型很快就固化在单一答案模式。第三,基座模型必须已经具备基本预测能力。你让一个loss还在5以上的模型去学推理,它连“数字1后面接着什么”都不知道,RL只会空转。
我实测用这个方法让20M参数的小模型在“三个数字排序”上达到了相对稳定的正确率,但说实话,任务稍一复杂就崩。这恰恰说明了一个大模型领域反复被验证的事实:推理能力不会凭空出现,它需要基座足够的容错能力和表征空间。RL是放大器,不是无中生有的魔法。
6. 常见问题与排查技巧实录
6.1 Loss不下降或直接变成NaN
这是从零训练模型遇得最多的问题,90%的原因出在三个地方:学习率太大、数据顺序没打乱、混合精度溢出。排查顺序建议这样:先把学习率降到1e-4,关闭AMP,确认数据加载时每个epoch都做了shuffle,然后看前200步的loss曲线。如果loss在1e-4下还是掉不下去,就检查数据——把输入文本打印前100个token,确认不是乱码或者全空白。
我踩过最冤的一次坑是:数据文件里混入了一大段全角空格和零宽字符,tokenizer把这些字符全分成了同一个token,模型学到了“高频token等于空格”的假规律,loss卡在某个点上不去,怎么调超参都没用。最后打印输入样本才发现问题,清洗完数据,loss立刻恢复正常。
6.2 生成结果反复重复
模型生成的内容如果总是“I am ... I am ... I am ...”,通常有四个可能:温度太低、上下文太短、模型容量不够、训练步数不足。排查时,先把温度升到0.9试试;如果还重复,再看训练日志——final loss如果远高于验证集上应该有的水平,说明模型欠拟合,需要更多训练步数;如果训练loss已经很低但验证loss偏高,那就是过拟合,可以加dropout或者提前停止。
还有一种情况:context window设得太短,120个token的上下文对学习莎士比亚这种长句子结构确实不够,模型记不住前文,只能靠当前几个词硬接。把block_size从128提到256,通常有明显改善。
6.3 显存溢出和训练崩溃
OOM(Out of Memory)是训练标配,不必慌。先算一笔账:一个20M参数的模型,纯参数占80MB,AdamW优化器状态占240MB,但中间激活值才是大头。如果batch=32、seq_len=128、6层Transformer,激活值大概占2~4GB。如果超了,优先减小batch_size,然后考虑开启梯度累积替代、开启gradient checkpointing。20M参数用gradient checkpointing的开销不大,可以接受。
还有一个隐藏问题:dataloader的num_workers设得太大,磁盘IO跟不上,训练循环卡在数据加载环节,GPU利用率变成0。排查方法是看GPU利用率,如果一直是0%,先检查是不是数据加载瓶颈,而不是模型问题。
6.4 代码组织与实验可复现
从零构建的项目,最怕“昨天还能跑,今天跑不了”。我强烈建议从第一天就做三件事:配置用yaml管理、tokenizer版本绑定hash、每500步保存checkpoint并记录对应的训练配置。这样任何一次实验结果都可以精确复现。不要相信自己的记忆,训练日志里要同时记录数据文件hash、模型配置、优化器参数、最终loss和生成样本。我用这套方法,一次实验结束后三个月再回来,仍然能100%重跑出当时的模型。
最后说点实在的
做“ai-engineering-from-scratch”这个项目,最大的收获不是那个20M参数的模型本身,而是我失去了一层“恐惧感”。以前看到别人分享的训练曲线、参数配置,总觉得对方很神秘;现在知道,每个loss数字背后都有实实在在的物理含义,每个超参数都有它的作用边界。模型报错的时候,我不会再满头雾水地随机改参数碰运气,而是能像看心电图一样,从曲线形状判断出问题出在哪个环节。
给想走这条路的朋友一个建议:别去找什么现成的“从零构建大模型”的网盘资源,也别抱着书看三遍不动手。直接打开编辑器,下载一份莎士比亚文本,从字符编码开始,写一个BPE,搭一个单层注意力模型,看着loss曲线掉下去,再亲手把梯度调崩一次。这个过程走一遍,你学到的东西比看一百篇综述都扎实。AI工程说到底不是知识,是手感。