路由

HybridInference 的核心就是路由。客户端只请求一个对外的模型 id,由网关决定实际交给哪个上游端点处理:失败了就换一个端点重试,不再往已经宕掉的端点发流量,并把同一段对话留在前缀缓存已经预热的端点上。

本页介绍路由引擎、它的可调参数,以及怎么添加自己的路由策略。配置文件放在哪里、网关怎么找到它们,见配置;模型条目的结构和其中的 route: 列表,见添加新模型。router 怎么把请求交给它选中的 adapter,见路由内部机制。

为每个模型选择 router

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

models:
  - id: <model-id>
    provider: openai_compat
    router: fixed            # strategy name; omit to use default_router
    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——加权随机选路,带自动回退。它是默认策略,也是唯一不需要额外依赖的策略。它的设置里目前真正起作用的只有 router_params.hybrid_composition(默认 false),用于开启一种实验性的本地/云端组合,详见路由内部机制。

  • routewise——考虑成本的策略,实现在单独的包里;见下文 RouteWise。

router_params: 存放所选策略的设置。未知的策略名会让启动失败,错误消息会列出已知的策略;策略不接受的键也会让启动失败,错误消息会指出是哪个键。ENABLE_ROUTEWISE=true 是一个较早的开关:当路由配置文件里的 default_router 仍是 fixed 时,它会把默认策略换成 routewise。

别名与它所属的模型共用同一个 router,所以从流量中学习的 router 会把模型本身的请求和各个别名的请求合在一起看。

FixedRouter 如何挑选端点

每来一个请求,fixed router 都按下面的顺序逐步筛选模型的路由:

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

  2. 模态过滤。请求带有非文本输入时,只保留 input_modalities 能覆盖它的路由。一条都没有的话,抛出 AllCircuitsOpenError,错误里写明需要哪些模态。

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

  4. 权重与熔断准入。去掉权重为 0 的路由,以及熔断器已打开、或者已经有一个半开探测在跑的路由。一条都不剩时,AllCircuitsOpenError 会列出它考虑过的端点。模型的卸载路由在这一步也会先放到一边,除非只剩它一条(见排队等待卸载)。

  5. 会话亲和。如果这个调用方在这个模型上还有有效的 pin,就优先用它,除非 pin 住的端点已经积压(见会话亲和)。

  6. 加权抽取。把剩下的权重重新归一化到总和为 1,再抽出一条路由,抽取时尽量避开正忙于 prefill 的端点(见 prefill 感知的选路)。

回退

选中的 adapter 抛错时,FixedRouter 会把这次失败记在那个端点上,丢掉调用方的亲和 pin,再按声明顺序逐条尝试模型剩下的路由。其间会跳过三类路由:权重为 0 的,接受不了这个请求模态的,以及熔断器不给派发凭据的(熔断器已打开,或者已经有一个探测在跑)。哪条先成功,就由哪条返回结果。如果所有路由都失败,就重新抛出首选路由的错误,并附上完整的尝试列表。模型的卸载路由不在这个顺序里:如果某次尝试因为排队等待超出预算、或者等引擎首个 token 超时而放弃,下一个就轮到卸载路由;否则它排在最后(见排队等待卸载)。

有两个有意为之的例外:

  • pin 的请求从不回退。调用方点名要某一个端点,悄悄换掉的话,拿到的结果会误导人。

  • 流式响应只要已经有字节发到客户端,就不再回退。往一条已经开始的 SSE 流里接入第二个 provider,会重复发送 role 事件、在一条消息中途换了语气,用量合计也对不上;两害相权取其轻,宁可让流截断。

成功的响应会带一段 _routing 数据(provider、base_url、endpoint_id;发生过回退时还有 fallback 和 failed_attempts),服务层据此把请求记到实际处理它的端点名下。流式响应也一样,只是放在流里:apps/backend/routing/telemetry.py 中的 routing_chunk() 会构造一个合成 chunk,completions router 转发前再把它去掉。这两样客户端都看不到。

端点健康与熔断

每个端点都有一个熔断器和一个成功率的滑动平均值,进程内的所有 router 共用它们。连续失败 CIRCUIT_FAILURE_THRESHOLD 次,或者某次失败让成功率跌到 CIRCUIT_MIN_AVAILABILITY 以下,熔断器就会打开,不再把请求路由到这个端点。过了 CIRCUIT_COOLDOWN_SECONDS 秒,它会放行一个测试请求:成功就关闭熔断器,失败则重新打开。

这个测试请求还在进行时,其他无处可去的请求会直接拿到 503,而不是再往一个多半仍然宕着的 provider 上压第二个请求。如果模型在别处还有健康的路由,就直接改用那一条。相关的四个设置:

变量

默认值

含义

CIRCUIT_FAILURE_THRESHOLD

3

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

CIRCUIT_COOLDOWN_SECONDS

30

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

