Docker Model Runner 接入 Compose 实战
TL;DR:models 顶层元素要 Docker Compose 2.38.0 及以上,provider 服务类型要 2.36.0 及以上,Compose Bridge 要 Docker Desktop 4.43.0 及以上。容器里连宿主机上的模型,Docker Desktop 用 http://model-runner.docker.internal/engines/v1,Docker Engine 用 http://172.17.0.1:12434/engines/v1。官方 api-reference 表格里给容器的 Base URL 是 http://model-runner.docker.internal,这一栏不带 /engines/v1,照抄进 OpenAI SDK 会拼出 /chat/completions。短语法注入的变量名会撞车,一律改用长语法的 endpoint_var 与 model_var。
Docker Model Runner(DMR)能把 GGUF 之类的 OCI 制品拉起来、暴露成 OpenAI 兼容端点,这件事本身不难。真正卡住人的从来不是「怎么跑模型」,而是「模型跑在宿主机上,我的应用跑在容器里,地址到底写哪个」。官方给了四套口径,分布在四个页面里,而且它们彼此不完全一致。我实际在本机把每一套都喂给真实的 SDK,拦截它拼出来的最终 URL,结果比文档本身好懂。
为什么要在 compose.yaml 里声明模型
把一个 AI 应用拆成「前端 + 后端 + 数据库 + 模型」四个服务之后,模型其实和数据库一样是个外部依赖。以前的写法是在 docker compose up 之前先手动 docker model run ai/smollm2,或者在后端启动脚本里硬编码 http://localhost:12434。这两种做法都有同一个毛病:模型这一环没有被版本控制,换台机器就跑不起来。
Compose 的做法是把模型提升成顶层元素。官方在 docs.docker.com/ai/compose/models-and-compose/ 里写得很清楚:用 models 顶层元素声明模型,由平台负责拉起和暴露,并把连接信息以环境变量注入你的服务容器。Docker Compose 自己只是规范的实现者之一,Cloud 平台可以用 x-* 扩展属性接管同一份声明。
所以这里的收益不是「少敲一条命令」,而是你的 compose.yaml 终于能完整描述应用依赖,不用再配一份 README 说明「记得先手动起模型」。
models 顶层元素:短语法与长语法
先看最小形态:
services:
chat-app:
image: my-chat-app
models:
- llm
models:
llm:
model: ai/smollm2
models 是顶层元素,和服务平级;服务里面那个 models 是「我这个服务要用哪些模型」的引用。三层属性官方给了明确定义:model(必填,OCI 制品标识)、context_size(最大上下文 token 数)、runtime_flags(原样传给推理引擎的命令行参数)。此外平台相关的配置走 x-* 扩展属性,Compose 规范本身不做校验。
长语法用来改注入的变量名:
services:
app:
image: my-app
models:
llm:
endpoint_var: AI_MODEL_URL
model_var: AI_MODEL_NAME
embedding-model:
endpoint_var: EMBEDDING_URL
model_var: EMBEDDING_NAME
models:
llm:
model: ai/smollm2
context_size: 4096
embedding-model:
model: ai/all-minilm
关于 context_size,官方文档里有一条容易被忽略的提醒:每个模型有自己的最大上下文长度,调大时先考虑硬件约束,「尽量把 context size 控制在满足需求的最小值」。这不是性能优化建议,因为超了会直接起不来。
注入的环境变量是怎么算出来的
短语法下,平台按模型名生成两个变量:{模型名大写}_URL 和 {模型名大写}_MODEL,其中连字符换成下划线。官方在 models-and-compose 页里给的例子是 llm 生成 LLM_URL 与 LLM_MODEL,embedding-model 生成 EMBEDDING_MODEL_URL 与 EMBEDDING_MODEL_MODEL。
最后那个 EMBEDDING_MODEL_MODEL 看着像笔误,但它是规则的自然结果。真正的问题在下面这张表。我按官方规则写了个复算脚本,把常见命名都跑了一遍:
def short_vars(key: str):
p = key.upper().replace("-", "_")
return p + "_URL", p + "_MODEL"
for k in ["llm", "embedding-model", "chat", "model"]:
print(k, "->", short_vars(k))
for a, b in [("llm", "llm-model"), ("my-model", "my_model"), ("chat", "chat-model")]:
print(a, "+", b, "交集:", set(short_vars(a)) & set(short_vars(b)))
实测输出(本机 Python 3.13.14):
llm -> ('LLM_URL', 'LLM_MODEL')
embedding-model -> ('EMBEDDING_MODEL_URL', 'EMBEDDING_MODEL_MODEL')
chat -> ('CHAT_URL', 'CHAT_MODEL')
model -> ('MODEL_URL', 'MODEL_MODEL')
llm + llm-model 交集: set()
my-model + my_model 交集: {'MY_MODEL_MODEL', 'MY_MODEL_URL'}
chat + chat-model 交集: set()
my-model 和 my_model 会生成完全相同的两个变量名,后写的值覆盖先写的,而且不会有任何报错——你只会发现其中一个模型怎么调都是另一个在应答。这不是我编的场景,短语法做命名规范化的那一步(upper() 加 - 换 _)必然产生这个结果。
结论很简单:只要一份 compose 文件里有两个及以上模型,就用长语法把 endpoint_var 和 model_var 显式写出来。多写四行,换掉一整类无法解释的串模型问题。
容器内该连哪个地址:四套官方口径
这是全文最容易踩的地方,因为四个页面给了四种写法,而且没有一个页面声明「其他写法是错的」。
| 来源页面 | 场景 | 给的地址 |
|---|---|---|
compose/bridge/use-model-runner | Docker Desktop(Helm modelRunner.enabled: false) | http://host.docker.internal:12434/engines/v1/ |
compose/bridge/usage | Docker Desktop / Kubernetes | http://host.docker.internal:12434/engines/v1/ 与 http://model-runner/engines/v1/ |
ai/model-runner/api-reference | Docker Desktop,从容器内访问 | http://model-runner.docker.internal |
ai/model-runner/api-reference | Docker Engine,从容器内访问 | http://172.17.0.1:12434 |
ai/model-runner/api-reference | 宿主进程(TCP) | http://localhost:12434 |
注意第三行:http://model-runner.docker.internal 不带端口,也不带 /engines/v1。同一个页面的「第三方工具 Base URL」表又写着 OpenAI SDK 应该用 http://localhost:12434/engines/v1。也就是说,从一张表抄下来的值,喂给另一张表指定的 SDK,路径会少一段。
官方对端口差异其实给了解释:Compose Bridge 在 Kubernetes 里生成的 Service 暴露的是 80 端口,映射到容器的 12434。所以 http://model-runner/engines/v1/ 里的 80 是默认值,不是漏写。同理,Docker Desktop 上 model-runner.docker.internal 解析到的也是带端口转发的内网地址。但这些解释写在各自的页面里,交叉阅读时非常容易丢。
还有一条更隐蔽的:Docker Engine(纯 Linux,非 Desktop)场景下,官方明确说 172.17.0.1 这个网桥地址「对 Compose 项目内的容器可能默认不可用」,需要自己加 extra_hosts:
services:
app:
image: my-app
extra_hosts:
- "model-runner.docker.internal:host-gateway"
加完之后官方给的可用地址是 http://model-runner.docker.internal:12434/——同一个主机名,在 Desktop 上不带端口,在 Engine 上带端口。这个不一致我没有替官方圆过去的能力,只能原样摆出来。
本机实测:SDK 真正发出去的 URL
我没有 Docker Desktop,也没有 GPU,所以不假装做过端到端推理。但我可以做一件更有用的事:用 httpx.MockTransport 拦住 SDK,看它到底把请求发到哪个 URL。这在任何机器上都能复现,不需要模型、不需要显卡。
环境:Windows + Python 3.13.14,openai 3.31.0、httpx 0.28.1、anthropic 1.14.0(截至发稿版本)。
import httpx
from openai import OpenAI
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={"id": "x", "object": "chat.completion",
"created": 0, "model": "ai/smollm2",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}})
for base in ["http://host.docker.internal:12434/engines/v1/",
"http://host.docker.internal:12434/engines/v1",
"http://model-runner.docker.internal",
"http://model-runner.docker.internal/engines/v1",
"http://172.17.0.1:12434/engines/v1/",
"http://model-runner/engines/v1/",
"http://localhost:12434/engines/v1/"]:
c = OpenAI(api_key="not-needed", base_url=base,
http_client=httpx.Client(transport=httpx.MockTransport(handler)))
c.chat.completions.create(model="ai/smollm2",
messages=[{"role": "user", "content": "hi"}], max_tokens=8)
print(base, "->", captured["url"])
实测输出:
http://host.docker.internal:12434/engines/v1/ -> http://host.docker.internal:12434/engines/v1/chat/completions
http://host.docker.internal:12434/engines/v1 -> http://host.docker.internal:12434/engines/v1/chat/completions
http://model-runner.docker.internal -> http://model-runner.docker.internal/chat/completions
http://model-runner.docker.internal/engines/v1 -> http://model-runner.docker.internal/engines/v1/chat/completions
http://172.17.0.1:12434/engines/v1/ -> http://172.17.0.1:12434/engines/v1/chat/completions
http://model-runner/engines/v1/ -> http://model-runner/engines/v1/chat/completions
http://localhost:12434/engines/v1/ -> http://localhost:12434/engines/v1/chat/completions
7 个候选地址里 6 个拼出了正确的 /engines/v1/chat/completions,占比 85.7%。唯一拼错的第 3 个,恰好是官方 api-reference 表格里「从容器访问」那一栏的原值。末尾带不带斜杠不影响结果,httpx 会补上,所以斜杠不是问题,/engines/v1 这一段才是。
Anthropic SDK 那边还有一个独立的矛盾。官方 api-reference 的端点表写的是 /anthropic/v1/messages,但同一页的 curl 示例用的是 http://localhost:12434/v1/messages,而「第三方工具 Base URL」表给 Anthropic 的是 http://localhost:12434。我把这几个都喂给了 anthropic 1.14.0:
base_url=http://localhost:12434 -> http://localhost:12434/v1/messages
base_url=http://localhost:12434/anthropic -> http://localhost:12434/anthropic/v1/messages
即:按官方 Base URL 表配置,SDK 拼出来的是 /v1/messages,与 curl 示例一致,与端点表不一致。两个都出自同一份官方文档,我没法判断哪个会在你的 DMR 版本上生效,所以这里的建议是照着 curl 示例配,并在上线前用一次真实请求确认,而不是照着端点表配。
Troubleshooting:4 个真实报错
下面 4 个报错都是我本机跑出来的原始输出,不是「典型错误示例」。前两个在连接层,后两个在配置层。
报错一:容器内写 localhost:12434,连不上
openai.APIConnectionError: Connection error.
cause: httpx.ConnectError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。
原因:localhost 在容器里指容器自己,模型跑在宿主机上,容器里的 12434 端口没有任何东西在监听。这是把「宿主进程」那一行地址用在了「容器内」场景。
修法:Docker Desktop 换成 http://model-runner.docker.internal/engines/v1,Docker Engine 换成 http://172.17.0.1:12434/engines/v1(或加 extra_hosts 后用 http://model-runner.docker.internal:12434/engines/v1)。
错误码是平台相关的:上面 [WinError 10061] 是 Windows 实测输出,Linux 上是另一套 errno 文案,但错误类型同样是 httpx.ConnectError。别照抄错误码去搜,要认 ConnectError 这个类型。
报错二:主机名压根解析不了
openai.APIConnectionError: Connection error.
cause: httpx.ConnectError: [Errno 11001] getaddrinfo failed
原因:model-runner.docker.internal 这个内网域名由 Docker Desktop 提供。在纯 Docker Engine 环境、或者容器不在 Compose 项目网络里时,DNS 里没有这条记录。官方对 172.17.0.1 的说明里也提到容器可能默认访问不到,需要 extra_hosts。
修法:在服务的 extra_hosts 里显式声明。
services:
app:
image: my-app
extra_hosts:
- "model-runner.docker.internal:host-gateway"
environment:
- AI_MODEL_URL=http://model-runner.docker.internal:12434/engines/v1
判据很简单:先跑 docker compose exec app getent hosts model-runner.docker.internal,解析不出来就是这一层的问题,别去改应用代码。
报错三:路径少了 /engines/v1
这个不会抛异常,只会返回 404。用上一节的脚本抓下来是这样:
http://model-runner.docker.internal/chat/completions # 错
http://model-runner.docker.internal/engines/v1/chat/completions # 对
原因:官方 api-reference 给容器的 Base URL 那一栏不带 /engines/v1,而 OpenAI SDK 只会在 base_url 后面追加 /chat/completions,不会帮你补中间那段。
修法:要么把 base_url 写成 http://model-runner.docker.internal/engines/v1,要么直接用 Compose 注入的变量,并确认注入值里带 /engines/v1(跑一次 docker compose exec app env 看一眼,比猜快)。
报错四:两个模型的注入变量重名
没有报错信息,只会发现模型 A 的请求被模型 B 应答。复算结果:
my-model + my_model 交集: {'MY_MODEL_MODEL', 'MY_MODEL_URL'}
原因:短语法的规范化规则是 upper() 再把 - 换成 _,my-model 与 my_model 归一化后完全相同。
修法:全部改用长语法。
services:
app:
image: my-app
models:
my-model:
endpoint_var: MY_MODEL_A_URL
model_var: MY_MODEL_A_NAME
my_model:
endpoint_var: MY_MODEL_B_URL
model_var: MY_MODEL_B_NAME
如果已经踩了,跑 docker compose exec app env 看看有几个 _URL 变量、值分别是多少,一眼就能看出来。
一份能跑的最小配置
把上面的结论收进一份文件,长这样:
services:
app:
image: my-app
models:
llm:
endpoint_var: AI_MODEL_URL
model_var: AI_MODEL_NAME
extra_hosts:
- "model-runner.docker.internal:host-gateway"
environment:
- AI_MODEL_URL=http://model-runner.docker.internal:12434/engines/v1
models:
llm:
model: ai/smollm2:360M-Q4_K_M
context_size: 4096
几个说明:ai/smollm2:360M-Q4_K_M 是官方示例里出现的带标签写法,360M 参数量约 3.6 亿,属于能在 CPU 上跑起来的量级;context_size 按官方提醒取满足需求的最小值;extra_hosts 只在 Docker Engine 上必需,Docker Desktop 上留着也不影响。
应用侧的读法:
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["AI_MODEL_URL"],
api_key="not-needed", # 官方:DMR 忽略 Authorization 头
)
resp = client.chat.completions.create(
model=os.environ["AI_MODEL_NAME"],
messages=[{"role": "user", "content": "hi"}],
)
api_key 那一行不是占位符。官方在「与 OpenAI API 的差异」里明确写了 API key 不需要、DMR 忽略 Authorization 头。这也意味着任何能访问到该端点的进程都能无凭证调你的模型,把 12434 暴露到不可信网络之前先想清楚。
什么时候不值得上这套机制
三个前提有一个不满足时,别硬上:Compose 版本到不了 2.38.0、部署目标不是支持 Compose models 的平台、或者你的模型本来就在远端 API(那直接用官方 SDK 配远端 base_url 更简单)。
还有一点要说清楚:models 顶层元素解决的是声明与连接,不是性能。上下文开多大、用哪个推理后端、要不要 GPU,这些仍然要在 DMR 那一侧配。Compose 只是让「我的服务依赖这个模型」这件事从口头约定变成文件里的一行。
相关阅读
- Docker Model Runner 架构解剖:三引擎对比与容器访问地址口径
- Docker Model Runner 接 Open WebUI 老是连不上:4 个官方口径对照
- vLLM 0.31.0 KV Cache 解剖:分块分配实测与 4 个真实报错
本文所有数字与报错均来自本机实测或官方文档原文,官方文档口径不一致的地方已在正文标出,未做取舍。版本与地址均标注「截至发稿」,请以官方页面为准。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):