☰
vLLM部署Yuan2.0:PagedAttention与高并发推理调优
2026/10/26 19:56:16 网站建设 项目流程

手上有一批卡,想跑一个中文能力过得去的开源大模型做内部知识问答,很多人的第一反应是拉个 transformers 的generate循环先跑通再说。我最初也是这么干的:单条请求答得挺像样,可一旦把并发压到 20 以上,GPU 利用率就开始像心电图一样上下跳,P99 延迟直接飙到十几秒,显存还先爆了。后来把服务换成 vLLM 来部署 Yuan2.0,同样的卡、同样的模型权重,吞吐量翻了四倍多,显存占用反而降下来了。这篇就把我从选型、算显存、调参数、压测到上线排障的完整链路摊开讲一遍,重点说清楚 vLLM 为什么能把 Yuan2.0 这类模型的吞吐拉起来,以及那些文档里不会写、但会实实在在卡住你的细节。适合已经能把模型跑起来、但被并发和延迟折磨过的同学,也适合刚开始接触推理服务、想少走弯路的新手。

1. 先把"高吞吐"这三个字拆开看:原生推理到底卡在哪

1.1generate循环的三处硬伤

很多人以为推理慢是因为 GPU 算力不够,实际上大部分时候 GPU 是在"等"。原生generate的做法是来一个请求处理一个,或者最多做一层朴素的批处理:把所有请求补齐到同一个长度,一起送进模型。这里立刻出现第一个问题——长度对齐带来的无效计算。一个 30 token 的问题和一个 4000 token 的文档摘要请求放在同一个 batch 里,短的那个要白白填充几千个 pad token,算力全花在没用的位置上。

第二个问题是KV cache 的预分配。传统实现会按max_seq_len一次性把显存预留出来,不管你这条请求实际只用 200 个 token。这就像订酒店时要求你把整层楼都包下来,实际只住一间。实测下来,这种方式下 KV cache 区域的有效利用率经常只有两三成,剩下全是碎片和空洞。

第三个问题是没有前缀复用。同一套系统提示词、同一份文档被反复提问时,每次都要从头算一遍 attention,白白烧掉大量算力。

1.2 PagedAttention 做的事情,其实就是给显存装了个"分页器"

vLLM 最核心的改动是把 KV cache 切成固定大小的 block(默认 16 个 token 一块),按需分配、按块寻址。这个思路和操作系统的虚拟内存分页几乎一模一样:逻辑上连续的序列,物理上可以散落在显存的任意 block 里,通过一张 block table 做映射。

带来的直接好处有三个。第一,内部碎片被压到最小,最多浪费最后一个 block 里没填满的部分,浪费率从百分之几十掉到个位数。第二,多个请求可以共享同一个物理 block,做并行采样或者相同前缀的场景,显存直接省一大截。第三,请求可以动态进出批次,短请求算完立刻返回,不必等长请求,这就是 continuous batching 的底层支撑。

这三件事叠加起来,才是"高吞吐"的真实来源。不是模型变快了,是单位显存能同时容纳的 token 数变多了,GPU 的空转时间被填满了。

1.3 Yuan2.0 自身的几个特性,会放大或削弱这些收益

不能指望换个引擎就万事大吉,模型本身的结构会直接影响优化效果,这几点必须先心里有数。

词表偏大。Yuan2.0 系列的分词器词表规模在十三万量级,明显大于常见的十万以内词表。词表大意味着 embedding 层和输出层的参数占比更高,在 2B 这种小规格上尤其明显——采样阶段要在大矩阵上做一次 softmax,如果 batch 里序列很长、生成步数很多,这部分开销会变成不可忽视的固定成本。

部分规格带自定义注意力算子。该系列在注意力层做过局部滤波之类的结构增强,落到代码上就是带有自定义模块的modeling_*.py。vLLM 如果当前版本没有对应的融合 kernel 实现,会退回到 eager 模式执行,速度会打折扣但能跑。判断方法很简单:看启动日志里有没有 fallback 相关提示,或者看--enforce-eager加不加性能差异大不大。

对话模板是强约束。Yuan2.0 的对话格式有自己的特殊占位符(类似<sep>、<bsp>、<eos>这类标记),拼错一个字符,模型就会开始复读或者答非所问。这个问题在后面第 5 节会专门展开,它是新手最容易误判成"模型不行"的坑。

2. 动第一行命令之前,把显存和并行度的账算清楚

2.1 从config.json里抄三个数出来

