ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零构建大语言模型与推理能力:从GPT复现到思维链强化学习

从零构建大语言模型与推理能力:从GPT复现到思维链强化学习 很多工程师第一次搜“from scratch”这个词大概率是看到 Sebastian Raschka 那本《Build a Large Language Model (From Scratch)》之后手痒了。我最初也以为这本书只是把 GPT 架构用 PyTorch 又抄了一遍真正按它的路线把 tokenizer、数据管道、注意力机制、训练循环、推理采样逐行跑通之后才发现这套“从零”的价值不在于让你丢掉 HuggingFace而是让你把黑盒变成了灰盒——你知道每一层张量为什么是那个形状知道 loss 曲线为什么在这一步突然塌下去。当时还有一个搜索热词是“build a reasoning model from scratch”。这说明很多人已经不满足于“训练一个会续写的模型”而是想要一个“会思考的模型”。这条路线比单纯复现 GPT 至少多出三个环节推理轨迹数据怎么构造、监督微调怎么注入思维链、强化学习怎么把“试错”变成“推理能力”。这篇文章就沿着这两条线索展开——先用一本书的骨架把 LLM 从零立起来再回答“推理能力到底从哪里来、怎么用工程手段把它造出来”这个更进阶的问题。1. 内容整体设计与思路拆解1.1 想清楚“从零”到底要拆到什么程度“from scratch”在 AI 工程里其实有三个层次很多人没区分清楚就开始动手结果要么卡死要么走偏第一个层次是“不调现成模型”。你不用 OpenAI 的 API也不直接 load 一个 LLaMA 权重而是自己初始化一个模型、自己喂数据、自己训出来。这个层次只要一张消费级显卡就能玩难点在工程组织不在理论。第二个层次是“不抄现成实现”。不用 transformers 库里的GPT2LMHeadModel而是用 PyTorch 的nn.Module一行行把嵌入层、多头注意力、前馈网络、层归一化、残差连接搭出来。这个层次会逼着你回答很多平时懒得想的问题为什么 GPT-2 的权重标准差是0.02为什么第一个 token 的pad会被 mask 掉为什么训练时causal mask要同时处理 attention score 里的-inf和 Softmax 的数值稳定性第三个层次是“从数据源头重来”。连 tokenizer 都要自己在语料上训练词表大小、BPE 合并规则、special token 的分配都要自己定。Raschka 那本书的特别之处在于它把这三个层次全串起来了训练出的 GPTModel 参数规模只有 1.24 亿左右但结构上和生产级 1.5B 模型是同构的。我的建议是别把“从零”理解成“从发明 Transformer 开始”。你不需要自己推导 attention is all you need不需要重新发明 AdamW。真正的“从零”是亲手把关键链条完整过一遍——数据流、张量流、梯度流、采样流——让整个系统在你的脑子里变成一条可解释、可复现、可改造的流水线。这本书给了这套流水线的标准施工图你要做的是亲手浇筑一遍。1.2 为什么这条路线值得走复现成本与认知收益的杠杆比很多人问有 HuggingFace 全家桶何必自己造轮子我的回答是调用框架解决的是“能不能跑”的问题从零复现解决的是“错了知道去哪查”的问题。举一个真实的例子。我在训练自己的小模型时遇到 eval loss 在 3000 步之后突然反弹训练 loss 却一路下降。用 transformers 直接训你大概率只会怀疑超参数。但自己写过 attention 之后你会第一时间想到是不是因果 mask 在某些 batch 里没有正确上三角化是不是位置编码的unsqueeze维度广播错了导致某些 token 看到了未来信息这种“猜得到原因”的能力是用别人的库学不来的。再算一笔成本账。用 1 亿参数级别的模型在一张 24GB 显存的卡上batch size 开到 16、序列长度 512跑 5000 步也就一两个小时。这个成本换来的是对整个 Transformer 架构的肌肉记忆。等到你要设计 MoE 路由、要改 GQA 注意力、要调 RoPE 位置编码时你会发现这些所谓“进阶结构”都是基础模块的排列组合——你早就亲手写过它们的原型了。另一个常被忽视的收益是调试能力。自己从零实现的模型你可以 print 任意中间张量、手动检查 attention 权重分布、临时把某些层替换成恒等映射来做消融实验。这种自由度在任何高层框架里都要绕很多弯路才能获得。可以说从零复现是通往 AI 工程深水区最直接的入场券。2. 核心细节解析与实操要点2.1 数据管道从文本到张量的最后一公里Raschka 书中贯穿始终的一条主线是数据处理。他先讲 BPE tokenizer 的训练再讲如何用torch.utils.data.DataLoader组装 batch。这里有几个关键点我复现时踩过的坑比想象中多。第一个坑是 tokenizer 的词表大小与 embedding 矩阵的同步。如果你自己训练 BPE 词表得到vocab_size 50257那么 embedding 层的 shape 就是(50257, 768)。但很多人在加载预训练词表时用了不同的 special token 设置导致词表里多出几个 tokenembedding 矩阵与 softmax 输出层的维度对不上。正确做法是在构建模型之前先用 tokenizer 试编码一段文本确认词表大小再动态传给模型构造函数。写死常量是万恶之源。第二个坑是序列截断与 padding 的策略。GPT 类模型是 causal decoder理论上不需要attention_mask——因为因果 mask 已经保证了每个位置只能看到前面的 token。但当你自己组织 batch 时如果某个序列长度不足block_size你得决定是丢弃还是 padding。Raschka 的做法是直接截断或填充到固定长度然后用ignore_index-100把 padding 部分的 loss 屏蔽掉。这个处理我在很多代码库里见过不同变体最稳妥的方式是三种同时做padding 到 batch 内最大长度、attention mask 保留 padding 位置的显式 mask、loss 计算时 ignore padding。三维联防才不会崩。第三个值得说的是数据流。很多人以为从零训练大模型要准备 TB 级语料其实针对这个项目用几本公开书籍的纯文本就够训出“效果不错”的玩具模型。关键在于数据的重复使用策略。原书用的语料不算大所以每个 epoch 模型都会反复见同一批数据这意味着你必须在训练循环里做随机打乱否则模型会按顺序“背”下语料测试时泛化能力惨不忍睹。我用random.shuffle配合seed固定实测下来 eval loss 比不 shuffle 低了不少。2.2 核心骨架多头注意力与前馈网络的工程实现多头注意力是整个项目里最值得手写、也最容易写错的部分。我先说数学结构再讲工程坑。单头注意力的核心是Q K.T / sqrt(d_k) mask然后过 Softmax再 V。多头就是把d_model维度的 Q、K、V 切成num_heads份分别做注意力再拼接。工程上的高效做法是几步合一先把输入的(batch, seq_len, d_model)通过权重矩阵映射成(batch, seq_len, d_model)再 reshape 成(batch, seq_len, num_heads, head_dim)然后交换维度变成(batch, num_heads, seq_len, head_dim)这样一次批量矩阵乘法就能算出所有头的 attention。我在复现时犯过一个经典错误reshape和transpose用混了。reshape会把张量按内存顺序重新切块而transpose只是交换维度视图。如果先view成(batch, seq_len, num_heads, head_dim)再transpose到(batch, num_heads, seq_len, head_dim)得到的“多头”其实是按连续 chunk 切的和期望的按 head 切不一致。正确写法是先 reshape 再 permute或者直接在nn.Linear之后用view一次到位但必须清楚每一步的 shape。我建议每写一个模块就手动构造一个小输入打印中间 shape 做断言。前馈网络相对简单nn.Linear(d_model, d_ff)接 GELU 再接nn.Linear(d_ff, d_model)。但要注意 GPT-2 原文用的 GELU 是近似形式x * sigmoid(1.702 * x)PyTorch 的nn.GELU()默认是精确形式两者数值略有差异但不影响训练收敛。如果追求与原始实现一致可以手工定义近似 GELU实测下来差异在 1e-3 量级对最终指标没有实质影响。2.3 训练循环与采样连接理论与产品的两个接口训练循环看起来不难但有三个细节决定成败。第一是交叉熵损失的计算。语言模型的任务是预测下一个 token所以标签是输入序列右移一位。工程上是把logits的[batch, seq_len, vocab_size]和标签的[batch, seq_len]都拉平然后调nn.CrossEntropyLoss()。这里必须把标签中的 padding 位置设成-100否则 padding token 也会参与 loss 计算模型会拼命去预测[PAD]。第二是梯度累积与混合精度。一张消费级显卡的 batch size 通常开不大但大 batch 又是收敛质量的重要保证。梯度累积的做法很简单每accumulation_steps次 forward/backward 才执行一次 optimizer.step() 并清空梯度。配合torch.cuda.amp的GradScaler可以把显存占用大幅压下去。我在 24GB 卡上试过不开 AMPbatch 16 就快爆显存开了 AMP 再加梯度累积等效 batch 可以做到 64 以上。AMP 的关键坑是不要在 loss.backward() 前手动.item()取 loss否则会中断自动缩放。第三是采样参数。训练完模型之后你不可能每次都用 argmax 贪婪解码否则生成的内容单调重复。Raschka 书里给了 temperature 和 top-k 的简洁实现。Temperature 的物理含义是调节 Softmax 分布的尖锐程度logits / temperature温度越低越确定越高越随机。Top-k 是只从概率最高的 k 个 token 里重新归一化采样。实际体验下来temperature1.0、top_k50是童话风格生成的甜点参数但不同语料的最优参数差异很大建议在验证集上做小规模网格搜索。3. 实操过程与核心环节实现3.1 从零到收敛训练一个小规模 GPT 的完整配置我按“从零”路线实际训练过一个 1.24 亿参数的 GPT 模型把关键配置列出来供参考配置项数值说明词表大小50257BPE 词表与 GPT-2 一致嵌入维度 d_model768也是注意力输出的维度注意力头数12每个 head 维度 64层数12Transformer block 堆叠数序列长度512训练时统一截断到 512训练步数5000约 1.5 个 epoch数据不大优化器AdamWlr3e-4weight_decay1e-2学习率调度warmup线性衰减warmup 500 步批大小16 物理 / 128 等效梯度累积 8 步混合精度bf1624GB 显存无压力这套配置跑完验证集 loss 能稳定降到 3.2 左右生成短文本时语法基本正确、主题连贯性在短段落内可以接受。如果把序列长度加到 1024显存占用翻倍收敛速度反而更慢因为长序列的注意力计算量是平方增长的。所以在有限算力下我建议优先保 batch size 而不是序列长度——更大的 batch 对梯度估计的稳定性帮助远大于单个样本上下文长度增加。3.2 训练脚本的骨架与关键函数实现下面给一段可以直接跑通的训练循环骨架核心逻辑省略数据加载细节这段代码我在实际项目里改了很多遍标注了最容易出错的位置import torch import torch.nn as nn from torch.cuda.amp import GradScaler, autocast def train_model(model, train_loader, val_loader, config): optimizer torch.optim.AdamW( model.parameters(), lrconfig[lr], weight_decay1e-2 ) scheduler torch.optim.lr_scheduler.LambdaLR( optimizer, lr_lambdalambda step: min((step 1) / config[warmup_steps], 1.0) * (1 - step / config[total_steps]) 1e-8, ) scaler GradScaler() global_step 0 for epoch in range(config[num_epochs]): for batch in train_loader: input_ids batch[input_ids].to(config[device]) # 标签是右移一位的目标序列 targets batch[target_ids].to(config[device]) with autocast(): logits model(input_ids) # [batch, seq_len, vocab_size] loss nn.CrossEntropyLoss(ignore_index-100)( logits.view(-1, logits.size(-1)), targets.view(-1), ) scaler.scale(loss).backward() # 梯度累积每 accumulation_steps 步更新一次 if (global_step 1) % config[accumulation_steps] 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad() scheduler.step() global_step 1 if global_step % config[log_interval] 0: val_loss evaluate(model, val_loader, config) print(fstep {global_step}: train_loss{loss.item():.4f}, val_loss{val_loss:.4f}) return model这段代码有两个关键细节。一是ignore_index-100这是处理 padding 的标准做法必须保证构造targets时 padding 位置填的是-100而不是 0。二是 AMP 的scaler.scale(loss).backward()与scaler.step(optimizer)必须配对如果只在部分步数调用scaler.step在梯度累积的非更新步别忘了scaler.update()也要跳过否则会出现缩放因子错乱。3.3 推理能力从哪来从“续写”到“思考”的三阶段路线如果你只是训练一个 GPT 模型出来它能做的是“流畅地续写”而不是“推理”。让模型具备推理能力是当前社区最热门的方向。综合“build a reasoning model from scratch”的搜索热度和我自己的实验经验我建议的路线分为三阶段每一阶段的工作量和算力需求都不同。第一阶段是基础语言模型。也就是前面 3.1 节的 GPT 骨架训练。它负责提供世界知识、语法能力和上下文建模能力。这个阶段的模型是“会说话的鹦鹉”——能接话但容易被带偏遇到数学题会一本正经地胡说。第二阶段是推理轨迹的监督微调SFT。核心思路不是让模型“学知识”而是让模型“模仿推理的过程”。你需要准备一批带思维链的数据每个样本包含问题、中间推理步骤、最终答案。在训练时把“问题思考过程答案”拼成一个长序列用标准的语言模型目标训练模型。这个阶段的关键是数据质量远重要于数据量。我实测过用 2 万条高质量推理轨迹做 SFT效果远好于用 20 万条从开源数据集里粗暴拼接的噪声数据。另一个关键是推理链的长短控制——如果所有样本的思维链都特别长模型会学会“废话连篇”的推理如果太短则学不到中间步骤的好处。我在实践中会把思维链控制在 3 到 8 个中间步骤同时混合一些“短链直接给答案”的样本让模型学会区分简单和复杂问题。第三阶段是推理强化学习Reasoning RL。这是目前最前沿、也最难落地的环节。思路是在 SFT 模型的基础上用强化学习让模型在推理任务上自己“试错”。典型做法是定义一组可自动评判的推理任务数学题、代码题、逻辑题让模型生成多个推理路径根据答案对错或格式规范性给 reward再用策略梯度类算法如 PPO 或 GRPO更新模型。这个阶段对工程能力的要求很高但收益也是巨大的——模型会涌现出“自我纠错”“多路径验证”“反思”等行为。我在尝试这个三阶段路线时遇到过两个很实际的问题。一是 SFT 之后模型很容易“过拟合到思维链模板”——遇到不会的问题也强行输出一大段逻辑不通的推理。缓解方法是混合一定比例的无思维链样本保持模型在简单任务上的直接响应能力。二是 RL 阶段 reward 设计不当会让模型“作弊”——比如通过修改输出格式来骗过规则判分器。解决方法是尽量用可验证的自动评测如代码的单元测试、数学题的符号答案比对少用模糊的“模型打分”。3.4 评估你的“推理模型”比 loss 更重要的指标训练完一个推理模型你不能只盯着 loss 曲线因为 loss 下降不等于推理质量上升。我常用的评估视角有四个Pass1 准确率每个问题只生成一次答案的准确率。这是最贴近真实使用的指标。SFT 之后这个指标会显著提升但如果发现 pass1 提升但 passk生成多个答案取最好不提升说明模型其实是在“碰运气”并没有真正学会推理。答案一致性同一个问题生成多次看答案的分布是否稳定。如果模型在同一个数学题上时而答对时而答错说明推理链不稳定这时候要检查训练数据里是不是存在“对了但推理过程是错的”样本。推理链路质量人工抽样检查中间步骤的逻辑连贯性。这个指标没法自动化但至少每周抽 50 条看一遍。我见过模型在训练集上 pass1 高达 80%抽样却发现大量“伪推理”——先写了一个错误的中间结果最后蒙对了一个答案。这种模型一换数据集就崩。评估的另一个重点是区分“记忆”和“推理”。我常用的方法是构造一组数字变换型题目比如“把 123 反过来写再乘以 2 是多少”这类题目在训练语料里几乎不可能出现。模型如果能在这种全新题目上做对才说明它是真的在按规则推理而不是在检索记忆。4. 常见问题与排查技巧实录4.1 训练不收敛的排障顺序从零训练模型最容易遇到的问题是 loss 不降或直接 diverge。我的排查顺序是固定的从“最笨的检查”到“最深的怀疑”先从数据开始检查。把 batch 里 input_ids 和 targets 打出来人工看一遍确认标签确实是“下一个 token”而不是“当前位置 token”。这个低级错误我犯过不止一次——DataLoader 里忘了shift导致模型学的任务是“预测自己”loss 怎么调都降不到 4.0 以下。再看 attention mask。如果 causal mask 实现错误模型偷看到了未来 token训练 loss 会异常低但 eval 会崩得惨不忍睹。快速验证方法是构造一个长度为 2 的输入用手算第一行的 attention score 应该和第二行的未来 token 无关打印出来看是否等于-inf。最后检查数值稳定性。如果 attention score 在d_k64时直接拿QK.T不除sqrt(64)Softmax 容易饱和到 one-hot梯度消失。把缩放加上之后 loss 通常会立刻健康起来。4.2 显存不足和训练速度慢的工程解法显存溢出OOM是最常见的工程问题。第一优先方案是开梯度累积 混合精度前面 3.1 节已经给了具体配置。第二优先方案是减小 batch size 并调大累积步数保持“等效 batch size”不变。第三优先方案是检查是否有张量意外被保留在计算图里——比如在训练循环外不小心用了loss.item()之外的带梯度变量做日志。我在调试时有一个习惯在关键节点用torch.cuda.max_memory_allocated() / 1e6打印显存峰值对比理论估算值很快就能定位哪一层消耗异常。训练速度慢的元凶通常是数据加载来不及喂饱 GPU。解决办法是DataLoader开num_workers4甚至8并把prefetch_factor调到 4 以上。如果你发现 GPU 利用率经常低于 50%先别急着调模型先看数据管道是不是瓶颈。4.3 推理模型独有的坑思维链模板泄漏与奖励作弊训练推理模型还有一个非常隐蔽的问题——思维链模板泄漏。如果 SFT 数据里所有样本都用“我们来一步步思考\n第一步...\n第二步...\n最终答案...”的固定模板模型会学到一种“自动续写思维链”的惯性。遇到并集、交集这类本身很简单的题目时它也会强行展开一段冗长思维链反而把简单答案带偏。缓解方案是在 SFT 数据中混合 30% 以上的“直接答案”样本并随机打断某些样本的模板格式。强化学习阶段的奖励作弊问题前面说过这里再补充一个我踩过的实坑给“思考长度过长”做负向惩罚时如果惩罚系数太大模型会学会“用极短的无意义输出骗取时间分”反而把推理能力抹掉了。正确做法是只惩罚格式违规如没有输出答案分隔符不对推理长度做直接惩罚而是通过采样温度和 top-p 控制长度分布。4.4 常见问题速查表现象最可能原因快速排查与对策train loss 不降标签未 shift / 学习率过大 / 数据未 shuffle打印 batch 的 input_ids 与 targets 对比lr 降到 1e-4 以下确认 DataLoader 设置 shuffletrain loss 降但 eval loss 发散过拟合 / 数据泄漏检查验证集是否与训练集有重叠增大 dropout 或提前早停显存 OOMbatch 太大或 AMP 未开启梯度累积 AMP减小 batch检查是否有张量未 detach生成内容全是重复循环采样温度太低或 top-k 太小提高 temperature 到 1.0-1.2top-k 调到 50-200推理模型“话痨但答不对”SFT 思维链模板过拟合混合直答样本减少固定模板比例检查训练集中“伪推理”样本RL 训练时不收敛reward 稀疏或作弊改用可自动验证的 reward对格式违规加负项降低策略更新步幅5. 一些从实操里长出来的经验最后分享几条不是书上写的、但我在多次实验里反复验证过的体会。关于“from scratch”这件事我的判断是它最珍贵的产出不是一个模型而是带着你走了一遍“从问题到架构再到实现”的完整闭环。以后你再遇到新的结构无论是 MoE、RWKV 还是 Mamba你都具备一种“拆骨架”的迁移能力——先问这个模型改了哪一层、动了哪个张量流、数据管道需要做什么适配而不是先去搜别人封装好的实现。关于推理模型的路线我也越来越确信一件事先有基础模型才有推理能力先有高质量推理数据才有稳定的推理行为先有可靠的自动评估才有安全的强化学习。每一步都急不得。我不建议一上来就追 RL 的最新论文而是先把 SFT 这一步做扎实。我见过太多团队在 SFT 阶段数据质量不过关的情况下硬上 RL最后得到的是一个“会输出格式规范的废话”的模型。另一个很实用的经验是日志和实验记录。从零训练的项目中模型结构、数据版本、超参数三者形成了高度耦合的关系。每改动一个配置都要在日志里记录当时的 loss 曲线和采样样例。我最初偷懒不做系统记录结果两周后发现某个“更好的模型”其实是因为数据文件被意外覆盖了。现在我坚持用 WB 或 MLflow 做实验追踪哪怕只是个人项目也值得花这个时间。如果你正在读那本书或者准备踏上这条路我的建议是不要追求一次跑完所有章节而是每完成一个模块就停下来做一件事——关掉书从空白文件开始重新把这个模块默写一遍。第一次默写会很痛苦但正是这种痛苦把“看懂了”变成“真会了”。这大概是“from scratch”这个短语最朴素也最诚实的含义。
返回列表