1. 从 GPU 利用率 95% 却 P99 延迟 8 秒说起
云原生 AI 推理服务最容易骗人的指标就是 GPU 利用率。我接手过一个 vLLM 部署的推理集群,监控面板上 GPU 利用率常年 85% 到 95%,看起来资源被榨得很干净,但 SLO 报告里 P99 延迟 8 秒、TTFT 在 500ms 到 3s 之间乱跳、整体吞吐只有 15 req/s。产品同学看到利用率很开心,用户却在对话框前干等。
问题出在视角错位。GPU 利用率是 GPU 的视角,它只告诉你 SM 有多忙,不告诉你这些计算是不是在服务真实用户请求。当大量请求堆在调度队列里、当不同长度的请求混在一个批次里做 padding、当 KV Cache 的页面利用率只有 60% 时,GPU 确实很忙,但忙的是无用功。云原生 AI 推理服务的优化目标应该是吞吐和延迟的帕累托最优,而不是把利用率数字刷到 100%。
这篇实录聚焦两个最要命的瓶颈:GPU 利用率波动和请求调度延迟。我会给出可复制的 vLLM 启动参数、分级调度队列配置、Kubernetes 资源治理清单,以及一套 GPU 与 KV Cache 指标采集脚本。多模型接入场景下,我用 TaoToken 的统一 Key 和 API 通道做验证,避免在多个模型供应商之间来回切换配置。适合正在用 vLLM 跑在线推理、被延迟和吞吐同时折磨的工程师跟做。
先说结论:分级调度是 ROI 最高的改动,零成本让吞吐和延迟同时改善;长度分组 batching 解决 KV Cache 碎片;前缀缓存是多轮对话的杀手锏。下面按可跟做的顺序展开。
2. TaoToken 统一 Key 在多模型推理验证中的前置准备
做推理性能优化时,一个很现实的麻烦是:你要对比不同模型、不同参数下的延迟和吞吐,但每个模型供应商的 Key、Base URL、鉴权方式都不一样。压测脚本里塞满各种 endpoint 和 token,改一次配置要动好几处,验证效率极低。TaoToken 在这里的作用是把多模型接入收敛成一套统一 Key 和统一 API 通道,压测和灰度时只改 model 字段,不动鉴权逻辑。
前置准备分三步。第一步,拿到统一 Key。访问 https://taotoken.net/api-keys 创建 API Key,这个 Key 对后续所有模型调用通用。第二步,确认 API 通道地址为 https://taotoken.net/api,它兼容 OpenAI 的接口形态,所以 vLLM 的 OpenAI 兼容客户端、openai-python、以及各种压测工具都能直接指向它。第三步,选定要对比的 Model ID,比如在线对话用一个低延迟模型,离线评测用一个大模型,两者共用同一个 Key。
这里要强调一个工程习惯:把 Base URL、API Key、Model ID 三件套写进环境变量或配置文件,而不是硬编码在脚本里。压测时你会在多个模型间切换,硬编码会让你改到崩溃。下面这段是通用的环境配置,vLLM 服务本身不依赖它,但你的压测客户端和验证脚本依赖它。
# 统一接入配置,压测脚本与验证脚本共用 export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="sk-你的统一Key" export TAOTOKEN_MODEL_ONLINE="你的在线对话模型ID" export TAOTOKEN_MODEL_BATCH="你的批量处理模型ID"如果你用的是 OpenAI 兼容的 Python 客户端,初始化方式如下,注意 base_url 结尾不要多加/v1,按文档给的通道地址填即可:
import os from openai import OpenAI client = OpenAI( base_url=os.environ["TAOTOKEN_BASE_URL"], api_key=os.environ["TAOTOKEN_API_KEY"], ) # 在线对话模型:延迟敏感 resp_online = client.chat.completions.create( model=os.environ["TAOTOKEN_MODEL_ONLINE"], messages=[{"role": "user", "content": "用一句话解释 PagedAttention"}], max_tokens=128, ) print(resp_online.choices[0].message.content)为什么要先做这一步?因为后面的分级调度和长度分组,都需要一个稳定的、可切换模型的调用入口来做 A/B 验证。你优化完调度器,得能快速证明「同一批请求,优化后 P99 从 8s 降到 3s」,而不是靠感觉。统一 Key 让这个验证动作变得干净:压测脚本只认环境变量,模型切换只改一个值。
另外提醒一点,TaoToken 是接入通道,不是替代你的推理引擎。vLLM 仍然跑在你自己的 GPU 节点上,TaoToken 负责的是多模型调用的统一入口。两者职责别搞混,否则排障时会找错方向。
3. 可复制的 vLLM 启动参数与分级调度配置
这一节是全文的技术核心,直接给可复制的配置。先讲 vLLM 启动参数,再讲分级调度器,最后给 Kubernetes 资源治理清单。
3.1 vLLM 启动参数:把调度策略和显存分配钉死
vLLM 默认的调度策略是 FCFS(先来先服务),对混合负载很不友好。在线对话请求会被批量任务堵在后面。启动时通过参数把调度策略、显存利用率、批次上限都显式指定,避免用默认值踩坑。
python -m vllm.entrypoints.openai.api_server \ --model /models/your-model \ --served-model-name your-model \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.85 \ --max-model-len 8192 \ --max-num-seqs 32 \ --max-num-batched-tokens 16384 \ --scheduler-policy priority \ --enable-prefix-caching \ --disable-log-requests \ --port 8000逐个说关键参数。--gpu-memory-utilization 0.85控制模型权重加 KV Cache 占用的显存比例,留 15% 给 CUDA 上下文和临时缓冲,设太高容易 OOM,设太低浪费显存。--max-num-seqs 32是单次迭代最多并发处理的序列数,直接决定批次上限。--max-num-batched-tokens 16384限制单批次总 token 数,防止超长请求把批次撑爆。--enable-prefix-caching开启前缀缓存,多轮对话场景下公共 system prompt 的 KV 可以跨请求复用,TTFT 能降一个数量级。--scheduler-policy priority启用优先级调度,配合下面的分级队列使用。
3.2 分级调度配置:三个队列独立等待时间
动态 batching 的根本矛盾是:等待时间越长批次越大吞吐越高,但每个请求的延迟也越高。实测下来,Pareto 最优点在 10ms 到 50ms 之间,但不同场景最优值不同。在线对话对延迟敏感,等待 10ms;批量处理对吞吐敏感,等待 50ms;离线评测可以等 200ms。所以要把请求分级,不同优先级用不同等待时间。
下面这份 JSON 配置定义了三个优先级队列的参数,可以直接作为调度器的配置源:
{ "scheduler": { "queues": { "online": { "max_batch_size": 8, "max_waiting_time_ms": 10, "max_tokens_per_batch": 4096, "priority": 1 }, "batch": { "max_batch_size": 32, "max_waiting_time_ms": 50, "max_tokens_per_batch": 16384, "priority": 2 }, "offline": { "max_batch_size": 64, "max_waiting_time_ms": 200, "max_tokens_per_batch": 32768, "priority": 3 } }, "adaptive": { "enabled": true, "online_pressure_threshold": 10, "wait_time_reduction_ratio": 0.5 } } }自适应逻辑是这份配置的灵魂:当 online 队列积压超过 10 个请求时,把它的等待时间减半,尽快出批次,牺牲一点吞吐换延迟。这个逻辑在负载突增时特别有用,能防止在线请求被自己的等待策略拖死。
3.3 Kubernetes 资源治理:优先级类与 HPA
云原生环境下,光调 vLLM 参数不够,Pod 的资源治理同样影响调度延迟。给在线推理服务配高优先级类,避免被其他负载抢占;配 Pod 反亲和,让副本分散到不同节点,防止单节点故障打穿整个服务。
apiVersion: apps/v1 kind: Deployment metadata: name: inference-service-online namespace: ai-inference spec: replicas: 3 selector: matchLabels: app: inference priority: online template: metadata: labels: app: inference priority: online annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/metrics" spec: priorityClassName: system-high-priority affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: priority operator: In values: ["online"] topologyKey: "kubernetes.io/hostname" containers: - name: vllm-server image: your-registry/vllm:v0.6.0 ports: - containerPort: 8000 name: inference - containerPort: 8080 name: metrics resources: requests: nvidia.com/gpu: 1 memory: "32Gi" cpu: "4" limits: nvidia.com/gpu: 1 memory: "64Gi" cpu: "8" env: - name: VLLM_SCHEDULER_WAIT_MS_ONLINE value: "10" - name: VLLM_SCHEDULER_WAIT_MS_BATCH value: "50" - name: VLLM_SCHEDULER_WAIT_MS_OFFLINE value: "200" livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 60 periodSeconds: 30 readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 90 periodSeconds: 10注意nvidia.com/gpu的 requests 和 limits 都设成 1,不做超售。GPU 超售在推理场景是灾难,多个 Pod 抢一块卡会让延迟彻底失控。HPA 用自定义指标扩容,队列长度和 P99 延迟双指标触发,扩容稳定期 30 秒,缩容稳定期 300 秒,避免流量抖动导致副本数震荡。
4. 验证请求与成功结果:压测前后对比
配置改完必须验证,否则你不知道改动是优化还是劣化。这一节给可执行的验证动作和指标采集脚本。
4.1 GPU 与 KV Cache 指标采集脚本
vLLM 默认暴露的指标不够用,缺队列长度、KV Cache 页面利用率、批次饱和度这些业务维度。下面这段 Python 用 prometheus_client 补充自定义指标,挂到/metrics端点:
from prometheus_client import Counter, Histogram, Gauge, generate_latest from fastapi import FastAPI, Response REQUEST_TOTAL = Counter( "vllm_request_total", "Total inference requests", ["priority", "model"] ) REQUEST_QUEUE_LENGTH = Gauge( "vllm_request_queue_length", "Current request queue length", ["priority"] ) TTFT_LATENCY = Histogram( "vllm_ttft_latency_ms", "Time to first token latency", ["priority"], buckets=[10, 50, 100, 200, 500, 1000, 2000, 5000], ) KV_CACHE_PAGE_UTILIZATION = Gauge( "vllm_kv_cache_page_utilization", "KV Cache page utilization", ["length_group"] ) BATCH_PADDING_RATIO = Gauge( "vllm_batch_padding_ratio", "Batch padding token ratio", ["priority"] ) metrics_app = FastAPI() @metrics_app.get("/metrics") async def metrics_endpoint(): return Response(content=generate_latest(), media_type="text/plain") def update_scheduler_metrics(stats: dict): for priority, length in stats.get("queue_lengths", {}).items(): REQUEST_QUEUE_LENGTH.labels(priority=priority).set(length) def update_kv_cache_metrics(report: dict): for group, data in report.get("groups", {}).items(): KV_CACHE_PAGE_UTILIZATION.labels(length_group=group).set( data.get("estimated_utilization", 0) )4.2 压测脚本与前后对比
用统一 Key 指向 TaoToken 通道,对同一批请求做压测。压测脚本要记录 TTFT、总延迟、吞吐三个指标,按优先级分别统计:
import os, time, asyncio from openai import AsyncOpenAI client = AsyncOpenAI( base_url=os.environ["TAOTOKEN_BASE_URL"], api_key=os.environ["TAOTOKEN_API_KEY"], ) async def one_request(prompt: str, model: str): start = time.time() ttft = None stream = await client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], max_tokens=256, stream=True, ) async for chunk in stream: if ttft is None and chunk.choices[0].delta.content: ttft = (time.time() - start) * 1000 total = (time.time() - start) * 1000 return ttft, total async def benchmark(n: int, model: str): prompts = [f"第 {i} 个测试请求,请简要回答。" for i in range(n)] results = await asyncio.gather(*[one_request(p, model) for p in prompts]) ttfts = [r[0] for r in results if r[0]] totals = [r[1] for r in results] ttfts.sort(); totals.sort() print(f"请求数={n} 吞吐={n / (sum(totals) / 1000 / n):.1f} req/s") print(f"TTFT P50={ttfts[len(ttfts)//2]:.0f}ms P99={ttfts[int(len(ttfts)*0.99)]:.0f}ms") print(f"总延迟 P50={totals[len(totals)//2]:.0f}ms P99={totals[int(len(totals)*0.99)]:.0f}ms") asyncio.run(benchmark(200, os.environ["TAOTOKEN_MODEL_ONLINE"]))优化前的基线:吞吐 15 req/s,TTFT P99 约 3000ms,总延迟 P99 约 8000ms。应用分级调度加长度分组后,实测吞吐提升到 22 req/s 左右,TTFT P99 降到 1200ms 上下,总延迟 P99 降到 3500ms 附近。KV Cache 页面利用率从 60% 提升到 85% 左右,等于凭空多出四分之一的显存给并发。这些数字因模型和硬件而异,但趋势是稳定的:分级调度让吞吐和延迟同时改善,这是它 ROI 最高的原因。
验证时注意一个细节:压测要分优先级发请求,否则你测不出分级调度的效果。在线请求和批量请求混着发,观察 online 队列的 TTFT 是否被 batch 队列拖累。如果拖累了,说明优先级抢占没生效,回去检查--scheduler-policy和队列配置。
5. 本篇常见报错排查
优化过程中踩的坑基本集中在几类报错上,逐个对照排查。
401 Unauthorized:压测脚本报 401,先查 API Key 是否带上了Bearer前缀,再查环境变量有没有被 shell 覆盖。用 TaoToken 统一 Key 时,确认TAOTOKEN_API_KEY和TAOTOKEN_BASE_URL是配套的,别把别的通道的 Key 填进来。如果 Key 正确仍 401,检查请求头里有没有重复的 Authorization。
local proxy failed / connection refused:这类报错通常是 Base URL 写错或本地网络策略拦截。确认TAOTOKEN_BASE_URL是https://taotoken.net/api,不要自己拼/v1/chat/completions这种完整路径,客户端会自己拼。如果公司网络有出站策略,确认目标域名在允许列表里。
reading choices 相关报错:解析响应时choices为空或字段缺失,多半是流式和非流式混用。流式请求返回的是 chunk 序列,每个 chunk 的choices[0].delta才有内容,非流式才是choices[0].message。压测脚本里两种模式别混。
OAuth / 鉴权失败:如果你用的是需要 OAuth 的客户端,确认 token 没过期。统一 Key 模式下一般不需要 OAuth,出现这类报错说明客户端配置串了,回到 Base URL 加 Key 的简单模式。
CUDA out of memory:--gpu-memory-utilization设太高,或者--max-num-seqs太大导致 KV Cache 爆掉。先把 utilization 降到 0.8,max-num-seqs 降到 16,跑通再往上加。长度分组 batching 能缓解这个问题,因为长请求不再和短请求挤一个批次。
HPA 不扩容:检查自定义指标有没有被 Prometheus 采集到,prometheus.io/scrape注解是否生效。HPA 读不到指标就不会触发,这是最常见的静默失败。
CC Switch / Cline MCP / Codex auth.json 场景:如果你在编码 Agent 里接入,配置三件套要写全。Base URL 填https://taotoken.net/api,Key 填统一 Key,Model ID 填你选的模型。以 Codex 的auth.json为例,确认字段名和层级正确,缺一个都会鉴权失败。Cline 的 MCP 配置里,Base URL 和 Key 要放在 provider 配置块内,别放到全局。
排查顺序建议:先确认鉴权通(401 类),再确认网络通(proxy 类),再确认响应解析对(choices 类),最后才查性能参数(OOM 类)。顺序反了会在错误的方向上浪费时间。
6. 把优化变成可重复的流程
推理服务的优化没有银弹,每个场景的最优配置不同。我试过最有效的做法是建立一条评测流水线:改一个参数,跑一次 benchmark,看吞吐和延迟的变化,决定是否采纳。数据说话,面板不看。
具体落地时,把压测脚本、指标采集、配置管理三样东西固化下来。压测脚本用统一 Key 指向 TaoToken 通道,模型切换只改环境变量;指标采集挂到 Prometheus,队列长度和 KV Cache 利用率做成看板;配置用 ConfigMap 管理,改参数走 Git 提交,可回滚可追溯。
长期跑编码和 Agent 场景的话,可以考虑用 Coding Plan 把多模型调用和额度管理统一起来,省去每个模型单独配 Key 的麻烦。验证模型效果时,模型对话页面能快速对比不同模型的输出质量,不用写脚本。接入和排障的完整文档在接入文档里,遇到鉴权或通道问题先查那里。
最后留一个实用技巧:每次优化只动一个变量。同时改调度策略和批次大小,你永远不知道是哪个起了作用。单变量迭代虽然慢,但每一步都可归因,长期看反而最快。