LiteLLM 路由内幕:一行 routing_strategy 如何变成一次具体的模型调用

基于 LiteLLM v1.97.0 源码逐行解读模型路由的完整链路:策略字符串怎么被识别成选择器、选择器为什么同时是日志回调、候选部署要过哪十一道漏斗、五种策略各自怎么记账怎么打分、失败怎么触发冷却、重试为什么睡 0 秒——把 config 里那一行配置到一次真实模型调用之间的所有代码打开给你看。

你在 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):同一个模型组内,失败后再选一次部署。
  • 内环 · 路由_acompletionasync_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-shufflelar1,加上 RoutingStrategy 枚举的六个成员——least-busylatency-based-routingcost-based-routingusage-based-routing-v2usage-based-routingprovider-budget-routing。拼错会直接抛 ValueError 并提示你检查 router_settings.routing_strategy(Proxy 模式下 config.yaml 的 router_settings 整段就是 Router 的构造参数)。

识别的核心在 _build_strategy_selector:一个 match 语句把策略字符串映射成选择器对象——

策略字符串选择器类状态存哪
simple-shuffle(默认)无选择器(纯函数)无状态
least-busyLeastBusyLoggingHandler{model_group}_request_count
usage-based-routing (v1)LowestTPMLoggingHandler{model_group}_map
usage-based-routing-v2LowestTPMLoggingHandler_v2{model_id}:{model}:tpm/rpm:{分钟}
latency-based-routingLowestLatencyLoggingHandler{model_group}_map
cost-based-routingLowestCostLoggingHandler{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,优先级从高到低:

  1. 请求级覆盖:Proxy 会把 API Key / 团队上配置的 router_settings.routing_strategy 塞进请求 kwargs。只有白名单内的策略被接受,坏值只打警告不生效——源码注释写得很直白:“存在 key 或 team 上的坏值永远不能打垮这个调用方的流量”。
  2. 路由组routing_groups):每个 model_name 至多属于一个组,组可以有自己的策略和独立的选择器状态(不同组的延迟账本互不污染)。
  3. 全局策略:构造函数那行 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_aliasopenai/* 这类通配符组走 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_deploymentsimple-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 深度上限约束):

  1. order 层级回退:候选里存在更高 order 层级(备胎层)时最先启用——主备切换优先于跨组回退;
  2. 组内加权 failover(可选开关 enable_weighted_failover,仅 simple-shuffle):同组内剔除已试过的部署再加权重抽一次;
  3. 按异常类型的专用回退context_window_fallbacks / content_policy_fallbacks
  4. 通用组回退fallbacks=[{"gpt-4o": ["claude-sonnet", ...]}],找不到本组条目再找 {"*": [...]} 通配默认。

成功响应会带上 x-litellm 系的重试/回退计数头——排障时先看响应头就能知道这次请求经历了几级阶梯。

番外 · 正在长出来的新策略族

router_strategy/ 目录里还有一排新目录:auto_router(引入 semantic-router 库,用 embedding 相似度把请求语义路由到不同模型)、complexity_routerquality_routeradaptive_routerlar1。它们不走”选择器打分”老路,而是挂在 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 秒后回归——两个实验各十几行代码,把第一、三、四幕全部点亮。

参考来源

源码与官方文档

本站相关文章