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:
→ 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)
关键事实¶
- 每次 tick 之前都重新
deepcopy(llm_model_list),所以新增 / 删除模型在下一个 tick 自动生效(store_model_in_db: true配合add_deployment_job30s 轮询热更新)。 disable_background_health_check在每个 tick 重新检查,运行中改model_info也能即时生效。shared_health_manager实例在 task 启动时创建一次,运行中改DEFAULT_SHARED_HEALTH_CHECK_TTLenv var 不会被读到(manager 实例已捕获了创建时的值)——重启 proxy 才能换。- 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-285perform_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 的两种情况:
- YAML 没设
use_shared_health_check: true - 设了但
redis_usage_cache is None—— 这通常意味着litellm.cache.cache不是RedisCache实例。常见触发点: litellm_settings.cache: false或没配- Redis 连接失败导致初始化降级(要看启动 log 里
_init_cache的报错)
判断条件 proxy_server.py:2148:
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 缓存绕过¶
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_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-185 对 bedrock/region/model 格式做了路径剥离:心跳 bypass 了 get_llm_provider,需要手动把 bedrock/us-west-2/mistral.xxx 还原成 mistral.xxx 才能正确发请求。这是上游 issue #15807 的修复点,与你们日常 OpenAI / Azure 路径无关。
6. 结果落地与 HTTP 端点联动¶
6.1 进程内全局¶
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_HealthChecks(model_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
下一步¶
- 全部配置项:02-config-reference.md
- 怎么把心跳成本压下去:03-cost-reduction.md
- 怎么排障:04-troubleshooting.md