Modal 部署模型教程

简介

本教程介绍如何用 Modal 部署 Hugging Face 开源模型、批量发送请求并取回结果。

Modal 的基本单位是一个 Python 函数。通过装饰器声明函数运行的镜像、GPU 型号和最长运行时间,Modal 会在云端启动容器执行该函数,函数结束后容器随即释放,按秒计费。

准备本地环境

  1. 安装 uv。

  2. 获取 Modal token,有两种方式:

    • 交互式:执行 uvx modal token new,在浏览器登录后,token 会写入 ~/.modal.toml。
    • 环境变量:设置 MODAL_TOKEN_ID 和 MODAL_TOKEN_SECRET。
  3. 如果网络需要经过本地 HTTP 代理,需要安装带代理支持的 modal:

    uv run -w "modal[api-proxy-support]" modal --help
    

下文为了简洁,把 uv run -w "modal[api-proxy-support]" modal <subcommand> 简写为 modal <subcommand>。

部署方式

@app.function

@app.function 包装一个远程函数 run(cases)。该函数在容器内下载权重、启动模型(或模型自带的服务)、逐个发送请求,并把结果作为返回值带回本地,由本地写成 JSON 文件。这种方式适合一次性的批量请求,有三个特点:

  • 没有常驻成本,任务结束即释放资源。
  • 每个模型的运行环境相互隔离。需要 trust_remote_code 的模型会执行作者提供的代码,应当只在这样的隔离容器中运行。
  • 任务失败后可以直接重跑。

下面的脚本演示模型自带 HTTP 服务的情形,保存为 modal_function.py:

"""Runs test cases against a model server on Modal."""

import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

import modal

_POLL_LIMIT_S = 3600  # Seconds to wait for the remote call before giving up.

image = (
    modal.Image.debian_slim(python_version="3.12")
    .apt_install("git", "build-essential")
    .uv_pip_install("torch==2.14.0", "transformers==5.17.0", "huggingface_hub==1.33.0")
)
app = modal.App("my-model")
# A Volume keeps downloaded weights, so a retry does not download them again.
cache = modal.Volume.from_name("hf-cache", create_if_missing=True)


