03 - 路由与容错:选哪个后端,怎么算出来的
数据快照 2026-08-19。代码引自当天拉取的
BerriAI/litellm@main与higress-group/higress@main。
网关最核心的一次决策发生在毫秒级:同一个模型名下挂着 N 个后端,这次请求发给谁。
LiteLLM 把这个决策抽象成了 router_strategy/ 目录下的一组策略类,每个策略回答同一个问题的不同版本。
litellm/router_strategy/
├── lowest_latency.py 542 行 ← 最低延 迟
├── lowest_tpm_rpm_v2.py 624 行 ← 最少已用配额
├── lowest_cost.py 305 行 ← 最低单价
├── budget_limiter.py 843 行 ← 预算过滤
├── tag_based_routing.py 668 行 ← 标签匹配
└── complexity_router/ 119 KB ← 按请求复杂度选模型
前置:01 - 网关是什么 里的三个词 —— deployment(一个具体可调用的模型端点)、TPM / RPM(provider 给的每分钟 token / 请求硬上限)、fallback(失败了换一个)。这三个不清楚的话先回去看一眼,本篇全程用它们。
本篇回答:同一个模型名下挂着 5 个 deployment,网关凭什么选中其中一个?这个决策的真实代码长什么样、藏着哪些坑。
读法建议:第一节(最低延迟)是全篇最重要的,四个步骤每一步都对应一个真实的线上故障。看懂它,后面几种策略都是同一套骨架换个排序依据。
一、最低延迟:比想象中复杂得多
lowest_latency.py 的核心是 _get_available_deployments()。整个流程分成四步,每一步都藏着一个工程决策。
步骤 1:延迟数据存哪、存多久
class RoutingArgs(LiteLLMPydanticObjectBase):
ttl: float = 1 * 60 * 60 # 1 hour
lowest_latency_buffer: float = 0
max_latency_list_size: int = 10
每个后端只保留最近 10 次延迟,TTL 一小时。 不是滑动窗口平均,不是 EWMA,就是一个定长列表。写入时:
if len(request_count_dict[id].get("latency", [])) < self.routing_args.max_latency_list_size:
request_count_dict[id].setdefault("latency", []).append(final_value)
else:
request_count_dict[id]["latency"] = request_count_dict[id]["latency"][:-1] + [final_value]
这个实现有个容易忽略的性质:列表满了之后,替换的是最后一个元素,而不是最老的元素。 也就是说前 9 个样本一旦写进去就再也不会被挤出,只有第 10 个位置在滚动。延迟统计因此会带上很重的历史惯性 —— 这在后端性能长期稳定时无所谓,但在后端刚从故障中恢复时,会让它长时间"背着旧账"。
步骤 2:流式请求用 TTFT,非流式用总延迟
use_ttft = (
request_kwargs is not None
and request_kwargs.get("stream", None) is not None
and request_kwargs["stream"] is True
and len(item_ttft_latency) > 0
)
if use_ttft:
for _call_latency in item_ttft_latency:
if isinstance(_call_latency, float):
total += _call_latency
item_latency = total / len(item_ttft_latency)
else:
# 用总延迟
这一步是整个策略里最正确的设计。 流式场景下用户感知的是首字延迟(TTFT),非流式场景下感知的是总耗时,两者根本不是同一个指标。而且 TTFT 在记录时还做了归一化:
time_to_first_token = safe_divide_seconds(ttft_seconds, completion_tokens)
除以了输出 token 数 —— 严格说这算出来的是"每 token 的平均首字延迟",语义上有点怪,但它让长短不一的请求可以横向比较。
步骤 3:先按配额硬过滤,再排序
if (
item_tpm + input_tokens > _deployment_tpm or item_rpm + 1 > _deployment_rpm
):
continue
else:
potential_deployments.append((_deployment, item_latency))
配额是按分钟粒度记的,key 长这样:
current_date = datetime.now().strftime("%Y-%m-%d")
current_hour = datetime.now().strftime("%H")
current_minute = datetime.now().strftime("%M")
precise_minute = f"{current_date}-{current_hour}-{current_minute}"
注意这是自然分钟对齐,不是滑动窗口。 意味着每分钟的第 0 秒配额会瞬间清零,突发流量可以在分钟交界处打出两倍于限额的量。这是所有用"当前分钟做 key"的限流实现的通病,第 04 篇会看到 Envoy 用完全不同的方式处理它。