← 返回首页

Docker Model Runner 接入 Compose 实战

DockerDMRDocker 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-runnerDocker Desktop(Helm modelRunner.enabled: false)http://host.docker.internal:12434/engines/v1/
compose/bridge/usageDocker Desktop / Kuberneteshttp://host.docker.internal:12434/engines/v1/ 与 http://model-runner/engines/v1/
ai/model-runner/api-referenceDocker Desktop,从容器内访问http://model-runner.docker.internal
ai/model-runner/api-referenceDocker 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 只是让「我的服务依赖这个模型」这件事从口头约定变成文件里的一行。

相关阅读

本文所有数字与报错均来自本机实测或官方文档原文,官方文档口径不一致的地方已在正文标出,未做取舍。版本与地址均标注「截至发稿」,请以官方页面为准。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页