把模型训练完只是第一步,真正让它产生价值的是把它交到别人手里。你写好一段推理脚本,在自己的Jupyter Notebook里跑得飞快,精度也不错,但产品同事、前端同学、甚至是另一个服务想调用它时,总不能用截图或者把你电脑上的脚本拷贝过去跑。这时候最通用、最低侵入的方案就是:把模型包装成一个Web API,让任何人通过HTTP请求就能拿到预测结果。这也是我在多个项目里经历过从“本地能跑”到“稳定上线”之后,最想分享的一段经验。
这篇文章不聊高深理论,只讲实战。我会带你走一遍一个典型的机器学习模型部署流程:从模型序列化、接口设计,到用FastAPI封装、性能调优、Docker容器化,最后总结我在实际部署中踩过的坑。适合那些已经会用sklearn或PyTorch训练模型,但还不清楚怎么把它变成线上服务的同学。如果你只是听说过“模型部署”但没动过手,这篇可以直接照着操作。
1. 部署形态与选型:为什么Web API是必经之路
1.1 从Notebook到生产:脚本调用和API的差别
很多初学者会觉得,模型部署不就是写个predict函数,然后在服务器上跑起来吗?其实中间的坑比想象中多得多。最原始的方式是把模型文件和一个Python脚本放在服务器上,通过命令行或定时任务调用。这种方式看起来简单,但有几个硬伤:第一,调用方没法跨语言,别人用Java、Go写的服务要调你的模型,总不能强制他装Python环境;第二,无法处理并发请求,脚本是进程级的,来一个请求就要起一个Python进程,GPU资源根本吃不住;第三,没有统一的错误处理和数据校验机制,输入一个坏数据可能直接让进程崩溃。
Web API之所以成为事实标准,是因为它把“模型能力”抽象成了一个HTTP端点。调用方只需要发送JSON格式的输入,收到JSON格式的输出,完全不用关心背后是sklearn、TensorFlow还是某个自研的推理引擎。这种解耦方式让模型服务可以独立部署、独立扩容,也能很自然地接入微服务体系里。我在实际项目里最直观的感受是:当后端把这个预测接口暴露给前端和第三方之后,后续的模型迭代只需要替换服务内部的文件,接口地址和数据结构不用变,省了无穷无尽的沟通成本。
1.2 主流框架对比与我的选择
就算决定用Web API,也还有框架选型的问题。我在几个框架里都写过不同的部署服务,简单说下我的主观体验:
| 框架 | 适合场景 | 我的体验 |
|---|---|---|
| Flask | 简单快速出活 | 同步阻塞,性能一般,写小demo没问题,生产环境要自己封装很多东西 |
| FastAPI | 现代异步API、高并发、自动文档 | 基于Pydantic做数据校验很爽,自带OpenAPI文档,性能也不错,我最推荐 |
| Django REST Framework | 需要自带后台管理、ORM | 重量级,适合有数据库交互的大系统,普通模型服务杀鸡用牛刀 |
| .NET Core Web API | 企业级C#技术栈 | 配合ML.NET或ONNX Runtime能跑,但生态相对封闭,团队要都是Python就绕开 |
FastAPI用起来最顺手。它原生支持异步,不用自己折腾线程池;请求体用Pydantic校验,输入格式不对能直接返回400错误,省得在函数里写一堆if not isinstance。另外它自动生成的/docs接口文档可以直接拿给前端同学联调,这是Flask没有的待遇。如果你的团队对JVM更熟,也可以用Spring Boot,但后面我所有的例子都会用FastAPI,因为它在大部分模型部署场景里最不费脑子。
模型格式上,如果只是sklearn或XGBoost这类传统模型,直接joblib.dump或者pickle.dump就够了;如果是深度学习模型,建议导出成ONNX,这样能跨框架、用CPU也很快,后面会专门讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模型落地前必须想清楚的几件事:序列化与数据对齐
2.1 模型持久化格式:pkl、joblib、onnx该怎么选
很多人直接把model.save("model.h5")或pickle.dump(model)就完事了,直到部署时才踩坑。不同框架有不同的序列化方式,我们得先处理好“模型文件”这一步。
对于传统机器学习模型,比如sklearn里的LogisticRegression、RandomForest,我推荐用joblib.dump,因为它对numpy数组做了优化,加载速度比原生pickle快不少。示例:
python复制import joblib
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_train, y_train)
joblib.dump(model, "model.joblib")
对于PyTorch模型,不要在项目里到处用torch.save(model.state_dict()),因为部署时需要先定义出相同的模型类,很容易因为代码路径不一致而出错。更稳妥的做法是导出为torch.jit.script或ONNX。我特别推荐ONNX,因为它不仅让推理可以用ONNX Runtime这个轻量级引擎,还能直接兼容CPU、GPU,做量化也方便。
导出ONNX时需要注意,输入张量的维度名最好定义得清晰一点,后面生成的API文档会好懂很多:
python复制import torch
import torch.onnx
dummy_input = torch.randn(1, 3, 224, 224)
torch.onnx.export(model, dummy_input, "model.onnx",
input_names=["input"], output_names=["output"],
dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"},
"output": {0: "batch"}})
这一步的关键是别随便动态维度,有些算子不支持动态shape,导出时容易报错。如果业务确实有可变尺寸的输入,建议统一做resize,把输入固定成一个形状,省得后续一堆麻烦。
2.2 特征处理流程的一致性问题
这是部署里最容易翻车的地方,没有之一。比如训练时你给原始数据做了标准化、缺失值填充、类别编码,但部署时如果只有模型文件,新来的请求没有经过同样处理,预测结果就是垃圾。更严重的是,你把这些预处理逻辑写在训练脚本里,没有单独抽出来,那么线上推理时只能重新实现一遍,手抖漏掉一个StandardScaler就完蛋。
我的做法是把“特征工程”封装成一个独立的类或函数,训练和推理共用同一份代码。更简单一点的办法是把预处理器和模型打包成Pipeline,用joblib.dump一起保存:
python复制from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
pipeline = Pipeline([
("scaler", StandardScaler()),
("model", LogisticRegression())
])
pipeline.fit(X_train, y_train)
joblib.dump(pipeline, "pipeline.joblib")
这样线上加载的时候直接pipeline.predict(input_data),预处理和模型预测在一条链路里完成,不会出现两边逻辑漂移。如果你用PyTorch这类框架,建议把预处理统一放到API启动时初始化的一个Preprocessor里,并写单元测试确保线上和线下处理结果一致。我在实战中测过,有时候线上请求里传入的特征有缺失值,而训练时没有,如果预处理里没写np.nan_to_num,直接会让模型异常,所以预处理模块必须能容忍脏数据。
3. 手把手把模型封装成FastAPI接口
3.1 项目结构设计
先搭一个干净的目录结构,后面维护起来很舒服。我常用的项目布局是这样的:
code复制ml-api/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI入口
│ ├── schemas.py # 请求/响应的Pydantic模型
│ ├── inference.py # 模型加载与推理逻辑
│ └── config.py # 配置项,如模型路径
├── models/
│ └── pipeline.joblib # 模型文件
├── requirements.txt
└── Dockerfile
为什么不把所有代码塞进一个main.py?因为模型服务也会长大。一旦需要支持多个模型、多种输入格式,或者要加一些管理接口,拆分开会清晰很多。另外,模型加载是重型操作,不应该每次请求都重新读一次文件,所以我会在inference.py里做模块级加载,让模型在进程启动时就被加载进内存。
3.2 核心代码实现
先写config.py,把模型路径等常量放进去:
python复制from pathlib import Path
MODEL_PATH = Path(__file__).parent.parent / "models" / "pipeline.joblib"
inference.py负责加载模型和定义预测函数:
python复制import joblib
import numpy as np
from app.config import MODEL_PATH
_pipeline = None
def get_model():
global _pipeline
if _pipeline is None:
_pipeline = joblib.load(MODEL_PATH)
return _pipeline
def predict(features: list[float]) -> dict:
model = get_model()
X = np.array(features).reshape(1, -1)
proba = model.predict_proba(X)[0]
label = int(model.predict(X)[0])
return {"label": label, "probability": float(proba.max())}
注意这里我用了proba.max(),如果你的业务场景需要每一类的概率,就把整个proba数组返回,记得把numpy类型转成Python原生类型,否则FastAPI在序列化时会报“Object of type float32 is not JSON serializable”。这是一个高频坑。
schemas.py里定义清楚请求体和响应体:
python复制from pydantic import BaseModel, Field
class PredictRequest(BaseModel):
features: list[float] = Field(
...,
description="特征向量,顺序必须与训练时一致",
min_length=1
)
class PredictResponse(BaseModel):
label: int
probability: float
最后是main.py:
python复制from fastapi import FastAPI
from app.schemas import PredictRequest, PredictResponse
from app.inference import predict
app = FastAPI(title="ML Model API")
@app.post("/predict", response_model=PredictResponse)
def predict_endpoint(req: PredictRequest):
result = predict(req.features)
return result
@app.get("/health")
def health():
return {"status": "ok"}
这里有两个细节。第一,/health这个端点是非常必要的,后面接K8s或者负载均衡时,探活全靠它。第二,我把响应模型定义成了Pydantic的PredictResponse,FastAPI会根据它自动做字段校验和序列化,省去手动转JSON的代码。
如果你想把多个模型挂在同一个服务下,可以在请求体里加一个model_name字段,然后在predict函数里做一个字典映射,把不同模型加载到不同命名空间的全局变量里。不过我不建议一开始就做成插件化,等确实有多个模型再重构,早做反而复杂。
3.3 本地调试和第一个HTTP请求
本地启动服务:
bash复制uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
--reload只建议开发时用,生产环境一定去掉,因为热重载会带来额外的文件监控开销,而且有可能重复加载模型。
然后你就可以用curl来测了:
bash复制curl -X POST "http://127.0.0.1:8000/predict" \
-H "Content-Type: application/json" \
-d '{"features": [5.1, 3.5, 1.4, 0.2]}'
正常返回:
json复制{"label":0,"probability":0.97}
同时打开http://127.0.0.1:8000/docs,你会看到自动生成的接口文档,可以直接在页面上点击“Try it out”测试请求。这个文档在对接前端时非常有用,前端照着Schema就能知道字段名和类型,不需要再翻代码。
4. 性能进阶:量化、并发和容器化部署
4.1 FP32、FP16、BF16、TF32到底怎么选
如果你的模型是深度学习模型,浮点精度选型对显存占用和推理速度影响巨大。热词里提到的那几个概念,我结合自己的部署经验解释一下。
FP32是默认的单精度浮点数,训练阶段几乎都用它,因为精度有保障。但推理时,如果模型权重和中间激活值都存成32位,占显存大,计算也慢。于是就有了低精度推理。
FP16是半精度,显存占用减半,计算速度在支持FP16的GPU上能翻倍。但代价是数值范围小,容易溢出,对某些模型(尤其是带有BatchNorm的)可能出现精度明显下降。如果模型对精度不敏感,比如做图像分类,FP16一般没问题。BF16是Brain Floating Point格式,它保留了FP32的指数范围,只是降低了尾数精度,所以不容易溢出,对大多数模型更友好。TF32是NVIDIA Ampere架构以后提出的格式,本质是Tensor Core上的一种截断精度计算,它训练的数值稳定性和FP32差不多,但速度接近FP16。
我的经验法则是:
| 格式 | 显存占用 | 速度 | 精度风险 | 适用场景 |
|---|---|---|---|---|
| FP32 | 基准 | 慢 | 无 | 小模型、精度要求极高 |
| FP16 | 减半 | 快 | 中等 | 图像识别、大多数BERT变体 |
| BF16 | 减半 | 快 | 低 | 大模型、推荐系统 |
| TF32 | 近FP32 | 较快 | 极低 | NVIDIA A100/V100等Tensor Core场景 |
在代码里切格式很简单,PyTorch推理时:
python复制model.half() # 转成FP16
model.eval()
with torch.no_grad():
output = model(input_tensor.half())
但注意:如果模型里有CPU算子,model.half()可能会导致类型不匹配报错。别偷懒,一定要在推理数据里把输入也转成对应精度。部署前通过离线评测集验证一下转换后的精度,选一个又快又准的格式。
4.2 使用ONNX Runtime加速推理
ONNX Runtime是微软开源的推理引擎,非常值得集成到FastAPI服务里。我试过在CPU上把一个BERT模型从PyTorch换到ONNX Runtime,速度能快3到5倍,内存占用也更小。
用ONNX Runtime加载模型很简单:
python复制import onnxruntime as ort
sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
def predict_onnx(input_data):
ort_inputs = {sess.get_inputs()[0].name: input_data}
ort_outputs = sess.run(None, ort_inputs)
return ort_outputs[0]
如果有多GPU,可以配置CUDAExecutionProvider,但要注意provider顺序,通常CUDA优先:
python复制sess = ort.InferenceSession(
"model.onnx",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"]
)
这里有个小坑:ONNX Runtime的输入是numpy数组,且shape必须匹配导出时的shape。我默认把输入reshape成(batch, dim)再喂进去,不然会报shape mismatch。
如果还想再快一点,可以做模型量化。ONNX Runtime支持动态量化,把FP32权重转成INT8,压缩后模型大小缩小到四分之一,CPU上推理速度可以再提升一个档次。代码很简单:
bash复制python -m onnxruntime.quantization.quantize --input model.onnx --output model_int8.onnx
量化后精度会有些许下降,我实践过在文本分类任务上,F1值一般掉0.01到0.02,可以接受。但如果你的业务是对数字极其敏感的金融风控,建议先跑离线评测再决定。
4.3 用Gunicorn+Uvicorn跑多进程
默认uvicorn app.main:app只启动一个进程,CPU利用率上不去。生产环境我会一边用Gunicorn做进程管理,一边让Uvicorn的worker负责ASGI底层协议,这样能利用多核CPU。
安装依赖:
bash复制pip install gunicorn uvicorn
启动命令:
bash复制gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 --timeout 120
-w 4表示4个worker进程,一般按CPU核心数+1来设置。但注意,如果你的模型本身就占用了大量内存(比如深度学习模型),开太多worker会导致内存超载。比如机器只有8G内存,模型加载就占了4G,两个worker可能直接OOM,所以需要根据实际内存调节worker数。
另外,FastAPI本身是异步框架,但它内部调用的predict函数是同步阻塞的。当模型推理耗时较长时,这会阻塞事件循环。我的解决办法是在predict函数前加def还是async def?如果你用的是同步阻塞的模型推理,建议直接把路由函数声明为同步def,FastAPI会自动把同步函数放到线程池里执行,避免阻塞整个事件循环。如果你硬要写成async def,然后同步调用模型,那就把并发能力废了。
4.4 把服务装进Docker
为了让服务在任何环境都能一键启动,容器化是必选项。一个最小可用的Dockerfile长这样:
dockerfile复制FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY models ./models
EXPOSE 8000
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "2", "-b", "0.0.0.0:8000", "--timeout", "120"]
构建并启动:
bash复制docker build -t ml-api .
docker run -d -p 8000:8000 ml-api
这里有几个生产环境会踩的坑。第一,基础镜像别用python:3.9这种全量版,镜像巨大,build时间也长,用-slim就够了。第二,requirements.txt里最好锁定所有依赖的精确版本,不要用>=,不然一周后构建镜像可能就装了个不兼容的新版本,服务起不来。第三,模型文件要一起打进镜像,但这样一来镜像会很大。更优雅的做法是用数据卷把模型目录挂载进来,这样模型更新时不需要重新构建镜像。我通常用docker run -v /path/to/models:/app/models这种方式,把模型目录从宿主机挂载进去,方便热更新。
5. 线上事故与排障实录:你大概率也会遇到
5.1 常见问题速查表
我把这几年模型服务上线后遇到的典型问题整理了一下,方便你排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 接口返回500,日志里报“Object of type float32 is not JSON serializable” | numpy类型没有转Python原生类型 | 统一用float()、int()做转换 |
| 模型加载特别慢,每个请求都卡很久 | 在函数内部重复加载模型 | 用模块级缓存,只加载一次 |
| 并发一高,响应变慢甚至超时 | 同步阻塞写了async def,或worker数不够 |
改用同步def,增大Gunicorn worker数 |
| Docker启动后端口无法访问 | Dockerfile里EXPOSE和运行时-p没配对 |
检查EXPOSE和-p映射,最终以-p为准 |
| 输入是图片,base64字符串太长,请求体超限 | 没有调整反向代理的body大小限制 | nginx里设client_max_body_size 10m |
| 模型精度和离线测试不一致 | 特征预处理不一致或漏做 | 把特征工程封装进Pipeline共用一个对象 |
| 服务内存持续上涨,最终OOM | 每个worker加载了模型副本,worker开太多 | 按内存容量估算worker数,或者用单独推理进程 |
| /docs接口打开是空白 | 网络代理或CDN拦截了静态资源 | 部署环境关闭对/swagger-ui的拦截 |
5.2 排查实战:一次真实的并发超时问题
有一次我部署一个基于BERT的语义匹配服务,单请求耗时约150ms,本地压测到50并发时延迟飙升到秒级,CPU使用率却只有30%。当时怀疑是模型推理太慢,但用top看CPU没有榨干,后来才发现是接口函数写了async def predict_endpoint,内部却调用同步的transformers模型。事件循环被阻塞,处理并发请求的能力几乎为零。改成普通def后,FastAPI把请求交给线程池,50并发下单请求耗时稳定在220ms左右,吞吐量立刻上来了。这个经验告诉我,FastAPI的“异步”不等于“你的模型会自动异步”,要理解它的事件循环机制。
另一个经验是把耗时操作做成离线批量。如果你的场景不要求实时响应,比如用户上传一批数据,点击“预测”后等结果,完全可以改为后台任务+轮询接口。这个不在本文展开,但方向是对的。
5.3 避坑心得:模型版本管理和回滚
最后分享两个我觉得极其重要的习惯。
第一,每次重训后的模型文件不要覆盖旧文件。我习惯在模型文件名里带上版本号或者训练时间,同时API配置里指定当前使用的是哪个版本。比如:
code复制models/
├── pipeline_v1.joblib
├── pipeline_v2.joblib
└── current→pipeline_v2.joblib
通过软链接切换当前版本,回滚只需要改软链接指向。这样即使新模型有问题,也能秒级切回旧模型。
第二,上线前一定要跑一个“冒烟测试”。我不会只测正常输入,还会故意发空字段、类型错误、缺失值等异常请求,确认接口会返回可读的错误信息而不是一大段堆栈。给前端同学调试时,错误信息最好直接放在HTTP响应体里,比如{"detail": "feature list cannot be empty"}。这样两边协作效率高很多。
我在实际项目里踩过的坑远不止这些,但上面这些是最共性、最容易复发的。模型部署这件事,聪明人很多,但把细节敲定的人很少。很多线上事故都不是模型不行,而是工程细节没有对齐。
最后说点个人体会。从模型到Web API,看起来只是一个包装层,但它背后牵涉到数据流设计、进程模型、资源管理、异常处理、版本发布等一堆事情。如果你正在准备把自己的第一个模型部署成服务,别怕,把这个过程走一遍,你对机器学习工程化的理解会上升一个台阶。我现在的习惯是,任何新模型上来,先问自己三个问题:线上输入长什么样?特征处理逻辑和训练时一不一致?如果模型挂了,怎么快速回滚?这三个问题想清楚,部署就成功了一大半。
