路由

路由正是 HybridInference 存在的意义。客户端只请求一个对外的模型 id,由网关决定实际由哪个上游端点来服务它:失败时改试另一个,对已经宕掉的端点停止发送流量,并把同一段对话留在前缀缓存已经预热的那个端点上。

本页介绍路由引擎本身、它的各个旋钮,以及如何添加你自己的路由策略。配置文件放在哪里、又是如何被找到的,见配置;模型条目及其 route: 列表的结构,见添加新模型

架构

三个部分,都在 apps/backend/routing/ 下:

代码

职责

部署级权重策略

manager.py + strategies/weight.py

读取路由文件,按本地/远端的分流比例重写每个模型各条路由的权重。可选。

按模型选择 router

model_router_registry.py + strategies/__init__.py

把每个模型 id 映射到一个 RouterProtocol 实现,由该模型的 router: 字段决定选哪个。

执行

routers.py (FixedRouter)

为每个请求选出一个 adapter 并派发,失败时回退,并记录端点健康状况。

apps/backend/routing/executor.py 是一层向后兼容的垫片,它把 FixedRouterRouteExecutor 之名重新导出,同时导出 AllCircuitsOpenErrorProviderPinErrorRouteConfig。不要改它——要改就改 routers.py

启动时的接线在 apps/backend/serving/servers/bootstrap.py:它把模型注册表里的路由注册进一个进程级的 FixedRouter,可选地应用 RoutingManager,再在同一张路由表之上构建 ModelRouterRegistry。所有已知模型的 router 都在启动时立即构造,因此错误的策略名或错误的 router_params: 会在启动时就报出来,而不是等到第一个请求。

为每个模型选择 router

路由文件里的 default_router: 指定那些自己没有选策略的模型所用的策略。注册表中的模型条目可以覆盖它:

models:
  - id: <model-id>
    provider: openai_compat
    router: fixed            # strategy name; omit to use default_router
    router_params:           # validated by that strategy's Pydantic model
      local_fraction: 0.6
    route:
      - kind: openai_compat
        weight: 0.7
        base_url: http://localhost:8000/v1
        provider_model_id: <served-model-name>
      - kind: openai_compat
        weight: 0.3
        base_url: https://api.your-provider.example/v1
        api_key: ${YOUR_PROVIDER_API_KEY}
        provider_model_id: <upstream-model-id>

开箱注册了两个策略:

  • fixed——带自动回退的加权随机选择。这是默认策略,也是唯一不需要额外依赖的策略。

  • routewise——一个成本感知的策略,实现在一个独立的包里;见下文 RouteWise

策略名来自 apps/backend/routing/strategies/__init__.py 中的注册表。未知的名字会在校验阶段抛错,错误消息里会带上已知策略的列表;router_params: 中的未知键同样会被拒绝,因为每个策略的参数模型都设置了 extra="forbid"

别名与规范模型共用同一个 router:ModelRouterRegistry 在查找或构建任何东西之前,先把别名解析成规范 id,因此一个有状态的 router 绝不会被拆到模型和它的别名两边。

FixedRouter 如何挑选端点

对每个请求,FixedRouter._select_adapter 按以下顺序收窄该模型的路由候选:

  1. 路由必须已发布且非空。否则就没有可用路由,请求返回 404。

  2. 模态过滤。携带非文本输入的请求只保留 input_modalities 能覆盖它的路由。如果一条都没有,就抛出 AllCircuitsOpenError,并在其中指明所需的模态。

  3. 显式钉选(pin)。在 /v1/chat/completions 上带 X-Route-Pin: <provider-or-endpoint-id> 会直接选中那条路由并关闭回退——这是给管理员用的调试手段,不是面向客户端的功能。钉不到任何路由时抛出 ProviderPinError

  4. 权重与熔断准入。权重为 0 的路由被剔除,熔断器(circuit breaker)处于打开状态的路由同样被剔除。如果一条都不剩,AllCircuitsOpenError 会列出它考虑过的端点。

  5. 会话亲和。该调用方与该模型上仍然有效的钉选优先,除非被钉选的端点已经积压(见 会话亲和)。

  6. 加权抽取。剩余权重重新归一化到总和为 1,然后抽出一条路由,抽取时会有意避开当前正忙于 prefill 的端点(见 prefill 感知的选路)。

回退

