从零构建大语言模型:Transformer、训练与推理全流程实战

📅 发布时间:2026/9/29 6:32:17
从零构建大语言模型:Transformer、训练与推理全流程实战
说实话第一次看到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的核心好戏在 BPEByte 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 的小模型把下面这些模块全部自己实现一遍输入 Embeddingtoken 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, devicex.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]的 batchlabel就是输入左移一位。这一步要把 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_norm1.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_tokens128, temperature0.8, top_p0.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, descendingTrue, 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, keepdimTrue) 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然后换算成 perplexityppl 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 需要训练一个奖励模型来模拟人类偏好而奖励模型本身又容易学偏、需要大量人工标注。推理模型快速火爆的一个关键原因是很多推理任务天然带“可验证”的答案。数学题的最终答案可以字符串匹配编程题可以看单元测试是否通过逻辑题可以规则判定。于是有了RLVRReinforcement Learning with Verifiable Rewards——直接用规则算奖励不需要人来打分。具体到实现你会做这样一件事给模型一个 prompt比如一道数学题让模型采样生成多条完整回答每条回答根据最终答案是否正确得 1 分或 0 分奖励信号通过策略梯度算法反传给模型。这里我不建议一上来就搞 PPO虽然它经典但实现重对显存也不友好。目前社区更常见的轻量选择是GRPOGroup 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.../thinkanswer.../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_size4096小语料够用别一开始就 50kd_model192隐藏维度num_layers6Transformer 层数num_heads6注意力头数head_dim32d_model / num_headsmax_seq_len256序列长度先短一点batch_size32微批次grad_accum4梯度累积等效 batch 128total_steps10000总训练步数peak_lr3e-4峰值学习率warmup_steps500预热步数weight_decay0.1AdamW 权重衰减grad_clip1.0梯度裁剪上限这个模型参数量大概在 10M 级别单张 4090 上训练几小时就能跑到一个能明显看出“学会点东西”的状态。关键是流程闭环而不是追求指标。4.3 训练超参与收敛判断训练开始后你会看到 loss 从很高的值一路下降。以莎士比亚级别的文本为例vocab_size4096的话随机初始化时 loss 大约在log(4096)≈8.3附近。训练几十步之后 loss 应该快速掉到 4~5然后进入匀速下降阶段。我自己的判断标准是train loss 和 val loss 同步下降说明训练健康val loss 掉到某个点开始反弹说明过拟合了要么加数据要么加 weight decayloss 在某个步数突然跳高一般不是偶然而是数据批次里混进了异常样本或学习率没调好建议先看梯度范数。这里特别提醒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降低 lrloss 下降很快但生成全乱tokenizer 和模型 vocab 不匹配decode(encode(text)) 验证重建 tokenizer 并重训 embedding生成大量重复 tokentemperature 太低、过拟合调高温度到 0.9 看变化用 repetition penalty 增加 dropout长文本生成跑偏序列超出 RoPE 外推范围观察生成位置与训练长度用已训练的 max_seq_len 限制长度推理速度极慢没有实现 KV cache用 profiler 看时间分配实现 KV cache、用 bf16、torch.compileRL 训练奖励不涨KL 惩罚太紧或采样温度过高打印 reward、KL、样本格式调 beta把采样温度压到 0.75.1 损失不降或 NaN 的排查loss 不降是我见过最多的求助帖内容。碰到这种情况先别急着调模型架构。按顺序查这几个点数据顺序是不是完全没打乱如果一个 epoch 内模型反复看同一批次数据它会在“背答案”而不是“学规律”表现为 val loss 降不下去初始化是否正确如果 embedding 太大或输出层初始化不当head 维度上的 logits 方差会很大softmax 饱和导致梯度微弱学习率是不是太极端5e-4不是万能超过模型规模合适范围时早期训练会反复冲高。我建议把max_grad_norm1.0常年开着能挡掉一半的 NaN 问题有没有不需要 mask 的地方误用了全局注意力因果语言模型里如果 attention mask 没设对信息泄漏会让训练 loss 看起来很漂亮但真实生成质量非常差。NaN 出现时先损失loss.item()和前一层梯度的绝对值有没有异常再看是不是数据里混进 NaN。很多文本清洗脚本会导致某些 token 被映射到-1embedding 查表直接溢出这个问题在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 梯度累积这是最简单有效的办法。比如原本想跑batch128改成batch32累积 4 步效果几乎一样开启 bf16显存直接砍半而且对训练稳定性影响很小开启 gradient checkpointing以少量计算换显存适合长序列训练简化 KV cache 和激活存储如果你手写了 attention可以减少在att上的保留变量因为那个矩阵的形状是[B, num_heads, T, T]序列长度上来后非常吃显存降低 max_seq_len如果业务允许把 1024 降到 512显存压力会小很多。还有一个“隐形显存杀手”在推理服务里没关梯度。如果模型处于训练模式并且输入张量requires_gradTrue那么一次 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 的关系找到平衡点。我常用beta0.04起步然后逐步调低到0.01数据分布太单一如果训练集里全是“计算题”模型只会练出对计算题的套路换个题型立刻失效。我建议至少混合数学和带规则判断的逻辑题让模型学到通用的解题模式。另外补充一个非常容易被忽略的细节在 RL 阶段之前一定要让模型通过 SFT 稳定输出标准格式。如果你丢给 GRPO 一个连think都经常不闭合的模型强化学习的一大半算力都会浪费在“教格式”上面而不是“教推理”上面。这个顺序一旦倒过来训练过程会极其痛苦。6. 一些关于 from scratch 的个人体会走完整条ai-engineering-from-scratch路线之后我最大的感受是真正值钱的东西不是最后那个能生成文本的 checkpoint而是你在反复 debug 过程中建立的“直觉”。以前我在用开源模型做微调时遇到 loss 曲线诡异只会干着急不知道从哪查起。自己动手写过 tokenizer、训练循环、解码器之后再遇到同类问题我脑子里会自动浮现一张链路图数据进入 tokenizer 变成 idid 进 embedding 变成向量向量经过 N 层 attention 变成 logitslogits 经过采样变成文本每个环节的失败模式都不一样排查的时候就有一条清晰的路径。最后分享一个小习惯我每次开始新一轮from scratch实验前会在 Git commit message 里记录语料来源、vocab_size、模型配置和峰值学习率。这个东西坚持半年后再回头看比任何实验管理工具都好用。因为你会发现几个月前某个下午跑出的优质 checkpoint当时随手记下的配置能让今天的你节省一整天重跑时间。如果你也打算走这条路我的建议是不要急着把代码框架铺得太大先按“最小闭环”来从几十 MB 语料、几百万参数开始把一个完整的训练和生成链路跑通再把程序适当地向两边延伸——下面补数据清洗和硬件效率优化上面补推理模型的强化学习。这条路的终点不是拿它去和大模型竞争而是让你从此拥有真正拆开 AI 黑箱的能力。