CIRCUIT_MIN_AVAILABILITY

0.7

成功率下限:某次失败让端点的平均成功率跌到它以下时,熔断器打开。

ROUTER_HEALTH_EWMA_ALPHA

0.1

可用性 EWMA 的平滑系数。

GET /health/deep 显示每个端点的可用性和熔断状态。provider 第一次拒绝网关自己的凭据时,它就把这个端点标为降级,不等成功率平均值降下来:平均值变化很慢,而 key 一旦被拒,每个请求都会失败。

这些状态按进程隔离,每个后端 worker 各有一套熔断器。

路由配置文件里的 health_check: 探测是另一回事,它只负责报告,从不改变请求发往哪个端点。见路由配置文件。

准入(写给 router 作者)

router 检查端点分两步,这样拆开是有讲究的:

  • allow_request(endpoint_id) 是纯谓词,只回答一个问题:这个端点算不算候选?router 枚举时会对每一条候选路由都问一遍,最后最多只派发给其中一条,所以它不改动任何状态。

  • begin_dispatch(endpoint_id) 才是真正的提交。router 选定端点后,在那里调用一次;它返回一个 DispatchClaim,端点接不了这个请求时返回 None。调用方要在包住整个派发过程的 finally 里调用 end_dispatch(claim) 交还凭据,和 prefill 租约放在一起,原因也一样:客户端断开连接或协程被取消时,也必须释放它。

测试请求的名额只属于拿到它的那个派发凭据,别的都释放不了它:绕过准入的请求(显式的 X-Route-Pin,或者在熔断器还关着时就放行的请求)记下的结果,说明不了测试是否已经结束。派发凭据还会在 CIRCUIT_COOLDOWN_SECONDS 秒后过期,所以一次始终没有收尾的派发,最多多耗一个冷却周期,不会把端点永远挡在路由之外。

被排除在选路之外的路由

有效权重为 0 的路由,加权选路和所有回退循环都会跳过,所以它是配置里有、实际上用不到的容量。有四种机制会造成这种情况,GET /health/deep 会在 route_exclusions 下写明是哪一种——每个(模型,端点)组合一条记录,包含配置权重、有效权重和一个 reasons 列表:

原因

由谁设置

在哪里撤销

weight_override

针对该(模型,端点)的一条 provider_weight_overrides 记录

管理控制台 → 路由权重

provider_disabled

针对该 provider 标签的一条 disabled_providers 记录

管理控制台 → providers

routing_yaml

RoutingManager 在启动时应用一次的本地/远端拆分

该部署 overlay 的 routing.yaml

configured_zero

模型注册表里的 weight: 0

该部署 overlay 的 models.yaml

只有在没有数据库的网关上才会出现 routing_yaml。有数据库时,路由配置文件里的本地/远端拆分根本不会作用到权重上;见路由配置文件。

同样的排除信息也会以 excluded_from_models 和 exclusion_reasons 字段合并进 providers 映射,映射里原本没有的端点也会补上,因为从没派发过请求的路由根本进不了端点健康登记表。这些信息特意不影响 deep health 的结论:把路由权重置零是运维的决定,不是故障。

网关在启动时和每次重新加载覆盖值之后,也会在日志里列出这些路由:已配置的路由在运行时被置零,记一条 WARNING 级别的 route_weight_zeroed;只是权重被改了,记一条 INFO 级别的 route_weight_overridden。两种日志都带 configured_weight、effective_weight 和原因。

prefill 感知的选路

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

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

  • 只有当两次抽中的端点里较重的那个,排队等 prefill 的未缓存 prompt token 达到 ROUTING_PREFILL_INTERVENE_TOKENS 时,才按负载二选一;低于这个值就只看配置的权重,因为权重体现的不只是容量,还有成本和 provider 偏好。

  • 超大 prompt(“大象”)还会跳过已经达到单端点大象上限的端点,这样两个超大 prefill 会分散到不同副本上,而不是挤在同一个副本上。如果所有候选都到了上限,就取消这个限制:最坏也只是退化成“选负载最小的”,绝不会退化成“拒绝路由”。

  • 未缓存部分有多大,是根据调用方在这个端点上最近一次完成的 prompt 估算的,所以缓存已经预热的续写不会被误当成冷启动的超大 prefill。

  • 租约在收到第一个 token 时就释放,不等到流结束:decode 阶段哪怕很长,开销也小,不算 prefill 压力。

变量

默认值

含义

ROUTING_PREFILL_AWARE_ENABLED

1

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

ROUTING_PREFILL_INTERVENE_TOKENS

50000

积压达到该值后,负载开始优先于权重。

ROUTING_PREFILL_ELEPHANT_TOKENS

200000

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

ROUTING_PREFILL_ELEPHANT_LIMIT

1

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

ROUTING_PREFILL_AFFINITY_CEILING

150000

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

