Skip to main content

03 - 路由与容错:选哪个后端,怎么算出来的

数据快照 2026-08-19。代码引自当天拉取的 BerriAI/litellm@mainhigress-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 用完全不同的方式处理它。

步骤 4:不是选最快的,是在最快的一档里随机

sorted_deployments = sorted(potential_deployments, key=lambda x: x[1])
lowest_latency = sorted_deployments[0][1]

buffer = self.routing_args.lowest_latency_buffer * lowest_latency
valid_deployments = [x for x in sorted_deployments if x[1] <= lowest_latency + buffer]

random_valid_deployment = random.choice(valid_deployments)

这是整个策略里最关键的一行工程智慧。 如果严格选最快的那个,所有请求会瞬间涌向同一个后端,把它打慢,然后集体切到下一个 —— 典型的羊群效应。加一个 buffer 让"差不多快"的后端都进入候选,再随机选,把抖动抹平。

而且在这之前还有一次洗牌:

# randomly sample from all_deployments, incase all deployments have latency=0.0
_all_deployments = random.sample(list(_items), len(_items))

冷启动时所有后端延迟都是 0,如果不洗牌,sorted 的稳定性会让所有请求全打给列表里的第一个。这行注释写得很朴实,但它防的是一个真实会炸的线上问题。

二、最低成本:一个危险的默认值

lowest_cost.py 的逻辑简单得多 —— 算单价,排序,取第一个:

potential_deployments = sorted(potential_deployments, key=lambda x: x[1])
selected_deployment = potential_deployments[0][0]

注意它没有 buffer + 随机那一套,因为成本是确定值不会抖动。

真正值得注意的是取不到价格时的兜底:

if item_input_cost is None:
item_input_cost = item_litellm_model_cost_map.get("input_cost_per_token", 5.0)

if item_output_cost is None:
item_output_cost = item_litellm_model_cost_map.get("output_cost_per_token", 5.0)

# if litellm["model"] is not in model_cost map -> use item_cost = $10
item_cost = item_input_cost + item_output_cost

价格表里查不到的模型,单价按 5.0 + 5.0 算。 这是一个天文数字级别的惩罚性默认值(真实模型的 input_cost_per_token 通常在 1e-6 量级),效果等于"未知模型永远不会被选中"。

这个设计是对的 —— 成本路由下选一个不知道价格的模型是不可接受的风险。但它有个副作用:自建的、私有的、刚上线还没进 model_cost 表的模型,在成本路由下会被静默地完全排除。 排查这类"我的模型怎么永远没流量"的问题时,这是第一个该看的地方。

三、标签路由:多租户的雏形

tag_based_routing.py 的文件头把语义写得很清楚:

- If tags in request is a subset of tags in deployment, return deployment
- if deployments are set with default tags, return all default deployment
- If no default_deployments are set, return all deployments

匹配函数支持两种模式:

def is_valid_deployment_tag(
deployment_tags: Sequence[str], request_tags: Sequence[str], match_any: bool = True
) -> bool:
if not request_tags:
return False
dep_set = set(deployment_tags)
req_set = set(request_tags)

match_any=True 是默认值(交集非空即匹配),False 时要求请求标签是后端标签的子集。还支持正则形式的标签(_is_valid_deployment_tag_regex)。

标签路由是从"路由"通向"多租户"的桥。 一旦你能给请求打上 team:researchenv:prod 这样的标签并据此选后端,配额、计费、隔离就都有了挂载点 —— 这条线在第 04 篇继续。

四、预算:路由前的一道过滤器

budget_limiter.py 和上面几个策略不是一个层次的东西。它不参与"选谁",而是在选之前先把没钱的后端踢出去:

class RouterBudgetLimiting(CustomLogger):
async def async_filter_deployments(...)
def _filter_out_deployments_above_budget(...)

支持两个粒度,provider 级和 deployment 级:

current_spend = spend_map.get(f"provider_spend:{provider}:{config.budget_duration}", 0.0)
if config.max_budget and current_spend >= config.max_budget:
debug_msg = f"Exceeded budget for provider {provider}: {current_spend} >= {config.max_budget}"
current_spend = spend_map.get(f"deployment_spend:{model_id}:{config.budget_duration}", 0.0)

Key 的构造方式(provider_spend:{provider}:{duration})说明花费是按 provider + 周期聚合的,周期本身是 key 的一部分 —— 换周期等于换一套计数器,天然避免了跨周期污染。

