☰
从零手搓AI工程:PyTorch训练到FastAPI推理的避坑指南
2026/11/3 23:09:03 网站建设 项目流程

1. 从零手搓AI工程:为什么我不建议你直接调包

很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,拖几个组件,调几个API,然后跑通了事。我刚开始接触这个领域的时候也是这么想的,直到有一次线上推理服务在高峰期直接雪崩,日志里全是显存溢出的报错,我才意识到——如果你不知道一个东西是怎么从底层跑起来的,你就不可能在它出问题的时候把它救回来。

ai-engineering-from-scratch这个项目标题,说白了就是“从零开始搭建AI工程体系”。它不是教你调包,而是让你亲手把数据管道、特征存储、模型训练、推理服务、监控告警这一整条链路用最原始的方式搭一遍。适合谁看?适合那些已经会用PyTorch或TensorFlow跑demo,但一上生产环境就抓瞎的工程师;也适合那些想转行做AI工程但被各种框架抽象层绕晕的开发者。我自己的经验是,你亲手写过一次反向传播的梯度检查,亲手配过一次CUDA内存分配策略,亲手压测过一次推理服务的QPS上限,你对整个系统的理解会完全不一样。

这篇文章我会把从零搭建AI工程的核心环节拆开讲,包括整体架构怎么设计、数据管道怎么搭、训练循环怎么写、推理服务怎么部署、监控怎么做。每个环节我都会给出具体的代码片段和参数计算过程,也会分享我自己踩过的坑。你不需要全部照搬,但至少能知道每个决策背后的逻辑是什么。

2. 整体架构设计与技术选型思路

2.1 为什么我选择“单体优先”而不是微服务

很多教程一上来就教你上Kubernetes、上微服务、上消息队列,好像不搞分布式就不叫AI工程。我试过那套方案,结果是在项目初期花了大量时间在服务发现和网络配置上,真正用来调模型的时间不到三成。后来我换了个思路:单体优先,按需拆分。

具体来说,整个系统一开始就是一个Python包,里面包含数据加载、特征处理、模型定义、训练循环、评估指标、推理接口这几个模块。它们都在同一个进程里跑,通过函数调用通信。这样做的好处是调试极其方便,你可以在任何一个环节打断点,看数据长什么样、梯度有没有异常。等到某个模块真的成为瓶颈了——比如数据预处理吃满了CPU导致推理延迟飙升——再把它拆出去做成独立的服务。

这个决策背后的逻辑是:分布式系统的复杂度应该由实际性能瓶颈驱动,而不是由架构图的美观程度驱动。我见过太多团队在日请求量不到一万的时候就上了全套微服务,结果运维成本比开发成本还高。

2.2 技术栈选型:为什么是PyTorch + FastAPI + PostgreSQL

技术选型这块我纠结过很久。TensorFlow生态更完整,但调试体验差;JAX性能好,但社区资源少。最后我选了PyTorch,原因很简单:动态图机制让调试变得直观。你可以在forward函数里直接print张量的形状和数值,不用先构建静态图再跑session。

推理服务用FastAPI,不是因为它性能最好,而是因为它的异步支持和自动文档生成能省掉大量胶水代码。你定义一个Pydantic模型,请求校验和API文档就自动生成了。对于从零搭建的项目来说,这种开发效率的提升比那点性能差异重要得多。

数据存储用PostgreSQL,不是因为它最适合存特征,而是因为它同时支持结构化查询和JSON字段。你可以在同一张表里存用户ID、时间戳这些结构化字段,也可以存embedding向量这种半结构化数据。等到数据量真的上来了,再迁移到专门的向量数据库也不迟。

组件选型核心理由替代方案
深度学习框架PyTorch动态图调试方便TensorFlow, JAX
推理服务FastAPI异步支持+自动文档Flask, TorchServe
主数据库PostgreSQL结构化+JSON混合存储MySQL, MongoDB
缓存Redis低延迟特征读取Memcached
监控Prometheus+Grafana指标采集生态成熟ELK Stack

2.3 目录结构设计:让代码自己说话

从零搭建项目最容易犯的错误就是把所有代码堆在一个文件里。我建议从一开始就按职责划分目录:

ai-engineering-from-scratch/ ├── configs/ # 配置文件 ├── data/ # 数据加载与预处理 ├── features/ # 特征工程 ├── models/ # 模型定义 ├── training/ # 训练循环 ├── serving/ # 推理服务 ├── monitoring/ # 监控指标 ├── tests/ # 单元测试 └── scripts/ # 运维脚本

