添加新的本地模型
本指南说明如何在 HybridInference 网关后面注册一个自建模型。如果模型已经由本地的 OpenAI 兼容服务器提供服务——例如 vLLM、SGLang、Ollama,或你自己的 /v1/chat/completions 服务——就用这份指南。
远程 provider 或自定义 adapter 见添加新模型。
概览
添加一个本地模型分三步:
启动本地推理服务器。
在模型注册表中添加一条指向该服务器的条目。
重启网关,并通过公开的
/v1API 验证。
本地服务器必须暴露 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 兼容服务器用vllm、sglang、ollama或openai_compat。route[].kind:网关使用的 adapter 类型。本地 OpenAI 兼容服务可以用vllm、sglang、ollama或openai_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-a 和 Local 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 的名字(
vllm、zai、openrouter等)。借用会把这条路由的流量并进那个 provider 的配额统计和禁用开关。格式非法或占用保留名的标签会在加载注册表时抛错,于是后端起来时模型列表是不完整的,而不是悄悄给流量贴错标签。注意这条日志是 WARNING 级的Failed to load models.yaml(bootstrap.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: 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没有与其他模型冲突。一条路由中由
${VAR}提供的api_key、api_keys或base_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_tools和supports_structured_output。