选中的 adapter 抛错时,FixedRouter 会把这次失败记到那个端点账上,丢弃调用方的亲和钉选,然后按声明顺序遍历该模型剩下的路由——跳过权重为 0 的路由、无法接受该请求模态的路由,以及熔断器已打开的路由。第一条成功的路由回答这个请求。如果每条路由都失败,则重新抛出那个错误,并附上完整的尝试列表。

有两个有意为之的例外:

  • 被钉选的请求永不回退。调用方指名了某一个端点,悄悄换掉会给出误导性的结果。

  • 流式响应一旦有字节送达客户端就不再回退。把第二个 provider 拼接进一条已经开始的 SSE 流,会重复 role 事件、在消息中途换掉语气,并给出对不上的用量合计;截断的流是两害相权更轻的那个。

成功的响应会带上一块 _routing 数据(providerbase_urlendpoint_id,发生过回退时还有 fallbackfailed_attempts),供服务层把这个请求归属到真正服务了它的那个端点。流式则通过 apps/backend/routing/telemetry.pyrouting_chunk() 构造的一个合成 chunk 在带内做同样的事,completions router 在转发前会把它剥掉。这两样客户端都看不到。

端点健康与熔断

apps/backend/routing/endpoint_health.py 为每个 endpoint_id 维护一个 _ProviderHealth(成功率的 EWMA)和一个 _CircuitBreaker,它们都放在一个进程级、被所有 router 共享的 EndpointHealthRegistry 里。allow_request 在选路时回退时都会被查询,因此打开的熔断器在两处都会被跳过。

熔断器在连续 failure_threshold 次失败后打开,保持打开 cooldown_seconds 秒,然后放行一次半开探测:成功就关闭它,失败就重新打开。这四个旋钮都优先读取环境变量:

变量

默认值

含义

CIRCUIT_FAILURE_THRESHOLD

3

熔断器打开前的连续失败次数。

CIRCUIT_COOLDOWN_SECONDS

30

放行一次半开探测前等待的秒数。

CIRCUIT_MIN_AVAILABILITY

0.7

EWMA 成功率下限,低于此值即视该端点为不健康。

ROUTER_HEALTH_EWMA_ALPHA

0.1

可用性 EWMA 的平滑系数。

GET /health/deep 会报出这份登记:每个端点的可用率、熔断状态,以及一个「上游连续拒绝凭据」的计数器——它从第一次被拒起就把该端点降级。鉴权被拒和其他失败一样,确实会计入可用率 EWMA,但这个 EWMA 是刻意做得慢的:一个可用率满值的端点,在默认 alpha0.1 时要连续失败四次才会掉到 0.7 的下限之下。而凭据被拒从第一个请求起对每个调用方就都是致命的,所以这个计数器直接报出来,不等平均值追上。

状态是按进程隔离的。每个后端 worker 各自维护自己的熔断器。

这与路由文件里的 health_check: 探测循环(apps/backend/routing/health.py)是两回事:后者按固定间隔轮询 local_deployment 端点的 /health,只影响 配置 中描述的权重分配。

prefill 感知的选路

加权随机平衡的是请求数,而对一个受 prefill 制约的部署来说,这个单位选错了:一个未命中缓存的超大 prompt 可能占住一个副本好几分钟,而一个小 prompt 只花几毫秒,两者却都算一个请求。apps/backend/routing/prefill_load.py 按端点统计当前正在 prefill 的未缓存 prompt token 数,并把它当作选路信号。

  • 选路采用 power-of-two-choices:做两次独立的加权抽取,保留 prefill 负载较小的那个端点。权重高 10 倍的路由仍然大约被抽中 10 倍的次数,但抽取结果不太会落到已被 prefill 压住的端点上。

  • 只有当较重的那次抽取在该端点上积压了至少 ROUTING_PREFILL_INTERVENE_TOKENS 个待 prefill 的未命中缓存 prompt token 时,这条决胜规则才会启用;低于该值时完全由配置的权重决定,因为权重编码的是成本和 provider 偏好,而不只是容量。

  • 超大的 prompt(「大象」)还会额外跳过那些已经达到逐端点大象上限的端点,这样两个超大 prefill 会在副本之间串行,而不是堆在同一个副本上。如果所有候选都已到上限,这条限制就被放弃:它降级为「最少负载」,绝不会降级为「拒绝路由」。

  • 未缓存的规模,是按调用方在那个端点上最近一次完成的 prompt 估算的,因此一次预热过的续写不会被误判成冷启动的超大 prefill。

  • 租约在第一个 token 时就释放,而不是等到流结束:一段漫长而廉价的 decode 不构成 prefill 压力。