这个结构的好处是每个目录的职责边界清晰。比如你想换一个模型架构,只需要改models目录;想调整特征处理逻辑,只需要改features目录。测试的时候也可以按模块单独测试,不用把整个系统跑起来。

注意:不要过早引入抽象层。我见过有人在项目初期就搞了一套插件系统,结果每个新功能都要改三四个文件才能加进去。从零搭建的核心是快速迭代,不是设计一个完美的框架。

3. 数据管道与特征工程的核心细节

3.1 数据加载:为什么我放弃了DataLoader的默认配置

PyTorch的DataLoader用起来很方便,但默认配置在生产环境里几乎一定会出问题。最典型的就是num_workers参数——默认值是0,意味着数据加载在主进程里同步执行。训练的时候GPU利用率上不去,很多时候就是因为数据加载成了瓶颈。

我的经验是:num_workers设置为CPU核心数的70%左右。比如你的机器有16个核,就设成11或12。设得太高会导致进程切换开销超过并行收益,设得太低又喂不饱GPU。你可以用nvidia-smi看GPU利用率,如果一直在50%以下波动,大概率就是数据加载拖了后腿。

还有一个坑是pin_memory。这个参数控制是否把数据加载到锁页内存,从而加速CPU到GPU的传输。默认是False,我建议开启。但要注意,开启之后内存占用会增加,如果你的数据集特别大,需要相应调整batch_size。

