打造 ML/AI 系统的内部开发者平台(IDP)——模型推理与服务部署

0 阅读32分钟

本章涵盖:

  • 使用 BentoML 进行模型服务与构建模型服务器
  • BentoML 中的可观测性与监控
  • 打包与部署 BentoML Services
  • 结合使用 BentoML 与 MLflow
  • 仅使用 MLflow 管理模型生命周期
  • BentoML 与 MLflow 的替代方案

现在我们已经有了一条可用的模型训练与验证流水线,是时候把模型以服务的形式对外提供了。本章将探索如何使用 BentoML(一个面向规模化构建与服务 ML 模型的强大框架),无缝地将你的目标检测模型与电影推荐模型以服务方式提供。

你将有机会把前两章训练出的两个模型部署出去,获得真实部署场景的动手经验。我们会先在本地构建并部署服务,然后进一步创建一个把服务完整封装起来的容器用于部署,并将其无缝集成进你的 ML 工作流中(图 10.1)。

image.png

图 10.1 心智地图:我们现在聚焦于模型部署(6),并将模型作为 API(E)对外提供

面向工程师的自助式模型部署(self-service model deployment)对构建机器学习运维(MLOps)的工程师有多项优势:

  • 减少错误——手工部署容易出错,而且往往不可重复;自助部署能显著减少这类问题。
  • 前置验证——更早建立信心:确认模型可部署,从而能更快发现并解决潜在问题。
  • 资源优化——可以在 Kubernetes(K8s)集群上尝试更强硬件,突破本地开发环境的限制;在其他场景中,最终部署还可能包含针对资源受限环境与硬件(如嵌入式设备)的测试与优化步骤。
  • 减少瓶颈——不必依赖 ML 工程师帮你部署,消除等待时间,加速迭代开发流程。
  • 增强协作——弥合开发与运维之间的鸿沟,促进数据科学家与 ML 工程师之间的沟通。

需要注意的是,自助部署虽然赋能模型开发者,但并不会消除生产环境中模型部署工程师的需求。它更多是让数据科学家能够把模型部署到非生产环境,从而在交付给 ML 工程师做生产部署之前,完成充分的测试与验证。

在本章结束时,你将对如何使用 BentoML 做模型服务有扎实理解,能够快速从模型开发切换到部署。这些知识不仅能提升你的工作流效率,也能改善你在 ML 项目中与 ML 工程师及其他干系人的协作。

10.1 模型部署很难

传统软件部署本来就很复杂,涉及版本控制、各种测试方法(单元、集成、性能等)、持续集成(CI)与持续部署(CD)等诸多要素。而当我们把 ML 模型部署加入这套流程时,会引入一类 ML 独有的额外复杂性,需要认真对待:

  • 推理模式(Inference patterns) ——理解服务化模型将如何被消费至关重要:是批量预测、实时推理,还是两者兼有?
  • 模型可扩展性(Model scalability) ——模型通常需要应对波动负载,因此需要健壮的扩缩容方案。
  • 性能监控与日志——需要完备的监控与日志来追踪性能、检测漂移(drift)并调试问题。
  • 持续学习(Continuous learning) ——建立重训流水线并自动部署更优版本,是 ML 系统中的关键挑战。
  • 数据依赖(Data dependencies) ——模型往往依赖特定数据格式或预处理步骤,需要在服务环境中复刻。
  • 硬件要求(Hardware requirements) ——某些模型需要专用硬件(如 GPU)才能高效推理,增加部署基础设施复杂度。
  • 模型版本管理(Model versioning) ——管理多版本模型并支持回滚,对系统可靠性至关重要。
  • 可解释性与可理解性(Explainability and interpretability) ——生产部署常需要解释模型决策机制,尤其在强监管行业。
  • 资源管理(Resource management) ——在模型服务与应用其他组件之间平衡计算资源可能很难。
  • 安全与隐私(Security and privacy) ——确保已部署模型不泄露敏感信息,并能抵御对抗攻击(adversarial attacks)非常关键。

虽然软件工程原则为解决这些挑战提供了基础,但 ML 引入了需要专门知识与工具的独特考量。ML 部署位于软件工程与数据科学的交叉点,要求数据科学家与 ML 工程师协作,构建稳健、可扩展、可维护的 ML 系统。

10.2 BentoML:简化模型部署

BentoML 是一个强大的框架,旨在打通模型开发与部署之间的鸿沟,解决我们刚讨论的许多复杂性。BentoML 主要通过以下方式应对这些挑战:

  • 统一的模型服务——无论你用的是 PyTorch、TensorFlow 还是 scikit-learn,BentoML 都提供标准化的打包与服务方式,简化从开发到部署的迁移。
  • 灵活的推理模式——开箱即用支持实时 API 服务与批量推理,适配不同用例。
  • 可扩展性——与 K8s 等容器编排平台无缝集成,便于扩展模型服务负载。
  • 内置监控与日志——框架自带监控与日志能力,便于追踪模型表现并在生产中调试问题。
  • 模型管理——提供模型版本控制,支持管理多版本模型并在需要时轻松回滚。
  • 可复现构建——把模型、依赖与服务逻辑一起打包,确保开发与生产环境一致性。
  • 自适应微批处理(Adaptive microbatching) ——可自动把请求批量化以提升吞吐并优化资源利用。
  • API 层抽象——提供高层 API 来定义服务逻辑,减少部署所需样板代码。
  • 资源优化——支持更细粒度的资源分配控制,帮助在性能与成本之间权衡。
  • 生态集成——可与常见 MLOps 工具与平台集成,方便融入现有工作流。

