路由内部机制

本页写给要修改路由引擎的贡献者。它讲 router 如何执行自己选中的端点、一个 router 如何把请求交给另一个 router,以及在此基础上搭起来的实验性组合。运行网关不需要配置这里的任何东西;需要配置的内容见路由。

简单来说:router 选定一个端点后,通过一层叫作叶子(leaf)的薄包装去调用该端点的 adapter;叶子只执行 router 选中的那个 adapter,别的什么都不做。池(pool)是另一种包装:它把请求交给第二个 router,由后者在一组有限的端点里自己做选择。目前在用的每个 router 都通过叶子执行;只有文末介绍的、需要显式开启的组合才会用到池。

请求如何到达 adapter

router 负责选择、准入、重试和反馈。选定端点后,它创建一个 EndpointBinding,里面放着选中的 adapter,再由 LeafBackend 调用这个 adapter。TreeBackend 提供的是另一种能力:把请求委托给一个 router,并限定它在声明好的候选池里工作;被包装的 router 保留自己的选择和执行流程。见叶子、池与派发指令。

启动时的组装逻辑在 apps/backend/serving/servers/bootstrap.py:先把模型注册表里的路由注册进一个进程级的 FixedRouter,按需应用 RoutingManager,再基于同一张路由表构建 ModelRouterRegistry。所有已知模型的 router 都在启动时就构造好,所以策略名或 router_params: 写错,启动时就会报出来,不会等到第一个请求。注册表给某个模型返回什么,取决于它的 router: 字段:

  • routewise——一个覆盖该模型完整候选池的 RouteWiseRouter,本地与云端一视同仁。

  • fixed——使用共享的 FixedRouter。只有模型用 router_params.hybrid_composition: true 显式开启组合,并且同时有本地和云端候选时,apps/backend/serving/servers/hybrid_composition.py 才会改为在 LocalBackend 和一个云端后端之上构建 HybridRouter。全是本地或全是云端候选的模型,即使开了这个选项,也仍使用共享的 FixedRouter。

普通的 Fixed 和 RouteWise 已经在使用叶子这层执行边界。现有配置不用开启组合,走的就是下面这两条路径:

FixedRouter     -> LeafBackend -> selected Adapter
RouteWiseRouter -> LeafBackend -> selected Adapter

这两个 router 都能在同一模型路由里的本地和云端端点之间选择。hybrid_composition 开启的是额外的一层 HybridRouter,由它在分开的本地池和云端池之间规划;无论是用叶子执行边界,还是用混合候选池,都不需要开这个选项。

叶子、池与派发指令

LeafBackend 沿用 adapter 的调用签名,直接转发 chat 调用或流式迭代器。它不实现 RoutingBackend 协议,那是按 router 的接口形状定义的。TreeBackend 这类池包装才实现这个协议,并把请求转给一个范围受限的 router。

组合派发时,HybridRouter 用两种显式指令(apps/backend/routing/dispatch.py)说明池可以做什么:

指令

权限

池的行为

ExecuteEndpoint(binding)

只执行绑定的那个端点。

TreeBackend 接受落在自己模型和端点范围内的绑定。子 router 对这个目标做准入,再通过叶子执行,不重新选择,也不回退。

DelegatePool(pool_id)

在指定池内选择。

TreeBackend 把选择、准入、重试和对冲都交给自己那个范围受限的 router。

所以精确派发也可以经过子 router 做准入,只是指令把这个 router 限定在绑定的端点上。check_dispatch() 和池的请求校验会在发生任何上游 I/O 之前,拒绝错误的池、被排除的模型或越界的绑定。这类组合错误直接向上抛出,不触发回退,也不记为 provider 失败样本。叶子没有在池内选择的能力,执行不了 DelegatePool。

EndpointBinding 里有端点 id、模型、池、一个用于诊断的路由表 generation,以及 adapter 本身。关键就在于持有 adapter:执行时不再查一次目标,所以管理员改了路由,也没法把在途请求改发到别处。构造绑定和叶子时都会校验端点身份,不一致就拒绝;叶子还会拒绝同时报告多个端点结果的组合执行器。每一路对冲请求都有自己的叶子。如果 RouteWise 某个端点的候选已经换成了另一个 adapter 对象,RouteWise 会在预留容量之前拒绝旧的绑定:准入和执行必须用同一个 adapter。generation 只用于诊断,不用来判断绑定是否过期。

