添加新的本地模型

本指南说明如何在 HybridInference 网关后面注册一个自建模型。如果模型已经由本地的 OpenAI 兼容服务器提供服务——例如 vLLM、SGLang、Ollama,或你自己的 /v1/chat/completions 服务——就用这份指南。

远程 provider 或自定义 adapter 见添加新模型

概览

添加一个本地模型分三步:

  1. 启动本地推理服务器。

  2. 在模型注册表中添加一条指向该服务器的条目。

  3. 重启网关,并通过公开的 /v1 API 验证。

本地服务器必须暴露 OpenAI 兼容的端点。网关把对话请求转发到 /v1/chat/completions;当模型以 model_type: embedding 注册时,embedding 请求转发到 /v1/embeddings

模型注册表在哪里。本仓库里没有 config/models.yaml。后端在 apps/backend/serving/config/distribution.py 中按以下优先级解析注册表路径:MODELS_CONFIG_PATH → distribution manifest 的 paths.models(仅当 DISTRIBUTION_CONFIG_PATH 指定了一份 manifest DISTRIBUTION_CONFIG_MODE=active 时)→ 随仓库附带的 config/examples/models.openrouter.yaml。完整规则、以及自建者可选的两种布局,见模型注册表在哪里。下文说的「模型注册表」,指的就是这套解析最终选中的那个文件。

私有服务器(不接公网)

如果模型跑在另一台不对公网暴露的机器上,就让它保持私有,由网关通过可信的网络路径访问它。

把路由指向私有地址:

    route:
      - kind: openai_compat
        weight: 1.0
        base_url: "http://10.0.12.34:8000/v1"
        provider_model_id: "your-served-model-name"

或者用 SSH 反向隧道把端口转发到网关主机:

# Run this on the INTERNAL model host
ssh -N -R 8001:127.0.0.1:8000 <user>@<gateway-host>

这样路由的目标就是网关主机上的一个回环地址:

base_url: "http://127.0.0.1:8001/v1"  # resolved on the gateway host

使用反向隧道时,先在网关主机上验证,再去改网关配置:

curl http://127.0.0.1:8001/v1/models | jq

第 1 步:启动本地模型服务器

用你惯用的推理运行时启动模型。本指南后面把这台服务器注册为 kind: sglang,所以示例就起一个 sglang:

python -m sglang.launch_server \
  --model-path <hf-org>/<hf-model> \
  --host 0.0.0.0 \
  --port 8007 \
  --served-model-name my-local-model

vLLM 和 Ollama 的做法完全一样——vllm serve <hf-org>/<hf-model> --port 8007 就是等价的命令。三者都分发到同一个 OpenAICompatAdapter;你注册的 kind 决定的是指标标签和 provider profile,所以填你实际启动的那个运行时。

在动网关配置之前,先确认本地服务器有响应:

curl http://localhost:8007/v1/models | jq
curl -s -X POST http://localhost:8007/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "my-local-model",
    "messages": [{"role": "user", "content": "Hello"}],
    "max_tokens": 32
  }' | jq

本地推理服务器通常不需要 API key,所以上面这两个调用都没有带 Authorization 头。网关自己的 API 则需要——见第 5 步

如果网关跑在 Docker 里,注册表中要用 http://host.docker.internal:<port>,容器才能访问宿主机。如果网关直接跑在宿主机上,用 http://localhost:<port> 即可。

第 2 步:把模型加进注册表

models: 下新增一条条目。公开的 id 要短、要稳定,因为客户端会在 model 字段里发送它。

  - id: my-local-model
    name: My Local Model
    provider: sglang
    quantization: "unknown"
    input_modalities: ["text"]
    output_modalities: ["text"]
    context_length: 65536
    max_output_length: 8192
    supports_tools: true
    supports_structured_output: true
    supported_params: [temperature, top_p, max_tokens, stop, stream]
    aliases: ["My-Local-Model"]
    pricing:
      prompt: "0"
      completion: "0"
      image: "0"
      request: "0"
      input_cache_reads: "0"
      input_cache_writes: "0"
    route:
      - kind: sglang
        weight: 1.0
        base_url: "http://host.docker.internal:8007"
        provider_model_id: "my-local-model"
        pricing:
          prompt: "0"
          completion: "0"

