1. 为什么我仍然推荐“从零手搓”的方式来学AI工程
先交代一下背景。我接触AI工程这条路,是从几年前拿着一个模型推理项目开始的——那时候市面上还没有这么多开箱即用的平台,想调通一条推理链路,得自己处理数据、自己写训练脚本、自己排查显存溢出,每一步都是硬功夫。如今AI工程这个词被各类工具、框架、平台推得很高,很多新人一上来就套着现成的能力去“组装产品”,我见过太多简历上写着“熟练使用大模型API”“做过Prompt调优”的人,遇到一个需要自己训练、自己部署、自己优化性能的场景就直接懵掉了。
这让我越来越确信一件事:尽管现在造的轮子很多,但从零完整走一遍AI工程的全流程,仍然是判断一个工程师是否真正理解这套体系的最佳方式。我不否认直接调用成熟能力可以快速交付业务价值,但如果你没有亲手搭建过数据管线、没有写过训练循环、没有亲自处理过推理延迟和模型体积的取舍问题,那你的认知边界就永远停在“能跑”而不是“知道它为什么能跑、怎么才能跑得更好”。
我接触到“ai-engineering-from-scratch”这个实践路径时,第一反应就是它把整个学习过程重新拉回了本质——数据、模型、训练、评估、部署,五个环节全都自己做,不跳过任何一个。这篇文章我想用自己的实操经历,把这条路径拆开来讲:环境怎么搭、数据怎么准备、模型怎么从零构建、训练要注意什么、部署有哪些暗坑。适合对AI工程有好奇心、但还没完整跑通过一条链路的人参考,当然如果你已经在用别人的框架和平台,这篇文章也能帮你补上底层视角,之后再做技术选型时会更有底气。
很多人质疑“从零搭建模型”在海量开源模型面前还有什么意义,我的看法是:意义不在最终产出的模型比开源模型更强,而在于你知道每一步为什么这样做。就像学汽车维修的人不会因为现在造车技术成熟就跳过拆发动机的练习。你拆过、装过,才知道引擎盖下面到底发生了什么。AI工程也是一样,别人给你一个微调好的模型,你只有亲手把训练流程跑通一遍,才有能力判断它适合不适合你的业务、还能不能再优化一点。
下面我按一条完整的落地路径来讲,从环境准备开始,逐层深入,最后落回到实际部署和避坑总结。每一节的内容都是我在实际操作里沉淀过的经验,不是教科书式的概念堆叠。
2. 本地环境搭建:CUDA、Python环境和训练框架的取舍
2.1 硬件选型:显存大小直接决定你能碰多大的模型
先说硬件。很多人一上来就想训练大模型,但手里只有一张消费级显卡,这就得先面对现实:显存决定了模型规模的上限。拿我自己的实践来说,最开始我用的是12GB显存的卡,训练一个参数量在1亿以内的Transformer模型勉强够用,但再往上加层数或序列长度就得靠梯度累积和混合精度来硬撑,训练速度慢得让人怀疑人生。
如果只是跑通流程、验证学习链路,有个8GB到12GB显存的GPU就已经能覆盖大部分练手场景了。如果你打算做一些中小规模的生成模型或稍微大一点的分类模型,24GB以上的显存会更舒服。实在没有GPU,用云GPU实例也是一种选择,只是要注意费用控制,别让一次练手跑掉几百块。
显存计算有个粗略公式:模型参数量乘以参数精度字节数,再乘以优化器状态和梯度占用的倍数,大致就能估算出单卡训练最低需求。比如一个1亿参数的FP16模型,参数本身约200MB,但Adam优化器需要额外保存状态,算下来可能就要占用1GB以上的显存,再加上激活值,实际占用还会成倍上涨。实践里我习惯先用一个很小的批次把模型和数据加载起来,观察显存占用,再逐步调大批次,直到接近上限。
2.2 开发环境配置:conda、CUDA和PyTorch的版本匹配是关键
说句实话,AI工程里最容易让人崩溃的环节不是模型设计,而是环境配置。CUDA、cuDNN、PyTorch、Python版本之间的兼容关系,稍微错一个版本就能让你在import的时候直接报错,报错信息还往往看不懂。
我的做法是固定一套经过验证的版本组合。以我当前的主力环境为例:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.10 | 兼容性最稳妥的版本区间 |
| CUDA | 12.1 | 驱动版本需要支持该CUDA版本 |
| PyTorch | 2.1.x | 自带对应CUDA的预编译包 |
| 训练框架 | 原生PyTorch + HuggingFace生态 | 灵活且可控 |
| 包管理 | conda | 环境隔离,避免互相污染 |
关键坑在于:PyTorch的安装命令里要带正确CUDA的index-url,否则它会默认安装CPU版本,训练慢到让你怀疑代码有问题。我第一次没注意这个,直接pip install torch,结果训练一个epoch要两天,后来才发现装的是CPU版。所以装完先跑一句torch.cuda.is_available(),确认输出是True再往下走。
我推荐用conda建独立的虚拟环境,不要一股脑把项目依赖装进base环境。因为不同项目可能依赖不同版本的numpy、pandas、甚至PyTorch,混在一起迟早出事。我后来养成的习惯是每个项目新建一个环境,虽然磁盘占用多一点,但排查问题的时间省了不止一半。
2.3 选原生PyTorch还是封装好的框架
现在训练框架很丰富,HuggingFace的Trainer、PyTorch Lightning、FastAI都提供了很友好的抽象层。但对于“from scratch”的学习目的,我强烈建议至少完整手写一次训练循环,不要一上来就用Trainer把细节都隐藏掉。
手写训练循环其实没有想象中那么难,核心就是这几步:遍历数据、前向传播、计算损失、反向传播、更新参数、查看指标。把这几步写明白,你才算真正理解一个模型是怎么被“训练”出来的。之后再切换到Trainer之类的封装工具,遇到问题也知道它内部在做什么,不会一脸黑线地乱改参数。
当然我也不是让大家永远手写。等跑通一两条链路后,切换到高级封装可以明显提高实验效率。关键是顺序不能颠倒:先理解底层,再用高层工具。这个观点可能有人不同意,觉得有省力的工具为什么不用,但我的亲身体会告诉我,省力工具省的是重复劳动的力,不是理解原理的力,直接用封装工具最后往往会陷入“调参靠猜、报错靠百度”的尴尬局面。
3. 数据是整个工程的地基:从公开数据集到清洗管线
3.1 别把数据当配角,它的优先级应该是最高的
很多人学AI工程时,把大部分注意力放在模型结构上——今天Transformer、明天注意力机制、后天扩散模型,研究得很上头。但真实项目里,模型占比可能只有20%的时间投入,数据清洗、标注、管线建设反而吃掉了一大半工作量。甚至可以说,决定一个AI系统上限的往往不是模型,而是数据质量。
我印象很深的一次经历:训练一个文本分类模型,用了同一个模型结构、同一套超参数,只是换了一批数据,一个在验证集上F1达到0.89,另一个只有0.72。差距全在数据质量上——一份干净、均衡、贴合业务分布,另一份包含大量错误标签和重复样本。从那以后我再也不在做数据这件事上压缩时间。
3.2 从哪里拿数据、怎么判断数据能不能用
公开数据集平台主要有几个:Kaggle、HuggingFace Datasets、UCI等。HuggingFace Datasets的优势在于它有一个统一的加载接口,很多数据集可以用一行代码下载并预处理成标准格式,对练手项目非常友好。
不过公开数据集在真实项目里往往不够用,这时候就得自己动手收集和构造数据。比如做一个客服意图分类,公开数据集里可能没有你的业务场景,你需要从历史工单、聊天记录里抽样本,再人工打标。这个过程很苦,但也是数据工程能力的核心——你如何把一堆非结构化的原始文本变成干净的训练语料,直接决定了后续模型表现。
数据检查的四个基本维度:
- 数量够不够:分类任务每个类别至少几百条起步,生成任务则需要更大规模
- 标签准不准:随机抽几十条人工复核,标签错误率超过5%就要考虑清洗或重新标注
- 分布均不均衡:类别严重倾斜时考虑过采样、欠采样或重新设计评估指标
- 有没有泄漏:训练集和验证集里出现重复样本,或者验证集包含了训练时才能看到的信息,都会导致评估结果虚高
3.3 构建一条可以复用的数据清洗管线
数据清洗不是一次性脚本,而是应该构建成可以反复执行的处理管线,尤其是当数据源持续更新时。我的习惯是分几步处理:
- 去重:同一文本重复出现会加剧过拟合,用哈希或SimHash做第一层去重
- 过滤:长度异常、包含乱码、明显非目标语言的样本一律剔除
- 标准化:统一大小写、处理多余空格和换行,文本类任务通常还需要分词或子词切分
- 标签清洗:对不一致的标签做规则批处理,再人工抽检
- 划分数据集:按训练集、验证集、测试集划分,划分顺序要以文本ID或哈希值为依据,避免随机种子带来的不稳定
做完管线后,我强烈建议把每个阶段的样本数量变化记录下来——原始数据多少条、去重后多少条、过滤后多少条。一方面方便排查管线哪里吞了不该吞的数据,另一方面也能让你对数据质量有一个量化的感知。很多工程师忽略这个细节,等模型效果差的时候复盘,根本不知道是哪一步处理出了问题。
3.4 数据增强和少样本场景的补数技巧
在真实工业场景里,高质量数据永远是稀缺的。少样本场景下有几个简单实用的补数技巧。第一是回译增强,把中文文本翻译成英文再翻译回中文,虽然有时会改变语义,但能得到一大批语义相似、表达不同的样本。第二是关键词替换,在保留原句结构的前提下,把核心词替换为同义词或关联词。还有一种是基于语言模型的重写,借助一个预训练语言模型生成同义改写,但需要注意人工审核,防止生成出错误标签或矛盾信息。
不过我要强调一个底线:数据增强不能改变标签语义。一个“客户投诉物流慢”的样本,增强后变成了“客户抱怨配送延迟”,标签依然是“投诉”没问题;但如果改成了“客户询问物流时长”,那就变成“咨询”了,硬用原标签训练,等于在教模型理解错误关系。这是最典型的坑之一。
4. 模型从零构建:以“推理模型”为例的完整搭建过程
4.1 “推理模型”到底是什么:从LLM到reasoning model的演进逻辑
最近“build a reasoning model from scratch”这个话题在AI社区里热度相当高。所谓推理模型,简单来说就是让模型不只生成“看起来流畅”的文本,还要具备一步步推导的过程能力。比如你问一个大语言模型“为什么天空是蓝色的”,普通模型可能直接给出一段百科式答案;而推理模型会先内部拆解问题,分步骤组织答案链路,最终给出的内容更接近人的思考过程。
从技术角度拆解,推理能力的核心来自两个层面。一是模型本身的参数规模与训练数据是否足够丰富,让它可以隐含学到“多步推理”的模式;二是在训练策略上做了强化学习或监督微调,引导模型在推理路径上做优化。很多推理模型在基础生成能力之上,加入了类似“思维链”的训练数据,让模型学会把自己内部的推导过程逐步呈现出来。
对于从零构建一个推理模型的练手项目,我建议不要一上来就挑战几百亿参数的大规模模型。一个参数量在几亿到十几亿的模型已经足够用来理解核心机制。重点在于:你会不会构造推理类型的训练数据、能不能设计合理的训练目标、有没有能力评估模型到底在“推理”而不是“背答案”。
4.2 准备“推理数据”:思维链数据怎么构造、怎么清洗
训练一个推理模型,数据构造的复杂度比普通文本生成高一个量级。普通文本生成只需要“文本”作为训练目标;推理模型需要“问题-推理过程-最终答案”这样的三元组结构。目前公开的推理数据源并不多,比较可行的做法有三种。
第一种是从问答社区抓取带详细解析的题目,例如数学类解答、逻辑推理类题目,把解析过程拆解成结构化的推理链。第二种是用现成的强推理模型生成“教学式”推理过程,比如让一个大模型对一道题目输出步骤推导,再人工校验后作为训练数据。第三种是手工构造一整套带有明确规则的小规模推理任务,比如数学应用题、多跳问答、符号推演等,每个样本都由人工编写推理步骤。
数据清洗在推理任务里尤其重要,原因很直接:推理过程只要错了一步,最终结果就不可信。我处理这类文本时有三条硬规则:
- 推理过程的每一步必须与最终答案严格一致,不允许存在前后矛盾的步骤
- 推理链的中间步骤必须完整,不能跳跃关键推导,否则模型学到的就是“结果正确但过程残缺”
- 同类任务的数据格式要尽量统一,比如数学题就统一用“设局→列式→计算→验算”的格式,减少模型在格式映射上的负担
4.3 搭建Transformer主干:从零实现的代码骨架
这里我用一段可以实际运行的PyTorch代码来演示从零搭建一个小型Transformer模型。这个框架既可以用来做文本分类,也可以稍作改造用于序列生成。核心是理解几个关键组件的组装方式:嵌入层、位置编码、多头注意力、前馈网络、残差连接和层归一化。
先看看我实际跑通过的一个小型Transformer代码骨架:
import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model: int, max_len: int = 512): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer("pe", pe.unsqueeze(0)) def forward(self, x: torch.Tensor) -> torch.Tensor: return x + self.pe[:, : x.size(1), :] class SelfAttention(nn.Module): def __init__(self, d_model: int, n_heads: int): super().__init__() assert d_model % n_heads == 0, "d_model must be divisible by n_heads" self.d_model = d_model self.n_heads = n_heads self.head_dim = d_model // n_heads self.wq = nn.Linear(d_model, d_model) self.wk = nn.Linear(d_model, d_model) self.wv = nn.Linear(d_model, d_model) self.wo = nn.Linear(d_model, d_model) def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor: batch_size, seq_len, _ = x.size() q = self.wq(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2) k = self.wk(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2) v = self.wv(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim) if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) attn = torch.softmax(scores, dim=-1) out = torch.matmul(attn, v).transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model) return self.wo(out) class TransformerBlock(nn.Module): def __init__(self, d_model: int, n_heads: int, d_ff: int, dropout: float = 0.1): super().__init__() self.attn = SelfAttention(d_model, n_heads) self.ff = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model), ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor: x = x + self.dropout(self.attn(self.norm1(x), mask)) x = x + self.dropout(self.ff(self.norm2(x))) return x class TinyTransformer(nn.Module): def __init__(self, vocab_size: int, d_model: int, n_layers: int, n_heads: int, d_ff: int, max_len: int = 512, dropout: float = 0.1): super().__init__() self.embed = nn.Embedding(vocab_size, d_model) self.pe = PositionalEncoding(d_model, max_len) self.blocks = nn.ModuleList([TransformerBlock(d_model, n_heads, d_ff, dropout) for _ in range(n_layers)]) self.norm = nn.LayerNorm(d_model) self.head = nn.Linear(d_model, vocab_size) def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor: x = self.embed(x) * math.sqrt(self.embed.embedding_dim) x = self.pe(x) for block in self.blocks: x = block(x, mask) return self.head(self.norm(x))这里要特别解释几个看起来不起眼但很容易出错的设计决策。
位置编码用的是经典的sin/cos函数式,而不是可学习的位置编码。原因是它在序列长度超出训练长度时有一定的泛化能力,对练手项目来说接口也更稳定。如果你的任务里序列长度经常变,这个设计会比可学习的版本更省心。
多头注意力的实现里有一个常见细节:先做线性变换得到Q、K、V,然后拆分头,在拆分后做注意力计算,最后合并头再经过输出线性层。很多初学者把“多头”错误理解成多套独立的QKV权重,其实是在同一个线性层输出上拆分维度,这样既省参数又达到了让人关注不同子空间的目的。
残差连接和层归一化的顺序也有讲究。代码里采用的是“先归一化再进子层、残差连接在外”的写法,这种“Pre-LN”结构在训练稳定性上明显优于“Post-LN”。如果我在练手时把顺序写反,很容易遇到深层模型训练不稳定、loss震荡甚至直接发散的情况。
注意力掩码在自回归推理任务里是关键中的关键。训练时我们不允许模型看到未来的token,所以要用一个上三角矩阵把未来位置掩掉。代码里通过masked_fill把掩码位置置为负无穷,再通过softmax之后这些位置的概率就无限接近零。如果漏掉这一步,模型训练时就会看到答案,表面上loss很好看,实际生成时完全崩溃。这种“训练时偷看答案”的问题在不带掩码的练手项目里非常隐蔽,我踩过一次,花了两天才定位到根因。
4.4 训练循环怎么写:梯度下降、优化器和损失函数的配合逻辑
模型的骨架搭好了,接下来就是训练循环。一个完整的训练循环模板大概长这样:
def train_one_epoch(model, dataloader, optimizer, criterion, device, clip_grad=1.0): model.train() total_loss, total_tokens = 0.0, 0 for batch in dataloader: input_ids = batch["input_ids"].to(device) labels = batch["labels"].to(device) logits = model(input_ids) loss = criterion(logits.view(-1, logits.size(-1)), labels.view(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip_grad) optimizer.step() total_loss += loss.item() * input_ids.size(0) total_tokens += input_ids.size(0) return total_loss / total_tokens这段代码里有两个容易被忽略但极其重要的设计。
第一个是梯度裁剪。训练Transformer模型时,梯度范数在某些batch上会突然变得很大,导致参数更新幅度剧烈、loss爆炸。加上梯度裁剪后,更新的步长被限制了,训练稳定性会明显提升。我自己的经验是:对于中小型Transformer,梯度裁剪阈值设在1.0附近是个比较稳的起步值。
第二个是优化器的选择。文本生成任务里最常用的是AdamW,而不是普通的Adam,区别在于AdamW把权重衰减从梯度更新中解耦出来,效果更好且对学习率的敏感度更低。学习率设置也需要注意,Transformer模型通常需要一个预热阶段,也就是先从一个很小的学习率线性涨到目标值,再按余弦函数衰减。这个warmup机制我一开始不太理解,后来才明白:深层模型在训练初期很脆弱,参数剧烈更新容易让模型陷入坏的局部最优,预热相当于给模型一个“缓慢起步热身”的过程。
4.5 评估:怎么判断推理模型是真的学会推理还是只会背答案
评估是所有人最容易糊弄过去、但恰恰最不该偷懒的环节。对于推理模型,准确率或BLEU这类单值指标远远不够,至少要加上这么几层检查。
第一层是最终答案的正确率。这个不用多说,答案对就是对,错就是错。
第二层是中间推理过程的连贯性。我习惯随机抽取预测结果,人工看推理链的每一步是否和上一步逻辑衔接。如果出现“前一步还在计算A,后一步直接跳到结论B”,说明模型并没有真正学会推导,只是在模仿推理文本的格式。
第三层是对抗性测试。把训练集里常见的数字换掉、把问题换个说法但逻辑结构保持一致,看模型是否还能答对。这叫分布外泛化测试。如果模型只在训练数据上表现好,换一个说法就崩,说明它并没有泛化出“推理能力”,只是在记忆训练样本。这一点对推理模型尤其致命。
我个人的经验法则是:一个能被称作“推理能力”的模型,必须能在未见过的问题表述上达到至少接近训练时水平的正确率。如果只能背训练样本,那它跟一个检索系统没什么本质区别。
5. 训练与调优过程中那些容易忽略的关键配置
5.1 批次大小、学习率和序列长度之间的联动关系
训练一个模型,表面上看超参数似乎可以独立调节,实际它们是互相牵制的。批次大小、学习率和序列长度这三个变量,经常被单独调整,忽略联动关系,最终导致loss不降或显存瓶颈。
先说批次大小与学习率的关系。批次越大,每个batch的梯度越稳定,可以适当把学习率调高;批次越小,梯度越嘈杂,学习率应该相应降低。标准做法是在改变批次大小时,学习率跟着线性或平方根缩放。如果固定学习率不变,只是把批次调大,常常会出现训练初期loss剧烈震荡的问题。
再说序列长度对有效批次大小的影响。同样是“batch_size=32”,序列长度是64还是512,每个batch实际的token数是4倍差异,显存占用和训练步数完全不同。我的习惯是以“每个batch的token总数”为单位来配置一批超参数,而不是盯着batch_size这个数字。这样可以避免不同实验之间的对比失真。
5.2 过拟合还是欠拟合:先观察哪边
训练中判断模型状态,我从不直接看训练集loss,而是同时监控训练集和验证集的曲线。如果训练loss持续下降、验证loss在某个点后开始回升,那就是典型的过拟合;如果训练loss和验证loss都很高且不下降,通常是模型容量不够或者学习率设置不合理,属于欠拟合。
针对过拟合,最直接的调整是加Dropout、增强数据、减小模型容量或增加权重衰减。针对欠拟合,则需要增大模型容量、调高学习率、或者检查数据质量。有一类情况我特别提醒一下:有些项目测试集指标上不去,不是过拟合也不是欠拟合,而是训练集和验证集的分布不一致。比如训练数据大多是短文本,验证数据全是长文本,模型在长文本上表现差是完全正常的。这时候改模型参数没用,要处理的是数据分布问题。
5.3 用早停法和检查点管理训练过程
训练大模型动辄几个小时甚至几天,如果中途挂了或者跑到后面才发现loss已经发散,没有及时存储检查点,那前面所有时间都白费了。我的实践经验是:
- 每跑完一个epoch就把模型参数存到磁盘,文件名带上epoch数和验证集指标
- 保留表现最好的几个检查点,不要只存最新的,因为验证集指标最好的epoch往往不是最后一个
- 开启早停逻辑,当验证集指标连续多个epoch不再提升时,主动终止训练,返回最优检查点
早停的阈值设置也有讲究。设置太严格,可能在指标还在缓慢上升的阶段就提前停了;设置太宽松,又浪费训练时间。我通常看验证loss是否在连续5到10个epoch内不再下降,作为判断依据。如果曲线波动大,还可以选择在平滑后的指标上做早停判断。
5.4 混合精度训练:为什么每张卡都能靠它“多装一倍的模型”
混合精度训练已经成为AI工程的标配技巧,原理很简单:前向和反向计算用FP16来加速和减少显存占用,但优化器更新参数时用FP32来保持精度。实现上,PyTorch自带的torch.autocast和GradScaler已经封装好了大部分细节,代码改动量很小。
我自己的实际体验是,在支持的GPU上开混合精度后,显存占用大约能下降40%左右,训练速度提升30%以上。但要注意两个坑:第一个是有一些算子对FP16非常敏感,计算结果可能溢出,尤其是涉及大数值范围的操作;第二个是首次启用混合精度时,loss可能会出现罕见的NaN,这时需要检查GradScaler是否有收到“inf/nan”的标记,并考虑是不是学习率过高导致的。实践中跑通第一轮后,我通常会对比一下FP32和混合精度在验证集上的指标差异,如果差异很小(比如0.01以内),就放心用混合精度继续跑。
6. 部署上线:把训练好的模型真正变成可用的服务
6.1 部署方式选型:是直接起API服务还是走推理优化框架
模型训练完成之后,很多人就认为项目收工了,但真实世界里,“训练好”和“能用上”之间的距离相当远。部署一个Transformer模型到线上环境,要考虑推理延迟、并发量、显存资源和模型体积,每一步都有坑。
最简单的部署方式是把模型加载进一个Web服务框架,比如用FastAPI包一层接口,pipeline直接调用模型推理。这种方式的优点是代码量小、改动快,适合原型验证和流量很小的场景。缺点也很明显:模型加载到内存后不会自动释放,并发量上来时GPU显存可能直接被打满,推理延迟也会随之升高。
当流量变大后,就需要考虑更专业的推理方案。目前主流的做法有几个方向:一是用ONNX Runtime对模型做导出和加速推理,模型体积通常能压缩,推理速度也有明显提升;二是用TensorRT做深度优化,但其对模型结构的支持有限,不是所有模型都能顺利转换;三是用专门的推理服务框架,比如vLLM,它对语言模型的在线推理做了大量优化,包括连续批处理、PagedAttention机制等。选型的核心依据是你的业务规模和模型类型,不要为了炫技把简单问题复杂化。
6.2 与训练服务化相关的几个容易被忽视的工程点
部署不只是把模型文件放到服务器上然后跑起来那么简单,有几个问题很值得关注。
第一个是把模型结构定义和权重文件管理在一起. 训练时你可能在Jupyter Notebook里定义了模型类,但部署时又要重新写一份,两边稍有偏差就会导致state_dict加载不匹配。我的习惯是训练之初就把模型类放到独立的.py文件里,统一import,部署时直接复用同一个类定义。
第二个是输入数据的预处理链路要和训练时完全一致。训练时你用了什么分词器、什么长度截断策略、什么padding方式,部署时的预处理管线必须严格复刻。这里最容易出bug的地方是tokenizer的版本不一致。同一个tokenizer不同版本的结果可能不同,上线前一定要在测试数据上逐条对比部署环境和训练环境预处理后的ID是否完全一致。这一步我吃过亏,曾因为tokenizer版本更新导致线上推理结果和线下评测结果对不上,排查了很久才发现是预处理不一致。
第三个是推理时的长度控制。生成式模型在线上推理时最大生成长度、温度参数、采样策略都要显式设置,不能依赖训练时的默认参数。不然用户输入一个超长问题,模型可能生成一段又臭又长的回答,既浪费算力又影响体验。通常我会根据业务需求设定一个合理的max_new_tokens,并配合温度参数做一定的随机性控制。
6.3 量化与模型压缩:当显存放不下完整模型时怎么办
部署时经常遇到显存不足的问题,尤其是当模型规模超过显卡容量。这时候量化是一个有效的方案。量化就是把模型的权重参数从FP32/FP16压缩到INT8甚至INT4,从而大幅减少模型体积和显存占用。代价是推理精度的轻微下降。
我对量化的态度是:先用,但要验证。不是所有模型量化后都还能保持原有表现。对于分类型任务,INT8量化通常几乎无损;对于生成型任务,尤其是推理模型,量化后生成质量可能会下降,必须用评估集做量化前后对比。除了量化,另一个思路是蒸馏——用一个大的教师模型训练一个小一点的学生模型,让小模型模仿大模型的输出。蒸馏训练本身需要额外的算力投入,但换来的是一个体积小、推理快且性能接近大模型的部署实体。
6.4 线上监控与效果回溯:部署后才是真正的开始
模型部署上线之后,还远没到可以高枕无忧的时候。我在实际工程中深刻体会到一个问题:离线评估结果好,不代表线上效果好。因为线上遇到的输入分布可能和训练集、评估集都有差异,用户的新问法、新术语、错别字、超长输入层出不穷。
所以部署后至少要建立三套基础监控:第一是服务层监控,包括请求延迟、吞吐量、错误率;第二是输入分布监控,记录线上输入的文本长度分布、高频词汇变化,及时发现和训练分布差距拉大的趋势;第三是输出质量抽检,定期对模型的线上预测结果做人工抽检,留出“bad case池子”,为下一轮迭代收集训练数据。
这三个监控里,最容易被忽视但价值最高的其实是第三个。很多团队部署后只看系统指标,不看业务指标,结果模型悄悄变差了几个月都没人发现。把线上的bad case沉淀下来,定期做模型微调或提示词优化,整个系统才能持续进化,而不是一锤子买卖。
7. 复盘:从零到一最值得避开的几个典型深坑
7.1 坑一:数据泄漏让评估结果虚高,上线后惨遭打脸
这是我在早期项目中犯过的、也是我见过新人最容易犯的错误。数据泄漏的表现形式很多样,最常见的是训练集和验证集之间存在重复或高度相似的样本,导致验证集指标远高于实际可达到的水平。还有一种隐蔽的情况是,数据清洗时用了目标变量的信息来过滤样本,比如把标签为“投诉”的样本手动剔除了一部分,然后模型在验证集上又看到了这些本不该存在的分布规律。
规避方法其实不复杂,但要求从一开始就养成习惯:划分数据集时按ID或哈希值而不是随机种子、重复样本必须先整体去重再划分、清洗规则里禁止使用目标变量信息。上线前做一次数据泄漏专项自查非常有必要,把训练集和验证集的相似度算一遍,能让你少走很多弯路。
7.2 坑二:对tokenizer的改动不做验证,导致模型效果神秘下降
文本类AI项目里,tokenizer是牵一发而动全身的组件。有人升级了tokenizer库版本、有人换了预训练tokenizer、有人在代码里改了归一化规则,这些改动都会直接影响输入token的切分方式,进而影响模型效果。
有一次我把一个预训练的中文tokenizer换成了另一个分词效果更好的版本,所有离线指标都涨了,但模型的序列长度需求也变了,导致部分长文本被截断,线上出现大量“回答不完整”的bad case。那次之后我养成了一个习惯:任何与tokenizer相关的改动,都要先在固定样本集上比对token序列和模型输出,确认无异常后再全面推开。
7.3 坑三:前期不做消融实验,后期根本定位不了有效改进
很多工程师在训练模型时喜欢一次性加很多技巧——混合精度、梯度裁剪、warmup、学习率衰减、数据增强全开。结果模型效果好了,却不知道是哪个改动起了作用;模型效果差了,也不知道是哪个环节拖的后腿。这就是典型的没有做消融实验。
我的建议是:每加一个改动,跑一组对照实验。对照组是最简单的基线配置,实验组只在基线上加一个变量。虽然这样做会消耗一些算力,但换来的是对每一次改动效果的确切认知,长期来看反而是省算力的做法。尤其是你在写博客或做技术分享时,“哪个改动带来了多少提升”这种数据比“我最终效果很好”有说服力得多。
7.4 坑四:拿着训练代码直接部署,推理延迟高到无法接受
还有一个高频翻车点:把训练阶段用的模型对象直接搬到线上做推理。训练阶段的模型带着dropout、batch normalization这些在训练时有用的机制,推理阶段应该关闭或调整;训练阶段的代码还会保存优化器状态、梯度信息,这些在部署时都是完全无用的负担。
线上推理至少要单独写一套推理代码,关闭dropout、走批量推理来提升吞吐、移掉优化器状态、合理设置KV缓存复用。直接用训练代码起服务的人,我几乎可以肯定会在延迟上踩坑,只是时间早晚的问题。
8. 写在最后:从我踩过的坑里总结的几条经验
回顾整个从零构建AI工程的实践过程,我最深的感受是:这条路没有捷径,但也不是盲目地一步一个脚印走到底。环境配置阶段的枯燥重复、数据清洗阶段的无聊琐碎、训练调参阶段的反复试错,这些看似低效的过程,恰恰是建立技术直觉最有效的途径。
如果让我给后来者三条最具体的建议,我会说:第一,从最小的闭环开始——哪怕模型很小、数据很少,也要把“数据→训练→评估→部署”完整跑通一遍,再逐步放大规模;第二,把每一次失败都记录成文档——很多报错和现象在第二次遇到时,如果有一份排查日志,解决问题的时间可以缩短十倍;第三,不要跳步骤——跳过数据清洗去做模型调优、跳过模型评估去做部署优化,最终都会以更昂贵的方式重新补课。
最后再分享一个小技巧:每次项目结束后,花半小时把项目里“改对了但不知道为什么对”的配置整理成一个清单,标注当时的实验结果,下次遇到类似场景直接查阅。这几年下来,这个清单已经成为我最宝贵的技术资产,比任何教程都有用。如果你也准备踏上从零开始的AI工程之路,希望这些经验能帮你少踩几个坑,把更多时间花在真正有趣的部分——理解模型、理解数据、理解系统如何协同工作。