添加新的本地模型
本指南介绍如何把自建模型注册到 HybridInference 网关后面。如果模型已经跑在本地的 OpenAI 兼容服务器上(例如 vLLM、SGLang、Ollama,或你自己实现的 /v1/chat/completions 服务),就看这篇。
远程 provider 或自定义 adapter 见添加新模型。
概览
添加一个本地模型分三步:
启动本地推理服务器。
在模型注册表中添加一条指向该服务器的条目。
重启网关,并通过公开的
/v1API 验证。
本地服务器必须暴露 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。