☰
01_LLM服务的健康检查该怎么写
2026/10/3 11:49:07 网站建设 项目流程

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。原因有两条:

  1. 就绪探针被调得很频繁,每次都打上游是浪费;
  2. 只列模型不产生 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 不可信——结构化输出的四层保障。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询