
我做了大半年“ai-engineering-from-scratch”这个项目过程很难受但收获比之前调包三年还要大。网上很多人都在聊“从零构建大语言模型”“从零搭建推理模型”但大多数教程只给个概览真正动手时你连从哪里开始都不知道。这篇就把我完整走过一遍的经验拆开讲包括怎么从纯数学手写前向传播、怎么搭数据管线、怎么训练出一个能用的最小Transformer、怎么判断结果是真进步还是自欺欺人以及最后怎么往“推理模型”的方向扩展。适合那些已经会用PyTorch、能跑通HuggingFace模型但始终觉得底层隔了一层膜、想亲手揭开看看的开发者。看完你能获得一条可以复现的完整路径以及一堆文档里不会写的坑。1. 为什么我把“从零开始”当成必做项目1.1 框架黑盒带来的那种无力感先聊聊我为什么想做这么“傻”的事。前几年做NLP相关项目工作流非常固定处理数据、从HuggingFace拉一个预训练模型、接上训练器、跑几个epoch、看指标、微调、上线。大部分时候效果还不错但一旦出问题那种无力感会非常明显。举个具体例子。有次用某个主流框架做序列标注训练过程中发现验证集F1一直在某个数值附近震荡死活上不去。我试了调学习率、换优化器、加深网络、加dropout都没用。最后查了一个多星期才发现是框架内置的数据采样器在一定条件下会造成标签偏移而我们使用的特定编码方式恰好踩中了那个bug。那个星期我反反复复读框架源码读到凌晨然后发现只要我懂底层实现这个问题第一天就能定位。这只是众多事情中的一件。框架给你封装的每个“一行调用”背后都有大量假设和细节。你用它们时不需要理解但一旦环境和数据不匹配那些细节就会变成坑。这是我在日常工作中最强烈的感受对底层缺乏亲手构建过的直观感知排障能力是断层的。做“ai-engineering-from-scratch”很大程度是想把这些断层重新补上。不是说要否定现成框架——那当然非常高效率而是说我应该用亲手实现的方式把那些“一行调用”里的所有假设都拆开看清楚这样再回去用框架时才能真正驾驭它而不是依赖运气。1.2 从零开始不等于重新发明轮子可能有人会说现在库这么成熟你手写一个Transformer性能比PyTorch差几十倍有什么意义我的理解是“从零开始”从来不等于从晶体管开始造计算机。它的意思是把你日常依赖的库的边界当作“地面”从那个之上开始所有用到的东西都必须由你自己写出来。你不用自己实现GPU驱动但你写矩阵乘法和注意力机制不用重新发明Python语言但你亲手写Tokenizer和训练循环。我给这个项目定的规则很简单训练模型时不允许调用PyTorch里的nn.Linear、nn.Embedding、nn.MultiheadAttention这些现层模块但是允许用一个极简的Tensor库做底层数值运算允许用自动微分工具辅助计算梯度。这样既不会被真实工程中那些鸡毛蒜皮的工程细节拖死又能保证每一个模型结构都由自己一行行写出来理解了每个变量的形状变化和梯度走向。这个边界很重要。如果完全从零连梯度都手推训练简单模型还行训练Transformer就容易把人劝退如果边界划得太高直接用现成Transformer层那本质上还是在调包。我的选择是模型结构必须手写数值运算可以用自动微分简化但必须自己理解每步梯度。1.3 这套项目最终能带来什么把项目做下来之后有几项收获是之前完全没料到的排障速度快了一个量级。现在用OpenAI的CLIP或LLaMA相关代码时遇到形状不匹配、训练发散、评估异常我能很快判断是哪里出了问题因为每种结构我都亲手写过知道它内部的张量形状和数值范围。性能调优时的思路完全变了。以前调参就是网格搜索现在会先想清楚某个模块的数学性质和数值瓶颈再去动超参数或结构。迁移到新框架的成本极低。因为对新实现的核心逻辑已经内化换框架只需要学API映射不需要重新学概念。面试和技术分享时能把原理讲透。虽然这有点功利但确实在不少场合帮我从“会用的人”变成了“懂原理的人”。处理的最大收获是当你在底层亲手写过每一个组件你会发现那些论文里看似高深的模块本质上都是几个矩阵变换的组合没什么玄学。所以我的态度很明确别觉得“从零构建”浪费时间这个投入是值得的尤其是当你打算在这个行业长期深耕。2. 先下手的是数据管线而不是模型结构2.1 为什么数据先行很多人一谈到从零构建模型第一反应都是先写Transformer结构。但我的经验是数据管线才是做AI工程首先需要从零搞定的事。模型效果的上限由数据决定而大多数项目里数据清洗、构造、切分这些工作要吃掉整个项目工期的一半以上。你在训练流程里发现的很多问题最后查出来其实都是数据问题比如重复样本、标签泄漏、批次内上下文污染。如果模型结构和数据管线混在一起排查会非常痛苦。另外还有个很现实的原因要训练出一个真正能验证“模型逻辑正确”的模型你需要非常小、非常干净、能让模型快速出现过拟合的数据集。如果你一上来就用几十GB的维基百科清洗语料训练训练慢且不说出了问题也完全无法定位是因为模型写错、数据太脏还是训练策略有问题。所以我当时的策略是先用几万个样本的小数据集把整个链路跑通再把数据管线扩展到大规模版本。2.2 手写一个最小可用的文本加载器我当时的任务是训练一个字符级或单词级语言模型。最先写的是一个尽可能简单的数据加载器目标函数是给定一段文本预测下一个词。这个数据管线从零实现起来并不复杂核心步骤是读取原始文本文件记录总字符数。建立词表按空格切分得到词或者按字符级别直接建字符表。小规模研究用字符级比较合适因为词表小、训练快、容易过拟合。把文本转成整数索引得到一个长索引序列。构造训练样本给定一个窗口长度block_size每次从长序列里取连续的一段作为输入x再把这段序列整体右移一位作为标签y。分批采样训练时从长序列中随机选一个起点取batch_size组这样的(x, y)。下面是一个极简的数据加载器核心逻辑我用的是Python原生数据结构加上NumPy避免一开始就引入重型库import numpy as np import torch class CharDataset: def __init__(self, text, block_size128): chars sorted(list(set(text))) self.stoi {ch: i for i, ch in enumerate(chars)} self.itos {i: ch for i, ch in enumerate(chars)} self.vocab_size len(chars) self.data np.array([self.stoi[ch] for ch in text], dtypenp.uint16) self.block_size block_size def get_batch(self, batch_size): # 随机选择batch_size个起点 ix np.random.randint(0, len(self.data) - self.block_size, sizebatch_size) x np.stack([self.data[i:iself.block_size] for i in ix]) y np.stack([self.data[i1:iself.block_size1] for i in ix]) x torch.from_numpy(x).long() y torch.from_numpy(y).long() return x, y这里有个非常容易被忽略的小细节标签不是独立于输入的另一个句子而是把输入向右平移一个单位。也就是说输入x的第t个位置应该预测的是第t1个词而这个词正好就是y在t位置的值。我最初做的时候就是没搞清楚这一点把标签建成了下一句的开头结果训练出来模型完全是个哑巴。2.3 数据管线里的隐藏坑把加载器搭起来之后我踩过几个典型的坑值得单独列出验证集数据泄漏。如果你按顺序把文本切成长度为block_size的块然后随机划分训练集和验证集相邻两个块之间会有大量重复的内容因为每个块和下一个块是连续的同一段文本。模型不需要任何泛化能力只要记住上下文就能在验证集上拿到不错甚至完美的结果。这个问题的正确做法是按连续的区间去划分验证集或者干脆使用完全独立的文本作为验证集。采样时的随机种子管理。调试时如果希望结果可复现务必固定随机种子。通常建议在一个seed_everything函数里把Python、NumPy、PyTorch的种子都设置好。内存和I/O问题。当文本是几GB级别时把所有数据一次性加载进内存并不是一个好主意。关键是不要用list或str存放上千万字符那样内存占用会巨大最好使用numpy数组或内存映射文件。我在做小规模数据时没有在意这个等扩展到大规模时才发现直接read()一个1GB的文件训练还没开始内存就先爆了。后来改用mmap按块加载问题立刻解决。数据管线是整个项目的奠基石。这部分做好了后面写模型、写训练逻辑都会轻松很多。3. 手写一个小型Transformer不是复刻而是理解3.1 先拆解核心组件当数据管线稳定之后我进入项目最核心的部分——手写一个现代Transformer的最小实现。最开始的目标不是要训练一个大模型而是把一个几百万参数、能跑通前向和反向的小模型在自己的代码里跑起来。很多人觉得Transformer是个很复杂的东西但拆解下来其实就那么几块Token Embedding把离散的词索引映射到连续向量空间。位置编码因为自注意力本身不感知顺序需要把位置信息加进去。多头自注意力让每个位置能“看”到序列中其他位置的信息。前馈网络每个位置独立地做非线性变换。层归一化与残差连接稳定训练、让信息流畅传播。最终的输出映射将隐层向量映射回词表大小的logits。这里不打算贴全部代码那个太长只展示最关键的注意力机制的实现思路。真正常见的问题是为什么自注意力里要除以根号d_k因为如果不缩放当维度变大时点积结果会很大进入Softmax之后梯度会异常小训练极度不稳定。缩放成sqrt(d_k)是为了让点积结果的方差保持在一个合理的范围内大约接近1。下面是一个简单的多头自注意力的实现骨架把它替换成纯NumPy也完全可以但用最小化的自动微分工具更方便训练import torch import torch.nn as nn import torch.nn.functional as F class CausalSelfAttention(nn.Module): def __init__(self, emb_dim, num_heads, dropout0.1): super().__init__() self.num_heads num_heads self.head_dim emb_dim // num_heads self.c_attn nn.Linear(emb_dim, 3 * emb_dim, biasFalse) self.c_proj nn.Linear(emb_dim, emb_dim, biasFalse) self.dropout nn.Dropout(dropout) def forward(self, x): B, T, C x.size() q, k, v self.c_attn(x).split(C, dim2) 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) att torch.matmul(q, k.transpose(-2, -1)) / (self.head_dim ** 0.5) # 因果掩码只允许每个位置看到它之前的token mask torch.tril(torch.ones(T, T, devicex.device)).view(1, 1, T, T).bool() att att.masked_fill(~mask, float(-inf)) att F.softmax(att, dim-1) att self.dropout(att) y torch.matmul(att, v) y y.transpose(1, 2).contiguous().view(B, T, C) y self.c_proj(y) return y注意这段代码里的masked_fill(~mask, float(-inf))这是整个模型能否正确训练的关键。因为我们要做的是自回归语言模型位置t在预测时只能看到前面的位置不能偷看后面的词。如果你忘了加因果掩码模型会直接作弊训练时acc看起来很高但实际生成时完全乱套。3.2 每一步的张量形状变化必须烂熟于心写Transformer时最容易出的问题就是维度对不上。我发现与其到处查报错提示不如先把每一层的张量形状变化写在一张纸上输入x的形状(batch_size, block_size, emb_dim)。经过QKV投影后切分成三份每份还是(batch_size, block_size, emb_dim)。为了做多头注意力把它reshape成(batch_size, block_size, num_heads, head_dim)再transpose成(batch_size, num_heads, block_size, head_dim)。点积得到注意力分数形状为(batch_size, num_heads, block_size, block_size)。掩码、Softmax后再和v相乘输出恢复成(batch_size, block_size, emb_dim)。类似地残差连接里要搞清楚哪里加、哪里不加。层归一化一般加在注意力或前馈网络之前还是之后不同论文有不同做法。我参考的实践是在每个子层之前做LayerNorm然后再过子层最后加残差。这种预归一化结构在深网络中更稳定。如果每一步的形状变化都能默写出来之后写更复杂模型时就会非常顺畅。3.3 反向传播不能只依赖自动求导既然项目叫“from scratch”我不能只靠loss.backward()然后坐享其成。我给自己加了一个硬性要求对每一层梯度的手推公式要做到心里有数并且用数值梯度做一次完整校验。具体做法是随机构造一个小输入把模型参数铺平对每个参数加一个很小的扰动epsilon用(f(x epsilon) - f(x - epsilon)) / (2 * epsilon)近似梯度再和自动微分得到的梯度做对比。如果两者相对误差在1e-6量级内说明手写的前向逻辑和反向链路是一致的没有隐藏bug。这一步非常关键。我第一次实现注意力层时数值梯度校验就抓到了一个错误我当时在实现causal mask时误把源码里掩码的维度广播方式写错了导致模型可以提前看到未来信息。如果只靠loss下降来判断正确性你根本看不出来因为模型照样能训练甚至loss下降得更快。但用梯度校验一查立刻就会发现数值梯度跟自动微分对不上。这也是做底层实现最该重视的一个环节。3.4 训练前先做“过拟合测试”写完模型结构之后我做的第一件事不是启动完整训练而是准备一个极端小的数据集比如一小段文章只截取几个KB然后用单批数据不停训练几十步看看模型能不能把这段文本背下来。这个测试的逻辑很简单如果模型连一小段数据都无法过拟合那说明结构或训练逻辑有问题。只有当它可以快速过拟合时才说明整个前向、反向、优化链路是通的。如果这个测试无法通过千万别急着扩大数据规模否则你会在训练到一半时面对一个完全无法归因的烂摊子。我当时用一个小语料做这个测试十几个step之后loss就降到了很低生成的文本和原文几乎一模一样。这给了我很强的信心模型逻辑没大问题可以继续往下走。4. 训练“最小但完整”的语言模型4.1 训练循环的骨架从零写训练循环其实不是难事难的是把里面所有细节都处理对。我的训练循环骨架大致如下optimizer AdamW(model.parameters(), lr1e-3, weight_decay0.1) scheduler get_cosine_schedule_with_warmup(optimizer, num_warmup_steps200, num_training_steps10000) model.train() for step in range(max_steps): x, y dataset.get_batch(batch_size) logits model(x) loss F.cross_entropy(logits.view(-1, vocab_size), y.view(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()这段代码看着简单但每行背后都有讲究。比如clip_grad_norm_就是必不可少的一步Transformer训练初期如果不做梯度裁剪很容易出现梯度爆炸导致loss瞬间变成NaN。又比如F.cross_entropy视图形状的转换实际中非常容易出错必须确保logits和标签的batch维度和序列维度对齐。4.2 超参数怎么定不能靠感觉训练语言模型常用的超参数我在这篇文章里用表格列一下方便大家直接参考。对比项包括我自己项目的初始值和原因超参数初始值建议调整逻辑batch_size64太小会导致梯度噪声大训练震荡太大则显存吃紧梯度估计平滑但容易提前收敛到次优点。早期调试用小批量稳定后再增大。learning_rate1e-3配合warmup学习率过大loss会爆发或发散过小则收敛慢。使用warmup可以先让梯度稳定再逐步增大到目标学习率。warmup_steps总步数的5%~10%初期模型参数随机直接大步长更新容易震荡warmup能提供一个平滑启动阶段。weight_decay0.1对偏置和归一化层通常不加weight decay只对权重矩阵做正则化。我实际测试里对LN层做正则化没有帮助。clip_grad_norm1.0如果loss频繁跳到极大值可以试试降到0.5每次修改后都要观察损失曲线不应收敛更快但变stabler。很多教程直接给一组超参数但完全没有解释为什么。我的理解是Transformer训练过程中遗忘的典型顺序是“短期规律迅速被学到长期依赖逐渐被编码”。学习率过高会导致初期模型记住了大量表面统计特征反而阻碍了后续对深层结构的拟合学习率过低又会让模型过度依赖初期特征。所以要靠学习率warmup和cosine衰减来配合不同阶段的训练状态。4.3 训练正常的标志曲线里藏着大量信息在从零训练阶段我最关心的不是准确率而是三条曲线训练loss曲线应该持续下降并且下降速度逐渐放缓这说明模型还在学习新的模式。梯度范数曲线稳定在一个较小范围一般从一开始的较大值快速下降然后趋于平稳。验证loss曲线如果验证loss先下降后反弹而过早出现反弹很可能是过拟合也可能是学习率过大导致模型权重大幅震荡。采集训练曲线时我建议定期打印以下几项内容step: 2500, train_loss: 2.103, val_loss: 2.214, grad_norm: 0.612, lr: 0.00047, time: 1.23s/batch这样有助于快速判断问题出在哪。我见过太多人只看最终精度完全不看训练过程结果模型崩了也只能对着测试集发呆。另外有一个非常实用的小技巧训练到后期如果你发现验证loss很长时间不降可以试试把学习率人为调小一个数量级再跑几百步。这通常能帮助模型在损失曲面里找到一个更平坦的极小点测试效果往往有明显改善。5. 评估环节比训练更容易骗人5.1 只盯loss的陷阱训练结束之后很多人的第一反应是看验证集loss降了多少。这是一个很方便的指标但对语言模型却很容易误导。举个我实际遇到的例子有一次我训练一个比较小的模型验证loss从3.2降到了2.6看起来效果提升了。但是当我实际去生成文本时发现它翻来覆去只会输出同一类句子比如“根据目前的情况来看……”、“对此有专家表示……”虽然每一个句子读起来都很顺畅但内容多样性极低完全不像一个正常语言模型该有的输出。问题出在哪里验证loss是每个词的平均交叉熵它关注的是预测的准确度不关注生成的整体质量和多样性。某些数据集里只要把高频词和常见搭配学好loss就能降得很低但这和“模型具备广泛语言能力”完全是两回事。所以我的评估方案逐渐发展成三层基础层验证集loss和困惑度perplexity。观察模型拟合程度。中间层写几个指定的生成任务比如“请生成一段包含特定关键词的短文”看生成质量。高层多样性和重复度指标。统计生成文本中不同句子的重复程度、词汇重复率等。5.2 我为模型设计的几个实用评估维度在实际评估中我经常用这些办法来观察模型是否有真正的效果困惑度困惑度等于交叉熵损失的指数形式它衡量模型对下一个词预测的“惊讶程度”。如果模型认为下一个词一定是某个词而且确实如此困惑度接近1如果模型完全随机猜困惑度接近词表大小。生成样本的多样性设置多次采样统计生成结果的n-gram重复率。具体做法是对同一个前缀采样10次看一下这10条文本中有多少不同的句子如果大家都一模一样那就说明模型只学到了最高频的路径没有学到丰富的语言结构。指定任务的完成率给模型一个明确的提示例如“解释一下什么是注意力机制”看输出是否切题、是否包含关键概念。完全走生成式问答的评估路线。这里我不建议你只选一个指标。因为AI工程不是比赛刷榜你要判断的是模型“实际上能用吗”而单一指标很容易骗人。5.3 一个典型的“loss正常但效果崩了”案例我印象最深的一次是我把block_size调大了之后训练loss一直下降速度还挺快但模型生成出来几乎是在复读机式地拼凑最近出现的词。排查了很久最后才发现问题出在数据加载器上我在构造批次时随机选择的起点ix可能会相互重叠导致同一个批次里出现大量相邻甚至重复的内容。模型在这种数据上学到的是“只要记住上一段就行不需要真正预测下一个词”所以loss好看、生成很差。这个坑说明一个道理模型评估必须放在真实的使用场景里去观察不能只盯着数字。数据管线里一个小小的问题扎根很深却很少暴露在loss曲线上。6. 从语言模型到“推理模型”的扩展路线6.1 在基础模型上叠加思维链数据项目做到这个阶段我开始从热搜和社区里看到越来越多的“build a reasoning model from scratch”相关讨论也就是从零构建会“推理”的模型。这个方向很有吸引力但它并不是在变魔术而是建立在基础语言模型之上的一个特殊训练阶段。我在自己这个小模型上做的尝试是先训练好一个基础语言模型然后在数据里额外添加一批“思维链”样本。所谓思维链样本就是在标准的问题-答案对之间插入一系列推理中间的句子例如“先分析已知条件……”“由此可知……”“所以答案是……”。为什么有效因为语言模型本质上是在学习序列概率分布当大量样本都展示了“从已知到未知的中间推导步骤”时模型在预测时就会倾向于也生成这类中间步骤而不是直接给出一个可能错误的答案。跟人的学习很像如果你只看别人解题的最终答案你能记住套路但很容易算错如果你看了完整推导过程你就能在这个基础上迁移到新题目上。但思维链数据不是万能的关键是要保证数据质量。我试过直接丢给它一大把网上爬来的QA数据结果模型没有变“聪明”反而学会的是从上下文里抽出一句看起来很合理的话来填空。这就是为什么很多开源推理模型的数据是需要专门构建的不是随便凑数。6.2 用策略梯度做推理微调再进一步就是结合强化学习做推理模型的微调。这也是当前社区里特别火的路线让模型生成答案然后用某种规则或评估器判断答案对不对对了给正奖励错了给负奖励最后通过策略梯度算法更新模型。我在从零项目里做一个极简版本的做法是准备一组带“标准答案”的推理题。让当前模型对这些题目做多次采样生成。写一个最朴素的评估函数如果生成文本里包含正确答案奖励分1如果答案错误奖励分-1如果生成的是一段很长的废话且没有结论则奖励分-0.5。用REINFORCE算法按奖励调整生成每个token的概率奖励高的样本增加其生成概率奖励低的样本降低其生成概率。核心代码不复杂# 伪代码简化的REINFORCE logprobs model.forward(question_tokens) reward evaluate_answer(generated_text, ground_truth) loss - (logprobs * reward).sum() loss.backward() optimizer.step()这一步跑起来之后模型在特定任务上的表现会有明显提升。但注意这里有非常容易踩的坑如果奖励设计不到位模型会利用奖励函数的漏洞比如不停重复一个可能包含正确答案的句子导致评估器判正确但实际根本不“推理”。我踩过这个坑后又给奖励函数加上了长度惩罚和重复惩罚情况才有所改善。6.3 推理阶段的采样策略训练完成后推理阶段的做法也影响效果。我总结下来几个关键点温度参数温度越低输出越确定温度越高输出越多样。做推理任务时温度太高会让模型东拉西扯太低又容易陷入单一推导路径。Top-p采样只从累积概率达到p的那部分token里采样能在多样性和准确性之间取得较好平衡。我实际使用中p取0.9效果比较稳定。多次采样投票让模型对同一个问题采样N次然后用规则投票选最多的答案。这招虽然简单但在不少推理任务里能直接把准确率提升好几个点因为模型不同次生成的推理路径不一样正确路径通常会被更多次采样覆盖到。限制最大生成长度推理任务没必要让模型无限写限制长度可以迫使它尽早给出结论减少废话。但这个度要把握好太短可能来不及展开推导过程会直接抄上下文。还有个容易被忽略的小技巧清空或抑制某些高频连词的输出概率比如“首先”“其次”“综上所述”这类词出现得太多会严重挤占实质内容的生成概率。在推理模型里这类连接词是需要被压制的否则生成长度很长但真正的信息密度很低。6.4 扩展阶段我最想分享的几个坑最后集中说说我在这个扩展阶段实际踩过的几个重要坑希望你遇到时能少走弯路不要急着在基础模型上直接做强化学习。如果基础模型连基本的文本生成能力都很弱你给它加再多推理奖励它也只能在原地打转。训练推理模型的前提是基础模型已经具备一定的通用语言能力。奖励函数一定要设计得足够细。如果只判断“答案对不对”模型会倾向于只输出一个答案跳过所有推理过程。但如果奖励过重地偏向“有中间步骤”模型又会废话连篇。需要在奖励函数里同时考虑正确性、中间步骤存在性和输出长度。思维链数据不是越多越好。我试过把思维链样本加到训练集比例的50%以上结果模型在通用文本上的生成能力反而下降因为它“太”会推理了反而变得机械。我最终用的比例是10%~20%这个平衡点需要自己去调。警惕评估时用真答案污染数据。做强化学习微调时如果你的reward来源依赖某个LLM打分器务必要检查打分器本身的倾向否则模型会去讨好打分器而不是真正提高推理能力。到这个阶段你会发现“ai-engineering-from-scratch”已经远远不止是手写一个Transformer那么简单它通向的是一条完整的、从语言模型到推理模型的训练路线。每一步都伴随无数个从零构建的细节但也正是这些细节让你对整个现代AI系统的运转有了真正扎实的理解。如果你也想动手做我的建议是不要一开始就追求大模型和完美效果而是先搭一个最小的完整链路亲手感受一次数据、模型、训练、评估、推理全流程跑通的体验。那个过程带给你的收获比任何现成模型和框架都大。