部署任何模型,我都建议先打开权重目录下的config.json,把这三个字段抄到纸上:num_hidden_layers、num_key_value_heads、hidden_size(或者head_dim)。这三个数决定了后面所有显存计算,比任何经验值都可靠。

权重本身的显存占用很好估:参数量 × 每个参数的字节数。fp16 就是 2 字节,int8 是 1 字节,int4 是 0.5 字节。再乘一个 1.1 到 1.15 的系数,用来覆盖激活值、框架开销和一点点对齐损耗。

模型规格fp16 权重int8 权重int4 权重
2B 级别约 4.0 GB约 2.0 GB约 1.0 GB
51B 级别约 102 GB约 51 GB约 26 GB
102B 级别约 204 GB约 102 GB约 51 GB

看到 102GB 这个数字就该明白,51B 的 fp16 单张 80G 卡是绝对放不下的,必须走张量并行或者量化。这是硬约束,不是调参能绕过去的。

2.2 KV cache 占多少,手算一遍比猜靠谱

KV cache 的显存公式不复杂,但每一步都要用对字段:

单 token 单层 KV 字节数 = 2 × num_key_value_heads × head_dim × dtype_bytes 整模型单 token 字节数 = 上式 × num_hidden_layers 单条序列 KV 占用 = 整模型单 token 字节数 × 序列长度

拿一个 51B 级别的配置举例,假设num_hidden_layers=80、num_key_value_heads=8、head_dim=128、fp16:

  • 单 token 单层:2 × 8 × 128 × 2 = 4096 字节 = 4 KB
  • 整模型单 token:4 KB × 80 = 320 KB
  • 一条 8192 token 的满长度序列:320 KB × 8192 ≈ 2.6 GB

再看可用空间。两张 80G 卡做 TP=2,把--gpu-memory-utilization设成 0.9,每卡可用 72GB,减去 51GB 权重,剩 21GB 给 KV cache。21GB ÷ 2.6GB ≈ 8 条满长度序列。但实际请求平均长度远小于 8192,按平均 2K 算,能同时容纳三十条左右。这个数字直接决定了你的--max-num-seqs该设多大——设得比这还大,多出来的请求只会排队而不是并行。

提示:这段计算里的层数、KV 头数是我按常见配置代入的演示值。你手上的权重如果是不同规格或者做过结构裁剪,务必用自己config.json里的真实字段重算一遍,差一个数量级都不奇怪。

2.3 张量并行不是切得越细越好

TP 的直觉是"多切几份,每卡占用就少",但每切一层都要在层与层之间做 all-reduce 通信。单机内走 NVLink 还好,跨 PCIe 或者跨机的时候,通信开销会迅速吃掉并行带来的收益。

我实测过一个经验规律:同一个模型,TP=4 往往比 TP=8 快,除非权重实在放不下。原因是 8 卡切分后每卡的矩阵乘规模变小,算术强度下降,而通信轮次翻倍。判断阈值可以这样想——如果 TP=8 之后每卡的权重占用还不到 15GB,那大概率是切过头了。

还有一个硬约束:tensor-parallel-size通常要能整除注意力头数。如果num_key_value_heads是 8,那 TP 只能是 1、2、4、8,设成 3 或者 6 会直接报错。

3. 启动参数逐个拆:每个数字背后在控制什么

3.1 最小可跑的一条命令

先把能跑通的版本贴出来,后面所有调优都是在这个基础上做加减:

python -m vllm.entrypoints.openai.api_server \ --model /data/models/yuan2 \ --served-model-name yuan2 \ --trust-remote-code \ --tensor-parallel-size 2 \ --dtype bfloat16 \ --max-model-len 8192 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 32 \ --port 8000

--trust-remote-code这个参数在部署 Yuan2.0 时基本是必加的,因为模型目录里带自定义的 modeling 文件,vLLM 需要执行它才能识别architectures字段对应的结构。加它有安全考量,所以权重来源要可控,别随便从不明渠道拉。

--dtype我建议明确写死,不要让它自动探测。自动探测在某些卡上会选 fp16,而 fp16 在长序列上有溢出风险,bfloat16 的数值范围更宽,长上下文场景更稳。代价是部分老卡对 bf16 支持不完整,这个要看你实际硬件。

3.2 六个真正影响吞吐的参数

网上参数表很多,但大部分只写了"这个参数控制什么",没写"调大调小分别会怎样"。我把实测中变化最明显的六个整理成对照表:

