跳转至

01 · 心跳机制:从启动到一次真实 ping

本文按调用顺序梳理后台心跳的完整运行链路。读完应该能回答以下问题:

  • _run_background_health_check 什么时候启动?只启动一次还是每个 worker 一份?
  • 一次 tick 经过哪些分支?SharedHealthCheckManager 内部什么情况下会真打模型?
  • litellm.ahealth_check 实际向上游发什么请求?
  • 通配符模型(openai/*)的心跳为什么会扇出多次调用?
  • 心跳结果除了存在内存里,还会写哪里?

1. 启动入口与"两层心跳"的边界

LiteLLM 里"心跳"有两个完全独立的层级,混淆它们是排障常见错误:

层级 触发者 文件 是否打上游模型
K8s 探针 外部 kubelet / 监控 litellm/proxy/health_endpoints/_health_endpoints.py 不打/health/liveliness 直接返回 200;/health/readiness 只查 DB / Cache / Callback;/health 读取后台 task 写入的内存结果
后台 task proxy 进程内 asyncio litellm/proxy/proxy_server.py:872 asyncio.create_task(_run_background_health_check()) 。每次真实 ping 都对每个 deployment 走完整推理调用

本文档只讨论后者。前者用法见上游 docs。

后台 task 的启动条件 proxy_server.py:871-875

# Start background health checks AFTER models are loaded and index is built
if use_background_health_checks:
    asyncio.create_task(
        _run_background_health_check()
    )  # start the background health check coroutine.

use_background_health_checks 来自 proxy_server.py:3170-3171

use_background_health_checks = general_settings.get(
    "background_health_checks", False
)

→ YAML 里 general_settings.background_health_checks: false(默认)就完全没有这个 task。

多 worker 场景:proxy 用 uvicorn 多 worker(NUM_WORKERS>1)时每个 worker 都是独立进程,每个进程都会起一份后台 task。这就是"多 Pod 放大"的另一种形式(同 Pod 内多进程也会放大)。


2. 主循环结构

_run_background_health_check 在 proxy_server.py:2130-2232`,骨架如下:

async def _run_background_health_check():
    global health_check_results, llm_model_list, health_check_interval, ...

    # 1. 入口校验
    if health_check_interval is None or not isinstance(health_check_interval, int) or health_check_interval <= 0:
        return

    # 2. 决定走 SHARED 还是 DIRECT
    shared_health_manager = None
    if use_shared_health_check and redis_usage_cache is not None:
        shared_health_manager = SharedHealthCheckManager(
            redis_cache=redis_usage_cache,
            health_check_ttl=DEFAULT_SHARED_HEALTH_CHECK_TTL,
            lock_ttl=DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL,
        )

    # 3. 主循环
    while True:
        _llm_model_list = copy.deepcopy(llm_model_list) or []
        # 过滤禁用心跳的模型
        _llm_model_list = [
            m for m in _llm_model_list
            if not m.get("model_info", {}).get("disable_background_health_check", False)
        ]

        if shared_health_manager is not None:
            healthy_endpoints, unhealthy_endpoints = \
                await shared_health_manager.perform_shared_health_check(
                    model_list=_llm_model_list, details=details_bool
                )
        else:
            healthy_endpoints, unhealthy_endpoints = await perform_health_check(
                model_list=_llm_model_list, details=health_check_details
            )

        # 写入进程内全局
        health_check_results["healthy_endpoints"] = healthy_endpoints
        health_check_results["unhealthy_endpoints"] = unhealthy_endpoints

        # 异步落库(仅状态变化时真实 INSERT)
        if prisma_client is not None:
            asyncio.create_task(_save_background_health_checks_to_db(...))

        await asyncio.sleep(health_check_interval)

关键事实

  1. 每次 tick 之前都重新 deepcopy(llm_model_list),所以新增 / 删除模型在下一个 tick 自动生效(store_model_in_db: true 配合 add_deployment_job 30s 轮询热更新)。
  2. disable_background_health_check 在每个 tick 重新检查,运行中改 model_info 也能即时生效。
  3. shared_health_manager 实例在 task 启动时创建一次,运行中改 DEFAULT_SHARED_HEALTH_CHECK_TTL env var 不会被读到(manager 实例已捕获了创建时的值)——重启 proxy 才能换。
  4. tick 间隔由 health_check_interval 控制,但 tick 本身不一定打模型——SHARED 路径下大多数 tick 都是 cache hit。

3. SharedHealthCheckManager 内部决策

代码:shared_health_check_manager.py

3.1 数据结构

Redis 里维护两个 key:

key 类型 TTL 内容
health_check_results string (JSON) health_check_ttl(默认 300) {healthy_endpoints, unhealthy_endpoints, timestamp, checked_by}
health_check_lock string lock_ttl(默认 60) leader pod_id(互斥,nx=True)

pod_id 在 manager 构造时生成:pod_{int(time.time() * 1000)} —— 每次进程启动唯一。

3.2 一次 tick 的决策路径

sequenceDiagram
    participant L as loop tick
    participant M as SharedManager
    participant R as Redis
    participant P as perform_health_check<br/>(真打模型)

    L->>M: perform_shared_health_check(model_list)
    M->>R: GET health_check_results

    alt cache 存在且 (now - timestamp) <= health_check_ttl
        R-->>M: cache_data
        M-->>L: 返回缓存的 healthy/unhealthy<br/>(此 tick 不打模型)
    else cache 不存在或过期
        M->>R: SET health_check_lock NX EX lock_ttl
        alt 抢到锁(成为 leader)
            R-->>M: OK
            M->>P: perform_health_check(...)
            P-->>M: healthy, unhealthy
            M->>R: SET health_check_results EX health_check_ttl<br/>{timestamp: now}
            M->>R: DEL health_check_lock
            M-->>L: 返回新结果
        else 没抢到锁(成为 follower)
            R-->>M: nil
            loop 每 2 秒 polling 一次,直到 lock_ttl 上限
                Note over M: asyncio.sleep(2)
                M->>R: GET health_check_results
                alt cache 出现(leader 写好了)
                    R-->>M: cache_data
                    M-->>L: 返回缓存,退出 polling
                else 还没出现
                    R-->>M: nil
                end
            end
            Note over M: lock_ttl 内仍未等到<br/>(leader 卡死/异常)
            M-->>L: 返回 ([], []),跳过这一 tick
        end
    end

3.3 follower 不再 fallback 自打 —— 本 fork 修复

🔧 本仓库的修复:上游 PR #15380 的原始实现里,follower 抢锁失败后只 asyncio.sleep(2) 就放弃等待、回退到本地 perform_health_check。在你们 prod (10 Pod × 28 deployment) 规模下,leader 实际跑完需要 12-30 秒,2 秒等不到 → N-1 个 follower 全部自打 → 总请求数被放大到 N × deployment 数

本 fork 把 follower 路径改成"polling 直到 lock_ttl 超时,超时直接放弃这一 tick"。修复见 shared_health_check_manager.py:218-285 perform_shared_health_check。详细动机和量化收益在 04-troubleshooting.md §5§实战案例

修复后的 follower 行为(简化伪代码):

# 抢锁失败 → 进入 polling
deadline = time.time() + self.lock_ttl   # 默认 60s,生产建议 180s
while time.time() < deadline:
    await asyncio.sleep(self.FOLLOWER_POLL_INTERVAL_SECONDS)  # 每 2s polling 一次
    cached = await self.get_cached_health_check_results()
    if cached is not None:
        return cached["healthy_endpoints"], cached["unhealthy_endpoints"]

# leader 在 lock_ttl 内没完成 → 放弃这一 tick,下一 tick(30s 后)再争锁
return [], []

关键不变量: - follower 绝不调用 perform_health_check,因此绝不直接打上游模型 - 总请求数 = leader 那一份 × 1,与 Pod 数无关 - 极端情况(leader 死了/卡死):这一 tick 返回空,下一 tick 重新选 leader,系统自愈

注意 lock_ttl 应大于一次完整 health check 的最大耗时。默认 60s 在 28 deployment 规模下偏紧,建议 prod 设置 DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL=180

3.4 频率为什么由 TTL 决定,不是 interval

很多人最初会以为 SHARED 模式下"每 health_check_interval 秒打一次模型"。错的

实际节奏: - health_check_interval 秒一次 tick(即调用 perform_shared_health_check) - 大多数 tick:cache hit,秒级返回,不打模型 - 只有 cache 过期(即上次写入超过 health_check_ttl 秒)的那一次 tick 才真正打模型

真实模型 ping 节奏 ≈ DEFAULT_SHARED_HEALTH_CHECK_TTL,与 health_check_interval 几乎无关。

health_check_interval 设小(如 30s)的好处:cache 过期那一刻能更快被某个 Pod 检测到、补上缓存。设大没意义。


4. DIRECT 路径:没有 Redis 协调时

shared_health_manager is None 的两种情况:

  1. YAML 没设 use_shared_health_check: true
  2. 设了但 redis_usage_cache is None —— 这通常意味着 litellm.cache.cache 不是 RedisCache 实例。常见触发点:
  3. litellm_settings.cache: false 或没配
  4. Redis 连接失败导致初始化降级(要看启动 log 里 _init_cache 的报错)

判断条件 proxy_server.py:2148

if use_shared_health_check and redis_usage_cache is not None:

redis_usage_cache 赋值条件 proxy_server.py:2519-2523

if litellm.cache is not None and isinstance(
    litellm.cache.cache, (RedisCache, RedisClusterCache)
):
    redis_usage_cache = litellm.cache.cache

DIRECT 路径下:

  • health_check_interval(默认 300)调一次 perform_health_check
  • 每个 Pod 独立运行,没有去重
  • 总请求数 = N(Pod) × deployment 数 / interval

→ 如果 health_check_interval 默认到 300s,多 Pod 部署看到的就是"每 5 分钟一批"——这跟 SHARED 模式 TTL=300 时的现象表面相同,但根因不同


5. 真实模型 ping 调用栈

入口 litellm/proxy/health_check.py:83-129 _perform_health_check

async def _perform_health_check(model_list: list, details: Optional[bool] = True):
    tasks = []
    for model in model_list:
        litellm_params = _update_litellm_params_for_health_check(model_info, litellm_params)
        timeout = model_info.get("health_check_timeout") or HEALTH_CHECK_TIMEOUT_SECONDS

        task = run_with_timeout(
            litellm.ahealth_check(
                model["litellm_params"],
                mode=mode,
                prompt=DEFAULT_HEALTH_CHECK_PROMPT,
                input=["test from litellm"],
            ),
            timeout,
        )
        tasks.append(task)

    results = await asyncio.gather(*tasks, return_exceptions=True)
    ...

5.1 deployment 去重

health_check.py:216-218 在执行前过 filter_deployments_by_id,按 model_info.id 去重。每个 model_list 条目都有独立 id,所以同名模型的多个 deployment 不会被合并

5.2 mode 派发:12 种心跳模式

litellm/main.py:7041-7070 ahealth_check,按 mode 派发到 health_check_helpers.py:get_mode_handlers

mode 实际调用 心跳载荷
chat acompletion messages=[{"role":"user","content":<random>}],随机选 "Hey how's it going?" / "What's 1 + 1?"
completion atext_completion prompt="test"
embedding aembedding input=["test"] —— 通常最便宜的心跳
audio_speech aspeech input="test", voice="alloy"(可被 health_check_voice 覆盖)
audio_transcription atranscription 内置 wav 文件
image_generation aimage_generation prompt="test"
video_generation avideo_generation prompt="test"
rerank arerank query="test", documents=["test"]
realtime _realtime_health_check WebSocket connect-only,不发消息
batch alist_batches 列表查询,不创建 batch
responses aresponses input="test"
ocr aocr 内置图片

mode 来源优先级(main.py:7104-7118): 1. model_info.mode 显式配置 2. litellm.model_cost[model].mode 从 cost map 自动推断 3. 默认 chat

5.3 缓存绕过

main.py:7120-7122 强制:

model_params["cache"] = {
    "no-cache": True
}  # don't used cached responses for making health check calls

→ 心跳调用永远不会命中 litellm.cache(即使 prod 开了 chat completion 缓存),保证健康状态真实可信。

5.4 通配符模型扇出

health_check_helpers.py:17-47 ahealth_check_wildcard_models

cheapest_models = pick_cheapest_chat_models_from_llm_provider(
    custom_llm_provider=custom_llm_provider, n=3
)
...
fallback_models = cheapest_models[1:]  # 后 2 个作为 fallback
model_params["model"] = cheapest_models[0]
model_params["fallbacks"] = fallback_models
model_params["max_tokens"] = 10
await acompletion(**model_params)

如果 model_list 里有 openai/*azure/* 这种通配符 deployment: - 最佳情况:首选成功,1 次真实调用 - 最坏情况:首选失败 + 2 个 fallback 全失败 + num_retries: 3 → 最多 (1+3) × 3 = 12 次真实调用 / 单个通配符 deployment

详见 03-cost-reduction.md §wildcard-的隐性放大

5.5 health_check_model 替换

health_check.py:144-146

_health_check_model = model_info.get("health_check_model", None)
if _health_check_model is not None:
    litellm_params["model"] = _health_check_model

如果 model_info.health_check_model 设了,心跳时把实际请求的 model 字段替换。典型用法:让 gpt-4o 这种贵模型的心跳改打 gpt-4o-mini,省成本。

5.6 Bedrock 区域路由特殊处理

health_check.py:159-185bedrock/region/model 格式做了路径剥离:心跳 bypass 了 get_llm_provider,需要手动把 bedrock/us-west-2/mistral.xxx 还原成 mistral.xxx 才能正确发请求。这是上游 issue #15807 的修复点,与你们日常 OpenAI / Azure 路径无关。


6. 结果落地与 HTTP 端点联动

6.1 进程内全局

proxy_server.py:2199-2202

health_check_results["healthy_endpoints"] = healthy_endpoints
health_check_results["unhealthy_endpoints"] = unhealthy_endpoints
health_check_results["healthy_count"] = len(healthy_endpoints)
health_check_results["unhealthy_count"] = len(unhealthy_endpoints)

/health/health/services 端点直接读这个 dict(不重新 ping)。

6.2 PostgreSQL 持久化

proxy_server.py:2204-2230 异步 task:

asyncio.create_task(
    _save_background_health_checks_to_db(
        prisma_client, _llm_model_list,
        healthy_endpoints, unhealthy_endpoints,
        start_time, checked_by=checked_by,
    )
)

_health_endpoints.py:_save_background_health_checks_to_db

  • 写入表 LiteLLM_HealthChecksmodel_id, status, timestamp, checked_by...)
  • 仅在状态变化时 INSERT(healthy → unhealthy 或反向),避免每分钟刷一遍 DB
  • checked_by 在 SHARED 模式下是 leader 的 pod_id,DIRECT 模式下固定 "background_health_check"

6.3 Redis 共享缓存(仅 SHARED)

shared_health_check_manager.py:153-194 cache_health_check_results,每次 leader 跑完 health check 都会更新 health_check_results key,TTL = health_check_ttl


7. 一图总结

flowchart TB
    Start["proxy 启动<br/>:872"]
    Start --> CheckEnable{"background_<br/>health_checks<br/>= true ?"}
    CheckEnable -- no --> End[完全没心跳]
    CheckEnable -- yes --> CreateTask["asyncio.create_task<br/>_run_background_health_check"]

    CreateTask --> CheckShared{"use_shared_health_check<br/>+ redis_usage_cache<br/>都 OK ?"}
    CheckShared -- yes --> CreateMgr["new SharedHealthCheckManager<br/>(TTL=DEFAULT_SHARED_HEALTH_CHECK_TTL)"]
    CheckShared -- no --> NoMgr["shared_health_manager = None"]

    CreateMgr --> Loop["while True:"]
    NoMgr --> Loop

    Loop --> Tick["tick: deepcopy llm_model_list<br/>过滤 disable_background_health_check"]
    Tick --> ChooseRoute{"manager 存在?"}

    ChooseRoute -- yes --> SHARED["SharedManager.<br/>perform_shared_health_check"]
    ChooseRoute -- no --> DIRECT["perform_health_check<br/>(直接打)"]

    SHARED --> CacheCheck{"GET cache key<br/>未过期?"}
    CacheCheck -- hit --> Return1[返回缓存结果]
    CacheCheck -- miss --> LockTry{"SET lock NX"}
    LockTry -- 抢到(leader) --> RunReal1["perform_health_check"]
    RunReal1 --> WriteCache["SET cache TTL=health_check_ttl"]
    WriteCache --> Return2[返回新结果]
    LockTry -- 没抢到(follower) --> Poll["polling loop:<br/>每 2s 检查 cache<br/>(总上限 lock_ttl)"]
    Poll -- cache 出现 --> Return3[返回缓存]
    Poll -- lock_ttl 超时 --> Skip["返回 [], []<br/>跳过这一 tick"]

    DIRECT --> RunReal2["真打 model_list"]

    RunReal1 --> AHC["litellm.ahealth_check<br/>main.py:7041"]
    RunReal2 --> AHC

    AHC --> Mode{"mode 派发"}
    Mode --> Real["acompletion / aembedding / ..."]

    Return1 --> Save["health_check_results dict<br/>+ asyncio task 写 DB"]
    Return2 --> Save
    Return3 --> Save
    Skip --> Save
    DIRECT --> Save

    Save --> Sleep["await asyncio.sleep(<br/>health_check_interval)"]
    Sleep --> Tick

    style CheckShared fill:#fdd,stroke:#c33
    style Poll fill:#dff,stroke:#39c,stroke-width:2px
    style WriteCache fill:#dfd,stroke:#3a3

下一步