手上有一批卡,想跑一个中文能力过得去的开源大模型做内部知识问答,很多人的第一反应是拉个 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 更快,但会挤占 decode | decode 更平滑,长 prompt 排队 | 2048 到 4096 |
--gpu-memory-utilization | KV cache 池能占多少显存 | KV 池更大,并发更高 | 保守,容易浪费 | 0.88 到 0.92 |
--max-model-len | 支持的最大上下文 | 显存按这个长度预留,直接吃掉并发 | 超长请求被拒 | 按业务最长 × 1.2 |
--swap-space | CPU 侧换出空间 | 极端拥塞时更抗压 | 容易触发抢占失败 | 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 P50 | TTFT P99 |
|---|---|---|---|---|
| 基线:seqs=16, batched=2048, 无 chunked | 32 | 780 | 0.42s | 6.9s |
| 第一步:seqs 提到 48 | 32 | 1120 | 0.61s | 7.8s |
| 第二步:加 chunked-prefill | 32 | 1075 | 0.55s | 3.2s |
| 第三步:batched-tokens 提到 4096 | 32 | 1310 | 0.58s | 3.6s |
| 第四步:gpu-util 从 0.85 提到 0.92 | 32 | 1385 | 0.57s | 3.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_perc | KV 池占用率 | 持续高于 0.95 |
vllm:time_to_first_token | TTFT 分布 | P99 超过业务 SLA |
vllm:time_per_output_token | TPOT 分布 | 突然抬升说明算力或带宽瓶颈 |
前三个是判断"要不要扩容"的依据,后两个是判断"用户体验有没有劣化"的依据。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 和当时的显存占用。调参这件事最怕的就是"感觉快了",过两天想回滚都找不到原始数字。这套记录我攒了小半年,后来每次换模型、换卡型,翻一翻就能快速定位到合适的起点,比从头试错省太多时间。