ARTICLE DETAIL

资讯详情

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

vLLM高并发部署Qwen大模型:四大参数坑与调优实战

vLLM高并发部署Qwen大模型:四大参数坑与调优实战 上个月我把 Qwen2.5-7B-Instruct 用 vLLM 部署成内部平台的 OpenAI 兼容 API目标很直接让十几个业务方同时调用单接口 P95 延迟控制在 2 秒以内。结果从压测到上线前后踩了四个大坑——不是模型能力问题全是部署参数和调度策略的细节。今天这篇就把每个坑的现象、原因、排查思路和最终配置一次讲清楚给正准备用 vLLM 做开源大模型高并发 API 的人当个参考。先交代一下环境后面所有参数都基于这套Ubuntu 22.04A100 80G 单卡vLLM 0.6.3模型 Qwen2.5-7B-Instructbf16 精度通过vllm serve启动 OpenAI 兼容服务。这套组合现在非常常见你换成 13B 或者 70B 模型坑的类型是一样的只是数字要重算。1. 坑一max-model-len 拍脑袋乱设KV Cache 直接被吃穿1.1 线上 400 报错问题出在启动参数服务上线第一天就出事了。短文本测试全部通过结果有业务方直接传了一份几页纸的合同文本进来API 立刻返回 400This models maximum context length is 4096 tokens. However, you requested 5120 tokens. Please reduce the length of the messages or completion.这个报错在 GitHub issues 里被问了无数次原因非常朴素启动 vLLM 时--max-model-len默认取了模型 config 里的 4096或者你自己设了一个偏小的值导致请求超过这个长度直接被拒绝。更隐蔽的是很多团队根本不看启动日志里打印的 KV cache 信息等线上爆了才发现并发能力远远低于预期。还有个特别容易忽略的细节如果你用了--served-model-name给模型起了别名而调用方传的 model 字段和别名对不上也会报类似的 400 错误。热词里那个 The supported API model names are deepseek-v4-pro... 就是同一个坑。确保调用方的 model 名称和服务端--served-model-name完全一致这是 OpenAI 兼容接口的基本礼仪。1.2 为什么 max-model-len 和显存是强耦合的很多人以为max-model-len只是一个允许的最大长度开关调大点没坏处。实际上它直接决定单条序列占用的 KV cache 大小。KV cache 是推理过程中缓存的 Key 和 Value 矩阵用来避免每生成一个 token 都重新计算前面的注意力。它的大小可以近似用这个公式算单序列 KV cache 2 × num_hidden_layers × num_key_value_heads × head_dim × dtype_size × max_model_len以 Qwen2.5-7B 为例config.json 里num_hidden_layers28num_key_value_heads4head_dim128bf16 每个数占 2 字节那么每个 token 的 KV cache 占用2 × 28 × 4 × 128 × 2 57344 字节 ≈ 56 KB一个 max_model_len32768 的请求单序列 KV cache 就有 1.75 GB。如果你在 A100 上gpu_memory_utilization 预留了 50GB 给 KV cache那理论上最多同时跑 28 个这种长度的请求再往上就是 OOM。所以max-model-len不是越大越好它是一个显存预算的分配开关。设小了长文本请求被拒设大了单序列吃掉太多 KV cache系统整体并发能力暴跌。1.3 先统计业务 token 分布再确定 max-model-len这个坑的解法不是靠猜而是靠业务数据。上线前我用 tokenizer 把历史请求做了一次长度分布统计from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen2.5-7B-Instruct) lengths [len(tokenizer.encode(text)) for text in historical_prompts] # 输出 P50/P90/P95/P99 分位数 for p in [50, 90, 95, 99]: val sorted(lengths)[int(len(lengths) * p / 100)] print(fP{p}: {val})统计结果显示我们内部业务 95% 的请求在 8K token 以内但偶尔会有 20K 左右的长文档。最终我把max-model-len定在 32768既覆盖了长文档场景也没有把并发压到不可接受的程度。启动命令是vllm serve Qwen/Qwen2.5-7B-Instruct \ --served-model-name qwen2.5-7b \ --max-model-len 32768 \ --gpu-memory-utilization 0.90启动后务必用/v1/models接口确认参数生效curl http://localhost:8000/v1/models返回的 JSON 里max_model_len字段会显示当前实际值。我用一个表格列出 Qwen2.5-7B 在 A100 80G、KV cache 预算约 50G 的前提下不同 max-model-len 对应的单序列占用和理论最大并发max-model-len单序列 KV cache理论最大并发序列4096224 MB约 2288192448 MB约 111327681.75 GB约 281310727 GB约 7注意这里的并发上限是所有请求都跑满 max-model-len的极端值实际业务请求长短不一可用并发会更高。但方向很清楚max-model-len 每翻一倍长请求的并发能力就减半。所以很多人图省事直接设 128K上线后 QPS 上不去根本不是 vLLM 不行而是 KV cache 预算早就被超长序列吃干了。2. 坑二默认并发参数直接用于生产QPS 上不去还偶发 OOM2.1 现象压测一到 10 并发就开始排队第二个坑是上线前压测踩的。vLLM 的默认启动参数看起来很省心不传--max-num-seqs、--max-num-batched-tokens也能跑。我用 locust 模拟 10 个并发用户跑了一个简单的问答场景结果傻眼了单请求延迟 300ms但 10 并发时 P95 直接跳到 5 秒GPU 利用率一直在 20%~40% 之间波动任务堆积严重。更诡异的是偶尔还会冒出来 CUDA out of memory。单请求明明怎么跑都不会 OOM为什么并发一高就爆显存2.2 原理max-num-seqs 和 max-num-batched-tokens 是配合关系vLLM 之所以快核心是 continuous batching一个 iteration 里同时处理多个序列动态地在 prefill 和 decode 之间切换。控制这个行为的有两个关键参数--max-num-seqs一个 iteration 里最多同时处理的序列数默认通常是 256。--max-num-batched-tokens一个 iteration 里最多处理的 token 总数不同版本默认值有差别有的版本默认 2048有的默认 8192务必用vllm serve --help确认。这两个参数是配合关系不是独立关系。max-num-batched-tokens决定了每轮迭代的总预算max-num-seqs决定了这个预算最多分给几个序列。如果max-num-batched-tokens太小而每个请求在 prefill 阶段有几千 token那么一个迭代里只能塞进一两个序列剩余序列全部排队吞吐自然上不去。反过来如果你把max-num-batched-tokens拉到很大每轮迭代要处理大量 tokenGPU 显存瞬时峰值会飙升碰到几个长序列同时 prefillOOM 就来了。我们当时的情况就是这个典型默认 max-num-batched-tokens 对长 prompt 业务太小调度器每轮只能喂进去少量请求QPS 上不去而当请求数量堆积、某些请求的 batch 被强行合并时显存峰值又爆了。2.3 解法按场景给参数用 bench 验证这个问题没有万能参数但有一个合理的经验路径。首先想清楚你的业务形态短文本场景输入输出都在 1K token 以内可以把max-num-batched-tokens设大让更多请求同时挤进一个 iteration。长文本场景输入几 K输出几百 tokenmax-num-seqs不能太大否则 prefill 阶段会短时间吃掉大量显存。我调了一版参数用下来比较稳定场景max-num-seqsmax-num-batched-tokens配合的 max-model-len短问答输入≈512输出≈51212881928192中长文本输入≈2K输出≈1K64819216384长文档分析输入≈8K输出≈1K32409632768注意表格只是起点值不是终点。关键是验证手段。vLLM 0.6 以上版本自带了压测命令vllm bench serve可以直接跑一组基准vllm bench serve --model Qwen/Qwen2.5-7B-Instruct \ --tokenizer Qwen/Qwen2.5-7B-Instruct \ --input-len 512 --output-len 256 \ --max-num-seqs 32 \ --max-num-batched-tokens 4096它会输出吞吐量和延迟分布我用它快速对比不同参数组合比自己在 locust 里造数据高效得多。另外压测时一定要盯着/metrics接口关注vllm:num_requests_running和vllm:num_requests_waiting两个指标。如果 running 长期低于 max-num-seqs 而 waiting 一直在涨说明参数没配好GPU 在空转而不是没有资源。我这边的最终调整是--max-num-seqs 96 --max-num-batched-tokens 819210 并发压测 P95 从 5 秒降到 1.2 秒OOM 再没出现过。3. 坑三重复前缀不缓存几十路并发全在重复 prefill3.1 现象TTFT 高GPU 算力拉满但吞吐还是上不去第三个坑是在优化请求延迟时发现的。我们服务的 prompt 结构很固定一大段几百字的 system prompt加上几条 few-shot 示例最后才是用户的真实问题。理论上前面的公共前缀每次都一样每次请求都应该直接复用但实测 TTFT首 token 延迟一直很高GPU 计算量看起来也很满吞吐却没有对应提升。后来看 vLLM 的日志prefix cache hit rate 一直徘徊在个位数。也就是说绝大多数请求都在重复计算同一段 system prompt 和 few-shot这是巨大的浪费。3.2 原理Automatic Prefix Caching 的命中条件比你想的更苛刻vLLM 提供 Automatic Prefix CachingAPC原理是把 prompt 切分成固定大小的 block默认 16 个 token为每个 block 计算 token id 的 hash缓存 KV block。新请求进来时如果前缀 block 的 hash 和缓存里的一致就直接复用 KV跳过 prefill 计算。听起来很美但命中条件非常苛刻必须是 token 级别的前缀完全一致而且对齐到 block 边界。这意味着三件事第一前缀必须放在 prompt 的最前面中间不能插入任何变化的内容。如果 system prompt 是固定的但你在最前面拼了一个当前时间或者request_id那第一个 block 的 hash 就对不上后续所有缓存全部失效。第二消息结构必须稳定。在 OpenAI 兼容模式下vLLM 会把 messages 数组用 chat template 拼成一个字符串再切 block。如果你这次是 system 在前下次是 user 在前或者 system 内容顺序微调缓存立刻作废。第三很多团队忽略的加载模型时用的 chat template 版本变了也会导致前缀完全对不上。这个我们踩过升级 vLLM 后 tokenizer_config.json 的 chat template 被更新了老缓存全部失效。3.3 解法显式开启缓存把 prompt 结构设计成前固定后变化先说参数。老版本 vLLM 需要显式传--enable-prefix-caching新版本虽然默认开我还是建议你在启动脚本里显式写出来防止版本升级后行为变化vllm serve Qwen/Qwen2.5-7B-Instruct \ --served-model-name qwen2.5-7b \ --max-model-len 32768 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 96 \ --max-num-batched-tokens 8192 \ --enable-prefix-caching然后是最关键的结构设计。prompt 应该遵守前固定后变化原则system prompt 开头完全固定不要插时间戳、request_id、随机数。few-shot 示例放在 system 后面位置和顺序保持稳定。变化的业务参数放到 user 消息的末尾或者在 messages 里作为最后一条消息传入。举个例子如果你要传姓名、诉求这两个变化字段不要把单一字符串拼在 system 前面而是放在 user 的最后system: 你是客服助手规则如下…… user: 我叫张三我的诉求是查询订单状态。而不是system: 当前用户叫张三诉求是查询订单状态下面是客服规则……前者 system 固定前缀缓存能命中后者每个用户都不同缓存完全失效。效果方面我们固定大约 1.5K token 的 system prompt 之后缓存命中率从个位数提升到 80% 以上。RAG 场景收益更大——把长文档放 system 开头、query 放最后多个用户问同一篇文档时文档部分的 prefill 几乎可以完全跳过。这里要提醒一句多副本部署时 KV cache 是各实例独立的负载均衡如果随机路由缓存命中率会被稀释。如果业务对缓存依赖很高可以考虑在网关层按 system prompt 的 hash 做一致性路由让相同前缀的请求尽量打到同一个实例。4. 坑四长请求堵住短请求P95 延迟直接失控4.1 现象P95 飙升、监控探活误报、503/529 频发第四个坑是真正上线后才暴露的。短请求压测一切正常但混合场景一上来——既有几十 token 的短问答又有几千 token 的长文档生成——P95 延迟直接失控。短请求明明 200ms 就能做完硬生生等了十几秒。更难受的是监控系统频繁探活/health有时候探活都超时误报服务不可用触发了一堆无意义的告警和重启。这种场景在热词里太常见了503 server overloaded、529 overloaded全是典型的容量不足或调度被长任务堵死的报警。4.2 原理先到先服务的批调度长任务会挤压短任务vLLM 的 continuous batching 虽然能动态混合 prefill 和 decode但整体调度还是偏先到先服务。一个长输出序列在 decode 阶段会一直占住max-num-seqs里的一个位置并且每轮 iteration 至少消耗一个 token 的max-num-batched-tokens预算。如果长请求大量挤进来它们会持续占住并发名额短请求即使先到也可能排不进去而 vLLM 的 API server 默认会把暂时处理不了的请求放进 waiting 队列而不是直接拒绝于是等待时间一路累积P95 就崩了。另外探活也会成为帮凶。/health接口本身只检查进程是否活着但它同样会进入 API server 的处理流程。如果你在高负载时高频调用它它可能因为排队超时导致误报而误报触发的服务重启又会把正在处理的请求全部打断雪上加霜。4.3 解法限流入口、拆分长短任务、多副本 一致性哈希针对这个问题我做了三件事。第一在 API 网关层加限流超过阈值直接返回 429而不是让请求无限排队。vLLM 本身不擅长做业务级限流它更倾向于接收所有请求然后尽力调度。对高并发 API 来说快速失败比无限等待更健康客户端可以拿到明确信号去重试或降级。用 nginx 做个最简单的并发限制limit_req_zone $binary_remote_addr zonellm_api:10m rate20r/s; server { location /v1/completions { limit_req zonellm_api burst40 nodelay; proxy_pass http://vllm_upstream; } }第二把长请求和短请求拆到不同的 vLLM 实例上。长文档分析走一个--max-num-seqs 16 --max-model-len 32768的实例短问答走另一个--max-num-seqs 128 --max-model-len 8192的实例互不抢占。这个策略对混合负载非常有效代价是多部署一套环境但收益是延迟曲线变得非常平稳。第三改造探活和监控方式。不要只靠/health它只能告诉你进程在不在不能告诉你处理能力强不强。更可靠的容量信号是/metrics里的 waiting 队列长度。只要vllm:num_requests_waiting持续大于 0说明实例已经饱和应该扩容而不是重启。我把告警规则改成了waiting 持续 10 秒以上才触发扩容探活频率从每 1 秒一次降到每 15 秒一次误报率立刻降了下来。多副本扩容时还有个小技巧如果副本数不止一个网关尽量按用户或者按 system prompt 做哈希路由这样能最大程度保住跨请求的 prefix cache。最简单的方式是在网关层对messages[0].content取 hash 再对副本数取模实测能把多副本场景下的缓存命中率保持在单实例的 80% 以上。这轮部署下来我的体感是 vLLM 的默认参数能跑通 demo但离稳定的高并发 API 还差得很远。你真正要做的不是照抄某一组参数而是先摸清四个数字业务侧的真实 token 分布、KV cache 预算、单实例并发上限、副本数。这四个数字一旦清晰大部分故障都能在启动命令阶段提前避开。最后再分享一个实用建议所有参数改动都要落到启动脚本的版本管理里压测结果和参数一一对应。我们后来排查 P95 飙升时就是靠 git 历史对比才发现某个同学把--max-num-batched-tokens从 8192 调到了 16384导致长请求大批挤占 batch。没有历史记录这种问题查起来会非常痛苦。
返回列表