添加新的本地模型

本指南介绍如何把自建模型注册到 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。

下文所说的「你的模型注册表」,指网关从中加载模型的那个文件;具体是哪一个,见模型注册表在哪里。

私有服务器(不接公网)

如果模型跑在另一台不对公网开放的机器上,不要把它暴露出去,让网关通过可信的网络路径访问它。

把路由指向私有地址:

    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 对应的命令是 vllm serve <hf-org>/<hf-model> --port 8007 --served-model-name my-local-model。三者都由同一个 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 兼容服务器用 vllm、sglang、ollama 或 openai_compat。

  • route[].kind:网关使用的 adapter kind。本地 OpenAI 兼容服务可以用 vllm、sglang、ollama 或 openai_compat。

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

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

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

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

  • route[].provider:可选,覆盖统计用的 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-a 和 Local box B · local-b 显示为两个独立的 provider,各自有自己的错误率、缓存命中率、token 总量和启用/禁用开关。

变的只有标签。路由仍然使用它的 kind 选定的 adapter,连的也仍是它的 base_url 指向的服务器。它的 endpoint_id 保持不变,与之挂钩的熔断器、延迟历史和权重覆盖也都不变;API key 也仍按 kind 放在同一个池里,所以一个 LOCAL_API_KEY 照样能给两台机器用。

规则与注意事项:

  • 标签只能由小写字母、数字、短横线或下划线组成(最多 64 个字符),并且不能借用内置 provider 的名字(vllm、zai、openrouter 等)。借用的话,这条路由的流量会被算进那个 provider 的配额统计,也会受它的禁用开关控制。标签格式不对或用了保留名,注册表会在这个模型处停止加载,后端启动后只有排在它前面的模型——宁可少加载,也不悄悄把流量记到错误的标签下。请在日志里找 Failed to load models.yaml;这条日志是 WARNING 级别,不是 ERROR。

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

  • 标签会占用它的 slug,Providers 标签页里新建的自定义 provider 就不能再用这个 slug。如果已经存在同 slug 的自定义 provider,它会保留自己的 key 和路由目标,启动时这个冲突会记为一条 error——请给标签改名,否则两者会统计到同一个 provider 名下。

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

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

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

想要自动回退,就再加一条路由。回退路由只有在权重大于 0 时才会被尝试;给它一个很小的权重,比如 0.01,它就成了一条几乎不会被首选的回退路由:

    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.01
        base_url: https://openrouter.ai/api/v1
        api_key: ${OPENROUTER_API_KEY}
        provider_model_id: "<upstream-model-slug>"
        pricing:
          prompt: "0"
          completion: "0"

按这组权重,大约每一百个请求中有一个会先发往 OpenRouter,而在本地服务器上失败的请求会转到那里重试。权重为 0 的路由虽然仍在配置里,但永远不会被使用,连回退也不会用到它。config/examples/models.openrouter.yaml 里就有一个按这种方式写成、可以直接用的本地优先条目。

第 4 步:重启网关

重启后端,让它重新加载注册表。使用仓库自带的 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 --no-proxy-headers --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
  }'

路由说明

网关按模型注册表中的权重来分配一个模型的流量。路由配置文件可以在本地和远程路由之间调整权重,但只对没有数据库的网关有效;见路由配置文件和路由。

在 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.01
        base_url: https://openrouter.ai/api/v1
        api_key: ${OPENROUTER_API_KEY}

优先级根据估算的未命中缓存的 prefill 计算,即 prompt 大小减去该端点预计已缓存的前缀。三档的默认值分别是 interactive 20、large 15、elephant 0;客户端不能自己指定,请求体里的 priority 字段会被丢弃。/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,每个请求都会被记成缓存未命中,而这种错误比原来的缺失值更难在事后发现。验证方法是对该端点用同一个 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" 会被判为配置错误,因此它永远不会意外地把这个 flag 打开。

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

故障排查

模型没有出现在 /v1/models 中

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

  • 检查 models: 下的 YAML 缩进。

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

  • 确认 id 和 aliases 没有与其他模型冲突。

  • 如果某条路由的 api_key、api_keys 或 base_url 引用的 ${VAR} 解析为空,整个模型都会被丢弃;日志会列出被跳过的模型和未设置的变量。给这条路由加上 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 会直接在它后面追加 /chat/completions。

  • 只有在本地运行时确实支持时,才设置 supports_tools 和 supports_structured_output。

另见

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

  • 快速开始——搭一个能直接跑起来的部署,最后接上你自己的本地 vLLM/SGLang/Ollama 服务器

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