变量

默认值

含义

ROUTING_PREFILL_AWARE_ENABLED

1

设为 0 即退回普通的加权抽取。

ROUTING_PREFILL_INTERVENE_TOKENS

50000

积压达到该值后,负载开始压过加权抽取。

ROUTING_PREFILL_ELEPHANT_TOKENS

200000

估算的未缓存 token 数达到该值时,一个 prompt 即算作大象。

ROUTING_PREFILL_ELEPHANT_LIMIT

1

每个端点允许的并发大象数。

ROUTING_PREFILL_AFFINITY_CEILING

150000

积压超过该值时,放弃亲和钉选。

ROUTING_PREFILL_HINT_TTL_SEC

1200

按 (调用方, 端点) 记录的 prompt 规模记忆的存活时长。

这个模块从不阻塞、拒绝或排队任何请求。它最多也就是改选另一个本来就可用的端点。

会话亲和

FixedRouter 按 (调用方, 模型) 把上一次选中的端点钉选五分钟,采用滑动 TTL(routers.py 里的 AFFINITY_TTL_SECONDS)。目标:

  • 把一段对话留在同一个后端上,让 prompt 缓存保持预热、时延保持稳定。

  • 那个后端一出错就立刻丢掉钉选,免得调用方卡在一个正在失败的 provider 上。

亲和键——由 apps/backend/serving/utils/request_ip.py 中的 derive_affinity_key() 推导,先匹配者胜:

  1. 已鉴权的请求:调用方 API key 的哈希。

  2. 以推理 grant 而非 key 发起的请求:grant:<grant_id>。这类调用方通常共用一个 NAT 或中继地址,若用 IP 作键,会把所有并发任务都压到同一个端点上。

  3. 匿名请求:ip:<bucket>,其中 IPv6 会折叠到它的 /64,这样轮换的隐私地址仍会落在同一个后端上。

每个会派发到 adapter 的请求入口——/v1/chat/completions/v1/messages/v1/embeddings——都会把这个键发布到请求上下文里,好让 router 的端点钉选和 KeyPool 的上游 key 绑定看到同一个调用方。没有调用方身份的内部流量(健康探测、预热、管理员 playground)什么都不发布,共用同一个匿名绑定。

钉选的生命周期:

  1. 来自 (key, model) 的第一个请求 → 加权挑选 → 存下一条记录。

  2. TTL 之内、同一 (key, model) 的后续请求复用同一个端点,并刷新 TTL。

  3. 被钉选的端点抛出任何异常都会丢弃这条记录;回退随即执行;下一个请求会建立新的钉选。

  4. 如果被钉选的端点不再可用——权重为 0,或它的熔断器已打开——这条记录被丢弃,重新做一次加权挑选。

  5. 如果被钉选端点的 prefill 积压高于 ROUTING_PREFILL_AFFINITY_CEILING,本次请求放弃该钉选,排除那个端点重新选路,然后把钉选改到新选中的端点上。钉选是一项缓存局部性优化,不是排队等待的承诺。

  6. TTL 走完而期间没有流量,这条记录即过期。过期记录会在表规模超过 AFFINITY_SWEEP_THRESHOLD(1000)之后被惰性清扫。

作用范围与限制:

  • 状态在进程内;每个 worker 各自维护自己的表,key_pool.py 也是如此。

  • 亲和关系不会跨重启保留。

  • 显式的 X-Route-Pin 会完全绕过亲和。

关闭开关:ROUTING_AFFINITY_ENABLED=0

API 端点

端点

用途

GET /v1/models

列出已发布的模型。

POST /v1/chat/completions

带自动路由的对话补全。

GET /health

存活探测,外加一个 routes_configured 计数。

GET /health/deep

逐端点的可用性与熔断状态。

GET /routing

每个模型当前的权重分布。

警告

GET /routing 不需要任何鉴权,它会为每个已发布的模型返回各条路由的 providerbase_url 和权重。在公网主机上,这等于泄露你的上游拓扑——包括内网地址,以及路由 base URL 里出现的任何内部主机名。把它放到你的反向代理之后,或者干脆不要暴露它。

运行期的路由与权重管理在 /admin/routing/... 下,那些接口确实需要管理员鉴权。