def _post(url, body, timeout=300):
    """Posts a JSON body and returns the decoded JSON response."""
    req = urllib.request.Request(
        url,
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.load(resp)


@app.function(image=image, gpu="A100-80GB", timeout=2100, volumes={"/cache": cache})
def run(cases):
    """Starts the server, waits until it answers, then sends every case."""
    start = time.monotonic()
    log_path = Path("/tmp/server.log")
    # The child process keeps its own copy of the file descriptor, so leaving the
    # with block does not stop it from writing to the log.
    with log_path.open("w", encoding="utf-8") as log:
        server = subprocess.Popen(
            [sys.executable, "-m", "my_server", "--port", "8000"],
            stdout=log,
            stderr=subprocess.STDOUT,
        )
    url = "http://127.0.0.1:8000/v1/systemone"
    try:
        while True:  # Wait for readiness.
            if server.poll() is not None:
                raise RuntimeError(log_path.read_text(encoding="utf-8")[-3000:])
            try:
                _post(url, cases[0]["body"], 120)
                break
            except urllib.error.HTTPError as exc:
                # A 4xx means the request is wrong, so waiting longer will not help.
                if exc.code < 500:
                    raise
                time.sleep(5)
            except OSError:
                time.sleep(5)
        ready_s = time.monotonic() - start
        results = []
        for case in cases:
            t = time.monotonic()
            response = _post(url, case["body"])
            results.append(
                {
                    "id": case["id"],
                    "seconds": time.monotonic() - t,
                    "response": response,
                }
            )
    finally:
        # Stop the server on success and on every error path.
        server.terminate()
        try:
            server.wait(timeout=30)
        except subprocess.TimeoutExpired:
            server.kill()
    cache.commit()  # Persist what was written to the Volume.
    return {"ready_s": ready_s, "results": results}


@app.local_entrypoint()
def main():
    """Submits the cases to Modal and saves the results to a JSON file."""
    cases = [{"id": "case-1", "body": {"state": "hello", "questions": {}}}]
    # Spawn and poll with short waits instead of one long blocking call.
    call = run.spawn(cases)
    deadline = time.monotonic() + _POLL_LIMIT_S
    while time.monotonic() < deadline:
        try:
            out = call.get(timeout=30)
            break
        except TimeoutError:
            continue
        except OSError:
            time.sleep(10)
    else:
        # The loop ended without a break, so no result arrived in time.
        raise TimeoutError("no result before the polling deadline")
    Path("results.json").write_text(
        json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8"
    )

@app.function 的几个关键参数:

  • gpu:GPU 型号,常用 L4、L40S、A100-80GB、H100。27B 参数的 bf16 模型权重约 52 GB,需要 80 GB 显存的 GPU;依赖 FP8 的模型需要 H100。
  • timeout:单次函数调用的最长运行时间,超时后容器会被终止,用于限制单次调用的最长耗时,防止费用失控。应为下载权重和加载模型留出时间,但不宜过大。本教程示例使用 20 到 40 分钟,这是经验取值,不是官方建议。
  • volumes:把持久化的 Volume 挂载到容器中,用于保存下载的权重。

使用上述示例时还需要注意:

  • 被装饰函数的参数和返回值必须可序列化,建议只传递 JSON 兼容的 Python 对象。
  • 示例中的 my_server 是占位的服务模块,需要换成真实的服务。

@app.cls

@app.function 的每次调用都是独立的:容器启动、加载模型、处理请求、结束。如果要对同一个模型反复发请求,每次调用都重复加载模型,开销较大。Modal 的做法是把函数改写成一个类,用 @app.cls 装饰,并用三个生命周期装饰器标出各阶段:

装饰器 执行时机 用途
@modal.enter() 容器启动时执行一次 加载模型,或启动模型服务
@modal.method() 每次调用时执行 处理一个请求,复用已加载的模型
@modal.exit() 容器退出前执行一次(可选) 清理资源,例如停止服务

@app.cls 接收的参数与 @app.function 相同(image、gpu、timeout、volumes 等)。下面是上一节示例的 @app.cls 版本,保存为 modal_cls.py:

"""Keeps one model server alive in a container and sends many calls to it."""

import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

import modal

_POLL_LIMIT_S = 3600  # Seconds to wait for a spawned call before giving up.
# A minimal request used only to check that the server is ready.
_PROBE = {"state": "ping", "questions": {}}

image = (
    modal.Image.debian_slim(python_version="3.12")
    .apt_install("git", "build-essential")
    .uv_pip_install("torch==2.14.0", "transformers==5.17.0", "huggingface_hub==1.33.0")
)
app = modal.App("my-model")
cache = modal.Volume.from_name("hf-cache", create_if_missing=True)


def _post(url, body, timeout=300):
    """Posts a JSON body and returns the decoded JSON response."""
    req = urllib.request.Request(
        url,
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.load(resp)


def _wait(call):
    """Polls a spawned call with short waits until it returns a result."""
    deadline = time.monotonic() + _POLL_LIMIT_S
    while time.monotonic() < deadline:
        try:
            return call.get(timeout=30)
        except TimeoutError:
            continue
        except OSError:
            time.sleep(10)
    raise TimeoutError("no result before the polling deadline")


@app.cls(image=image, gpu="A100-80GB", timeout=2100, volumes={"/cache": cache})
class Server:
    """Holds one model server per container; every call to answer() reuses it."""

    url = "http://127.0.0.1:8000/v1/systemone"

    @modal.enter()
    def start(self):
        """Launches the server and waits until it answers; runs once per container."""
        self.log_path = Path("/tmp/server.log")
        # The child process keeps its own copy of the file descriptor, so leaving the
        # with block does not stop it from writing to the log.
        with self.log_path.open("w", encoding="utf-8") as log:
            self.server = subprocess.Popen(
                [sys.executable, "-m", "my_server", "--port", "8000"],
                stdout=log,
                stderr=subprocess.STDOUT,
            )
        while True:  # Wait for readiness.
            if self.server.poll() is not None:
                raise RuntimeError(self.log_path.read_text(encoding="utf-8")[-3000:])
            try:
                _post(self.url, _PROBE, 120)
                break
            except urllib.error.HTTPError as exc:
                # A 4xx means the probe is wrong, so waiting longer will not help.
                if exc.code < 500:
                    raise
                time.sleep(5)
            except OSError:
                time.sleep(5)

    @modal.method()
    def answer(self, body):
        """Sends one request to the already running server."""
        return _post(self.url, body)

    @modal.exit()
    def stop(self):
        """Stops the server; runs once before the container exits."""
        self.server.terminate()
        try:
            self.server.wait(timeout=30)
        except subprocess.TimeoutExpired:
            self.server.kill()


@app.local_entrypoint()
def main():
    """Sends the cases to the server one by one and saves the results to a JSON file."""
    cases = [{"id": "case-1", "body": {"state": "hello", "questions": {}}}]
    server = Server()
    results = []
    for case in cases:
        t = time.monotonic()
        response = _wait(server.answer.spawn(case["body"]))
        results.append(
            {
                "id": case["id"],
                "seconds": time.monotonic() - t,
                "response": response,
            }
        )
    Path("results.json").write_text(
        json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8"
    )

运行方式与前面相同:modal run modal_cls.py。调用时先创建一个实例 Server(),再通过 server.answer.spawn(...) 或 .remote(...) 调用方法。

使用时需要注意:

  • 同一个容器里的多次调用共享 @modal.enter() 中创建的状态(这里是 self.server)。服务只启动一次,之后的调用只发请求。第一次调用的耗时包含容器启动和 @modal.enter()。
  • 并发调用会启动多个容器,每个容器各执行一次 @modal.enter()。对模型服务来说,这意味着每个容器各加载一份模型,各占一块 GPU。
  • @modal.enter() 的耗时计入启动时间。timeout(默认 300 秒)涵盖输入的执行时间和启动时间;startup_timeout(默认不设置)单独限制启动时间,优先级高于 timeout。加载模型很慢时,把 timeout 设得足够大(本节示例为 2100 秒),或单独设置 startup_timeout。
  • 空闲容器会保留一段时间再回收,由 scaledown_window 控制。显式设置时取值范围是 2 到 3600 秒(modal 1.6.2 的实现)。官方文档没有给出不设置时的保留时长,需要稳定行为时请显式设置。
  • @modal.exit() 在容器即将退出时执行。Modal 文档提到,容器被抢占时退出处理函数也会执行,并有 30 秒宽限时间,超时会被强制结束,所以清理应当很快完成。

下表是在 Modal 上的实测结果(modal 1.6.2;第一行在 A100-80GB 上只测了 1 次,其余在 CPU 容器上各重复 3 次)。容器数和耗时受机器和调度影响,仅供参考:

现象 设置 结果
首次与后续调用的耗时 占位服务 第一次 9 到 15 秒(含容器启动和 enter,两次实测),之后每次约 0.7 到 0.9 秒,其中大部分是调用往返开销
enter 超过 timeout timeout=15,enter 耗时 30 秒 3/3 次在 120 秒内无结果;日志中平台反复报 Runner has been initializing for too long: 15 seconds. Consider increasing the startup_timeout of your Function.,即在 15 秒处终止启动并重试
startup_timeout 优先 同上,加 startup_timeout=90 3/3 次在 32 到 33 秒正常返回,没有该报错
并发 同时 4 个各耗时 5 秒的调用 分别得到 4、4、3 个容器,每个容器各执行一次 enter
空闲保留 同一实例在空闲 30 秒、130 秒后再次调用 3/3 次都复用原容器,未重新执行 enter。只说明保留时间至少 130 秒,上限未测

配置运行环境

选择镜像

Modal 的镜像通过链式调用构建。构建在云端的 CPU 容器中完成,不使用 GPU,因此不产生 GPU 费用。常用的基础镜像有三种:

场景 基础镜像 说明
纯 PyTorch 或 transformers 推理 modal.Image.debian_slim(python_version="3.12") 加 uv_pip_install 体积最小,适用于大多数模型。Modal 文档推荐默认使用 uv_pip_install,遇到问题再退回 pip_install
使用 vLLM modal.Image.from_registry("vllm/vllm-openai:v0.30.0", add_python="3.12").entrypoint([]) 官方镜像自带 CUDA 工具链(nvcc);entrypoint([]) 清除镜像默认的启动命令
需要编译 CUDA 代码 modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12") devel 版本包含 nvcc;add_python 使 Modal 注入其所需的 Python

选择镜像时的注意事项:

  • debian_slim 不含 nvcc,而 vLLM 启动时需要即时编译部分内核,因此 vLLM 应使用官方镜像或 CUDA devel 镜像。
  • 不建议使用 PyTorch 官方的 conda 镜像(pytorch/pytorch:...)。实际使用中,基于该镜像的函数在 Modal 上出现过容器反复启动失败的情况,改用 CUDA devel 镜像并通过 pip_install("torch==...", index_url=...) 安装 PyTorch 后恢复正常。
  • 镜像构建阶段没有 GPU,编译 CUDA 扩展时必须显式指定目标架构,例如 TORCH_CUDA_ARCH_LIST=8.0(A100),或 llama.cpp 的 --cuda-arch 89(L40S)。
  • 要求 CUDA 13 的镜像可以在 Modal 上使用:在 L4、L40S、A100-80GB、H100 上各检查 3 次,驱动版本均为 610.57.04(支持到 CUDA 13.3),用 CUDA 13.0 的 nvcc 编译的核函数都运行正确。驱动版本可能随时间、地区和机器变化;本教程的编译示例使用 CUDA 12.8。
  • 固定所有版本:仓库使用 git checkout <sha>,模型使用 snapshot_download(..., revision=...),包使用 == 固定版本。否则同一个脚本在数日后可能得到不同结果。对于需要 trust_remote_code 的模型,务必把 revision 固定到具体提交,否则仓库作者更新代码后,容器里执行的就是新代码。

镜像构建器版本

构建器版本决定新镜像的基础内容。构建时按以下顺序取第一个有值的:环境变量 MODAL_IMAGE_BUILDER_VERSION、.modal.toml 中的 image_builder_version、部署环境的设置、工作区默认值。

可用版本由本地 Modal 客户端决定,用以下命令列出:

uv run -w modal python -c "from typing import get_args; from modal.experimental import ImageBuilderVersion; print(get_args(ImageBuilderVersion))"

输出中的 PREVIEW 为预览版。modal workspace settings list 查看工作区默认值,modal environment list 查看各环境的设置。

切换版本后,下次部署会重新构建所有镜像。需要可复现的结果时,应在运行前显式设置 MODAL_IMAGE_BUILDER_VERSION。

下载权重

门控模型与 Secret

公开且未设门控的仓库无需 token。需要 token 的(门控)仓库,应通过 Modal 的 Secret 提供:

  1. 创建一个包含 HF_TOKEN 的 Secret,例如命名为 huggingface:modal secret create huggingface HF_TOKEN=<token>
  2. 在函数装饰器中加上 secrets=[modal.Secret.from_name("huggingface")]。huggingface_hub 会自动读取环境变量 HF_TOKEN。

缓存

在函数内用 huggingface_hub.snapshot_download(repo, local_dir="/cache/xxx") 下载权重。把下载目录放在 Volume 中,重试时就不必重新下载;也可以把 HF_HOME 指向 Volume。

使用 Volume 时需要注意两点:

  1. 构建镜像的步骤中不要向挂载路径写入任何内容。如果构建期间已向 /cache 写入文件(例如先设置了 HF_HOME=/cache/hf,再在构建命令中下载文件),镜像里的 /cache 就是非空目录,运行时 Volume 无法挂载,报错 cannot mount volume on non-empty path。正确做法是把 .env({"HF_HOME": ...}) 放在所有构建命令之后。
  2. Volume 的文件系统不支持全部操作。例如某个下载脚本在把 .part 文件重命名为最终文件时,报 Operation not permitted。对于需要重命名或频繁写入的下载,应写入容器本地磁盘(如 /tmp);只需要保留结果的文件才放入 Volume。

执行任务

启动模型

模型的启动方式有三种:

  1. 模型自带 HTTP 服务:在函数中用 subprocess.Popen 启动服务,轮询到它能响应后,再用 urllib 发送请求。轮询时要区分两类情况:连接被拒绝说明服务尚未就绪,应继续等待;服务返回 HTTP 4xx 说明请求本身有问题,应立即报错,否则会一直等到超时。
  2. 进程内调用库:直接在函数中导入库、加载模型并调用,无需 HTTP 服务。
  3. vLLM 加 LoRA 适配器:使用 vLLM 官方镜像,启动模型仓库提供的服务脚本,启动参数以模型卡为准。

无论采用哪种方式,都应先发送一个热身请求,再开始正式计时。许多模型首次请求时需要编译内核,耗时可能达数十秒。

运行与轮询

modal run --detach modal_function.py
  • 运行 modal run <script>.py 时,脚本入口 main() 在本机执行;被 @app.function 装饰的函数由 Modal 提交到云端容器执行。容器按装饰器配置准备镜像、GPU 和 Volume,函数返回值再交回本机,因此示例中的 results.json 写在本机。
  • 加上 --detach 后,本地进程被终止或断开时,最后一次触发的函数不会被取消。但结果仍需由本地进程写入文件,所以 --detach 并不意味着可以关闭本地进程。
  • 长时间阻塞的 run.remote() 在经过代理时,gRPC 长连接可能被重置。更稳妥的做法是用 run.spawn() 提交,再用 call.get(timeout=30) 短周期轮询,这样一次断线只会损失一次重试。上面 @app.function 一节的示例即采用这种写法。

注意事项:

  • 把输出重定向到文件时,避免经过会缓冲的管道。GNU sed 默认按块缓冲,可能数分钟没有任何输出,容易误判为任务卡住。需要实时输出时,使用 sed -u。
  • 同时启动多个任务时,应错开启动时间。一次性创建过多应用会触发 App create rate limit exceeded,间隔约 40 秒启动即可避免。
  • 判断任务是否结束时,不要依赖上一次运行遗留的标记文件。应以本次运行的输出和 modal app list 中的状态为准。

取回结果

  • 函数的返回值是 Python 对象,在 main() 中写成 JSON 文件。建议所有模型统一输出格式,例如 {"model", "ready_s", "results": [{"id", "seconds", "response"}]},这样同一个比较脚本可以处理所有模型。
  • 只保存原始响应,不要在容器内加工。格式有差异时,回到本地修改比较脚本即可,无需重跑耗费 GPU 的任务。
  • 如果模型的接口与统一格式不同,应在函数内做转换,并在脚本的文档字符串中写明转换规则。这类转换会影响结果,报告结果时应说明。
  • 例如某个模型一次只能回答一个问题时,是非题用 Yes、No 两个选项适配,选择题的选项写成 name: description。

清理资源

通过环境变量提供 token 之后,可使用下列命令:

modal app list                    # 所有应用及其状态
modal container list              # 正在运行的容器,计费以它为准
modal app stop <app-id>           # 停止应用,会要求确认;非交互环境需通过管道回答 y
modal volume list                 # 所有 Volume 及其状态
modal volume delete --yes <name>  # 非交互环境必须加 --yes,否则报 "no interactive terminal detected"

任务结束后按以下顺序清理:

  1. 执行 modal container list,确认没有容器仍在运行。
  2. 停止所有不再需要的应用。modal app stop 的确认提示会说明该应用当前是否有容器在运行,可据此判断是否产生过 GPU 费用。
  3. 删除缓存用的 Volume。权重缓存可能达到数百 GB,且删除不可逆,应在确认结果都已取回后再删除。
  4. 结束本机上残留的 modal run 客户端进程。
  5. 确认 token 只存在于执行命令的进程中,没有写入任何文件。
posted @ 2026-10-27 16:57  undefined443  阅读(13)  评论(0)    收藏  举报