news 2026/9/29 6:32:12

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建大语言模型: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的核心好戏在 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 与蒸馏的正确顺序

如果你想从零自己训一个能“思考”的小模型,我的建议顺序是:

  1. 冷启动 SFT:先用一批带思维链的高质量样本做监督微调。这一阶段的目标不是学会推理,而是学会推理的格式和基本语气——知道先写思考过程再写答案;
  2. 规则奖励 RL:在 SFT 基础上,用数学或代码数据集做 GRPO 训练,让模型自己探索更长的解题路径;
  3. 拒绝采样蒸馏:训练完 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_size4096小语料够用,别一开始就 50k
d_model192隐藏维度
num_layers6Transformer 层数
num_heads6注意力头数
head_dim32d_model / num_heads
max_seq_len256序列长度,先短一点
batch_size32微批次
grad_accum4梯度累积,等效 batch 128
total_steps10000总训练步数
peak_lr3e-4峰值学习率
warmup_steps500预热步数
weight_decay0.1AdamW 权重衰减
grad_clip1.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
生成大量重复 tokentemperature 太低、过拟合调高温度到 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项目里显存紧张是常态。从我的经验看,按性价比排序的解法是:

  1. 降低 batch size + 梯度累积:这是最简单有效的办法。比如原本想跑batch=128,改成batch=32累积 4 步,效果几乎一样;
  2. 开启 bf16:显存直接砍半,而且对训练稳定性影响很小;
  3. 开启 gradient checkpointing:以少量计算换显存,适合长序列训练;
  4. 简化 KV cache 和激活存储:如果你手写了 attention,可以减少在att上的保留变量,因为那个矩阵的形状是[B, num_heads, T, T],序列长度上来后非常吃显存;
  5. 降低 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 黑箱的能力。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 6:29:59

superpowers:从手工重复到自动化,开发者效率工具集与Codex协同实践

做过几年开发的朋友一定有过这种体验&#xff1a;一个任务本身不难&#xff0c;但琐碎得让人崩溃。比如新项目要为三套环境生成配置文件&#xff0c;每个环境有几十个占位符要替换&#xff1b;比如要批量把几百个JSON转成CSV&#xff0c;字段映射还各不相同&#xff1b;再比如写…

作者头像 李华
网站建设 2026/9/29 6:29:14

2026年CTF趋势与备考攻略:AI Agent、MCP与缝合题型全解析

2025年我打了差不多四十场CTF&#xff0c;线上为主&#xff0c;中间也冲过几次线下赛。把这一年的题放在一起复盘&#xff0c;最明显的变化不是分数高低&#xff0c;而是出题逻辑变了&#xff1a;AI开始真正参与答题&#xff0c;MCP协议被摆上台面&#xff0c;以前那种单考一个…

作者头像 李华
网站建设 2026/9/29 6:28:38

sd-scripts 中 OFTv2 与 BOFT 正交微调适配器训练完全指南

深度学习计算机视觉媒体生成模型训练微调 【免费下载链接】sd-scripts 项目地址&#xff1a; https://gitcode.com/gh_mirrors/sd/sd-scripts 点击查看 免费下载 本文基于 sd-scripts 仓库中的 train_network_oft_boft.md&#xff0c;系统讲解如何在 train_network.py&#xf…

作者头像 李华