通过使用 BentoML,数据科学家与 ML 工程师可以把更多精力放在模型开发上,而不是部署基础设施的细枝末节。它提供了一条从实验到生产的顺滑路径。接下来我们会更深入地看看如何用 BentoML 打包并服务你的模型,并展示它在简化部署过程中的实际价值。

10.3 BentoML 快速上手导览

在第 6 章,我们简单提过 BentoML 与 Yatai 的安装。现在,我们将把上一章训练好的 YOLOv8 模型,通过 BentoML Service 部署为一个 ML 服务。

首先,安装 BentoML(写作时最新版本为 1.1.6):

pip install bentoml==1.1.6

检查 BentoML 是否安装正确:

% bentoml -v
bentoml, version 1.1.6

如果你在跟着项目走,那么服务相关内容都在 serving 目录下。否则,你也可以创建一个自己的空项目。

10.3.1 BentoML Service 与 Runners

在进入代码之前,先理解 BentoML Service 的组成与关键组件非常重要。图 10.2 从高层概览了 BentoML 如何处理一次推理请求。

image.png

图 10.2 BentoML Service 架构:API Server 将请求分发给多个 Runner

BentoML Service 是一种抽象,它封装了一个或多个 API server,以及一种或多种 Runner(并且可以同时存在多个 Runner 实例)。API server 是一个 HTTP 服务器,在指定端口监听入站请求。注意我们可以实现多个 API server,这带来几个优势:

  • 水平扩展——根据可用资源,我们可以增加 API server 实例,使 BentoML Service 能处理更高负载与更高并发请求。这在推理需求随时间波动时尤其重要。
  • 负载均衡——多个 API 实例意味着入站请求可以被分发到不同实例,避免单点实例成为瓶颈。
  • 并行处理——多个 API server 并行处理请求,从而提升整体吞吐。

API server 会对输入做解析与校验(我们后面会深入)。完成后,输入参数会被交给 Runner。Runner 是一个计算单元,它封装了一个 ML 模型,并基于 API server 传入的数据执行实际推理。

这种模型设计非常优雅,因为它让 BentoML 能支持多种执行环境:无论是在本地运行 BentoML Service,还是在 K8s 或云上运行。每个 Runner 都在自己的 Python worker 中运行,BentoML 会利用这一点,使多个 Runner 实例并行执行。Runner 还支持一些更有意思的能力,但目前这些理解已足够。

术语讲清楚后,我们直接进入代码。我们希望构建一个 BentoML Service:包含多个 API server,并配套多个 YOLOv8 Runner,如图 10.3 所示。

image.png

图 10.3 BentoML Service 架构:多个 API server + 多个 runner

API server 将暴露两个端点:

  • /inference——输入一张图片,输出 JSON 格式结果。例如:

    [  {    "name": "id_card",    "class": 0,    "confidence": 0.5838027000427246,    "box": {      "x1": 1.8923637866973877,      "y1": 146.94198608398438,      "x2": 243.37542724609375,      "y2": 338.2261657714844    }  }]
    
  • /render——输入一张图片,输出带有边界框、标签与概率的图片。图 10.4 给出示例。

image.png

图 10.4 YOLOv8 在驾驶证图片上的目标检测输出示例

这两个端点为用户与目标检测模型的交互提供了灵活性:/inference 更适合程序化调用与系统集成,而 /render 提供了可视化输出,适合调试、演示或快速视觉检查。

通过以这些端点组织你的 BentoML Service,你就为目标检测模型提供了一个多用途且用户友好的接口:既支持通过 JSON 输出进行机器对机器通信,也支持面向人的可视化输出,以满足不同用例与用户需求。

在下一节中,我们会带你完成实现该服务并在本地运行的全过程。这段动手实践会帮助你牢固理解 BentoML 的各个组件,并为未来部署更复杂服务打下实战基础。

10.4 在本地执行 BentoML Service

现在,是时候撸起袖子动手玩 BentoML 了。我们将带你完成创建 BentoML Service 并在本地运行的过程。你会学到如何把目标检测模型打包起来,并把它暴露为一个 REST API,便于本地测试与开发。

构建 BentoML Service 时,我们从 Runner 开始。下一节会展示具体做法。

10.4.1 使用 BentoML Runner 加载模型

创建一个 service.py 文件,它将同时包含 BentoML Service 与 Runner 的实现。创建继承自 bentoml.RunnableYOLOv8Runnable 类:

import bentoml
from ultralytics import YOLO
class YOLOv8Runnable(bentoml.Runnable):
   def __init__(self):
       self.model = YOLO("yolov8_custom.pt")

YOLO 模型在构造函数中被初始化,并赋值给 model 属性。为简化起见,这里的 yolov8_custom.pt 权重来自上一章,且应放在与 service.py 相同的目录中。

我们这里演示的方法——在 Runner 的构造函数里直接初始化模型——只是 BentoML 加载模型的多种方式之一。它直观、简单,适用于很多用例。但你可能会考虑更动态的方式,比如按需拉取模型,或在不同版本之间切换。

这些都是很好的考量,尤其在生产环境里,灵活性与版本控制非常关键。BentoML 通过其模型注册表(model registry)提供更高级的模型管理能力。这个强大概念支持动态加载、版本化以及无缝更新。不过,为了保持当前重点在 BentoML Service 的基础搭建上,我们会在 10.6 节再深入讨论模型注册表。

目前我们继续采用这个简单配置,它非常适合理解核心概念并尽快把一个可用服务跑起来。随着内容推进,请记住:更复杂的模型管理方式是存在的,我们后续会在此基础上继续扩展。

我们来实现第一种方法:invocation 调用:

import bentoml
import json
from ultralytics import YOLO

class YOLOv8Runnable(bentoml.Runnable):

   def __init__(self):
       self.model = YOLO("yolov8_custom.pt")

   @bentoml.Runnable.method(batchable=False)     #1
   def inference(self, input_img):               #2
       results = self.model(input_img)[0]        #3
       return json.loads(results[0].tojson())    #4
#1 将方法标记为不可 batch,仅支持单张图片
#2 定义对图片执行推理的方法
#3 从模型对图片的预测结果中取第一个结果
#4 将预测结果以 JSON 对象返回

这里首先要注意的是 @bentoml.Runnable.method 装饰器。它会创建一个 RunnableMethod,使该方法可以被客户端或其他服务远程调用,而实际执行由 BentoML Service 负责。在这里,inference 接收单张图片并将其传给模型。

用图片调用模型函数会执行推理。默认情况下它返回一个列表,因为该方法也可能一次接收多张图片。但我们只传一张,所以只取第一项返回。最后,结果会被转成 JSON 字符串,因此你需要用 json.loads 把它转成 JSON。

接下来用 bentoml.Runner 基于前面定义的 YOLOv8Runnable 创建一个 Runner 实例。建议这里显式指定一个名称,因为 BentoML 默认命名(类名)在 Runner 运行时可能不够直观:

import bentoml

yolo_v8_runner = bentoml.Runner(
    YOLOv8Runnable, name="yolov8_runnable")    #1
svc = bentoml.Service(
       "yolo_v8",
        runners=[yolo_v8_runner])    #2
#1 为 YOLOv8Runnable 模型创建一个 BentoML Runner
#2 定义名为 “yolo_v8” 的 BentoML Service,并挂载该 runner

Runnable 创建好之后,就可以用 bentoml.Service 初始化 BentoML Service,并传入 Runner 列表(这里仅 yolo_v8_runner)。如你所想,我们也可以把多个 Runner 传给 Service,以支持多阶段工作流,我们稍后会展开一点。

在学习如何使用多个 Runner 之前,先把 BentoML Service 拼起来。既然我们已经创建了 svc 实例,下面是如何创建一个 endpoint:

from bentoml.io import Image                                    #1
from bentoml.io import JSON                                      #1
 #1
@svc.api(input=Image(), output=JSON())                           #1
async def invocation(input_img):
   inf = yolo_v8_runner.inference.async_run([input_img])       #2
   return await inf #3
#1 装饰函数,并使用 IO 描述器定义 API 输入与输出
#2 定义用于 API 处理的异步函数
#3 异步调用并等待 Runner 的 inference 结果

@svc.api(input=Image(), output=JSON()) 装饰器用于定义 API endpoint:输入为图片(Image()),输出为 JSON(JSON())。

此外,async def invocation(input_img) 定义了一个名为 invocation 的异步函数,作为 endpoint 的实际实现,参数 input_img 表示输入图片。注意这里的 async 关键字。最后最关键的一行是:

await yolo_v8_runner.inference.async_run([input_img])

你把包含输入图片的列表传给 async_run。该方法会在后台异步启动目标检测过程。await 确保代码在继续之前等待预测完成。

为什么需要 await 关键字?

await 在 Python 异步函数中非常关键,原因如下:

  • 异步执行——async_run 会异步启动目标检测,让它在后台运行而不阻塞主执行流。
  • 处理 promise——async_run 返回的是“未来结果的承诺”(promise),不是结果本身;await 用来等待该承诺兑现。
  • 避免错误——没有 await 时,代码会立刻继续执行,可能在结果尚未准备好时就尝试使用它,从而导致错误。
  • 保持响应性——在 Web 应用中,await 允许服务器在等待当前操作完成时继续处理其他请求。