from torch.utils.data import DataLoader train_loader = DataLoader( dataset=train_dataset, batch_size=64, shuffle=True, num_workers=11, # CPU核心数的70% pin_memory=True, # 加速CPU到GPU传输 prefetch_factor=2, # 每个worker预取2个batch persistent_workers=True # 避免每个epoch重新创建worker )

prefetch_factor这个参数很多人会忽略。它控制每个worker提前加载多少个batch。默认是2,对于IO密集型的任务可以调到4,但会占用更多内存。persistent_workers设为True可以避免每个epoch结束后重新创建worker进程,对于小数据集来说能省下不少时间。

3.2 特征工程:从原始数据到模型输入的完整链路

特征工程是AI工程里最脏最累但最重要的一环。我见过太多项目模型结构很漂亮,但特征处理一塌糊涂,最后效果还不如一个逻辑回归。从零搭建的时候,我建议把特征处理拆成三个独立的步骤:清洗、变换、编码。

清洗阶段主要处理缺失值和异常值。数值型特征用中位数填充,类别型特征用众数填充。异常值用IQR方法检测,超过1.5倍四分位距的视为异常,用边界值截断。这个阶段的关键是记录所有清洗操作的统计量,因为推理的时候要用同样的统计量来处理新数据。

变换阶段包括标准化、归一化、对数变换等。数值型特征我一般用StandardScaler做标准化,但如果分布严重偏斜,会先做对数变换。这里有个细节:标准化参数必须只在训练集上拟合,然后应用到验证集和测试集。我见过有人在全量数据上拟合Scaler,导致数据泄露,线下指标虚高。

编码阶段处理类别型特征。低基数特征用One-Hot编码,高基数特征用Target Encoding或Embedding。Target Encoding有个坑是容易过拟合,我的做法是加一个平滑项:

def target_encode(df, cat_col, target_col, smoothing=10): """带平滑的Target Encoding""" global_mean = df[target_col].mean() agg = df.groupby(cat_col)[target_col].agg(['mean', 'count']) smooth = (agg['mean'] * agg['count'] + global_mean * smoothing) / (agg['count'] + smoothing) return df[cat_col].map(smooth)

平滑系数smoothing控制的是类别统计量向全局均值收缩的程度。类别样本数越少,收缩越明显。这个技巧在类别不平衡的场景下特别有用。

3.3 特征存储:为什么我用PostgreSQL而不是专门的向量数据库

特征存储这块我纠结了很久。专门的向量数据库如Milvus、Pinecone确实在相似度搜索上有优势,但对于从零搭建的项目来说,引入一个新组件的成本远高于它带来的收益。PostgreSQL配合pgvector扩展已经能支持基本的向量检索,而且你不需要额外维护一套数据库集群。

我的做法是在PostgreSQL里建两张表:一张存用户维度的静态特征,一张存时间序列的动态特征。静态特征用JSONB字段存,动态特征按时间分区。查询的时候用JOIN把两张表拼起来,延迟在毫秒级别。

-- 静态特征表 CREATE TABLE user_features ( user_id BIGINT PRIMARY KEY, features JSONB NOT NULL, updated_at TIMESTAMP DEFAULT NOW() ); -- 动态特征表(按天分区) CREATE TABLE user_behavior ( user_id BIGINT, event_time TIMESTAMP, features JSONB, PRIMARY KEY (user_id, event_time) ) PARTITION BY RANGE (event_time);

提示:JSONB字段虽然灵活,但不要什么都往里塞。高频查询的字段还是应该单独建列并加索引,否则查询性能会随着数据量增长急剧下降。

4. 模型训练与推理服务的实操要点

4.1 训练循环:从零写一个带梯度裁剪和混合精度的训练器

很多人用PyTorch的model.fit()或者Lightning的Trainer,确实省事,但出了问题你根本不知道从哪里查。我建议从零写一次训练循环,把每个步骤都显式地写出来。

核心步骤包括:前向传播、损失计算、反向传播、梯度裁剪、参数更新、学习率调度。其中梯度裁剪是防止梯度爆炸的关键,特别是在RNN和Transformer类模型里。我一般用torch.nn.utils.clip_grad_norm_,阈值设在1.0到5.0之间。阈值太小会导致梯度被过度裁剪,模型学不动;太大又起不到防止爆炸的作用。

混合精度训练是另一个必选项。它用FP16做前向和反向传播,用FP32维护模型参数副本,能在几乎不损失精度的情况下把训练速度提升30%到50%,显存占用降低40%左右。PyTorch的torch.cuda.amp模块让这件事变得很简单:

import torch from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for epoch in range(num_epochs): model.train() for batch in train_loader: optimizer.zero_grad() with autocast(): outputs = model(batch['input_ids']) loss = criterion(outputs, batch['labels']) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() scheduler.step()

这里有个细节:scaler.unscale_(optimizer)必须在梯度裁剪之前调用,因为GradScaler会对梯度做缩放,不还原的话裁剪阈值就不准了。这个坑我踩过,当时梯度裁剪完全失效,训练到一半loss直接变成NaN。

4.2 学习率调度:为什么我不用StepLR而用CosineAnnealing

学习率调度对训练效果的影响被很多人低估了。StepLR每隔固定epoch把学习率乘以一个衰减因子,简单但不够平滑。CosineAnnealing让学习率按余弦曲线从最大值降到最小值,训练后期学习率很小,模型能在局部最优附近精细搜索。

我的经验是:CosineAnnealing配合Warmup效果最好。Warmup就是在训练最开始的前几个epoch把学习率从0线性增加到最大值,避免一开始就大学习率导致参数更新过猛。这个技巧在Transformer类模型里几乎是标配。

from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts scheduler = CosineAnnealingWarmRestarts( optimizer, T_0=10, # 第一次重启的epoch数 T_mult=2, # 每次重启后周期翻倍 eta_min=1e-6 # 最小学习率 )

T_0和T_mult的配合让学习率周期性重启,每次重启后周期翻倍。这样模型可以在训练初期快速收敛,后期又能跳出局部最优。eta_min是最小学习率,不要设成0,否则后期参数完全不更新。

4.3 推理服务:FastAPI + ONNX Runtime的部署方案

训练好的模型要部署成服务才能产生价值。我试过TorchServe、Triton、FastAPI三种方案,最后选了FastAPI + ONNX Runtime。TorchServe功能全但配置复杂,Triton性能好但学习曲线陡。FastAPI + ONNX Runtime的组合在开发效率和推理性能之间取得了很好的平衡。

ONNX Runtime的好处是跨平台且推理速度快。你把PyTorch模型导出成ONNX格式,然后用ONNX Runtime加载,推理速度通常比原生PyTorch快20%到40%,显存占用也更低。导出的时候要注意设置动态维度,否则batch size变了就要重新导出。

import torch.onnx torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size", 1: "sequence_length"}, "output": {0: "batch_size"} }, opset_version=14 )

dynamic_axes参数指定了哪些维度是动态的。这里batch size和sequence length都是动态的,意味着你可以用任意batch size和序列长度做推理。opset_version建议用14或更高,对Transformer类算子的支持更好。

FastAPI这边,核心是把模型加载放在启动时而不是每次请求时:

from fastapi import FastAPI import onnxruntime as ort import numpy as np app = FastAPI() session = None @app.on_event("startup") async def load_model(): global session session = ort.InferenceSession("model.onnx") @app.post("/predict") async def predict(request: PredictRequest): input_array = np.array(request.features, dtype=np.float32) outputs = session.run(None, {"input": input_array}) return {"prediction": outputs[0].tolist()}

注意:ONNX Runtime的session不是线程安全的,多个请求同时进来会出问题。解决方案是用ort.SessionOptions()设置intra_op_num_threads,或者用FastAPI的run_in_executor把推理放到线程池里执行。

4.4 批处理与动态批处理:提升吞吐量的关键

单条推理的吞吐量很低,因为GPU的并行计算能力没有被充分利用。批处理是把多个请求攒在一起同时推理,能显著提升吞吐量。但简单的批处理会增加延迟,因为要等攒够一个batch才能处理。

动态批处理是更好的方案:设置一个最大batch size和一个最大等待时间,哪个条件先满足就触发推理。比如最大batch size是32,最大等待时间是10毫秒。如果10毫秒内攒了8个请求,就用batch size 8推理;如果攒了32个,立刻推理不等了。

import asyncio from collections import deque class DynamicBatcher: def __init__(self, max_batch_size=32, max_wait_ms=10): self.max_batch_size = max_batch_size self.max_wait = max_wait_ms / 1000 self.queue = deque() self.lock = asyncio.Lock() async def add_request(self, features): async with self.lock: future = asyncio.Future() self.queue.append((features, future)) if len(self.queue) >= self.max_batch_size: await self._process_batch() else: asyncio.create_task(self._wait_and_process()) return await future async def _wait_and_process(self): await asyncio.sleep(self.max_wait) async with self.lock: if self.queue: await self._process_batch()

这个实现里,每个请求进来都会检查队列长度。如果达到最大batch size,立刻处理;否则启动一个定时任务,等待最大等待时间后处理。这样既保证了吞吐量,又控制了延迟上限。

5. 监控、日志与性能调优的实战经验

5.1 监控指标:除了准确率你还应该看什么

模型上线之后,准确率只是众多指标中的一个。我见过模型准确率很高但线上效果很差的情况,原因是数据分布漂移——训练数据的分布和线上推理数据的分布不一致。所以监控体系里必须包含数据分布指标。

我一般会监控这几类指标:服务层包括QPS、P99延迟、错误率、超时率;模型层包括预测分布、置信度分布、特征缺失率;数据层包括特征均值、方差、分位数随时间的变化。其中特征分布的变化用KL散度或PSI(Population Stability Index)来量化。

PSI的计算公式是:

$$PSI = \sum_{i=1}^{n} (Actual_i% - Expected_i%) \times \ln\left(\frac{Actual_i%}{Expected_i%}\right)$$

其中Actual是当前窗口的分布,Expected是基准窗口的分布。PSI小于0.1说明分布稳定,0.1到0.25说明有轻微漂移,大于0.25说明分布显著变化,需要排查原因。

import numpy as np def calculate_psi(expected, actual, buckets=10): """计算PSI""" breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1)) expected_counts = np.histogram(expected, bins=breakpoints)[0] / len(expected) actual_counts = np.histogram(actual, bins=breakpoints)[0] / len(actual) # 避免除零 expected_counts = np.where(expected_counts == 0, 0.0001, expected_counts) actual_counts = np.where(actual_counts == 0, 0.0001, actual_counts) psi = np.sum((actual_counts - expected_counts) * np.log(actual_counts / expected_counts)) return psi