添加一个路由策略

路由策略是本仓库主要的扩展点。添加一个策略,就是写一个满足 RouterProtocol 的类,并以某个名字注册它,好让模型的 router: 字段能选中。

1. 理解这份契约

RouterProtocolapps/backend/routing/protocols.py)是一个可在运行时检查的 Protocol,有四个成员:

async def chat_completion(
    self,
    model_id: str,
    messages: list[dict[str, Any]],
    *,
    routing_options: RoutingRequestOptions | None = None,
    **params: Any,
) -> dict[str, Any]: ...

def stream_chat_completion(
    self,
    model_id: str,
    messages: list[dict[str, Any]],
    *,
    routing_options: RoutingRequestOptions | None = None,
    **params: Any,
) -> AsyncIterator[Any]: ...

def record_observation(self, obs: RoutingObservation) -> None: ...

def get_provider_status(self) -> dict[str, dict[str, Any]]: ...

RoutingRequestOptions 携带两个由 router 自己拥有、绝不能转发给 provider adapter 的控制项:pin_providerrequired_modalitiesRoutingObservationrouters.py)则是服务层汇报一个已完成请求的方式——端点 id、TTFT、总时延、token 计数、是否成功,以及一个由策略自己在选路时填好的 strategy_metadata 字典。无状态的策略在 record_observation 里返回 None;在线学习的策略则在那里更新自己的模型。

另有两项可选能力:

  • RouteTableRefreshable——如果你的 router 从路由表派生状态,并且需要在管理员改动路由后重建它,就实现 refresh_route_table()ModelRouterRegistry.refresh_route_tables() 会调用它。

  • ManagedRouterrouters.py)——如果你的 router 拥有后台任务,就实现 async start() / async stop()。它们的生命周期由 bootstrap 驱动。

2. 拿到路由

构造函数里不会把路由表交给你的 router。注册表先把 router 构造出来,再在它上面调用 attach_route_table(route_table)——前提是这个方法存在。你收到的对象满足 RouteTableViewapps/backend/routing/route_table.py):

def iter_effective_routes(self) -> tuple[EffectiveRoute, ...]: ...
def canonical_id(self, model_id: str) -> str: ...

每个 EffectiveRoute 都是一个冻结的 (route_key, canonical_model_id, adapters) 三元组,其中 adapters 是一串 (adapter, weight) 对,运行期的权重覆盖和管理员对 provider 的停用都已经应用过了。给它做个快照;不要在一次派发的全程持有锁。

一个从不实现 attach_route_table 的 router 根本看不到任何路由,所以实践中这一步并非可选。

3. 写策略模块

策略放在 apps/backend/routing/strategies/。一个策略一个模块,各自导出一个 router 类和一个 Pydantic 参数模型。下面是一个完整的轮询策略——把它保存为 apps/backend/routing/strategies/round_robin.py

"""Round-robin routing strategy."""

from __future__ import annotations

import threading
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel

from routing.endpoint_health import EndpointHealthRegistry
from routing.endpoints import endpoint_id_for_adapter
from routing.strategies import register_strategy

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from routing.protocols import RoutingRequestOptions
    from routing.route_table import RouteTableView
    from routing.routers import RoutingObservation
    from serving.adapters.base import BaseAdapter


class RoundRobinParams(BaseModel):
    """Parameters accepted under ``router_params:`` for this strategy."""

    model_config = {"extra": "forbid"}

    skip_open_circuits: bool = True


