做AI工程这几年,我心里一直有种不踏实感。用别人训练好的模型、调现成的接口、一条命令部署服务,每一步都很顺,可真到出了问题的时候,我发现自己根本说不清内部到底发生了什么。年初我给自己定了个目标:做一个“ai-engineering-from-scratch”项目,从零开始,不调现成大模型API、不用封装好的训练框架、不下载现成权重,把数据、模型、训练、推理、部署整条链路亲手走一遍。这篇记录,就是这个项目的完整复盘。
这里说的“推理模型”不是泛指语言模型,而是具备思维链(CoT)能力、会“先想后答”的reasoning model。我选它作为目标,是因为推理模型的构建几乎覆盖了当代AI工程的完整技术栈:数据合成、预训练、监督微调、强化学习、量化部署,哪一个环节都绕不开。如果你能亲手从零搭出一个1B量级、能推理、能部署的模型,你对AI工程的整体理解会和只会调包完全不同。这篇记录适合三类人:想做AI应用但不想停留在“调API”层面的工程师、正在入门大模型训练的研究生,以及所有对黑盒内部好奇的实践者。
1. 从零构建AI推理模型:为什么非要走最远的路
1.1 “from scratch”到底是从哪里开始
很多人说要“从零写AI”,但真动手会发现起点都定义不清楚。是从写Attention机制开始?是从清洗语料开始?还是从数学上推一遍反向传播?我这次给自己划的边界非常明确:任何环节都不调用现成的“大模型成品能力”,不下载别人训练好的权重做微调,不用封装好的Transformer Trainer。数据由公开语料和合成脚本搭建,模型结构用PyTorch底层函数自建,训练循环自己写,推理服务和量化也全部自实现。
划这个边界不是为了标榜“能造轮子”,而是为了理解每个环节的真实成本。用现成框架训练,loss一掉万事大吉,但我不知道数据配比、学习率、batch size这些参数在链路里扮演什么角色。自己动手之后,每一个异常现象都得自己解释——这种被迫思考的机制,反而成了学习速度最快的推进器。
1.2 为什么把目标定为“推理模型”而不是普通文本模型
如果只是验证“从零能搭起流程”,做个文本续写模型就够了。但那是半途而废。真正的reasoning model背后涉及三块关键能力:基础语言能力(预训练阶段获得)、遵循推理格式的能力(监督微调阶段获得)、自我纠错与探索的能力(强化学习阶段获得)。这三块恰好串起了当今AI工程里最热门的三类技术路线。
还有一个非常实际的工程原因:推理行为在小模型上也能观察到明显效果。1B左右参数量,单卡就能训练,推理延迟低,迭代速度快,特别适合作为从零实践的载体。如果你一上来就选100B模型,大概率连数据都装不下,项目会在中途夭折。做工程和做研究不一样,能持续看到反馈比什么都重要。
1.3 哪些人适合参考这份实践记录
我估计下面三类人能从中得到价值。第一类是AI应用开发者,他们每天调接口,却不知道训练和推理之间的依赖关系。第二类是论文复现入门者,读了很多理论但不知道工程上从哪里下刀。第三类是已经在用现成框架、但遇到问题只会“重启再试”的实践者。
需要的基础其实不高:Python语法、PyTorch的Tensor基础、基本的机器学习概念——知道损失函数、梯度、过拟合是什么意思就够了。不需要你会手推完整的反向传播公式。但如果你连Tensor的shape变换都不熟练,建议先花两周补一下基础,否则下面涉及的数据处理和模型实现细节会有些吃力。
2. 数据与架构先行:两块绕不开的基石
2.1 推理数据的构造:一条思维链如何变成训练样本
推理模型训练的核心数据是“(问题,思维链,答案)”三元组。所谓思维链,就是模型在给出最终答案之前展示的完整推理过程。为什么必须用这种结构的数据?因为推理模型最重要的行为特征就是在回答前先输出推理路径,SFT阶段如果看不到这种结构,模型能学会流畅说话,却学不会“先想后答”的行为模式。
数据来源我用了两条腿走路。一部分来自公开的数学题集和逻辑题集,挑选标准是有标准答案、干扰项少、表述清晰;另一部分是自己写脚本合成的问题——基于数学题模板随机生成数值变体,再用符号计算脚本自动生成标准答案和分步推导。公开数据集的规模有限且模型容易“背题”,合成数据可以无限扩展且天然带标准答案,两者互补是最稳的组合。
关于数据量的经验,给大家一个参考:1B模型预训练我用了约20B token的通用语料,SFT阶段用了约800万条三元组样本。这个量在单卡A100 80G上大约一周能训完一个可用模型。如果显存小,数据量要相应压缩,但SFT数据不要低于百万级,否则指令跟随能力会非常弱。
数据清洗我踩了不小的坑。公开数据里答案标注错误的比例看着只有1%到2%,但在小模型上,这点噪声可能带来评测分数明显下降。我的处理是:合成数据全部用计算脚本重新推导一遍;公开数据做一次投票过滤,用一个小模型对推理过程和答案分别打分,分数过低的直接剔除。这套预处理看起来费时,但它省掉了训练后期排查bad case的大量时间,属于典型的“先省后花”。
2.2 模型架构参数怎么定:以1B模型为例
我选择的基础架构是标准的decoder-only transformer,参数量约1B。具体配置如下:
| 参数项 | 数值 | 说明 |
|---|---|---|
| 隐藏维度 hidden size | 1536 | 1B量级常用配置 |
| 层数 num layers | 18 | 深度适中,单卡可训 |
| 注意力头数 num heads | 18 | 与隐藏维度对齐,head dim=96 |
| 前馈网络维度 | 4096 | 采用SwiGLU结构 |
| 上下文长度 | 4096 | 支撑较长思维链输出 |
| 词表大小 | 32k | BPE,兼顾中英文 |
| 总参数量 | 约0.9B | 含embedding参数 |
这里专门说说为什么用SwiGLU而不用传统ReLU全连接。SwiGLU在相同参数量下能带来稳定的下游能力提升,特别是在需要分布式推理的任务上,信息在门控结构里更容易被分段保留。代价是实现稍微多几行代码,但完全值得。对工程实践来说,SwiGLU和RoPE位置编码已经是现代LLM的标配,别自己再走一遍ReLU加绝对位置编码的回头路。
参数计算有个容易忽略的点:embedding矩阵占比很高。以hidden size为1536、词表32k为例,仅embedding就有1536×32000约4900万参数,占总参数的5%以上。这就是为什么词表不能盲目扩大的原因——每增一万词,参数就增加d_model×10000。我最终选32k词表,是在中英文覆盖度和参数效率之间取的平衡。
2.3 预训练阶段就要为推理能力做铺垫
一个常见想法是:先把通用语料训完,再在SFT阶段教推理。这个思路没错,但有隐患——如果预训练语料里推理相关内容太少,SFT阶段需要学的东西过于集中,模型容易学成“背模板”而不是真正掌握推理。我的做法是在预训练语料里混入约5%的代码和数学文本,再加约2%的带分步推导的科学文本,让模型在预训练阶段就见过“逐步推导”这种文本模式。
这7%是经验值,不是铁律。通用语料质量高可以适当调低,质量一般就调高。实测下来,推理类语料占比低于8%时,通用语言能力几乎没有损失;超过10%,模型回复会明显变生硬,频繁出现“第一步、第二步”的机械化表达。找到这个阈值的过程,本身就是理解数据配比如何影响模型行为的最好课堂。
3. 训练全流程实录:从预训练到强化学习
3.1 预训练:先让模型获得语言基础
预训练阶段的目标不是让模型“会做题”,而是获得扎实的语言建模能力。损失函数就是最标准的下一个token交叉熵。配置:AdamW优化器,学习率峰值3e-4,warmup占比1%,之后线性衰减到1e-5;batch size为512个序列,每序列4096个token,相当每次迭代约200万token;梯度裁剪1.0。
有个容易被忽视的细节是数据顺序打乱。预训练语料里大量文本来自同一来源,如果按来源顺序喂数据,训练初期loss曲线会出现周期性的锯齿,模型会在不同领域间“切换状态”。我在数据加载器里严格按来源做了混洗,每个epoch内再重新打乱一次。调整后loss曲线平滑很多,最终能力也更好。
预训练在单卡A100 80G上跑了约7天,20B token大约见10个epoch。有人会问:为什么要让模型反复看这些数据?小模型没有能力一遍记住全部数据,多epoch是合理的;但一般不要超过15个epoch,否则会开始记忆语料噪声,表现为生成时出现与原文高度重合的段落。8到12个epoch是我试下来最稳定的区间。
3.2 监督微调:用思维链数据教模型“先想后答”
预训练结束后,模型能流畅生成文本,但不会答题。SFT阶段要做的就是教会它:面对问题时先输出推理过程,再输出最终答案。
训练样本的格式分三段:系统提示(“请先逐步推理,再给出最终答案”)、用户问题、模型回复(思维链加答案)。训练时,系统提示和用户问题部分不计算loss,只在模型回复部分算。这样梯度信号全部集中在“如何作答”上,模型不会在“如何读题”上浪费能力。
SFT学习率比预训练低一个数量级,我设为2e-5,batch size 128,训练4个epoch。这里有个关键经验:一定不要训练过久。SFT loss下降到一定程度后继续训练,模型多样性会下降,表现为所有回答长得一样。这在推理任务里特别致命——模型一旦只会套模板,遇到没见过的问题就彻底失效。我试过训到8个epoch,评测分数反而下降,是典型的过拟合信号。
另一个容易踩的坑是输出长度设置。思维链数据里有些推理过程特别长,训练时如果直接截断过长样本,模型会学到坏习惯:推理到一半就停下。我的解法是动态长度组合:短样本和长样本分开组batch,长样本单独训练。虽然训练效率略有下降,思维链的完整性明显更好。
3.3 强化学习阶段:让模型学会自我纠错
SFT做完的模型已经能输出推理链,但它有个明显问题:遇到不会的题会硬编一个推理过程,答案明明错了也毫不动摇。强化学习阶段解决的就是这个——让模型在推理过程中发现错误、修正方向,最终给出正确结果。
我用的是GRPO(Group Relative Policy Optimization)。思路可以通俗解释:让模型对同一个问题生成多组回答,给每组回答打分,然后用组内相对优势更新策略——比组内平均表现好的回答被强化,差的被抑制。这样不用训练额外的价值网络,工程实现简单很多,也更省显存。
奖励函数我设计了三个维度。一是格式奖励,模型必须完整输出思维链和答案,缺一部分就扣分;二是答案正确性奖励,用脚本计算最终答案是否正确;三是推理过程奖励,检查关键步骤是否与标准推导一致。最初只用前两个维度,结果模型学会了“跳步”——答案正确但完全不展示推理过程。加上第三维后,模型才逐步生成完整可验证的推理链。
GRPO训练里我给每组生成8个回答,RL学习率3e-6,KL惩罚系数0.01。这个KL系数是控制模型偏离SFT模型的“松紧带”。系数太小模型会乱跑,太大又学不到新东西。我试过0.05、0.03、0.01,最终0.01效果最好,训练稳定性和最终得分达到平衡。
3.4 训练稳定性的三个关键参数
RL训练最痛的是稳定性差。我经历了几次loss爆炸和策略崩溃,最后沉淀出三个关键参数。
第一是学习率。RL学习率必须比SFT低一个数量级以上,我用3e-6而不是SFT的2e-5。如果学习率过高,策略会在几轮内大幅偏离SFT模型,输出直接变乱码。第二是KL惩罚系数,它不能为0,也不能太大。第三是梯度裁剪,RL阶段有效batch因为每组生成8个而变大,梯度方向本身更平滑,但裁剪依然必须开,我设1.0,防止个别异常样本把参数推出稳定区域。
还有一个容易被忽略的细节:RL问题集的加载顺序。应该预先打乱,并且不要让每个epoch的顺序完全一致。固定顺序会导致模型记住问题顺序,看似loss在降,实际泛化能力在恶化。
4. 把模型真正用起来:推理部署与性能优化
4.1 INT8量化:精度、吞吐与显存的三角权衡
训练完成后,模型还是FP16权重,1B参数约占2GB显存。单用户场景没问题,但并发一高,显存和带宽就成了瓶颈。我做了INT8量化,权重显存降到约1GB,吞吐提升约30%。
量化方案选了weight-only INT8:只量化权重矩阵,激活保持FP16。实现简单且对推理质量影响小。具体做法是对每个权重矩阵按行计算缩放系数,用对称量化映射到[-127, 127]。实测1B模型在数学推理评测集上的分数下降不超过1%。
一个反直觉的经验:量化前一定要做校准,不能直接把权重一股脑转成INT8。校准集要贴近实际使用场景——问题加思维链的数据,而不是随便抽几段通用文本。我用500条评测集样本做校准,效果比用通用语料好很多。校准本身只花十几分钟,但对最终精度影响极大。
4.2 批处理与KV Cache:延迟和吞吐的取舍逻辑
推理服务的核心矛盾是延迟和吞吐。单请求进来直接计算延迟最低,但显存利用率极差;多个请求拼成batch吞吐上去,最慢的请求会拖累所有人。
我的处理是连续批处理思路:不等到batch里所有请求结束再释放资源,而是每完成一个请求就把它移出去,立刻加新请求进来。这样长请求不会卡死短请求。实现上最重的部分是按请求长度动态管理KV Cache。一开始我写静态KV Cache池,后来改成动态分配,延迟抖动明显减小。
KV Cache分配也有讲究。1B模型4096上下文,每个请求的KV Cache大约占几十MB。如果服务要支撑同时30个请求,建议预留至少2倍空间,因为连续批处理下实际生成长度可能比预估值长得多。我在这个上面吃过亏:一开始只留1.5倍,并发一高显存不够,请求开始排队,延迟暴涨。调到2倍后一切恢复正常。
4.3 自写推理服务:比套框架多学到的几件事
这次项目我特意自己写推理服务,没有直接套现成框架,目的是把推理循环每个环节吃透。服务主体是一个Python异步HTTP接口,内部就是生成循环:接收请求、拼接prompt、查KV Cache、逐token采样、拼接输出。
采样环节我设了两档温度:普通问答0.7,推理场景0.5。温度越低生成越稳定,但过低会损失推理多样性,导致模型在同一个错误方向上反复打转。实测数学推理场景0.5比0.7准确率高约2个点,但遇到难题时“卡壳”概率也更高。这个取舍必须根据场景调整,不能一刀切。
服务里还要把max_new_tokens留足。推理模型的思维链经常很长,我设2048。低于这个值模型会被截断,表现为“推理到一半突然结束”。如果问题特别复杂,要同步加大上下文长度和max_new_tokens,但记得KV Cache占用也会成比例上涨。
4.4 三层评测体系:怎么证明模型真的会推理
服务上线后,真正的问题是:它到底会不会推理?我建了三层评测。第一层是数学推理评测集,约500道带标准答案的数学题,看答案正确率;第二层是逻辑推理自建集,约200道条件推理和排序推理题,用规则脚本判断结果是否符合逻辑结构;第三层是通用对话评测,看自由对话流畅度,防止推理能力增强后把对话能力搞崩。
三层评测贯穿训练过程,每个epoch结束都完整跑一遍。一个值得记录的经验是评测batch不要太大。模型在推理时,如果同批次问题难度差异过大容易互相干扰,难问题回答质量会下降。我把评测集每个batch控制在8条以内,结果更稳定,也更能反映真实水平。
5. 踩坑实录:训练和部署中的典型问题排查
5.1 预训练loss震荡不收敛
第一次遇到的严重问题是预训练中期loss呈锯齿状震荡,一降一升不停。排查思路三步走:先看数据加载顺序,是否按来源混洗;再看学习率,峰值是否过高;最后检查梯度范数,如果频繁超过1说明裁剪没生效。
这次问题的根源是我把梯度裁剪的位置写错了——只裁剪了主参数梯度,没有裁剪优化器的动量状态,个别异常batch的梯度被放大,导致参数在稳定区域附近来回震荡。修正后loss重新平滑下降。这个坑提醒我:梯度裁剪必须验证它真正生效的位置,而不是代码里出现一行就以为万事大吉。
5.2 输出死循环与思维链截断
SFT完成后测试,发现模型频繁输出“第一步,第一步,第一步……”的重复循环。最开始还以为是采样温度设置问题,降到0.1依然复现,才意识到是训练数据的问题。排查后发现SFT数据里有约2%的样本,其思维链本身就是重复文本,被模型当成了正常模式。
解法分两层。数据层面写脚本检测思维链中连续重复的n-gram,超过阈值直接剔除。推理层面加一条运行时规则:当检测到相同三元组重复出现两次以上,强制中断当前生成,对最后一段做高温度重新采样。这个“重采样兜底”策略在实际使用中几乎杜绝了死循环现象。
5.3 RL阶段策略崩溃:奖励量纲不匹配的教训
GRPO训练到第20个epoch左右,模型突然开始输出大量无意义符号。追查发现是奖励尺度出了问题:三个奖励维度的量级差别太大,格式奖励占了绝对主导,模型为了拿格式分直接放弃了推理内容。
解法是重新设计奖励融合方式:对每个维度的奖励先做归一化,再乘各自的权重系数后相加。格式权重降到0.1,正确性0.5,推理过程0.4。调整后训练曲线恢复,模型开始在正确性奖励引导下重新学习推理。这个教训非常关键:多目标奖励不能裸加,必须先统一尺度再融合,否则梯度会被单一目标绑架。
5.4 INT8量化后效果下降
部署阶段发现INT8量化在简单题上表现几乎不掉点,但难题正确率掉了约5%。排查定位到精度损失主要集中在attention层的大型权重矩阵。修复方式是改用per-group量化,group size设为128,替代per-row量化。per-row在小矩阵上够用,但在大矩阵上舍入误差会累积。
改进后难题正确率下降从5%收窄到1%左右。如果你的量化模型效果下降明显,优先检查attention和mlp大矩阵的量化粒度,这个方向能解决大部分问题。
5.5 排查方法论小结
把这些坑串起来看,我会把排查经验归纳成一句话:异常现象先归类,归类后从数据、模型、参数三层分别切入。数据问题会表现为模型学会某类错误模式、loss形态奇怪;模型实现问题多表现为分布极端异常、tensor形状报错;参数问题通常表现为loss震荡、训练不收敛。按这个框架排查,比顺手“改改学习率再跑一次”要高效得多。
6. 复用这套工程能力:它不止做了一个模型
这次从零实践做完之后,最大的收获不是模型分数高了几个点,而是把链路每个环节都串了起来。以前用现成框架训练,出问题只会重启;现在看到loss异常,我能快速判断是数据问题、学习率问题,还是奖励配置问题。这种“定位能力”和“掌控感”,是任何现成工具都给不了的。
做完这套项目后,这套能力可以迁移到几个方向:一是给团队搭建内部的模型评测和诊断流水线;二是做垂直领域的推理优化时,知道改数据配比、改奖励函数、改量化粒度分别会带来什么效果;三是从零复现一篇新论文时会从容很多,不会对着论文里的公式发愁“这到底怎么实现”。
如果只保留一条经验,我会选“奖励归一化”。它帮我避开了RL阶段最大的坑,也真正理解了多目标优化的本质——不同量纲的目标不能直接相加,必须先统一尺度。这是AI工程里放之四海皆准的原则,以后做任何多目标优化的任务都绕不开它。
最后给也想动手做类似项目的朋友一句建议:别追求一步到位复现大模型。先用自建数据、自写代码,把一个小模型的完整链路跑通,再一步步扩规模。这条路看起来绕,实际是理解AI工程最扎实的路径。工程能力不是看会了多少工具,而是看你在没有工具的时候,能自己造出多少可用的东西。