这个函数把特征值分桶,然后计算两个分布的差异。实际使用的时候,我会对每个重要特征都算一遍PSI,然后取最大值作为整体漂移指标。

5.2 日志规范:结构化日志比文本日志好用一百倍

很多人打日志就是print(f"预测结果: {result}"),这种日志在本地调试还行,上了生产环境根本没法查。我建议从一开始就用结构化日志,每条日志是一个JSON对象,包含时间戳、请求ID、用户ID、模型版本、输入特征摘要、输出结果、延迟等字段。

import structlog logger = structlog.get_logger() logger.info( "prediction_completed", request_id=request_id, user_id=user_id, model_version="v1.2.3", input_shape=list(input_array.shape), output_summary=float(outputs[0].sum()), latency_ms=latency )

结构化日志的好处是可以直接被日志系统解析和索引。你想查某个用户的所有请求,或者某个模型版本的平均延迟,直接写查询语句就行,不用正则表达式去匹配文本。

提示:输入特征摘要不要打全量数据,只打形状和统计量。全量数据会让日志体积爆炸,而且可能包含敏感信息。

5.3 性能调优:从GPU利用率到内存泄漏的排查路径

性能调优的第一步是定位瓶颈。我一般按这个顺序排查:先看GPU利用率,如果低于70%说明GPU在等数据;再看CPU利用率,如果某个核跑满说明有单线程瓶颈;再看内存和显存占用,如果持续增长说明有内存泄漏。

