架构

HybridInference 是一个 FastAPI 网关,对外提供 OpenAI/OpenRouter 格式的 HTTP API,把每个请求转发给若干可互换的上游之一:本地推理服务器(vLLM、SGLang、Ollama,或其他任何兼容 OpenAI 的服务),或者托管 API。网关的核心在于:客户端请求的是模型 id,而实际处理请求的端点可以随时替换。所以同一个客户端调用,可以在你自己的机器和租来的机器之间做负载均衡、故障转移、计费和日志记录。

后端是 apps/backend/ 下的一组 Python 包,分成两部分:

目录

职责

apps/backend/serving/

HTTP 接口、认证与配额、请求 schema、provider adapter、存储、可观测性

apps/backend/routing/

路由表、router 策略、端点健康状态、熔断器、回退

两者都可以直接当作顶层包导入(serving.*、routing.*);导入根是 apps/backend,在 pyproject.toml 里声明。

设计原则

理解了下面五条,大部分代码也就好懂了:

  1. 一个模型 id,多个端点。客户端只指定模型,交给哪台机器处理,由网关决定。其余设计都由此而来。

  2. 遇到故障就绕开,而不是反复重试。非幂等的生成请求绝不会用同一个 key 重发;容错靠的是轮换 key、回退链和熔断器。

  3. 调用方只能知道它有权知道的东西。访问失败一律返回同样的 404,上游错误在返回客户端之前会先脱敏。

  4. 配置属于部署,不属于项目。仓库只提供可运行的示例;实际部署自带模型注册表和路由配置文件,网关怎么找到它们,见下文配置一节。

  5. 扩展点是声明式的。新增 provider:声明一个 kind,API 不兼容 OpenAI 的话再加一个 adapter。新增路由行为:在 apps/backend/routing/strategies/ 下放一个会自动注册的模块。两者都不用改请求处理代码。

四个层次

                    ┌──────────────────────────────────────────┐
   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 把最后注册的跑在最外层,因此从外到内的实际顺序是:

  1. RequestIdMiddleware——生成或沿用一个请求 id,每一行日志都带着它。

  2. FallbackErrorMiddleware——兜底,统一错误响应的格式。

  3. RequestLogMiddleware——每个 HTTP 请求一条结构化日志记录。

  4. TimeoutMiddleware——普通请求用 REQUEST_TIMEOUT_SECONDS(默认 120s);响应一旦被标记为流式,就改用 STREAM_REQUEST_TIMEOUT_SECONDS(默认 3600s,<=0 表示取消该上限)。

  5. 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>,存储时只保存它的 HMAC-SHA256 摘要,密钥是 API_KEY_SECRET。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,完全跳过选择。pin 住的请求永远不会回退:悄悄换了端点,pin 住也就没有意义了。

5. 分发、回退与熔断器

随后 router 通过一层薄包装调用选中的 adapter。这层包装直接持有 router 选中的那个 adapter,所以即使管理员在此期间改了配置,也没法把在途的请求改发到别处。见路由内部机制。

成功时该端点被记为健康,响应里带上一个内部的 _routing 块(provider、base URL、endpoint_id)。

失败时,端点记一次失败。只要调用方没有 pin 住 provider,router 就会按路由顺序逐个尝试该模型剩下的路由分支,跳过已禁用、模态不兼容或熔断器已打开的分支。每次尝试都会追加到 failed_attempts 列表里,这个列表随最终的响应或错误一起返回,所以请求日志会把失败算到真实的上游头上,而不是算到 router 头上。如果所有分支都失败,重新抛出的是首选路由的错误。

熔断器状态按 endpoint_id 分别维护,代码在 apps/backend/routing/endpoint_health.py:

参数

环境变量

默认值

连续多少次失败会打开熔断器

CIRCUIT_FAILURE_THRESHOLD

3

熔断器打开后,等待多少秒才做一次半开探测

CIRCUIT_COOLDOWN_SECONDS

30

可用率下限