ROUTING_PREFILL_HINT_TTL_SEC

1200

按(调用方,端点)记住的 prompt 大小保留多久。

这个模块从不阻塞、拒绝请求,也不让请求排队。它最多只是改选另一个本来就能准入的端点。

会话亲和

FixedRouter 按(调用方,模型)把上一次选中的端点 pin 住五分钟,TTL 是滑动的(routers.py 里的 AFFINITY_TTL_SECONDS)。这样做是为了:

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

  • 那个后端一出错就立刻丢掉 pin,免得调用方卡在出故障的 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 的端点 pin 和 KeyPool 的上游 key 绑定认的是同一个调用方。没有调用方身份的内部流量(健康探测、预热、管理控制台的 playground)不写任何键,共用同一个匿名绑定。

pin 的生命周期:

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

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

  3. pin 住的端点只要抛出异常,这条记录就会被丢弃,接着执行回退;下一个请求会重新建立 pin。

  4. 如果 pin 住的端点已经不能准入(权重为 0,或者熔断器已打开),就丢掉这条记录,重新做一次加权挑选。

  5. 如果 pin 住的端点 prefill 积压超过 ROUTING_PREFILL_AFFINITY_CEILING,这个请求就不走 pin,而是排除那个端点重新选路,再把 pin 改到新选中的端点上。pin 只是为缓存局部性做的优化,不承诺一定排队等那个端点。

  6. TTL 内一直没有流量,这条记录就过期。表里的条目超过 AFFINITY_SWEEP_THRESHOLD(1000)之后,才会惰性清理过期记录。

  7. 不管调用方多忙,一个 pin 从建立起最多只保留一天(ROUTING_AFFINITY_MAX_AGE_SEC,默认 86400;设为 0 则取消这个上限)。刷新 TTL 不会让它超过这个期限,所以到期后的第一个请求会重新做一次加权挑选。没有这个上限的话,一个从不停顿满五分钟的调用方会一直留在原来的端点上,权重调整(包括管理员设置的覆盖值)只能影响那些安静下来或发生回退的调用方。pin 从各自建立的时刻开始计时,所以这些重新挑选会分散在一天当中。

作用范围与限制:

  • 状态保存在进程内,每个 worker 有自己的一张表,和 key_pool.py 一样。

  • 重启后亲和关系不保留。

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

关闭开关: 设 ROUTING_AFFINITY_ENABLED=0。

出站并发

网关会限制自己对每个 provider 账号同时保持的在途请求数——每个 provider 标签与 API key 的组合各有一个上限——多出来的请求排队等待。这个上限会自动调整:初始为 UPSTREAM_CONCURRENCY_INITIAL_LIMIT,provider 每返回一次 429 就减一,每累计 UPSTREAM_CONCURRENCY_PROBE_SUCCESS_INTERVAL 次成功响应就加一,最高到 UPSTREAM_CONCURRENCY_MAX_LIMIT。在 UPSTREAM_CONCURRENCY_ACQUIRE_TIMEOUT_SEC 秒内拿不到槽位的请求,会转去尝试模型的下一条路由,并且不会记到该端点的熔断器上:provider 根本没见过这个请求。

地址是 localhost、127.0.0.1、0.0.0.0 或 host.docker.internal 的服务器从不受限,因为它们会自己调度工作。你在另一台机器上运行的服务器,则和托管 provider 一样受限。

变量

默认值

含义

UPSTREAM_CONCURRENCY_ENABLED

true

设为 false 时,每个请求都立即发出

UPSTREAM_CONCURRENCY_INITIAL_LIMIT

8

每个 provider key 的初始上限

UPSTREAM_CONCURRENCY_MAX_LIMIT

64

上限最高能升到的值

UPSTREAM_CONCURRENCY_PROBE_SUCCESS_INTERVAL

100

两次上调之间需要的成功响应数

UPSTREAM_CONCURRENCY_ACQUIRE_TIMEOUT_SEC

30

请求等待槽位的最长时间,超时后转去下一条路由

模型还可以把等待过久的请求交给一条专门为它们预留的路由;见排队等待卸载。

排队等待卸载

用 fixed 路由的模型,可以把自己的一条路由留作卸载路由,专门接其他路由安排不下的请求。卸载路由按模型配置,可以在管理控制台(Routing → Queue offload)里设置,也可以调用 PUT /admin/routing/offload-routes/{model_id};配置内容是一个路由 id、一个等待秒数,以及可选的引擎队列上限和最大卸载输入,存在 site_settings 的 model_offload_route:<model_id> 下。路由逻辑在 apps/backend/routing/offload.py。