class RoundRobinRouter:
    """Cycle through a model's routes in declaration order."""

    def __init__(
        self,
        params: RoundRobinParams | None = None,
        *,
        health_registry: EndpointHealthRegistry | None = None,
    ) -> None:
        self.params = params or RoundRobinParams()
        self._health = health_registry or EndpointHealthRegistry()
        self._lock = threading.Lock()
        self._cursor: dict[str, int] = {}
        self._routes: dict[str, tuple[BaseAdapter, ...]] = {}
        self.route_table: RouteTableView | None = None

    # -- registry binding -------------------------------------------------
    def attach_route_table(self, route_table: RouteTableView) -> None:
        """Bind the shared read-only route table after construction."""
        self.route_table = route_table
        self.refresh_route_table()

    def refresh_route_table(self) -> None:
        """Rebuild route-derived state after an admin route change."""
        table = self.route_table
        if table is None:
            return
        rebuilt: dict[str, tuple[BaseAdapter, ...]] = {}
        for route in table.iter_effective_routes():
            rebuilt[route.route_key] = tuple(
                adapter for adapter, weight in route.adapters if weight > 0
            )
        with self._lock:
            self._routes = rebuilt

    # -- selection --------------------------------------------------------
    def _next_adapter(self, model_id: str) -> BaseAdapter | None:
        with self._lock:
            adapters = self._routes.get(model_id, ())
            if not adapters:
                return None
            start = self._cursor.get(model_id, 0)
            for offset in range(len(adapters)):
                index = (start + offset) % len(adapters)
                adapter = adapters[index]
                if self.params.skip_open_circuits and not self._health.allow_request(
                    endpoint_id_for_adapter(adapter)
                ):
                    continue
                self._cursor[model_id] = index + 1
                return adapter
        return None

    # -- RouterProtocol ---------------------------------------------------
    async def chat_completion(
        self,
        model_id: str,
        messages: list[dict[str, Any]],
        *,
        routing_options: RoutingRequestOptions | None = None,
        **params: Any,
    ) -> dict[str, Any]:
        """Route a non-streaming chat completion request."""
        adapter = self._next_adapter(model_id)
        if adapter is None:
            raise ValueError(f"No route available for model {model_id}")
        endpoint_id = endpoint_id_for_adapter(adapter)
        self._health.ensure(endpoint_id)
        try:
            response = await adapter.chat_completion(messages, **params)
        except Exception as exc:
            self._health.record_failure(endpoint_id, reason="chat_exception", exc=exc)
            raise
        self._health.record_success(endpoint_id)
        response.setdefault(
            "_routing",
            {
                "provider": adapter.config.provider,
                "base_url": adapter.config.base_url,
                "endpoint_id": endpoint_id,
            },
        )
        return response

    async def stream_chat_completion(
        self,
        model_id: str,
        messages: list[dict[str, Any]],
        *,
        routing_options: RoutingRequestOptions | None = None,
        **params: Any,
    ) -> AsyncIterator[str]:
        """Route a streaming chat completion request."""
        adapter = self._next_adapter(model_id)
        if adapter is None:
            raise ValueError(f"No route available for model {model_id}")
        endpoint_id = endpoint_id_for_adapter(adapter)
        self._health.ensure(endpoint_id)
        try:
            async for chunk in adapter.stream_chat_completion(messages, **params):
                yield chunk
        except Exception as exc:
            self._health.record_failure(endpoint_id, reason="stream_exception", exc=exc)
            raise
        self._health.record_success(endpoint_id)

    def record_observation(self, obs: RoutingObservation) -> None:
        """Ignore observations: this strategy keeps no online-learning state."""
        return None

    def get_provider_status(self) -> dict[str, dict[str, Any]]:
        """Return endpoint health and circuit state."""
        return self._health.snapshot()


register_strategy("round_robin")((RoundRobinRouter, RoundRobinParams))

值得照搬的几点:

  • 参数模型上要写 extra="forbid"。这样 router_params: 里的拼写错误会在启动时带着一条清晰的消息失败,而不是悄悄用上默认值。

  • 要接受 health_registry=。当注册表传入应用级的 RouterBuildDependencies 时,它要求构造函数能接受 health_registry=(或 **kwargs),否则抛出 TypeError。共享进程级的注册表,也正是让某个端点的熔断状态对所有 router 都可见的原因。

  • 第一个参数必须叫 paramsbuild_router 是这样调用的:router_cls(params=validated, ...)

  • 复用 endpoint_id_for_adapter。端点 id 是健康状况、时延画像和日志归属共用的键;自己另造一套会把它们割裂开。

4. 在导入时注册它

注册是导入的副作用,所以模块必须被导入。把它加到 apps/backend/routing/strategies/__init__.py末尾,紧挨着已有的那些导入:

from routing.strategies import fixed  # noqa: F401
from routing.strategies import round_robin  # noqa: F401

这些导入放在该文件末尾是有意为之:策略模块在自己文件顶部从 routing.routers 导入,依赖方向因此是单向的(strategies -> routers),而在这里写文件顶部的导入会形成循环。

如果你的策略依赖一个可选的包,就给导入加上保护,并在 except ImportError: 分支里调用 register_missing_strategy(name, reason)。这样选用它时会带着你写的消息在配置校验阶段失败,而不是让所有人的后端导入直接崩掉——routewise 正是这么处理的。

