
先说个挺常见的场景训练阶段你可以在 Jupyter Notebook 里把模型精度刷到 90% 以上各种花活玩得飞起但真到了“上线”这一步很多人会突然卡住。要么不知道怎么把模型变成别人能调用的服务要么图省事用 Flask 起了一个最简单的接口结果线上并发一上来直接被打爆。机器学习模型部署这个环节本质上是把训练阶段的“实验品”变成生产环境里的“产品”而把模型封装成 Web API 是其中最常见、也最实用的落地方式。这篇文章我会结合自己部署过的几个实际项目把“模型转 Web API”的完整链路讲清楚从模型怎么导出、推理引擎怎么选到 FastAPI 服务怎么写、性能怎么压再到容器化上线和线上踩坑。内容偏实践给的是可以直接照着抄的方案适合已经跑通训练流程、正准备把模型推上线或者对部署流程还比较模糊的读者。1. 为什么非要把模型塞进 Web API 里1.1 脚本调用的局限性模型训练完之后如果你的交付物只是一个.pth文件或者.h5文件那这个模型本质上还是“死”的。业务方要调用它总不能每个人都去装 Python 环境、装 PyTorch、把权重文件拷来拷去吧更现实的问题是你的模型可能需要跑在 GPU 上而调用方可能只是一台普通的业务服务器甚至是一个前端页面。这时候把模型封装成 Web API等于在模型和业务之间搭了一座桥调用方只需要发一个 HTTP 请求就能拿到推理结果完全不关心背后是 PyTorch 还是 TensorFlow也不关心是 GPU 还是 CPU。我之前接过一个项目业务方的系统是 Java 写的模型是 Python 训练的两边根本没法直接互通。最后就是用 FastAPI 把模型包成一个推理服务Java 那边通过 HTTP 调用整个联调过程非常顺。这种跨语言、跨团队的协作场景几乎是 Web API 化最典型的动机。1.2 API 化解决的不只是“远程调用”很多人觉得 API 化就是“让别的程序能调用”其实它的价值远不止于此。当你把模型封装成独立服务之后你顺带获得了几样东西模型可以独立扩缩容——大促的时候多加几个推理实例平时缩回来省钱模型可以独立升级——新模型上线不影响业务主流程你可以做 A/B 测试让一部分流量走新模型模型可以独立监控——每个请求的耗时、成功率、推理结果分布都能单独统计出了问题能快速定位。我有一个很深的体会模型服务最怕的不是性能差而是“出了事没人知道”。直接嵌在业务代码里的模型出了问题会被业务日志淹没独立成 API 服务之后你可以给这个服务单独配监控、配告警模型什么时候开始漂移、什么时候响应变慢一清二楚。所以从架构角度看API 化不是多此一举而是让模型真正成为可运维的基础设施。2. 部署前的模型体检格式、精度与推理引擎选型2.1 模型格式不是玄学模型从训练到部署第一步是解决“格式”问题。很多人直接拿着 PyTorch 的.pth文件就上线了这在原型阶段没问题但生产环境里通常不建议这么做原因有三一是性能PyTorch 的推理模式虽然做了优化但跟专门的推理引擎比还是有差距二是依赖.pth文件必须配合 PyTorch 环境才能跑镜像体积直接奔着几个 GB 去了三是跨平台你很难把 PyTorch 模型直接塞进移动端、边缘设备或者 Java 环境里。目前主流做法是导出为 ONNX 格式。ONNX 是一个开放的模型表示标准相当于模型界的“通用语言”主流的深度学习框架都能导出主流的推理引擎也都支持。导出之后你可以用 ONNX Runtime、TensorRT 这些专门优化过的推理引擎来加载它速度通常比原框架直接推理快不少而且可以脱离 PyTorch 环境运行。2.2 如何导出 ONNX 并且不炸精度以 PyTorch 为例导出 ONNX 的核心代码其实很简单import torch import torch.onnx model YourModel() model.load_state_dict(torch.load(model.pth)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, model.onnx, export_paramsTrue, opset_version17, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}} )这里有几个关键点都是我在实际导出时踩过坑才总结出来的。第一个是必须调用model.eval()。很多模型在训练模式下有 Dropout 和 BatchNorm 的随机行为不切换到 eval 模式导出的 ONNX 推理结果会不稳定精度飘得你怀疑人生。第二个是dummy_input 的形状要和真实输入一致。别小看这个占位张量它决定了 ONNX 图里输入张量的形状。如果你的服务要支持任意 batch size就必须配置dynamic_axes把 batch 维度设成动态的。但要注意动态维度会带来一定的性能损失因为推理引擎没法做静态形状优化。如果业务场景里 batch size 是固定的反而建议用静态形状性能更好。第三个是注意算子兼容性。PyTorch 版本太老或者 opset 版本太低某些算子可能导不出来。我遇到过一个比较典型的问题模型里用了某个较新的注意力机制算子用默认的 opset 版本导出直接报错把opset_version调高之后就好了。反过来也有问题太高的 opset 版本在某些老的推理框架上不兼容。建议导完以后用onnxruntime先跑一遍推理跟 PyTorch 的输出比对一下误差误差在 1e-4 量级以内基本没问题。2.3 推理引擎怎么选ONNX 只是中间表示真正跑推理的是引擎。目前最常用的三个选择是ONNX Runtime、TensorRT 和 OpenVINO。ONNX Runtime 是最通用的选择微软出品CPU 和 GPU 都支持安装简单生态完善而且不用写任何额外代码。绝大多数场景下ONNX Runtime 的优化已经够用了我一般首推它。TensorRT 是 NVIDIA 的推理引擎性能是真的猛但前提是你得有 NVIDIA 显卡而且它会把 ONNX 图做一层编译优化生成 TensorRT 引擎文件.plan。这个过程挺折腾尤其在模型结构比较复杂的场景下各种算子不支持、参数调不明白的问题层出不穷。我的经验是如果你的 GPU 推理服务 QPS 要求极高值得花时间去搞 TensorRT如果只是普通业务量级ONNX Runtime 完全够用别自找麻烦。OpenVINO 是 Intel 的推理引擎主打 CPU 推理优化在 Intel 的 CPU 上效果不错。如果你们的推理服务跑在纯 CPU 环境可以考虑它部署方式和 ONNX Runtime 类似切换成本不高。顺手用表格总结一下推理引擎适用场景优点缺点ONNX Runtime通用场景CPU/GPU 均可部署简单、跨平台、生态成熟极致性能不如专用引擎TensorRTNVIDIA GPU 高并发场景延迟低、吞吐高只支持 NVIDIA、编译过程繁琐OpenVINOIntel CPU 推理CPU 性能优化好依赖 Intel 硬件、生态相对封闭3. 用 FastAPI 搭建推理服务从能跑到好用3.1 为什么选 FastAPI 而不是 Flask很多人一想到写 Web 服务第一个想到的是 Flask。但做模型推理服务我更推荐 FastAPI。原因很实在FastAPI 原生支持异步而推理服务最大的瓶颈往往在 I/O 上——请求进来、预处理图片/文本、调推理引擎、返回结果这里面有大量等待操作异步能把并发能力拉高一大截。另外 FastAPI 自带 OpenAPI 文档接口联调的时候直接把文档地址发给前端或者业务方他们自己就能试接口省了不知道多少沟通成本。FastAPI 还有一点对我很实用基于 Pydantic 的请求参数校验。推理服务的输入五花八门——图片可能是 base64 字符串、可能是 URL文本可能是单条、可能是批量列表。用 Pydantic 定义好请求模型非法输入在进业务逻辑之前就被拦下了省得在代码里写一堆 if else 做防御。3.2 一个能直接用的推理服务骨架这是我目前比较推荐的一个最小可用的服务结构# app.py import base64 import io import time import numpy as np import onnxruntime as ort from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI(titleImage Classification Service) # 模型加载放在模块级别避免每次请求都重新加载 session ort.InferenceSession(model.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) class PredictRequest(BaseModel): image_base64: str top_k: int 5 class PredictResponse(BaseModel): class_id: int confidence: float def preprocess(image_data: bytes) - np.ndarray: # 这里省略具体图像解码和归一化逻辑 pass def postprocess(logits: np.ndarray, top_k: int): probs np.exp(logits) / np.exp(logits).sum() idx np.argsort(probs)[::-1][:top_k] return idx.tolist(), probs[idx].tolist() app.post(/predict, response_modelPredictResponse) async def predict(req: PredictRequest): try: image_bytes base64.b64decode(req.image_base64) tensor preprocess(image_bytes) logits session.run(None, {input: tensor})[0] class_id, conf postprocess(logits, req.top_k) return {class_id: class_id, confidence: conf} except Exception as e: raise HTTPException(status_code400, detailfinference failed: {str(e)})这里有一个很重要的设计模型加载必须放在模块级别也就是在进程启动时加载一次而不是每次请求都重新加载。ONNX 模型加载和初始化是相对重的操作如果放在请求处理函数里并发上来之后每个请求都要重新初始化性能直接崩。我之前见过一个同事把InferenceSession放在函数内部创建本地单测没问题一压测 CPU 直接打满QPS 低得可怜。另外一个细节是providers参数的设置。ONNX Runtime 加载时会自动选择可用的 provider最好显式指定优先级有 GPU 就用 GPU没有就退到 CPU这样同一个镜像在开发机和 GPU 服务器上都能跑。3.3 输入校验与错误设计别让脏数据打穿服务模型服务的输入往往是不受控的——业务方传上来的图片可能是损坏的、格式不对的、尺寸离谱的文本可能是空串、超长的、包含特殊字符的。如果你在预处理阶段不做防护一个异常输入就可能让整个 worker 崩溃。我的做法是多层防护第一层是 Pydantic 的字段校验限制字段类型、长度范围第二层是在预处理函数里做 try-except解析失败就立刻返回 4xx 错误不要让异常抛到框架层面第三层是给推理本身加超时保护用一个线程池或者子进程去跑推理超过一定时间就返回超时错误。关于错误码的设计我踩过一个教训一开始我把所有异常都返回 500结果调用方根本分不清是“你传的参数有问题”还是“我的服务出故障了”。正确的做法是把参数错误、格式错误、推理超时这类问题都映射为 4xx只有服务内部真正不可恢复的故障才返回 5xx。这样调用方可以根据状态码做重试或者降级处理监控报警也能更准确地定位问题。4. 性能治理并发、批处理与显存博弈4.1 同步接口为什么一口气就喘不过来一个很常见的现象服务写完了本地测了一下单请求延迟 20ms挺快。结果一上线压测发现 QPS 只有几十。问题出在哪很可能就是你用了同步处理。FastAPI 是异步框架但如果你在异步函数里直接调用同步的 ONNX Runtime 推理整个事件循环会被阻塞一个请求在跑推理其他所有请求都得排队。这就好比一个单线程的收银台第一个顾客在慢慢找钱后面所有人干等着。解决办法有几种。简单粗暴的方案是用run_in_executor把推理丢到线程池里跑import asyncio import concurrent.futures executor concurrent.futures.ThreadPoolExecutor(max_workers8) app.post(/predict) async def predict(req: PredictRequest): loop asyncio.get_event_loop() result await loop.run_in_executor(executor, run_inference, req) return result但这里有个要命的坑ONNX Runtime 的 session 不是严格线程安全的多个线程同时调用同一个 session 的run方法可能会出现竞争问题。实际测下来ONNX Runtime 的InferenceSession.run在多线程下是可用的但速度会有锁竞争开销。更稳妥的做法是在每个 worker 里创建独立 session。不过这样一来显存占用会成倍增长——每个 session 都要加载一份模型权重8 个线程就是 8 份显存。这就是性能和资源的博弈。我的经验是先确认你的推理引擎底层是不是已经做了并发处理。ONNX Runtime 在新版本里已经比较好地支持多线程调用了线程数控制在 2-4 个通常问题不大再多就容易出现显存溢出或者性能不升反降的情况。4.2 动态批处理把多个请求攒起来一起算如果你的模型单次推理延迟是 10ms一次推理能同时处理 32 个样本那么把多个请求攒到一起推理整体吞吐量能提升一个量级。这就是动态批处理Dynamic Batching的思路。实现方式不复杂用一个队列收集请求每隔固定时间比如 10ms或者攒够一定数量就批量推理一次然后把结果分发给各个请求。核心代码如下import asyncio from collections import deque class BatchInferenceManager: def __init__(self, batch_size32, max_wait0.01): self.batch_size batch_size self.max_wait max_wait self.queue deque() self._lock asyncio.Lock() async def submit(self, tensor): future asyncio.Future() async with self._lock: self.queue.append((tensor, future)) return await future async def worker(self): while True: async with self._lock: if len(self.queue) 0: await asyncio.sleep(self.max_wait) continue batch [] while self.queue and len(batch) self.batch_size: tensor, future self.queue.popleft() batch.append((tensor, future)) if batch: tensors np.concatenate([t for t, _ in batch], axis0) logits session.run(None, {input: tensors})[0] for (_, future), logit in zip(batch, logits): future.set_result(logit[None, ...])动态批处理的关键参数是batch_size和max_wait。batch_size决定了攒到多少开始算max_wait决定了最多等多久。两个参数需要根据业务流量调高并发场景可以把 batch 调大、等待时间调短低流量场景为了不拖慢单请求延迟可以把等待时间压到 5ms 以内。4.3 GPU 显存博弈与多模型共存当同一个 GPU 上要部署多个模型服务时显存分配就是个头疼的问题。ONNX Runtime 默认会把模型整个加载进显存而且理论上会预分配一部分显存池。如果两个服务各自加载一个大模型第二个模型很可能因为显存不足直接启动失败。我实际用过比较顺手的方案是给每个服务设置arena_extend_strategy和gpu_mem_limit这类配置项控制 ONNX Runtime 的显存使用上限。另外就是通过环境变量CUDA_VISIBLE_DEVICES把不同的服务隔离到不同的 GPU 上虽然会造成一定浪费但隔离性最好出问题不会互相影响。如果你要在一个服务里同时加载多个模型比如一个检测模型加一个分类模型考虑用一个共享的 session 池来管理模型按需加载长期不用的模型可以释放避免显存碎片化。5. 容器化与上线Dockerfile 只是第一步5.1 镜像构建的正确姿势模型服务容器化第一件事是写 Dockerfile。这里最容易犯的错是图省事直接pip install torch装一整套深度学习框架。其实如果你已经导出成 ONNX 模型推理环境根本不需要 torch只需要onnxruntime和fastapi这几个轻量依赖就够了。一个值得借鉴的 Dockerfile 示例FROM python:3.11-slim WORKDIR /app # 先装依赖利用层缓存 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再拷贝代码和模型 COPY app/ ./app/ COPY model.onnx . # 用非 root 用户运行 RUN useradd -m inference USER inference EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000, --workers, 2]有几个细节值得强调一是requirements.txt单独拷贝、先安装依赖再拷贝代码这是为了利用 Docker 的层缓存以后代码改动不用重新下载依赖二是用非 root 用户运行服务降低安全风险三是CMD里直接写uvicorn不要用 shell 脚本包一层减少不必要的进程层级。模型文件要不要打进镜像里我的建议是模型不大几百 MB 以内就直接打进镜像部署最简单模型很大几个 GB或者更新频繁就别打进镜像而是挂载到容器里。因为镜像一旦包含大模型每次构建和推送都是一场灾难而且镜像仓库会迅速膨胀。用外挂模型的方式模型更新只需要替换文件再重启容器不用重新构建镜像。5.2 健康检查与优雅退出容器化部署之后编排系统比如 Kubernetes会通过健康检查来判断服务是否存活。如果你的服务没有实现/health接口编排系统只能靠“进程还活着”来判断这在很多场景下是不够的。模型服务比较典型的问题是进程活着但 GPU 显存已经爆了推理全部报错。这时候健康检查应该能发现服务不可用触发重启。我一般会实现两个健康检查接口一个是/health/live只检查进程是否存活给运维同事看的一个是/health/ready会实际做一次极小的推理确认推理引擎还能正常工作给编排系统看。ready 检查会额外消耗一点资源但换来的是一旦服务“假死”能被及时发现。优雅退出也很重要。Kubernetes 在滚动更新时会先给容器发 SIGTERM 信号然后等待一段时间再强杀。如果你的服务收到 SIGTERM 就立刻退出当前正在处理的请求会全部断掉。正确姿势是监听 SIGTERM 信号停止接收新请求等当前请求处理完再退出。FastAPI 配合 uvicorn 其实内置了优雅退出机制但你需要在代码里避免“一收到信号就中断推理操作”。5.3 模型版本别和在代码里写死模型更新是常态但很多人把模型版本写死在代码里每次更新模型都要改代码、重新构建镜像。我经历过一次业务方半夜打电话说新模型效果不对排查了半天发现线上跑的居然是三周前的旧模型因为那次代码发布没成功模型文件却被覆盖了。现在我的做法是模型文件的路径通过环境变量注入或者通过配置中心下发。部署新模型时把新模型文件放到指定目录然后更新配置里的版本号重启服务即可。同时在服务里暴露一个/version接口返回当前模型版本号和加载时间排查问题的时候先看一眼这个接口能省一半时间。另外模型文件建议保留历史的 n 个版本出问题可以秒级回滚而不是重新上传模型、重新构建镜像。6. 线上踩坑实录6.1 精度漂移不是模型的错有一次上线一个图像识别服务内测的时候 P99 延迟、准确率都正常但运营反馈线上图片的识别结果明显不如测试集好。排查了半天最后发现是预处理环节的差异测试的时候用的是 PIL 读取图片并做归一化线上服务里我用了 OpenCV 来解码图片。OpenCV 读出来的图片通道顺序是 BGR而模型训练时用的是 RGB通道一颠倒精度自然惨不忍睹。这类问题特别隐蔽因为代码不报错、接口正常返回但结果就是不对。我的教训是写推理服务时预处理逻辑一定要和训练时的 data pipeline 保持完全一致包括归一化参数、通道顺序、缩放插值算法。最稳妥的做法是把训练时的预处理代码抽出来原封不动地放到推理服务里而不是“看一遍训练代码照着重新写一个”。6.2 图片请求体过大导致的连接超时有一次压测发现当图片分辨率特别大比如 4000×3000时接口直接返回超时。原因有两个层面一是 base64 编码会让数据体积膨胀约 33%二是默认的请求体大小限制没配置再加上大图预处理耗时太长整体延迟飙到了几秒。解决办法是入口限制请求体大小超过限制直接返回 413预处理阶段强制 resize 到一个统一尺寸既稳定了延迟也降低了显存压力如果必须传大图就改成先上传到对象存储再传 URL 给推理服务推理服务内部去拉取图片。这两种方案我都用过最终选了后者——因为图片存储可以复用业务方现有的基础设施推理服务不用处理大体积请求延迟也稳定得多。6.3 慢请求吃光线程池还有一次线上事故让我记忆很深某个调用方循环里忘了做超时控制并发请求一直积压把我们的线程池完全占满导致正常请求也进不来。服务本身没崩但所有请求都在排队整体表现为“接口无响应”。之后我做了三件事第一每个请求设置严格的最长处理时间超时直接丢弃并返回错误第二线程池设置最大队列长度和拒绝策略队列满就立刻返回 503而不是无限制地堆积第三给调用方同步了官方 SDK把超时、重试、熔断的逻辑封装在 SDK 里避免业务方自己瞎写。这里也想提醒一下模型服务本质上也是高并发系统服务端要限流客户端要超时和重试两头都要管住。模型部署这事儿看起来就是“导个模型、写个接口、起个服务”三步但每一步里都有大量细节。把模型转成 Web API 只是第一步真正考验人的是如何让这个服务稳定、高效、可运维地跑在线上。以上这些经验来自我自己的真实项目希望对你有帮助。如果你正在部署自己的第一个模型服务我的建议是先把这个最小可靠的方案跑通再逐步加监控、加批处理、加容器编排不要一上来就追求大而全的架构。