这里的等待指的是在网关自己的出站队列里等。并发限流器(apps/backend/serving/adapters/upstream_limiter.py)对每个 provider key 同时发出的请求数有一个学习出来的上限,超出的请求排队;没有卸载路由时,排队的请求最多等 UPSTREAM_CONCURRENCY_ACQUIRE_TIMEOUT_SEC(默认 30)秒,拿不到槽位就转去下一条路由。有了卸载路由之后:

  • 选择。只要还有别的路由可以准入,卸载路由就不会成为主路由:加权抽取、亲和与首选目标都会跳过它,所以无论权重多少,它都不接收普通流量。

  • 排队等待。请求在其他路由上的每次尝试,等出站槽位的时间都不超过配置的秒数。时间一到,请求就退出队列,下一个直接交给卸载路由,排在其余回退路由之前。如果是限流器自己的获取超时先到,也按同样方式处理:请求排过队,但始终没拿到槽位。

  • 兜底。其他每条路由都失败之后,卸载路由也是最后一个回退;没有其他路由可以准入时,它就是主路由。

  • 卸载尝试本身照常排队,使用完整的获取超时,因为已经没有别处可以再送。一个请求最多被卸载一次。

  • 卸载失败。如果卸载路由也失败了,为它被截断的那次尝试会在其他所有候选之后再试一次,只试一次,这次拿槽位最多等满完整的获取超时。卸载可能让请求变慢,但绝不会让本来能由自己的路由完成的请求失败。

提前结束排队等待是安全的,因为请求还没有离开网关:放弃它在队列中的位置不会释放任何上游资源,也不会造成重复生成;这次等待不计入端点的熔断器,也不会清掉它的前缀缓存提示。

引擎等待

网关看不到推理引擎自己的队列。vLLM 或 SGLang 服务器会接受每一个请求,把暂时调度不了的排进自己的队列;从外面看,在那里等待的请求和只是慢一些的请求没有区别——直到引擎发出第一个 token。因此,对于配置了卸载路由的模型,FixedRouter 会为该模型其他路由上的每次流式尝试设定一个等待这个 token 的时限(apps/backend/routing/engine_wait.py):

  • 首 token 等待。如果引擎在同样的等待时间内一个 token 都没发出,这次尝试就会被取消(关闭流会让引擎中止这个请求),请求接着交给卸载路由。计时从请求离开网关开始,所以请求在网关里排队的时间,无论是在等出站槽位,还是在为本地引擎排队,都由排队等待单独约束。引擎在首个 token 之前发来的内容(比如只含 role 的 delta)会先暂存,等 token 到了再放行,所以客户端永远看不到被放弃的那次尝试。

  • 回到原引擎。如果卸载路由也失败了,请求会在其他所有候选之后回到它离开的那个引擎,只回去一次,这次引擎要多久就等多久。

  • 只影响当前请求。等待只决定它计时的那一个请求怎么走。引擎那边不留任何记录:下一个请求照常发给它,并重新计时;引擎等待既不计入熔断器,也不会清掉这个端点的前缀缓存提示。

  • adapter 暂存的输出也算数。有些 adapter 会先暂存输出,直到能判断它是什么:GLM 和 Qwen Coder 的流处理器会把 XML 工具调用保留到完整为止,MiniMax 的处理器会整段去掉 <think> 块,Claude 的 adapter 会把工具调用的 JSON 保留到消息结束。它们从上游读到第一段输出时都会通知 router,此后这个请求不再有时限,引擎需要多久就等多久。

只有流式请求会计时:非流式响应是一次性整体返回的,没有首个 token 可等。首 token 等待包含引擎的 prefill 时间,所以处理超长 prompt 的模型,等待时间要比它开始回答这些 prompt 所需的时间更长。这个等待需要 Python 3.11 或更新版本:截止时间用 asyncio.timeout 实现,它能分清是自己触发的取消,还是客户端恰好在同一时刻断开。在 Python 3.10 上,流式请求一律不计时。

由卸载路由返回的响应,除了常见的 fallback 和 failed_attempts,还带有 _routing.offload:如果之前某次尝试等得太久,值为 queue_wait 或 engine_wait,否则为 last_resort。不管是不是流式,请求日志的元数据里都会保留它;每次派发到卸载路由,还会记一行 route_offload 日志。卸载路由失败时,请求依然保留这个标记,不管之后由另一条路由返回,还是整个请求失败。这时它的 _routing 描述的是另一条路由:返回响应的那条,或者报告出错的那条(通常是主路由),所以还会用 offload_endpoint_id 写明卸载路由。被截断后又重试过的尝试,报告的是重试时的错误,而不是截断它的那次等待。

管理后台的 Recent Requests 标签页用一张 Offloaded requests 表统计这些请求:过去 24 小时内每个模型发往其卸载路由的请求,按路由和原因拆分,并给出它们占该模型流量的比例,以及其中仍然失败的数量(客户端断开不计在内)。它跟随该标签页的用户、会话、模型和类型筛选,由 GET /admin/recent-requests/offloads 提供数据(days 默认为 1)。

