很多人一开始学AI工程,第一反应是刷课程、读论文、跑现成模型的demo。我当年也这么干过,真正把“AI工程”这四个字吃透,反而是被环境逼出来的:手里没有GPU集群、没有开源团队维护的代码库、连预训练权重都要现下,只剩一台笔记本和一堆想解决的问题。“ai-engineering-from-scratch”就是这么一回事——不依赖任何现成的Trainer和模型库,从数据到模型到部署,亲手把每个环节走一遍。
这篇文章就把我在这个过程中走过的路、踩过的坑、最终稳定下来的方案完整拆开给你看。它不是那种“pip install然后等结果”的教程,而是把一个最小可用的AI系统从零拼起来的完整路径:选问题、搭环境、备数据、写模型、调训练、做部署。不管你是刚入行的学生、想转AI的工程师,还是被框架宠坏了想补底层认知的开发者,按这条路径走一遍,你对“AI工程”的理解会比刷十门课都扎实。
1. “从零开始”的边界:不是从数学共识出发,而是从最小系统出发
先做个务实的定义。“from scratch”这个词在不同人嘴里含义完全不同。有人觉得是从线性代数、概率论的教科书开始,有人觉得是手写所有算子,还有人觉得不碰TensorFlow/PyTorch才叫从零。我的态度很明确:工程上的from scratch,不是从最底层理论开始,而是从最小可行内核开始。
1.1 每个人对from scratch的理解都不一样
我见过一类学习者,他们花三个月把矩阵求导、反向传播的数学推导过了一遍,结果打开代码编辑器还是不知道第一行该写什么。这不是他们笨,而是他们搞混了“研究”和“工程”的边界。作为工程实践,你的目标不是证明一个算法为什么能工作,而是让一个系统跑起来、能迭代、能持续改进。理论是支撑你的工具,不是你要跨越的门槛。
反过来,我也见过另一类人,他们一上来就拉一个LSTM或Transformer的开源代码,改两行超参就开始训练,训完发现效果差,却不知道问题出在数据、模型还是训练配置上。这种经验积累不了——因为你没有亲手搭建系统,就无法判断每个模块对最终结果的影响。
所以我说的“ai-engineering-from-scratch”,是指你亲自控制数据管线、模型结构、训练循环和推理服务这四个核心模块,但底层算子可以放心交给框架。这样的边界既不会让你困在数学推导里出不来,也不会让你沦为只会调包的工具人。
1.2 最小可行内核:我的选型思路
起步项目选什么?我的建议是:字符级语言模型(Character-level Language Model)。不要一上来就追大语言模型,字符级模型虽然小,五脏俱全——它有数据清洗、分词、嵌入、序列建模、损失计算、文本生成、部署推理,凡是AI工程该有的环节一个都不少。
具体选一个经典任务:给模型输入一段文本,让它预测下一个字符。比如喂给模型“hello worl”,它应该输出“d”。这个任务看起来简单,但它包含了一个语言模型的所有核心机制,而且训练速度快,CPU上就能跑,非常适合验证你的工程能力。
我的目标定得非常具体:用莎士比亚的一段戏剧文本(几万字符就够),训练一个只有几百万参数的微型模型,让它能生成风格相近的英文文本。这个目标一明确,技术栈就跟着倒推出来了:
- Python 3.10+,PyTorch 2.x
- 纯CPU或单张入门级GPU即可
- 数据用公开的TinyShakespeare或者自己准备的纯文本语料
选定问题域之后,整个过程就有了清晰的验收标准:模型能生成语法基本正确、风格接近的文本,训练过程Loss稳定下降,推理接口能响应请求。这比“学会AI工程”这种模糊目标可执行得多。
2. 环境与工具链:GPU不是必需品,复现却是硬指标
在没有GPU的条件下起步,很多人觉得做不了AI工程。实际上,当你选择的问题域足够小——字符级模型、小参数量——笔记本的CPU完全扛得住。我最初的实验就是在MacBook的CPU上跑的,一个epoch大概几分钟,完全可接受。
2.1 算力不足的务实解法
如果你连本地CPU都嫌慢,有几个替代方案可以按顺序考虑:
- Colab免费版:送一张T4 GPU,跑微型模型绰绰有余,缺点是会话会断,需要养成随时保存checkpoint的习惯。
- 云厂商的按量GPU实例:按小时计费那种,训练阶段用一下,跑完立刻释放,成本可控。
- 本地CPU + 小数据:把语料缩小到2万字符,模型缩小到两层,一样能跑通全流程。
我个人的建议是,第一步先别急着花钱买算力。先用本地CPU把整个链路跑通——数据、模型、训练、推理——确认你的代码是通的,再考虑上GPU加速。否则你一上来就面对分布式训练、梯度累积这些复杂度,很容易崩心态。
2.2 一套能复现的实验环境配置
环境管理我用的是conda加pip的组合,核心原则是固定所有依赖版本。很多项目跑着跑着报错,最后发现是某个库悄悄升级了。我自己的做法是,在项目根目录维护一份requirements.txt,每次成功运行后就把版本号正式记录下来:
conda create -n ai-from-scratch python=3.10 -y conda activate ai-from-scratch pip install torch==2.1.0 numpy==1.26.0 tqdm==4.66.1 pip freeze > requirements.txt还有一个必须养成的习惯:固定随机种子。PyTorch模型初始化、数据加载的shuffle、dropout这些都有随机性,不固定种子的话,两次训练结果可能差异很大,你就很难判断改动代码到底是变好了还是变坏了。
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)注意:固定随机种子不是万能的。PyTorch在部分算子上的随机性并不保证完全可复现,但它至少能帮你排除大部分干扰因素,让你在调参时有一个相对稳定的基线。
2.3 项目目录怎么搭才不混乱
我最早做项目时,所有代码堆在一个notebook里,后来模型要迭代、数据要更新,彻底乱套。从零开始的工程实践,一个重要收获就是学会组织代码。一个清晰的项目目录大概长这样:
ai-engineering-from-scratch/ ├── data/ │ ├── raw/ # 原始语料 │ └── processed/ # 清洗、编码后的输入数据 ├── src/ │ ├── data_pipeline.py # 数据下载、清洗、分词 │ ├── model.py # 模型定义 │ ├── train.py # 训练循环 │ ├── evaluate.py # 评估脚本 │ └── generate.py # 推理生成 ├── checkpoints/ # 模型权重保存 ├── config.py # 超参配置 └── requirements.txt这个结构不是随便分的,每个文件的职责边界非常清楚:data_pipeline只负责“把原始数据变成模型能吃的张量”,model.py只负责前向计算,train.py负责反向传播和参数更新。这样你要调数据清洗,不用去翻模型代码;你想改网络结构,不用动数据逻辑。这套拆分方式在任何规模的工程里都用得上。
3. 数据管线:模型吃什么,决定了它长出什么
很多教程把数据准备一笔带过,但说句实在话,数据的质量直接决定模型的天花板。模型结构再精巧,喂给它垃圾数据,它也只能学会生成垃圾。在我做字符级语言模型的整个过程中,数据管线花的时间比写模型还多。
3.1 数据集从哪里来:公开资源与自制数据
字符级模型的数据不挑,任何纯文本都行。我最终选了TinyShakespeare——这个数据集大约1MB,包含莎士比亚多部戏剧的原文,字符规模适中,社区里很多人都拿它当语言模型的“Hello World”数据。
如果你想用自己准备的数据,也很简单:找一批你感兴趣的领域文本,存成UTF-8编码的纯文本文件即可。关键点是编码格式必须统一。我踩过一次坑:数据里混了GBK和UTF-8编码的文件,训练到一半Loss突然变成NaN,排查了很久才发现是加载数据时解码出了问题。
数据获取之后,先做一通体检:
- 有没有重复段落?
- 有没有空白行、乱码、异常符号?
- 总字符数有多少?字符种类分布怎样?
对于字符级模型,还有一个特别重要的检查:看看字符集有多大。如果你的语料里混入了大量特殊符号,模型的词表会被撑大,训练难度跟着上升。我一般会把字符集控制在英文字母、数字、常见标点范围内,其他一律过滤或替换。
3.2 清洗、分词、批处理:三个容易翻车的环节
清洗(Cleaning)的目标是让语料规整。我做这步时,把连续空白字符压缩成单个空格,统一换行符,删除过短的段落,避免大量无意义的碎片文本进入训练。
分词(Tokenization)在字符级模型里就是:给每个字符分配一个整数ID。这里有一个容易被忽略的细节:训练集中出现的字符,在推理时如果遇到没见过的字符,会直接导致查表失败。所以词表构建时,必须保留一个专门的“UNK”占位符ID,把未知字符统一映射过去。
批处理(Batching)则是把已经ID化的文本切成固定长度的序列。这里有个设计决策:序列长度(context length)设为多少。长度太短,模型的上下文感知能力弱;长度太长,训练显存和耗时都上去了。我做实验时先用64,跑通后再换128对比效果。
3.3 一个足够简单的DataLoader示例
我当时的实现非常朴素,但胜在逻辑清楚:
import torch from torch.utils.data import Dataset, DataLoader class CharTextDataset(Dataset): def __init__(self, text, seq_len=64): self.seq_len = seq_len self.chars = sorted(set(text)) self.stoi = {c: i for i, c in enumerate(self.chars)} self.itos = {i: c for c, i in self.stoi.items()} self.data = text def __len__(self): return (len(self.data) - self.seq_len - 1) // self.seq_len def __getitem__(self, idx): start = idx * self.seq_len end = start + self.seq_len x = torch.tensor([self.stoi.get(c, 0) for c in self.data[start:end]]) y = torch.tensor([self.stoi.get(c, 0) for c in self.data[start+1:end+1]]) return x, y这里最关键的设计是x和y的对应关系:输入是第i个字符到第i+seq_len-1个字符,标签是第i+1个字符到第i+seq_len个字符。也就是说,模型在每个时间步都要预测下一个字符,这就是语言模型自监督训练的本质:不需要人工标注,文本本身就是标签。
一个我后来才意识到的细节:__len__的计算。如果文本长度是10000,seq_len是64,可用的样本数大约是(10000-64-1)//64,而不是10000//64。为什么要减掉seq_len再加1?因为最后一个样本的标签需要从切片的最后一个字符再往后移一位。这个边界问题,新手很难注意到,但漏掉它会导致样本数量虚高,影响数据batch的对齐。
4. 手写微型Transformer:从零构建语言模型的骨架
模型是AI工程的核心。完整的大模型实现要做多头注意力、KV-Cache、MoE、RoPE位置编码,但作为从零开始的工程实践,我建议你先掌握一个极简Transformer,它包含所有核心思想,但代码量控制在200行以内。
4.1 为什么选Transformer而不是RNN
RNN是很经典的序列模型,但在长序列上存在梯度传播衰减的问题。Transformer通过注意力机制让序列中任意两个位置的字符直接建立连接,路径长度只有1,所以长程依赖学习能力更强。现在的主流语言模型几乎全是Transformer架构,从这个模型入手也是为以后扩展打基础。
Transformer的组成可以分成四块:嵌入层、自注意力、前馈网络、层归一化与残差连接。下面一个模块一个模块过。
4.2 嵌入层与位置编码:给模型“空间感”
字符本质上是一个个离散的ID(比如a=1、b=2)。直接把ID当作特征喂给模型是有问题的:ID的大小关系是人为强加的,ID越大并不表示任何语义上的“更多”。嵌入层的作用就是把每个离散ID映射到一个低维稠密向量,让模型能够在向量空间里灵活表达字符之间的关系。
class TokenEmbedding(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim) def forward(self, tokens): # tokens: [batch, seq] return self.embed(tokens) # [batch, seq, embed_dim]但嵌入层处理得再好,也有一个关键问题:它没有位置信息。“我打你”和“你打我”的字符序列不同,但如果模型不知道字符出现在什么位置,它看到的信息就完全一样。所以需要加一个位置编码(Positional Encoding),把位置信息注入到每个字符的向量里。
我的实现用最简单可学习的做法:
class PositionalEncoding(nn.Module): def __init__(self, max_seq_len, embed_dim): super().__init__() self.position_embeds = nn.Parameter(torch.randn(max_seq_len, embed_dim)) def forward(self, x): # x: [batch, seq, embed_dim] return x + self.position_embeds[:x.size(1)]这不是唯一的方案。Transformer论文里用的正弦余弦编码不依赖训练数据,可学习的编码更灵活。作为工程实践,可学习的编码实现更直观、好调试。至于RoPE这类更复杂的位置编码,属于进阶优化,先把基础版吃透再说。
4.3 自注意力机制:用生活类比拆解
自注意力是整个Transformer的灵魂。用一个生活化的类比来理解:你在读一句话时,读到某个词会不自觉地去联系句子里其他相关的词。“他推开那扇门,吱呀一声”——“吱呀”让你想到“门”,这就是注意力机制在做的事:根据当前字符的需要,从整个序列中检索相关信息。
具体到代码,自注意力通过三个矩阵完成:Q(Query,查询)、K(Key,键)、V(Value,值)。你可以这么理解:当前字符发出一个Query,问整个序列“谁和我的推断相关”;每个位置的字符提供一个Key,用来回答“我和谁匹配”;匹配到的位置再用Value输出自己的内容。
class SelfAttention(nn.Module): def __init__(self, embed_dim, head_dim): super().__init__() self.q_proj = nn.Linear(embed_dim, head_dim, bias=False) self.k_proj = nn.Linear(embed_dim, head_dim, bias=False) self.v_proj = nn.Linear(embed_dim, head_dim, bias=False) def forward(self, x): # x: [batch, seq, embed_dim] q = self.q_proj(x) k = self.k_proj(x) v = self.v_proj(x) scores = q @ k.transpose(-2, -1) / math.sqrt(k.size(-1)) weights = torch.softmax(scores, dim=-1) return weights @ v注意一下scores除以sqrt(k.size(-1))这个操作。如果不做这个缩放,q和k的点积结果在维度较大时会很大,进入softmax的饱和区,梯度趋近于0,模型训练会非常困难。这个细节是Transformer论文里的关键设计,别省略它。
还有一个在实际训练中必须加的组件:因果掩码(Causal Mask)。语言模型生成文本时只能看到当前字符和之前的字符,看不到未来的字符。如果不加掩码,模型就等于作弊——它可以直接“偷看”正确答案。
def forward(self, x): q = self.q_proj(x) k = self.k_proj(x) v = self.v_proj(x) scores = q @ k.transpose(-2, -1) / math.sqrt(k.size(-1)) seq_len = x.size(1) causal_mask = torch.tril(torch.ones(seq_len, seq_len)).bool() scores = scores.masked_fill(~causal_mask, float('-inf')) weights = torch.softmax(scores, dim=-1) return weights @ vtorch.tril生成一个下三角矩阵,保留当前位置及之前的位置,未来位置被掩码为负无穷,softmax之后就变成0权重。
4.4 前馈网络与残差连接:深层模型的地基
自注意力本质上是信息的聚合操作,但模型的表达能力还需要更强的非线性变换。前馈网络(FFN)就承担这个职责,它是一个两层的全连接网络:先把维度放大,再缩回去。
class FeedForward(nn.Module): def __init__(self, embed_dim, ff_dim): super().__init__() self.net = nn.Sequential( nn.Linear(embed_dim, ff_dim), nn.GELU(), nn.Linear(ff_dim, embed_dim) ) def forward(self, x): return self.net(x)为什么中间要放大维度?一个直观的解释是:注意力负责“找关系”,FFN负责“做计算”。把特征映射到更高维空间,相当于给模型更多参数来处理复杂的模式。工程上常见的比例是4倍,即embed_dim=128时,ff_dim设为512。
有了自注意力和前馈网络,接下来要把它们叠层深。但盲目加深层数会带来一个工程问题:梯度在反向传播中会逐渐消失,深层网络反而比浅层网络效果差。残差连接(把输入直接加到输出上)和层归一化就是为这个服务的。我的写法是Pre-LN——先归一化,再做注意力/FFN,最后加残差。
class TransformerBlock(nn.Module): def __init__(self, embed_dim, head_dim, ff_dim, dropout=0.1): super().__init__() self.attn = SelfAttention(embed_dim, head_dim) self.ffn = FeedForward(embed_dim, ff_dim) self.norm1 = nn.LayerNorm(embed_dim) self.norm2 = nn.LayerNorm(embed_dim) self.dropout = nn.Dropout(dropout) def forward(self, x): x = x + self.dropout(self.attn(self.norm1(x))) x = x + self.dropout(self.ffn(self.norm2(x))) return x这个x = x + 子模块(x)就是残差连接。它的作用很实在:就算子模块内部的梯度传不回来,输入x的信息也能通过这条“捷径”直接传到下一层,保证深层网络的梯度路径始终通畅。
4.5 训练循环与损失函数:让梯度告诉模型方向
模型前向输出之后,需要和真实标签做对比。语言模型的输出是词表大小的概率分布,所以用交叉熵损失(CrossEntropyLoss)衡量预测分布和真实字符的差距。
class MiniTransformer(nn.Module): def __init__(self, vocab_size, embed_dim, num_layers, head_dim, ff_dim): super().__init__() self.token_embed = TokenEmbedding(vocab_size, embed_dim) self.pos_embed = PositionalEncoding(1024, embed_dim) self.blocks = nn.ModuleList([ TransformerBlock(embed_dim, head_dim, ff_dim) for _ in range(num_layers) ]) self.ln_final = nn.LayerNorm(embed_dim) self.lm_head = nn.Linear(embed_dim, vocab_size) def forward(self, tokens): x = self.pos_embed(self.token_embed(tokens)) for block in self.blocks: x = block(x) logits = self.lm_head(self.ln_final(x)) return logits训练循环是这段工程的核心,它的逻辑是:前向计算损失 → 反向传播梯度 → 优化器更新参数。我的实现里最花功夫的不是框架调用,而是细节处理:
def train_step(model, data, optimizer, criterion, device): model.train() x, y = data x, y = x.to(device), y.to(device) logits = model(x) loss = criterion(logits.view(-1, logits.size(-1)), y.view(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() return loss.item()这里logits.view(-1, logits.size(-1))把形状从[batch, seq, vocab]展平成[batch*seq, vocab],标签y也展平成一维,交叉熵就能直接计算。
训练中有一个被我经常忽略后来才重视的细节:梯度裁剪(gradient clipping)。字符级模型看似简单,但序列较长时依然可能出现梯度爆炸。加了clip_grad_norm_之后,训练的稳定性大大提升,Loss曲线不再突然跳到NaN。
另外一个工程细节是保存训练状态。我每训练几步就把model、optimizer、step三样都存进checkpoint:
torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'step': step, }, f'checkpoints/step_{step}.pt')为什么连optimizer一起存?因为如果训练中断,光恢复模型参数还不够——Adam优化器内部维护着每个参数的动量估计值,不恢复的话,后续训练的走向会偏离原本的轨迹。
5. 训练调参实录:Loss曲线会说话
训练模型不是一锤子买卖。对我来说,真正的工程挑战在后半段:Loss不降、过拟合、最终效果总是差一点。这一节我用实际案例拆解几个高频问题。
5.1 学习率与batch size:一对需要搭配的参数
动手调参前,我习惯先把一组基础超参定下来:
| 超参数 | 我的初始值 | 调整方向说明 |
|---|---|---|
| embedding维度 | 128 | 太小拟合不动,太大训练慢 |
| 层数 | 2 | 字符级任务,2-4层足够 |
| 序列长度 | 64 | 先短后长,基线跑通再拉长 |
| batch size | 64 | 看显存/内存波动 |
| 学习率 | 3e-4 | 太高易震荡,太低收敛慢 |
| 优化器 | AdamW | 带权重衰减,泛化更稳 |
学习率和batch size是一对搭档。大batch size下梯度估计更准,可以大胆用更高的学习率;小batch size噪声大,学习率太高容易震荡。一个实务经验:先用3e-4的学习率加batch size 64跑2000步,看Loss是不是稳定下降。如果曲线跳得像锯齿,把学习率降到1e-4或8e-5;如果Loss降得很慢,可以反过来加大学习率。
5.2 过拟合与欠拟合:怎么从曲线判断
用一个小模型训练TinyShakespeare,最典型的现象就是欠拟合:Loss降不下去,生成文本几乎是无意义的字符堆砌。此时优先加大模型容量——从2层加到3层,或者把embed_dim从128提升到256。
而过拟合在字符级模型上也同样会发生,尤其是语料很小、模型参数量相对过大时。训练集Loss持续下降,但验证集Loss不降反升。我自己的排查步骤是:先看训练集和验证集的数据分布是否一致,再检查是否有数据泄露(比如验证集文本和训练集高度重叠),最后才是调dropout和weight decay。
5.3 训练崩溃的常见信号与修复手段
训练中Loss突然变成NaN,是最让人头疼的问题之一。我遇到的常见原因有以下三种:
- 数据里有非数值内容或极端值,特征数值溢出
- 学习率过高导致梯度更新步长过大
- 前向计算中出现了数值不稳定的操作(比如未加掩码的softmax出现inf值)
排查手段也讲究顺序。我从经验里总结了一套“三步诊断法”:第一步,打印输入tensor的基本统计量,确认没有NaN或inf混入;第二步,把学习率临时调到极低值(比如1e-6),看Loss是否恢复正常;如果还不行,第三步就是加梯度裁剪,限制最大梯度范数。这三步基本能定位90%以上的崩溃问题。
5.4 checkpoint策略:别让一次崩溃毁掉一切
我的习惯是双策略并存:每隔固定步数存一个周期性checkpoint(比如每500步),同时在验证集上表现最好的模型也单独存一份。前者保证训练进程不会因为一次意外全部丢掉,后者保证你手里始终有最佳版本的权重。
有一次我训练到一万步时,硬盘写满了,进程直接崩溃。当时如果没有周期性的checkpoint,前面十几个小时的工作就全浪费了。这也是我为什么一直强调:训练的稳定性和可恢复性,比单次训练效果重要得多。
6. 把模型变成服务:部署与推理的最后一公里
模型训练好了,权重躺在checkpoint文件里,这只是完成了工程的一半。真正让模型产生价值的是把它部署成服务,供外部调用。这一节我从经验出发,分享从训练代码过渡到推理服务的完整路径。
6.1 模型加载与推理:别把训练代码和推理代码混在一起
我见过不少新手直接复用训练脚本里的模型初始化逻辑来推理,这样不是不行,但会引入很多不必要的依赖——比如optimizer、criterion这些推理时根本用不上。更好的做法是把模型定义单独放在model.py里,推理脚本只负责加载权重和做前向计算。
推理脚本里,文本生成的核心是自回归采样:输入一个prompt,模型预测下一个字符,把这个字符拼到输入尾部,再重新输入模型,如此循环。这里有一个陷阱:每次循环都从零开始前向计算,速度很慢。进阶优化是KV-Cache,把之前已经算过的Key和Value缓存下来,避免重复计算。作为入门实践,先用简单循环跑通,后续再优化性能。
温度采样(Temperature Sampling)是控制生成质量的重要手段。模型输出的logits分布,经过除以温度系数后再做softmax:温度越低,分布越尖,生成越保守;温度越高,分布越平,生成越随机。我的经验是:训练时用交叉熵,生成时直接取argmax容易陷入重复,加一个temperature=0.8的采样,生成文本的多样性明显提升。
def generate(model, tokenizer, prompt, max_new_tokens=100, temperature=0.8): model.eval() tokens = tokenizer.encode(prompt) input_ids = torch.tensor([tokens]) with torch.no_grad(): for _ in range(max_new_tokens): logits = model(input_ids)[0, -1, :] / temperature probs = torch.softmax(logits, dim=-1) next_id = torch.multinomial(probs, num_samples=1).item() input_ids = torch.cat([input_ids, torch.tensor([[next_id]])], dim=1) tokenizer.chars.append(tokenizer.itos[next_id]) return ''.join(tokenizer.chars)6.2 一个轻量的推理服务示例
模型推理跑通之后,可以把它封装成HTTP接口。我用FastAPI来做,它的响应速度、自动文档和简单易用程度都适合这个场景。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model, tokenizer = load_model_and_tokenizer() class GenerateRequest(BaseModel): prompt: str = "To be, or not to be" max_new_tokens: int = 100 temperature: float = 0.8 @app.post("/generate") def generate_text(req: GenerateRequest): output = generate(model, tokenizer, req.prompt, req.max_new_tokens, req.temperature) return {"output": output}这个服务看起来简单,但有一点必须注意:模型加载应该在服务启动时做一次,而不是每个请求都重新加载模型。我最初写接口的时候没注意,把模型加载写进了请求处理函数里,结果每个请求都要等好几秒才能响应。后来改成启动时加载,延迟立刻降到毫秒级。
6.3 性能优化:批处理、缓存与量化入门
如果你希望服务支撑更大的并发,有几个简单有效的优化方向。
第一是批处理(Batching):把多个请求合并成一个batch喂给模型,分摊计算成本。FastAPI配合队列机制可以实现请求级的动态批处理。
第二是缓存:如果用户输入的prompt重复命中率很高,把生成结果缓存起来,用字典或者Redis都可以。这个优化收益很大,实现成本又低。
第三是简单量化:把模型的权重从float32转成float16,在CPU上的推理速度能提升不少,内存占用也减半。PyTorch提供torch.to(torch.float16)直接把模型转半精度,不过要注意有些CPU算子对半精度支持不好,需要测试对比。
我和很多做AI工程的朋友交流,大家普遍的体会是:训练一个模型只是第一步,把它稳定地跑在服务里、让别人能调用,才算真正走完闭环。这个过程里每一个环节——从数据到模型再到部署——都会有各种细碎的问题,而解决这些问题积累起来的经验,恰恰就是“AI工程”和“会调包”的本质区别。
如果你也想从零开始走一遍这条路,我的建议是别贪大。从一个字符级模型起步,把它每一个环节都亲手摸一遍,再逐步扩展到更大的数据、更复杂的模型、更完善的工程架构。等到你能熟练地在某个环节出问题时快速定位根因,你对AI工程的理解,就已经超过大多数只会跑现成demo的人了。