记账留在做出选择的 router 里。选择、准入凭据、prefill 租约、重试和对冲的记录、_routing 元数据,以及交给 record_observation() 的反馈,都归选中该端点的 router 管。叶子不会再存一份这些状态,也不会重复记账。

池的范围既约束选择,也约束回退。LocalBackend 和 FixedCloudBackend 会校验模型授权,并在转给共享 router 之前收窄 RoutingRequestOptions.endpoint_scope。RouteWiseCloudBackend 还会绑定一个 RouteScopeView(route_scope.py),把子 router 的候选选择、重试、对冲请求和主动探测都限制在它的云端范围内。比较范围之前,provider 标签会在所请求的模型内展开成规范的端点 id。调用方的范围可以比池授予的更窄;交集为空时直接拒绝,绝不当成不受限制。

请求沿用 router 的 API。调用照样是 chat_completion() 或 stream_chat_completion(),带上 routing_options。每次组合尝试都由 dispatch_for_attempt() 构造指令;解析好的绑定、范围,以及目标和回退相关的控制项,经 RoutingRequestOptions 传给子 router。偏好的端点只是偏好:除非设置了 require_target,router 可以改选其他候选。allow_fallback 控制一次已执行的尝试失败后,还能不能重试。

准入被拒不算上游失败。配置好的池没有可用容量时,可以抛出 TargetUnavailableError。HybridRouter 接着执行下一个允许的尝试;回退成功就正常返回。如果所有尝试都在到达上游之前被拒,最终返回 503。流式响应如果已经发出了响应头,就在 SSE 错误里带上 503 错误码,而不是改 HTTP 状态码。如果确实有上游请求失败,最终返回哪个错误由 router 的错误选择规则决定。

组合按模型单独开启,默认关闭:

router: fixed
router_params:
  hybrid_composition: true

它更新亲和的方式、首选端点准入被拒后的重新选择,以及回退时检查熔断器的时机,都还没有证明与原来的 Fixed 循环等价,所以在证明之前,默认入口仍是共享的 FixedRouter;见 #1457。通过 HTTP API 和管理端 playground pin 住的请求,无论如何都仍走共享的 FixedRouter。

候选范围并不划分物理容量。每个 RouteWiseRouter 仍然持有自己的资源管理器;让相互独立的实例共享同一个受限池,以及拒绝不受支持的重复池配置,这两件事都推迟到同一个 issue 里处理。

router 类型与执行边界

路由层还有一个组合实现 HybridRouter(apps/backend/routing/hybrid.py),它在本地池和云端池之间规划,把每次尝试委托给一个 TreeBackend。它不是 router: 的第三种取值:router: fixed 的模型用 router_params.hybrid_composition: true 显式开启,而且只有同时存在本地和云端候选时才会构建组合。其他 fixed 模型仍使用共享的 FixedRouter,routewise 模型仍使用自己的入口和完整候选池。普通的 Fixed 和 RouteWise 路径本来就通过 LeafBackend 执行;开不开组合是另一回事。

池这一层存在的意义在于:一个算法可以负责某个域的准入,另一个算法负责该域内部的选择,而两者都不必枚举对方的端点。

下面的图根据可组合路由设计整理,画出当前实现及其计划中的扩展。第一张是类型关系图,后面三张是请求路径图。虚线方框或虚线连线表示尚未实现的部分。

FixedRouter、RouteWiseRouter 和 HybridRouter 都实现 RouterProtocol。Fixed 和 RouteWise 通过 LeafBackend 执行选中的端点,由 LeafBackend 调用一个 Adapter。HybridRouter 自己不选择端点,而是委托给 RoutingBackend 池契约,TreeBackend 通过持有一个范围受限的 Router 来实现该契约。GreedyRouter 和 NimbusRouter 画成虚线,因为它们尚未实现。

Router 契约与端点执行。虚线方框和虚线连线标出尚未实现的部分

FixedRouter 和 RouteWiseRouter 是 RouterProtocol 的同级实现;将来的 GreedyRouter 和 NimbusRouter 也在这一级。HybridRouter 也在这一级,但它是组合而不是策略:它自己不选端点,每次尝试都交给一个池。每个 router 保留自己的选择、准入、重试和反馈流程。LeafBackend 绑定一个 adapter 并执行它。它沿用 adapter 的调用约定,不实现按 router 接口定义的 RoutingBackend 协议,叶子正因此成为递归的终点。TreeBackend 实现这个池协议,并持有一个范围受限的 router;被持有的 router 可以是 RouteWise,RouteWise 并不会因此变成另一类策略。