以下情况不会卸载:

  • 网关不会让它排队的非流式请求。只有限流器会让请求排队,而它豁免了本地推理服务器(主机在 registry._LOCAL_HOSTS 里),因为它们自己调度请求;但设置了引擎队列上限时,发往它们的流式请求会在网关排队。UPSTREAM_CONCURRENCY_ENABLED=false 时,远程路由完全不排队。在这些情况下,流式请求如果没能及时等到引擎的首个 token,仍然会被卸载,卸载路由也仍然是兜底。

  • pin 的请求。X-Route-Pin 指定一个端点且从不回退,所以也从不卸载。由调用方掌控候选顺序的尝试(allow_fallback 关闭,混合组合就是这样规划尝试的)同样不会卸载;此外,卸载路由必须位于派发范围之内,并能接受该请求的模态。

  • 卸载路由装不下的 prompt。某条路由以超出上下文窗口为由拒绝了一个 prompt 之后,回退会跳过所有配置的 context_length 不比它大的路由,卸载路由也一样。既然卸载路由反正会被跳过,后续的尝试也不会为了它退出排队。

  • 超过最大卸载输入的 prompt。它永远不会被发给卸载路由;见最大卸载输入。

  • /v1/messages。它自己挑选一个 adapter,并且没有回退。除非别的都不可用,它不会挑中卸载路由(prompt 超过最大卸载输入时则完全不会挑中),但也不会因为等待而卸载。/v1/chat/completions 以及构建在它之上的接口(/v1/responses、/v1/completions 和管理控制台的 playground)会卸载。

  • RouteWise。routewise 模型自己规划候选,忽略卸载路由;设置了 hybrid_composition: true 的 fixed 模型也一样。管理 API 拒绝为这两类模型设置卸载路由,也拒绝把已有卸载路由的模型切换到 routewise。在模型变成这两类之前就存了的策略,会被列为未生效,路由也会忽略它。

等待时间必须为正数,可以比获取超时更长:请求到了获取超时仍会退出网关队列,同样会被卸载;这时更长的等待只用来限制引擎的首个 token——长 prompt 要过一阵才开始回答的模型,需要的正是这个。卸载路由的有效权重必须大于 0:把权重覆盖为 0 或禁用 provider,都会让卸载失效,这时 GET /admin/routing/offload-routes 返回的策略会带上 active: false 和 inactive_reason。卸载路由按路由 id 指定,管理员把路由改指向别处后 id 不变;如果一条运行时路由是某个模型的卸载路由,在清除这项卸载设置之前,删除它会被拒绝。

引擎队列上限

引擎等待只能在请求已经进了引擎的队列之后,才告诉网关它等久了,这时再离开就意味着要在引擎那边中止它。卸载策略可以让这个队列改为留在网关里。设置引擎队列上限 N(控制台里的 Engine queue limit,API 里的 engine_queue_limit)之后,该模型路由到的每个本地推理服务器,同时最多只会收到 N 个还没返回首个 token 的流式请求。其余流式请求在网关里按到达顺序排队,每个引擎一条队(apps/backend/serving/adapters/upstream_limiter.py 里的 EngineHold):

  • 首个 token 一到就让出位置。引擎一发出请求的首个输出,这个请求就不再计数;如果这次尝试先结束了,就在结束时不再计数。引擎已经在回答的请求从不计数,所以引擎同时能跑多少个请求不受限制:上限限制的是在引擎那里排队或做 prefill 的请求数。

  • 能卸载的排队请求到了等待时间就离开,和离开任何队列一样(_routing.offload 为 queue_wait),这时引擎还没见过它。引擎那边没有要取消的请求,也不会白做 prefill。

  • 不能卸载的请求按顺序等。无处可去的尝试——固定路由(pinned)的请求、由调用方决定候选顺序的请求、卸载路由里没有其可用 key 的调用方、prompt 超过最大卸载输入的请求、卸载失败后的重试、卸载尝试本身——没有自己的截止时间,但同样计数。它最多保留位置到获取超时(UPSTREAM_CONCURRENCY_ACQUIRE_TIMEOUT_SEC),之后无论如何都会超出上限发给引擎。排队只决定请求在哪里等,从不让请求失败。

  • 只有发往本地服务器的流式请求会在网关排队。非流式请求没有首个 token 可以用来让出位置,会直接发给引擎;远程路由有限流器自己的队列。无论 UPSTREAM_CONCURRENCY_ENABLED 是否开启,这个上限都生效。

上限留空,所有请求都会直接发给各自的引擎。上限为 1 时,只有前一个请求开始回答之后,引擎才会收到下一个;更高的上限让引擎可以同时为多个 prompt 做 prefill,这样一个长 prompt 不会卡住排在它后面的请求。新的上限从下一个请求起生效,对已经在排队的请求同样适用;两个模型共用的引擎,按最近一个发给它的请求所带的上限计。计数只覆盖本网关进程发出的请求,所以如果引擎还有其他客户端,它们的请求仍可能在引擎里排队。