这几个字段要小心填写:

  • id/v1/models 返回、客户端使用的公开模型 id。

  • provider:用作元数据的顶层 provider 标签;在没有给出 route: 列表时,它也是默认的 route[].kind。本地 OpenAI 兼容服务器用 vllmsglangollamaopenai_compat

  • route[].kind:网关使用的 adapter 类型。本地 OpenAI 兼容服务可以用 vllmsglangollamaopenai_compat

  • base_url:本地服务器的根地址。可以带 /v1,但不是必须。

  • provider_model_id:发给本地服务器的模型名。它必须与推理运行时的模型名一致(vLLM 的 --served-model-name、Ollama 的 tag,等等)。

  • aliases:可选的额外公开名称,解析到同一个网关模型。它们不能与另一个模型的 id 或别名冲突——重名会解析到最后加载的那个模型,后端会记一条警告。

  • supported_params:只填本地运行时接受的参数。

  • route[].provider:可选的分析标签覆盖——见在仪表盘中给路由命名

  • route[].provider_display_name:该标签可选的可读名称。

完整的字段参考(包括一条路由条目接受的全部内容)见添加新模型

在仪表盘中给路由命名

默认情况下,一条路由把自己的 kind 作为 provider 标签上报;api_logs.provider 记录的就是这个标签,所有按 provider 划分的管理视图也都以它分组:Token Usage、Provider Performance、Provider Observability、provider 禁用开关,以及 provider 注册表。因此两台都由 kind: vllm 提供服务的本地机器会落在同一行里,无法互相比较。

给每条路由各自的标签就能把它们拆开,还可以再给一个显示名:

    route:
      - kind: vllm
        weight: 1.0
        provider: local-a
        provider_display_name: "Local box A"
        base_url: ${LOCAL_A_URL}
        api_key: ${LOCAL_API_KEY}
      - kind: vllm
        weight: 1.0
        provider: local-b
        provider_display_name: "Local box B"
        base_url: ${LOCAL_B_URL}
        api_key: ${LOCAL_API_KEY}

仪表盘随后会把 Local box A · local-aLocal box B · local-b 显示为两个独立的 provider,各自有自己的错误率、缓存命中率、token 总量和启用/禁用开关。

变的只有分析标签。路由仍然与它的 kind 所选的上游通信,endpoint_id 仍由模型 id、该路由的 kind 和它的 base URL 一起推导(apps/backend/serving/servers/registry.py 里的 _make_provider_id),API key 仍按 kind 汇集在一起——所以一个 LOCAL_API_KEY 依然同时服务两台机器。

规则与注意事项:

  • 标签只能由小写字母、数字、短横线或下划线组成(最多 64 个字符),并且不能借用内置 provider 的名字(vllmzaiopenrouter 等)。借用会把这条路由的流量并进那个 provider 的配额统计和禁用开关。格式非法或占用保留名的标签会在加载注册表时抛错,于是后端起来时模型列表是不完整的,而不是悄悄给流量贴错标签。注意这条日志是 WARNING 级的 Failed to load models.yamlbootstrap.py,在一个很宽的 except Exception 里面)——按 error 去 grep 是找不到的,这一点和注册表缺失的情况不同,后者记在 ERROR 级。

  • provider_display_name 也可以单独使用:只想在仪表盘里给某个 provider 改名、而不拆分它时。

  • 标签会针对 Providers 标签页中创建的自定义 provider 预占这个 slug。如果同 slug 的自定义 provider 已经存在,那么自定义 provider 保留它的 key 和路由目标,冲突会在启动时记为一条 error——请给标签改名,否则两者会汇报到同一个 provider 名下。

  • 改名不会重写历史。已经以旧标签写入的数据行仍保留旧标签,因此两个标签会同时出现,直到旧数据从 provider_hourly_stats 中过期(30 天清理)——改名之后,新标签的图表会有一段空档。

  • 按模型的路由权重覆盖以 endpoint_id 为键,而不是标签,所以改名不会动到它们。

