路由
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 都按下面的顺序逐步筛选模型的路由:
路由必须已发布且非空。否则就没有可用路由,请求返回 404。
模态过滤。请求带有非文本输入时,只保留
input_modalities能覆盖它的路由。一条都没有的话,抛出AllCircuitsOpenError,错误里写明需要哪些模态。显式 pin(pin)。在
/v1/chat/completions上带X-Route-Pin: <provider-or-endpoint-id>会直接选中那条路由并关闭回退——这是给管理员用的调试手段,不是面向客户端的功能。钉不到任何路由时抛出ProviderPinError。权重与熔断准入。去掉权重为
0的路由,以及熔断器已打开、或者已经有一个半开探测在跑的路由。一条都不剩时,AllCircuitsOpenError会列出它考虑过的端点。模型的卸载路由在这一步也会先放到一边,除非只剩它一条(见排队等待卸载)。会话亲和。如果这个调用方在这个模型上还有有效的 pin,就优先用它,除非 pin 住的端点已经积压(见会话亲和)。
加权抽取。把剩下的权重重新归一化到总和为 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 上压第二个请求。如果模型在别处还有健康的路由,就直接改用那一条。相关的四个设置:
变量 |
默认值 |
含义 |
|---|---|---|
|
|
熔断器打开前的连续失败次数。 |
|
|
放行一次半开探测前等待的秒数。 |
|
|
成功率下限:某次失败让端点的平均成功率跌到它以下时,熔断器打开。 |
|
|
可用性 EWMA 的平滑系数。 |
GET /health/deep 显示每个端点的可用性和熔断状态。provider 第一次拒绝网关自己的凭据时,它就把这个端点标为降级,不等成功率平均值降下来:平均值变化很慢,而 key 一旦被拒,每个请求都会失败。
这些状态按进程隔离,每个后端 worker 各有一套熔断器。
路由配置文件里的 health_check: 探测是另一回事,它只负责报告,从不改变请求发往哪个端点。见路由配置文件。
被排除在选路之外的路由
有效权重为 0 的路由,加权选路和所有回退循环都会跳过,所以它是配置里有、实际上用不到的容量。有四种机制会造成这种情况,GET /health/deep 会在 route_exclusions 下写明是哪一种——每个(模型,端点)组合一条记录,包含配置权重、有效权重和一个 reasons 列表:
原因 |
由谁设置 |
在哪里撤销 |
|---|---|---|
|
针对该(模型,端点)的一条 |
管理控制台 → 路由权重 |
|
针对该 provider 标签的一条 |
管理控制台 → providers |
|
|
该部署 overlay 的 |
|
模型注册表里的 |
该部署 overlay 的 |
只有在没有数据库的网关上才会出现 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 压力。
变量 |
默认值 |
含义 |
|---|---|---|
|
|
设为 |
|
|
积压达到该值后,负载开始优先于权重。 |
|
|
prompt 估算的未缓存 token 数达到该值时,算作大象。 |
|
|
每个端点允许的并发大象数。 |
|
|
积压超过该值时,放弃亲和 pin。 |
|
|
按(调用方,端点)记住的 prompt 大小保留多久。 |
这个模块从不阻塞、拒绝请求,也不让请求排队。它最多只是改选另一个本来就能准入的端点。
会话亲和
FixedRouter 按(调用方,模型)把上一次选中的端点 pin 住五分钟,TTL 是滑动的(routers.py 里的 AFFINITY_TTL_SECONDS)。这样做是为了:
把一段对话留在同一个后端上,让 prompt 缓存保持预热、时延保持稳定。
那个后端一出错就立刻丢掉 pin,免得调用方卡在出故障的 provider 上。
亲和键——由 apps/backend/serving/utils/request_ip.py 中的 derive_affinity_key() 生成,按下面的顺序取第一个匹配的:
已鉴权的请求:调用方 API key 的哈希。
以推理 grant 而非 key 发起的请求:
grant:<grant_id>。这类调用方通常共用一个 NAT 或中继地址,若用 IP 作键,会把所有并发任务都压到同一个端点上。匿名请求:
ip:<bucket>,其中 IPv6 会折叠到它的/64,这样轮换的隐私地址仍会落在同一个后端上。
所有会派发到 adapter 的请求入口(/v1/chat/completions、/v1/messages 和 /v1/embeddings)都会把这个键写进请求上下文,这样 router 的端点 pin 和 KeyPool 的上游 key 绑定认的是同一个调用方。没有调用方身份的内部流量(健康探测、预热、管理控制台的 playground)不写任何键,共用同一个匿名绑定。
pin 的生命周期:
来自
(key, model)的第一个请求 → 加权挑选 → 存下一条记录。TTL 之内、同一
(key, model)的后续请求复用同一个端点,并刷新 TTL。pin 住的端点只要抛出异常,这条记录就会被丢弃,接着执行回退;下一个请求会重新建立 pin。
如果 pin 住的端点已经不能准入(权重为
0,或者熔断器已打开),就丢掉这条记录,重新做一次加权挑选。如果 pin 住的端点 prefill 积压超过
ROUTING_PREFILL_AFFINITY_CEILING,这个请求就不走 pin,而是排除那个端点重新选路,再把 pin 改到新选中的端点上。pin 只是为缓存局部性做的优化,不承诺一定排队等那个端点。TTL 内一直没有流量,这条记录就过期。表里的条目超过
AFFINITY_SWEEP_THRESHOLD(1000)之后,才会惰性清理过期记录。不管调用方多忙,一个 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 一样受限。
变量 |
默认值 |
含义 |
|---|---|---|
|
|
设为 |
|
|
每个 provider key 的初始上限 |
|
|
上限最高能升到的值 |
|
|
两次上调之间需要的成功响应数 |
|
|
请求等待槽位的最长时间,超时后转去下一条路由 |
模型还可以把等待过久的请求交给一条专门为它们预留的路由;见排队等待卸载。
排队等待卸载
用 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里的RouteWiseConfigdataclass 逐字段生成的,那份 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 用不同的端点:
来源 |
端点 |
预留记录在哪里 |
|---|---|---|
数据库(从仪表盘添加的) |
|
|
环境变量( |
|
|
来自环境变量的凭据在数据库里没有自己的行,所以它的预留以哈希为键。这有两个后果:一是 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 来限制。
Auto 模型
前面讲的都是为请求点名的模型选择端点;auto 模型选择的是模型本身。客户端点名一个档位,例如 auto-balance,每个请求都会交给这个档位的某个候选模型,之后的处理与直接点名该模型的请求完全相同。
档位定义在模型注册表的 auto: 段里,和 models: 并列:
auto:
default_profile: balance # what a request for plain `auto` gets
session:
idle_timeout_seconds: 1800
max_sessions: 10000
profiles:
intelligence:
candidates:
- {model: <frontier-model>, reasoning_effort: high}
- {model: <mid-model>, reasoning_effort: high}
balance:
candidates:
- {model: <mid-model>}
- {model: <frontier-model>}
fast:
candidates:
- {model: <small-model>}
- {model: <mid-model>, reasoning_effort: low}
每个档位以 auto-<name> 的名字提供。只有运行时设置 auto_mode_enabled 打开时 auto 模型才存在,它默认关闭。关闭时,点名 auto 模型的请求得到 404,/v1/models 也不列出它们。点名真实模型的请求不受任何影响。
由哪个候选服务。列表中第一个能服务该请求的候选。候选在以下任一情况下会被跳过:
调用方不能直接点名使用它的模型:角色、可见性覆盖、调用方禁用的模型和 grant 的模型列表都会生效。
它的模型没有任何端点可以准入,因为 provider 被禁用,或者熔断器全部打开。
请求带了 tools,而模型不支持。
请求带有模型不接受的输入类型,例如图片。
prompt 估计值乘以 1.25,再加上按模型输出上限截断后的
max_tokens,超出了它的上下文窗口。有多条路由的模型按其中最小的窗口计算。模型不接受候选指定的
reasoning_effort。
只有请求自己没有设置推理强度时,才会使用候选的 reasoning_effort。
会话。在对话中途换模型,会让 prompt 缓存失效,还会把 agent 的计划交给一个没有参与制定它的模型。所以:
处在工具循环中的对话留在当前模型上。判断依据是最后一条消息带有工具结果。只有当前模型无法继续服务时才会换。
新的用户轮次会重新排序候选,所以回退过的对话会在首选模型恢复后回到首选。
Claude Code 的工具权限检查使用对话当前的模型,不改变任何会话状态。
对话由调用方、客户端声明的会话和对话开头的消息共同识别。这样 Claude Code 的子 agent 和辅助调用,不会和与它们共用一个会话的主对话混在一起。会话保存在进程内,重启后丢失。
如果会话换过模型,而请求要发往 Anthropic 原生端点,会先从历史中去掉 thinking block,因为这类上游会拒绝其他模型写出的 thinking。当前模型在工具循环最后一步的 thinking 会保留。
没有候选能服务时。调用方用不了档位内的任何模型时,请求得到 404,与未知模型的回答相同。能接这个请求的模型没有可准入的端点时,得到 503。调用方能用的每个模型都接不了这个请求时(例如 prompt 对它们都太长),得到 400。
客户端看到什么。只有 auto 模型,永远看不到实际服务请求的模型。响应中的 model、流式输出的每个 chunk 以及 Responses API 都给出请求的 auto 模型。错误消息里用它代替实际服务的模型,上游自己的错误消息也一样。控制台的请求历史和推理 grant 的请求账本同样显示 auto 模型,不显示 provider 和路由细节,错误消息也换成通用文本。/v1/models 只向能使用其中任一模型的调用方列出对应的 auto 模型。它的窗口、输入类型和工具支持取这些模型中能提供的最大值;采样参数和结构化输出只有在所有模型都支持时才列出。
记录了什么。日志行的 model_id 是实际服务该请求的模型,成本也按它计算。决策记在 metadata.auto 中:档位及其版本、按顺序排列的候选、每个被跳过的候选及原因,以及会话动作。
启动时,这一段会和网关启动时的模型对照检查。这一段无效时 auto 关闭,并在日志中列出其中的每个问题。档位名与已注册的模型重名时 auto 也会关闭,因为档位会把那个模型挡住。候选的模型未注册,或者候选要求的 effort 模型不接受,只记一条警告,请求会跳过它,直到它可以服务。管理 API 拒绝创建名为 auto 或 auto-<anything> 的模型。
管理后台的 playground 和控制台自己的模型列表不提供 auto 模型。设计见 auto 模式设计。
API 端点
端点 |
用途 |
|---|---|
|
列出已发布的模型。 |
|
带自动路由的对话补全。 |
|
存活探测,外加一个 |
|
每个端点的可用性、熔断状态,以及 |
|
每个模型当前的权重分布,外加探测循环的 |
警告
GET /routing 不需要任何鉴权,它会为每个已发布的模型返回各条路由的 provider、base_url 和权重。在公网主机上,这等于泄露你的上游拓扑——包括内网地址,以及路由 base URL 里出现的任何内部主机名。把它放到你的反向代理之后,或者干脆不要暴露它。
GET /health/deep 同样不需要鉴权:它的 providers 键就是 endpoint_id(<model-id>:<location>),而每条 route_exclusions 记录还带着该路由的 base_url。两个端点都要设防,不是只管 /routing。
运行时管理接口都在 /admin/... 下,需要管理员鉴权,数据存在运行数据存储里。与路由相关的端点:
端点 |
用途 |
|---|---|
|
权重分布,包括未发布的路由。 |
|
网关服务的每一条路由,按模型列出,标着 |
|
创建一个运行时模型,连同它的第一条路由。 |
|
给模型加一条路由;对 |
|
把注册表里的路由改指向别处; |
|
设置或清除权重覆盖项。 |
|
切换模型的 router。 |
|
每个模型的卸载路由,以及它当前是否生效; |
|
按模型的 RouteWise 调参——见 RouteWise。 |
每个会改动路由的 POST 或 PUT,都有一个对应的 ...-verifications 端点,只用来试一下上游,什么都不保存。管理控制台的哪个标签页调用哪个端点、各自存了什么、这些状态启动时怎么和注册表合并,见从管理控制台做运行时配置。
代码在哪里
共四个部分,全部位于 apps/backend/routing/ 下:
层 |
代码 |
职责 |
|---|---|---|
部署级权重策略 |
|
读取路由配置文件,按本地/远端的分流比例重写每个模型各条路由的权重。可选。 |
按模型选择 router |
|
把每个模型 id 映射到一个 |
路由 |
|
为请求选定端点,按容量做准入,按回退顺序逐个尝试,并记录端点健康状况。 |
执行 |
|
调用 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