架构
HybridInference 是一个 FastAPI 网关,对外提供 OpenAI/OpenRouter 的 HTTP API,并把每个请求分发给若干可互换上游中的一个:本地推理服务器(vLLM、SGLang、Ollama,或任何其他 OpenAI 兼容的服务),或者某个托管 API。网关的意义在于,客户端点名的模型 id 与真正为它服务的端点是解耦的,因此同一个客户端调用可以在你自有的机器和你租来的机器之间做负载均衡、故障转移、计价和记录日志。
后端是 apps/backend/ 下的一棵 Python 包树,分成两半:
目录 |
职责 |
|---|---|
|
HTTP 接口、认证与配额、请求 schema、provider adapter、存储、可观测性 |
|
路由表、router 策略、端点健康、熔断器、回退 |
两者都可以作为顶层包导入(serving.*、routing.*);导入根目录是 apps/backend,在 pyproject.toml 中声明。
四个层次
┌──────────────────────────────────────────┐
HTTP client ────▶│ Serving layer apps/backend/serving/ │
(OpenAI SDK, │ │
Anthropic SDK, │ middleware → auth/quota → model gate │
curl, IDE) │ servers/app.py, servers/auth.py, │
│ servers/routers/completions.py │
└────────────────────┬─────────────────────┘
│ model id + messages
▼
┌──────────────────────────────────────────┐
│ Routing layer apps/backend/routing/ │
│ │
│ per-model router → weighted selection │
│ circuit admission → automatic fallback │
│ routers.py, model_router_registry.py, │
│ endpoint_health.py │
└────────────────────┬─────────────────────┘
│ chosen adapter
▼
┌──────────────────────────────────────────┐
│ Adapter layer │
│ apps/backend/serving/adapters/ │
│ │
│ translate request/response, own the │
│ API key, normalize usage + errors │
└────────────────────┬─────────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────┐
│ Providers: local vLLM / SGLang / Ollama, │
│ OpenRouter, Anthropic, Gemini, any │
│ OpenAI-compatible service │
└──────────────────────────────────────────┘
Alongside every layer, both under apps/backend/serving/:
storage/ Postgres operational store + request log (api_logs)
observability/ structured logs, alert rules, Slack alerting
一个公开部署如何把流量送到网关——反向代理、CDN,以及控制台自己的路径重写——见边缘与控制台路由。本页的内容不依赖那套拓扑;网关就是一台普通的 HTTP 服务器。
请求生命周期
apps/backend/serving/servers/routers/completions.py 里的 chat-completions 路径是最典型的那一条;其他推理接口复用它的组成部分。
1. 中间件
apps/backend/serving/servers/app.py 中的 create_app() 注册了五个中间件。Starlette 把最后注册的跑在最外层,因此从外到内的实际顺序是:
RequestIdMiddleware——生成或沿用一个请求 id,每一行日志都带着它。FallbackErrorMiddleware——兜底的错误整形。RequestLogMiddleware——每个 HTTP 请求一条结构化日志记录。TimeoutMiddleware——普通请求用REQUEST_TIMEOUT_SECONDS(默认 120s);响应一旦被标记为流式,就改用STREAM_REQUEST_TIMEOUT_SECONDS(默认 3600s,<=0表示取消该上限)。CORSMiddleware.
中间件的顺序是承重的:流式响应是 StreamingResponse 对象,绝不能被缓冲。任何在转发之前读取响应体的新中间件,都会破坏 Server-Sent Events。
2. 认证与配额
apps/backend/serving/servers/auth.py 中的 verify_api_key 是挂在每个推理端点上的 FastAPI 依赖。它按以下顺序解析:
Agent grant token——一个独立的凭据命名空间,最先检查。grant 只能用在推理路径上;用在别处会得到
403 insufficient_scope。认证关闭——当
USER_AUTH_ENABLED为假值时,调用方被当作匿名管理员。这个开关是 fail-closed 的:除非显式关掉,否则认证始终开启。API key——key 以
hyi-<url-safe token>的形式签发,只以API_KEY_SECRET为密钥的 HMAC-SHA256 digest 形式存储。key 可以通过Authorization: Bearer …或X-API-Key传入。
同一个依赖还会执行调用方的每日消费配额;无论请求从哪扇门进来,都由同一个 payload 构造器(apps/backend/serving/quota.py)返回 429。另有一个 enforce_user_concurrency 依赖,在请求持续期间为每个用户占住一个在途槽位。
像 /v1/models 这样的只读端点改用 optional_verify_api_key:key 缺失或无效时按匿名处理而不是拒绝,key 只决定哪些模型可见。
3. 模型门禁
在联系任何 provider 之前,如果模型不在路由表里、未发布、要求调用方不具备的角色、对该用户被禁用,或者落在某个 grant 的范围之外,处理器就会以 404 拒绝请求。这五种情况故意返回同一个 "model not found" 响应体,这样调用方就无法靠 403 与 404 的差异,枚举出自己无权访问的模型。
4. router 选择
每个模型通过 ModelRouterRegistry(apps/backend/routing/model_router_registry.py)解析到一个 router 实例;它从模型注册表读取该模型的 router: / router_params: 字段,并为每个模型 id 缓存一个 router。没有写 router: 的模型使用路由配置中的 default_router 值。
接着 router 为这次请求挑选一个 adapter(FixedRouter._select_adapter):
provider 被管理员禁用的路由权重为 0,会被跳过;
输入模态无法接收该请求所带媒体的路由被排除;
熔断器处于打开状态的路由不予准入——如果这样一条都不剩,请求以
AllCircuitsOpenError失败;在幸存的路由中按权重随机选择,并有意避开已经被 prefill 工作压满的端点;
一条按调用方划分的短期亲和性(5 分钟)会把一段对话重新钉回已经持有它前缀缓存的端点,除非那个端点已经积压了任务。
管理员可以用 X-Route-Pin 请求头点名一个 provider 标签或 endpoint_id,从而完全跳过选择。被钉住的请求永远不会回退——悄悄换一个端点会让钉选失去意义。
5. 分发、回退与熔断器
FixedRouter.chat_completion / stream_chat_completion 调用选中的 adapter。成功时该端点被记为健康,响应里带上一个内部的 _routing 块(provider、base URL、endpoint_id)。
失败时该端点记下一次失败,并且——除非调用方钉住了某个 provider——router 会按路由顺序遍历该模型剩下的路由分支,跳过被禁用的、模态不兼容的、以及熔断器打开的分支,逐个尝试。每次尝试都会追加进一个 failed_attempts 列表,这个列表随最终的响应或错误一起传出,因此请求日志会把失败归因到真实的上游,而不是归给 router。如果每条分支都失败,则重新抛出最初那个错误。
熔断器状态按 endpoint_id 保存在 apps/backend/routing/endpoint_health.py:
参数 |
环境变量 |
默认值 |
|---|---|---|
连续多少次失败会打开熔断器 |
|
3 |
熔断器打开后,等待多少秒才做一次半开探测 |
|
30 |
可用率下限 |
|
0.7 |
可用率估计的 EWMA 平滑系数 |
|
0.1 |
客户端错误不会触发熔断:除 408、429、401、407 以外的 4xx 是调用方自己的问题,把它计入会让一个畸形请求就把一个端点从所有人手里夺走。408 和 429 表示上游过载,计入;401 和 407 是对网关自己的凭据的明确拒绝,也计入,因为没有哪个用户能修好它们。
另外,RoutingManager 可以启动一个 HealthMonitor,按 health_check 的间隔轮询路由配置中列出的本地端点的 /health 路径。它只探测本地部署——远程 provider 不提供这个路径,探测只会让它们平白被标记为不健康。
6. 日志
响应产生之后,CompletionsLogger(apps/backend/serving/servers/routers/completions_logging.py)会安排两个发后不理的副作用:往 api_logs 写一行,以及把一个 RoutingObservation 交回给 router。FixedRouter 会忽略这些观测;在线学习类的 router 用它们更新自己的代价模型。
路由引擎详解
router 与策略
apps/backend/routing/strategies/ 下正好有三个模块,而它们并不是同一类东西:
模块 |
它是什么 |
|---|---|
|
注册 |
|
注册 |
|
根本不是按模型的 router。 |
router: 只接受 fixed 和 routewise 这两个值。选一个未注册的名字会让配置校验失败,并在错误消息里列出已知的策略。每个策略都声明了一个 extra="forbid" 的 Pydantic 参数模型,因此 router_params: 里的拼写错误会在启动时失败,而不是悄悄退回默认值。
apps/backend/routing/executor.py 是一个向后兼容的 shim,把 FixedRouter 以它的旧名字 RouteExecutor 重新导出。请改为编辑 apps/backend/routing/routers.py。
两层「策略」
这是最常把新人绕晕的地方:
routing config default_router: fixed
│ (+ local_deployment / remote_deployment pools)
▼
RoutingManager ──uses──▶ FixedRatioStrategy → rewrites per-adapter WEIGHTS
(routing/manager.py) (strategies/weight.py) for models whose endpoints
appear in those pools
model registry router: fixed | routewise
│ router_params: {...}
▼
ModelRouterRegistry ──▶ build_router() → chooses WHICH ROUTER runs
(model_router_registry.py) for one model's requests
只有当生效的策略是 fixed 时,RoutingManager 才会重写权重;否则它什么都不碰就返回。模型注册表里声明的逐路由权重本身已经编码了本地/远程的切分,所以一个没有在路由配置里列出端点池的部署,就直接保留它声明的权重。
provider 与 endpoint_id
这两个标识符长得像,意思却不同。
provider 是路由上的一个标签。它默认取 adapter 的 kind,也可以在模型注册表里用逐路由的 provider: 覆盖。它是 api_logs.provider 记录下来的东西,是按 provider 分组的仪表盘的分组依据,也是管理员禁用开关作用的对象。两台本地 vLLM 机器可以带不同的 provider 标签,好让它们的流量留在不同的分组里。一条路由不能借用 _make_adapter 已经从某个 kind 派生出来的标签——apps/backend/serving/servers/registry.py 中的 RESERVED_PROVIDER_LABELS 会拒绝这些标签。
endpoint_id 是某个模型的某个端点的唯一键,由 _make_provider_id 铸造为 {model_id}:{location}:
_LOCAL_HOSTS中的主机(localhost、127.0.0.1、0.0.0.0、host.docker.internal)得到local-<port>,没有端口时是local;否则位置由 kind 或主机名派生,例如
<model>:openrouter-api。
延迟画像、可用性追踪和熔断器状态全都以 endpoint_id 为键,这也是为什么两条指向同一个 base URL、但钉住不同上游的路由分支,仍然各自拥有独立的熔断器。
后缀不是所有权信号。一台网关自有、跑在局域网地址上的服务器,会像任何远程服务一样按主机名打标;而管理员提供的 route_id 会原样成为 endpoint_id。不要从这个字符串去推断「这台机器是不是我的」。
Adapters
adapter 是知道如何与某一个 provider 对话的对象:它构造 URL 和请求头,持有 API key(或一池轮换使用的 key),翻译请求体与响应,归一化用量记账,并以 router 能理解的形状抛出错误。adapter 位于 apps/backend/serving/adapters/,由 apps/backend/serving/servers/registry.py 中的 _make_adapter 从模型注册表构造。
模块 |
类 |
覆盖范围 |
|---|---|---|
|
|
所有 OpenAI 兼容的服务,包括本地的 vLLM、SGLang 和 Ollama。本地推理服务器没有专用 adapter |
|
|
OpenRouter;见通过 OpenRouter 路由 |
|
|
直连 Anthropic Messages API |
|
|
通过 Google Vertex 提供的 Claude |
|
|
Gemini API |
|
|
以编码工具身份作为准入条件的 OpenAI 兼容 provider |
_make_adapter 把一条路由的 kind: 映射到上面这些当中的一个,并预置 provider 特有的配置——一份用量画像、一条非标准的 chat 路径,或者能否安全地发送 stream_options: {include_usage: true}。接入一个本身就 OpenAI 兼容的 provider,通常意味着在这里加一个 kind,而不是写一个新类;见添加新模型。
key 轮换是 adapter 的事。当一条路由声明了 api_keys:(复数)时,OpenAICompatAdapter 会从一个池子里取 key:某个 key 碰到与 key 相关的或临时性的失败(429、401/402/403、408/425、5xx、超时)就会被静默五分钟,请求转向下一个 key。像 400 和 422 这类与请求本身相关的错误在每个 key 上都会失败,因此立即向上传播,而不是把整个池子烧掉。补全的 POST 绝不会用同一个 key 重试——重发一次非幂等的生成会让它被计费两次。韧性来自 router 的回退链,而不是盲目重试。
配置
三个 YAML 文件描述一个部署:模型注册表、路由配置和告警配置。它们并不在固定路径上。apps/backend/serving/config/distribution.py 中的 resolve_config_path() 按三步优先级逐个解析它们:
显式的环境变量——
MODELS_CONFIG_PATH、ROUTING_CONFIG_PATH、ALERTS_CONFIG_PATH。总是优先。distribution manifest——一个带版本的 YAML 文件,写明站点身份和各配置文件的位置,由
DISTRIBUTION_CONFIG_PATH指向。manifest 里的相对paths:相对于 manifest 自身所在的目录解析,因此distributions/<name>/下的部署 overlay 是自包含的。内置默认值——
config/examples/models.openrouter.yaml和config/examples/routing.minimal.yaml。一份全新的 clone 在没有环境变量、没有 overlay 时拿到的就是这个:给一个OPENROUTER_API_KEY,网关就能提供一份可用的模型目录。告警的默认路径是config/alerts.yaml——本仓库刻意不提供这个文件;没有告警文件时,采用内置阈值。
manifest 是可选启用的,默认只做一次空跑。设置了 DISTRIBUTION_CONFIG_PATH 但没设 DISTRIBUTION_CONFIG_MODE 时,模式是 dark:manifest 会被加载、校验,并与实际生效的路径做比对——按文件记录一份 digest 比对——而解析结果保持不变。设置 DISTRIBUTION_CONFIG_MODE=active 会让 manifest 里的路径真正生效;在该模式下,加载失败的 manifest 会让进程直接停下,而不是悄悄提供一份与所声明的部署不同的注册表。
这三份 YAML 文件都支持环境变量插值:${VAR} 和 ${VAR:-default}。一条路由的 api_key、api_keys 或 base_url 如果展开成空,要么被跳过(如果该路由标记为可选),要么导致启动失败——绝不会被注册成一个死端点。
存储
持久化由 apps/backend/serving/storage/base.py 中的两个抽象基类定义:
OperationalStore——账户、API key、角色、配额、管理员管理的 provider 与路由、运行时设置。LogStore——请求日志。
Postgres 两个都实现了(postgres_operational.py、postgres_log.py)。CachedOperationalStore 给操作型存储包了一层进程内缓存,因为每一个请求的认证都要查它。
请求日志表 api_logs 及其小时级汇总只在一个地方定义——apps/backend/serving/storage/log_schema.py,两条建表的代码路径都套用它。值得知道的列有:request_id、model_id、provider、served_model_id / served_endpoint_id(真正作答的是哪个端点,区别于客户端点名的那个)、ttft_ms 和 latency_ms、各项 token 计数、cost_usd(按模型自身定价向调用方计费的金额)和 upstream_cost_usd(上游报告的金额,如果它报告的话)。
表结构迁移会先读系统目录(catalog),只发出确实缺失的那部分 DDL,并且在有上限的锁等待下执行——一条排在长查询后面的 ALTER TABLE,会把排在它后面的每一个读者都挡住。见数据库。
网关在没有数据库时也能启动。此时 /health 报告 database_connected: false,请求日志和账户功能不可用,但路由和补全照常工作——这正是 router 教程的第一阶段完全不需要 Postgres 就能跑起来的原因。
可观测性
没有 Prometheus exporter,它已经被移除。受支持的观测面是:
结构化日志。
RequestLogMiddleware为每个 HTTP 请求发出一条记录;apps/backend/serving/utils/logging.py负责整形,并在LOG_LEVEL=DEBUG之外抑制健康探测路径带来的噪声。请求日志。
api_logs是持久的记录,也是管理员仪表盘和用量报表的数据来源。健康端点。
/health是存活检查:当某个已配置的存储挂掉、但流量仍能被服务时,它返回 200 并带status: "degraded";只有当每一个已配置的存储都不可达时才返回 503。/health/ready是严格版本:除非配置的一切都正常,否则返回 503。/health/deep额外给出每个端点的可用性和熔断器状态,只要有任何一个端点处于降级状态,它就报告degraded。告警。
apps/backend/serving/observability/alerts.py中的alert_slack()和alert_on_transition()会推送到 Slack webhook(SLACK_ALERTS_WEBHOOK_URL),并在设置了CODEX_ONCALL_RELAY_URL/CODEX_ONCALL_RELAY_TOKEN时推送到值班中继。阈值来自由同一条resolve_config_path()链解析出来的告警配置;没有告警文件时使用内置阈值。熔断器会在circuit_open状态转换时、以及上游凭据被拒时呼叫值班,并按端点设置冷却时间,让持续存在的故障以固定节奏重复呼叫,而不是刷屏。
HTTP 接口
面向客户端的分组,全部由同一个应用提供:
分组 |
路径 |
认证 |
|---|---|---|
OpenAI 兼容推理 |
|
API key |
Anthropic 兼容推理 |
|
API key |
模型目录 |
|
可选——key 只会放宽列出的范围 |
健康检查 |
|
无 |
路由信息披露 |
|
在当前 commit 上没有 |
管理员 |
|
管理员 |
账户与控制台 API |
|
混合 |
警告
在当前 commit 上,GET /routing 和 GET /admin/routing 没有挂任何认证依赖。两者都会返回每条路由的 provider 标签、上游 base URL 和流量权重;/admin/routing 还额外包含未发布的路由。如果你的网关能从公网访问,除非你有意公开自己的上游拓扑,否则请在反向代理上封掉这两个路径。
/v1/models 会随客户端变形:Anthropic 系的客户端调用它,会拿到 Anthropic 的列表响应;而 /models 和 /openrouter/models 始终返回 OpenAI/OpenRouter 的形状。
部署形态
仓库在 deploy/docker/ 下提供 Dockerfile 和 Compose 文件:Dockerfile.backend(网关)、Dockerfile.frontend(apps/frontend/ 里的 Next.js 控制台)、Dockerfile.rocm(AMD GPU 变体)和 docker-compose.yml。systemd 单元在 deploy/systemd/。
控制台和 API 共享同一个 origin:apps/frontend/next.config.js 里的 Next.js rewrites 把 /v1、/anthropic、/auth、/user、/admin、/health 和 /site-config 代理到后端,因此浏览器会话和 API key 访问的是同一台主机上的同一批路径。这些 rewrites——而不是某份反向代理配置——才是公开的路径表;见边缘与控制台路由。前端有自己的工具链和质量门禁,与 Python 的 make 目标相互独立。
设计原则
一个模型 id,多个端点。客户端点名一个模型;由网关掌握哪台机器来服务它。其余的一切都由此推导而来。
故障是被绕过去的,不是往里重试的。非幂等的生成绝不会重发到同一个端点;韧性来自回退链和熔断器。
调用方不会知道任何它无权知道的东西。访问失败一律收敛成统一的
404,上游错误在到达客户端之前会被清洗。配置属于部署,不属于项目。仓库提供可运行的示例;真实的部署通过上面那条解析链提供自己的注册表和路由文件。
扩展点是声明式的。新 provider:一个
kind;如果它的 API 不是 OpenAI 兼容的,再加一个 adapter。新的路由行为:在apps/backend/routing/strategies/下放一个自注册的模块。两者都不需要动请求处理器。