第 3 步:添加可选的远程回退

要实现自动回退,再加一条权重更低或相等的路由:

    route:
      - kind: sglang
        weight: 1.0
        base_url: "http://host.docker.internal:8007"
        provider_model_id: "my-local-model"
        pricing:
          prompt: "0"
          completion: "0"
      - kind: openrouter
        weight: 0
        base_url: https://openrouter.ai/api/v1
        api_key: ${OPENROUTER_API_KEY}
        provider_model_id: "<upstream-model-slug>"
        pricing:
          prompt: "0"
          completion: "0"

把回退路由的 weight 设为 0,可以让它保持配置但不被选中;设为大于 0 则允许按权重路由和故障切换。config/examples/models.openrouter.yaml 里就带了一条完全按这种方式写成、可直接使用的本地优先混合条目。

第 4 步:重启网关

重启 backend,让它重新加载注册表。使用仓库自带的 Docker Compose 时:

make restart s=backend

本地开发不用 Docker 时,直接启动:

PYTHONPATH=apps/backend \
  MODELS_CONFIG_PATH=/path/to/your/models.yaml \
  uv run uvicorn serving.servers.app:app --port 8080

启动时后端会记录 Registered N routes from <path>。这一行是确认它实际加载了哪个注册表文件最快的办法。

第 5 步:通过网关验证

列出已注册的模型。GET /v1/models 接受匿名请求——它解析 API key 只是为了决定要不要包含仅管理员可见的条目:

curl -s http://localhost:8080/v1/models | jq

POST /v1/chat/completions 会走 verify_api_key,所以没有有效的网关 API key 时它返回 401,除非后端以 USER_AUTH_ENABLED=false 运行。这里用的是你的网关签发的 key,不是本地服务器的 key:

export GATEWAY_API_KEY=<your gateway API key>

curl -s -X POST http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "my-local-model",
    "messages": [{"role": "user", "content": "Hello from the gateway"}],
    "max_tokens": 32
  }' | jq

测试流式:

curl -N -s -X POST http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "my-local-model",
    "messages": [{"role": "user", "content": "Stream one sentence"}],
    "stream": true,
    "max_tokens": 64
  }'

路由说明

存在路由配置文件时,RoutingManager 可以在模型注册之后调整路由权重。它的路径解析方式与注册表相同——先 ROUTING_CONFIG_PATH,再 manifest 的 paths.routing,最后 config/examples/routing.minimal.yaml。没有这个文件时,网关使用模型注册表里写的权重。见路由

在 sglang 路由上优先调度 decode

如果一条路由指向的服务器是带 sglang 的 --enable-priority-scheduling 启动的,就可以声明这一点,网关会在发往上游的请求体上逐请求打上 priority,让超大 prefill 排在交互流量之后,而不是之前:

    route:
      - kind: sglang
        weight: 1.0
        base_url: ${LOCAL_DEPLOYMENT_URL}
        api_keys:
          - ${LOCAL_API_KEY}
        priority_scheduling: true
      # A remote fallback must NOT set it — it is a fact about an sglang
      # server, not about the model.
      - kind: openrouter
        weight: 0.0
        base_url: https://openrouter.ai/api/v1
        api_key: ${OPENROUTER_API_KEY}

优先级由估算出的未命中缓存的 prefill 推导得出——即 prompt 大小减去该端点预计已缓存的前缀。三个档位的默认值是 interactive 20、large 15、elephant 0(apps/backend/routing/prefill_load.py),客户端不能自己设置:客户端请求体里的 priority 字段会被 validate_params 丢弃。/v1/chat/completions/v1/messages 都会打上它。使用 router: routewise 的模型则保留上游的默认优先级,因为该 router 没有 prefill 记账、算不出这个折扣——所以 priority_scheduling: true 在那里是不起作用的。

只在指向带该 flag 启动的服务器的路由上设置它。没带该 flag 的服务器会忽略这个字段,但严格校验请求体的远程 provider 不会。

在 sglang 路由上记录前缀缓存未命中

