我做了大半年“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_size=128): 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], dtype=np.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, size=batch_size) x = np.stack([self.data[i:i+self.block_size] for i in ix]) y = np.stack([self.data[i+1:i+self.block_size+1] for i in ix]) x = torch.from_numpy(x).long() y = torch.from_numpy(y).long() return x, y这里有个非常容易被忽略的小细节:标签不是独立于输入的另一个句子,而是把输入向右平移一个单位。也就是说,输入x的第t个位置应该预测的是第t+1个词,而这个词正好就是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, dropout=0.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, bias=False) self.c_proj = nn.Linear(emb_dim, emb_dim, bias=False) self.dropout = nn.Dropout(dropout) def forward(self, x): B, T, C = x.size() q, k, v = self.c_attn(x).split(C, dim=2) 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, device=x.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(), lr=1e-3, weight_decay=0.1) scheduler = get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=200, num_training_steps=10000) 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_norm=1.0) optimizer.step() scheduler.step()这段代码看着简单,但每行背后都有讲究。比如clip_grad_norm_就是必不可少的一步:Transformer训练初期如果不做梯度裁剪,很容易出现梯度爆炸,导致loss瞬间变成NaN。又比如F.cross_entropy视图形状的转换,实际中非常容易出错,必须确保logits和标签的batch维度和序列维度对齐。
4.2 超参数怎么定,不能靠感觉
训练语言模型常用的超参数,我在这篇文章里用表格列一下,方便大家直接参考。对比项包括我自己项目的初始值和原因:
| 超参数 | 初始值建议 | 调整逻辑 |
|---|---|---|
batch_size | 64 | 太小会导致梯度噪声大,训练震荡;太大则显存吃紧,梯度估计平滑但容易提前收敛到次优点。早期调试用小批量,稳定后再增大。 |
learning_rate | 1e-3(配合warmup) | 学习率过大,loss会爆发或发散;过小则收敛慢。使用warmup可以先让梯度稳定,再逐步增大到目标学习率。 |
warmup_steps | 总步数的5%~10% | 初期模型参数随机,直接大步长更新容易震荡,warmup能提供一个平滑启动阶段。 |
weight_decay | 0.1 | 对偏置和归一化层通常不加weight decay,只对权重矩阵做正则化。我实际测试里对LN层做正则化没有帮助。 |
clip_grad_norm | 1.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系统的运转有了真正扎实的理解。
如果你也想动手做,我的建议是:不要一开始就追求大模型和完美效果,而是先搭一个最小的完整链路,亲手感受一次数据、模型、训练、评估、推理全流程跑通的体验。那个过程带给你的收获,比任何现成模型和框架都大。