CIRCUIT_MIN_AVAILABILITY

0.7

可用率估计的 EWMA 平滑系数

ROUTER_HEALTH_EWMA_ALPHA

0.1

客户端错误不会触发熔断:除 408、429、401、407 之外的 4xx 都是调用方自己的问题,如果计入,一个格式错误的请求就能让端点对所有人都不可用。408 和 429 说明上游过载,要计入;401 和 407 明确表示网关自己的凭据被拒,也要计入,因为用户没法修复它们。

另外,路由配置文件可以开启健康探测,定期轮询文件里列出的本地端点的 /health 路径。探测只报告结果,从不改变路由。见路由配置文件。

6. 日志

响应生成后,CompletionsLogger(apps/backend/serving/servers/routers/completions_logging.py)会发起两个后台任务,不等待结果:往 api_logs 写一行,并把一个 RoutingObservation 交回给 router。FixedRouter 会忽略这些观测;在线学习型的 router 用它们更新自己的成本模型。

路由引擎详解

各个 router 的示意图,以及它们怎样执行请求,见路由内部机制。

router 与策略

apps/backend/routing/strategies/ 下正好有三个模块,而它们并不是同一类东西:

模块

它是什么

fixed.py

注册 fixed 策略:带自动回退的按权重随机选择,由 apps/backend/routing/routers.py 中的 FixedRouter 实现

routewise.py

注册 routewise 策略:一个考虑成本的 router,会从 RoutingObservation 反馈中学习,实现在 apps/backend/routing/routewise/ 下

weight.py

根本不是按模型选用的 router。FixedRatioStrategy 在本地和远程两组端点之间分配一份权重预算;用它的是 RoutingManager,不是 router 注册表

router: 只接受 fixed 和 routewise 这两个值。写了未注册的名字,配置校验会失败,错误消息里会列出已知的策略。每个策略都声明了一个 extra="forbid" 的 Pydantic 参数模型,所以 router_params: 里有拼写错误时,启动就会报错,而不是悄悄退回默认值。

apps/backend/routing/executor.py 只是一个向后兼容的 shim,用旧名字 RouteExecutor 重新导出 FixedRouter。改代码请直接改 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 才会重写权重;否则直接返回,什么都不动。模型注册表里给每条路由声明的权重本身已经体现了本地/远程的划分,所以部署如果没有在路由配置里列出端点池,就直接沿用声明的权重。有数据库的网关根本不用重写后的权重;见路由配置文件。

执行与组合

每个 router 都通过一层叫作叶子(leaf)的薄包装来执行它选中的端点。另外还有一个实验性的组合 HybridRouter,可以把一个模型的路由分成本地池和云端池,并在两者之间统筹规划;除非模型显式开启,否则它不生效。路由内部机制介绍了这两者,并附有示意图。

provider 与 endpoint_id

这两个标识符长得像,意思却不同。

provider 是路由上的一个标签。它默认取 adapter 的 kind,也可以在模型注册表里用每条路由的 provider: 覆盖。api_logs.provider 记录的就是它,按 provider 统计的仪表盘按它分组,管理员的禁用开关也作用在它上面。两台本地 vLLM 机器可以用不同的 provider 标签,让它们的流量分开统计。路由不能使用与某个 adapter kind 同名的标签,后端扩展新增的 kind 也算在内。

endpoint_id 是某个模型的某个端点的唯一键,写作 {model_id}:{location}:

  • 主机为 localhost、127.0.0.1、0.0.0.0 或 host.docker.internal 时,得到 local-<port>,没有端口时是 local;

  • 否则位置由 kind 或主机名派生,例如 <model>:openrouter-api。

延迟画像、可用性追踪和熔断器状态都以 endpoint_id 为键。所以两条路由分支即使指向同一个 base URL,只要 pin 住的上游不同,熔断器也是各自独立的。

