跳转至

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_THRESHOLDconstants.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_failsallowed_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

router.py:487-489:

self.failed_calls = (
    InMemoryCache()
)  # cache to track failed call per deployment

注意:是 InMemoryCache()不是 DualCache()。意味着: - 多 Pod 间不共享计数器 - 每个 Pod 各自维护一份失败计数 - Pod A 上失败 10 次,Pod B 上失败 0 次,全局其实失败了 10 次但 V1 认为某个 Pod 是 0 次

多 Pod 部署用 V1 模式有副作用:实际触发 cooldown 比配置的阈值"延迟",因为分散到多个 Pod 各自计数。

详见 04-troubleshooting.md §4.

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 格式

deployment:25a8bd00-b1ba-489d-92f6-d878bbe46bae:cooldown

model_idmodel_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 过滤实现

router.py:9202-9223:

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-routinglowest-tpm-rpm-v2simple-shufflecost-based-routingleast-busy,都会先过这个过滤器,再做 strategy 自己的选择逻辑。

任何 routing strategy 下,cooldown 都自动生效,不需要额外配置。

6.4 读取:_async_get_cooldown_deployments

cooldown_handlers.py:323-348:

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_cooldownsasync_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 干两件事:

  1. prometheusLogger.set_deployment_complete_outage(...) —— gauge 标记完全 outage
  2. 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:&lt;id&gt;: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

下一步