最大卸载输入

卸载策略还可以按大小限制哪些请求能被卸载。设置 N 个 token 的最大卸载输入(控制台里的 Max offload input (tokens),API 里的 max_input_tokens)之后,prompt 估计超过 N 个 token 的请求永远不会被发给卸载路由,只由该模型的其他路由处理,就像这个模型没有卸载路由一样:

  • 它会一直等自己的路由。它的尝试没有排队截止时间,也没有首 token 等待:等出站槽位最多等到获取超时,等首个 token 则是引擎需要多久就等多久。设置了引擎队列上限时,它照样在队列里占一个位置,按顺序等。

  • 它没有兜底。其他每条路由都失败时,请求会像没有卸载路由时一样失败。如果根本没有其他路由可以准入,请求会以 503 被拒绝,而不是被发给卸载路由。

  • 不超过 N 个 token 的 prompt 照常卸载。

大小是路由器自己对 prompt 的估计,也就是 prefill 感知的选路所用的那个(apps/backend/routing/prefill_load.py 里的 estimate_prefill_tokens):请求发往上游的全部内容——每条消息及其中的工具调用和结果、工具定义和响应格式——按每 4 字节 UTF-8 文本一个 token 计算,另外每张图片固定算 85 个 token,每段音频输入算 200 个。估计时不运行分词器,所以结果可能和服务商自己的计数有出入;如果这个上限是在代替某个硬性限制(比如卸载路由的上下文窗口),请留出一些余量。/v1/messages 用同样的方法估计 Anthropic 请求体的大小(包括 system prompt),并且只在模型的卸载路由设置了最大输入时才估计。如果卸载路由是原生 Anthropic 路由,还会计入 output_config.format 里的结构化输出 schema:这条路由会随请求体一起收到它,而基于 OpenAI 的路由永远收不到。pin 的请求不论大小,都发往它 pin 的地方。该字段留空,则任何大小的请求都可以卸载。

RouteWise

routewise 是第二个已注册的策略。它在不超出成本预算的前提下,尽量压低平均首 token 时延:它给每条可用路由计价,测量每条路由多快开始作答,然后挑出同样花费下最快的路由组合。它需要按模型显式开启(router: routewise),每个开启它的模型都有自己的 RouteWiseRouter 实例。它如何执行自己选中的路由,见路由内部机制。

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

即使缺了这个包——比如安装不完整,或者构建时没有带上这个策略——后端照样能导入,没有模型使用 routewise 的网关也能正常启动。但只要有任何模型选用了它,启动就会失败,错误消息会说明缺少这个包。

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

RouteWise 需要时延测量数据。在测过某个端点之前,它分不清哪个更快,于是把所有请求都发给最便宜的那个——结果另一个端点就永远测不到。测量数据来自线上流量、启动时回放的近期请求日志(db_bootstrap_enabled,默认开启),以及内置的时延探测器(routewise_probe_enabled,默认关闭);探测器会发送小的测试请求,不需要数据库。新部署没有日志可回放,所以要把探测器打开。

每个 worker 共用一个探测上限。routewise_probe_max_concurrency(默认 1)是按模型设置的,但同一个 worker 里的所有 RouteWise 模型共用一个上限,取它们要求的最小值。否则,同一个 provider 订阅下的两个模型可能同时去探测它,provider 拒掉多出来的那个时,被拒的可能是一个真实请求。如果在探测进行期间新增了模型,新的上限对所有尚未发出的探测生效。跨 worker 时,由一个数据库租约(routewise_probe_leases)防止两个 worker 同时探测同一个模型;它并不限制整个部署的探测流量。

配置按归属拆成两处:

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

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

资源上限以前放在 router_params: 里。现在启动时会拒绝这些键,报错里会指出对应的路由级替代写法,所以过时的注册表会直接报错,而不是悄悄用上默认值。

配额来源。quota_source: 是选择器,不是抓取器。它写明 provider / usage_label / unit,_find_usage 拿这三项去和 provider 配额抓取器返回的用量记录比对,三项都必须完全一致。RouteWise 通过 ProviderQuotaSnapshotStore(apps/backend/routing/routewise/quota.py)自己调用这些抓取器,而不是读管理端轮询器的缓存;它在 Providers 标签页用的那个注册表里查找抓取器:网关默认不启用任何抓取器,由后端扩展用 register_quota_fetcher 给每个 provider 注册一个(见配额报告)。usage_label 是抓取器自己定义的标签字符串,不是运维人员起的名字。

路由的 kind: 决定用哪种推理协议;provider: 标识这个部署用的服务或账号,可以和 kind 不同。不管抓取器用的是这些路由的推理 key,还是单独的计费凭据,它统计的都必须是这些路由实际使用的账号。本地配额示例用的是 kind: openai_compat 加 provider: example_quota,并注册了匹配的来源。它不需要真实的服务商账号就能跑;只有选用这份单独的注册表时才会用到它,默认的双 provider 示例保持不变。

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

