你在 config.yaml 里写下一行
routing_strategy: usage-based-routing-v2,然后呢?这行字符串怎么变成”这次请求打到东京区的那台 GPT-4o 部署”?这篇把 LiteLLM Router 的源码打开,从策略识别到最终调用,把中间的每一步指给你看。
本文所有结论来自 LiteLLM 主干源码 commit b0fac57(pyproject 版本 1.97.0,克隆于 2026-08-11) 的静态阅读,函数名、缓存键、默认常量均照抄原文;文中行号在未来版本会漂移,但函数名可长期用于检索。这是《一把 Key 背后》网关六层解剖里”路由与回退层”的显微镜续篇:那篇说了路由层该做什么,这篇看 LiteLLM 实际怎么做。
全景:三层洋葱与一条时序
先建立骨架。router.acompletion() 不是直接调模型,而是把请求裹进三层嵌套:
- 外环 · 回退(
async_function_with_fallbacks):整组重试都失败后,换一个模型组再来。 - 中环 · 重试(
async_function_with_retries):同一个模型组内,失败后再选一次部署。 - 内环 · 路由(
_acompletion→async_get_available_deployment):真正的”选哪台”决策 + 实际调用。
sequenceDiagram
participant C as 调用方
participant F as 外环 fallbacks
participant R as 中环 retries
participant A as 内环 _acompletion
participant S as 漏斗+策略
participant L as LLM 部署
C->>F: acompletion(model=组名)
F->>R: 首次尝试
loop 最多 num_retries 次
R->>A: original_function
A->>S: async_get_available_deployment
S-->>A: 选中的 deployment
A->>L: 实际调用
L--xA: 失败 → 冷却回调记账
Note over R: 有健康替补则睡 0 秒立刻重选
end
R--xF: 组内彻底失败
F->>R: 换 fallback 模型组重进中环
F-->>C: 响应(带 x-litellm 重试/回退头)
关键分工:重试解决”这台机器不行”,回退解决”这个模型组不行”。重试每一轮都会重新走一遍完整路由(失败的部署此时已被冷却过滤器拦掉),所以 LiteLLM 的重试天然是”换机重试”而不是”原地重试”。
下面按一次请求的时间顺序拆五幕。
第一幕 · 识别策略:字符串如何变成选择器
合法值清单与映射表
Router.__init__ 接受 routing_strategy 参数(字符串或枚举)。_validate_routing_strategy 里写死了合法值:simple-shuffle、lar1,加上 RoutingStrategy 枚举的六个成员——least-busy、latency-based-routing、cost-based-routing、usage-based-routing-v2、usage-based-routing、provider-budget-routing。拼错会直接抛 ValueError 并提示你检查 router_settings.routing_strategy(Proxy 模式下 config.yaml 的 router_settings 整段就是 Router 的构造参数)。
识别的核心在 _build_strategy_selector:一个 match 语句把策略字符串映射成选择器对象——
| 策略字符串 | 选择器类 | 状态存哪 |
|---|---|---|
simple-shuffle(默认) | 无选择器(纯函数) | 无状态 |
least-busy | LeastBusyLoggingHandler | {model_group}_request_count |
usage-based-routing (v1) | LowestTPMLoggingHandler | {model_group}_map |
usage-based-routing-v2 | LowestTPMLoggingHandler_v2 | {model_id}:{model}:tpm/rpm:{分钟} |
latency-based-routing | LowestLatencyLoggingHandler | {model_group}_map |
cost-based-routing | LowestCostLoggingHandler | {model_group}_map |
最重要的一个设计:选择器就是日志回调
注意这些类的名字都带 LoggingHandler——它们全部继承 CustomLogger,并且在构造时被注册进 litellm.callbacks(least-busy 还额外挂进 litellm.input_callback)。这不是巧合,是整个路由器的中心设计:
每个策略选择器有双重身份:写路上,它是观察请求生命周期的日志回调(log_pre_api_call / log_success_event / log_failure_event),把”哪台部署正在处理几个请求、刚才那次用了多少 token、延迟多少”记进缓存;读路上,它是被路由查询的打分器(get_available_deployments),用刚才记的账挑下一台。路由状态不是靠侵入请求路径采集的,而是旁路记账——请求成功失败都会顺手把账本更新,路由决策只是账本的读者。
账本本身住在 DualCache(进程内存 + 可选 Redis 双层)。这一点对生产部署是硬约束:网关开多副本时,simple-shuffle 无状态无所谓,但 usage/latency/least-busy 的账本若只在各自内存里,每个副本只看得见自己那一半流量——v2 策略的文档字符串明说它是为跨实例设计的(“Meant to work across instances”,用 redis.incr 记账、redis.mget 批量读账)。双副本网关不接 Redis,就等于两个各自为政的半盲路由器(这正是单机高可用 ADR 解剖篇里双副本网关架构要面对的具体问题)。
策略解析的三级优先级
真正决定”这次请求用哪个策略”的是 _get_routing_context,优先级从高到低:
- 请求级覆盖:Proxy 会把 API Key / 团队上配置的
router_settings.routing_strategy塞进请求 kwargs。只有白名单内的策略被接受,坏值只打警告不生效——源码注释写得很直白:“存在 key 或 team 上的坏值永远不能打垮这个调用方的流量”。 - 路由组(
routing_groups):每个model_name至多属于一个组,组可以有自己的策略和独立的选择器状态(不同组的延迟账本互不污染)。 - 全局策略:构造函数那行
routing_strategy,不配则simple-shuffle。
第二幕 · 候选漏斗:谁有资格上场
策略打分之前,async_get_healthy_deployments 先把候选部署过一遍长漏斗。这是整个路由里代码量最大的部分——先约束,后优化:
flowchart TD
A["model_name 解析<br>别名 / 模型ID直指 / 通配符"] --> B["团队与访问组过滤"]
B --> C["健康检查过滤<br>可选 health-check routing"]
C --> D["冷却过滤<br>CooldownCache 里的部署出局"]
D --> E["屏蔽过滤<br>blocked=true 手动摘流量"]
E --> F["预检过滤 _pre_call_checks<br>上下文窗口 / RPM / 区域 / 参数支持"]
F --> G["标签路由 tag_based_routing"]
G --> H["order 过滤<br>只留最低 order 层级"]
H --> I["剩 0 台 → 抛异常<br>否则进入策略打分"]
几道值得细看的滤网:
- 模型名解析:传
model_id直指某台部署则跳过一切策略;传别名先查model_group_alias;openai/*这类通配符组走pattern_match_deployments模式匹配。都查不到且配了默认回退(default_fallbacks)时,整个 model 直接换成回退组重查。 - 冷却过滤:从
CooldownCache读当前被冷却的部署 ID 列表并剔除(第四幕细讲谁会进冷却)。全员冷却时有一个刻意收窄的兜底:仅当冷却是健康检查驱动(配置了allowed_fails_policy)时才无视冷却放行——真实请求失败导致的冷却绝不绕过。 - 预检过滤
_pre_call_checks(需enable_pre_call_checks=True):四类硬约束——①上下文窗口:数一遍输入 token(仅当部署声明了max_input_tokens才数,且至多数一次——注释注明 tiktoken 是这条热路径的主要开销),超窗的部署出局;②RPM:读本地缓存的当前分钟请求数,达到部署rpm上限的出局(源码里 v2 策略被显式豁免这道检查——它有自己的原子预检,见第三幕);③区域:allowed_model_region指定后,不在该区域或区域未知的出局;④参数支持:drop_params=False时,不支持请求里某参数(如response_format)的部署出局。 - order 过滤:部署可配
order: 1/2/...,漏斗只保留当前最低 order 层级——这是”主备”语义:order=2 的备胎平时根本进不了打分环节,只有主层全灭后由回退机制换层(外环会把更高 order 层级作为最优先的回退项)。
漏斗排空时抛出的异常会带上当前冷却名单等调试信息。注意一个容易误读的点:这条漏斗里的”RPM 过滤”是硬约束(过线出局),而策略打分里的”TPM 最低”是软目标(比较取小)——同一个指标在两处扮演不同角色。
第三幕 · 策略打分:五本账本五种挑法
漏斗剩下的候选交给策略。async_get_available_deployment 里 simple-shuffle 走独立快路径,其余策略经 _select_deployment_async 派发给选择器。逐个看实现细节——每个策略讲三件事:记什么账、怎么挑、有什么坑。
simple-shuffle:加权随机(默认策略)
不记账。按顺序找第一个存在的权重字段:weight > rpm > tpm,用 random.choices 按权重抽签;都没配则均匀随机。两台部署 rpm 配 100:900,流量就按 10%:90% 分。坑:权重取自 healthy_deployments[0]——只看第一台有没有配权重字段来决定这一轮用哪种权重,混配(一台配了 weight 一台没配)时未配的按 0 权重参与抽签。
least-busy:最少在途请求
- 记账:请求即将发出时(
log_pre_api_call,走input_callback)给该部署的在途计数 +1,成功或失败回调里 -1。账本是{model_group}_request_count一个字典。 - 挑法:取在途计数最小的那台;没账的新部署计 0(天然优先)。
- 坑:加减发生在两次独立的 get/set 缓存操作之间,无原子性;进程崩溃或回调漏触发会让计数漂移,且此策略只在 v1 风格的整字典读改写上工作——高并发下它是五种策略里账本最脆的。
usage-based-routing-v2:最低 TPM(生产主力)
- 记账:两组带分钟窗口的计数键——
{model_id}:{模型名}:rpm:{HH-MM}在发请求前用redis.incr原子 +1(相当于预扣一个名额);:tpm:键在成功后按响应 usage 里的真实total_tokens增加。键 TTL 60 秒,窗口自然滑动。写 Redis 走批量缓冲(0.1 秒同步一次),读走mget批量。 - 挑法:批量读出所有候选的当前分钟 tpm/rpm,剔除”tpm + 本次输入 token 会超限”或”rpm+1 会达限”的,在剩余里取当前 TPM 最低者,并列则随机。
- 额外一层保险:选中之后、真正调用之前还有
async_pre_call_check(在信号量内执行,专为修并发竞态,源码引 issue #2994)——先查本地缓存,可能超限才查 Redis,确认超限直接抛 429 并带retry-after: 60。也就是说 rpm 上限被检查了两次:漏斗里一次(软过滤),信号量里一次(硬拒绝)。
latency-based-routing:最低延迟
- 记账:
{model_group}_map里每台部署存一个滑动窗口列表(默认最多 10 条,TTL 1 小时)。存的不是原始延迟,而是”秒 / completion token”——总耗时除以输出 token 数,消除”答案长所以慢”的偏差;流式请求另存 TTFT(首 token 时间)列表,选择时流式请求优先用 TTFT 账本。超时失败罚记 1000.0 秒,一次超时就能把 10 条窗口的平均值拖高两个数量级——这是隐性的软冷却。 - 挑法:算每台的窗口均值,先剔除 tpm/rpm 预计超限者,然后取最低均值;配了
lowest_latency_buffer(如 0.5)则在”最低值 × 1.5 以内”的部署里随机挑——官方留的抗马太效应旋钮,防止流量全灌给最快那台。 - 坑:没打过账的新部署延迟记
[0]——冷启动的新部署在这个策略眼里是全场最快的,会先吃一波流量再被真实数据纠正。
cost-based-routing:最低成本
- 记账:只记每分钟 tpm/rpm(用于限额过滤),成本本身不用记——单价是静态的。
- 挑法:每台部署的成本 =
input_cost_per_token + output_cost_per_token,优先取部署上的自定义单价,否则查litellm.model_cost全局价目表;查不到的模型两项各按 5.0 美元记(合 $10/token)——贵到必输,“未知模型默认最贵”是刻意的安全设计。过滤限额后按成本升序取第一台,无随机化:同价永远选同一台。
usage-based-routing (v1):历史遗留
model_group 级整字典账本、只有同步实现(异步路径里被内联调用)。文档和代码都指向 v2,新部署没有理由选它,认识它只为读懂旧配置。
第四幕 · 反馈回路:失败如何变成冷却
Router 构造时把自己的 deployment_callback_on_failure 注册进全局失败回调——又是”旁路记账”模式。每次失败经两道判定决定要不要把部署投入冷却(CooldownCache,值里带异常信息、状态码、时间戳,TTL 即冷却时长,默认 5 秒):
第一道 _is_cooldown_required——按状态码分流(照抄源码逻辑):
| 异常 | 冷却? |
|---|---|
| 429 限流 | ✅ |
| 401 认证失败 | ✅ |
| 404 / 408 | ✅ |
| 其余 4xx(如 400 参数错) | ❌ 你的锅,不怪部署 |
| 5xx 及其他一切 | ✅ |
| 文本含 APIConnectionError | ❌ 网络层问题不冷却 |
第二道 _should_cooldown_deployment——按失败率与保护规则(默认 v2 逻辑,未配 allowed_fails 时):
- 429 → 直接冷却(单部署模型组除外);
- 当前分钟失败率 100% 且请求数 ≥ 1000(
SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD)→ 冷却; - 失败率 > 50%(
DEFAULT_FAILURE_THRESHOLD_PERCENT)且当前分钟请求数 ≥ 5(防单次失败误伤)且非单部署组 → 冷却; - 状态码属于”不该重试”类 → 冷却。
配了 allowed_fails(全局默认常量 3)或 allowed_fails_policy(按异常类型给不同限额)则走传统计数逻辑:当前分钟失败数超限即冷却。
注意反复出现的单部署保护:模型组里只有一台部署时,几乎所有冷却路径都被豁免——冷却唯一的部署等于把整组打死,不如让请求继续尝试。这个”宁可慢也不拒绝”的取舍,和延迟策略的 1000 秒软惩罚形成互补:多部署时用硬冷却快速摘除病号,单部署时靠重试硬扛。
第五幕 · 重试与回退:失败之后的两级阶梯
中环重试的三个设计值得抄:
- 重试次数的解析链:请求级
num_retries> 部署级num_retries(挂在异常对象上传回来)>retry_policy(按异常类型 × 模型组配不同次数,且优先级压过下面的短路检查)> Router 级默认。 - 短路清单
should_retry_this_error:有些错重试无意义,直接抛给外环换模型组——上下文超窗且配了context_window_fallbacks;内容策略违规且配了content_policy_fallbacks;不可重试状态码;NotFound;同组已无健康部署。 - 睡多久
_time_to_sleep_before_retry:同组还有其他健康部署时返回 0,立刻重试——因为重试即换机,没必要等病号恢复;只剩一台时才按Retry-After响应头 / 指数退避真正睡觉。这是”换机重试”语义下才成立的激进优化。
外环回退按固定顺序尝试(每步都受 max_fallbacks 深度上限约束):
- order 层级回退:候选里存在更高 order 层级(备胎层)时最先启用——主备切换优先于跨组回退;
- 组内加权 failover(可选开关
enable_weighted_failover,仅 simple-shuffle):同组内剔除已试过的部署再加权重抽一次; - 按异常类型的专用回退:
context_window_fallbacks/content_policy_fallbacks; - 通用组回退:
fallbacks=[{"gpt-4o": ["claude-sonnet", ...]}],找不到本组条目再找{"*": [...]}通配默认。
成功响应会带上 x-litellm 系的重试/回退计数头——排障时先看响应头就能知道这次请求经历了几级阶梯。
番外 · 正在长出来的新策略族
router_strategy/ 目录里还有一排新目录:auto_router(引入 semantic-router 库,用 embedding 相似度把请求语义路由到不同模型)、complexity_router、quality_router、adaptive_router、lar1。它们不走”选择器打分”老路,而是挂在 pre-routing hook 上——在漏斗之前直接改写 model(比如把别名 smart-router 改写成具体模型组)。
这印证了网关解剖篇元数据分界定律推出的预测:“质量路由会以小模型(embedding/分类器)长进网关内部”——auto_router 就是长出来的样子;也呼应评测路由篇的蒸馏定律:这些语义路由器的路由表,本质是某个评测信号的在线蒸馏。经典五策略调度的是基础设施指标(忙闲/延迟/用量/价格),新策略族开始调度语义与质量——两代策略的分界线清晰可见。
总表:五策略速查
| 策略 | 账本键 | 写路(何时记什么) | 读路(怎么挑) | 一句话适用 |
|---|---|---|---|---|
| simple-shuffle | 无 | 不记 | weight>rpm>tpm 加权随机 | 默认;多副本零依赖 |
| least-busy | {组}_request_count | 发出+1,结束-1 | 在途最少 | 长短请求混杂 |
| usage-based-v2 | {id}:{模型}:tpm/rpm:{分钟} | rpm 预扣 incr;tpm 成功后按实记 | 分钟窗口 TPM 最低,超限剔除 | 多实例+配额精细 |
| latency-based | {组}_map | 秒/token 滑窗 10 条;超时罚 1000s | 窗口均值最低(流式看 TTFT),buffer 内随机 | 延迟敏感 |
| cost-based | {组}_map(仅限额) | 只记 tpm/rpm | 单价最低;未知模型按 $10 必输 | 成本敏感 |
元规律:一个函数里的门禁与路由
把整条链路压缩成一句话(这是我的读法,不是官方文档表述):
LiteLLM 把”选部署”拆成两个性质不同的阶段:漏斗做硬约束(谁有资格上场),策略做软优化(上场的里面谁最好)——而支撑两者的状态,全部来自旁路回调的记账,请求路径本身不为路由付出感知成本。
这正是门禁篇与评测路由篇那对姊妹概念在一个函数内部的同框出镜:Gate 是约束器(冷却过滤、上下文窗口、RPM 硬限),Router 是优化器(最低延迟、最低 TPM、最低成本)——async_get_available_deployment 前半段全是 Gate,最后一步才是 Router。读任何路由系统的源码,先问”哪些代码在做资格审查、哪些在做打分排序”,结构立刻清晰。
诚实的提醒
- 本文基于 LiteLLM 主干 commit
b0fac57(pyproject 版本 1.97.0,2026-08-11 克隆)的静态源码阅读,所有函数名、缓存键格式、默认常量(冷却 5 秒、失败率阈值 50%、最小请求数 5、单部署保护阈值 1000、allowed_fails 默认 3、延迟窗口 10 条、超时罚 1000 秒、v2 键 TTL 60 秒)均照抄源码;未逐条在运行时验证,且主干代码与你安装的 release 版本可能有差异。 - “least-busy 账本最脆""冷启动吃流量”是我从实现推断的行为,不是复现过的实测结论。
- 成本最低的亲手验证实验:本地起一个 Router,两台 mock 部署(
mock_response参数即可,零成本),配routing_strategy: simple-shuffle且 rpm 100:900,跑 200 次统计流量比是否接近 1:9;再把其中一台的 mock 换成抛 429,观察它进冷却后流量归零、5 秒后回归——两个实验各十几行代码,把第一、三、四幕全部点亮。
参考来源
源码与官方文档:
- BerriAI/litellm(GitHub,commit b0fac57)——重点文件:
litellm/router.py、litellm/router_strategy/*.py、litellm/router_utils/cooldown_handlers.py - LiteLLM Routing 官方文档
本站相关文章:
- 一把 Key 背后:AI 网关到底替你收编了后端栈的哪几层——本篇是其”路由与回退层”的源码显微镜
- 评测路由篇:路由器是评测器的蒸馏
- 门禁篇:不可逆边界定律——漏斗=Gate、打分=Router 的概念来源
- 从 JD 到施工图:公司级大模型路由与评测系统
- 把指标写成条款:解剖一份单机高可用 ADR——双副本网关下路由账本需要 Redis 的语境