参数本质在控制什么调大之后调小之后我的常用值
--max-num-seqs同时在跑的序列上限吞吐上升,单请求延迟上升,显存吃紧延迟好看,吞吐掉得厉害KV 估算值 × 0.8
--max-num-batched-tokens单次前向的总 token 预算prefill 更快,但会挤占 decodedecode 更平滑,长 prompt 排队2048 到 4096
--gpu-memory-utilizationKV cache 池能占多少显存KV 池更大,并发更高保守,容易浪费0.88 到 0.92
--max-model-len支持的最大上下文显存按这个长度预留,直接吃掉并发超长请求被拒按业务最长 × 1.2
--swap-spaceCPU 侧换出空间极端拥塞时更抗压容易触发抢占失败8 到 16 GB
--enable-chunked-prefill长 prefill 是否切块长 prompt 不再阻塞短请求短请求被长 prefill 拖住开启

--max-model-len这一项特别值得说。很多人为了"支持更长输入",直接写成 32768,结果发现并发掉了一半还多。原因是 vLLM 会按这个长度去规划 KV cache 池的容量上限,设得越大,单条序列能占用的最大空间越大,池子里能塞下的序列数就越少。正确做法是按业务实际的最长输入来设,比如你的文档问答最长也就 6K,那设 8192 就够,别为了一个可能永远用不上的场景牺牲日常并发。

3.3 chunked prefill:长 prompt 场景的必开项

这个参数值得单独拎出来讲。默认情况下,一个 6000 token 的 prompt 会作为一个整体做 prefill,这期间 GPU 全被它占着,其他正在 decode 的请求全部卡住,表现为"偶发的长尾延迟"。开了--enable-chunked-prefill之后,vLLM 会把长 prefill 切成若干块,和 decode 请求混在一批里执行,让每个 token 的生成步不至于中断。

我做过对比:在混合负载(20% 长文档 + 80% 短问答)下,开启之后 P99 延迟从 8.4 秒降到 3.1 秒,吞吐只掉了不到 5%。这个交换比非常划算。需要注意的是开这个参数时--max-num-batched-tokens要跟着调,一般设成 2048 以上效果才明显,太小了切块太碎,反而增加调度开销。

3.4 那些看起来无关、实际会咬人的开关

--enforce-eager是排查问题的利器,也是性能的隐形杀手。它强制关闭 CUDA graph,好处是显存占用略降、报错信息更清楚,坏处是 decode 阶段每步都要重新走一遍 kernel 启动流程,吞吐可能掉 15% 到 25%。我的用法是:排查阶段打开,定位完立刻关掉。如果关掉之后启动失败或者输出乱码,那说明当前 vLLM 版本对这个模型结构的图捕获支持有问题,这时候才需要长期开启。

--disable-log-requests在高并发下必须加上。默认每个请求都会打一行日志,QPS 上千的时候日志本身就会成为瓶颈,甚至把磁盘写满。

--served-model-name建议显式指定一个短名字,不要用完整路径。下游客户端调用时模型名带一长串路径,既难看又容易在迁移时踩坑。

4. 把吞吐真正压出来:三轮实测的过程和思路

4.1 压测脚本怎么写才不失真

用curl循环压测基本没意义,它测的是串行延迟,不是吞吐。我用的是一个基于 asyncio 加 aiohttp 的异步脚本,核心是控制并发数而不是请求总数,同时记录首 token 时间(TTFT)和每 token 输出时间(TPOT)两个指标:

import asyncio, time, aiohttp, statistics URL = "http://127.0.0.1:8000/v1/chat/completions" async def one_request(session, prompt, max_tokens, results): payload = { "model": "yuan2", "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": 0.0, "stream": True, } t0 = time.perf_counter() ttft = None n_tok = 0 async with session.post(URL, json=payload) as resp: async for line in resp.content: if not line.startswith(b"data:"): continue n_tok += 1 if ttft is None: ttft = time.perf_counter() - t0 total = time.perf_counter() - t0 results.append((ttft, total, n_tok)) async def main(concurrency, rounds): results = [] connector = aiohttp.TCPConnector(limit=concurrency) async with aiohttp.ClientSession(connector=connector) as s: tasks = [] for i in range(concurrency * rounds): tasks.append(one_request(s, "解释一下分页注意力的原理", 256, results)) await asyncio.gather(*tasks) ttfts = [r[0] for r in results] totals = [r[1] for r in results] tok = sum(r[2] for r in results) wall = max(totals) print(f"并发 {concurrency} 吞吐 {tok/wall:.1f} tok/s " f"TTFT_P50 {statistics.median(ttfts):.3f}s " f"TTFT_P99 {sorted(ttfts)[int(len(ttfts)*0.99)]:.3f}s") asyncio.run(main(32, 4))