管理员创建的路由上可能会出现 local provider,它不是配额来源。它只是网关内部的一个简单计数器,用于这样的配额路由:route_metadata 里设了 local_quota_fallback: true,或者路由的 provider 与上游 provider 不同;在 quota_source: 里写 provider: local 并不会启用它。它在 worker 进程里对请求计数,每次重启都从零开始,也不会跨 worker 累加,并在服务器本地时间的午夜重置。所以它只能表达每日的请求次数额度。四小时窗口、按月窗口、token 或花费上限,或者由 provider 决定何时重置的套餐,都需要一个真正的 quota_source。

端点标识。时延画像、可用性跟踪和请求日志对每条路由用同一个键,由 registry._make_provider_id 生成,格式是 {model_id}:{location}。base URL 指向本地主机时,location 是 local-{port};否则,通用 adapter kind 用从主机名取出的名字(api.minimax.io → minimax-api),其余 kind 用 {kind}-api。所以,只要改动碰到这个推导出来的部分(本地换了端口,远端换了厂商主机名),端点就会改名,时延画像从头开始;没碰到的改动(比如同一端口换一个本地主机)则保留原来的画像。静态 models.yaml 里的 route_id: 不会覆盖它;那个字段属于管理端的 provider-routes API。

冷启动。带配额的路由需要一份校准过的成本包络,由近期请求历史算出来。如果模型的配额路由旁边还有不带配额的路由,模型启动时处于降级状态:流量把包络校准出来之前,配额路由会被屏蔽。如果模型只有带配额的路由,启动时会抛出 EnvelopeNotCalibratedError,bootstrap 会把它继续往上抛,于是部署直接启动失败,而不是对外提供一个根本无法路由的模型。

