如果你关注 AI 领域超过半年,大概率已经发现一个有意思的割裂:会调用模型接口的人很多,能讲明白模型为什么输出错、怎么调训练数据、怎么从零跑通一条训练管线的工程师却不多。“ai-engineering-from-scratch”这个项目,标题把“AI工程”和“从零开始”绑在一起,目标非常明确——不去走“填鸭式调包”的捷径,而是从第一性原理出发,把数据集处理、模型架构、训练优化、推理部署这条链路亲手搭出来。
很多教程习惯先给你一堆库、几个命令,跑通了就宣布你“会了”。这种学习方式的致命问题在于,它把你训练成了一个熟练的操作手,而不是一个有判断力的工程师。一旦模型表现异常,你根本不知道问题出在数据、模型、训练还是推理阶段,因为你从来没亲手搭建过这些环节,对每个环节可能出现的故障模式完全没有体感。从零开始的目的,恰恰是建立这种体感。
这篇文章顺着一个核心问题展开:为什么那么多教程教你三分钟部署一个聊天机器人,却没有教程教你从零训练一个能对话的模型?因为前者是使用,后者是造物。这个项目要补的,恰恰是后者。我把它拆成了六个部分,从学习路径、核心模块、实际训练、推理增强、部署上线到高频问题排查,每一部分都有可以直接照做的内容。
1. 学习路径拆解:为什么“从零开始”才是AI工程的正路
1.1 先别急着调API,把“AI工程”的地图摊开
AI工程这个说法这几年被用得太泛了。有人写了个调用GPT的脚本就自称AI工程师,有人把模型部署到服务器上也叫AI工程。但我个人的理解是:真正的AI工程,是从数据准备、模型设计、训练调优、推理优化、服务上线到效果评估这一整条流水线的系统工程,缺任何一环,模型都走不到生产环境。
而“from scratch”这个限定词,等于把这件事的难度和意义都推到了顶点。它要求你抛开现成的训练框架和封装好的推理服务,亲手把每一个环节像积木一样搭出来。这里我想先强调一个观念:对于新手来说,调API像看别人做菜,感觉什么都简单;从零构建像自己下厨,你会知道每个调料放下去是为了什么,也知道菜炒坏了该从哪里补救。这种掌控感,是AI工程师和API调用者之间最本质的区别。
这个项目的目标读者大概分三类:打算入行深度学习但被各种抽象概念劝退的新手;日常工作主要靠调API、想补全底层能力的应用工程师;以及想学习大模型训练、推理细节但苦于没有完整路径的同学。无论哪一类,跟着动手做完,最大的收获不是记住多少公式,而是真正拥有“把一个模型从想法变成可用服务”的闭环能力。接下来我把知识地图摊开,让每一步都有目标、有产出、能验证。
1.2 五层知识结构,每一层对应一个能跑通的东西
我把AI工程的知识拆成五个递进的层级,每一层不追求理论完备,但要求产出一个能被验证的东西。因为AI工程里,能跑通永远比“以为懂了”可靠。
第一层是数学基础,核心包括线性代数、概率统计和基础的微积分。不需要学到数学系水平,但矩阵乘法、梯度下降、正态分布这些概念必须达到“看到公式能对应到代码”的程度。这一层的产出物,是用NumPy手写一个线性回归并拟合出结果。
第二层是Python工程能力,重点是数据处理与性能意识。要熟练使用PyTorch的张量操作、Dataset/DataLoader机制、torch.no_grad和混合精度(amp)这些工具。这一层的产出物,是写出一个可以指定batch size并行加载数据的数据管线。
第三层是深度学习核心原理,覆盖反向传播、优化器、损失函数、卷积和注意力机制。这一层的产出物,是从零实现一个微型神经网络,在一个简单任务上完成训练,并让loss稳定下降。
第四层是大语言模型专项,包括Tokenizer、Transformer架构、预训练策略、微调和人类对齐。这一层的产出物,是训练出一个能够完成简单对话或文本续写的小规模语言模型。
第五层是工程化,包含模型导出、量化、推理加速、服务部署、监控评估。这一层的产出物,是把第四层的模型部署成HTTP服务,并在并发请求下保持稳定响应。
下面这张表是我给团队同学做培训时常用的一张速查表,作用相当于路线图,避免在学习过程中迷失方向。先有地图,再一步步走,遇到问题才知道自己卡在哪一层。
| 层级 | 核心内容 | 代表产出 | 核心工具/概念 |
|---|---|---|---|
| 数学基础 | 线性代数、概率统计、微积分 | NumPy手写线性回归 | 矩阵乘法、梯度、正态分布 |
| Python工程 | 数据处理、GPU编程意识 | 高性能数据管线 | PyTorch Dataset/DataLoader、amp |
| 深度学习原理 | 反向传播、优化器、注意力 | 可训练的微型神经网络 | CrossEntropy、AdamW、backward |
| LLM专项 | Tokenizer、Transformer、训练对齐 | 小型对话模型 | BPE、RoPE、SFT、DPO/RL |
| 工程化 | 量化、推理加速、服务化 | 可并发的HTTP推理服务 | GGUF、vLLM、KV Cache |
很多学习者容易犯的错,是直接从第四层开始,结果被Tokenizer、位置编码、强化学习这些复杂概念按在地上摩擦。我的建议始终是:前两层可以快,但不要跳;第三层是分水岭,这层没过,第四五层遇到问题很难定位原因。
1.3 为什么必须亲手“造轮子”
也许你会问,现在开源训练框架那么多,HuggingFace的Trainer几行代码就能训练模型,为什么还要自己写?这是一个非常好的问题,也是“from scratch”路线的灵魂所在。
我的回答分两层。第一,框架帮你做了大量隐式决策。比如Trainer默认启用了梯度累积、混合精度、学习率调度等一堆行为,新手根本不知道哪些选项影响了什么。从零写一遍,你才理解每一次backward之后优化器到底更新了什么,梯度裁剪到底防止了什么,warmup为什么能稳定早期训练。第二,生产环境里永远会有框架覆盖不到的需求,比如自定义数据采样、特殊损失函数、定制评估逻辑。当你要在Trainer上做改造而不知道底层机制时,改一个参数就得查半天文档;而你自己写过一遍,改起来就像改自己家装修一样心里有数。
所以在这个项目里,我的策略是:能不用框架就不靠框架,主要用PyTorch的核心API手写数据加载、模型、训练循环和推理函数。PyTorch本身已经帮你处理了自动微分和GPU调度,剩下的结构性工作必须自己完成。这可以算是“from scratch”和个人能力成长之间一个比较合理的折中:既不重复造轮子,也不停留在只会调包的水平。
2. 关键环节逐一拆解:数据、架构、训练、推理、评估
2.1 第一关:Tokenizer与数据配比决定模型上限
我见过太多人把注意力全放在模型结构上,却忽略了一个事实:模型能学到什么,首先取决于它看到的文本长什么样。Tokenizer和训练数据的质量、配比,在很大程度上决定了模型能力的上限。
先聊Tokenizer。中文和英文的切词方式差异很大,英文按空格切分就能得到不错的词,但中文如果按字切,模型很难学到词义和组合规律。目前主流做法是BPE(Byte Pair Encoding),思路是把文本从字符级别开始,不断合并最高频出现的相邻片段,直到达到预设的词表大小。实现BPE并不复杂,核心是统计相邻token对的频率并迭代合并。从零实现一次BPE,能让你明白为什么某些词会被切得比较奇怪,为什么词表大小是模型参数量和内存消耗的重要变量。
然后是数据配比。预训练数据不能只喂单一来源,比如全塞维基百科,模型的对话能力和代码能力都会很弱。实践中通常按来源配比混合,比如通用网页、百科、代码、对话数据按合理比例混合,再用去重和清洗规则去掉低质量文本。清洗这一步太重要了。
我用开源语料时踩过不少坑:重复的段落会让模型学会复读,带有异常符号的网页文本会干扰分词,甚至有些数据里残留大量HTML标记,模型会学会生成“
2.2 第二关:Transformer里每个组件究竟在干什么
Transformer的代码在GitHub上到处都有,但多数人抄下来之后,并不知道每个组件为什么必要。我按“输入文本如何被模型理解”的流程拆一遍,你再看代码就会清晰得多。
首先是嵌入层。输入进来的token ID被映射成一个向量,这个向量代表它在高维语义空间的初始位置。接着是位置编码,因为Transformer本身没有顺序概念,必须把每个token的位置信息注入进来。主流做法已经从绝对位置编码演进到RoPE(旋转位置编码),它的好处是能够外推到训练时没见过的序列长度。
然后是注意力机制,这是Transformer的灵魂。注意力做的事可以类比成“在读书时,根据当前这个词,回想前文哪些词最值得关注”。计算上就是每组token生成Q(查询)、K(键)、V(值)三个向量,然后用Q和K做点积得到注意力权重,再用权重去加权V。点积结果除以sqrt(d)是为了防止维度变大后数值爆炸,因为点积会随维度增大而增大。多头注意力就是把这个过程切成多份并行执行,让模型能同时关注语法关系、语义关系、共现关系等不同维度。
每个Transformer层里除了注意力,还有前馈网络、层归一化和残差连接。残差连接让梯度可以更顺畅地在深层网络中回传;层归一化稳定每层输入的分布,避免训练过程数值剧烈波动;前馈网络给模型提供非线性变换能力。把这些基本单元堆叠若干层,就构成了现代大模型的骨干。
从零写代码时,我建议按照“单头注意力→多头注意力→完整Block→多层堆叠”的顺序逐步增加复杂度,每步都用简单的测试验证输出形状正确,再继续下一步。这个顺序看起来慢,实际上比直接抄完整代码快得多,因为每一步出错都能立刻定位。
2.3 第三关:训练流程不是“跑一下”那么简单
很多教程说“训练一个语言模型”,好像就是把数据喂进去、等loss下降、完事。但真实训练的复杂度比这高得多。我在训练小模型时主要关注以下几个环节。
预训练阶段,目标是让模型学会预测下一个token。这步需要海量文本和长时间训练,但在“from scratch”的学习场景里,用几百MB到几个GB的语料训练一个小尺寸模型,就已经能验证整个流水线。关键不是把loss降到很低,而是每一步都跑通:数据能正确加载、前向传播形状正确、反向传播梯度合理、checkpoint能保存恢复。
指令微调阶段,目标是让模型学会“听指令”。做法是在高比例的指令-回答数据上继续训练,让模型从“接续文本”转变成“回答问题”。这个阶段数据量不需要很大,几万条高质量样本就足够让模型行为发生显著变化。
对齐阶段,目标是让模型输出符合人类偏好。最简单有效的方式是DPO(Direct Preference Optimization),它不需要单独训练一个奖励模型,只需要成对的“好答案-坏答案”数据。如果要做更贴近论文里前沿方向的推理能力,则需要引入强化学习,这部分我在第4章单独讲。
训练过程中的工程细节同样不能忽略。混合精度训练能大幅降低显存并加速;梯度累积可以把小batch模拟成大batch;学习率调度普遍用warmup加余弦衰减;梯度裁剪防止训练后期梯度爆炸。这些不是锦上添花,而是稳定训练的必要条件。我在实际训练中,宁可损失一点速度,也要把梯度裁剪和状态记录打开,因为谁也不想训练到第2000步时眼睁睁看着loss变成NaN。
2.4 第四关:推理优化,模型能用和模型好用是两码事
训练完模型只是第一步,真正上线需要解决推理效率问题。自回归生成的特点是逐token生成,模型每生成一个新token,理论上要把之前的整个序列重新算一遍注意力。如果没有任何优化,成本随序列长度线性增长,时间长到无法接受。
KV Cache是解决这个问题的核心手段。在生成过程中,前序token的K和V向量被缓存下来,当前token只需要计算自己的Q、K、V,然后与缓存的K、V做注意力计算即可。这个优化把每一步的计算量从“整段序列重算”降到“单个token计算”,是推理引擎的基础能力。我在自己写推理函数时,第一版没有加KV Cache,生成50个token都慢得让人崩溃;加上之后速度提升了将近一个数量级。建议所有学习者在实现推理代码时,第二版就要把KV Cache加上,这是性价比最高的优化之一。
量化是另一个必备手段。模型权重默认是FP32或BF16,每个参数占用4字节或2字节。INT8量化可以把参数压缩到1字节,INT4更少。代价是轻微精度损失,但换来显存占用和推理速度的显著改善。GGUF格式就是围绕量化设计的,llama.cpp也依赖这个格式跑本地模型。选择量化级别时要看任务对精度的敏感程度:代码生成、数学推理对精度比较敏感,推荐INT8;一般聊天场景INT4基本够用。
推理引擎的选择也有讲究,llama.cpp适合单机低延迟场景,vLLM适合高并发服务化场景,因为后者实现了PagedAttention和连续批处理,能大幅提升吞吐。后面在第5章我给出了一张更细的选型对照表,这里先记住一个结论:没有最好的引擎,只有最适合当前场景的引擎。
2.5 第五关:评估不是跑个benchmark就完了
模型做完了,究竟行不行?很多人直接跑几个公开benchmark,分数看着不错就以为自己成功了。但benchmark只覆盖有限能力,而且很容易过拟合——模型见过测试集相关文本后,分数虚高是非常常见的现象。
我的评估习惯是至少做四层。第一层是标准benchmark,比如语言理解、数学推理、代码生成的公开评测集,用来和大模型baseline做粗对比。第二层是定制测试集,围绕你的实际业务场景,手工构造50到100条有代表性的输入,覆盖正常、边界、误导性三种情况。第三层是人工评估,找几个不同背景的人盲测输出,用1到5分打分,重点看流畅度、事实性、格式规范。第四层是上线后的在线评估,记录用户反馈、纠错率、平均对话轮数等指标。
这个观念特别重要:模型不是训练完就万事大吉,它是要放到业务里被人用的。只有建立起多维度的评估体系,后续的模型迭代、数据补充、微调才有方向和依据。很多人觉得评估是项目结尾才做的事,其实评估设计应该从项目一开始就规划好,否则你可能训练了一个自己都说不清好坏的黑盒。
3. 动手实操:从零训练一个小型语言模型的完整流程
3.1 硬件选型与参数量估算:先算账再动手
先说硬件。如果你在个人电脑上跑,没有GPU也能完成绝大部分代码开发和调试,只是实际训练会比较慢。想真正训练一个有意义的语言模型,我建议至少有一张6GB显存以上的显卡,显存不够就用云GPU按需租用,成本可控。关键在于先算清“显存账”:模型的参数量乘以每参数字节数,再加上优化器状态、梯度和激活值,才是完整的显存需求。
举个例子,一个参数量大约110M的GPT模型,参数本身在FP32下约440MB;AdamW优化器会额外保存一阶动量、二阶动量和参数副本,大约是参数量的3倍,也就是1.3GB左右。梯度再占一份几百MB。加上batch中的激活值、序列长度带来的KV Cache,总显存轻松超过2.5GB。如果换成7B模型,光参数用INT8部署也要7GB以上,训练则需要更多卡。所以学习阶段,参数量控制在几十M到几百M之间最合适,跑通流程、理解原理优先。
上面的计算方式可以套用到任何规模的模型。很多同学问“为什么我一用大模型就OOM”,本质上就是因为只算了参数显存,没算优化器和激活。做工程不先把这笔账算清楚,后面每一轮调试都在盲目试错。我习惯在启动训练前写一个小脚本,打印出模型参数量、预计显存占用和数据集token数,一目了然。
3.2 数据准备:从开源语料到干净训练集
训练数据我推荐直接用公开可获取的开源语料,比如The Pile的子集、中文维基的清洗版本,或者HuggingFace上公开的各类语料。不要自己从零爬取网页,耗时费力还容易引入大量噪声。拿到原始语料后,我的清洗流程是这样:
第一,按行或按段落切分,去除空行和明显乱码。第二,使用规则过滤掉包含大量HTML标签、广告词、重复内容的段落。第三,做全局去重,重复文本会严重拉低预训练的信息密度。第四,统计文本长度分布,过滤掉太短和超长的片段。最后,根据任务目标做配比,比如聊天语料和无结构文本的比例,代码和自然语言的比例。
清洗数据的标准很简单:你最好能随机抽查100条样本,用肉眼判断质量,如果超过八成文本是人类读起来通顺、有意义的内容,这批数据基本合格。我个人的经验是,清洗脚本写完先跑小样本看输出,再全量处理,否则几GB数据跑完才发现过滤规则太严或太松,返工成本非常高。这一步值得耐心,因为训练数据的质量会在后面成倍地反馈到模型效果上。
3.3 从零写一个GPT骨架:核心代码的每一段都清楚
接下来是重头戏。我给出一个用于学习的最小GPT实现,注释里写清楚每个组件的用途。这个版本不追求性能,只追求结构清晰、可运行。先看多头注意力部分:
import torch import torch.nn as nn class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout=0.1): super().__init__() assert d_model % n_heads == 0 self.d_head = d_model // n_heads self.n_heads = n_heads # 统一投影,之后切分多头 self.qkv = nn.Linear(d_model, 3 * d_model, bias=False) self.out_proj = nn.Linear(d_model, d_model, bias=False) self.dropout = nn.Dropout(dropout) def forward(self, x): B, T, C = x.shape qkv = self.qkv(x) # B, T, 3C q, k, v = qkv.chunk(3, dim=-1) # 切分多头: B, T, n_heads, d_head -> B, n_heads, T, d_head q = q.view(B, T, self.n_heads, self.d_head).transpose(1, 2) k = k.view(B, T, self.n_heads, self.d_head).transpose(1, 2) v = v.view(B, T, self.n_heads, self.d_head).transpose(1, 2) # 注意力分数 att = q @ k.transpose(-2, -1) / (self.d_head ** 0.5) # 因果掩码:只允许看过去和当前 mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool() att = att.masked_fill(mask, float('-inf')) att = torch.softmax(att, dim=-1) att = self.dropout(att) out = att @ v # B, n_heads, T, d_head out = out.transpose(1, 2).contiguous().view(B, T, C) return self.out_proj(out)这段代码里最容易被忽略的是因果掩码那一行,它保证模型在预测第t个token时看不到未来的token。如果你删掉掩码,模型会作弊,训练loss会很低,但实际生成质量极差。这是我在教学中最常看到的错误,每次都要专门拎出来说一遍。
然后是前馈网络和Transformer Block。现代大模型普遍采用Pre-Norm结构,也就是先做层归一化,再进入注意力或前馈层,而不是传统Transformer论文里的Post-Norm。Pre-Norm让每层输入被稳定在零附近,深层堆叠时训练更稳定,这就是GPT系列的主流选择。
class MLP(nn.Module): def __init__(self, d_model, dropout=0.1): super().__init__() self.fc = nn.Sequential( nn.Linear(d_model, 4 * d_model), nn.GELU(), nn.Linear(4 * d_model, d_model), nn.Dropout(dropout), ) def forward(self, x): return self.fc(x) class TransformerBlock(nn.Module): def __init__(self, d_model, n_heads, dropout=0.1): super().__init__() self.ln1 = nn.LayerNorm(d_model) self.attn = MultiHeadAttention(d_model, n_heads, dropout) self.ln2 = nn.LayerNorm(d_model) self.mlp = MLP(d_model, dropout) def forward(self, x): # Pre-Norm结构:先归一化再进子层,训练更稳定 x = x + self.attn(self.ln1(x)) x = x + self.mlp(self.ln2(x)) return x最后把词嵌入、位置编码、多层Block和输出层组装成完整模型。这里有一个值得注意的细节:权重绑定,也就是让token嵌入层和输出层的权重共享同一个参数矩阵。它建立在“词嵌入空间和预测输出的表示空间可以共享”的观察上,能将大词表模型的参数量压缩约一整个嵌入层的大小。当然,现代大模型因为词表变大、训练更复杂,逐渐倾向于解绑输出层,但在学习阶段,绑定能显著降低显存压力,是一个合理的简化。
class MiniGPT(nn.Module): def __init__(self, vocab_size, d_model, n_heads, n_layers, max_len, dropout=0.1): super().__init__() self.token_embedding = nn.Embedding(vocab_size, d_model) self.pos_embedding = nn.Embedding(max_len, d_model) self.blocks = nn.ModuleList([ TransformerBlock(d_model, n_heads, dropout) for _ in range(n_layers) ]) self.ln_f = nn.LayerNorm(d_model) self.lm_head = nn.Linear(d_model, vocab_size, bias=False) # 权重绑定:token嵌入与输出层共享权重 self.token_embedding.weight = self.lm_head.weight def forward(self, idx): B, T = idx.shape pos = torch.arange(0, T, device=idx.device).unsqueeze(0) x = self.token_embedding(idx) + self.pos_embedding(pos) for block in self.blocks: x = block(x) x = self.ln_f(x) logits = self.lm_head(x) return logits这个模型的参数量取决于词表大小、d_model和层数。做一个简单的计算:词表5000、d_model=256、n_layers=6、n_heads=8,参数量大约在十几M的量级,配合一小块GPU就能训练。这个规模非常适合做实验,因为迭代速度快,可以在几十分钟内看到效果。
3.4 训练循环与小实验:看曲线说问题
模型定义好之后,训练循环看起来简单,其实藏着大量细节。下面这是我常用的一个训练循环骨架,包含了混合精度、梯度裁剪和warmup:
def train_step(model, batch, optimizer, scaler, grad_clip=1.0): x, y = batch # x为输入token,y为右移一位的目标token with torch.cuda.amp.autocast(): logits = model(x) loss = nn.functional.cross_entropy( logits.view(-1, logits.size(-1)), y.view(-1) ) optimizer.zero_grad() scaler.scale(loss).backward() # 统一梯度范数到clip阈值内,防止梯度爆炸 scaler.unscale_(optimizer) nn.utils.clip_grad_norm_(model.parameters(), max_norm=grad_clip) scaler.step(optimizer) scaler.update() return loss.item()warmup的实现可以用一个简单的lambda函数配合PyTorch的LambdaLR:前若干步学习率从零线性升到目标值,之后按余弦曲线衰减。warmup的意义在于,训练初期模型参数还是随机状态,如果一上来就用很大的学习率,梯度方向非常不稳定,可能把参数冲到损失面剧烈震荡的位置;先小学习率走一段,让模型找到大体方向再提速,整个训练就平稳多了。
训练时我最关注的指标有四个:loss值、梯度范数、学习率、以及每若干步在固定验证集上的困惑度。loss平滑下降说明训练健康;梯度范数突然飙升往往预示即将发散;验证困惑度和训练困惑度差距越来越大,说明过拟合,该增加数据多样性或提高dropout。
我建议第一个实验不要贪大,用几十M参数的模型、几百MB的语料、适中的batch size,目标是让loss稳定下降到明显低于随机初始水平,并且能生成通顺的短句。这一步走通,你对数据、训练、采样全流程的掌控感就建立起来了。之后再逐步加数据、加模型容量,你会发现每一次改动都更有依据,而不是靠运气。
4. 进阶实验:把普通模型训练成会“推理”的模型
4.1 推理模型和普通生成模型的本质区别
现在AI圈最热的方向之一,已经从“会聊天”变成了“会推理”。所谓推理模型,不只是给你一个答案,而是会在内部生成一段思考过程,经过尝试、验证、纠正之后,再输出最终答案。它模仿的是人类做复杂题目时“先写草稿纸推演,再誊写答案”的过程。
从工程角度看,推理模型和普通生成模型的主要区别有两层。第一层是输出格式:推理模型在最终答案前增加了一段思考链条,这部分内容更像自言自语,而不是直接面向用户的输出。第二层是训练方式:仅靠语言模型的next token预测很难自发涌现这种长链条的自我校验能力,需要通过强化学习让模型发现“先思考再作答”能带来更高的最终奖励。
这对于“from scratch”学习路径来说,是一个绝佳的进阶实验。因为推理能力的形成不一定需要从零预训练一个超级大模型,而是在一个已经学会文本生成的中小模型基础上,用规模可控的强化学习去塑造行为。你可以在单张消费级显卡上跑完这个小实验,同时还能在实操中把强化学习这门AI工程里比较难啃的骨头啃下一角。
4.2 一个可落地的小规模强化学习训练流程
做这个小实验,基础模型建议先用第3章训练好的MiniGPT,或者从HuggingFace找一个1B以下的开源模型。然后准备一个数学或逻辑类的简单任务集,比如求两位数加法的结果,或者根据前提判断结论是否正确。关键是要有可自动判别的标准答案,因为规则奖励是强化学习的信号来源。
整体训练流程分三步。第一步,用带“思考过程+最终答案”格式的样本做短期的监督微调,让模型先学会这种输出范式;第二步,用规则奖励定义得分,只对最终答案正确性评分,不做中间步骤的复杂评判;第三步,用策略优化方法训练,一个相对容易理解的简化实现是:对每个输入采样多个输出,用奖励信号直接加权更新策略,同时固定基础模型作为参考模型,通过KL散度约束让当前模型不会走太远。
伪代码大致是这样的:
for step in range(total_steps): batch = sample_tasks() outputs = model.generate(batch, num_return_sequences=4) rewards = [rule_reward(out) for out in outputs] # 用相对奖励做加权,简单版本直接使用带正负激励的rewards loss = policy_loss(model, outputs, rewards, ref_model) optimizer.zero_grad() loss.backward() optimizer.step()训练中最常见的三个坑,我提前说。第一是reward hacking,模型会找到规则漏洞,比如输出格式满足解析要求但答案是随便拼的,所以奖励函数一定要定义严格、测试充分。第二是熵崩溃,随着训练推进,模型策略可能过早固定,多样性骤降,要在loss里保留一定的熵正则或动态调整采样温度。第三是思考链条退化,模型可能学会“假思考”,也就是思考过程和最终答案完全脱节,这种情况下需要引入对思考过程的弱监督,或者提升任务难度让模型必须有真实推理才能答对。
这一步做完,你就已经从“训练一个会说人话的模型”,进阶到“训练一个会解决问题并自我校验的模型”。这个能力在业界是增量价值很明显的一项,也是面试时能让你和其他候选人拉开差距的实战经验。
5. 部署上线:让模型跑进业务的落地细节
5.1 模型导出:从PyTorch到GGUF
第3章训练出来的模型是PyTorch格式,它在开发调试时很方便,但直接用于生产服务并不合适。官方文档喜欢推荐ONNX,但实际业务里需要根据部署平台做选择。如果要在CPU上跑推理,或者想利用llama.cpp的实现效率,我建议转成GGUF格式。
转换流程有固定路径:先把PyTorch模型参数转成HuggingFace的safetensors格式,然后用llama.cpp仓库自带的转换脚本生成F32或F16的GGUF,最后用其提供的量化工具压到目标位宽。比如llama.cpp仓库里就有convert_hf_to_gguf.py这个现成脚本,一步步执行即可。看起来步骤多,但每一步都有成熟脚本,关键是把模型结构定义和预训练权重路径填对。转换完成后,先本地加载跑几个case,验证输出是否和PyTorch原始版本一致,再进入服务化阶段,这一步千万别省。
从零构建的项目走到这一步,往往是学习曲线最陡、成就感也最强的时候。你亲手训练出来的模型,终于被封装成一个标准格式,可以被各种推理引擎加载运行了。
5.2 服务化选型:llama.cpp还是vLLM
模型准备好之后,怎么把推理能力暴露给业务方?最快的方式是用llama.cpp编译得到的server程序,它自带一个OpenAI兼容的HTTP接口,配置好模型路径、端口和显存限制就能启动,适合原型验证和低并发场景。但要应对高并发、多用户,vLLM是更主流的选择,它通过连续批处理和PagedAttention让GPU利用率大幅提升。
下面这张选型对照表,可以帮你根据场景快速做出判断:
| 维度 | llama.cpp | vLLM |
|---|---|---|
| 典型场景 | 本地单机、CPU/小显存 | 高并发在线服务 |
| 批处理能力 | 无或有限 | PagedAttention连续批处理 |
| 量化支持 | GGUF原生支持 | AWQ/GPTQ等 |
| 部署复杂度 | 低 | 较高 |
| API兼容 | OpenAI兼容 | OpenAI兼容 |
我在项目中给同学的建议是分阶段选择。第一阶段,只要求能通,llama.cpp足够了;第二阶段,压力测试显示并发超过某个阈值,果断迁移到vLLM。迁移成本并不可怕,因为二者都提供OpenAI兼容接口,业务代码不需要改动。真正需要重新调的是推理参数,比如max_tokens、temperature、top_p,这些值在切换引擎后要做小范围实验,不能直接照搬。
另外,无论用哪个引擎,都要注意用与业务一致的tokenizer,尽量保持和训练时相同的prompt模板。很多线上推理效果奇怪,原因不是模型本身,而是prompt格式和微调时不一致,导致模型行为发生漂移。这个坑我踩过一次之后,每次部署前都会先跑20条回归用例。
5.3 服务化部署的工程细节:显存、并发、可观测性
本地跑通和稳定上线之间的距离,往往表现在工程细节上。显存管理首当其冲:模型加载后要监控空闲显存,vLLM用gpu_memory_utilization控制显存利用率,llama.cpp用n_gpu_layers控制模型哪些层放到GPU。这两个参数都要根据显卡规格和并发预期提前算好。
然后是流式输出。聊天场景几乎必须支持流式返回,否则用户要等模型生成完所有token才能看到内容,体验极差。OpenAI兼容接口本身支持stream模式,业务端用SSE接收即可,但要注意代理和网关层对SSE连接时间的限制,超时配置不合理会导致长输出中途断流。
可观测性也是容易被忽略的一环。上线前至少把三类指标接好:请求成功率、平均首token延迟、平均生成token速率。没有这些指标,出了问题只能靠用户截图反馈,效率太低。我习惯在服务启动时同步打印模型配置、显存占用、量化位宽,这样每次排查问题时,能先确认线上版本与预期一致,再往下查。
另外,模型服务的版本管理也值得提一句。模型文件的体积比普通代码大几个数量级,不可能像代码一样频繁提交到仓库。比较靠谱的做法是给每个模型版本打上明确标识,比如基于训练日期、数据版本、参数量生成一个模型名,同时把对应的tokenizer、prompt模板、评测结果记录在一个配置文件里。这样线上服务加载的模型,随时能回溯到它“出生”时的完整状态。
到这里,模型已经从训练机里的实验品,变成了一个可以被真实用户调用的服务。这中间的每一步距离都不长,但缺少任何一步,模型都走不出开发环境。
6. 高频问题排查与避坑实录
6.1 训练阶段的高频问题:从loss停滞到OOM
在这个项目实践过程中,我记录过不少自己和新手同学反复踩的问题,整理成一张速查表,比单讲理论直观得多:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| loss基本不下降 | 学习率过低或数据没配对 | 检查tokenizer ID和标签对齐,适当调高学习率 |
| loss剧烈震荡或NaN | 学习率过高、数据有异常值、精度不稳定 | 降低学习率、加梯度裁剪、检查GPU精度支持 |
| 显存OOM | 模型、优化器、激活、KV Cache总占用超限 | 减小batch size、开梯度累积、开混合精度、梯度checkpoint |
| 验证loss与训练loss差距大 | 过拟合 | 增加数据多样性、加大dropout、早停 |
| 生成内容全是重复 | 数据本身重复或采样温度过低 | 清洗数据、调高temperature、改用top_k采样 |
loss停滞是新手最焦虑的场景。我的排查顺序是:先检查数据管道,用一个小batch打印模型的输入和标签,肉眼确认第t个token的标签确实是第t+1个token;再检查学习率,如果日志记录的学习率一直很小,可能是调度器被错误叠加了;最后才怀疑模型结构。这条排查顺序能省很多时间。
NaN问题比loss停滞更吓人。通常做法是把输入数据缩放到合理范围,降低学习率一个量级,开启混合精度但用BF16而不是FP16。BF16动态范围更大,训练稳定性明显好于FP16,这也是现代大模型训练普遍选择BF16的原因。如果你用的GPU支持BF16,直接换上就好,能规避很多数值问题。
6.2 部署阶段的高频问题:并发、延迟与输出质量
部署上线后常见问题完全换了一批:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 并发上升后响应变慢 | 模型执行推理时排队 | 用vLLM连续批处理、提高并发上限、增加副本 |
| 首token很久才出来 | 预填充阶段计算量过大 | 截断超长输入、量化KV Cache、换更强推理引擎 |
| 输出偶发乱码或中断 | 采样参数冲突或SSE超时断流 | 检查tokenizer、更新超时配置、降低采样温度 |
| 显存持续上涨 | 缓存未释放或流式累积 | 限制max_tokens、定期重启、用监控排查泄漏源 |
这里我要重点强调一个问题:很多部署端“模型变傻了”的案例,最后查下来根本不是模型问题,而是prompt模板和训练时不匹配。比如微调时用了带“用户:”和“助手:”标记的模板,线上却没有沿用,模型的对话行为就会变得很怪。所以我在部署阶段会保留一个最简单的回归测试:把训练时表现最好的20条对话记录成测试集,每次配置变更都跑一遍,比对输出相似度。这套回归用例是排查线上质量问题最快的抓手。
6.3 项目推进建议:先窄后宽,先跑通再优化
最后分享我对这类“from scratch”项目推进节奏的建议。人在面对一个庞大的体系时,最容易犯的错误是试图追求一次性完美。我在带项目时始终强调一个原则:先窄后宽,先跑通再优化。
具体可以拆成三个milestone。第一个milestone,用最小的模型和最少的数据跑通全流程,哪怕生成的文本完全不通顺,只要训练、评估、部署全部打通,就算成功。第二个milestone,提升数据质量和模型容量,让生成结果在语法和语义上变得合理。第三个milestone,才加入推理训练、并发部署、量化加速这些工程优化。每个milestone都有明确产出,遇到问题能定位到具体环节,心理压力也小得多。
如果真的要学习《Build a Large Language Model (From Scratch)》这类书,我也建议遵循同样的节奏。先把书中代码完完整整跑一遍,理解每个函数的作用,再尝试换数据、调参数,最后尝试改架构。书是路径,不是终点,你亲手跑通的最后那个版本才是你能力的标尺。
我个人在这个项目里最大的收获,不是写出了一堆能跑的代码,而是建立了一种“面对未知领域也能靠拆解和验证推进”的信心。当你亲手从空目录开始,完成数据清洗、模型实现、训练调优、部署上线这条完整链路后,再去看任何AI相关的框架、论文或者产品方案,观察角度都会完全不同。你会自动去想:它的数据是怎么处理的?训练策略是什么?推理成本怎么控制?这种底层视角,恰恰是“from scratch”这条路最值钱的回报。