5. 选用它

按模型来,写在模型注册表里:

models:
  - id: <model-id>
    router: round_robin
    router_params:
      skip_open_circuits: true

或者按部署来,写在路由文件里:

default_router: round_robin

注意 RoutingManager.apply() 只在生效的 default_routerfixed 时才重写权重;设成别的值,会让每个模型 route: 里的权重保持写下来的原样。

6. 测试它

tests/unit/routing/test_router_contract.py 里是每个对外服务的 router 都必须满足的行为契约——把你的类加进去。test_strategies.py 覆盖注册表本身:注册、参数校验,以及依赖注入检查。isinstance(router, RouterProtocol) 这样的断言是有意义的,因为这个 protocol 标了 @runtime_checkable

用这条命令跑路由相关的测试:

uv run pytest tests/unit/routing -q

RouteWise

routewise 是第二个注册的策略:一个成本感知的 router,它把每条可行的路由折算成一个有效成本,在这些路由上求解一个带成本预算、以平均 TTFT 为目标的线性规划,再从得到的稀疏混合分布中抽样出一条主选路由。它按模型选择性启用(router: routewise),每个启用它的模型都有自己的 RouteWiseRouter 实例。

它的实现是一个独立的包:MIT 许可的 llm-routewise。本网关把它作为必需依赖引入,而任何应用都可以拿它来选 provider——它不做任何网络 I/O,也不读取任何凭据。设计细节见 RouteWise: Latency--Cost Optimization for Multi-Provider LLM Routing(EuroSys '27)。

它是必需依赖,但不是启动的承重件。当这个包缺失时——安装不完整,或者某个构建刻意去掉了这个策略——apps/backend/routing/strategies/__init__.py 会把 routewise 注册为一个缺失的策略:选用它会在配置校验阶段带着一条可操作的消息失败,而不是让后端导入崩掉。没有任何单一路由算法能决定网关能不能启动。

想要一份不用改就能对着两个回环 provider 直接跑、且每个选项都有注释的注册表,见 config/examples/models.routewise.yaml

延迟证据。只有在有实测数据的地方,这套策略才会拿延迟去换成本。两个都没有 TTFT 样本的端点在延迟目标上是打平的,cost_tiebroken_objective 会按价格打破平局,于是在任何成本预算下 LP 都会在更便宜的那个上给出 one-hot 解——结果就是策略正在回避的那个端点永远不会被测到。证据有三个来源:线上真实流量、db_bootstrap_enabled 在启动时回放近期的 api_logs,以及主动探测器(routewise_probe_enabled)——后者在进程内运行,不需要运营存储;把探测样本持久化只是在这之上的一项优化。

配置按归属拆成两处:

  • router_params:(按模型)——只放算法旋钮。可接受的键及其默认值,是从 apps/backend/routing/routewise/config.py 里的 RouteWiseConfig dataclass 逐字段生成的,那份 dataclass 才是权威列表:成本预算插值、时延对冲模式、时延 SLO 与窗口、成本包络的分位数/窗口/最小样本数、输出长度预测器,以及前缀缓存开关。

  • 路由条目(按 provider)——资源语义:provider_type: on_demand | quota | concurrencypricing:quota: {limit} 加一个 quota_source: 块、concurrency: {limit},以及可选的 quota_pool: / concurrency_pool: id,供共享同一份订阅的多条路由使用。

资源上限以前是放在 router_params: 里的。那些键现在会在启动时被拒绝,并在消息里指出它们对应的路由级替代项,这样陈旧的注册表会大声失败,而不是悄悄用上默认值。

配额来源quota_source: 是一个选择器,不是抓取器。它指定 provider / usage_label / unit 三项,_find_usage 会拿这三项和 provider 抓取器返回的用量记录做精确匹配。RouteWise 是通过 ProviderQuotaSnapshotStoreapps/backend/routing/routewise/quota.py)自己去调这些抓取器的,并不读 admin 轮询器的缓存;而已注册的抓取器只有两个:chutesminimaxusage_label 是抓取器自己的标签字符串,不是运维可以随便起的名字——Chutes 的抓取器发出的是 Daily requestsunit: requests