--enable-cache-report 启动的 sglang 服务器,对前缀缓存未命中的应答是 "prompt_tokens_details": null,而不是 {"cached_tokens": 0}。不加处理的话,网关会把这个 null 读成「这个 provider 对缓存只字未提」,于是往 api_logs.cache_read_tokens 里存 NULL——和一个根本无法上报的 provider 存的是同一个值,于是一次实测到的未命中,会从任何基于该列计算的命中率分母里消失。

路由可以声明自己的服务器确实会上报,从而把这个 null 变回它真正代表的 0:

    route:
      - kind: sglang
        weight: 1.0
        base_url: ${LOCAL_DEPLOYMENT_URL}
        api_keys:
          - ${LOCAL_API_KEY}
        null_cache_details_means_miss: true

设置之前先验证。这个 null 是有歧义的:没有 --enable-cache-report 的 sglang,以及没有 --enable-prompt-tokens-details 的 vLLM,对每一个请求都发同一个 null——命中未命中都一样(vllm-project/vllm#44377)。在那种服务器上声明这个 flag,等于把一个诚实的 NULL 换成一个编造出来的「实测未命中」,而后者比它替换掉的那个缺失值更难在事后被察觉。验证方法是对该端点发一次冷请求、再发一次同样 prompt 的热请求:

curl -s "$BASE_URL/chat/completions" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"'"$MODEL"'","messages":[{"role":"user","content":"<a long, freshly generated prompt>"}],"max_tokens":1}' | jq .usage

跑两次。只有当第二次返回 {"cached_tokens": N}N > 0、而第一次返回 null 时,这条路由才算合格。如果两次都返回 null,说明服务器并没有在上报——别开这个 flag。

有两条加载器规则,防止一个错误的声明悄悄通过:

  • 只能写在路由级。写在模型上(包括没有 route: 块的简写模型)是配置错误,因为这个声明说的是某一台服务器的启动参数,而继承会把它带到每一条回退路由上。

  • 只接受真正的 YAML 布尔值。带引号的 "false" 会被判为配置错误,而不是变成一次意外的开启——因为 bool("false") 的结果是 True

客户端看到什么取决于接口:非流式响应里带 cache_read_tokens: 0Usage 响应模型会丢掉其余字段),而流式的最后一个 chunk 还会带上 cached_tokens: 0prompt_tokens_details: {"cached_tokens": 0}/v1/messages 则把它报成 cache_read_input_tokens: 0

排障

模型没有出现在 /v1/models

  • 从启动日志的 Registered N routes from <path> 一行确认后端加载了哪个注册表。如果解析出的路径下找不到注册表,它会记一条 error 并指出该路径,/v1/models 为空,而且每个请求都会报模型不存在。

  • 检查 models: 下的 YAML 缩进。

  • 编辑注册表之后要重启后端。

  • 确认 idaliases 没有与其他模型冲突。

  • 一条路由中由 ${VAR} 提供的 api_keyapi_keysbase_url 若解析为空,整个模型都会被丢弃;日志会列出被跳过的模型和未设置的变量。把这条路由标记为 optional: true,可以只跳过该路由。

网关访问不到本地服务器

  • 在 Docker 里用 host.docker.internal 代替 localhost

  • 在裸机上用 localhost 或主机 IP。

  • 私有的远程服务器用私有 IP/主机名或私有隧道端点;不要暴露到公网。

  • 如果需要从容器里访问,确认本地服务器监听在 0.0.0.0,而不只是 127.0.0.1

  • 在与后端相同的环境里验证 curl <base_url>/v1/models 能通。

注册之后请求失败

  • 确认 provider_model_id 与本地运行时对外暴露的模型名一致。

  • 把本地运行时不支持的请求参数从 supported_params 里去掉。

  • 如果运行时的 base URL 已经以 /v1 结尾,就保持原样;adapter 会在该 base 之下追加 /chat/completions

  • 只有在本地运行时确实支持时,才设置 supports_toolssupports_structured_output

另请参阅

  • 添加新模型——完整的字段参考、adapter 类型,以及如何接入一个新的远程 provider

  • 快速开始——一个可直接运行的部署,最后会指向你自己的本地 vLLM/SGLang/Ollama 服务器

  • 路由——权重、健康检查与策略