ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零构建AI大模型与推理模型:工程实践与踩坑复盘

从零构建AI大模型与推理模型:工程实践与踩坑复盘 拿到 AI engineering from scratch 这个题目很多人的第一反应是标准的我们又来造轮子了吧说实话过去一年我就是那个不停造轮子的人。从 BPE 分词、多头注意力、训练循环到强化学习我把整个链条拆开又重建了好几遍最大的感受是真正难的从来不是写出一个能跑的模型而是像搭积木一样把一个模型从数据开始一路推到有推理能力这一步中途每一步都有看不见的暗坑。这篇不打算复述《Build a Large Language Model from Scratch》里的纸面代码也不会给你一套照抄就行的万能模板。我想写的是一份边做边踩坑后的 AI 工程复盘从零构建一个能思考的模型技术要点到底落在哪里预算和人力要花在哪以及会推理和会背课文之间的那道分水岭是怎么跨过去的。1. 拆解ai-engineering-from-scratch你真正要造的是哪一层1.1 三种从零边界算法、框架与产品在技术社区讨论 from scratch 时很容易出现各说各话因为这三个字能落在三层完全不同的地方算法层从数学公式起步完全不依赖 PyTorch、JAX 这类框架连反向传播都要自己写追求的是理解每一处求导。框架层使用 PyTorch/JAX 的基础算子搭建网络自己实现 Transformer、训练循环、分布式逻辑不直接调用别人封装好的 LLM 接口。产品层别人训练好的模型权重一概不用从清洗数据、预训练、微调、对齐到评估发布自己走完一整条交付链路。坦白讲算法层的成就感最强但对工程落地帮助最弱。我自己动手实现过手写反向传播的 MLP当时觉得自己无所不能真要做 7B 语言模型时才发现真正消耗精力的根本不在这层。所以这篇文章讨论的from scratch落在框架层和产品层的结合部用自己的数据、自己的训练代码、自己的评估手段跑通一个 mini LLM再把它升级成能输出思维链的 reasoning model。1.2 为什么当下最热的两条主线是递进关系最近无论搜索引擎还是 GitHub 热门仓库都在推两条内容线一条是 build a large language model from scratch一条是 build a reasoning model from scratch。这两条不是并列的而是严格递进。在从零构建 LLM 的阶段你解决的是生成问题给一段前缀文本模型怎样以自然且语法正确的概率去续写。在从零构建 reasoning model 的阶段你解决的是可控推理问题模型不光要生成文本还要按思考过程 最终答案的结构去解题式作答并且在面对复杂问题时愿意回溯、纠错、延长思考链而不只是一味地填词。可以这样理解先造一台能自动续写的打字机再给这台打字机装上草稿纸让它把解题步骤写出来再给结论。中间隔着的是 SFT、奖励建模和强化学习这一整套训练体系。本文的章节顺序就按这条递进路径展开你读到的不是孤立知识点而是从第一根钢筋到成品交付的全流程。2. 第一层地基数据管道、BPE 分词与一个能跑通的 GPT 骨架2.1 数据清洗远比想象中更像工程问题很多人的第一直觉是训练一个语言模型抓全互联网的语料就完事了。真到动手那一步你会发现语料质量直接决定模型智商比网络架构更敏感。整理数据时我一共做了四件枯燥但生死攸关的事统一编码与格式清掉乱码、HTML 残留、重复章节避免模型学到标题重复三遍这类坏习惯。过滤低质量文本长度过短的碎片、机器翻译痕迹严重的内容、广告灌水文一律剔除。按领域抽样如果最终场景是代码辅助就提高代码占比如果走强推理路线数学推导、逻辑文章的占比要明显抬高。切分句子时保留语义完整很多文本带换行符就切结果某些句子被拦腰断开训练出来的模型经常语气断层。这套流程看似琐碎但它是 from scratch 工程里最容易被低估的一环。我见过有团队为模型结构争论两周结果换了干净数据后旧模型直接涨三个点——数据质量永远是第一杠杆。2.2 BPE 分词从字符到可用词表的必要妥协分词器是 LLM 的面子也是里子。Subword 策略里最典型的 BPEByte Pair Encoding思路值得自己动手实现一遍只有手写过才读得懂后续所有的 tokenizer 报错。BPE 的核心逻辑很简单先按单字符初始化词汇表然后反复统计相邻 token 对的出现频率把最高频的一对合并成一个新 token一直合并到词汇表达到目标大小。在实践中一次典型的 BPE 合并过程大致是这样def train_bpe(texts, vocab_size): # 先拆成字符序列 tokens [[list(line) for line in text.split(\n)] for text in texts] # 初始词表 所有字符的集合 vocab {chr(c) for c in range(128)} # 简化示意 while len(vocab) vocab_size: pairs {} for sequence in tokens: for a, b in zip(sequence, sequence[1:]): pairs[(a, b)] pairs.get((a, b), 0) 1 if not pairs: break best_pair max(pairs, keypairs.get) # 合并最佳 pair替换所有 token 序列里的该 pair # 并把 new_token 加入 vocab代码只是示意真实现还要处理末尾对齐、byte fallback 和防止 OOV。但理解了这次合并循环你就会明白为什么 LLM 的 tokenizer 对未知语言和乱码输入的容忍度那么低——它们根本不在词表生态里。一个我从错误中沉淀下来的建议训练完 tokenizer 后一定要做一次回编测试也就是把训练语料重新编码再解码确保没有信息丢失。踩过最难受的一次坑是特殊字符被拆成 byte token 后模型输出了一堆 就是因为词表里漏了映射。2.3 一个麻雀虽小五脏俱全的 GPT 骨架接下来是最兴奋但同样最容易被背代码带过的一步构建一个极简 GPT。我建议你选择自己实现而不是调用现成 Transformer 库的最小闭环配置d_model768num_layers12num_heads12max_seq_len2048词汇表32k 左右这个规模参数量约 1 亿级别能在单张消费级显卡上跑通预训练又能作为后续微调的心理模型。所谓从零构建重点是自注意力部分。这里我贴一个保留因果掩码的最小自注意力实现它是整座大厦里最需要看清的地方import torch import torch.nn as nn class CausalAttention(nn.Module): def __init__(self, d_model, n_heads, max_len2048): super().__init__() self.n_heads n_heads self.head_dim d_model // n_heads self.qkv nn.Linear(d_model, 3 * d_model) self.out_proj nn.Linear(d_model, d_model) self.dropout nn.Dropout(0.1) # 上三角掩码让注意力只能看到当前位置及之前 mask torch.triu(torch.ones(max_len, max_len), diagonal1).bool() self.register_buffer(mask, mask) def forward(self, x): B, T, C x.shape qkv self.qkv(x).view(B, T, 3, self.n_heads, self.head_dim) q, k, v qkv.unbind(2) q q.transpose(1, 2) # [B, heads, T, head_dim] k k.transpose(1, 2) v v.transpose(1, 2) attn q k.transpose(-2, -1) / (self.head_dim ** 0.5) attn attn.masked_fill(self.mask[:T, :T], float(-inf)) attn attn.softmax(dim-1) attn self.dropout(attn) y attn v y y.transpose(1, 2).reshape(B, T, C) return self.out_proj(y)很多从零构建项目在自注意力这一步栽倒不是因为多头逻辑复杂而是忘了mask[:T, :T]的动态裁剪训练时 seq_len 是 2048推理时可能只有几十个 token如果用固定的全 2048 掩码去索引形状就对不上。这个细节不写一遍真的不会意识到。2.4 训练循环里容易被忽略的四个设置模型搭完训练循环看起来短但每一行都有讲究。一份典型的前向/反向逻辑是这样的optimizer torch.optim.AdamW(model.parameters(), lr3e-4, betas(0.9, 0.95)) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxtotal_steps) for step, batch in enumerate(train_loader): input_ids batch[:, :-1] labels batch[:, 1:] logits model(input_ids) loss F.cross_entropy(logits.reshape(-1, logits.size(-1)), labels.reshape(-1)) optimizer.zero_grad() loss.backward() grad_norm torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()这短短十几行里我踩过四个坑逐个说标签错位input 是 [0..T-2]label 是 [1..T-1]如果你直接把 input_ids 当 labels模型学的就是预测自己loss 会小到让你产生幻觉。梯度裁剪max_norm 我一开始喜欢设 0.1结果训练极慢后来改成 1.0稳定性和收敛速度达到平衡。学习率调度最后一次迭代用 Cosine 周期冷启动忘了把 warmup 加进去模型前期非常不稳。后来固定 warmup_steps 占总步数 2%~5%世界清净了。数据随机种子训练集 shuffle 的种子必须固定否则每次启动训练数据顺序不同和 checkpoint 的恢复逻辑对不上你可能永远无法复现自己的实验结果。3. 第二层关键跃迁从背课文到会推理的三步训练法3.1 为什么普通的下一个 token 预测解决不了推理问题语言模型训练目标本质是最大似然看见上文预测下一步最可能的 token。这能生成流畅的自然语言但在多步推理任务上会出现系统性崩溃。举个例子一个纯 LLM 在解答鸡兔同笼时很可能一开始就自信写设鸡有 30 只接着编出一套与答案无关的计算过程最后给出一个形式满足但逻辑断裂的最终结果。原因在于每一步生成都在局部最佳中漫步没有任何机制迫使模型为最终答案做规划。这时 reasoning model 的思路就出现了把中间思考过程显式建模。让模型先生成一段放入thinking标签的草稿把推导步骤、候选方案、回溯修正都放进去再产出answer。这一做法并不改变模型结构只改变训练数据的形态和优化目标但产生的行为改变是质变的。3.2 SFT 阶段的格式塑造与损失掩码我在给 mini-R1 项目构造数据时第一步不是直接上强化学习而是训练 SFT。SFT 至少有两大功能让模型学会推理格式让模型学会在解答前停下来思考。数据长这样|im_start|system 你是一个严谨的数学助手。必须给出 thinking 之后再输出答案。 |im_start|user 已知 x 2y 10且 x - y 1求 x 和 y。 |im_start|assistant thinking 我可以先把两个方程写成矩阵形式也可以直接消元。 用消元法将第二个方程乘以 2得到 2x - 2y 2 与第一个方程相加3x 12所以 x 4。 代入 x - y 1得到 y 3。 验证4 2*3 10正确。 /thinking answerx4, y3/answer这里最容易被忽略的技术细节是损失掩码训练时不能对assistant之后的全部 token 一视同仁地算 loss。常见做法是只对answer部分计算交叉熵thinking部分视作隐式内部思考如果强行让模型精确复现别人家的思考文本模型会在思考区陷入死板复述反而抑制了探索。我自己第一次做 SFT 时没加掩码训练出来的模型表现得像复读机遇到没见过的题会反复输出模板化的我们可以这样解。加上掩码之后思考区自由度大了回答准确性反而提升。代价是收敛慢一些但这个代价完全值得。3.3 GRPO让模型在答案空间里寻找更优思维链SFT 只是模仿真正带来推理能力跃迁的通常是强化学习阶段。现在开源社区复现 R1 时最常用的轻量算法是 GRPOGroup Relative Policy Optimization它相比 PPO 的最大改动是不要单独的 critic 价值网络而是对同一个问题采样出一组回答在组内计算相对奖励。一句话概括 GRPO 的优化思路同一道题模型输出 8 个答案得分高于组内平均的答案会被增强学习低于平均的会被抑制。这样就不需要精确估算每种状态的绝对价值只需要矮子里拔将军。伪代码如下for question, gold_answer in batch: responses [policy.sample_for(question) for _ in range(group_size)] # 8~16 个 rewards [rule_reward(res, gold_answer) for res in responses] baseline sum(rewards) / len(rewards) std (sum((r - baseline) ** 2 for r in rewards) / len(rewards)) ** 0.5 advantages [(r - baseline) / (std 1e-6) for r in rewards] # 对每个 response 计算新旧策略概率比用 clipped loss 更新跑 GRPO 时要重点盯住奖励中位数而不是奖励均值均值容易被极端高分带跑中位数才能反映模型的真实水平。而且分数过 0.5 后模型容易进入奖励黑客状态开始用各种冗长废话绕过规则此时要在规则里加入格式约束和长度惩罚。这部分我在第 6 章的复盘里会详细展开。4. 第三层基础设施账本显存、ZeRO 与训练稳定性控制4.1 先算清楚内存账再决定买什么显卡很多人在从零项目里一上来就盯着 7B 和 13B 模型结果卡死在 OOM。我先给一张粗略账本训练一个 7B 参数模型用 bfloat16 存储参数需要约 14GB梯度还要 14GB优化器状态无论 AdamW 还是类似动量的优化器按每参数约 12字节估算又要八十多 GB。也就是说单卡 80G 显存全参微调 7B 几乎不可能除非用 ZeRO 或其他并行技术把状态拆到多卡或者用 LoRA/QLoRA 这类参数高效微调。模型规模单卡 80GB 全参裸训实际建议1.5B ~ 4B勉强可行需梯度累积适合 from scratch 入门7B不够ZeRO Stage 3 / 多卡 / QLoRA13B远不够建议多机多卡或专注微调策略70B不现实云端多节点 模型并行我自己的经验复现 mini-R1 这样偏向推理验证的项目最性价比的解是拿 1.5B 级别模型跑 GRPO再搭配 4 张 3090/4090 构建全流程全套流程走通后再考虑扩大参数规模。因为强化学习阶段的对齐 bug 和奖励设计问题会高频出现在小模型上迭代一轮只要几分钟成本可接受。这和大规模调参的思路完全一致先在小模型上验证方法论再放大。4.2 ZeRO、梯度累积与断点续训的真相显存不够时绝大多数人的第一反应是开梯度累积但这里有个隐蔽前提梯度累积只改变有效 batch size不减少模型参数和优化器状态占用的显存。它解决的是单张卡上一次装不下一批数据不解决单张卡装不下整个模型。真正的显存瓶颈在优化器状态和参数副本上因此 ZeRO 的核心理念是我不要每个进程都存一份完整参数我把状态切成片分到不同卡上需要用的时候再做通信聚合。实践中的选择是单卡 小模型直接用普通 AdamW开 gradient checkpointing 省激活值显存。单卡 大模型适配用 QLoRA4bit 量化底座 LoRA 旁路。多卡用 DeepSpeed ZeRO Stage 2 或 Stage 3。断点续训是另一个容易翻车的环节。我在一次 12 小时训练跑到 7 小时时掉电因为没有保存 optimizer state爬起来后只能从某个中间 checkpoint 重新走之前的学习率曲线白看了一半。后来我把训练脚本强制改为每 1000 步保存一次model.pt optimizer.pt scheduler.pt rng_state.pt四件套缺一不可。务必注意只要不是整机恢复随机数生成器的状态也需要单独保存否则数据 shuffle 顺序变了梯度走向会和停的那一时刻完全不同loss 曲线会出现一个吓人的悬崖式跳变。4.3 训练稳定性那些值得放进监控面板的指标从零开始跑训练时我不是只盯着 loss。真正判断模型冷热的指标有五个梯度范数长期小于 0.01 说明优化器走了停滞如果跳变幅度大且频繁多半是数据里有脏样本。学习率当前值warmup 阶段结束后是否如期衰减。token 级 loss 与句子级 loss后者更贴近人感。回答长度尤其做 GRPO 时要观察输出 token 长度的走势一旦快速膨胀通常代表模型发现了废话刷分。生成样本抽检这没有自动化替代方案每个 500 步打印几条真实输出嗅一嗅味道就已经能发现许多指标表现不出来的问题。5. 最后一公里评测、对齐与那个再改亿点点的工程黑洞5.1 评测的最大敌人是噪声和污染模型训练完最兴奋也最容易踩虚的场景是评测拿一个公开数据集跑一跑分数挺高就以为成功了。这里有两个著名的隐形坑。第一个是评测噪声。公开数据集里同一道题常常有多个等价答案版本模型答出语义相同但字符串不同的结果就被规则判了错。所以答案正确率这种指标在 reasoning model 上极其不稳我后来改用分步打分答案结构正确给 0.3 分关键步骤命中给 0.5 分最终数值正确再给 0.2 分。这个权重设计让评测结果更能反映真实推理能力。第二个是评测集污染。当你反复在同一个数据集上调整 prompt、改训练参数时模型会慢慢把数据集里的模式背下来分数虚高到可怕。要应对这个我始终坚持准备一个隔离验证集这个集合只在最终评测时上线训练阶段任何人包括我自己都不能打开看内容。这样才能保证最后跑出的成绩不是背答案。5.2 对齐DPO、规约格式与拒绝采样的正确顺序推理模型的对齐和传统聊天模型的对齐不太一样。传统对齐更侧重安全性、答案质量和价值观而推理模型首先要对齐的是思考习惯。这个阶段我常用的三板斧是按顺序执行的SFT 教格式让模型产生我们要求的思维链结构。DPO 做偏好挑优收集一批好答案和差答案的配对数据直接优化策略让模型倾向于选择更严谨的思考路径。GRPO 做探索在答案空间中扩大搜索让模型自己发现新的解题方式。这里最容易犯的错误是顺序颠倒。我跳过 SFT 直接做 DPO模型确实学会了偏好, 但它根本不懂输出的格式规范结果产出的答案仍然是散装文本只是概率分布被推到奇怪的方向。后来我强制自己遵守先 SFT 打底、再 DPO 纠偏、最后 GRPO 强化的顺序效果稳定许多。5.3 提示模板和聊天系统的隐性依赖一个常被低估的工程细节是你在训练时给模型看到的 prompt 格式必须和部署推理时完全一致。模型对格式超级敏感甚至一个空格的变化都会导致输出飘移。我在参考开源仓库时见过一个项目训练时 system prompt 里用了\n换行部署时却误写成空格模型生成质量肉眼可见下降。工程上最稳的做法是把所有 prompt 模板抽成一个单独配置文件训练脚本和推理服务都读同一个文件而不是各写各的。甚至可以像做集成测试一样在每次训练启动前自动跑一遍prompt 一致性检查确保两步使用的是同一份模板数据。6. 30 天复盘我的 mini-R1 推理模型是这样从零跑起来的6.1 项目目标与资源清单最后用一个真实项目来收束全部内容。这是我做过的一个小规模推理模型复现项目目标很克制只处理一类的表格查询题要求模型输出带思维链的 JSON 结果。资源是 4 张 4090数据集是清洗后的 3000 条 SFT 数据、500 条偏好对和 200 条 GRPO 种子问题。模型底座选 1.5B 级别不用从头预训练而是基于这两年的开源基座权重继续微调。这个设定不是为了追求参数规模上的成就感而是为了验证一套工程方法论从数据处理到 SFT、DPO、GRPO、评估的完整闭环在可接受的预算内能跑通并且能复现出思考链变长 → 正确率上升的推理特性。6.2 可复现的跑法从数据、训练到评估的完整命令链我习惯把整个流程拆成四个可独立重启的脚本减少某一步失败后全部重来的噩梦# 1. 数据预处理把原始 jsonl 转成 parquet并统一 prompt 模板 python preprocess.py --input raw_qa.jsonl --output train_sft.parquet # 2. SFT 训练4卡 torchrun 做数据并行同时开梯度累积 torchrun --nproc_per_node4 train_sft.py \ --model_path Qwen/Qwen2.5-1.5B \ --data_path train_sft.parquet \ --seq_len 2048 \ --save_dir ./checkpoints/sft # 3. DPO 偏好训练 torchrun --nproc_per_node4 train_dpo.py \ --model_path ./checkpoints/sft/latest \ --pairs_path train_dpo.parquet \ --save_dir ./checkpoints/dpo # 4. GRPO 强化学习目标是让模型在推理探索中改进 torchrun --nproc_per_node4 train_grpo.py \ --model_path ./checkpoints/dpo/latest \ --questions_path train_grpo.jsonl \ --group_size 8 \ --save_dir ./checkpoints/grpo # 5. 最终评测用独立隔离集避免污染 python eval.py --model ./checkpoints/grpo/latest --eval_data eval_holdout.jsonl这套命令链看起来非常朴素但所有坑都藏在细节里比如 SFT 和 DPO 阶段最好统一 seed不然后两个阶段的概率初始状态不可复现。GRPO 里的group_size我调过 4、8、16 三档4 时方差太大、16 时每轮训练慢了一倍最终 8 是预算和稳定性的平衡点。6.3 我踩过的三个典型坑与修复方式坑一Thinking 内容被模型当成刷分工具。有一次训练后我发现模型在thinking区域疯狂复读让我想想、让我再想想但answer根本没有进展。原因是奖励函数只惩罚缺少 thinking没惩罚无效重复。修法很简单给输出加重复 token 惩罚并在奖励里加入关键步骤命中检查。坑二加载 SFT checkpoint 时词汇表对不上。由于我在数据预处理阶段用了不同版本的 tokenizer导致 SFT 训练的 vocab_size 和基座权重不一致加载模型时直接报 shape mismatch。后来把 tokenizer 训练脚本和模型加载脚本放进同一个 makefile 里同步构建彻底杜绝版本漂移。坑三GRPO 训练后拒答变多。现象是模型遇到不熟悉的问题直接输出抱歉我无法处理该问题但奖励只惩罚答错不惩罚拒绝。于是模型学会摆烂避损。修复时我在奖励函数中加入空答案片段惩罚 引导 token 强制并且把拒答输出同样送进 evaluation 流程观察保证最终模型不会只挑软柿子捏。6.4 复盘之后我最想提醒你的事从零构建推理模型这件事最折磨人的不是数学公式而是系统内到处都是看起来没问题合在一起就崩的耦合问题。老老实实把每一步都拆成独立可验证的环节比急着追求大参数更接近成功。如果你正准备动手我最后再给一个可复用的小建议把评测脚本从一开始就放进仓库根目录命名为eval.py每次改数据、改模型、改训练参数后都必须跑同一份评测。你会惊讶地发现这一条简单规则能挡住至少一半的无效调参。
返回列表