ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

vLLM 调优实践:分离 Prefill 与 Decode,构建无 GPU 前端

vLLM 调优实践:分离 Prefill 与 Decode,构建无 GPU 前端 如果你最近在租 GPU 跑 DeepSeek 或者 Qwen 这类大模型大概率遇到过这么个现象GPU 利用率一会儿拉到 99%一会儿又掉到 30%首 token 延迟忽快忽慢并发一多就开始排队。很多人第一反应是显存不够、并发开大了但真正的问题往往出在你把 prefill 和 decode 当成一个整体来看。vLLM 0.30 系列的调度器和以前不太一样把这两个阶段拆开理解、分别调参往往比无脑加 GPU 更管用。这篇文章我会从 prefill/decode 的区别讲起再给你一套可以直接抄的 vLLM 配置最后聊一个我最近在做的“无 GPU 前端”架构——把网关、排队、tokenizer 这类活全部丢到 CPU 机器上让 GPU 只干 GPU 该干的事。1. Prefill 和 Decode同一个推理里两种完全不同的“体力活”1.1 为什么 prefill 吃算力decode 吃带宽大模型推理不是均匀地逐 token 计算而是分成两个截然不同的阶段。Prefill 阶段模型要把你输入的整段 prompt 一次性读进去并行算出每个位置的注意力结果生成第一份 KV Cache键值缓存。这个过程本质上是大量矩阵乘法属于典型的算力密集任务。你可以把它理解成“通读全文并当场做笔记”——内容越多做笔记的时间越长这块拼的是 GPU 的 FLOPS。Decode 阶段模型开始逐个 token 往后生成。每一步只处理一个“当前 token”但需要把前面所有 token 的 KV Cache 都重新拖出来参与注意力计算。所以这一步的瓶颈几乎完全在显存带宽上——用生活里的话说就是“每次只写一个字但要把之前所有笔记从头翻一遍”。GPU 算力再强带宽上限卡在那吞吐量就很难再往上走。这两个阶段的差异直接体现在不同的性能指标上维度PrefillDecode瓶颈资源算力FLOPS显存带宽关键延迟指标TTFT首 token 延迟TPOT单 token 生成时间单步耗时特征耗时可能很长且波动大单步耗时相对稳定对 GPU 的占用方式短时间高烈度冲击长时间均匀占满1.2 混跑时的“相互拖后腿”问题vLLM 默认把所有请求丢进同一个调度队列模型执行阶段不区分 prefill 还是 decode谁排到队首谁先跑。这在并发低的时候问题不大一旦并发上来两种请求就会互相打架。最经典的局面是一个大 prompt 请求进了队列调度器把整个 prefill 阶段一口气跑完这个 step 可能要花几百毫秒甚至更久。在这段时间里所有正在 decode 的请求全部被卡住用户感知就是“打字都打完了屏幕还在转圈”。反过来如果 decode 请求太多每个 step 都被这些短任务塞满新来的长 prompt 请求就一直排不上队TTFT 无限拉高。这个场景很像一个大卡车和小汽车混行的单车道。卡车体积大、启动慢一旦上路后面的小汽车全得跟着龟速前进可如果频繁让小汽车插队卡车又永远到不了港口。vLLM 0.30 要解决的就是怎么让这两类“车”在同一个 GPU 上尽量和谐地跑。1.3 分离关注点的核心思路所谓 prefill/decode 分离并不是说一定要把两个阶段物理拆到两台不同的 GPU 上跑。至少在 vLLM 的标准用法里官方没有提供“一键把全部 prefill 丢到 0 号卡、decode 丢到 1 号卡”的开关。真正的分离思路有两种第一种时间维度的分离。通过调整调度参数把 prefill 拆成小块让它在 decode 的 step 间隔里穿插执行避免单个大 prefill 独占 GPU。这是 vLLM 本身就支持的能力也是这篇文章要重点讲的。第二种架构维度的分离。在部署层面把服务拆成两个角色一个实例专门负责接待长 prompt 的 prefill另一个实例专门负责稳定的 decode 续写两者之间用网关做路由。这种玩法更像生产级的拆分vLLM 本身不需要改动但要靠前端把请求调度好。这两种思路并不矛盾。我的建议是先掌握第一种把 vLLM 的调度参数吃透再考虑第二种因为架构层面的分离需要额外的服务编排和监控成本不是所有项目都有必要上。2. vLLM 0.30 里与阶段分离直接相关的关键开关vLLM 从 0.3 开始调度器一直在往“区分阶段、精细控制”的方向走。下面这几个参数是你在做 prefill/decode 分离调优时必须弄明白的。2.1 Chunked Prefill别再让超长 prompt 卡住所有 decodeChunked Prefill分块预填充是 vLLM 0.3.0 引入的特性作用很直接把一次完整的 prefill 切分成多个 chunk每个 chunk 和一部分 decode 请求放进同一个 step 里执行。没有 chunked prefill 时一个 4096 token 的 prompt 可能一次 prefill 就要占满整个 step。开了分块之后这 4096 个 token 会被切成比如 512 一段一个 step 跑一段中间穿插着 decode 的 token 生成。这样单个 step 的耗时更平均decode 请求不会长时间被卡在队里TTFT 反而更稳定。在 vLLM 0.30 的启动参数里你可以显式传python -m vllm.entrypoints.openai.api_server \ --model /models/qwen3-8b \ --chunked-prefill \ --max-num-batched-tokens 2048注意vLLM 0.3.x 到 0.5.x 之间chunked prefill 的行为和默认值一直在变化。我自己的体感是0.30 这个阶段最好手动加上到了 0.50 之后只要你设置了max-num-batched-tokens它会在部分条件下自动启用。所以最稳妥的做法是启动后看日志确认一下有没有出现Chunked prefill is enabled这类提示。2.2 max-num-batched-tokens / max-num-seqs给两个阶段各自留出“呼吸空间”这两个参数是 vLLM 调度器的两大控制旋钮。max-num-batched-tokens控制的是每一个 step 最多能处理多少个 token包括 prefill 和 decode 的 token 总和。可以理解为“单车道单次放行多少辆车”。这个值压得越小单个 step 的耗时越短decode 被阻塞的时间越短但单位时间处理的 prompt token 总量也会下降长 prompt 的 TTFT 可能变差。这个值开得越大整体吞吐能上去但每个 step 的延迟波动会变大。max-num-seqs控制的是同一个 step 里最多允许多少个序列也就是多少条并发请求同时参与计算。这个值决定了你能同时服务多少用户。要注意的是max-num-seqs乘上你的 max-model-len如果远大于显存容量vLLM 会直接拒绝启动或者频繁触发显存交换。我的经验是这两个参数必须放在一起调。只调一个不调另一个很容易出现“并发上去了但每个请求都慢”的情况。比如你把max-num-seqs开到 128但max-num-batched-tokens只有 512那一轮 step 里 128 个请求平分 512 个 token每个请求平均只能推进 4 个 tokendecode 速度直接塌了。2.3 Prefix Caching把重复计算的 prefill 直接省掉如果说 chunked prefill 是“把大活拆小了干”那 prefix caching 就是“重复的活干脆别干”。vLLM 支持对公共前缀做 KV Cache 复用也就是你之前算过的 prompt 开头部分如果下一个请求也带了相同前缀这一段的 prefill 可以直接跳过。这个特性对 real-world 场景特别有用。比如很多应用会在每个请求前拼接一大段 system prompt这部分内容每次完全相同再比如 RAG 场景里多轮对话会反复携带历史上下文。开了 prefix caching 之后这些重复计算会被砍掉prefill 阶段的压力会大幅降低。启动时加上python -m vllm.entrypoints.openai.api_server \ --model /models/deepseek-llm-7b \ --enable-prefix-caching需要提醒的是prefix caching 跟 chunked prefill 配合时有一个常见的坑如果你的 prefix 特别长但max-num-batched-tokens设得太小缓存命中后的剩余校验逻辑可能反而增加额外开销。我后面在实测章节会再细说。3. 实操配置从默认配置到“分离”模式的实际调参过程3.1 基准场景单人租用 GPU 跑 DeepSeek 的初始配置为了把配置讲得具体一点我拿一个常见场景举例你在云上租了一张 24GB 显存的卡比如 L40S 或者 RTX 4090要部署一个 7B 级别的 DeepSeek 或者 Qwen 模型服务的用户大概是内部工具或者小范围测试。很多人第一次启动 vLLM 会用这样的命令python -m vllm.entrypoints.openai.api_server \ --model /data/models/deepseek-llm-7b \ --served-model-name deepseek-7b \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --tensor-parallel-size 1这样能跑但并发一旦上来问题马上暴露。最典型的是前 10 个请求都顺畅第 11 个请求带了一段 3000 token 的上下文进来所有人的响应都变慢了。原因就是第一个大 prefill 把 GPU 整个霸占了。3.2 逐步调整参数后对比指标我建议把它调整成这种“分离”模式python -m vllm.entrypoints.openai.api_server \ --model /data/models/deepseek-llm-7b \ --served-model-name deepseek-7b \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --tensor-parallel-size 1 \ --enable-prefix-caching \ --chunked-prefill \ --max-num-batched-tokens 2048 \ --max-num-seqs 32改动点只有 4 个但每个的用意都不一样--enable-prefix-caching公共前缀直接复用 KV Cache长 prompt 的 prefill 耗时显著下降。--chunked-prefill把 prefill 拆块避免单个请求霸占 step。--max-num-batched-tokens 2048限制单 step 总 token 数让每个 step 的耗时可控。--max-num-seqs 32限制并发请求数防止过多请求摊薄每个请求的 token 配额。调整前和调整后最重要的三个指标变化方向是TTFT 更稳定不再出现某个长 prompt 请求把所有人卡死的峰值、TPOT 波动变小、整体吞吐不一定暴涨但用户体验会明显变好。如果你手里的场景是“高频小请求、短 prompt、长输出”可以尝试把max-num-batched-tokens再调低到 1024max-num-seqs调到 64。反之如果场景是“长文档问答、RAG、大上下文”建议保持 2048甚至可以试 4096但必须盯着显存占用和 latency 波动。3.3 配置时的注意点KV Cache、显存、并发数的三角关系调参最忌讳只盯着一个数字。gpu-memory-utilization决定 vLLM 可以占用多少显存KV Cache 是从这块显存里划出来的max-num-batched-tokens和max-num-seqs又共同决定了每一轮 step 需要多少 KV Cache 空间。这是三角关系牵一发而动全身。如果你把max-num-batched-tokens开得很大同时又设置了很大的max-num-seqs那么 KV Cache 需要预留的空间会指数级上涨最终可能触发ValueError: The number of sequences ... is huge之类的报错。我踩过一次比较深的坑为了追求高吞吐把max-num-batched-tokens调成 8192max-num-seqs调成 128结果 24GB 显存完全不够vLLM 直接启动失败。最后退回到 2048 和 32显存占用稳定在 22GB 左右。所以我的建议是小显存24GB的卡总量优先先把max-num-batched-tokens控制在 2048 以内再去调max-num-seqs。4. “无 GPU 前端”把 API 网关、请求队列、Tokenize 全部放到 CPU 机器上聊完推理内部的分离我们再往上层看前端。这里的“前端”不是浏览器里那个前端而是流量入口层包括 API 网关、请求队列、路由转发甚至一部分 prompt 预处理。很多人习惯把所有服务全塞进 GPU 机器导致 GPU 既要跑模型又要处理 HTTP 连接、排队调度、tokenizer 解析属于典型的高射炮打蚊子。4.1 为什么要做无 GPU 前端GPU 是按小时计费的尤其租来的卡每一秒都值钱。但 tokenizer 解析、HTTP 请求分发、用户连接维持这些都是轻量 CPU 任务放在 GPU 机器上跑并不会让推理变快反而可能因为机器负载升高影响稳定性。更重要的是生产环境里 GPU 机器通常是稀缺资源而普通 CPU 服务器便宜得多。把前端层全部丢到 CPU 机器上GPU 节点只需要保持一个稳定的 vLLM 进程专门吃推理请求架构上清爽很多也不容易互相干扰。“无 GPU 前端”并不是指前端这一层不需要调用 GPU而是指前端进程本身可以运行在完全不带 GPU 的机器上。它把请求收下来做基础处理再转发给后端的 vLLM 服务。vLLM 那边返回结果后前端再把结果吐回给调用方。4.2 整体架构拆解我目前比较推荐的最小生产架构是三层接入层无 GPUNginx 或 OpenResty负责 TLS 终结、限流、按路径或请求头路由。队列层无 GPU对突发流量做缓冲可以简单点用内部内存队列或者直接上 Redis 做请求排队。推理层GPUvLLM 的 OpenAI 兼容服务只暴露一个 API 端口。这个架构里真正的请求链路是客户端 - CPU 机器上的 Nginx - 转发规则 - 后端 vLLM 的/v1/chat/completions接口vLLM 在处理请求时tokenizer 本来就是在 CPU 上跑的GPU 只负责模型 forward流式返回时Nginx 需要关闭 proxy buffering否则你会看到前端积累一大块才返回流式效果被破坏4.3 使用容器和负载均衡落地用容器化来落地最简单。GPU 节点单独部署启动 vLLM 容器docker run --runtime nvidia --gpus all \ -e NVIDIA_VISIBLE_DEVICES0 \ -p 8000:8000 \ -v /data/models:/models \ vllm/vllm-openai:latest \ --model /models/deepseek-llm-7b \ --served-model-name deepseek-7b \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --enable-prefix-caching \ --chunked-prefill \ --max-num-batched-tokens 2048 \ --max-num-seqs 32前端节点不带 GPU跑个 Nginx 容器upstream vllm_backend { server 192.168.1.10:8000; keepalive 32; } server { listen 443 ssl; server_name your-api.example.com; location /v1/ { proxy_pass http://vllm_backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; } }这里面有几个关键点。proxy_buffering off很重要vLLM 的流式输出全靠 SSE如果 Nginx 默认开了缓冲前端会攒一大块再吐给调用方用户感知延迟会非常差。keepalive 32是为了保持后端连接避免每个请求都重新建连。VLLM 本身支持 HTTP/1.1 长连接Nginx 这边保持好吞吐会有明显提升。4.4 Windows 上跑 vLLM 社区版的特殊性vLLM 在 Linux 上最稳但确实有不少人是在 Windows 上折腾的。Windows 上启动 vLLM 大概率会撞见 ROCm 和 CUDA 的兼容问题社区版目前主要依赖 WSL2 或者 Docker Desktop。如果你非要在 Windows 本地跑我建议优先 WSL2显存透传和 CUDA 驱动兼容性都比 Docker Desktop 省心。还有一个常见现象Docker Desktop 在使用一段时间后docker pull偶尔会报一个奇怪的错failed to decode referrers index: invalid这个错跟镜像内容其实没关系通常是 Docker 的 content store 索引损坏或者 registry 通讯异常。解决办法很简单重启 Docker Desktop或者把本地的C:\Users\你的用户名\AppData\Local\Docker下面的临时缓存清理掉重新拉取一般就恢复了。Windows 上跑 GPU 容器本来就是“能跑就谢天谢地”不要在环境问题上死磕太久。5. 实测与踩坑记录5.1 从 GPU 利用率曲线看分离效果我拿一张 L40S 做了简单对比测试。场景是32 个并发请求其中混入了若干 2000 token 以上的长 prompt输出长度设定在 512 token。默认配置下GPU 利用率曲线是典型的锯齿状——每来一个长 prompt利用率瞬间冲上去然后其他请求全部等待利用率再掉下来。开了 chunked prefill 并把max-num-batched-tokens压到 2048 之后利用率曲线明显平缓TTFT 的 P99 从原来的 4 秒多降到了 1.5 秒左右TPOT 也稳定在 45ms 到 60ms 之间。这里要澄清一下分离调优不是让总吞吐翻倍而是让延迟更可预测、把资源分配做得更符合用户体感。如果你跑纯 benchmark固定输入输出长度、没有混合场景这类调参的收益可能不明显但生产流量往往是混合的所以更贴近实际。5.2 踩坑prefix caching 和 chunked prefill 的组合问题前面提到过prefix caching 和 chunked prefill 搭配时有个坑。有一次我把max-num-batched-tokens设成 512同时开着 prefix caching结果日志显示prefix cache hit rate很高但 TTFT 反而没有下降多少。分析之后发现问题出在 chunk 太小缓存校验本身需要读取前缀的 token id 列表和 block table这个开销占比变大把缓存省下来的 prefill 时间抵消了。后来我把max-num-batched-tokens提到 2048同时保证公共前缀在 KV Cache 中占用的是连续的 blockhit rate 和 TTFT 才都恢复正常。所以提醒大家这两个特性本身都很好但它们不是无脑叠加的需要观察 vLLM 启动日志里的 cache 统计再做微调。5.3 无 GPU 前端后的延迟影响和网络优化把前端拆到独立 CPU 机器之后最直接的影响是多了一跳网络。调用方到 Nginx 的延迟如果本来就 1ms再加后端到 GPU 节点 1ms 的 RTT总量也才 2ms对 LLM 推理来说完全可接受。真正需要担心的是连接建立开销。我遇到过的问题是Nginx 默认每请求一条新连接压力测试时后端的 ESTABLISHED 连接数飙到几千vLLM 进程出现延迟抖动。解决办法是在 Nginx 的 upstream 里开启 keepalive同时把proxy_http_version设为 1.1这样前后端共用连接池连接建立开销会大幅下降。另外如果请求体很大比如几万 token 的 prompt记得把 Nginx 的client_max_body_size调大默认 1MB 很容易被长上下文请求顶爆。最后再分享一个小技巧如果你在多个 GPU 节点前挂一个无 GPU 前端而且想按模型或者按负载做路由可以在 Nginx 层用请求体里的model字段做判断。vLLM 的 OpenAI 兼容 API 请求体是 JSON你可以用 OpenResty 读取请求体解析出model字段然后转发到不同的后端。这样一台 CPU 机器就能管住多个 GPU 节点而且完全不需要 GPU 资源。我自己踩过不少次坑后形成的习惯是新部署一个 vLLM 服务时永远先跑一套默认配置的压测记录下 TTFT 和 TPOT 的 P50/P99然后开 chunked prefill、prefix caching 再跑一套最后把前端拆出去之后再跑一套。三套数据摆在一起哪里出了问题一眼就能看出来。这比到处抄别人的启动命令靠谱得多。
返回列表