01 · Cooldown 机制:从触发到恢复的全链路¶
本文按照"业务请求失败 → cooldown 决策 → 写入存储 → 路由层读取过滤 → TTL 恢复"的顺序梳理完整链路。读完应该能回答以下问题:
_set_cooldown_deployments在 Router 的哪些位置被调用?传什么参数?- V2 默认模式的 4 条触发路径分别对应什么真实场景?
- V1
allowed_fails模式跟 V2 是怎么区分的? - CooldownCache 在 Redis 里 key 长什么样?value 是什么?
- 路由层怎么读 cooldown 列表?所有 routing strategy 共用一套吗?
- 多 Pod 部署时 cooldown 状态怎么共享?
1. 触发位置:业务请求失败回调¶
整个 cooldown 链路只有 2 个真正的触发点,两个都在 litellm/router.py。
1.1 主触发点:deployment_callback_on_failure¶
代码位置:router.py:5740-5798
def deployment_callback_on_failure(
self, kwargs, completion_response, start_time, end_time
):
"""
Failure logging callback. Triggers cooldown if the failure meets criteria.
"""
# 从 kwargs 里挖 exception / status_code / deployment_id
...
_set_cooldown_deployments(
litellm_router_instance=self,
deployment=deployment_id,
exception_status=exception_status,
original_exception=original_exception,
time_to_cooldown=time_to_cooldown,
)
触发时机:业务请求(acompletion / aembedding 等)走完 LiteLLM logging 管线后,如果是失败响应,这个 callback 被调用。
time_to_cooldown 计算(router.py:5767-5772)的优先级:
deployment_cooldown = ... # 从 model_info["cooldown_time"] 读
header_cooldown = ... # 从响应头 Retry-After 读(主要 429)
time_to_cooldown = (
deployment_cooldown
or header_cooldown
or self.cooldown_time # router.cooldown_time
or DEFAULT_COOLDOWN_TIME_SECONDS # 5
)
→ 优先级:deployment 自定义 > 上游响应头 Retry-After > Router 全局 > 默认 5 秒。
1.2 提前触发点:async_pre_call_check 的 RateLimit 异常分支¶
代码位置:router.py:6040-6089
async def async_pre_call_check(self, deployment, parent_otel_span, ...):
try:
# 调 OpenAI/Anthropic 的 rate-limit pre-flight check
...
except RateLimitError as e:
...
_set_cooldown_deployments(
litellm_router_instance=self,
deployment=deployment["model_info"]["id"],
exception_status=429,
original_exception=e,
time_to_cooldown=time_to_cooldown,
)
触发时机:Router 在调真实 LLM 之前,做 rate-limit pre-call check(针对配置了 rpm/tpm 限制的 deployment)。如果 pre-check 提前发现这个 deployment 已经 hit rate limit,不等到真打就直接 cooldown。
→ 这是纯防御性触发,跟实际请求是否失败无关,是 Router 主动跳过即将限流的 deployment。
1.3 没有的触发点¶
为了避免误解,这些情景都不会触发 cooldown:
| 情景 | 是否触发 |
|---|---|
| 心跳 ping 失败 | ❌ health_check.py 不调 cooldown |
num_retries retry 中间的失败 |
❌ retry 内失败不写 cooldown,整体最终失败才走 callback |
| Fallback 链中失败的 deployment | ✅ 但每个 deployment 单独 cooldown |
| Auth check 失败(用户额度耗尽 / API key 错) | ❌ 这是 auth 层,跟 cooldown 不相关 |
disable_cooldowns=True |
❌ 全局禁用 |
| 上游返回 200 但内容异常 | ❌ 不是 HTTP 错误码 |
2. 第一关:状态码白名单 _is_cooldown_required¶
进入 _set_cooldown_deployments 后,先过滤掉不应该 cooldown 的错误。代码在 cooldown_handlers.py:40-95:
| 状态码 | 是否进 cooldown 流程 | 备注 |
|---|---|---|
| 401 Auth | ✅ | API key 过期、auth header 错 |
| 404 Not Found | ✅ | 上游模型不存在 |
| 408 Timeout | ✅ | 上游超时 |
| 429 Rate Limit | ✅ | 限流 |
| 5xx Server | ✅ | 上游故障 / 5xx |
| 400 Bad Request | ❌ | 用户 payload 错(跟 deployment 健康无关) |
| 403 Forbidden | ❌ | 同上 |
| 422 Validation | ❌ | 同上 |
APIConnectionError 字符串 |
❌ | LiteLLM 自己的网络层错(行 57-63) |
设计意图:不是 deployment 自己的问题(用户输入错、本地网络问题)就不该让它"背锅"被 cooldown。
3. 第二关 V2 默认模式:4 条触发路径¶
代码:_should_cooldown_deployment 第 195-249 行。
进入条件:router.allowed_fails_policy is None 且 router.allowed_fails is None(也就是没显式配 V1 模式)。
# 拉当前分钟内的成功/失败计数
num_successes = get_deployment_successes_for_current_minute(deployment_id)
num_fails = get_deployment_failures_for_current_minute(deployment_id)
total = num_successes + num_fails
percent_fails = num_fails / total if total > 0 else 0.0
# 4 条路径,任一命中就 cooldown
if exception_status_int == 429 and not is_single_deployment_model_group:
return True # 路径 1.1
elif percent_fails == 1.0 and total >= SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD:
return True # 路径 1.2
elif (percent_fails > DEFAULT_FAILURE_THRESHOLD_PERCENT
and total >= DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS
and not is_single_deployment_model_group):
return True # 路径 1.3
elif litellm._should_retry(status_code=exception_status_int) is False:
return True # 路径 1.4
return False
3.1 路径 1.1:429 + 多 deployment 立即 cooldown¶
| 触发条件 | 状态码 = 429 且 model_group 有多个 deployment |
|---|---|
| 设计意图 | 限流即时避让,让请求路由到其他 region/key |
| 排除场景 | 单 deployment model group,因为冷它就没人服务了 |
3.2 路径 1.2:100% 失败 + 流量 ≥1000¶
| 触发条件 | 当分钟 100% 失败 且 当分钟流量 ≥ SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD = 1000 |
|---|---|
| 设计意图 | 单 deployment model group 的"高流量保险"——确实挂了才冷 |
| 实际意义 | 你单 deployment 大流量服务时才会触发 |
SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD 在 constants.py:82-83,可通过 env 覆盖。
3.3 路径 1.3:失败率 > 50% + 流量 ≥ 5 + 多 deployment¶
| 触发条件 | 失败率 > DEFAULT_FAILURE_THRESHOLD_PERCENT = 0.5 且 当分钟流量 ≥ DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS = 5 且 多 deployment |
|---|---|
| 设计意图 | 统计学有意义的失败率(防止单次抽风触发) |
| 实际意义 | 大部分 prod 场景命中这条 |
两个常量都在 constants.py:38, 86,env 可覆盖。
⚠️ 暗坑¶
当分钟流量 < 5 时不触发。如果你某 deployment 每分钟只有 2-3 个请求,即使全失败,路径 1.3 也不会触发。这时只能靠路径 1.4(永久错误)兜底。
3.4 路径 1.4:永久错误立即 cooldown¶
| 触发条件 | litellm._should_retry(status_code=...) == False |
|---|---|
litellm._should_retry 的判断(在 litellm/utils.py 里搜 _should_retry):
| 状态码 | _should_retry 返回 |
即"应该 retry 吗" |
|---|---|---|
| 408 Timeout | True | retry |
| 429 Rate Limit | True | retry |
| 500 Internal | True | retry |
| 502 Bad Gateway | True | retry |
| 503 Unavailable | True | retry |
| 504 Gateway Timeout | True | retry |
| 401 Auth | False | 不重试 → cooldown |
| 404 Not Found | False | 不重试 → cooldown |
→ 路径 1.4 主要捕捉 401 / 404:retry 也救不了的"永久"错误,立即 cooldown 这个 deployment。
3.5 都不命中怎么办¶
→ 不 cooldown,但这次失败已经被 track_deployment_metrics.py 记账了({deployment_id}:fails key TTL 60 秒)。下一次失败再加 1,可能就触发路径 1.3 了。
4. 第二关 V1 模式:allowed_fails 累计计数¶
进入条件:YAML 里设了 allowed_fails 或 allowed_fails_policy。
代码:should_cooldown_based_on_allowed_fails_policy
allowed_fails = (
router.get_allowed_fails_from_policy(exception=...) # 按错误类型查阈值
or router.allowed_fails # 全局阈值
)
cooldown_time = router.cooldown_time or DEFAULT_COOLDOWN_TIME_SECONDS
current_fails = router.failed_calls.get_cache(key=deployment) or 0
updated_fails = current_fails + 1
if updated_fails > allowed_fails:
return True # 累计失败超阈值 → cooldown
else:
router.failed_calls.set_cache(
key=deployment, value=updated_fails, ttl=cooldown_time
)
return False # 还没到阈值,只 +1 累计
4.1 跟 V2 的关键差异¶
| 维度 | V2 默认(按失败率) | V1 allowed_fails(按计数) |
|---|---|---|
| 数据 | 当分钟成功+失败计数(百分比) | 累计失败计数(绝对数) |
| 阈值 | 50% 失败率 + 流量底线 | 配置的 allowed_fails 数量 |
| 触发感觉 | 突发抽风不一定触发 | 抽风 N 次必触发 |
| 适合 | 小流量 / 抗噪 | 大流量 / 严格 |
4.2 ⚠️ 关键暗坑:failed_calls 是 InMemoryCache¶
注意:是 InMemoryCache(),不是 DualCache()。意味着:
- 多 Pod 间不共享计数器
- 每个 Pod 各自维护一份失败计数
- Pod A 上失败 10 次,Pod B 上失败 0 次,全局其实失败了 10 次但 V1 认为某个 Pod 是 0 次
→ 多 Pod 部署用 V1 模式有副作用:实际触发 cooldown 比配置的阈值"延迟",因为分散到多个 Pod 各自计数。
4.3 计数器何时清零¶
通过 TTL,没有显式清零:
router.failed_calls.set_cache(
key=deployment, value=updated_fails, ttl=cooldown_time # ← TTL = cooldown_time
)
每次失败时刷新 TTL(同时 +1)。cooldown_time 内不再有任何失败 → TTL 过期 → 计数器消失。下次失败重新从 1 开始。
→ "成功一次"不会清零计数器。这是反直觉的设计——但合理:成功不代表之前的失败可以忽略。
5. 写入:CooldownCache.add_deployment_to_cooldown¶
代码:cooldown_cache.py:add_deployment_to_cooldown
async def add_deployment_to_cooldown(
self, model_id, original_exception, exception_status, cooldown_time,
):
_cooldown_time = cooldown_time or self.default_cooldown_time
cache_value = CooldownCacheValue(
exception_received=safe_dumps(original_exception), # 敏感信息已脱敏
status_code=str(exception_status),
timestamp=time.time(),
cooldown_time=_cooldown_time,
)
await self.cache.async_set_cache(
key=f"deployment:{model_id}:cooldown",
value=cache_value,
ttl=_cooldown_time,
)
5.1 Redis key 格式¶
model_id 是 model_info.id,每个 deployment 唯一(UUID)。
5.2 Value 结构¶
{
"exception_received": "<truncated/sanitized error string>",
"status_code": "429",
"timestamp": 1715269130.123,
"cooldown_time": 5.0
}
5.3 DualCache 双层¶
cooldown_cache.py:CooldownCache:
class CooldownCache:
def __init__(self, cache: DualCache, default_cooldown_time: float):
self.cache = cache
self.default_cooldown_time = default_cooldown_time
DualCache = InMemoryCache (60s 本地缓存) + RedisCache (cooldown_time 即 5s)。
- 内存层:当前 Pod 写入后,本地 60s 内查询无 Redis 往返
- Redis 层:多 Pod 共享,TTL 跟实际 cooldown_time 对齐
→ 但内存层 TTL(60s)远大于 Redis 层(5s),会不会出现"内存里以为还在 cooldown,Redis 里已经过期"?
实际上 async_get_active_cooldowns 每次都过 DualCache,DualCache 内部保证内存层不会比 Redis 层"过期更晚"——具体看 dual_cache.py。
6. 路由层读取与过滤¶
整个 LiteLLM Router 选 deployment 时,只在一处统一过滤 cooldown:_filter_cooldown_deployments。
6.1 调用链¶
async_get_healthy_deployments:
async def async_get_healthy_deployments(self, model, ...):
healthy_deployments = self.get_model_list(model_name=model)
# 拉当前 cooldown 列表
cooldown_deployments = await _async_get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
# 过滤
healthy_deployments = self._filter_cooldown_deployments(
healthy_deployments=healthy_deployments,
cooldown_deployments=cooldown_deployments,
)
return healthy_deployments
6.2 过滤实现¶
def _filter_cooldown_deployments(
self, healthy_deployments: List[Dict],
cooldown_deployments: List[str],
) -> List[Dict]:
if not cooldown_deployments:
return healthy_deployments
cooldown_set = set(cooldown_deployments) # O(1) 查询
filtered = [
d for d in healthy_deployments
if d.get("model_info", {}).get("id") not in cooldown_set
]
return filtered
简单的"集合差集"过滤。
6.3 所有 routing strategy 共用¶
async_get_healthy_deployments 是所有 routing strategy 的统一入口。无论你配的是 latency-based-routing、lowest-tpm-rpm-v2、simple-shuffle、cost-based-routing、least-busy,都会先过这个过滤器,再做 strategy 自己的选择逻辑。
→ 任何 routing strategy 下,cooldown 都自动生效,不需要额外配置。
6.4 读取:_async_get_cooldown_deployments¶
async def _async_get_cooldown_deployments(litellm_router_instance, parent_otel_span):
model_ids = litellm_router_instance.get_model_ids() # 所有 deployment id
cooldown_models = (
await litellm_router_instance.cooldown_cache.async_get_active_cooldowns(
model_ids=model_ids,
parent_otel_span=parent_otel_span,
)
)
# 返回 [(deployment_id, cooldown_value), ...]
...
return [cv[0] for cv in cooldown_models]
底层 cooldown_cache.py:async_get_active_cooldowns 用 async_batch_get_cache(Redis MGET)一次性拉所有 deployment 的 cooldown key,过滤出存在的。
性能:async_batch_get_cache 是 MGET 不是 SCAN,所以是 O(N) 拉 N 个 deployment id 的 key,但单次 Redis round-trip。28 个 deployment 一次 MGET 几毫秒。
7. 恢复:TTL 自然过期¶
整个 cooldown 机制没有任何"主动恢复"逻辑。流程:
T=0 deployment X 失败 → 写 Redis "deployment:X:cooldown" TTL=5s
T=0~5 业务请求路由时:
async_get_healthy_deployments → 拉 cooldown 列表 → X 在里面 → 过滤掉
选 model_group 其它 deployment 发请求
T=5 Redis key 自动过期消失
T=5+ 下一次请求 X 重新进备选池
T=5+ 如果路由又选到 X 又失败 → 再写 cooldown key TTL=5
... 循环
7.1 没有"探活"¶
Router 不会自己跑一个小请求去测 X 是否恢复。靠的是真实业务请求"撞" X 来探测。
7.2 没有"指数退避"¶
每次失败都是 cooldown_time 秒,不会因为 X 反复失败延长冷却时间。
7.3 没有"成功清零"¶
Cooldown key 是单方向的:到期就消失。"X 之前 cooldown 过现在好了"这件事,Router 知道的方式只有"TTL 过期"。
8. Cooldown 事件 callback:仅 Prometheus¶
每次 _set_cooldown_deployments 写入成功,cooldown_handlers.py:311-318:
asyncio.create_task(
router_cooldown_event_callback(
litellm_router_instance=litellm_router_instance,
deployment_id=deployment,
exception_status=exception_status,
cooldown_time=time_to_cooldown,
)
)
cooldown_callbacks.py:router_cooldown_event_callback 干两件事:
- 调
prometheusLogger.set_deployment_complete_outage(...)—— gauge 标记完全 outage - 调
prometheusLogger.increment_deployment_cooled_down(...)—— counter 累计 cooldown 次数
→ 仅 Prometheus 通知,业务代码不能 hook 这个事件(除非自己改源码加 callback)。
具体指标见 02-config-reference.md §6 可观测性.
9. 一图总结¶
flowchart TB
Start["业务请求 acompletion(...)"]
Start --> Route["Router.async_get_healthy_deployments<br/>:8523"]
Route --> ListCD["_async_get_cooldown_deployments<br/>(Redis MGET 拉所有 cooldown key)"]
ListCD --> Filter["_filter_cooldown_deployments<br/>:9202"]
Filter --> Strategy["routing_strategy 选具体 deployment"]
Strategy --> PreCheck{"async_pre_call_check<br/>rate-limit pre-check"}
PreCheck -- "RateLimitError" --> Cool1["_set_cooldown_deployments<br/>(:6066, 提前 cooldown)"]
PreCheck -- OK --> CallLLM["真实调上游 LLM"]
CallLLM --> Result{"成功 / 失败 ?"}
Result -- 成功 --> Done[返回响应]
Result -- 失败 --> FailCB["deployment_callback_on_failure<br/>:5740"]
FailCB --> Cool2["_set_cooldown_deployments<br/>(:5782)"]
Cool1 --> Gate1{"_is_cooldown_required<br/>状态码白名单<br/>(401/404/408/429/5xx ?)"}
Cool2 --> Gate1
Gate1 -- 通过 --> Mode{"V1 or V2 模式 ?"}
Gate1 -- 不通过 --> Skip[直接放行不 cooldown]
Mode -- "V2 默认" --> V2["_should_cooldown_deployment<br/>4 条触发路径"]
Mode -- "V1 设了 allowed_fails" --> V1["should_cooldown_based_on_allowed_fails_policy<br/>累计计数 vs 阈值"]
V2 -- 任一命中 --> Write["CooldownCache.add_deployment_to_cooldown<br/>SET deployment:<id>:cooldown EX cooldown_time"]
V2 -- 都不命中 --> Skip2[只记账,不 cooldown]
V1 -- "fails > 阈值" --> Write
V1 -- 未达阈值 --> Skip3[只 +1 累计]
Write --> Promo["asyncio.create_task<br/>Prometheus 指标更新"]
Write --> TTL["TTL 到期前路由层自动跳过<br/>TTL 过期 deployment 重新进备选"]
style Cool1 fill:#fdd
style Cool2 fill:#fdd
style Gate1 fill:#fdd
style V2 fill:#ffd
style V1 fill:#ffd
style Write fill:#dfd
style Filter fill:#dfd
下一步¶
- 全部配置项:02-config-reference.md
- 推荐配置 + 跟其他机制叠加:03-best-practices.md
- 排障 + 实战案例:04-troubleshooting.md