架构
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 里声明。
设计原则
理解了下面五条,大部分代码也就好懂了:
一个模型 id,多个端点。客户端只指定模型,交给哪台机器处理,由网关决定。其余设计都由此而来。
遇到故障就绕开,而不是反复重试。非幂等的生成请求绝不会用同一个 key 重发;容错靠的是轮换 key、回退链和熔断器。
调用方只能知道它有权知道的东西。访问失败一律返回同样的
404,上游错误在返回客户端之前会先脱敏。配置属于部署,不属于项目。仓库只提供可运行的示例;实际部署自带模型注册表和路由配置文件,网关怎么找到它们,见下文配置一节。
扩展点是声明式的。新增 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 把最后注册的跑在最外层,因此从外到内的实际顺序是:
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>,存储时只保存它的 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:
参数 |
环境变量 |
默认值 |
|---|---|---|
连续多少次失败会打开熔断器 |
|
3 |
熔断器打开后,等待多少秒才做一次半开探测 |
|
30 |
可用率下限 |
|
0.7 |
可用率估计的 EWMA 平滑系数 |
|
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/ 下正好有三个模块,而它们并不是同一类东西:
模块 |
它是什么 |
|---|---|
|
注册 |
|
注册 |
|
根本不是按模型选用的 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 兼容的服务,包括本地的 vLLM、SGLang 和 Ollama。本地推理服务器没有专用 adapter |
|
|
OpenRouter;见通过 OpenRouter 路由 |
|
|
直连 Anthropic Messages API |
|
|
通过 Google Vertex 提供的 Claude |
|
|
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 的回退链,而不是盲目重试。
流式超时
上游生成到一半不再发数据,套接字却还开着,这种故障网关必须自己发现。如果干等连接断开,只有更外层的某个环节放弃时才会知道出了问题;到那时,发往这个端点的所有流已经一起挂掉,而新请求还在不断派发过去。一个流式请求由四个计时器把关,它们的时长有意错开,保证最内层的最先触发:
计时器 |
默认值 |
覆盖范围 |
|---|---|---|
|
180s |
首帧之后,两个带数据 SSE 帧之间的间隔 |
|
240s |
|
|
未设置 |
从建连到首帧,即 prefill 阶段 |
|
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() 按三级优先顺序逐个确定它们的路径:
显式的环境变量——
MODELS_CONFIG_PATH、ROUTING_CONFIG_PATH、ALERTS_CONFIG_PATH。总是优先。发行版的 manifest——一个带版本的 YAML 文件,写明站点身份和各配置文件的位置,由
DISTRIBUTION_CONFIG_PATH指向。manifest 里paths:的相对路径以 manifest 所在目录为基准,所以distributions/<name>/下的部署 overlay 自成一体。内置默认值——
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 兼容推理 |
|
API key |
Anthropic 兼容推理 |
|
API key |
模型目录 |
|
可选——带上 key 只会让列出的模型变多 |
健康检查 |
|
无 |
公开的路由信息 |
|
无 |
管理员 |
|
管理员 |
账号与控制台 API |
|
视路径而定 |
警告
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 目标互不相干。