LLM 服务的健康检查该怎么写:liveness 和 readiness 必须分开
健康检查大概是整个 LLM 服务里最不起眼、也最容易写错的一块。大多数人会写成"查一下依赖通不通,通就 200",这在普通 Web 服务里问题不大,放在 LLM 服务上会出事。
一、混在一起写会发生什么
先看一个典型的错误写法:
@app.get("/health")defhealth():ok=ping_llm_provider()# 探一下上游return{"status":"ok"ifokelse"down"}在 Kubernetes 里,/health通常被配成liveness 探针。liveness 的语义是"进程还活着吗",失败就重启容器。
问题就在这:上游抖了一下,你的全部实例被重启。
LLM 上游比普通依赖更容易抖(限流、排队、区域故障),我实测过同一批请求早上 p95 能到 22.85 秒、晚上只有 3.33 秒。一次上游抖动 → 健康检查全失败 → 全部实例重启 → 重启后冷启动又慢 → 健康检查继续失败。一次抖动被放大成一次雪崩。
这不是理论,是健康检查最经典的自伤式配置。
二、正确的切分
| 探针 | 回答什么 | 能不能碰网络 | 失败后果 |
|---|---|---|---|
| liveness | 进程活着吗 | 绝对不能 | 重启容器 |
| readiness | 能接客吗 | 可以 | 从负载均衡摘掉 |
对应到代码:
@app.get("/health",response_model=HealthOut)defhealth():"""K8s liveness 用的那种:进程活着就 200,不碰网络,永远不会因外部故障而假死。"""returnHealthOut(status="ok",uptime_s=round(time.time()-START_TIME,3),checked_at=datetime.now().isoformat(timespec="seconds"),)@app.get("/health/ready",response_model=ReadyOut)defready(ping:bool=Query(False)):providers=all_provider_status(settings,do_ping=ping).../health里一行网络调用都没有。它只回答"这个进程还在不在"。
/health/ready才查依赖,而且默认不真探活——要加?ping=1才去打各家/models。原因有两条:
- 就绪探针被调得很频繁,每次都打上游是浪费;
- 只列模型不产生 token 费用,但依然要网络往返,没必要每次都做。
三、降级态也要能表达
就绪探针的返回值不是只有"好/坏"两态,还有中间态——部分可用。
configured=[pforpinprovidersifp["configured"]]ifnotconfigured:status="not_ready"elifping:reachable=[pforpinconfiguredifp["status"]=="ready"]ifnotreachable:status="not_ready"eliflen(reachable)<len(configured):status="degraded"# 降级态:主用挂了,备胎还行degraded很关键。三家 provider 里挂了一家,服务其实还能用(有降级链),这时候该做的是告警,而不是把它摘掉或重启。
另外我把成本闸门也接进了就绪探针:
cost=summarize(settings)ifcost["over_limit"]:status="degraded"detail=f"本地成本账本已达闸门 ¥{cost['limit_cny']},禁止继续调用"为什么成本要参与健康判断?因为对一个 LLM 服务来说,"钱花超了"和"依赖挂了"是同一级别的不可用。早发现比事后对账强。
四、请求 ID:没有它,线上只能靠猜
classRequestIdMiddleware:"""纯 ASGI 中间件,不依赖 Starlette 的高层 API,升级不易碎。"""asyncdef__call__(self,scope,receive,send):rid=dict(scope["headers"]).get(b"x-request-id")rid=rid.decode()ifridelseuuid.uuid4().hex[:16]scope["request_id"]=ridasyncdefsend_wrapper(message):ifmessage["type"]=="http.response.start":message.setdefault("headers",[]).append((b"x-request-id",rid.encode()))awaitsend(message)awaitself.app(scope,receive,send_wrapper)两个要点:
- 支持透传:客户端带了就用客户端的,这样跨服务的链路能串起来。
- 写成纯 ASGI而不是继承
BaseHTTPMiddleware:后者会在每次请求额外起一个 anyio 任务组,有已知的性能和流式问题。纯 ASGI 就是处理三个 message 类型,没有隐藏开销。
五、成本账本:为什么用 append 而不是重写
defappend_entry(s:Settings,entry:dict)->None:entry.setdefault("ts",datetime.now().isoformat(timespec="seconds"))withopen(ledger_path(s),"a",encoding="utf-8")asf:f.write(json.dumps(entry,ensure_ascii=False)+"\n")一行调用一个 JSON 对象(JSONL 格式)。选 append 的理由很实在:进程崩了也不丢历史。如果是攒在内存里定期重写,崩掉的那一刻,最近一批记录全没了——而那批往往正是出问题时最需要的。
汇总时按行解析,顺便按 provider / model 聚合:
{"total_cny":0.103152,"calls":201,"limit_cny":20.0,"remaining_cny":19.896848,"by_provider":{"deepseek":0.103152,"zhipu":0.0,"dashscope":0.0},"by_model":{"deepseek-flash":0.103152,...}}by_model这一层不是凑数。真正排障时你要回答的是"钱是哪个模型烧掉的",只看总数答不了这个问题。
小结
| 要点 | 做法 |
|---|---|
| liveness | 只回答进程存活,绝不碰网络 |
| readiness | 才查依赖,默认不真探活,?ping=1才打 |
| 中间态 | 用degraded表达"部分可用",别只有好坏两态 |
| 成本 | 闸门状态参与健康判断 |
| 请求 ID | 支持透传,纯 ASGI 中间件实现 |
| 账本 | 追加写 JSONL,进程崩了不丢历史 |
下一篇:模型输出的 JSON 不可信——结构化输出的四层保障。