路由的 kind: 和它的凭据属于同一份契约。每个抓取器都是按 provider 去发现自己的 key 的——fetch_chutes 找的是 CHUTES_API_KEY,以及绑定在 chutes 路由上的 key——所以一条走通用 openai_compat adapter、用着不相干 key 的配额路由,永远不会加入那个池子:推理请求能通过鉴权,而配额快照会一直停在 not_configured。请让 kind: 与 provider 对应,并使用该 provider 自己的 key 变量。

写了那两个已注册 provider 之外的 provider,或者标签拼错,结果就是它永远解析不出来。而且没有任何警告——配额相关的日志只有「刷新失败」和「provider/路由 limit 不一致」两条——所以这条路由会一直处于未就绪状态,被静默跳过。上线一个新的配额来源之前,请先对着抓取器验证一遍。

在 admin 创建的路由里看到的 local provider 并不是第三个抓取器:它是通过 configure_local_fallbacks 装上的、网关侧的按请求计数器。在 quota_source: 里写 provider: local 并不会启用它,但静态 YAML 仍然可以够到它——_uses_local_quota_fallback 选中的是这样的配额路由:route_metadata 里设了 local_quota_fallback: true,或者 route_providerupstream_provider 不同。依赖它之前先搞清楚它是什么:计数活在 worker 进程里,所以每次重启都从零开始,也不会跨 worker 累加;它每个请求加一,所以只能表达请求次数额度,永远表达不了 token 或花费;并且它在下一个服务器本地午夜重置,所以它只能建模「每日上限」,别的都不行。四小时窗口、按月窗口、或者由 provider 决定何时重置的套餐,都需要一个真正的 quota_source

端点身份。延迟画像、可用性跟踪和请求日志共用每条路由的同一个 key,由 registry._make_provider_id 推导为 {model_id}:{location}。当 base URL 指向本地主机时,locationlocal-{port};否则,对通用 adapter 类别取自主机名(api.minimax.iominimax-api),其余则是 {kind}-api。所以一次改动只要动到了被推导的那一部分——本地换端口、远端换厂商主机名——就会给端点改名并让它的延迟画像重新开始;没动到的(比如在同一端口上的本地主机之间迁移)则会保留。静态 models.yaml 里的 route_id: 永远不会覆盖它;那个字段属于 admin 的 provider-routes API。

冷启动。一条带配额的路由需要一份标定过的成本包络,它由近期的请求历史构建。如果一个模型的配额路由旁边还有非配额路由,它启动时处于降级状态——在流量把包络标定出来之前,配额路由是被屏蔽的。如果一个模型所有路由都带配额,启动时会抛出 EnvelopeNotCalibratedError,bootstrap 会把它向上传播,于是整个部署快速失败,而不是对外提供一个根本路由不了的模型。

worker 作用域。RouteWise 的决策状态、时延画像、配额与并发预留,以及按模型的状态转移锁,都是进程本地的。在 WEB_CONCURRENCYUVICORN_WORKERSGUNICORN_WORKERS 大于 1 时配置 quotaconcurrency 路由,会在启动时抛错(这道保护就是配置字段 stateful_providers_single_worker_only,默认为 true)。只用 on-demand 路由的 RouteWise 模型可以跑多个 worker,但每个 worker 仍然各学各的。

运行期调参。管理端点从查询参数里取模型 id,因为模型 id 可能包含 /

GET    /admin/routewise/model-settings?model_id=<model-id>
PATCH  /admin/routewise/model-settings/{key}?model_id=<model-id>
DELETE /admin/routewise/model-settings/{key}?model_id=<model-id>

这些设置的作用域是模型,别名会解析到规范模型,因此别名和它的规范模型读写的始终是同一组值。DELETE 只删除覆盖值,恢复继承来的值。更早的 GET/PATCH /admin/routewise/settings 端点仍然保留,如今它设定的是一个兜底值,供既没有模型级覆盖、也没有 router_params: 取值的模型使用。

为某个层级预留上游 key

一份 provider 凭据可以预留给某个用户角色及其以上,这样优质的上游容量就不会被最低层级消耗掉。预留是挂在 key 上而不是模型上:模型目录里的 required_role 决定一个用户能调用什么,而 key 的 min_role 决定谁的请求可以花掉这份凭据。

