vLLM实例调优:利用TTFT与KV Cache定位首字延迟问题 如果你也跟我一样把开源模型接进 vLLM 之后第一件事是盯着 nvidia-smi 看 GPU-Util那你大概率会遇到一个挺尴尬的局面GPU 利用率一会儿 30%一会儿 100%但业务方照样来抱怨首字慢。后来我专门花了一个周末把 vLLM 自带的 Prometheus 指标拉到 Grafana 里把 TTFT、KV Cache 行为和 GPU 利用率放在同一张图上观察之前的“玄学调优”才算变成可量化、可定位、可验证的工程问题。这篇文章不是部署入门而是记录我在自托管 LLM 调优过程中怎么用这三类指标一步步排查和优化希望能给同样在做自托管服务的同学少踩几个坑。1. 为什么我先放弃“吞吐量”而盯上 TTFT做 LLM 服务优化时大家习惯一上来就看 tokens/s这个指标当然重要但它衡量的是“模型产出速度”不是“用户感知速度”。用户发一段带长系统提示词的请求体感到底好不好主要取决于第一个 token 多久能到。TTFTTime To First Token就是这一段间隔。标题里这组指标之所以值得单独拿出来说是因为如果你只盯吞吐很可能会把一批“首字很慢但均耗时不差”的问题漏掉。1.1 TTFT 是 Prefill 阶段的外部表现要理解 TTFT得先分清大模型推理的两个阶段Prefill 和 Decode。Prefill 阶段会把用户输入的 prompt 整段并行计算产出第一个 token 和对应的 KV CacheDecode 阶段则是一个 token 一个 token 地往后推。TTFT 主要反映的就是“调度等待时间 Prefill 计算时间”。这就带来一个很实际的观察TTFT 和你输入多长关系极大。同样一个 7B 模型你喂 500 token 的 prompt 和喂 4000 token 的 promptTTFT 完全不是一个量级。所以监控 TTFT 的时候我通常不会只看均值还会分两条线观察短 prompt 请求的 TTFT主要反映调度和排队效率长 prompt 请求的 TTFT主要反映 Prefill 计算压力和模型并行效率。如果业务形态是 RAG 或者多轮对话prompt 里往往塞了大量背景材料这类请求的 TTFT 会比普通闲聊高很多。这不是配置错了而是 Prefill 阶段的工作量本来就大。你需要做的是把这条曲线单拎出来设定一个“可接受的上限”而不是拿它和短请求做横向比较。1.2 vLLM 排队让 TTFT 变成两条曲线p50 与 p99vLLM 采用连续批处理Continuous Batching新请求不一定会立刻进入 GPU 计算而是先进入队列等当前 batch 里某些序列结束或者有预算剩余再被调度进去。所以 TTFT 可以拆成“排队时间 真正计算时间”两部分。只看平均值会掩盖一个典型问题p50 很低p99 却很离谱。我自己就在一次压测里遇到过平均 TTFT 0.9 秒看着还行但 p99 是 4 秒。业务方反馈突然变慢就是因为高峰期大部分请求都在排队。当时如果只看总吞吐根本发现不了问题因为总吞吐可能还是稳的。把vllm:num_requests_waiting和vllm:time_to_first_token_seconds的直方图放在一起看才确认瓶颈不在模型计算而在调度队列。所以我的习惯是给 TTFT 同时配置 p50、p99 两条查询不要只看avg。avg适合做趋势观察p99才是用户真实体感的边界。后面第五节会讲具体怎么用 p99 定位问题。2. 把 vLLM 的指标用起来先做到这四件事Prometheus 只负责“抓取”真正适合 vLLM 的采集路径其实很轻量因为 vLLM 服务本身就暴露了/metrics端点。我第一次接的时候以为要装什么 exporter结果一顿搜索才发现指标已经躺在那里了。下面把从确认端口到 Grafana 出图的关键步骤和坑梳理一遍。2.1 确认 vLLM 的 /metrics 在哪个端口如果你是用vllm serve起的服务默认 HTTP 端口是 8000在浏览器里直接访问http://127.0.0.1:8000/metrics正常情况下会看到一大片vllm:开头的指标文本。这些就是 Prometheus 能直接抓取的原生指标不需要额外做数据转换。要注意的是在某些自定义部署方案里团队会自己包一层 FastAPI 入口或者在网关层做路径重写这时候/metrics可能被吞掉。遇到抓不到的情况先别急着怪 Prometheus先在服务器本机curl -v http://127.0.0.1:8000/metrics看返回码。提示不同版本的 vLLM 对 metrics 的默认开关和指标命名有过调整升级版本后最好把/metrics内容重新拉一遍用vllm:前缀搜一下别盲目沿用旧面板。2.2 Prometheus 抓取配置里最容易踩的坑Prometheus 的 scrape 配置本身不复杂我通常这样写scrape_configs: - job_name: vllm metrics_path: /metrics static_configs: - targets: [127.0.0.1:8000] labels: instance: vllm-on-a100但有几个实际问题值得提醒。第一如果你的 vLLM 服务部署在容器里注意端口映射是否把/metrics所在的 HTTP 端口完整暴露出来有时候你只映射了推断接口漏了 metrics 端口。第二如果前面有 Nginx 或 Kong 这类网关记得把/metrics放到白名单或者单独用一条路由直接打到 vLLM。我见过网关把/metrics也当成业务 API 做了鉴权导致 Prometheus 抓取时收到 401那种问题只看 Prometheus 的 target 列表很难一眼发现。还有一个容易忽略的是多副本场景。如果你为了支撑更大并发起了多个 vLLM 实例建议每个实例都用不同的instance标签方便后面按副本维度拆解。否则 Grafana 上所有曲线的 label 都一样出了问题你会很难判断是哪台机器上的服务在拖后腿。2.3 Grafana 面板我回头看频率最高的四个图面板图不用做很多关键是把信息密度控制好。我现在线上最常用的是下面四块每一块背后都有对应的 PromQL图表用途PromQL 示例我主要拿它看什么TTFT p99histogram_quantile(0.99, sum(rate(vllm:time_to_first_token_seconds_bucket[5m])) by (le))用户首字体感是否恶化运行/排队请求数sum(vllm:num_requests_running)/sum(vllm:num_requests_waiting)调度压力和队列堆积生成 token 速率sum(rate(vllm:generation_tokens_total[1m]))实际吞吐趋势抢占次数sum(increase(vllm:num_preemptions_total[5m]))KV Cache 空间是否不足如果还想看吞吐和延迟之间的关系可以把“生成 token 速率”和“TTFT p99”画在同一张图的两个 Y 轴上。很多时候你会发现吞吐还没到顶TTFT 已经开始飙升这就是服务的“甜蜜点”已经过了后面再怎么加并发都是让所有人一起变慢。3. KV Cache真正决定并发上限的显存黑盒KV Cache 是很多人容易忽略的一环因为你在 nvidia-smi 里看到的“显存剩余”是一颗会骗人的烟雾弹。GPU 上不仅要放模型权重还要给每个并发序列分配 KV Cache 空间。权重是死的KV Cache 才是活的。3.1 为什么 KV Cache 会占掉一大半显存先给个粗算方法单个 token 的 KV Cache 大小约等于2 × 层数 × hidden_size × 精度字节数。这里的2表示 Key 和 Value 两份。以 32 层、hidden_size 4096 的 7B 级模型为例fp16 精度下单 token 大约是2 × 32 × 4096 × 2 ≈ 0.5 MB看着不多但 4096 token 长度的单路请求就接近 2GB 了。如果你把--max-model-len开到 32768同时来 8 个并发请求KV Cache 至少要预留 60 多 GB。这就是为什么很多自托管服务把 max length 调高之后并发能力反而断崖式下降。所以我现在的习惯是拿到一个新模型先手工算一笔账权重占多少、KV Cache 占多少、CUDA context 和临时显存预留多少然后再决定gpu_memory_utilization给多少。不要一上来就无脑0.95否则前面权重吃满了后面 KV Cache 很容易爆。3.2 cache_hit_rate 和 preemption 是左右手vLLM 早期版本就有 cache 相关指标只是命名在不同版本里变来变去常见的是vllm:cache_hit_rate、vllm:prefix_cache_hit_rate这一类。你不用死背名字在 Grafana 的指标浏览器里搜cache就能找到。重点是要理解这个数字背后的含义。cache hit 表示请求里的 prefix 能在 KV Cache 里直接命中跳掉一部分 Prefill 计算。在多轮对话和带固定 system prompt 的场景里这个指标非常关键。如果命中率高TTFT 会明显下降因为模型不需要从头算一遍已经见过的内容。另一方面vllm:num_preemptions_total是 KV Cache 不够用时调度器踢掉旧序列、重新调度的次数。这个计数器只要持续增长基本可以断定 KV Cache 分配不足。它表现在用户侧就是“并发一上来所有请求的延迟都在抖”因为老请求被抢占后又得重新排队TTFT 和 inter-token latency 双双变差。3.3 该不该开 prefix caching用指标回答而不是拍脑袋--enable-prefix-caching不是每个场景都有收益。如果你的请求 prompt 之间几乎没有任何共享前缀那 cache hit 会一直挂在 0 附近开这个开关可能只是多点调度开销。但如果你的服务是 chatbot每条请求都带同样的 system prompt或者请求里会塞相同的历史记录那 Prefix Caching 基本就是白送的显存减负。判断方法很简单开启前后各压测 15 分钟对比cache_hit_rate和 TTFT p99。如果 hit rate 从 0.1 涨到 0.5 以上这个开关就值得留如果还是 0说明业务前缀太发散开了也没用。别听别人说“某个参数必开”就照搬指标会告诉你这个参数在你的流量形态下到底有没有意义。4. GPU 利用率不是越高越好关键看它和吞吐是否匹配标题里把 GPU 利用率和 TTFT、KV Cache 放在一起是因为我在实际排障时发现GPU 利用率单拎出来看经常会误导人。尤其是 nvidia-smi 最上面那个 Volatile GPU-Util它不代表 GPU 的真实计算饱和度只是一段时间内有没有 CUDA kernel 在跑的采样结果。4.1 nvidia-smi 的 GPU-Util 在误导你LLM 推理的 Decode 阶段往往不是计算瓶颈而是显存带宽和延迟瓶颈。一个一个小 token 生成的时候SM 可能并没有跑满但显存带宽已经在极限附近。这时候 nvidia-smi 显示 30% 的利用率不代表你的 GPU 在摸鱼反而可能说明 vLLM 的 batch 已经足够大decode 在正常推进。我见过最典型的错误调优路径是看到 GPU-Util 不高就盲目把--max-num-seqs调大想把 GPU 塞满。结果并发是上去了KV Cache 很快就爆num_preemptions_total开始猛涨TTFT 不降反升。这种场景里 GPU-Util 低更多是“当前阶段本来就不吃 SM”而不是“还有大量计算能力被浪费”。所以看 GPU 利用率一定要和vllm:num_requests_running放在一起看。如果 running 请求数量很低GPU-Util 也低说明负载不够可以加并发如果 running 请求数量已经很高GPU-Util 还是低那大概率是瓶颈在别处加并发只会制造抢占。4.2 把 DCGM 指标和 vLLM 指标拼在一起看vLLM 的/metrics主要暴露的是服务层指标GPU 硬件层要靠另一路数据。我这边用的是 NVIDIA 的 dcgm-exporter它会暴露像DCGM_FI_DEV_GPU_UTIL、DCGM_FI_DEV_MEM_COPY_UTIL、DCGM_FI_DEV_MEM_CLOCK、DCGM_FI_DEV_GPU_TEMP、DCGM_FI_DEV_POWER_USAGE这一系列指标。简单说vLLM 告诉你服务在干什么DCGM 告诉你 GPU 硬件状态如何。在这两路数据都齐了之后我会把几个关键数字叠到同一时间轴上去推理num_requests_running高、SM 利用率低、显存带宽相关指标高系统处于 decode 密集阶段瓶颈是访存num_requests_running低、SM 利用率高、TTFT 高系统在 prefill 长 prompt 的阶段瓶颈是计算抢占计数持续上涨KV Cache 空间不足不要再加并发。我自己还遇到过一种“假负载”问题利用率曲线每个小时突然冲到 100%然后系统重启日志里能看到一堆 GPU crash dump triggered。这种情况别再调 vLLM 参数了先看温度和供电大概率是散热或者电源策略问题调参数只会让故障更频繁。4.3 利用率高但 TTFT 也高先查 prefill 是否挤占 decode当一堆超长 prompt 同时进入时Prefill 是很吃计算的阶段GPU 利用率可能冲到很高但它是在为“首字计算”服务同时会挤压正在 decode 的序列。如果只盯着利用率你会觉得“GPU 很忙系统状态很好”实际上用户的 TTFT 和后续 token 延迟都在恶化。这种时候我一般会开--enable-chunked-prefill把长 prompt 的 prefill 拆成小块和 decode 调度到一起避免 prefill 占满 GPU 导致 decode 被卡住。这个参数不一定要开但如果你的业务里长文档请求占比很高开完后 TTFT 的抖动通常会小很多。5. 一次完整调优p99 TTFT 从 4 秒压回 0.8 秒前面说了不少理论这节讲一次具体的调优过程。背景是自托管一个 27B 级模型部署在 4 卡 A100 80G 上用的就是 vLLM。压测一段时间后业务方反馈首字明显变慢于是我把 Prometheus 里的指标翻出来逐项排查。5.1 现场症状与初始配置当时的初始启动参数大概是vllm serve model-path \ --tensor-parallel-size 4 \ --max-model-len 65536 \ --gpu-memory-utilization 0.95 \ --max-num-seqs 256 \ --disable-log-requests没有开 Prefix Caching也没有开 Chunked Prefill。从 Grafana 看TTFT 的 p99 已经到 4 秒平均值也有 0.9 秒等待队列里经常积压 40 多个请求而 running 请求数只有 8 个。GPU 利用率在 20% 和 100% 之间剧烈抖动num_preemptions_total偶尔上涨cache hit rate 只有 0.08。单看任何一个指标都比较难解释但组合起来就很清楚max-model-len 65536让每一路请求的 KV Cache 预算大得离谱导致能同时进入 GPU 计算的路数非常少大量请求排队。GPU 利用率抖动则是因为 prefill 阶段把算力拉满decode 阶段又没人干活。5.2 用三张图把问题钉死我重点看了三块图num_requests_waiting的趋势确认排队是持续性现象而不是瞬时抖动TTFT p99 和num_requests_running的叠加确认延迟恶化发生在并发路数变多之后cache_hit_rate和num_preemptions_total确认 KV Cache 有没有被合理使用。这三块一叠结论已经不问自明不是模型算力不够而是 KV Cache 的分配策略把并发上限压低了同时 requests 之间没有共享 prefixprefill 每次都在从头算。这个定位过程里最需要注意的是先用指标推翻“GPU 利用率低所以加并发”这个直觉再去动参数。5.3 参数调整逻辑与复测方法调整时我遵循“一次只动两个以内的参数”原则方便对比效果。最终改动是这样vllm serve model-path \ --tensor-parallel-size 4 \ --max-model-len 16384 \ --gpu-memory-utilization 0.92 \ --max-num-seqs 128 \ --enable-prefix-caching \ --enable-chunked-prefill \ --disable-log-requestsmax-model-len降到 16384 是最关键的一步。它直接压缩了每路请求的 KV Cache 上限让更多序列能同时驻留在显存里。接着开prefix-caching把固定 system prompt 和常见对话前缀的重复计算省掉。max-num-seqs从 256 降到 128看着是减少了上限但换来的是更少的抢占和不那么夸张的排队延迟。同一组压测流量下前后数据对比如下指标调整前调整后TTFT p994.1s0.78sTTFT avg0.9s0.35scache hit rate0.080.61running requests824waiting requests40≤5GPU 利用率形态20%~100% 抖动50%~80% 平稳复测的时候要注意不能只压一轮。我是用线上抽样的请求回放模式压了 30 分钟取后 10 分钟数据做统计因为前面 10 分钟缓存还没热起来直接看会把 Prefix Caching 的收益低估掉。这也是为什么建议把 cache hit rate 一起纳入观测范围它决定了你复测结果可不可信。6. 收尾几条踩坑之后沉淀下来的监控经验最后分享几条我在这个项目上踩过实坑之后沉淀下来的习惯不算什么高深理论但确实能帮人省时间。6.1 报警阈值怎么定才不会被通知疲劳TTFT 的报警阈值不要按均值设我会直接看 p99设成“连续 5 分钟超过 2 秒”再告警。这样既不会因为瞬时抖动刷屏也能抓住真正的服务劣化。排队指标num_requests_waiting则需要根据你的实例规格单独定我的经验是超过 20 并持续 5 分钟就要人工介入因为从 20 涨到 50 往往只需要几十秒。抢占计数num_preemptions_total我基本是“零容忍”级监控连续 5 分钟出现增长就直接报警。因为它代表 KV Cache 空间不够已经开始牺牲一部分请求的延迟来保住整体吞吐长此以往用户的体感会非常不稳定。6.2 关于指标命名和版本升级的碎碎念vLLM 版本更新很快同一个指标在不同版本里可能改了名字或者改了语义。你去年搭好的 Grafana 面板升级 vLLM 之后突然不出曲线先别怀疑 Prometheus 坏了去/metrics里找找是不是指标名换了。最好的习惯是把面板里所有 query 存成变量升级后批量校验一遍。还有一个我反复吃亏的地方gpu_memory_utilization不要贪到 0.95 以上。虽然 vLLM 有自己的显存管理但 CUDA context、碎片化、临时变量这些都会额外占一点显存。留 8% 到 10% 的余量能避免在长尾流量冲上来的时候突然 OOM 或者触发一连串抢占。尤其是线上一旦出现gpu crash dump triggered你连排查窗口都没有那才叫真的麻烦。如果你现在也在为“首字慢”或者“并发上不去”发愁我建议别急着调参先把 vLLM 的/metrics接到 Prometheus把 TTFT、cache 相关计数、运行与排队请求数这三组数据看明白。大部分问题在图上都会自己说话。