把开源大模型落成高并发 API,vLLM 是目前绕不开的方案之一。我在过去大半年里,用 vLLM 把多个开源的 7B/8B 模型(Qwen 系为主)部署成线上接口,从单机单卡一路折腾到多机多卡,线上 QPS 从个位数压到几十,中间踩了不少坑。这篇就挑四个最典型的坑讲透:显存/KV Cache 配置、并发参数、上下文长度与 Chunked Prefill、Prefix Caching 命中率。
如果你正准备把开源模型包装成高并发的 API 服务,或者正在为 vLLM 的吞吐量、服务稳定性焦头烂额,这篇文章值得花十分钟看完。我会把部署思路、关键参数、实测数据、踩坑记录全部摆出来,不会只丢一句“调大参数”就完事。
1. 方案选型:为什么我最后锁定了 vLLM
1.1 vLLM 在高并发 API 场景的核心优势
先说结论:如果你的目标是“把开源大模型对外提供 API”,vLLM 几乎是目前综合成本最低的路线。原因在于它把推理侧的关键问题解决得比较干净,尤其是 PagedAttention 和 Continuous Batching。
PagedAttention 解决的是 KV Cache 内存碎片问题。早期推理框架为每个请求预分配一块固定大小的 KV Cache,请求长短不一,内存浪费严重,并发一高就 OOM。vLLM 把 KV Cache 切成固定大小的块,按需分配,类似操作系统里的分页内存管理。这个机制直接决定了它能把显存利用率做到很高,从而撑起更大的并发。
Continuous Batching 解决的是请求粒度问题。传统框架里 batch 一旦确定,必须等最慢的一条生成完才能整体结束,其他短请求只能干等。vLLM 在每一步解码时动态决定哪些序列继续、哪些新序列进入,短请求生成完立刻腾位置给新请求。这一条对 API 场景太关键了,因为它意味着同样一块卡可以塞下更多并发请求,而不是被少数长文本请求拖死。
除了这两个底层机制,vLLM 还自带 OpenAI 兼容的 HTTP 服务。也就是说你拉起vllm serve,客户端可以直接用 OpenAI SDK 去调,请求格式都兼容,省掉自己写协议转换的功夫。对于业务方来说,替换成本很低,这在实际落地时是个巨大的隐形收益。
1.2 部署之前的几个关键决策
很多人一上来就pip install vllm然后vllm serve model_name,这样跑通没问题,但高并发下几乎必然翻车。部署前有几个决策必须想清楚。
第一,卡型与显存。显存大小直接决定你能开多大的--max-model-len,以及同一时刻能塞下多少并发序列。我给个经验值:7B 模型 fp16 权重约 14GB,8B 模型约 16GB。剩余显存基本都留给 KV Cache 和计算中间态。如果你只有 24GB 的卡,跑 7B 模型做长上下文高并发,非常挤;48GB 或 80GB 的卡会舒服很多。
第二,是否量化。AWQ/GPTQ 4bit 量化能把 7B 权重压到 4~5GB,省下的显存全部能变成 KV Cache。代价是精度损失和偶尔的算子兼容问题。我的经验是:纯中文任务、对输出质量要求没那么苛刻,量化值得用;涉及代码生成、数学推理这类对精度敏感的任务,建议先拿着量化模型和你自己的评测集比对一遍再决定。
第三,版本锁定。vLLM 迭代非常快小版本之间行为差异也大。务必将 vLLM、CUDA、PyTorch 版本固定住,不要生产环境随便升。我后面会提到一个 0.23.0 的 chunk_size 问题,就是版本变动导致的行为变化。生产环境永远跑你压测通过的那个版本。
第四,单卡还是多卡。单卡能扛住就单卡,迫不得已再上--tensor-parallel-size 2/4。多卡虽然扩容,但通信开销会吃掉一部分吞吐,8B 以下模型多卡收益不显著。如果目的是提高并发,优先考虑多副本 + 负载均衡,而不是单机塞多卡。
2. 坑一:显存和 KV Cache 配置不当,要么 OOM 要么吞吐腰斩
2.1 gpu-memory-utilization 不是越大越好
我第一次部署时踩的坑非常朴素:为了让吞吐最大化,我把--gpu-memory-utilization调到了 0.95,结果服务跑着跑着,一个大请求进来,显存直接撑爆,进程崩溃,API 全部 503。当时我第一反应是“vLLM 是不是有内存泄漏”,后来查日志才发现,是 KV Cache 预留了过多显存,留给计算图和中间激活值的空间不够。
这里解释一下机制:vLLM 在启动时会根据gpu_memory_utilization参数划分显存,一部分给模型权重,一部分给 KV Cache,剩下的给 CUDA context 和激活值等。如果你把利用率压得太满,比如 0.95,初始分配是很激进,但一旦遇到极端 batch 或长序列,需要额外显存处理临时张量,就会 OOM。
我的建议是:
- 单卡专用推理服务:
--gpu-memory-utilization 0.85~0.88比较稳。 - 机器上还有其他进程(比如监控、采集):
0.75~0.80。 - 多卡 tensor parallel:每卡利用率按 0.85 算,留足通信缓冲。
这不是让你把参数调到极致,而是让系统在高并发波峰下有缓冲。容量规划里必须有“波峰余量”这个概念,不能拿平均值设计。
另外,启动日志里会打印类似GPU KV cache size: 38.0 GB的信息,这个值很重要。如果它远小于预期,说明模型权重或中间态占掉了过多显存,需要检查是不是没有量化、是不是max-model-len开太大、是不是swap-space设得过大。
提示:
--swap-space参数会让一部分 KV Cache 落到 CPU 内存,默认 4GB。显存充足时建议设为 0,避免触发换页导致延迟剧烈波动。我见过线上偶发 5~10 秒的毛刺,最后发现就是 swap 在作祟。
2.2 max-num-batched-tokens 和 max-num-seqs 是隐秘的瓶颈
比显存更隐蔽的坑是--max-num-batched-tokens。这个参数控制一次前向传播最多处理多少个 token。早期版本默认值很小(2048 左右),这意味着哪怕你的显存还多得很,一次 batch 也只能处理这么多 token。
怎么理解它?可以把一次前向传播想象成一辆卡车,max-num-batched-tokens 是卡车的载重上限,max-num-seqs 是车厢里的乘客数上限。你不能只看乘客数量,还得看所有乘客的 token 总量不能超过载重。
举个例子:max-num-seqs=128,看似能并发处理 128 个请求,但如果每个请求的 prompt 平均 1000 token,128 个请求就是 128000 token,远超默认的 batched-tokens 限制。这时候调度器会怎么办?它会优先处理一部分序列,剩下的只能排队。表现就是:明明显存没满,并发一高吞吐还是上不去。
我在 2×A100 80G 上部署 Qwen2.5-7B-Instruct 时,最初没有显式设置 max-num-batched-tokens,默认值只有 2048,单卡吞吐大概只有 800 tokens/s,怎么看怎么不对。后来翻文档意识到这个限制,把它调到 8192,吞吐量立刻翻倍。
但这个参数也不是无限大。你把它调到 16384,而 KV Cache 只够支撑 8000 token 的并发总量,调度器就会频繁做 preemption,把部分序列的 KV Cache 挤出,等下一轮再重新加载。来回被踢出踢进,延迟反而飙升。所以这两个参数必须跟显存、KV Cache 联动考虑,不能单独拍脑袋。
2.3 一套可复制的显存参数配置方法
我分享一下现在比较稳的配置流程。以 Qwen2.5-7B-Instruct 部署到单张 80G 卡为例。
先看权重大小:fp16 约 15GB。再设计最大并发:假设业务高峰期大约 50 个并发请求,平均每个请求 prompt + output 合计约 2000 token,那么理论上 KV Cache 需要支撑 100000 token 的状态。7B 模型每个 token 的 KV Cache 开销(GQA 结构)每层约几百 KB 量级,按典型值估算,100000 token 大约需要 30~40GB 显存。这样总显存需求约 60GB,80G 卡留出 20% 余量,设 0.80 是比较合理的。
启动参数大致如下:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.85 \ --max-model-len 8192 \ --max-num-seqs 64 \ --max-num-batched-tokens 8192 \ --swap-space 0 \ --enable-prefix-caching启动后看日志里的 KV Cache size 数值,再对着实际压测调。如果显存还有富余,优先加max-num-seqs;如果频繁出现 preemption 日志,优先降max-num-seqs或max-num-batched-tokens。
注意:early scheduling 规则下,
max-num-seqs是“同时在处理的序列上限”,不是“排队请求上限”。超出这个值的新请求会进入等待队列,这是正常现象,反而能平滑压力。关键是要让等待时间可控,不能无限制堆请求。
3. 坑二:并发参数没调好,压测一上来就 503 和超时
3.1 max-num-seqs 与排队机制的真相
第二个大坑来自并发参数的误读。vLLM 的 OpenAI 兼容服务默认用队列承接请求,当所有 worker 都在忙,新请求会排队。排队本身不是问题,问题是队列默认行为加上 Nginx 或客户端超时,会让你看到大量 503 或读超时。
我一开始以为把 max-num-seqs 调大就能抗住高并发,结果把 256 调上车后,显存立刻吃紧,preemption 频繁,且部分请求等太久。原因很容易理解:max-num-seqs 说的只是调度器同时处理的序列数上限,并不是“每分钟能处理多少请求”。每个请求的 prompt 长度和生成长度不一样,256 个长序列可能直接耗光 KV Cache,导致后面请求全线排队。
502/503 的典型来源是上游代理(比如 Nginx)与 vLLM Server 之间的超时设置。vLLM 的排队等待不计入流式响应,如果上游 read timeout 设 60 秒,而请求在队列里已经等了 70 秒,这条请求就断了。客户端重试往往又打回同一个实例,加剧雪崩。
所以高并发 API 并不只是“vLLM 内部参数”问题,而是客户端、网关、服务端三者之间的超时和重试策略需要对齐。
3.2 正确的压测姿势:用官方 benchmark 脚本,别自己乱试
vLLM 自带的benchmark_serving.py是压测时最省事的工具,但它也有一点需要留意:脚本默认按固定并发数和 request rate 发压,如果你的服务参数没调好,压测结果会显得特别惨。
我常用的压测命令类似下面这样(vLLM 0.6.x 以及之后版本):
python benchmarks/benchmark_serving.py \ --backend vllm \ --model Qwen/Qwen2.5-7B-Instruct \ --tokenizer Qwen/Qwen2.5-7B-Instruct \ --endpoint /v1/chat/completions \ --dataset-path ./sharegpt_8k.json \ --num-prompts 500 \ --request-rate 20 \ --max-concurrency 64--request-rate表示每秒注入多少请求,--max-concurrency表示最多同时在途多少请求。跑到 20 req/s 时如果失败率高于 1%,就要考虑调参,而不是继续加压力。
压测时还要注意,dataset 长度分布要贴近真实业务。我用过纯长 prompt 的压测数据,结果吓一跳,后来发现是压测数据太极端。正确做法是:用线上日志统计 prompt 长度的分位数(P50/P90/P99),据此构造压测数据集。
3.3 一次压测回放:从超时雪崩到稳定
这里记录一次真实调优过程。环境:单机 2×A100 80G,用的--tensor-parallel-size 2,部署 Qwen2.5-7B-Instruct。最初配置:max-num-seqs=256,gpu-memory-utilization=0.92,max-num-batched-tokens 没设(默认 2048),客户端并发 100。压测结果:连续 503、客户端 read timeout、单请求 P99 延迟达到 90 秒。
第一反应是“并发太高”,但我把并发降到 20,问题依旧。后来看日志发现 preemption 极多。原因链是这样的:默认 max-num-batched-tokens=2048 太小,调度器每次只能从队列里挑少量请求进 batch;而 max-num-seqs=256 太大,已进入的序列会持续占用 KV Cache;两者组合下,新请求一直进不来,老请求又在慢慢生成,整个服务吞吐极低,延迟雪崩。
调优方向是让两者匹配:把 max-num-seqs 降到 96,max-num-batched-tokens 提到 8192,gpu-memory-utilization 降到 0.85。重新压测,同并发下 P99 落到 4.2 秒,吞吐从不足 500 tokens/s 上升到 2200 tokens/s,几乎不再出现 503。
调完服务端参数后,我又在接入层加了令牌桶限流,把每实例的 QPS 峰值限制在压测通过值的 80%。这样即使业务方突发流量进来,服务端也不至于被瞬间压爆,而是排队时间可控,客户端不会看到大量失败。
实操心得:高并发排查顺序建议是“先看 vLLM 日志 preemption,再看延迟分位数,最后看失败率”。不要一看到失败率就加机器,很多时候是参数不匹配导致单机吞吐根本没发挥出来。给服务端一个缓冲用的限流,比盲目扩容更便宜。
4. 坑三:上下文长度和 Chunked Prefill 带来的 400 与首字延迟
4.1 max-model-len 设大了浪费,设小了报错
第三个高频问题:客户端报400 max context length ... tokens。原因通常是服务端--max-model-len设得比请求的实际长度小。比如模型理论支持 128K,但你部署时设了 8192,业务方一旦拼进去较长上下文就会 400。
反过来也有问题:有些人在部署时把--max-model-len直接拉到模型上限(比如 32768),看起来“一劳永逸”,实际上 KV Cache 根本不够支撑高并发下的长序列。而且 vLLM 给每个序列按最大长度预留缓存空间,max-model-len 越大,同样显存能同时服务的序列就越少。表现就是:设为 32K 后,哪怕业务实际请求只有 2K,并发也上不去。
我现在的做法是:先统计线上真实使用的输入长度 P99,取 P99 的 1.5 倍作为部署长度。比如业务 P99 是 6000 token,我就设 12288 或 16384。这样既不会频繁 400,也不会因为长度虚高而白白浪费显存。
如果确实需要长上下文和超高并发:用 Prefix Caching 配合上下文压缩,或直接把长上下文模型独立成一个专用服务,和短文本服务分开部署。
4.2 Chunked Prefill 的原理和 chunk_size 的坑
Prefill 是处理 prompt 的阶段,decode 是逐 token 生成的阶段。传统实现里,一个请求 doing prefill 时如果 prompt 很长,会独占 GPU 计算,其他请求的 decode 全部卡住,首字延迟(TTFT)直接飙升。Chunked Prefill 就是把长 prompt 切成多个 chunk,穿插到 decode 的缝隙里执行,让不同请求的计算可以混跑,整体吞吐和首字延迟得到平衡。
这里有一个非常值得注意的坑:vLLM 某个小版本(我在生产环境遇到过 0.23.0)里,chunk 大小相关参数在特定组合下会出现未预期的调度异常或性能退化。具体表现是:开启 chunked prefill 后,没有变快反而更慢,甚至偶发请求卡死。这个和参数、模型、版本都有关系,遇到这种问题不要慌,第一步是复现,第二步是升级到修复版本,第三步是如果没法升级,可以把 chunk_size 调回一个比较常规的值(例如 512 或 1024)观察是否缓解。
我的建议是:如果你不需要处理大量长 prompt 请求,刚开始部署可以先不开 chunked prefill,保证逻辑简单、可排查;但如果你的业务确实有长文档问答、长上下文分析,必须开,否则首字延迟会非常难看。开了之后,要重点盯两个指标:
- TTFT(首 token 延迟):如果 P99 急剧上升,看看是不是 chunk 太小导致切换开销过大。
- GPU 利用率:如果利用率保持高位但吞吐没涨,检查是不是 preemption 变多了。
4.3 长 Prompt 请求的建模与长度校验
在 API 服务这一层,我不能只依赖 vLLM 去处理长度问题。更稳妥的是在接入层做长度预检:通过 tokenizer 将 prompt 编码后统计长度,超过服务端max-model-len一定比例就提前驳回,或者做截断/摘要。
我处理长 Prompt 的惯用套路:
- 固定 system prompt,要求业务方尽量复用同一套。
- 动态内容(用户输入、数据库检索结果)放在 prompt 后段。
- 在接入层统计:如果超出 max-model-len,先做检索结果裁剪,再尝试截断,最后才报 400。
- 在监控里增加一个“请求长度超限次数”指标,用于推动业务方优化 prompt。
这个设计很朴素,但效果极佳。它能避免 400 直接打到用户脸上,也能防止恶意构造超长 prompt 把服务拖垮。
提示:vLLM 日志里如果频繁出现
Received prompt of length X exceeds max_model_len Y,不要只改 max-model-len,先去追问为什么会有这么长的 prompt。压垮服务的大部分时候不是正常业务,而是没控制好的长文本请求。
5. 坑四:Prefix Caching 开了却不生效,命中率上不去
5.1 前缀缓存的工作原理与价值
第四个坑是关于--enable-prefix-caching。它的思路是:如果多个请求的 prompt 前缀相同,前面那段已经算过的 KV Cache 可以复用,不用重新 prefill,从而省算力和时间。
这个能力在高并发聊天场景里价值非常大。因为很多业务会把 system prompt、角色设定、工具说明、历史会话摘要放在前面,变化的是后面的用户输入。前缀复用后,单请求的实际计算量可以大幅下降,吞吐和延迟都能受益。
但“开了这个参数”和“真的命中缓存”是两码事。我在早期天真地以为加上--enable-prefix-caching就完事了,结果看监控,命中率几乎没有。
5.2 命中率上不去的四个常见原因
第一个原因:请求前缀里有易变内容。最常见的就是把 request_id、timestamp、随机数拼在 prompt 开头。哪怕后面几百个字完全相同,只要头部变了,缓存就废了。这是最简单也最容易忽视的原因。
第二个原因:多副本负载均衡把相同前缀的请求分散到了不同的 vLLM 实例。每个实例的 KV Cache 是独立的,同一个前缀的缓存存在 A 实例上,但请求被 Nginx 分发到 B 实例,B 还得从头算。这是多副本场景下命中率下降的典型原因。
第三个原因:vLLM 版本差异。旧版本中 prefix caching 可能是默认关闭或者行为不一致,没有显式开启就不生效。新版本默认开启,但中间几个版本对缓存判定有调整,有的要求 greedy sample 才能复用,有的要求 temperature 等参数一致。如果你发现命中率莫名变化,优先查版本变更说明。
第四个原因:prompt 格式不统一,比如有的请求带 system 消息,有的不带;有的用<|im_start|>system这种模板包裹,有的不包裹;不同的多轮消息组织方式都会导致前缀不一致。
5.3 提高命中率的落地做法
我的落地策略分为三层:
第一层是 Prompt 规约。强制业务方遵守:固定系统提示词写最前面;所有动态信息统一拼到后段;同一场景使用同一个 prompt 模板。这一层最简单,也收益最大。
第二层是路由策略。如果有多副本,不要让负载均衡随机分发,可以按 system prompt 的哈希值进行一致性哈希路由,确保相同前缀的请求尽量打到同一个实例。这样即使有多个副本,每个副本的缓存也能有相对高的命中率。
第三层是监控。vLLM 的 metrics 或日志会暴露 prefix cache 相关信息。我在压测时专门看两个数据:缓存命中率和缓存命中带来的延迟收益。如果命中率低于 50%,回到前两层排查。
有一个实用的调优技巧:如果 model temperature、top_p 等采样参数会影响缓存,尽量把采样参数归一化到少量几个预设值,比如 temperature=0.2/0.7/1.0 三档,而不是让调用方随意传值。这能显著提升缓存复用率,代价是自由度下降,但对 API 服务是完全合理的取舍。
6. 把 4 个坑串起来:一套可以参考的生产配置
6.1 一份自带避坑结果的启动配置
把上面的经验汇总成一份可以直接抄作业的示例。我以 Qwen2.5-7B-Instruct、单机多卡 A100/H800、推理服务独占 GPU 为例子:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.85 \ --max-model-len 16384 \ --max-num-seqs 128 \ --max-num-batched-tokens 8192 \ --swap-space 0 \ --enable-prefix-caching \ --served-model-name qwen2.5-7b-instruct对应的接入层要点:
- Nginx upstream 的超时设置为
connect 5s、read 120s,不要小于后端 P99 生成时间。 - 客户端只允许使用预定义的采样参数组合,避免自定义参数导致缓存失效。
- 接入层对单实例做 QPS 限制,比如压测最大 30 req/s,线上限 24 req/s。
这里特别说明一下--served-model-name:对外暴露这个名字,方便以后在同一套 API 架构下切换模型,只要改服务名,不用改客户端。
6.2 压测后的大致效果参考
用这套配置压测(ShareGPT 数据分布,单请求平均输入 900 token、输出 300 token),我在 2×A100 80G 上看到的数据大概如下:
| 指标 | 数值 |
|---|---|
| 并发 60 时吞吐 | 约 2000~2400 tokens/s |
| 并发 60 时 TTFT P99 | 约 2.5~4s |
| 并发 60 时单请求 P99 | 约 4~6s |
| 500 请求成功率 | 99.5% 以上 |
这不是一个极限数字,而是“留了余量、能稳定跑”的数字。若你非要极限压,同一配置可能会高 20~30%,但线上稳定性没有保证。我的建议是:泡在能稳定跑的值上,把多出来的余量留给业务突刺。
6.3 资源更多时怎么扩展
这套单集群配置扛到一定程度后,必然会遇到上限。我的扩展优先级是:
- 先把 Prefix Caching、prompt 规约做到位,这部分零硬件成本,收益最大。
- 再考虑水平扩展:启动多个 vLLM 副本,前面加负载均衡,并做跨实例的一致性哈希路由,以保持每个副本的缓存命中率。
- 如果业务有明确的“长文本问答”和“短文本对话”两类需求,拆成两个服务,各自配不同的 max-model-len 和并发参数,避免互相干扰。
- 最后才考虑 tensor parallel 继续加卡,因为多卡通信开销会稀释收益。
这套路径我一直沿用到后来业务规模涨了大约三倍,核心思路没变:先榨单机,再做无状态横向扩展,最后才上多卡。
7. 踩过这几遍坑后,我想说的几件事
如果让我重新部署一次,我会把监控和日志排在第一位,而不是先调参数。vLLM 虽然自带一些 metrics,但真正有用的业务指标(TTFT、TPOT、preemption 数、KV Cache 使用率、排队时间)最好还是自己拉出来,接到 Prometheus/Grafana 或者简单的看板里。没有这些数据,调参就是瞎猜,出问题也只能重启。
第二件事是版本管理。vLLM 每个小版本都可能在调度、参数默认值上做调整。生产环境锁版本,升级前必须拿同一套压测脚本跑一遍 AB 对比,千万别上线前一晚手滑升个版本。
第三件事是给业务方留退路。API 服务无论怎么做容错,都可能出现极端情况。客户端要做好超时重试、退避、降级(比如切换到更小的模型或直接返回兜底答案)。这个不属于 vLLM 的范畴,但却是高并发 API 稳定性的另一半。
各个版本的差异、模型适配情况都在快速变化,我这套参数不是万能公式,但它能帮你少走很多弯路。建议拿着这四类问题的排查思路,结合你自己的模型和流量模式再调一轮,大概率能跑出一个稳定可用的服务。
最后再分享一个小技巧:每改一个参数,都记录下当时的压测结果和日志关键行。我后期能快速定位问题,靠的就是这套修改记录。部署开源模型这件事,麻烦的从来不是跑起来,而是把服务调稳,且能解释每一个数字背后的原因。