几个让数据不失真的细节:temperature设 0 保证输出长度可预期;一定要用stream模式才能拿到真实的 TTFT;并发数要按"同时在线"来理解,不是总请求数;每组至少跑三轮取中间值,第一轮往往因为 cache 冷启动偏低。

4.2 三轮调参的对照数据

我在同一台 8 卡机上用 51B 级别权重、TP=4、bf16 跑的对照,输入长度分布覆盖 200 到 3000 token,输出固定 256 token:

配置并发吞吐 (tok/s)TTFT P50TTFT P99
基线:seqs=16, batched=2048, 无 chunked327800.42s6.9s
第一步:seqs 提到 483211200.61s7.8s
第二步:加 chunked-prefill3210750.55s3.2s
第三步:batched-tokens 提到 40963213100.58s3.6s
第四步:gpu-util 从 0.85 提到 0.923213850.57s3.5s

从这组数据能读出几件事。第一,max-num-seqs从 16 提到 48,吞吐涨了 43%,但 P99 也涨了,这是典型的用尾延迟换吞吐,能不能接受取决于业务。第二,chunked prefill 几乎不损失吞吐,却把 P99 砍掉近一半,属于纯粹的赚。第三,gpu-memory-utilization从 0.85 提到 0.92 还有 5% 左右的空间,但再往上就要小心,留一点余量给激活值峰值,别把卡榨到 OOM。

4.3 瓶颈到底在 TTFT 还是 TPOT,判断方法不一样

这两个指标涨的原因完全不同,优化手段也不一样。TTFT 高,说明排队或者 prefill 慢,方向是加并发槽位、开 chunked prefill、缩短 prompt、上 prefix caching。TPOT 高(每个输出 token 的间隔),说明 decode 阶段算力不够或者显存带宽被打满,方向是降 bf16 到量化、减少 batch、或者干脆加卡。

判断的最快办法是看吞吐随并发的变化曲线。如果并发从 16 加到 32,吞吐涨了接近一倍,说明之前没吃满,瓶颈在调度;如果并发从 32 加到 64,吞吐只涨了 10%,那已经到算力墙了,再加并发只会让延迟难看。

4.4 相同前缀场景,prefix caching 是白捡的收益

内部知识问答这类场景有个特点:系统提示词固定、挂载的文档片段重复率高。开启--enable-prefix-caching之后,相同前缀的 KV 直接被复用,不用重算。我测过一个典型 case,系统提示词 1200 token、用户问题 50 token,开启前 TTFT 是 0.38 秒,开启后降到 0.09 秒,几乎是把 prefill 成本抹掉了。

需要注意的是它吃显存,因为要额外维护前缀的 block 索引,而且如果请求的前缀重复率很低(比如纯自由对话),开了反而会增加哈希计算开销。建议先统计一下线上请求的前缀重复分布再决定。

5. 上线之后真正会遇到的五类故障和排查链路

5.1 启动即 OOM:先分清是权重放不下还是 KV 池要太多

报错信息里如果出现out of memory且发生在加载权重阶段,那基本是并行度不够,加 TP 或者上量化。如果是在引擎初始化最后阶段、日志里已经打完模型加载完成之后才 OOM,那是 KV cache 池规划得太大,把--gpu-memory-utilization往下调 0.02 到 0.05 再试。

我的排查顺序是固定的:先把--max-model-len降到 2048 跑一次,能起来说明是长度规划问题;还起不来就把--gpu-memory-utilization降到 0.8;仍然不行才考虑加卡或者量化。这个顺序保证每次只动一个变量,不会把自己绕晕。

5.2 多卡启动卡住不动:NCCL 那点事

现象是进程起来了,日志停在初始化通信组那一步,几分钟没有任何输出,最后超时退出。这类问题九成出在卡间通信配置上。检查顺序:nvidia-smi topo -m看拓扑是不是预期的 NVLink 连接;环境变量里NCCL_P2P_DISABLE、NCCL_IB_DISABLE有没有被别人的脚本污染;容器里/dev/shm是不是太小,默认 64MB 在 8 卡场景下经常不够。

我遇到过一次特别隐蔽的:同一个容器镜像在两台机器上一台能起一台不能,最后发现是其中一台的驱动版本低了两个小版本,NCCL 走了不同的通信路径。所以部署前把驱动和 CUDA 版本对齐,比事后排查省事得多。

5.3 输出复读、答非所问:八成是对话模板拼错了

这是部署 Yuan2.0 最高频的"假故障"。表现是模型输出一段通顺但完全无关的内容,或者反复重复同一句话。很多人第一反应是"模型量化掉点了",其实大概率是 prompt 拼接格式不对。

