1. 为什么 LLM 服务的健康检查不能“一锅炖”:从一次线上雪崩说起
去年底,我们上线了一个基于 Llama-3-70B 的推理服务,部署在 Kubernetes 集群中,用的是标准的vLLM+FastAPI封装方案。上线第三天凌晨两点,监控告警疯狂刷屏:503 Service Unavailable暴涨,Prometheus 显示该服务的ready状态在 3 分钟内从 100% 断崖式跌到 0%,但liveness探针却始终返回 200。K8s 没杀它,它也没重启,就卡在那儿——像一具“活着的尸体”,持续拒绝新请求,把上游网关的连接池全占满,最终拖垮了整个 AI 中台。
事后复盘,根因就藏在探针配置里:我们图省事,把/healthz同时挂给了livenessProbe和readinessProbe,逻辑是“能连上就代表健康”。可问题在于,LLM 服务的“活着”和“能干活”根本不是一回事。liveness要回答的是:“进程还在跑吗?内存泄漏炸没炸?线程死锁了没?”而readiness必须回答:“模型加载完了吗?KV Cache 初始化好了吗?GPU 显存够不够撑住下一个 batch?当前负载是否已超水位线?”——这两个问题的答案,在 LLM 场景下,99% 的时间都是不同的。
这绝非个例。翻看社区 issue、生产事故报告,甚至某头部云厂商的公开故障通告,你会发现一个高频模式:LLM 服务在高并发预热阶段、长上下文推理后、或模型权重热更新时,readiness探针失败率飙升,但liveness却纹丝不动。K8s 于是不断把流量切过去,结果新请求全被429 Too Many Requests或500 Internal Server Error拒绝,集群资源被无效占用,运维同学半夜被叫醒,第一反应却是“删 Pod 重来”,治标不治本。
关键词LLM、liveness、readiness、健康检查、Kubernetes,它们组合在一起,指向的不是一个配置技巧,而是一套针对大模型服务特性的运行时治理哲学:你必须承认,LLM 不是传统 Web 服务。它的启动耗时以分钟计(模型加载、CUDA 初始化),它的资源消耗是动态且非线性的(显存占用随 prompt length 指数增长),它的“健康”状态是分层的、有状态的、带上下文的。把liveness和readiness混为一谈,等于用一把尺子去量体温和血压——量出来的不是数据,是灾难。
所以,这篇笔记不讲“怎么写一个健康接口”,而是带你亲手拆解:一个真正适配 LLM 服务的健康检查体系,到底该怎么设计、怎么实现、怎么验证。我会用vLLM作为主线示例(因其已成为事实标准),但所有原理和陷阱,对Text Generation Inference (TGI)、llama.cpp、甚至自研 C++ 推理引擎都完全适用。你不需要是 K8s 专家,但得愿意花 15 分钟,把探针背后的“为什么”刻进肌肉记忆。
2. LLM 健康检查的三层真相:从进程存活到业务就绪
要写出正确的探针,先得撕掉“健康检查=HTTP GET”的思维胶布。LLM 服务的健康,天然存在三个物理上分离、逻辑上递进的层次。忽略任何一层,你的探针就是纸糊的。
2.1 第一层:进程层(Liveness 的唯一领地)
这是最底层、最无脑、也最容易出错的一层。livenessProbe的唯一使命,就是判断这个进程是不是还“喘着气”。它不关心业务,不关心模型,只认一个信号:进程的 PID 是否还存在于宿主机的进程表中,且其主 goroutine/main thread 是否仍在执行循环。
常见错误配置:
- 用
/healthz返回{"status": "ok"}作为liveness探针。这看似安全,实则埋雷。如果服务因 OOM 被 Linux kernel 杀掉,但进程退出前恰好返回了200,K8s 会认为它“活得好好的”,不会触发重启。 - 探针路径调用了一个需要 GPU 计算的函数(比如
torch.cuda.is_available())。一旦 GPU 驱动异常或显存被其他进程锁死,这个探针就会 hang 住,K8s 在超时后直接 kill 进程——这不是你想要的“故障自愈”,而是“主动制造故障”。
正确做法:liveness探针必须是纯 CPU、无 IO、无外部依赖、秒级返回的。它应该只做三件事:
- 检查当前进程的
os.Getpid()是否有效(在 Go 中是os.Getpid() > 0;在 Python 中是os.getpid() > 0); - 检查一个本地内存标志位(比如一个全局布尔变量
app_is_running = True)是否为True; - (可选)检查一个极轻量的本地文件是否存在(如
/tmp/app_alive.flag),该文件由主进程在启动成功后创建,并在优雅退出前删除。
提示:不要试图在
liveness探针里检查 Redis、PostgreSQL 或 MinIO。这些属于readiness的范畴。liveness的哲学是:“只要进程没死,我就给你机会自己恢复”。把它搞复杂,就是在给 K8s 制造误判的温床。
2.2 第二层:资源层(Readiness 的基石)
readinessProbe的起点,是确认服务已经“坐到了工位上”,但还没开始“处理工单”。这一层关注的是服务启动后的静态资源准备状态。对于 LLM 服务,核心就两个:模型加载完成和硬件资源就绪。
模型加载完成:
vLLM启动时,会经历ModelLoader.load_model()->ModelRunner.init_cache()->EngineCore.start_background_loop()三个关键阶段。只有当EngineCore的后台推理循环真正跑起来,模型才算“坐稳”。一个可靠的探针,必须能感知到这个循环的running状态。在vLLM的AsyncLLMEngine中,你可以通过engine.is_running()方法获取此状态。硬编码一个time.sleep(120)等待,是反模式——不同模型、不同 GPU,加载时间差异巨大(7B 模型可能 30 秒,70B 模型可能 3 分钟)。硬件资源就绪:这比“GPU 是否存在”要精细得多。
readiness必须回答:“此刻,我有没有足够的显存来接下一个请求?”vLLM提供了engine.model_config.max_num_seqs和engine.cache_config.block_size等参数,但它们是静态配置。真正的动态水位,要看engine.scheduler的get_num_unfinished_requests()和get_num_running_requests()。一个健康的readiness探针,应该要求get_num_running_requests() < engine.scheduler_config.max_num_seqs * 0.8(留 20% 缓冲),否则就返回503,告诉 K8s:“别给我派活了,我快满了”。
注意:很多团队把
nvidia-smi的输出解析塞进readiness探针。这很危险。nvidia-smi是一个重量级 CLI 工具,频繁调用会显著增加 GPU 驱动的 CPU 开销,尤其在高并发时,可能成为性能瓶颈。正确姿势是:让 LLM 引擎自身暴露一个轻量级的GET /v1/internal/stats接口,里面只包含gpu_memory_utilization_percent和num_running_requests这两个字段,由引擎内部通过 CUDA API 直接读取,毫秒级返回。
2.3 第三层:业务层(Readiness 的终极考验)
这才是 LLM 健康检查的灵魂所在。readiness的终点,不是“我能启动”,而是“我能可靠地完成你的请求”。它必须模拟一次真实的、最小化的业务调用,并验证其端到端的正确性与时效性。
一个典型的 LLMreadiness探针,应该执行以下原子操作:
- 构造一个极简的
prompt,例如"Hello"; - 设置
max_tokens=1(强制只生成一个 token,避免长文本阻塞); - 设置
temperature=0.0(禁用随机性,确保结果可预测); - 发起一个同步的
POST /v1/completions请求; - 验证响应 HTTP 状态码为
200; - 解析 JSON 响应体,检查
choices[0].text是否为"Hello"或" Hello"(允许前导空格); - 记录整个过程耗时,要求
< 2000ms(可根据你的 SLA 调整)。
为什么必须走完整链路?因为这是唯一能发现“幽灵故障”的方式。比如:
- 模型权重文件损坏,但加载阶段未报错(某些量化格式容错性强);
- KV Cache 初始化失败,但服务进程仍在运行;
- 网关(如 Kong)与后端的 gRPC 连接异常,但 HTTP 探针本身是通的。
这个探针的代价是:每次调用都会真实消耗一次推理资源。所以,它的调用频率必须远低于liveness(例如liveness每 10 秒一次,readiness每 30 秒一次),并且必须设置一个合理的timeoutSeconds: 5,防止一个慢请求拖垮整个探针队列。
3. 实战:为 vLLM 服务编写生产级健康检查接口
理论讲完,现在动手。我们以vLLM0.4.2 版本为蓝本,手把手写出一个既符合 K8s 规范、又贴合 LLM 特性的健康检查模块。这个模块将被集成到vLLM的OpenAIServer中,无需修改其核心代码,只需在server.py的app实例上挂载新路由。
3.1 设计原则:零侵入、可配置、可观测
我们的目标是:不碰vLLM的源码,只通过其公开 API 和插件机制扩展。这样既能保证升级兼容性,又能方便地注入自定义逻辑。核心设计如下:
liveness探针:GET /healthz/liveness,纯内存检查,无任何外部依赖;readiness探针:GET /healthz/readiness,分三步检查:资源层(GPU/内存)、引擎层(调度器状态)、业务层(一次微请求);- 所有检查逻辑封装在一个
HealthChecker类中,支持通过环境变量动态开关各检查项(如HEALTH_CHECK_GPU=false); - 每次探针调用,都记录一条结构化日志,包含
check_type,duration_ms,status,便于后续用 Loki 或 ELK 追踪。
3.2 核心代码实现(Python)
# health_checker.py import asyncio import time import logging from typing import Dict, Any, Optional from fastapi import APIRouter, HTTPException, status from vllm.engine.async_llm_engine import AsyncLLMEngine from vllm.entrypoints.openai.serving_chat import OpenAIServingChat from vllm.entrypoints.openai.serving_completion import OpenAIServingCompletion logger = logging.getLogger(__name__) class HealthChecker: def __init__(self, engine: AsyncLLMEngine, chat_serving: Optional[OpenAIServingChat] = None, completion_serving: Optional[OpenAIServingCompletion] = None): self.engine = engine self.chat_serving = chat_serving self.completion_serving = completion_serving # 从环境变量读取配置 import os self.check_gpu = os.getenv("HEALTH_CHECK_GPU", "true").lower() == "true" self.check_business = os.getenv("HEALTH_CHECK_BUSINESS", "true").lower() == "true" async def check_liveness(self) -> Dict[str, Any]: """Liveness check: only process-level, no I/O, no external deps.""" start_time = time.time() try: # 1. Check if engine is running (lightweight) is_engine_running = await self.engine.is_running() # 2. Check a simple in-memory flag (simulate app health) app_healthy = True # In real world, this could be a shared state duration = int((time.time() - start_time) * 1000) return { "status": "ok", "checks": { "engine_running": is_engine_running, "app_healthy": app_healthy }, "duration_ms": duration } except Exception as e: logger.error(f"Liveness check failed: {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Liveness check error: {str(e)}") async def check_readiness(self) -> Dict[str, Any]: """Readiness check: multi-layered, business-critical.""" start_time = time.time() checks = {} # Layer 1: Resource Check (GPU & Memory) if self.check_gpu: gpu_check = await self._check_gpu_resources() checks["gpu"] = gpu_check if not gpu_check["ok"]: return self._build_failure_response("GPU check failed", checks, start_time) # Layer 2: Engine Check (Scheduler & Cache) engine_check = await self._check_engine_state() checks["engine"] = engine_check if not engine_check["ok"]: return self._build_failure_response("Engine check failed", checks, start_time) # Layer 3: Business Check (End-to-end inference) if self.check_business: business_check = await self._check_business_flow() checks["business"] = business_check if not business_check["ok"]: return self._build_failure_response("Business check failed", checks, start_time) duration = int((time.time() - start_time) * 1000) return { "status": "ok", "checks": checks, "duration_ms": duration } async def _check_gpu_resources(self) -> Dict[str, Any]: """Check GPU memory utilization and availability.""" try: # Use vLLM's built-in method to get GPU stats # This is much lighter than calling nvidia-smi stats = await self.engine.do_log_stats() # stats has 'gpu_cache_usage' and 'cpu_cache_usage' gpu_cache_usage = stats.get('gpu_cache_usage', 0.0) # We consider it ready if GPU cache usage is below 90% ok = gpu_cache_usage < 0.9 return {"ok": ok, "gpu_cache_usage_percent": round(gpu_cache_usage * 100, 2)} except Exception as e: logger.warning(f"GPU check warning: {e}") return {"ok": False, "error": str(e)} async def _check_engine_state(self) -> Dict[str, Any]: """Check if the engine scheduler is healthy and has capacity.""" try: # Check if engine is running is_running = await self.engine.is_running() if not is_running: return {"ok": False, "reason": "engine_not_running"} # Get current request stats num_unfinished = await self.engine.get_num_unfinished_requests() num_running = await self.engine.get_num_running_requests() # Get scheduler config scheduler_config = self.engine.scheduler_config max_concurrent = scheduler_config.max_num_seqs # Calculate utilization utilization = (num_running / max_concurrent) if max_concurrent > 0 else 0 # We allow up to 80% utilization for readiness ok = utilization < 0.8 return { "ok": ok, "num_running_requests": num_running, "num_unfinished_requests": num_unfinished, "max_concurrent": max_concurrent, "utilization_percent": round(utilization * 100, 2) } except Exception as e: logger.warning(f"Engine check warning: {e}") return {"ok": False, "error": str(e)} async def _check_business_flow(self) -> Dict[str, Any]: """Perform a minimal end-to-end inference test.""" start_time = time.time() try: # Construct a minimal prompt prompt = "Hello" # Use the completion serving interface for simplicity if self.completion_serving is None: return {"ok": False, "error": "completion_serving_not_available"} # Create a fake request object (mimicking OpenAI API) request_dict = { "model": "default-model", # This will be ignored by vLLM for internal check "prompt": prompt, "max_tokens": 1, "temperature": 0.0, "top_p": 1.0, "n": 1, "stream": False } # Call the internal generate method (bypassing full HTTP stack) # This is the key: use vLLM's internal async method for speed results_generator = self.completion_serving.create_completion( request_dict, raw_request=None ) # Consume the generator to get the result result = await anext(results_generator) text = result["choices"][0]["text"].strip() # Validate the response # For "Hello", we expect "Hello" or " Hello" (with space) expected_texts = ["Hello", " Hello"] ok = text in expected_texts duration = int((time.time() - start_time) * 1000) return { "ok": ok, "response_text": text, "expected_texts": expected_texts, "duration_ms": duration } except StopAsyncIteration: return {"ok": False, "error": "no_result_from_generator"} except Exception as e: logger.warning(f"Business check warning: {e}") duration = int((time.time() - start_time) * 1000) return {"ok": False, "error": str(e), "duration_ms": duration} def _build_failure_response(self, reason: str, checks: Dict, start_time: float) -> Dict[str, Any]: """Build a standardized failure response.""" duration = int((time.time() - start_time) * 1000) return { "status": "failure", "reason": reason, "checks": checks, "duration_ms": duration } # FastAPI Router router = APIRouter() # Global instance, will be initialized by main server _health_checker: Optional[HealthChecker] = None def set_health_checker(checker: HealthChecker): global _health_checker _health_checker = checker @router.get("/healthz/liveness") async def liveness(): if _health_checker is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Health checker not initialized") return await _health_checker.check_liveness() @router.get("/healthz/readiness") async def readiness(): if _health_checker is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Health checker not initialized") return await _health_checker.check_readiness()3.3 集成到 vLLM 主服务
接下来,我们需要把这个HealthChecker注入到vLLM的OpenAIServer生命周期中。打开vllm/entrypoints/openai/api_server.py,找到build_app()函数,在app创建之后、uvicorn.run()之前,加入初始化逻辑:
# In vllm/entrypoints/openai/api_server.py, inside build_app() # ... existing code ... # Initialize the health checker from health_checker import HealthChecker, set_health_checker health_checker = HealthChecker( engine=engine, chat_serving=chat_serving, completion_serving=completion_serving ) set_health_checker(health_checker) # Mount the health router app.include_router(router, prefix="/v1") # Or "/healthz" if you prefer root # ... rest of the code ...最后,别忘了在你的Dockerfile中,把health_checker.py复制进去,并在ENTRYPOINT中设置好环境变量:
# Dockerfile COPY health_checker.py /app/ ENV HEALTH_CHECK_GPU=true ENV HEALTH_CHECK_BUSINESS=true # ... rest of your Dockerfile ...3.4 K8s Deployment YAML 配置要点
有了接口,还得配对的 K8s YAML。以下是关键部分的配置说明,每一条都踩过坑:
# deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: vllm-inference spec: template: spec: containers: - name: vllm image: your-registry/vllm:0.4.2 # Liveness Probe: Simple, fast, no dependencies livenessProbe: httpGet: path: /v1/healthz/liveness port: 8000 initialDelaySeconds: 120 # Must be > model load time! 70B needs 180s+ periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 # 30s of failure before restart # Readiness Probe: Comprehensive, but slower readinessProbe: httpGet: path: /v1/healthz/readiness port: 8000 initialDelaySeconds: 180 # Must be > liveness! Give time for model load periodSeconds: 30 # Less frequent than liveness timeoutSeconds: 10 # Business check can take longer failureThreshold: 2 # 60s of failure before removing from service # Resource Limits: CRITICAL for LLM resources: limits: nvidia.com/gpu: 1 memory: 80Gi # For 70B model, 4-bit quantized requests: nvidia.com/gpu: 1 memory: 64Gi关键经验:
initialDelaySeconds是生死线。如果你的vLLM加载一个 70B 模型平均需要 150 秒,那么liveness.initialDelaySeconds至少设为180,readiness.initialDelaySeconds至少设为210。设小了,Pod 还没启动完就被 K8s 杀掉,陷入无限重启循环(CrashLoopBackOff)。我们曾因此在生产环境花了 3 小时排查,最后发现只是initialDelaySeconds少写了 10 秒。
4. 避坑指南:那些让 LLM 健康检查失效的“隐形杀手”
写对探针只是第一步,真正决定成败的,是那些藏在文档角落、社区 issue 里、以及深夜值班日志中的“隐形杀手”。下面是我和团队踩过的、最痛的五个坑,每一个都附带了可立即落地的解决方案。
4.1 坑一:initialDelaySeconds的“薛定谔时间”
现象:Pod 日志显示Model loaded successfully,但 K8s 事件里却反复出现Liveness probe failed: HTTP probe failed with statuscode: 503,紧接着 Pod 被Killing。
根因:initialDelaySeconds的值,是基于“理想环境”下的模型加载时间设定的。但在生产环境中,它受三大变量影响:
- 磁盘 IO:从 S3/NFS 加载模型权重,网络抖动会让加载时间从 2 分钟变成 5 分钟;
- GPU 驱动版本:旧版驱动在初始化 CUDA Context 时,比新版慢 30%-50%;
- CPU 核心数:
vLLM的ModelLoader使用多进程并行加载,CPU 核心不足会严重拖慢进度。
解决方案:永远不要硬编码initialDelaySeconds。采用“双保险”策略:
动态探测:在容器启动脚本中,加一段
wait-for-model.sh:#!/bin/bash # Wait for vLLM to log "Engine started." timeout 600 tail -f /var/log/vllm.log | grep -q "Engine started." || exit 1 echo "Model is ready, starting health probes..." exec "$@"然后在
Deployment中,用initContainer运行它,主容器command改为["/bin/sh", "-c", "wait-for-model.sh && python -m vllm.entrypoints.openai.api_server ..."]。K8s 原生支持:使用
startupProbe(K8s v1.16+)。它专为“启动时间不确定”的长周期应用设计:startupProbe: httpGet: path: /v1/healthz/liveness port: 8000 failureThreshold: 30 # 给足 5 分钟(30 * 10s) periodSeconds: 10 # 一旦 startupProbe 成功,livenessProbe 和 readinessProbe 才开始工作
4.2 坑二:readiness探针的“雪球效应”
现象:服务在低负载时一切正常,但当 QPS 从 10 上升到 50 时,readiness探针失败率从 0% 暴涨到 80%,导致 K8s 把所有 Pod 从 Service Endpoints 中移除,流量全部打到仅剩的 1 个 Pod 上,最终全站 503。
根因:readiness探针本身就是一个请求。当它和业务请求共用同一个vLLM的Scheduler时,探针请求会和用户请求一起排队。在高并发下,探针请求排在长队尾部,timeoutSeconds到期,探针失败,K8s 移除 Pod —— 这个动作又把更多流量压向其他 Pod,形成正反馈雪崩。
解决方案:给健康检查开“绿色通道”。vLLM0.4.0+ 支持--disable-log-stats和--disable-log-requests,但这还不够。你需要:
- 专用调度器:在
HealthChecker的_check_business_flow()中,不调用create_completion(),而是调用engine.generate()的底层方法,并传入一个priority=100的参数(需 patchvLLM的Scheduler,添加优先级队列支持); - 更激进的方案:为健康检查单独部署一个轻量级的
ping服务(如一个独立的FastAPI进程),它只负责监听/healthz/readiness,并定期(如每 5 秒)向主vLLM进程的 Unix Socket 发送一个心跳包。主进程收到后,只做内存状态检查,不走推理链路。这样,健康检查和业务流量彻底隔离。
4.3 坑三:liveness探针的“假阳性自杀”
现象:服务明明运行良好,但每隔几小时,Pod 就会无缘无故重启一次。kubectl describe pod显示Liveness probe failed: HTTP probe failed with statuscode: 500。
根因:liveness探针里调用了torch.cuda.memory_allocated()。这个函数在某些 GPU 驱动版本下,会触发 CUDA Context 的隐式初始化,而这个初始化过程是线程阻塞的。如果此时主推理线程正在执行一个长generate(),liveness探针线程就会被卡住,超时失败。
解决方案:liveness探针必须是“无副作用”的。永远不要在其中调用任何可能触发 CUDA、PyTorch、TensorFlow 初始化的函数。坚持我们前面说的“三件事”原则。如果一定要检查 GPU,那就把它移到readiness里,并确保readiness的timeoutSeconds足够长(如10s)。
4.4 坑四:readiness的“缓存幻觉”
现象:服务刚启动时readiness一直失败,但kubectl logs看到模型早已加载完毕,nvidia-smi也显示显存已被占用。
根因:vLLM的KV Cache初始化是懒加载的。它只在第一个真实请求到来时,才分配block_tables。readiness探针的第一次业务调用,恰恰就是那个“第一个请求”。如果这个请求因为网络原因(如 Kong 网关的 TLS 握手超时)失败了,readiness就会失败,而KV Cache的初始化状态也就此中断,后续探针永远无法成功。
解决方案:在HealthChecker的_check_business_flow()中,加入重试逻辑:
for attempt in range(3): try: result = await self.completion_serving.create_completion(...) if result and "choices" in result: break except Exception as e: if attempt == 2: # Last attempt raise e await asyncio.sleep(0.5) # Backoff同时,在Deployment中,为readinessProbe设置failureThreshold: 3,给它三次机会。
4.5 坑五:监控告警的“聋哑人困境”
现象:readiness探针失败了,但 Grafana 面板上没有任何告警,SRE 同学直到用户投诉才知道。
根因:K8s 的Probe事件默认只记录在kubectl describe pod里,不会自动上报到 Prometheus。你没有采集kube_pod_container_status_restarts_total和kube_pod_container_status_waiting_reason这两个关键指标。
解决方案:在 Prometheus 的scrape_configs中,添加对 K8s Events 的抓取:
- job_name: 'kubernetes-events' kubernetes_sd_configs: - role: event relabel_configs: - source_labels: [involvedObject.name] target_label: pod - source_labels: [reason] target_label: event_reason # Filter for probe failures - source_labels: [event_reason] regex: 'Unhealthy|Failed|CrashLoopBackOff' action: keep然后,创建一条告警规则:
- alert: VLLMPodReadinessFailure expr: count by (pod) (kube_pod_container_status_waiting_reason{reason=~"CrashLoopBackOff|Unhealthy"} > 0) > 0 for: 2m labels: severity: critical annotations: summary: "VLLM Pod {{ $labels.pod }} readiness check failing" description: "The readiness probe for pod {{ $labels.pod }} has been failing for more than 2 minutes."5. 进阶:让健康检查成为你的 LLM 服务“数字孪生”
写好liveness和readiness,只是完成了及格线。真正的高手,会把健康检查系统,打造成一个实时反映服务内在状态的“数字孪生”。它不仅能告诉你“好不好”,还能告诉你“为什么不好”,甚至“接下来会怎样”。
5.1 指标驱动:从布尔值到连续谱
/healthz/readiness返回的{ "status": "ok" }是一个布尔值,信息量为 1 bit。而一个生产级的 LLM 服务,需要的是一个 100 维的状态向量。我们应该让探针返回一个丰富的指标集:
{ "status": "degraded", "severity": "warning", "metrics": { "gpu_memory_utilization_percent": 85.2, "kv_cache_hit_rate_percent": 72.1, "avg_inference_latency_ms": 1240, "p95_inference_latency_ms": 2850, "scheduler_queue_length": 18, "num_running_requests": 42, "max_concurrent_requests_configured": 50, "uptime_seconds": 14238 } }这个 JSON 可以被 Prometheus 的json_exporter直接抓取,转换成vllm_health_gpu_memory_utilization_percent等一系列指标。然后,你就可以在 Grafana 里画出一张“健康热力图”,一眼看出哪个维度是瓶颈。
5.2 自愈能力:从告警到 Action
健康检查的终点,不应该是发一封邮件。它应该是自动化运维流水线的起点。我们可以构建一个简单的自愈闭环:
- Prometheus 检测到
vllm_health_scheduler_queue_length > 30持续 1 分钟; - 触发一个
Kubernetes Job,执行kubectl scale deploy/vllm-inference --replicas=3(水平扩容); - 如果 5 分钟后指标仍未改善,则触发另一个 Job,执行
kubectl rollout restart deploy/vllm-inference(滚动重启,强制刷新所有 Pod 的 KV Cache); - 所有操作记录到 Slack 频道
#ai-ops-alerts,并 @ on-call engineer。
这个闭环,只需要一个Prometheus Alertmanager+Kubernetes CronJob就能实现,成本极低,但价值巨大。
5.3 业务语义:让健康检查理解“LLM 的语言”
最后,也是最前沿的一点:健康检查,可以超越技术指标,去理解业务语义。还记得摘要描述里提到的llm的token三个点key我是谁、query我在找什么、value我能提供什么吗?我们可以把这个思想,注入到健康检查中。
设想一个/healthz/business探针,它不检查“能不能跑”,而是检查“能不能干好活”:
- 它会发送一个
prompt:“请用一句话总结‘量子计算’。” - 它会校验响应的
token_count是否在[15, 30]区间(太短说明模型没理解