说实话,第一次看到ai-engineering-from-scratch这个标题时,我第一反应是:又是一个把 "从头训练大模型" 当作卖点的仓库。但真正点进去,往下读了几行 README 之后,我发现它想讲的不是“怎么把数据喂给 transformers 然后等 loss 下降”,而是把整个 AI 工程链路——数据处理、分词、Transformer 实现、训练循环、推理解码、评估迭代、甚至从普通语言模型走向推理模型——重新用双手造一遍。
这个标题的准确解读应该是:不依赖torch.nn.Transformer一键换皮,不靠pip install transformers然后调 API,而是把 LLM 工程的核心组件一个模块一个模块地从零实现。这样做的价值在于,你在工作中早晚会遇到需要调模型、改架构、训基座的时候,到那时你才会明白“看过论文”和“亲手跑通”之间的差距有多大。
这篇文章我会按自己的理解,把这个from scratch路线拆开讲:它到底在做什么、为什么值得做、核心环节有哪些、以及怎么落地。适合的人群也很明确——有 Python 基础、懂一点深度学习、想真正理解大模型训练和推理链路的人。如果你是那种调 API 已经调腻了、想搞清楚模型内部发生了什么的人,这条路会很对你的胃口。
1. 项目解读与整体设计思路
1.1 “从零”到底是从哪个零开始
很多人一看到from scratch就会开玩笑说“从沙子开始造芯片”。但在 AI 工程语境里,from scratch的粒度通常是这样的:
- 不学造芯片、不学造显卡,那是硬件工程师的活;
- 不学写 CUDA 内核,除非你想深入 FlashAttention;
- 但要从 tokenizer 写起,包括 BPE 分词逻辑;
- 要从 Transformer block 写起,而不是直接
nn.Transformer; - 要从训练循环写起,包括数据加载、损失计算、反向传播、优化器调度;
- 要从采样逻辑写起,包括 temperature、top-p、KV cache;
- 要从评估脚本写起,包括困惑度计算和样例生成。
也就是说,这是一个介于“应用工程师”和“框架源码阅读者”之间的位置。你不必从零实现 autograd,因为 PyTorch 已经把自动微分做得足够好;但你必须从零实现模型结构和训练逻辑,而不是拿别人封装好的 Trainer 一把梭。
我见过不少同学,简历上写着“熟悉 BERT/GPT”,你问他attention_mask为什么要存在,他说“防止 pad 干扰”;你再问 pad 造成的干扰具体是通过哪条路径进入 loss 的,他就不太说得清了。这类模糊理解就是典型的“只用了轮子、没拆过轮子”。
1.2 为什么值得亲手造一遍轮子
我的一个很深的感受是:理解分成“看到”和“做到”两个层次。看论文、读源码是“看到”,自己把每个模块堆出来并让它跑通是“做到”。from scratch类项目最值钱的地方,就是强行把你从“看到”推向“做到”。
举个例子。很多教材都会告诉你:多头注意力是把d_model维度切成num_heads份,每个头独立做注意力,再拼接回去。这种描述听起来很简单,但等你自己写reshape和transpose的时候,你会发现一不小心就把[B, T, num_heads, head_dim]和[B, num_heads, T, head_dim]搞混。一旦view和permute的顺序错了,模型大概率还能训(因为维度对得上),但效果会莫名其妙地差。这种“看起来对但实际错”的问题,只有亲手写过一遍才会真正免疫。
再比如 KV Cache。只看示意图,你会以为它就是“把之前算过的 K 和 V 存一下,省得重复计算”。等你真去写生成器的解码循环,你会发现cache的索引错一位就可能导致模型输出错乱;你会发现每次新 token 只需要算它的 K、V,而不是重新算整个序列。这个认知差,不是看十遍文章能补上的。
1.3 这条路线适合谁走
从我带过的人来看,最适合走这条路的并不是那些刷了很多模型部署经验的工程师,而是下面三类人:
- 刚入门 LLM 方向的学生或转行者:需要一份“全链路”的实战地图,而不是零散的知识点。
from scratch类项目就是很好的主线教材。 - 工作中需要微调模型但经常翻车的人:一旦理解了训练循环里的每个环节(比如学习率调度、梯度裁剪、混合精度),你排查微调失败的速度会快很多。
- 对“推理模型(Reasoning Model)”好奇的人:现在热门的
build a reasoning model from scratch路线,它的前置基础恰恰是先能把普通语言模型训出来,再叠加思维链和强化学习。没有第一个from scratch,第二个基本无从谈起。
另外提醒一句:如果你是那种“只想要一个能用的模型”的开发者,这条路确实不是必需的,直接用开源模型和训练框架效率高得多。但如果你想搞懂原理、想进入模型训练和调优的深水区,这条路迟早要补。
2. 从零构建大语言模型的五大核心环节
2.1 数据工程:语料、清洗与 BPE 分词器
很多人觉得数据工程就是“下载一个数据集,然后丢给模型”。实际上从零开始做,最先卡住你的往往不是模型代码,而是数据怎么变成 tensor。
第一步是语料清洗。你搜集来的原始文本通常带着各种噪音:HTML 标签、重复行、乱码、格式不一致的引号括号,还有一些文档级别的重复内容。经验做法是这样:
- 按 UTF-8 解析文本,过滤无法解码的字节;
- 去掉连续重复超过一定比例的行(比如相似度超过 0.9 就删除一行);
- 统一换行符,删除过多的空白字符;
- 如果要做多文档训练,一定要在文档之间插入分隔 token(比如
<|endoftext|>),否则模型会自己“脑补”出一个没有边界的混沌语料。
第二步是分词。from scratch的核心好戏在 BPE(Byte Pair Encoding)。我自己写过一个最小实现,过程大概是:先把文本转成 UTF-8 字节序列,统计相邻字节对的频次,每次合并最高频的字节对,把新生成的符号加入词表,重复这个过程直到词表达标。关键点有两个:
- 用字节而不是字符作为最小单元,这样无论中文、日文还是 emoji,都能落到一个有限词表里,不会因为生僻字导致词表爆炸;
- 合并标准是频次,但实际实现里要看清楚局部统计的更新方式,否则训练速度会慢到怀疑人生。我建议先用小语料(几 MB)做一遍,比如 vocab_size 512 或者 1024,感受一下整个流程,再跑到 GPT-2 那个量级的 50257 词表。
我之前有个朋友直接拿别人的 tokenizer 文件怼进自己的模型,结果 vocab 对不上,embedding 矩阵维度直接报错。这类问题在from scratch项目里尤其常见,因为一切都要自己对齐。所以这里我强烈建议:分词器训练好之后,马上做一次 decode(encode(text)) == text 的完整性验证。
2.2 模型架构:手写一个极简 Decoder-only Transformer
模型架构方面,我不会建议你一上来就复制 GPT-4 的完整配置。from scratch的正确做法是从极简结构起步,比如一个 6 层、隐藏维度 192 的小模型,把下面这些模块全部自己实现一遍:
- 输入 Embedding:token id 到向量的映射,查表即可;
- 旋转位置编码 RoPE:我推荐优先实现 RoPE,而不是老式的可学习位置编码。原因在于 RoPE 把位置信息直接注入 Q、K 的向量里,attention score 天然依赖相对位置,对于训练长度之外的外推也更友好;
- RMSNorm:相比 LayerNorm 少了均值中心化,计算更快,大模型普遍在用。平时你会觉得它和 LayerNorm 差不多,真到自己推公式时,会发现梯度流更简洁;
- 多头自注意力:核心计算是 softmax(QK^T / sqrt(d_k) + mask) V。为什么要除以 sqrt(d_k)?因为如果不缩放,当 d_k 较大时 QK^T 的方差会变大,softmax 会被推到饱和区,梯度特别小。这个“为什么”如果只靠背结论是记不牢的;
- 前馈网络 FFN / SwiGLU:一个两层的 MLP,但激活函数可以换成门控线性单元,实践中效果更好;
- 残差连接:每个子层后面都接
x + sublayer(x),这是深层网络能稳定训练的基本保障; - 最终输出层:一般叫
lm_head,把最后一层输出映射到词表大小,然后算交叉熵。
一个常被忽略的细节是:lm_head的权重可以复用 token embedding 的权重(weight tying),这样可以显著减少参数量,而且在训练早期会让 loss 下降更稳定。很多开源小模型都是这么做的。
写 Transformer block 的时候,我建议用真正的reshape/permute来实现多头,而不是直接调nn.MultiheadAttention。前者能让你理解形状变化,后者只是调包。
import torch import torch.nn as nn import torch.nn.functional as F class CausalSelfAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() self.num_heads = num_heads self.head_dim = d_model // num_heads self.qkv = nn.Linear(d_model, 3 * d_model) self.out = nn.Linear(d_model, d_model) def forward(self, x): B, T, C = x.shape qkv = self.qkv(x) # [B, T, 3*C] q, k, v = qkv.chunk(3, dim=-1) # 切成多头 q = q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) k = k.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) v = v.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) # 带因果 mask 的注意力 att = (q @ k.transpose(-2, -1)) / (self.head_dim ** 0.5) mask = torch.tril(torch.ones(T, T, device=x.device)).view(1, 1, T, T) att = att.masked_fill(mask == 0, float('-inf')) att = F.softmax(att, dim=-1) y = att @ v # [B, num_heads, T, head_dim] y = y.transpose(1, 2).contiguous().view(B, T, C) return self.out(y)这只是最基础的一版,实际工程里还会加 RoPE、GQA、Flash Attention 等优化。但这个小代码能让你理解一件事:因果 mask 的作用是让位置t的 token 只看得到t以及它之前的 token。一旦 mask 出错,模型在训练时就会“偷看未来”,生成时的表现会非常奇怪。
2.3 训练流程:数据加载器、损失计算与学习率调度
模型搭好了,接下来是训练。from scratch的训练循环没有框架帮你封装,每一步都要自己来。这里面有几个容易被忽略的细节。
数据加载器方面,我的做法是把所有 token 拼成一个一维数组,然后随机起点切出[B, T]的 batch,label就是输入左移一位。这一步要把 token 长度和句子边界想清楚,如果你所有语料拼接时忘了插入分隔 token,跨文档的预测任务会非常抽象。
损失计算直接用cross_entropy。输入 logits 的形状是[B, T, vocab_size],目标 labels 是[B, T]。注意交叉熵默认会对 batch、序列长度一起取平均,如果你有 pad mask,要自己手动做 masked average。
优化器我推荐 AdamW,学习率设置3e-4起步,配合 cosine schedule 和 warmup。warmup 的作用是让训练开始时优化器的自适应统计量稳定下来,否则前几步很容易出现 loss 剧烈震荡甚至 NaN。我自己的经验法则是:在 10000 步以内的迷你模型训练中,用前 500 步做 warmup,峰值学习率3e-4,后面按 cosine 衰减到3e-6。
混合精度和梯度裁剪也得留意。现在主流做法是 bf16,它的指数位足够宽,一般不会像 fp16 那样动不动溢出,所以不需要 loss scaler。梯度裁剪我常年设置为max_grad_norm=1.0,这个数字对大多数 Transformer 训练都稳定。不要觉得 clip 是“老古董”操作,它对防止 loss spike 非常有效。
显存不够的时候,优先做 gradient accumulation——把小 batch 的梯度累加若干步再更新一次。注意:梯度累积要按比例放大 batch 并保持学习率大致不变,但 Adam 类的自适应优化器受这个影响不是特别大,可以先跑一把看看曲线。
2.4 推理与解码:温度、Top-p 与 KV Cache
训练完成后,模型还是一个torch.nn.Module,要真正“用起来”,你必须自己写解码循环。这一步我认为是from scratch项目里最有趣的环节,因为所有训练阶段没暴露的推论逻辑都挤在这里。
最基本的自回归生成是:输入 prompt 得到 logits,取最后一个位置的 logits,做 softmax 变成概率分布,然后采样下一个 token,把 token 拼回去,再重复直到遇到 EOS 或长度上限。
但实际工程里很少直接用裸的概率分布,通常会加三个控制项:
- Temperature:对 logits 除以一个温度系数。温度越低越保守,接近 greedy;温度大于 1 会让分布更平坦、更多样。推荐默认
0.8左右。 - Top-p(核采样):把所有 token 按概率从高到低累加,只保留累计概率达到
p的候选集合,在这个集合内重新归一化再采样。这个策略比单纯 top-k 更动态,序列短时尤其好用。 - Repetition Penalty:对已经出现过的 token 的 logits 做惩罚。实现是对比 tokens 的 logits 除以或乘以惩罚系数,比如 1.15。如果模型在长文本生成时总陷入复读循环,这个参数很有用。
然后是 KV Cache。这个优化建议在掌握了基础解码之后再加上去。核心思路:自回归生成时,第t步只需要关注新增 token 的 attention 查询结果,而之前所有 token 的 K、V 已经被算过,可以缓存下来复用。不加 KV cache 时,序列长度从 1 涨到 N,每一步都重新计算全部前向,复杂度接近 O(N^2·d),加上之后变成 O(N·d),长序列生成速度差距可以达到一个数量级。
def generate(model, tokenizer, prompt, max_new_tokens=128, temperature=0.8, top_p=0.95): model.eval() ids = tokenizer.encode(prompt) for _ in range(max_new_tokens): with torch.no_grad(): logits = model(torch.tensor([ids]))[:, -1, :] logits = logits / temperature probs = F.softmax(logits, dim=-1) sorted_probs, sorted_idx = torch.sort(probs, descending=True, dim=-1) cumsum = torch.cumsum(sorted_probs, dim=-1) mask = cumsum - sorted_probs > top_p sorted_probs[mask] = 0.0 sorted_probs /= sorted_probs.sum(dim=-1, keepdim=True) next_id = torch.multinomial(sorted_probs, 1) ids.append(next_id.item()) if next_id.item() == tokenizer.eos_id: break return tokenizer.decode(ids)这段代码看起来不长,但如果你自己写一遍,会立刻理解为什么推理框架里那么多for循环优化和内存管理技巧。因为真正的文本生成,瓶颈几乎都在logits的形状和缓存策略上。
2.5 评估与迭代:困惑度只是下限
训练到一半,怎么判断模型“学得好不好”?最简单的指标是留出验证集上的交叉熵 loss,然后换算成 perplexity:ppl = exp(loss)。困惑度可以粗略理解为模型对每个 token 的平均备选数,越小越好。但我要强调:ppl 下降不代表生成质量一定好,因为它只衡量“平均正确概率”,不代表模型不会在长序列后端重复或跑偏。所以评估必须两条腿走路:
- 量化指标:验证集 loss / perplexity,还可以做一下
HellaSwag这类小任务的 zero-shot 测试; - 人工观察:准备 10~20 个固定 prompt,每训练一定步数后生成一遍,把输出存到日志里,人工扫一眼。你会发现 loss 曲线没反映出的一些问题,比如重复尾缀、开始胡说八道、突然输出一堆 EOS。
我自己在迷你模型阶段最常干的事,是拿莎士比亚文本或 TinyStories 这类简单语料先跑通,然后逐渐加复杂语料。这个过程不是为了得到一个惊艳的模型,而是为了建立一个“手感”:你知道什么条件下 loss 应该降、什么条件下模型会过拟合、什么时候该调数据而不是调模型。
3. 从语言模型到推理模型:关键一步怎么迈
3.1 推理模型的本质:不是更聪明,而是更会“想”
最近到处都有人在聊build a reasoning model from scratch,其实就是把上面这套“普通语言模型”再往前推一步。普通 LLM 的训练目标是“下一个 token 的概率最大”,它学的是语言上的统计规律,不代表它有解题策略。你会发现,让普通模型直接做比较复杂的数学题时,它经常一本正经地写出错误答案,因为它跳过了思考过程。
推理模型的核心变化是:在输出最终答案之前,模型要先生成一段推理轨迹,而且这个推理轨迹是通过强化学习“练”出来的,不是简单从人类标注的思维链数据里背下来的。公开技术报告里反复提到的现象是:模型在强化学习训练中会自发涌现出“反思”“回溯”“验证中间步骤”等行为。把这条路线做到from scratch的规模,你会发现它的每一步其实都不玄乎,就是数据、策略、奖励循环。
我个人理解,这条路线可以压缩成三板斧:冷启动 SFT、可验证奖励的强化学习、蒸馏小模型。
3.2 可验证奖励与 RLVR:规则即信号
传统 RLHF 需要训练一个奖励模型来模拟人类偏好,而奖励模型本身又容易学偏、需要大量人工标注。推理模型快速火爆的一个关键原因是:很多推理任务天然带“可验证”的答案。数学题的最终答案可以字符串匹配,编程题可以看单元测试是否通过,逻辑题可以规则判定。于是有了RLVR(Reinforcement Learning with Verifiable Rewards)——直接用规则算奖励,不需要人来打分。
具体到实现,你会做这样一件事:
- 给模型一个 prompt(比如一道数学题);
- 让模型采样生成多条完整回答;
- 每条回答根据最终答案是否正确得 1 分或 0 分;
- 奖励信号通过策略梯度算法反传给模型。
这里我不建议一上来就搞 PPO,虽然它经典但实现重,对显存也不友好。目前社区更常见的轻量选择是GRPO(Group Relative Policy Optimization)。它的思路很简单:对同一个 prompt 采样一组回答,算出每个回答的奖励,再做组内标准化得到 advantage(正负),用它来放大或抑制所有 token 的概率。关键是它不需要像 PPO 那样维护一个独立的 critic 价值模型,省了巨大显存和实现复杂度,对个人开发者太友好了。
GRPO 更新时通常还带一个 KL 约束项:不能让策略模型漂移太远,否则语言能力会崩塌。你会看到一个超参数beta,比如0.04,它控制 KL 惩罚强度。beta太小,模型容易刷奖励刷到格式崩坏;beta太大,学习速度会明显变慢。这个参数和奖励信号之间需要平衡,实操时值得花时间调。
3.3 SFT、RL 与蒸馏的正确顺序
如果你想从零自己训一个能“思考”的小模型,我的建议顺序是:
- 冷启动 SFT:先用一批带思维链的高质量样本做监督微调。这一阶段的目标不是学会推理,而是学会推理的格式和基本语气——知道先写思考过程再写答案;
- 规则奖励 RL:在 SFT 基础上,用数学或代码数据集做 GRPO 训练,让模型自己探索更长的解题路径;
- 拒绝采样蒸馏:训练完 RL 模型后,拿它作为“教师”,采样大量 prompt 的回答,筛选出答案正确的样本,加入 SFT 数据去训练一个小模型。这个阶段能把教师模型的推理能力压缩到更小的模型上,速度和成本都会好看很多。
这三个阶段的梯度是递进的:没有 SFT 的格式基础,模型在 RL 里很容易放飞自我;没有 RL 的试错探索,纯 SFT 学到的思维链只是模仿,遇到没见过的题目依然很难泛化;没有蒸馏,你只能部署一个大而慢的推理模型。
3.4 冷启动数据与模板工程
最后说一个from scratch时最容易被低估的环节:模板和数据。
在 RL 阶段之前,几乎所有项目都会要求模型输出包含特殊标记,比如<think>...</think><answer>...</answer>。这个模板不仅仅是为了给人类看,更是为了让奖励函数能精准定位最终答案。你在写reward_checker时,一定要考虑模型输出格式不符合要求的情况:答案没闭合怎么办?思考过程里也有疑似答案怎么办?这些细节如果不处理,GRPO 训练时奖励信号会非常吵,模型会学得很痛苦。
构造冷启动 SFT 数据时,也不要一开始就追求几百万条。我在小规模项目里常用的路径是:拿一个通用的开源基座模型,用少量 prompt 让它生成候选回答,再用规则判断正确性,只把正确且有清晰中间步骤的回答捡回来作为 SFT 样本。这一步叫 rejection sampling,虽然看起来朴素,但它是整个from scratch推理模型路线里涨点最稳的一招。
4. 一条可以复现的完整实操路线
4.1 环境和工具选型
先解决工具问题。以下是我反复踩坑后确认适合from scratch入门的环境组合:
- Python 3.11,最好用虚拟环境;
- PyTorch 2.x,自动支持
torch.compile和更好的 bf16 支持; - 单张 RTX 4090 级别的显卡,或者 A100 也行。如果只有 CPU,也不是不能跑,但要把数据规模和模型尺寸再缩小一个量级;
- WandB 或本地 CSV 日志。我强烈建议至少把训练日志落盘,包括每条日志对应的超参 hash,不然几天后你根本不知道曲线是哪一版跑出来的;
- Git。每跑一个实验前 commit 一次代码和数据配置。
不建议一上来就上 DeepSpeed、多机多卡、Megatron 这类重型框架。from scratch的核心是建立直觉,先用单卡小模型把流程跑通,再考虑扩展。
4.2 最小能跑的项目骨架
这是我的建议目录结构:
ai-engineering-from-scratch/ ├── data/ │ ├── raw_corpus.txt │ └── prepare.py ├── tokenizer/ │ ├── train_bpe.py │ └── bpe.py ├── model/ │ ├── config.py │ ├── layers.py │ └── transformer.py ├── train/ │ ├── data_loader.py │ ├── trainer.py │ └── optim.py ├── infer/ │ ├── sample.py │ └── server.py ├── rl/ │ ├── env_checker.py │ ├── grpo.py │ └── prompts.py └── checkpoints/先不用急着把rl/写得很重,把model/和train/跑通是第一目标。config.py里集中放所有超参,我给出一个经过实测的小配置:
| 参数 | 取值 | 说明 |
|---|---|---|
| vocab_size | 4096 | 小语料够用,别一开始就 50k |
| d_model | 192 | 隐藏维度 |
| num_layers | 6 | Transformer 层数 |
| num_heads | 6 | 注意力头数 |
| head_dim | 32 | d_model / num_heads |
| max_seq_len | 256 | 序列长度,先短一点 |
| batch_size | 32 | 微批次 |
| grad_accum | 4 | 梯度累积,等效 batch 128 |
| total_steps | 10000 | 总训练步数 |
| peak_lr | 3e-4 | 峰值学习率 |
| warmup_steps | 500 | 预热步数 |
| weight_decay | 0.1 | AdamW 权重衰减 |
| grad_clip | 1.0 | 梯度裁剪上限 |
这个模型参数量大概在 10M 级别,单张 4090 上训练几小时就能跑到一个能明显看出“学会点东西”的状态。关键是流程闭环,而不是追求指标。
4.3 训练超参与收敛判断
训练开始后,你会看到 loss 从很高的值一路下降。以莎士比亚级别的文本为例,vocab_size=4096的话,随机初始化时 loss 大约在log(4096)≈8.3附近。训练几十步之后 loss 应该快速掉到 4~5,然后进入匀速下降阶段。我自己的判断标准是:
- train loss 和 val loss 同步下降,说明训练健康;
- val loss 掉到某个点开始反弹,说明过拟合了,要么加数据,要么加 weight decay;
- loss 在某个步数突然跳高,一般不是偶然,而是数据批次里混进了异常样本或学习率没调好,建议先看梯度范数。
这里特别提醒:from scratch项目里经常有人犯一个错误,就是用“生成结果看起来像人话”来反推模型训练没问题。短 prompt 容易生成通顺句子,不代表长上下文语义稳定。每次 checkpoint 后我都建议跑一组固定的评估 prompt,包括单句续写、多段问答、以及让你感觉最容易翻车的数字/逻辑类 prompt。
4.4 从 checkpoint 到部署推理
训练完成后,你手里的state_dict只是一个张量集合。要把它变成一个可部署的推理服务,还需要做几件事:
- 保存一份完整的 config 和 tokenizer 文件,因为反序列化模型时没有 config 就完全无法重建;
- 写一个简单的
FastAPI或Flask服务,把加载模型、编码 prompt、解码生成包在里面; - 如果想部署得更轻量,可以考虑把模型导出为 GGUF 格式,用 llama.cpp 跑推理,但量化本身又是一个
from scratch的好课题; - 如果你的目标是给其他应用调用,最好在推理层把温度、top-p、max_new_tokens 这些参数暴露为接口参数,而不是写死在代码里。
部署阶段我最常踩的坑是:模型加载后没有调用model.eval(),或者torch.no_grad()忘写了,导致推理速度奇慢无比,甚至 batch 里梯度图被偷偷构建出来。这些小问题在框架封装好时不会出现,但from scratch的价值正在于让你把这些细节一个个都摸清楚。
5. 常见问题与排查技巧实录
下面这些坑不是网上抄来的,是我在自己从零训练和微调模型过程中反复遇到的,整理成速查表方便你对照。
| 症状 | 可能原因 | 排查方法 | 解法 |
|---|---|---|---|
| loss 完全不动 | 学习率太省或数据没 shuffle | 打印 lr、loss、梯度范数 | 调大 lr,按 epoch shuffle 数据 |
| loss 先降后突然 NaN | 梯度爆炸或 bf16 溢出 | 看 NaN 前几轮的梯度范数 | grad_clip 降到 0.5,降低 lr |
| loss 下降很快但生成全乱 | tokenizer 和模型 vocab 不匹配 | decode(encode(text)) 验证 | 重建 tokenizer 并重训 embedding |
| 生成大量重复 token | temperature 太低、过拟合 | 调高温度到 0.9 看变化 | 用 repetition penalty + 增加 dropout |
| 长文本生成跑偏 | 序列超出 RoPE 外推范围 | 观察生成位置与训练长度 | 用已训练的 max_seq_len 限制长度 |
| 推理速度极慢 | 没有实现 KV cache | 用 profiler 看时间分配 | 实现 KV cache、用 bf16、torch.compile |
| RL 训练奖励不涨 | KL 惩罚太紧或采样温度过高 | 打印 reward、KL、样本格式 | 调 beta,把采样温度压到 0.7 |
5.1 损失不降或 NaN 的排查
loss 不降是我见过最多的求助帖内容。碰到这种情况,先别急着调模型架构。按顺序查这几个点:
- 数据顺序是不是完全没打乱?如果一个 epoch 内模型反复看同一批次数据,它会在“背答案”而不是“学规律”,表现为 val loss 降不下去;
- 初始化是否正确?如果 embedding 太大或输出层初始化不当,head 维度上的 logits 方差会很大,softmax 饱和导致梯度微弱;
- 学习率是不是太极端?
5e-4不是万能,超过模型规模合适范围时,早期训练会反复冲高。我建议把max_grad_norm=1.0常年开着,能挡掉一半的 NaN 问题; - 有没有不需要 mask 的地方误用了全局注意力?因果语言模型里,如果 attention mask 没设对,信息泄漏会让训练 loss 看起来很漂亮,但真实生成质量非常差。
NaN 出现时,先损失loss.item()和前一层梯度的绝对值有没有异常,再看是不是数据里混进 NaN。很多文本清洗脚本会导致某些 token 被映射到-1,embedding 查表直接溢出,这个问题在from scratch项目里尤其容易踩到。
5.2 生成长度崩坏与重复惩罚
我在训练 30M 左右的小模型时,经常发现生成到一定长度就开始疯狂重复一个词或一句话。这和小模型容量有限、没见过足够多样文本有关,也和采样参数有关。
如果训练已经完成,最实用的解法是在解码端做三件事:
- 把 temperature 从 0.8 降到 0.6 左右,减少随机性引发的循环;
- 打开 repetition penalty,常用值 1.1~1.3。注意 penalty 太大也会导致语义跳变,因为模型为了避开已经出现的词,可能会强行换一个奇怪的词;
- 对 top-p 做适度收紧,比如从 0.95 调到 0.9,候选集变小后生成稳定性也会变好。
如果训练还没结束,那么请检查数据里是否充斥着重复句子。我用过一个公开语料,里面有几万字是同一篇文本重复了 20 遍,模型学到的唯一规律就是“重复也是正常的”。这个教训提醒我:数据清洗里“去重”不是可选步骤。
5.3 显存不足的多层解法
from scratch项目里显存紧张是常态。从我的经验看,按性价比排序的解法是:
- 降低 batch size + 梯度累积:这是最简单有效的办法。比如原本想跑
batch=128,改成batch=32累积 4 步,效果几乎一样; - 开启 bf16:显存直接砍半,而且对训练稳定性影响很小;
- 开启 gradient checkpointing:以少量计算换显存,适合长序列训练;
- 简化 KV cache 和激活存储:如果你手写了 attention,可以减少在
att上的保留变量,因为那个矩阵的形状是[B, num_heads, T, T],序列长度上来后非常吃显存; - 降低 max_seq_len:如果业务允许,把 1024 降到 512,显存压力会小很多。
还有一个“隐形显存杀手”:在推理服务里没关梯度。如果模型处于训练模式并且输入张量requires_grad=True,那么一次 forward 就会构建整张计算图,显存爆掉是迟早的事。记得model.eval()+torch.no_grad()。
5.4 推理模型的奖励信号失效怎么办
当你从普通 LLM 走向 reasoning model,开始做 GRPO 时,最常见的问题是“奖励一直在原地抖动,模型没有变聪明”。我遇到过几个具体原因:
- 奖励函数太脆:比如只检查最终答案是否包含某个数字,模型可能在思考过程里疯狂出现这个数字而最终答案乱写,这种 reward hacking 会让模型学会“刷奖励”而不是“学推理”。解法是加强格式检查,提取
<answer>标签里真正的内容再判定; - 采样温度不合适:GRPO 需要足够探索,如果温度设在 0.2,采出来的样本几乎都一样,advantage 没有区分度。我一般把采样温度放在 0.6~0.8,同时配合 top-p 0.95;但如果太高,模型会输出大量格式崩坏的样本,奖励大部分是 0,学习信号也很弱;
- KL 惩罚过大:KL 项太大相当于给策略模型拴上铁链,它不敢尝试新的解题路径。你可以先跑一轮小实验,统计 KL 值和 reward 的关系,找到平衡点。我常用
beta=0.04起步,然后逐步调低到0.01; - 数据分布太单一:如果训练集里全是“计算题”,模型只会练出对计算题的套路,换个题型立刻失效。我建议至少混合数学和带规则判断的逻辑题,让模型学到通用的解题模式。
另外补充一个非常容易被忽略的细节:在 RL 阶段之前,一定要让模型通过 SFT 稳定输出标准格式。如果你丢给 GRPO 一个连<think>都经常不闭合的模型,强化学习的一大半算力都会浪费在“教格式”上面,而不是“教推理”上面。这个顺序一旦倒过来,训练过程会极其痛苦。
6. 一些关于 from scratch 的个人体会
走完整条ai-engineering-from-scratch路线之后,我最大的感受是:真正值钱的东西不是最后那个能生成文本的 checkpoint,而是你在反复 debug 过程中建立的“直觉”。
以前我在用开源模型做微调时,遇到 loss 曲线诡异只会干着急,不知道从哪查起。自己动手写过 tokenizer、训练循环、解码器之后,再遇到同类问题,我脑子里会自动浮现一张链路图:数据进入 tokenizer 变成 id,id 进 embedding 变成向量,向量经过 N 层 attention 变成 logits,logits 经过采样变成文本,每个环节的失败模式都不一样,排查的时候就有一条清晰的路径。
最后分享一个小习惯:我每次开始新一轮from scratch实验前,会在 Git commit message 里记录语料来源、vocab_size、模型配置和峰值学习率。这个东西坚持半年后再回头看,比任何实验管理工具都好用。因为你会发现,几个月前某个下午跑出的优质 checkpoint,当时随手记下的配置能让今天的你节省一整天重跑时间。
如果你也打算走这条路,我的建议是:不要急着把代码框架铺得太大,先按“最小闭环”来,从几十 MB 语料、几百万参数开始,把一个完整的训练和生成链路跑通,再把程序适当地向两边延伸——下面补数据清洗和硬件效率优化,上面补推理模型的强化学习。这条路的终点不是拿它去和大模型竞争,而是让你从此拥有真正拆开 AI 黑箱的能力。