KeyPoolapps/backend/serving/adapters/key_pool.py)里的每把 key 都带一个 min_role,默认是 free——即不预留。任何更高的取值(prointernaladmin)都会让这把 key 对层级低于它的调用方不可见:

  • 选取。对达不到 min_role 的调用方,被预留的 key 会被过滤掉。在一个调用方可以使用的 key 之中,预留层级最高的排在最前,因此有资格的调用方会先耗尽为它留出的容量,再退回到所有层级共享的那些 key。

  • 亲和。key 池自己那份五分钟的 key 绑定,只对创建它的角色有效;池中内容的任何变动——某个层级挪动、加入一把 key、重新启用或移除一把 key——都会丢弃所有首选 key 发生变动的绑定。这里说的静默,是 KeyPool 自己的术语:一把 key 在遇到 key 相关的失败(401/402/403/429)后被临时移出轮转;它仍然是声明过的,过一阵会自己回来。绑定到一把只是被静默的 key 时,绑定仍然保留,因为那是暂态,acquire 会绕开它重新挑选;绑定到已被移除的 key 则一律作废。只有声明发生变化才会触发这件事,所以普通流量不会因此丢掉 prompt 缓存的预热。

  • 耗尽。如果一个调用方可用的 key 全部被静默(或者它一把也没有),就会得到 KeyPoolExhausted;router 把它当作一次上游失败,转而回退到下一条路由。

  • 健康计数。当这个池仍然能服务一个不受限的调用方时,这次拒绝是 KeyPoolRoleRestrictedKeyPoolExhausted 的子类),EndpointHealthRegistry.record_failure 会跳过它。因为并没有任何东西发往上游,而且该端点仍在为拥有那些 key 的层级提供服务;把它计入的话,一波低层级流量的突发就能打开熔断器,把预留容量从它本该服务的调用方手里剥走。一个谁都用不了的池仍然是普通的 KeyPoolExhausted,照常计入。

  • 单 adapter 的入口/v1/messages 一上来就锁定一个 adapter,而不是沿着回退链走下去,因此它会挑第一个持有该调用方可以花掉的 key 的 adapter。否则,一个被预留的首个 adapter 会让本可由另一条路由服务的请求直接硬失败。

调用方的角色经由请求上下文抵达 key 池,由 API key 鉴权依赖项发布。没有用户身份的请求——健康探测、预热、管理员 playground——不带角色,一律按不受限处理:预留是对更低的层级扣住容量,而不是对网关自己的机件。

如何管理。预留是通过管理 API 声明的,而不是环境变量,因为池的成员来自每条路由的 api_keys 列表,而它们不一定就是 <PROVIDER>_API_KEY 这些变量。新增 key 时可以在 POST /admin/provider-keys 上带 min_role。改动会立即作用到运行中的池,不需要重启。端点按 key 的来源而不同:

来源

端点

预留记录在哪里

数据库(从仪表盘添加的)

POST /admin/provider-keys/{key_id}/min-role

provider_api_keys.min_role

环境变量(<PROVIDER>_API_KEY,注册表里的 api_keys

POST /admin/provider-keys/min-role-env

provider_env_key_min_roles,以该 key 的哈希为键

环境变量里的凭据没有属于自己的数据库行,所以它的预留以哈希为键。这带来两个后果:这把 key 退出轮换之后预留依然存在(把变量恢复回去,它就会以当初预留的层级回来),以及管理列表里可能出现一条凭据在任何地方都没有配置的预留——这样它可以被主动解除,而不是潜伏在那里等着。

实际生效的层级由 apps/backend/serving/adapters/dynamic_keys.py 掌管,因为池是从 adapter 配置构建的(那里不带层级信息),而且会被频繁重建。这些声明按 provider 缓存,并在启动时以及每次管理端改动之后从数据库刷新。同一份凭据被配置了两次时,最严格的那条声明胜出free 表示没有声明,而不是断言人人都可以花这把 key,因此加一条更宽松的重复声明并不能放宽访问,结果也不依赖配置顺序。

限制:没有 key 池的 provider 无法承载预留min_roleKeyPool 强制执行,因此它能覆盖 openai_compat 路由(其中包括本地的 vLLM/SGLang/Ollama 服务)和继承了它的 openrouter——外加任何只有单个 api_key 的路由,这类路由当且仅当有预留作用到它身上时,才会被提升到走池的那条路径上。专用的 anthropicclaudegemini adapter 直接持有自己的凭据,从不向 dynamic_keys 注册,所以针对这几个 provider 记录下来的预留会被存起来,却永远不会生效。这些模型请改用模型目录里的 required_role 来限制。