五、容错:Higress 的 API Key 故障转移

LiteLLM 的容错做在"后端"这个粒度上。Higress 的 failover.go(764 行)做在更细的一层:单个 API Key

场景很实在 —— 同一个 provider 你配了十个 API Key 轮着用,其中一个被限流或封禁了,不该把整个 provider 判死。

配置校验:两条路必选其一

func (f *failover) Validate() error {
if f.healthCheckModel == "" && f.cooldownDuration <= 0 {
return errors.New("either healthCheckModel or cooldownDuration must be configured in failover config")
}

要么配主动健康检查(拿一个真实模型发探测请求),要么配冷却时长(时间到了自动放回)。 两个都不配就直接报错 —— 因为那样失败的 key 会永远出不来。默认值:

f.healthCheckInterval = 5000   // ms
f.healthCheckTimeout = 5000 // ms

恢复的两条路径

// 路径一:冷却到期,直接恢复
if now-since >= c.failover.cooldownDuration {
log.Infof("cooldown recovery: apiToken %s has cooled down for %dms, restoring to available list", apiToken, now-since)
removeApiToken(c.failover.ctxUnavailableApiTokens, apiToken)
}

// 路径二:主动健康检查,发真实请求探测
if c.failover.healthCheckModel != "" && len(unavailableTokens) > 0 {
for _, apiToken := range unavailableTokens {
healthCheckEndpoint, headers, body := c.generateRequestHeadersAndBody()
// ... 用 healthCheckModel 发一个真实的 completion 请求
}
}

主动健康检查用的是真实的 completion 请求,不是 ping。这一点很重要:API Key 的可用性没法用 TCP 或 HTTP 层探测出来,只有真的发一次推理请求,才知道它是不是还有额度、有没有被封。代价是健康检查本身要花钱。

Wasm 形态强加的复杂度

这段逻辑在库形态里十几行就写完了。但 Higress 是 Wasm 插件,每个 Envoy worker 线程有独立的 Wasm VM,如果每个 VM 都跑健康检查,十个 VM 就是十倍的探测请求和十倍的花费。

于是必须选出一个 VM 来干这件事,靠的是 CAS 租约(第 02 篇提过,这里是完整版):

func (c *ProviderConfig) tryAcquireOrRenewLease(vmID string) bool {
now := time.Now().Unix()
data, cas, err := proxywasm.GetSharedData(c.failover.ctxVmLease)
if err != nil {
if errors.Is(err, types.ErrorStatusNotFound) {
return c.setLease(vmID, now, cas)
}
return false
}
var lease Lease
json.Unmarshal(data, &lease)

// If vmID is itself, try to renew the lease directly
// If the lease is expired (60s), try to acquire the lease
if lease.VMID == vmID || now-lease.Timestamp > 60 {
lease.VMID = vmID
lease.Timestamp = now
return c.setLease(vmID, now, cas)
}
return false
}

setLeaseproxywasm.SetSharedData(key, value, cas) 做 CAS 写入,冲突就重试,上限 casMaxRetries = 10。租约 60 秒过期 —— 持有者所在的 VM 挂了,最多 60 秒后另一个 VM 会接管。

这是一个完整的、跑在 Envoy 进程内的分布式租约实现,只为了决定"谁来发健康检查请求"。

六、路由决策会反向影响后端性能

最后一个容易被忽略的点:网关的路由策略和推理后端的前缀缓存是耦合的。

vLLM 的 Prefix Caching 靠的是"相同前缀命中同一个实例上已算好的 KV Cache"。如果网关按最低延迟随机打散请求,同一个 system prompt 的请求会被分到不同实例,每个实例都得重算一遍前缀,缓存命中率直接崩掉。

这就是为什么生产级推理平台会做"缓存感知路由"(cache-aware routing)—— 让相同前缀的请求尽量粘在同一个后端上,哪怕它当下不是最快的那个。这条线在 vLLM 推理专题 · Prefix Caching 里有完整展开。

结论:如果你的后端是自建的 vLLM 集群而不是外部 API,那么本文讲的这几种策略都不能直接用 —— 它们全都假设后端是无状态的。

下一篇03 - 多租户与配额:虚拟密钥、预算、限流三件套,看 LiteLLM 和 Envoy AI Gateway 两条完全不同的技术路线。