后缀说明不了机器归谁。网关自己的服务器如果用的是局域网地址,也会像远程服务一样按主机名生成后缀;管理员提供的 route_id 则会原样用作 endpoint_id。不要靠这个字符串判断「这台机器是不是我的」。

adapter

adapter 负责和某一个 provider 通信:构造 URL 和请求头,持有 API key(或一组轮换使用的 key),转换请求体和响应,统一用量统计,并以 router 能理解的格式抛出错误。adapter 放在 apps/backend/serving/adapters/ 下,由 apps/backend/serving/servers/registry.py 里的 _make_adapter 根据模型注册表构造。

模块

类

覆盖范围

openai_compat.py

OpenAICompatAdapter

所有 OpenAI 兼容的服务,包括本地的 vLLM、SGLang 和 Ollama。本地推理服务器没有专用 adapter

openrouter.py

OpenRouterAdapter

OpenRouter;见通过 OpenRouter 路由

anthropic.py

AnthropicAdapter

直连 Anthropic Messages API

claude.py

ClaudeAdapter

通过 Google Vertex 提供的 Claude

gemini.py

GeminiAdapter

Gemini API

_make_adapter 先查工厂表,表里的工厂由显式启用的后端扩展注册。没有注册的工厂时,它把路由的 kind: 映射到某个内置 adapter,并预填该 provider 特有的配置:用量 profile、非标准的 chat 路径,或者能否放心发送 stream_options: {include_usage: true}。扩展工厂则在这些默认值填入之前就拿到配置字典。接入一个本来就兼容 OpenAI 的 provider,通常只需选一个 profile,不必写新类;见添加新模型。

key 轮换由 adapter 负责。路由声明了 api_keys:(复数)时,OpenAICompatAdapter 从一个 key 池里取 key:某个 key 遇到与 key 相关的失败或临时性失败(429、401/402/403、408/425、5xx、超时)时,请求会转给下一个 key。先轮换,后静默:只要还有没试过的 key,出错的 key 就仍留在池里;只有请求已经无 key 可换时,最后出错的那个 key 才会被静默 20 秒。400、422 这类由请求本身导致的错误,换哪个 key 都会失败,所以直接向上抛出,不会白白耗尽整个池。补全的 POST 请求绝不会用同一个 key 重试,因为重发非幂等的生成会被重复计费。容错靠的是 router 的回退链,而不是盲目重试。

流式超时

上游生成到一半不再发数据,套接字却还开着,这种故障网关必须自己发现。如果干等连接断开,只有更外层的某个环节放弃时才会知道出了问题;到那时,发往这个端点的所有流已经一起挂掉,而新请求还在不断派发过去。一个流式请求由四个计时器把关,它们的时长有意错开,保证最内层的最先触发:

计时器

默认值

覆盖范围

STREAM_IDLE_TIMEOUT_SECONDS

180s

首帧之后,两个带数据 SSE 帧之间的间隔

STREAM_MAX_IDLE_S

240s

/v1/messages 上的同一间隔,按转发出去的帧计算

STREAM_FIRST_BYTE_TIMEOUT_SECONDS

未设置

从建连到首帧,即 prefill 阶段

STREAM_REQUEST_TIMEOUT_SECONDS

3600s

整个流式响应

首字节超时和帧间超时是有意分开的。套接字层的读超时(sock_read)每读一次就重新计时,给漫长的 prefill 留足时间,也就等于给卡住的后端留了同样多的时间。prefill 本来就慢——在自建服务器上,100 万 token 的 prompt 可能要两分多钟才出第一个 token——而之后帧与帧的间隔只有几毫秒。所以只有帧间超时有默认值;两者都可以设为非正数来关闭。/v1/messages 有自己更宽松的上限,因为它数的是网关转发出去的帧:缓冲中的 XML 工具调用可能把一帧压住一分多钟,而上游其实一直没闲着。