worker 作用域。RouteWise 的决策状态、时延画像、配额与并发预留,以及按模型的状态转移锁,都是进程本地的。在 WEB_CONCURRENCY、UVICORN_WORKERS 或 GUNICORN_WORKERS 大于 1 时配置 quota 或 concurrency 路由,会在启动时抛错(这道保护就是配置字段 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 决定谁的请求能用这份凭据。

KeyPool(apps/backend/serving/adapters/key_pool.py)里的每把 key 都带一个 min_role,默认是 free,即不预留。取值更高时(pro、internal、admin),层级低于它的调用方就看不到这把 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 把它当作一次上游失败,转而回退到下一条路由。

  • 健康统计。如果换成不受限的调用方,这个池还能提供服务,那么这次拒绝就是 KeyPoolRoleRestricted(KeyPoolExhausted 的子类),EndpointHealthRegistry.record_failure 会跳过它。因为请求根本没有发到上游,这个端点也还在为拥有这些 key 的层级服务;如果把它计进去,一波低层级流量就能把熔断器打开,让本该享有预留容量的调用方也用不上。谁都用不了的池仍然是普通的 KeyPoolExhausted,照常计入。

  • 只用单个 adapter 的入口。/v1/messages 一开始就选定一个 adapter,不会沿着回退链往下走,所以它会挑第一个持有调用方可用 key 的 adapter。不这样做的话,如果第一个 adapter 的 key 是预留的,本来另一条路由能处理的请求就会直接失败。

调用方的角色由 API key 鉴权依赖写进请求上下文,key 池从那里读取。没有用户身份的请求(健康探测、预热、管理控制台的 playground)不带角色,一律按不受限处理:预留针对的是较低的层级,不是网关自己的内部机制。

怎么管理。预留通过管理 API 声明,而不是环境变量,因为池里有哪些 key 取决于每条路由的 api_keys 列表,这些 key 不一定来自 <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_role 由 KeyPool 执行,所以它对 openai_compat 路由(包括本地的 vLLM/SGLang/Ollama 服务)和继承它的 openrouter 生效;另外还有只配了单个 api_key 的路由——当且仅当有预留作用在它上面时,这类路由才会改走 key 池那条路径。专用的 anthropic、claude 和 gemini adapter 直接持有自己的凭据,从不注册到 dynamic_keys,所以对这几个 provider 记录的预留会保存下来,但永远不会生效。这些模型请改用模型目录里的 required_role 来限制。

API 端点

端点

用途

GET /v1/models

列出已发布的模型。

POST /v1/chat/completions

带自动路由的对话补全。

GET /health

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

GET /health/deep

每个端点的可用性、熔断状态,以及 route_exclusions。

GET /routing

每个模型当前的权重分布,外加探测循环的 endpoint_health 映射。

警告

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

GET /health/deep 同样不需要鉴权:它的 providers 键就是 endpoint_id(<model-id>:<location>),而每条 route_exclusions 记录还带着该路由的 base_url。两个端点都要设防,不是只管 /routing。

运行时管理接口都在 /admin/... 下,需要管理员鉴权,数据存在运行数据存储里。与路由相关的端点:

端点

用途

GET /admin/routing

权重分布,包括未发布的路由。

GET /admin/routing/provider-routes

网关服务的每一条路由,按模型列出,标着 source: yaml、override 或 runtime;.../{model_id} 只看一个模型。

POST /admin/routing/provider-route-models

创建一个运行时模型,连同它的第一条路由。

POST /admin/routing/provider-route-candidates/{model_id}

给模型加一条路由;对 .../{model_id}/{route_id} 做 PATCH 和 DELETE 来编辑或删除它。

PUT /admin/routing/provider-routes/{model_id}/{route_id}

把注册表里的路由改指向别处;DELETE 恢复 YAML 路由。

PUT / DELETE /admin/routing/weights/{model_id}/{endpoint_id}

设置或清除权重覆盖项。

PATCH /admin/routing/provider-route-strategies/{model_id}

切换模型的 router。

GET /admin/routing/offload-routes

每个模型的卸载路由,以及它当前是否生效;PUT / DELETE .../{model_id} 用来设置或清除——见排队等待卸载。

/admin/routewise/model-settings

按模型的 RouteWise 调参——见 RouteWise。

每个会改动路由的 POST 或 PUT,都有一个对应的 ...-verifications 端点,只用来试一下上游,什么都不保存。管理控制台的哪个标签页调用哪个端点、各自存了什么、这些状态启动时怎么和注册表合并,见从管理控制台做运行时配置。

代码在哪里

共四个部分,全部位于 apps/backend/routing/ 下:

层

代码

职责

部署级权重策略

manager.py + strategies/weight.py

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

按模型选择 router

model_router_registry.py + strategies/__init__.py

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

路由

routers.py(FixedRouter)、routewise/、hybrid.py(HybridRouter)

为请求选定端点,按容量做准入,按回退顺序逐个尝试,并记录端点健康状况。

执行

backends.py + dispatch.py

调用 router 已经选定的那个端点,或者把请求交给一个池,由池在自己声明的范围内选择。

apps/backend/routing/executor.py 只是为向后兼容保留的一层转发:它把 FixedRouter 重新导出为 RouteExecutor,同时导出 AllCircuitsOpenError、ProviderPinError 和 RouteConfig。不要改它——要改就改 routers.py。

router 如何把请求交给它选中的 adapter,以及跨本地池和云端池做规划的实验性组合,见路由内部机制。

添加路由策略

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

1. 理解这份契约

RouterProtocol(apps/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_provider(调用方的硬 pin,把路由限定到一个 provider,并关闭回退)、preferred_endpoint_id(首选端点;设了 require_target 时则必须用它)、endpoint_scope(允许的候选范围)、allow_fallback(执行失败后能不能换别的候选)、bound_endpoint(已解析好的 adapter 绑定)、require_target 和 required_modalities。RoutingObservation(routers.py)是服务层上报一次已完成请求用的:端点 id、TTFT、总时延、token 数、是否成功,以及策略自己在选路时填进去的 strategy_metadata 字典。无状态的策略在 record_observation 里直接返回 None;在线学习的策略在这里更新自己的模型。

另有两项可选能力:

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

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

2. 拿到路由

构造函数里不会把路由表交给你的 router。注册表先把 router 构造出来,再在它上面调用 attach_route_table(route_table)——前提是这个方法存在。你收到的对象满足 RouteTableView(apps/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.backends import LeafBackend
from routing.dispatch import binding_for_adapter
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}")
        leaf = LeafBackend.for_binding(binding_for_adapter(adapter, model_id=model_id))
        endpoint_id = leaf.endpoint_id
        self._health.ensure(endpoint_id)
        try:
            response = await leaf.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}")
        leaf = LeafBackend.for_binding(binding_for_adapter(adapter, model_id=model_id))
        endpoint_id = leaf.endpoint_id
        self._health.ensure(endpoint_id)
        try:
            async for chunk in leaf.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 都可见。

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

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

  • 通过叶子执行已选中的 adapter:遵循 Fixed 和 RouteWise 的接法,用 binding_for_adapter() 创建 binding,再用 LeafBackend.for_binding() 创建叶子,随后按 adapter 的参数签名调用它。准入、健康记录和 _routing 元数据留在拥有这些职责的 router 中。委托给池时,父层已经解析出精确目标才使用 ExecuteEndpoint;让子层选择时使用 DelegatePool。子层仍负责准入,因此还要通过 RoutingRequestOptions 传递已解析的 binding,以及目标和 fallback 控制字段。

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

注意:只有生效的 default_router 是 fixed 时,RoutingManager.apply() 才会重写权重;设成别的值,每个模型 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