Modal 部署模型教程
简介
本教程介绍如何用 Modal 部署 Hugging Face 开源模型、批量发送请求并取回结果。
Modal 的基本单位是一个 Python 函数。通过装饰器声明函数运行的镜像、GPU 型号和最长运行时间,Modal 会在云端启动容器执行该函数,函数结束后容器随即释放,按秒计费。
准备本地环境
-
安装 uv。
-
获取 Modal token,有两种方式:
- 交互式:执行
uvx modal token new,在浏览器登录后,token 会写入~/.modal.toml。 - 环境变量:设置
MODAL_TOKEN_ID和MODAL_TOKEN_SECRET。
- 交互式:执行
-
如果网络需要经过本地 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 提供:
- 创建一个包含
HF_TOKEN的 Secret,例如命名为huggingface:modal secret create huggingface HF_TOKEN=<token> - 在函数装饰器中加上
secrets=[modal.Secret.from_name("huggingface")]。huggingface_hub会自动读取环境变量HF_TOKEN。
缓存
在函数内用 huggingface_hub.snapshot_download(repo, local_dir="/cache/xxx") 下载权重。把下载目录放在 Volume 中,重试时就不必重新下载;也可以把 HF_HOME 指向 Volume。
使用 Volume 时需要注意两点:
- 构建镜像的步骤中不要向挂载路径写入任何内容。如果构建期间已向
/cache写入文件(例如先设置了HF_HOME=/cache/hf,再在构建命令中下载文件),镜像里的/cache就是非空目录,运行时 Volume 无法挂载,报错cannot mount volume on non-empty path。正确做法是把.env({"HF_HOME": ...})放在所有构建命令之后。 - Volume 的文件系统不支持全部操作。例如某个下载脚本在把
.part文件重命名为最终文件时,报Operation not permitted。对于需要重命名或频繁写入的下载,应写入容器本地磁盘(如/tmp);只需要保留结果的文件才放入 Volume。
执行任务
启动模型
模型的启动方式有三种:
- 模型自带 HTTP 服务:在函数中用
subprocess.Popen启动服务,轮询到它能响应后,再用urllib发送请求。轮询时要区分两类情况:连接被拒绝说明服务尚未就绪,应继续等待;服务返回 HTTP 4xx 说明请求本身有问题,应立即报错,否则会一直等到超时。 - 进程内调用库:直接在函数中导入库、加载模型并调用,无需 HTTP 服务。
- 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"
任务结束后按以下顺序清理:
- 执行
modal container list,确认没有容器仍在运行。 - 停止所有不再需要的应用。
modal app stop的确认提示会说明该应用当前是否有容器在运行,可据此判断是否产生过 GPU 费用。 - 删除缓存用的 Volume。权重缓存可能达到数百 GB,且删除不可逆,应在确认结果都已取回后再删除。
- 结束本机上残留的
modal run客户端进程。 - 确认 token 只存在于执行命令的进程中,没有写入任何文件。

浙公网安备 33010602011771号