我们用一个真实场景来说明:你在做一个图片实时目标检测的 Web 应用。

  • 用户上传一张图片。
  • 代码调用 yolo_v8_runner.inference.async_run([input_img])

场景 1:使用 await
await 会在此处暂停,使服务器可以处理其他请求;检测完成后继续执行并处理结果:

async def process_image(input_img): 
  result = await yolo_v8_runner.inference.async_run([input_img])   
  return process_result(result) 

场景 2:不使用 await
没有 await,代码会立即继续执行。process_result(result) 很可能失败,因为此时 result 是一个 promise(未来才会返回检测输出),而不是真正的检测输出:

def process_image(input_img):
    result = yolo_v8_runner.inference.async_run([input_img])
    return process_result(result)  #1
#1 这一行会立刻执行,很可能导致错误

通过使用 await,你能确保在继续执行前异步操作已完成,从而避免错误并保持应用响应。

多个 BentoML Runner(MULTIPLE BENTOML RUNNERS)

什么时候会使用多个 Runner?想象你在推理前需要对图片做预处理,比如把它转成灰度图,再送入目标检测器,那么服务可能长这样:

svc = bentoml.Service(
        "object_detector",
         runners=[grayscale_converter, object_detector])                    

另一个用例是:你希望同时测试两版目标检测模型,把 object_detector_1object_detector_2 都传进 runners

svc = bentoml.Service( 
         "object_detectors",  
        runners=[
            grayscale_converter, 
            object_detector_1, 
            object_detector_2
        ]
    )

那在服务里怎么用?下面是一个示例:

@svc.api(input=Image(), output=JSON())
async def predict(input_image: PIL.Image.Image) -> str:
    model_input = await grayscale_converter.async_run(input_image)

    results = await asyncio.gather(
        object_detector_1.async_run(model_input),
        object_detector_2.async_run(model_input),
    )

    return {"results": { "model_a": results[0], "model_b": results[1]

这段代码展示了如何创建一个使用多个模型做目标检测的 BentoML Service,含义如下:

  • Service 定义——@svc.api 定义 API endpoint,输入是图片,输出是 JSON。
  • 异步处理——predict 是异步函数(async def),支持非阻塞。
  • 输入预处理——grayscale_converter.async_run(input_image) 异步把输入转为灰度图,作为两个检测器的共同输入。
  • 并行推理——使用 asyncio.gather() 并发运行两个检测器,提高吞吐与整体性能。
  • 结果聚合——将两个模型结果收集并组织成字典,分别写入 key(model_amodel_b)。
  • JSON 响应——返回一个可 JSON 序列化的字典,包含两个模型的结果。

这种方式能高效地让单个输入并行通过多个模型,单次 API 调用就得到更全面的检测结果,体现了 BentoML 对可扩展多模型工作流的支持能力。

实现 /render 端点(IMPLEMENTING THE /RENDER ENDPOINT)

能够自动帮我们把标签与边界框画出来非常有用。我们来实现另一个端点——称为 render——它允许我们直接下载处理后的图片结果:

class YOLOv8Runnable(bentoml.Runnable):

   def __init__(self):

@bentoml.Runnable.method(batchable=False)
def render(self, input_img):
   result = self.model(input_img, save=True, project=os.getcwd()) 
   return PIL.Image.open(os.path.join(result[0].save_dir, result[0].path))

如果你传入 save=True,库会把推理结果保存到本地目录:

result = self.model(input_img, save=True, project=os.getcwd())

这里的 result 包含输出图片路径(图片里已经画了 bounding boxes 与类别),以及其他一些有趣的元数据(如果你想用其他模式,比如分割 mask,这些信息会很有用):

[ultralytics.engine.results.Results object with attributes:

boxes: ultralytics.engine.results.Boxes object
keypoints: None
masks: None
names: {0: 'id_card'}
orig_img: array([...], dtype=uint8)
orig_shape: (461, 258)
path: 'image0.jpg'
probs: None
save_dir: '/opt/homebrew/runs/detect/predict15'
speed: {
    'preprocess': 2.254009246826172, 
    'inference': 20.57194709777832, 
    'postprocess': 4.968881607055664}
]

接着我们用 save_dirpath 组合出返回图片的路径:

   return PIL.Image.open(os.path.join(result[0].save_dir, result[0].path))

再次启动服务(bentoml serve service.py),访问 http://0.0.0.0:3000,并在 /render 端点下上传一张测试图片。如果一切正常,你会看到在 200 响应里出现一个 Download File 链接,如图 10.5 所示。

image.png

图 10.5 通过 /render 端点成功推理并返回图片的界面

点击链接,你就能看到成果:效果类似图 10.6,身份证被边界框圈出,并标出类别(id_card)与概率(0.58)。

image.png

图 10.6 YOLOv8 在驾驶证图片上的目标检测输出示例

很好!你已经成功实现了 /render 端点,它允许用户直接下载带标签与边界框绘制结果的图片。这项功能非常实用,提升了用户体验,也让模型预测更容易可视化。

通过 save=True 参数,你把推理结果保存到了本地目录;随后利用 result 对象中的 save_dirpath 属性构造路径,并用 PIL.Image.open() 返回结果图片。

有了 /render 端点,用户现在可以通过 BentoML API server 轻松上传测试图片,并获得可下载链接,清晰看到边界框与标签。

接下来,我们把服务提升一个层级:探索“可观测性端点(observability endpoints)”的概念。可观测性对于监控与理解已部署模型的行为与性能至关重要。下一节我们将讨论如何为 BentoML Service 增加可观测性端点,帮助你获得关键洞察并确保目标检测系统的鲁棒性。

可观测性端点:确保服务健康与可靠性(OBSERVABILITY ENDPOINTS: ENSURING SERVICE HEALTH AND RELIABILITY)

在为 K8s 构建不那么简单的服务时,实现健康检查(health checks)以及存活/就绪探针(liveness/readiness probes)被认为是确保可观测性与有效 pod 管理的最佳实践。这些端点在监控 pod 的健康与状态、以及识别潜在问题方面发挥关键作用。

K8s 中的健康检查用于理解 pod 的整体健康状态:pod 是否按预期工作、是否能处理入站请求。另一方面,存活探针会周期性调用 /healthz 端点来判断 pod 是否存活并能运行主进程;如果存活探针失败,K8s 可能会重启 pod 来修复故障。

类似地,就绪探针会周期性调用 /readyz 端点来评估 pod 是否准备好接收流量。pod 被认为“就绪”,意味着它既存活又可用,也就是能够有效处理入站请求。这些可观测性端点不仅有助于监控 pod 健康,也能辅助排障。例如,如果 pod 初始化失败,/healthz 会显示失败状态,帮助你及时定位并解决问题。

你可能会想:那在我们新建的 BentoML Service 里,如何实现这些可观测性端点?好消息是:BentoML 已经替你做好了! 开箱即用,BentoML 内置对健康检查与存活/就绪探针端点的支持,确保你的 Service 在 K8s 环境中能被正确监控与管理(图 10.7)。

image.png

图 10.7 BentoML 在 K8s 环境中的内置可观测性端点

更棒的是,它还自带 Prometheus 指标(图 10.8)。你可能还记得:Prometheus 指标是一种标准化格式,用于监控与衡量系统性能,提供应用或基础设施各方面的时序数据,这些数据可以被 Prometheus(一个流行的开源监控系统)采集、存储并分析。

image.png

图 10.8 BentoML Service 输出的 Prometheus 指标示例

BentoML 对健康检查与存活/就绪探针的内置支持,对构建 ML 服务的开发者来说非常省心省时。它让你不必反复实现这些通用能力,从而能专注于真正重要的东西:服务的业务逻辑与核心功能。这不仅降低了引入错误的概率,也确保你的服务符合 K8s 部署的最佳实践。

而 BentoML 的可观测性能力不只是“方便”而已。它通过使服务在 K8s 环境中可被正确监控与管理,促进了可靠性、可扩展性与可维护性。有了这些端点,你就能更有信心:服务会更具韧性,并能对需求或基础设施变化保持响应。

10.5 构建 Bentos:将服务打包为可部署产物

在 BentoML 中,一个“可用于部署”的 ML 服务包被恰当地称为 Bento。Bento 会封装所有必要组件,包括训练好的模型、服务代码与依赖,从而让你能轻松在不同环境之间分发与部署服务。要创建一个 Bento,你需要定义一个名为 bentofile.yaml 的构建配置文件,它会指定服务文件、需要包含的文件,以及用于容器化的基础 Docker 镜像,例如:

service: "service.py:svc"
include:
 - "service.py"
 - "yolov8_custom.pt"
docker:
 base_image: "ultralytics/ultralytics:8.0.203-cpu"

现在在终端运行:

% bentoml build

如果一切成功,你会看到如图 10.9 所示的界面,表示构建完成。

image.png

图 10.9 BentoML build 成功信息与建议的下一步操作

注意 当你使用 bentoml build 构建 Bento 时,BentoML 会为该服务的这个特定版本自动生成一个唯一标识符。这个标识符称为 Bento tag,格式为 service_name:version_label,例如 yolo_v8:3jghhcfxvwsrnbsb

图中展示了成功构建的输出,其中包括:

  • 一条确认信息:
    "Successfully built Bento(tag="yolo_v8:3jghhcfxvwsrnbsb")"

  • 建议的下一步:

    • 容器化 Bentobentoml containerize yolo_v8:3jghhcfxvwsrnbsb
    • 推送到 BentoCloudbentoml push yolo_v8:3jghhcfxvwsrnbsb

我们不需要推送到 BentoCloud,所以现在可以先忽略。我们按第一个建议操作,把 Bento 容器化:

% bentoml containerize yolo_v8:3jghhcfxvwsrnbsb 

把 Bento 容器化是一种很强的手段,可以确保跨环境的可移植性与一致性。通过把服务与其依赖一起打包到容器镜像中,你可以轻松将其部署到不同平台与基础设施,而无需担心兼容性冲突。容器化也带来可扩展性与更高效的资源利用,使其成为生产部署的理想选择。

10.5.1 Bento tags:版本化与管理你的 Bentos

随着你持续开发并增强 ML 服务,建立一个健壮的版本化与管理体系就变得至关重要。BentoML 提供了一个称为 Bento tags 的特性,用来有效标记与组织你的 Bentos。Bento tags 能帮助你追踪服务的不同版本,使你更容易在不同环境里管理并部署目标版本。

还记得 bentoml build 命令如何在示例里自动把模型标记为 yolo_v8:3jghhcfxvwsrnbsb 吗?这就是 tags 特性的一个例子。你当然也可以在最后一个参数里不写 tag——也就是只写 yolo_v8,而不是 yolo_v8:3jghhcfxvwsrnbsb。但就像对待 Docker tags 一样,最佳实践是始终显式指定版本。

Bento tags 有多重用途:

  • 版本化(Versioning) ——追踪并管理模型与服务代码的不同迭代。
  • 部署(Deployment) ——确保在各环境里一致部署正确版本。
  • 可复现性(Reproducibility) ——为每个 Bento 提供唯一标识符,促进可复现与调试。

要验证 Bento 是否已成功容器化,按输出最后一行建议执行:

% docker run --rm -p 3000:3000 yolo_v8:3jghhcfxvwsrnbsb

你应该会看到 worker 启动的日志。如果访问 http://0.0.0.0:3000,你会看到熟悉的 API 页面(图 10.10)。试用这些 API,确保一切如预期工作:用样例图片测试 /invocation/render 端点,验证结果是否准确且与预期一致。如果 API 工作正常,你就可以确信 Bento 已成功容器化并准备好部署了。

image.png

图 10.10 BentoML Web 界面:展示已部署的 YOLOv8 服务端点与文档

用 BentoML 将 Bento 容器化,提供了一种方便高效的方式来打包与分发 ML 服务。通过把服务及其依赖封装进容器镜像,你可以确保跨环境的一致性与可移植性。Docker 的广泛普及与成熟生态,也使其成为理想的容器化选择,让你能在不同平台与基础设施上无缝部署 Bento。

当你成功把 YOLOv8 Bento 容器化后,你已经在部署流程里达成了一个重要里程碑。BentoML 直观的 API 与精简的工作流,让构建、打包与部署 ML 服务变得更容易。不过也值得注意:BentoML 并不是服务与部署 ML 模型的唯一选择。

10.6 BentoML 与 MLflow 推理

到目前为止,我们用 BentoML 部署了本地模型,并为它们开发了推理服务。我们可以将 BentoML 的能力与上一章构建的 MLflow 模型注册表特性结合起来,从而进一步扩展(图 10.11)。

image.png

图 10.11 结合使用 MLflow 与 BentoML 的逻辑流程

虽然 BentoML 与 MLflow 也可以分别用于模型部署,但二者结合往往能带来更好的开发与测试体验。正如前面讨论的,模型开发是迭代式的,因此让部署与推理更“省事”是把流程做简单的关键。MLflow 提供强大的追踪与实验能力,这通常对数据科学家与模型开发者更重要;而 BentoML 提供更易用的部署与监控体验,降低部署与运维工程师的运维负担。由于本章前面已经展示过 YOLOv8 的纯 BentoML 体验,这里我们会把它扩展到推荐项目:使用注册在 MLflow 中的模型。

快速回顾一下:在上一章,我们训练了一个模型并把它记录到 MLflow 的实验追踪系统中。模型与其他模型一起被评估,表现最好的那个被打上 production 标签。

开始之前,我们先像目标检测那样创建一个简单推理服务。但这里我们不再使用本地镜像,而是从 MLflow 拉取模型。

清单 10.1 在推理服务中结合使用 MLflow 与 BentoML

import bentoml
import mlflow
import torch
import numpy as np
from mlflow import MlflowClient

@bentoml.service(
    resources={"cpu": "2"},
    traffic={"timeout": 10},
)
class RecommenderRunnable:
    def __init__(
        self, 
        registered_model_name = 'recommender_production', 
        device = 'cpu'): 
        mlflow.set_tracking_uri(uri="http://mlflow:8080")     #1
        client = MlflowClient()     #1
        current_prod = client.get_model_version_by_alias(
                            registered_model_name, 
                            "prod")  #1
        model_uri = f"runs:/{current_prod.run_id}/model"         #1
        bentoml.mlflow.import_model(
            "recommender", model_uri)     #1
        bento_model = bentoml.mlflow.get(
            "recommender:latest")     #1
        mlflow_model_path = bento_model.path_of(
                bentoml.mlflow.MLFLOW_MODEL_FOLDER) 
        self.model = mlflow.pytorch.load_model(mlflow_model_path) 
        self.device = device 
        self.model.to(self.device) 
        self.model.eval() 

    @bentoml.api     #2
    def predict(
        self, 
        user_id: int, 
        top_k: int=10, 
        ranked_movies:np.ndarray=None) -> np.ndarray: 

        user_id = torch.tensor(
                    [user_id], 
                    dtype=torch.long).to(self.device) 
        all_items = torch.arange(
                        1, 
                        self.model.n_items + 1, 
                        dtype=torch.long).to(self.device) 
        if ranked_movies is not None:     #1
            ranked_movies = torch.tensor(
                ranked_movies,
                dtype=torch.long).to(self.device)  
            unrated_items = all_items[
                    ~torch.isin(all_items, ranked_movies)] 
        else: 
            unrated_items = all_items
        user_ids = user_id.repeat(len(unrated_items)) 
        with torch.no_grad(): 
            predictions = self.model(
                            user_ids, 
                            unrated_items).squeeze()     #3
        top_n_indices = torch.topk(
            predictions, top_k).indices     #4
        recommended_items = unrated_items[top_n_indices].cpu().numpy() 
        return recommended_items
#1 为从 MLflow 拉取模型所做的改动
#2 语法糖:自动设置最终 Runner 中的 /predict 端点
#3 为所有未评分条目预测评分
#4 获取 top_k 预测结果

上面的代码重点展示了我们为从 MLflow 拉取模型而做的修改,这些改动主要集中在服务类的初始化部分:我们先查询 MLflow,拿到带有 prod 别名的最新注册模型的 model_uri,把它下载到本地,然后像之前一样通过 API 提供服务。需要注意的是:在流水线中我们可以去掉 mlflow client,因为 URI 通常会作为输入参数或从上一步传入的变量获得。要把它构建成一个服务,我们使用如下 bentofile

清单 10.2 用于创建推理服务的示例 bentofile

service: "service:RecommenderRunnable"
labels:
  owner: mlops
  stage: demo
include:
  - "*.py"
python:
  requirements_txt: './requirements.txt'

使用 bentoml build 把服务构建成 Bento,然后用 bentoml serve recommender_runnable:latest 运行本地推理服务器。

此时,你应该已经在本机跑起了一个完整的 Bento。向 /predict 发送推理请求(参数与上一章相同),验证模型是否按预期运行并返回响应。

当你确认服务正常后,就可以用 bentoml containerize recommender_runnable:latest 将其容器化,从而得到一个可直接部署的模型服务器——是不是很快?

花一分钟想想 BentoML 在这里帮了我们什么:只需要一个简单的 service 文件和一个 bento 定义文件,我们就能用几条命令创建一个完整的模型服务器。没有 BentoML 的话,你需要自己写 API server,并实现监控、健康检查与存活探针端点——同时还要处理批处理、GPU 设备等 ML 特有复杂性。BentoML 还自带文档能力,使用户能快速查看输入 schema 与期望输出,促进组织协作,而无需开发者额外花时间写详细文档。

把 MLflow 与 Bentos 结合也展示了这套组合的强大:我们可以构建自动化流水线来创建、测试并把模型部署到推理服务。按我们的经验,真正的优势在于:我们可以非常容易地在开发者本机快速启动一个模型服务器,用于在 staging 环境测试模型,然后再把修改后的服务容器化。这使得一些有趣工作流成为可能,比如用自动化流水线生成的主模型与另一个采用不同数据处理步骤的模型做 AB 测试,观察它们在生产中的表现。我们一直强调:迭代是 MLOps 的关键,而这种工具组合是我们见过在跨职能组织与团队中收益最大的方案之一。

最后,BentoML 生态还提供 Yatai——一个为 K8s 集群设计、可良好扩展的健壮模型服务框架。BentoML 与 Yatai 可以无缝集成,构建好的 Bentos 可以推送到 Yatai 来启动模型服务器。这也是我们喜欢 BentoML 的原因之一:Yatai 提供集中存储与推理服务。

10.7 仅使用 MLflow 创建推理服务

虽然结合使用 BentoML 与 MLflow 有不少优势,但这仍然意味着你需要使用两套工具并在它们之间切换。另一种选择是只使用 MLflow 来创建推理服务,从而始终停留在 MLflow 生态中。使用 MLflow 部署模型时,你通常会按以下步骤进行(图 10.12):

image.png

图 10.12 仅使用 MLflow 覆盖整个模型生命周期的逻辑流程

  1. 使用 MLflow 的 tracking APIs 训练并记录模型。
  2. 在 MLflow model registry 中注册训练好的模型。
  3. 使用 MLflow 内置的部署工具,将模型作为 REST 端点对外服务。MLflow 支持多种部署方式,包括本地服务、Docker 容器化,以及部署到云平台。

我们在上一章已经完成了第 1 步和第 2 步,这里我们展开第 3 步。要在本地完成这件事,你只需要运行下面这个 Bash 脚本:

#!/usr/bin/env sh
export MLFLOW_TRACKING_URI=http://mlflow:5000
mlflow models serve -m "models:/recommender_production@prod"

在某些场景下,这种方式更容易快速拉起并开展实验。为了把流程补齐,我们还可以用下面的命令创建一个实现模型服务的 Docker 容器,之后即可用于规模化部署推理服务:

 mlflow models build-docker \
  -m models:/recommender_production@prod \
  -n recommender_service \
  --enable-mlserver

虽然我们提到过 BentoML 与 MLflow 组合非常适合我们的用例,但只用 MLflow 同样可以是一种有效的部署策略。模型服务器也会分别在 /health/invocations 下提供健康检查与推理端点。MLflow 的部署流程相对直接,也不要求具备 K8s 知识,因此对于优先考虑简单性与易用性的团队来说是个不错的选择。如果系统需求要求更细粒度的部署选项、可在更丰富环境中部署并具备监控能力,那么使用 BentoML 结合 MLflow 会是更好的架构。

10.8 KServe:BentoML 的替代方案

KServe(随 Kubeflow 提供)为将 ML 模型作为 K8s 微服务进行部署、服务化与管理提供了一组工具与抽象。和 BentoML 一样,KServe 对 ML 框架无关(framework agnostic),可以无缝支持多种 ML 框架。

KServe 的工作流与 BentoML 不同。要用 KServe 部署模型,你需要执行以下步骤:

  1. 为服务创建一个 K8s namespace(例如 kubectl create ns pytorch-yolo)。
  2. 创建一个 InferenceService。InferenceService 是 KServe 的自定义资源定义(CRD),也是部署与服务 ML 模型的核心抽象(下面只是示例):
kubectl apply -n pytorch-yolo -f - <<EOF
apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "pytorch-yolo"
spec:
  predictor:
    model:
      modelFormat:
        name: sklearn
      storageUri: "gs://yolov8/models/pytorch/1.0/model"
EOF

InferenceService 初始化需要一些时间。完成后,如果 DNS 配置正确,服务会被分配一个 URL。从那时起,服务就可以被消费调用了。

KServe 需要相当多的 K8s 知识,因此对于开发者来说,在组织成熟度不足的情况下可能会限制其用于测试部署。KServe 原生属于 Kubeflow 生态,因此相比直接使用原生 MLflow 或 BentoML 容器,它开箱即具备更强的可扩展性。

选择合适工具取决于你的具体需求与约束:如果你处于 K8s 中心的环境,并且需要对模型服务做更细粒度控制,KServe 可能很合适;但如果你的首要目标是易用与快速部署,BentoML 与 MLflow 会提供更直接的方案。表 10.1 展示了这些工具的差异,便于对比。

表 10.1 KServe、MLflow 与 BentoML 的关键差异

FeatureKServeBentoMLMLflow
Focus模型服务编排(model-serving orchestration)简化模型部署与服务端到端 ML 生命周期管理
Framework support框架无关框架无关框架无关
Cloud platform support平台无关平台无关平台无关
Kubernetes-centric
Ease-of-use较低(需要更多配置与 K8s 知识)较高(更易用的 API 与抽象,不要求 K8s 知识)较高(简单 API 与部署选项)
Experiment tracking

按我们的经验,BentoML 以其简洁与开发者友好的 API 脱颖而出,使数据科学家无需深入 K8s 知识也能快速上手。另一方面,MLflow 提供了覆盖整个 ML 生命周期的综合平台,包括实验追踪与模型版本管理,因此对于强调端到端工作流管理的团队非常有价值。

最终,在 KServe、BentoML 与 MLflow 之间的选择,取决于团队的技能结构、基础设施形态以及 ML 项目的具体需求。当你开始模型部署旅程时,记得评估自身需求,选择与团队能力与项目目标匹配的工具。借助本章获得的洞察,你已经具备构建稳健、可扩展、可维护 ML 服务的能力。

小结(Summary)

  • 模型部署会引入独特挑战,理解推理模式、可扩展性、监控与资源管理至关重要。
  • BentoML 通过统一的打包与服务框架简化模型部署,让你能聚焦于服务的核心功能。
  • 构建 BentoML Service 的关键步骤包括:定义 Runner、创建带 API 端点的 BentoML Service,并使用 BentoML 的直观 API 与装饰器处理输入/输出与异步执行。
  • BentoML 内置可观测性能力,包括健康检查、liveness/readiness 探针与 Prometheus 指标,确保在 K8s 环境中可被正确监控与管理。
  • 将 BentoML Service 打包成 Bento,可实现跨环境的便捷分发与部署;Bento tags 提供了健壮的版本化与管理体系。
  • 结合使用 MLflow 与 BentoML 能带来很多优势:MLflow 提供模型血缘与追踪,而 BentoML 能无缝把注册表中的模型服务化。
  • 也可以仅使用 MLflow 覆盖整个生命周期。对于更小型组织,可能更偏好这种“简单优先”的方案,而不是追求更全的功能集。
  • 选择模型服务工具时,也要考虑 KServe 等替代方案及其独特优势;应基于易用性、K8s 兼容性与所需控制粒度做选择。BentoML 因其简洁与开发者友好 API 而表现突出。