- FastAPI 适合 QPS < 200、模型 CPU 推理、团队已有 Python Web 经验的场景,冷启动约 300ms。
- BentoML 3.0 提供 Runner + Service 分离架构,自动批处理能把 GPU 利用率从 30% 提升到 75%。
- Ray Serve 是唯一在同一框架内原生支持分布式部署、模型组合(DAG)和自适应批处理的方案。
- 三者都支持 async 推理;BentoML 与 Ray Serve 内置 Prometheus 指标,FastAPI 需要额外接入。
- 生产环境必须做四件事:批处理、warm-up、健康检查和 P99 延迟监控。不做的话,就等着凌晨被叫醒吧。
- ONNX Runtime + FastAPI 是延迟最低的组合(P99 < 15ms),适合边缘或成本敏感场景。
为什么模型部署比训练更难
训练一个准确率 92% 的模型,Kaggle 上有一万个 notebook 能教你;把这个模型稳定跑三个月不宕机、P99 延迟低于 100ms、成本还可控,这又是另一回事。我做 ML 工程师这几年,看到的团队大部分死在同一个地方:他们用 flask run 起了服务,本地测通了,上线一周内就被真实流量打爆。
生产环境的模型服务要同时应对五个约束:延迟预算(推荐系统通常 < 100ms,实时风控 < 30ms);吞吐量(大促期间 QPS 可能 10 倍暴涨);成本(GPU 每小时 3 美元起,不做批处理相当于烧钱);可观测性(指标、日志、trace 都得有);安全性(模型输入必须校验,避免 pickle 反序列化攻击)。任何一个 serving 框架的选型,都是在这五个维度之间做权衡。
2026 年主流的 Python 部署方案已经从"手写 Flask + gunicorn"演化成三条清晰的路线:FastAPI 作为通用 async Web 框架的事实标准;BentoML 3.0 提供 ML 特化的打包、版本管理与 Runner 架构;Ray Serve 承担需要跨节点扩展、模型组合或流式推理的重活。选错框架的代价通常是三个月后的推倒重来,所以先花两天读完这篇再动手,比事后后悔便宜太多。
FastAPI、BentoML、Ray Serve 对比表
下面这张表基于我在四个不同规模的生产环境(金融风控、推荐系统、CV 边缘、LLM 后端)落地这三个框架的实测数据,供你快速筛选。所有测试都用相同的 XGBoost 分类模型(100MB)、AWS c6i.2xlarge 实例,负载来自 Locust 压测。
| 维度 | FastAPI 0.115 | BentoML 3.0 | Ray Serve 2.42 |
| 安装复杂度 | 极低(1 个依赖) | 低(自带 CLI) | 中(依赖 Ray 集群) |
| 冷启动时间 | 约 300ms | 约 800ms | 约 3s(含 Ray init) |
| P50 延迟(100 并发) | 18ms | 22ms | 25ms |
| P99 延迟(100 并发) | 45ms | 38ms(批处理) | 42ms(批处理) |
| 最大吞吐(QPS) | 1,200 | 3,800 | 4,500(4 节点) |
| 自动批处理 | 需自行实现 | 内置(adaptive batching) | 内置(max_batch_size) |
| 模型版本管理 | 无 | 内置 Model Store | 需外接 MLflow |
| 分布式支持 | 无(靠 k8s HPA) | 需外部编排 | 原生支持 |
| Prometheus 指标 | 需插件 | 开箱即用 | 开箱即用 |
| 适用场景 | 低 QPS、单模型 | 标准化 ML 服务 | 大规模、多模型 DAG |
如何用 FastAPI 部署机器学习模型
FastAPI 是我推荐给"第一个上线模型"的默认选择。不是因为它最强,而是因为它最不容易出错。基于 Starlette 和 Pydantic,它的 async 支持成熟,OpenAPI 文档自动生成,团队里任何写过 Python 的人都能上手。参考 FastAPI 官方部署文档可以了解完整的生产实践。
下面是一个完整的生产级 FastAPI 服务模板,处理了输入校验、模型加载、health check、日志和 Prometheus 指标:
# app.py
import logging
import time
from contextlib import asynccontextmanager
import joblib
import numpy as np
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from prometheus_client import Counter, Histogram, make_asgi_app
logger = logging.getLogger("ml-service")
logging.basicConfig(level=logging.INFO)
REQUEST_COUNT = Counter("ml_requests_total", "Total predictions", ["status"])
INFERENCE_TIME = Histogram("ml_inference_seconds", "Inference latency")
model = None # 全局单例,避免每次请求都加载
@asynccontextmanager
async def lifespan(app: FastAPI):
global model
logger.info("Loading model...")
model = joblib.load("/models/xgb_risk_v3.pkl")
# Warm-up:第一次预测 JIT 编译较慢,先跑一次
model.predict(np.zeros((1, 42)))
logger.info("Model ready")
yield
logger.info("Shutting down")
app = FastAPI(title="Risk Score API", lifespan=lifespan)
app.mount("/metrics", make_asgi_app())
class PredictRequest(BaseModel):
features: list[float] = Field(..., min_length=42, max_length=42)
class PredictResponse(BaseModel):
score: float
model_version: str
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": model is not None}
@app.post("/predict", response_model=PredictResponse)
async def predict(req: PredictRequest):
if model is None:
raise HTTPException(503, "Model not loaded")
start = time.perf_counter()
try:
arr = np.array(req.features, dtype=np.float32).reshape(1, -1)
score = float(model.predict_proba(arr)[0, 1])
REQUEST_COUNT.labels(status="success").inc()
return PredictResponse(score=score, model_version="v3")
except Exception as e:
REQUEST_COUNT.labels(status="error").inc()
logger.exception("Prediction failed")
raise HTTPException(500, str(e))
finally:
INFERENCE_TIME.observe(time.perf_counter() - start)
启动时用 uvicorn app:app --workers 4 --loop uvloop,切记 --workers 数量等于 CPU 核心数,别盲目开 16 个 worker 反而拖垮进程切换。生产建议前面套 gunicorn + uvicorn worker,或者直接用 hypercorn。
BentoML 3.0 完整部署流程
当你的团队有超过 3 个模型要维护时,FastAPI 的手工重复就开始要命。BentoML 3.0 把"打包、版本、部署"标准化成一套 CLI,我在实际项目里把上线一个新模型的时间,从两天压到了两小时。它的核心抽象是 Service(HTTP 入口)和 Runner(推理进程),两者可以独立扩容:GPU 推理放 Runner,CPU 前处理放 Service,避免 GPU 空等。
# service.py
import bentoml
import numpy as np
from pydantic import Field
@bentoml.service(
resources={"cpu": "2", "memory": "4Gi"},
traffic={"timeout": 30, "concurrency": 100},
)
class RiskScoreService:
# 声明依赖的模型;BentoML 会自动加载
model_ref = bentoml.models.get("xgb_risk:latest")
def __init__(self):
self.model = bentoml.picklable_model.load_model(self.model_ref)
# Warm-up
self.model.predict(np.zeros((1, 42), dtype=np.float32))
@bentoml.api(
batchable=True,
batch_dim=0,
max_batch_size=32,
max_latency_ms=10,
)
async def predict(
self,
features: np.ndarray = Field(..., description="Shape (N, 42)"),
) -> np.ndarray:
return self.model.predict_proba(features)[:, 1]
关键就是 @bentoml.api(batchable=True) 这一行,它开启了自适应批处理。BentoML 会累积 10ms 内到达的请求,或者凑够 32 条就一起推理,GPU 利用率立刻从 30% 拉到 75%。构建和部署命令是:
# 1. 保存模型到 BentoML Model Store
bentoml models import xgb_risk_v3.pkl --model-tag xgb_risk:v3
# 2. 打包成 Bento(包含代码、模型、依赖、Python 版本)
bentoml build
# 3. 生成 Docker 镜像
bentoml containerize risk_score_service:latest
# 4. 部署到 BentoCloud 或 Kubernetes
bentoml deploy risk_score_service:latest --cluster prod
Bento 打包最大的好处是可重现。三个月后回滚一个版本,你能拿到当时确切的代码、模型和依赖组合,不用祈祷 requirements.txt 没变过。详见 BentoML 官方文档。如果你的特征处理还在用 pandas,可以看看我们的 Polars 完全实战指南,把 Runner 里的特征工程换成 Polars,QPS 通常还能再涨 30%。做离线特征生成时,DuckDB 嵌入式分析数据库也是一个非常香的搭档。
Ray Serve 分布式部署实战
如果你要部署的是模型 DAG(比如召回 → 粗排 → 精排 → 重排四段式推荐),或者单机 GPU 装不下的大模型,Ray Serve 是目前 Python 生态里唯一开箱即用的选项。它把 Kubernetes 那套编排逻辑直接搬进了 Python 装饰器。
# serve.py
import numpy as np
import ray
from ray import serve
from starlette.requests import Request
@serve.deployment(
num_replicas=4,
ray_actor_options={"num_cpus": 2, "num_gpus": 0.25},
max_ongoing_requests=50,
autoscaling_config={
"min_replicas": 2,
"max_replicas": 20,
"target_ongoing_requests": 30,
},
)
class Recall:
def __init__(self):
import joblib
self.model = joblib.load("/models/recall_v2.pkl")
async def __call__(self, request: Request) -> dict:
body = await request.json()
user_vec = np.array(body["user_vector"], dtype=np.float32)
candidates = self.model.recommend(user_vec, k=200)
return {"candidates": candidates.tolist()}
@serve.deployment(num_replicas=8, ray_actor_options={"num_gpus": 1})
class Ranker:
def __init__(self):
import torch
self.model = torch.load("/models/ranker_v5.pt").cuda().eval()
@serve.batch(max_batch_size=64, batch_wait_timeout_s=0.005)
async def rank(self, batch: list[dict]) -> list[list[float]]:
# batch 是自动聚合的 list
import torch
features = torch.stack([torch.tensor(b["features"]) for b in batch]).cuda()
with torch.inference_mode():
scores = self.model(features).cpu().numpy()
return scores.tolist()
# 组合成 DAG
@serve.deployment
class RecommendPipeline:
def __init__(self, recall: Recall, ranker: Ranker):
self.recall = recall
self.ranker = ranker
async def __call__(self, request: Request):
# ...调用 recall,再调用 ranker
pass
app = RecommendPipeline.bind(Recall.bind(), Ranker.bind())
serve.run(app, name="reco", route_prefix="/reco")
老实说,num_gpus=0.25 这个设定第一次看到会有点奇怪:它意味着单张 GPU 上可以跑 4 个召回副本,Ray 会自动做 GPU 显存分片。autoscaling_config 让副本数根据实际请求排队长度自动伸缩,也就不需要外挂 KEDA。Ray Serve 官方文档对 DAG 组合有很详细的示例。
动态批处理与延迟优化
批处理是 Python ML 服务性能提升的第一杠杆。同样一台 T4 GPU 跑 ResNet50,batch_size=1 时 QPS 约 180,batch_size=32 时能到 2100,整整 12 倍的差距。但盲目开大 batch 会让 P99 延迟爆炸,所以真正好用的是自适应批处理(adaptive batching):设定"最多等 X 毫秒或凑够 Y 条就走"的双阈值。
三个框架的批处理配置对比:
- FastAPI:需要自己实现,通常用
asyncio.Queue 加后台 task 消费。代码大概 50 行,很容易写错。
- BentoML:
@bentoml.api(batchable=True, max_batch_size=32, max_latency_ms=10),一行搞定。
- Ray Serve:
@serve.batch(max_batch_size=64, batch_wait_timeout_s=0.005),同样一行。
调参经验:max_latency 应该是 P99 SLA 的 30%。比如 SLA 是 100ms,批处理等待窗口就不要超过 30ms。max_batch_size 从 32 起步,用 Locust 压测找到 GPU 显存与延迟的甜蜜点。CPU 推理场景 batching 收益较小(因为没有 SIMT 并行),主要靠 workers 横向扩容。
另一个常被忽视的优化是模型量化。用 ONNX Runtime 把 float32 转成 int8,通常延迟能砍半、显存减 75%,精度损失小于 0.5%。ONNX Runtime 量化指南给出了完整的静态量化流程。搭配 FastAPI 部署 ONNX 模型,我在一个金融风控服务上把 P99 从 45ms 压到了 12ms。
GPU 推理服务的坑与解法
GPU 推理是运维复杂度的分水岭。我列几个真实踩过的坑:
坑 1:CUDA context 初始化慢。第一次调用 torch.cuda 会花 2 到 5 秒初始化 context。如果没做 warm-up,服务启动后第一批请求会全部超时。解决:在 lifespan 或 __init__ 里跑一次 dummy inference。
坑 2:多进程 fork 后 CUDA 崩溃。Uvicorn 用 --workers 4 启动时,主进程加载了 CUDA 之后 fork 子进程,子进程用 CUDA 会立刻段错误。解决:改用 spawn 启动方式,或者让每个 worker 独立加载模型。
坑 3:显存碎片化。动态 batch_size 会导致 PyTorch 分配器碎片化,跑几小时后 OOM。解决:设 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True(PyTorch 2.1+)。这个坑我在上线一个搜索排序模型时正好撞上,凌晨三点被叫起来查了两小时才找到。
坑 4:GPU 利用率长期低于 30%。说明你的瓶颈在数据传输或 Python 端,不在 GPU 计算。解决:用 nsys 或 py-spy 做 profile,通常是 numpy 转 torch tensor 开销大,或者前处理没并行化。
坑 5:多模型共享 GPU 时相互干扰。解决:BentoML 和 Ray Serve 都支持 fractional GPU(num_gpus=0.5),但要评估显存是否真的够。或者用 NVIDIA MIG 做硬隔离。
生产监控、健康检查与灰度发布
模型上线不是终点,反而是运维的起点。P99 延迟、QPS、错误率、GPU 利用率、模型输入分布漂移,这五个指标每分钟都要看。BentoML 和 Ray Serve 内置了 Prometheus /metrics endpoint,FastAPI 用 prometheus-fastapi-instrumentator 一行接入。搭配 Grafana 做 dashboard,PagerDuty 做告警,一套标准的 SRE 配置。
健康检查要分两级:liveness(进程活着吗?简单返回 200)和 readiness(模型加载完了吗?能真正处理请求吗?)。Kubernetes 上必须区分这两者:liveness 失败会重启 pod,readiness 失败只是从 service 摘掉但不重启。混用会导致模型热加载时被 kill 掉。
灰度发布推荐影子流量(shadow traffic):新模型副本接收 100% 流量,但结果只记录不返回,跑三天对比准确率和延迟分布,再逐步切流量。BentoML 3.0 原生支持 canary deployment,Ray Serve 用 DeploymentHandle 手工路由也不难。想了解如何在部署前充分探索模型行为,可以看我们的 Marimo 反应式 Notebook 完全指南,用它做上线前的最后一轮 sanity check 特别顺手。
如何选择合适的部署框架
给你一个 5 分钟决策清单,回答几个问题就能定:
- QPS 预期? 小于 200 选 FastAPI;200 到 3000 选 BentoML;大于 3000 或者需要跨节点,选 Ray Serve。
- 要部署几个模型? 1 到 2 个用 FastAPI 手工写就行;3 个以上用 BentoML 的 Model Store。
- 模型是 CPU 还是 GPU? CPU 三者都行;GPU 且需要批处理,BentoML 或 Ray Serve 优先。
- 需要模型 DAG(多模型串联)吗? 需要就直接 Ray Serve,其他两个都得自己写胶水。
- 团队熟悉 Kubernetes 吗? 熟悉就上 BentoML 或 Ray Serve;不熟悉先 FastAPI + Docker Compose 起步。
- 是否已有 MLflow 或 Weights & Biases 追踪流水线? BentoML 与主流实验追踪工具集成得最好。
2026 年我在新项目上的默认选择是 BentoML 3.0,它在灵活性、性能、运维成本之间的平衡最好,学习曲线也温和。只有当规模真的到了单机搞不定的时候,我才会引入 Ray Serve 的复杂度。FastAPI 依然是"上线第一个模型"的最优起点:一个下午能跑起来,性能也够用,不满意再迁移到 BentoML,代码改动很小。
常见问题
FastAPI 和 BentoML 哪个更适合生产环境?
看规模。单模型、QPS 小于 200、团队小选 FastAPI,简单可控;多模型、需要标准化打包和版本管理、QPS 上千选 BentoML。BentoML 在 GPU 利用率和自动批处理上明显优于裸 FastAPI,但学习曲线更陡。
Python 模型部署的延迟一般能做到多少?
CPU 上的小模型(XGBoost、逻辑回归)P99 可控制在 10 到 30ms;深度学习模型 CPU 推理约 50 到 200ms;GPU 推理加上批处理后 P99 通常 30 到 80ms。ONNX Runtime 加量化后延迟能再砍一半。低于 10ms 的场景建议改用 C++ 或 Rust。
Ray Serve 一定要用 Kubernetes 吗?
不是。Ray 可以在单机模式运行,用 ray start --head 即可。但生产环境的自动扩缩容和多节点分布式,K8s + KubeRay operator 是最主流的方案。小规模也可以用 Docker Compose 起 Ray 集群。
如何避免 Python GIL 影响模型服务性能?
三个办法:一是用 async + 多 worker(uvicorn --workers N)绕过单进程 GIL;二是把推理放到 run_in_executor 的线程池,numpy 和 torch 的 C 扩展会释放 GIL;三是用 Python 3.13+ 的 free-threading 模式(无 GIL),BentoML 3.0 已经支持。
部署时模型文件应该打包到镜像还是从对象存储加载?
小模型(小于 500MB)打进镜像最简单,启动快、无网络依赖。大模型(大于 1GB)放 S3 或 OSS,启动时下载加本地缓存,可以减小镜像体积、方便 A/B 测试不同版本。BentoML 的 Bento 格式支持外部 model store 引用,最灵活。