帧间超时触发时,OpenAICompatAdapter 抛出 UpstreamStreamIdleError。它不同于响应体提前结束时的 _INCOMPLETE_STREAM_ERROR,后者是部署代理自己的读超时在网关这边的表现。前者以 stream_exception 的形式向上传播,于是端点的可用率下降、熔断器打开,而不是给残缺的回答补上一个伪造的 [DONE]。

配置

一个部署由三个 YAML 文件描述:模型注册表、路由配置和告警配置。它们的路径不是固定的。apps/backend/serving/config/distribution.py 里的 resolve_config_path() 按三级优先顺序逐个确定它们的路径:

  1. 显式的环境变量——MODELS_CONFIG_PATH、ROUTING_CONFIG_PATH、ALERTS_CONFIG_PATH。总是优先。

  2. 发行版的 manifest——一个带版本的 YAML 文件,写明站点身份和各配置文件的位置,由 DISTRIBUTION_CONFIG_PATH 指向。manifest 里 paths: 的相对路径以 manifest 所在目录为基准,所以 distributions/<name>/ 下的部署 overlay 自成一体。

  3. 内置默认值——config/examples/models.openrouter.yaml 和 config/examples/routing.minimal.yaml。新克隆的仓库既没设环境变量、也没有 overlay 时,用的就是它们:只要提供一个 OPENROUTER_API_KEY,网关就能提供一份可用的模型目录。告警配置的默认路径是 config/alerts.yaml,但本仓库不带这个文件;没有告警文件时,使用内置阈值。

manifest 需要主动启用,默认只做试运行。设置了 DISTRIBUTION_CONFIG_PATH 而没设 DISTRIBUTION_CONFIG_MODE 时,模式为 dark:网关会加载、校验 manifest,把它和实际生效的路径逐个文件比对摘要并写进日志,但实际使用的路径不变。设置 DISTRIBUTION_CONFIG_MODE=active 后,manifest 里的路径才真正生效;这种模式下如果 manifest 加载失败,网关宁可不加载任何模型,并记一条 CRITICAL 日志,也不会悄悄改用部署没有指定的注册表。

三份 YAML 文件都支持环境变量插值,但支持的写法不一样。模型注册表只展开整个值恰好是 ${VAR} 的情况(registry.py);路由配置和告警文件用的是递归的正则展开器,还能处理 ${VAR:-default} 和嵌在更长字符串里的变量(apps/backend/routing/config.py 里的 _expand_env_value)。如果路由的 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)。阈值取自告警配置,它的路径同样由 resolve_config_path() 这条链确定;没有告警文件时使用内置阈值。熔断器切换到 circuit_open、或者上游凭据被拒时,都会发告警;每个端点有各自的冷却时间,持续存在的故障会按固定间隔重复告警,而不会刷屏。

HTTP 接口

面向客户端的分组,全部由同一个应用提供:

分组

路径

认证

OpenAI 兼容推理

POST /v1/chat/completions, POST /v1/completions, POST /completion, POST /v1/embeddings, POST /v1/responses

API key

Anthropic 兼容推理

POST /v1/messages, POST /anthropic/v1/messages, …/count_tokens

API key

模型目录

GET /v1/models, GET /models, GET /openrouter/models, GET /anthropic/v1/models

可选——带上 key 只会让列出的模型变多

健康检查

GET /health, /health/ready, /health/deep

无

公开的路由信息

GET /routing

无

管理员

GET /admin/routing、GET /admin/stats 以及 /admin/* 下的其他路径

管理员

账号与控制台 API

/auth/*, /user/*, /site-config

视路径而定

警告

GET /routing 不需要认证,会返回每条已发布路由的 provider 标签、上游 base URL 和流量权重。如果你的网关能从公网访问,除非你有意公开自己的上游拓扑,否则请在反向代理上封掉这个路径。GET /admin/routing 还额外包含未发布的路由和路由管理器状态,但需要在 Authorization: Bearer ... 请求头中提供管理员的 JWT 或 ADMIN_TOKEN。

/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 目标互不相干。

要部署运行,见部署指南;要在本地开发,见安装;提交改动前,先读参与贡献。