排查方法很直接:用官方仓库里的推理脚本跑同一个问题,如果那边正常、vLLM 这边不正常,那问题就在 chat template。检查两件事——tokenizer 目录下的tokenizer_config.json里有没有正确声明chat_template字段;vLLM 版本是否会读取并应用它。如果字段缺失,就需要手动在请求侧按官方格式拼好prompt,走/v1/completions而不是/v1/chat/completions。

另外,temperature和repetition_penalty的组合也会造成复读。贪心解码(temperature=0)在某些模型上确实更容易陷入循环,可以试试temperature=0.1配合repetition_penalty=1.05做对比。

5.4 并发一高就大面积超时:先看是不是排队而不是算得慢

客户端报超时的时候,别急着加机器。先看服务端的 metrics:如果running数量一直顶在max-num-seqs上限、waiting队列持续增长,那就是排队,说明并发槽位不够或者单请求耗时太长。如果running没到上限但吞吐上不去,那才是算力问题。

还有一种情况是客户端连接池太小。压测脚本里如果不设TCPConnector的 limit,默认连接数会成为瓶颈,测出来的数据全是客户端限制的结果。这个坑我踩过,白白怀疑了半天服务端。

5.5 升级 vLLM 之后精度肉眼可见地掉了

版本升级带来的行为变化主要有两处:默认 dtype 策略变了,或者某个融合 kernel 的实现改了数值路径。排查方式是固定随机种子,用同一批 prompt 在新旧两个版本上各跑一遍,对比输出。如果只是措辞不同属于正常,如果出现明显的逻辑错误或者胡言乱语,就把--dtype显式写死成升级前的值再试。

我的习惯是生产环境锁死小版本号,升级前先在灰度实例上用固定的评测集跑一遍,确认没有回归再滚动。推理引擎这类组件的版本迭代很快,不锁版本等于给自己埋定时炸弹。

6. 服务化落地:接口、监控和灰度这几件事

6.1 OpenAI 兼容层够用,但要留意两个字段

vLLM 自带的/v1/chat/completions已经兼容主流客户端,直接换 base_url 就能接。有两个字段值得特别处理:max_tokens一定要在网关层强制限制上限,否则某个客户端传了个 32000,单条请求就会把整个 KV 池占满,其他请求全部排队;stream建议默认开启,用户感知的等待时间会短很多,但要注意网关层的缓冲配置,很多 Nginx 默认配置会把流式响应攒成一坨再发,用户端看到的就是"卡很久然后一次性出来"。

6.2 该盯的指标就那么几个

指标含义告警阈值参考
vllm:num_requests_running正在执行的序列数持续接近 max-num-seqs
vllm:num_requests_waiting排队中的请求数持续大于 0 超过 30 秒
vllm:gpu_cache_usage_percKV 池占用率持续高于 0.95
vllm:time_to_first_tokenTTFT 分布P99 超过业务 SLA
vllm:time_per_output_tokenTPOT 分布突然抬升说明算力或带宽瓶颈

前三个是判断"要不要扩容"的依据,后两个是判断"用户体验有没有劣化"的依据。gpu_cache_usage_perc长期贴着 1.0 是最危险的信号,说明 KV 池已经没有余量,下一步就是请求抢占和重算,延迟会突然爆掉。

6.3 优雅退出这件事,别等出事才想起来

推理服务重启时,如果直接杀进程,正在生成的请求全部中断,客户端拿到的是半截响应。vLLM 支持收到 SIGTERM 后先把手上的请求跑完再退出,但需要你给它留时间。K8s 里terminationGracePeriodSeconds建议设到 120 秒以上,配合preStop钩子先从 Service 摘流量、等几秒再发信号。

滚动更新的时候还有个细节:新实例起来后要等模型加载完成、健康检查通过才能接流量。模型加载在 51B 这个量级上要几分钟,如果 readiness probe 的initialDelaySeconds设得太短,新 Pod 会被反复重启,滚更永远走不完。我的做法是单独加一个/health探针,引擎真正 ready 之后才返回 200,而不是只探端口通不通。

最后分享一个我自己的习惯:每次调整参数之前,先把当前配置和一组基准数据记在同一个文件里,包括并发数、吞吐、TTFT、TPOT 和当时的显存占用。调参这件事最怕的就是"感觉快了",过两天想回滚都找不到原始数字。这套记录我攒了小半年,后来每次换模型、换卡型,翻一翻就能快速定位到合适的起点,比从头试错省太多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询