GPU利用率低最常见的原因是数据加载慢。解决方案是增加num_workers、开启pin_memory、把数据预处理放到GPU上做。如果数据在磁盘上,考虑用SSD或者把数据缓存到内存里。

内存泄漏的排查比较麻烦。Python的垃圾回收机制有时候不能及时释放循环引用的对象。我的做法是在训练循环里定期调用gc.collect()和torch.cuda.empty_cache(),然后观察显存占用是否稳定。如果还是持续增长,就用objgraph库找出哪些对象在不断增加。

import gc import torch def cleanup(): gc.collect() torch.cuda.empty_cache() if torch.cuda.is_available(): allocated = torch.cuda.memory_allocated() / 1024**3 reserved = torch.cuda.memory_reserved() / 1024**3 print(f"显存分配: {allocated:.2f}GB, 显存保留: {reserved:.2f}GB")

memory_allocated是实际被张量占用的显存,memory_reserved是PyTorch向GPU申请的总显存。如果reserved远大于allocated,说明有碎片化问题,可以通过设置PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True来缓解。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
训练loss不下降学习率过大/过小打印梯度范数调整学习率,加Warmup
训练loss变NaN梯度爆炸检查梯度范数加梯度裁剪,降低学习率
GPU利用率低数据加载瓶颈看CPU和IO增加num_workers,pin_memory
推理延迟高模型太大/批处理不当看P99延迟量化模型,动态批处理
显存溢出batch_size太大看显存占用减小batch_size,混合精度
线上效果差数据漂移计算PSI重新训练,加监控告警
服务崩溃内存泄漏看内存增长曲线定期gc,检查循环引用

这张表是我自己遇到问题后整理的,基本上覆盖了从零搭建AI工程时80%的常见故障。每次出问题的时候先查这张表,能省下大量排查时间。

6. 从零搭建的避坑心得与扩展方向

6.1 那些文档里不会写的坑

第一个坑是CUDA版本和PyTorch版本的兼容性。你以为pip install torch就完事了,结果跑起来报CUDA error: no kernel image is available for execution。原因是PyTorch编译时用的CUDA版本和你的驱动版本不匹配。我的做法是先去PyTorch官网查兼容性矩阵,确定驱动版本支持的CUDA版本,然后安装对应版本的PyTorch。不要用pip install torch的默认版本,一定要指定版本号。

第二个坑是多进程数据加载时的随机种子。你在主进程设置了torch.manual_seed(42),但DataLoader的worker进程里随机种子是独立的。如果数据增强用了随机操作,每个worker的增强结果会不一样,导致训练不稳定。解决方案是在worker初始化函数里重新设置种子:

def worker_init_fn(worker_id): np.random.seed(42 + worker_id) random.seed(42 + worker_id) train_loader = DataLoader( dataset, worker_init_fn=worker_init_fn, ... )

第三个坑是模型保存和加载时的设备映射。你在GPU上训练并保存了模型,加载的时候如果直接torch.load("model.pt"),它会尝试加载到原来的GPU设备上。如果换了机器或者GPU编号变了,就会报错。正确的做法是指定map_location:

state_dict = torch.load("model.pt", map_location="cpu") model.load_state_dict(state_dict) model.to(device)

6.2 后续可以扩展的方向

从零搭建完基础版本之后,有几个方向可以继续深入。模型量化能把FP32模型压缩成INT8,推理速度提升2到4倍,精度损失通常在1%以内。知识蒸馏用大模型教小模型,能在保持效果的同时大幅降低推理成本。A/B测试框架让你能同时跑多个模型版本,根据线上指标自动切换。

还有一个方向是特征版本管理。当你的特征处理逻辑发生变化时,旧模型可能不兼容新特征。解决方案是给每个特征集打上版本号,模型训练时记录使用的特征版本,推理时校验版本一致性。这个机制在多人协作的项目里特别重要。

我自己在实际操作中的体会是,从零搭建AI工程最大的价值不是让你成为某个框架的专家,而是让你建立起对整条链路的直觉。当线上出问题的时候,你能快速判断是数据的问题、模型的问题还是服务的问题。这种直觉是调包调不出来的,只能靠亲手踩坑踩出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询