全池 RouteWise:当前已支持

ModelRouterRegistry 将模型 A 交给全池 RouteWise。本地端点 L 和云端端点 C 各自拥有独立的 LeafBackend 和 Adapter。

全池 RouteWise 直接在本地与云端候选中选择

配置 router: routewise 时,注册表直接返回 RouteWiseRouter。它保留该模型的完整候选池、预留、重新求解、对冲和学习。图中两条分支只表示可能选中的端点,并不是两个都要调用:每次实际的尝试或每一路对冲请求,都执行各自绑定的叶子。普通的 router: fixed 路径同样是 FixedRouter → LeafBackend → Adapter,不需要开启组合。

Fixed 组合:需要显式开启

HybridRouter 与 FixedPolicy 通过 LocalBackend 或 FixedCloudBackend 派发。两个范围受限的 TreeBackend 包装持有同一个共享 FixedRouter,由它通过端点各自的 LeafBackend 和 Adapter 执行。

当前的 Fixed 组合:两个范围受限的池包装,共用一个 FixedRouter

混合候选的 router: fixed 模型,只有设置了 router_params.hybrid_composition: true 并且同时有本地和云端候选,才会走这条路径。HybridRouter 用 FixedPolicy 规划各次尝试;LocalBackend 和 FixedCloudBackend 把各自的范围和派发约束传给同一个共享的 FixedRouter。实例虽然共享,每次尝试各自的限制并不会因此失效。准入和健康记账仍由 router 负责,叶子只执行已绑定的 adapter。

每次尝试都带着 apps/backend/routing/dispatch.py 里两种指令中的一种;正因为有指令,它才是必须执行的要求,而不只是建议。首次尝试是 DelegatePool(pool_id):策略选出的目标作为偏好一起传下去,池可以在自己的范围内改选别的端点。预先规划好的每个回退都是 ExecuteEndpoint(binding),只能是那个端点,不能换,所以子 router 没法打乱组合已经定下的候选顺序。无论哪种指令,都会在任何上游 I/O 之前检查;池执行不了时报告组合错误,而不是回退。完整的表格见叶子、池与派发指令。

这是具体的组合类,不是公共的 router 接口。它的亲和处理、首选端点准入被拒后的重新选择,以及回退时检查熔断器的时机,都从未证明与普通 Fixed 路径等价(#1457 列出了这些差异),所以组合一直需要显式开启,没有成为默认。

Greedy 与云端 RouteWise:未来扩展

未来的 Greedy 接纳一个本地端点,或者把云端池委托给 TreeBackend 与一个仅覆盖云端的 RouteWise 实例。每个选中的云端端点各自拥有 LeafBackend 和 Adapter。Greedy 以及它发出的连线画成虚线,因为它们并不存在。

计划中的拓扑,目前还不能这样配置:Greedy 将负责本地准入,RouteWise 将负责云端池内部的选择

同一份 RouteWiseRouter 实现,既可以放在全池图里的模型入口,也可以放在这张图里只管云端的位置。这两种角色用的是不同的实例,各有各的范围;不会按请求去切换某个运行中实例的范围。本地准入被拒后,未来的 Greedy router 可以把云端选择委托给 TreeBackend,由其中的 RouteWise router 负责云端的选择、重试和对冲。

缺的是入口,而不是入口下面的池。TreeBackend 和 RouteWiseCloudBackend 现在就在 apps/backend/routing/backends.py 里,HybridFixedRouterFactory 也已经支持传入 cloud_backend 构造器(apps/backend/serving/servers/hybrid_composition.py),所以组合根(composition root)现在就能把一个只覆盖云端的 RouteWise 放到池下面。但 GreedyRouter 和 NimbusRouter 不是已注册的策略,models.yaml 里也没有字段能指定它们,所以目前没有任何配置能组装出这种结构。

这个例子讲的是两层路由,不是任意递归的配置。要让相互独立的 router 实例共享同一份物理配额或并发额度,还需要一套容量归属的实现,目前还没人写:范围分开并不会带来独立的容量;要做到这一点需要哪些工作,#1457 已经梳理过。本地/云端的归属与 provider_type 的取值 on_demand、quota、concurrency 互不相关。