每当看到群里有人问“为什么我本地起的LLM服务一遇到并发就报错”我就知道对方多半还没有接触过vLLM这类高吞吐推理引擎。单个请求跑起来很顺畅模型回答质量也没问题但只要并发一上来显存占用就像被倒进了无底洞速度断崖式下跌甚至直接OOM。其实这不是模型本身的问题而是框架在预备推理资源时的策略太粗放。vLLM能成为本地高并发推理服务的常用选择核心不是它“加了个批处理”而是它把显存管理和请求调度两件事从底层重写了。这篇文章会把原理、部署、调参、压测和踩坑整条链路完整讲一遍。适合那些正准备在本地或内网给团队搭一套推理服务、而不是只跑单次demo的人参考。1. 为什么单机推理服务撑不住高并发——先说痛点1.1 显存预分配看起来合理实际很浪费大多数传统推理框架处理请求时会为每个请求预先分配一块连续的显存存放它生成过程中的KV Cache。KV Cache是注意力计算需要缓存的历史键值生成多少个token就要往里面写多少数据所以它的大小和输出长度强相关。问题就出在这个“预先分配”上。框架不知道一次请求最终会生成多少token只能按照最大可能长度去预留。比如设置了max tokens为1024实际只生成了200个剩下824个token的显存份额就被整块空置了。一个两个还好并发一多大量显存被空置的预留占着真正的请求反而挤不进去。更麻烦的是每个请求都需要一段连续的显存区域显存被切得七零八落之后碎片越积越多明明总量看着还有新的请求就是分配不到合适的连续块。1.2 静态批处理先进来的反而卡住后面的人第二个隐蔽问题是批处理机制。传统框架的批处理是静态的一批请求同时进入必须等这一批里最后一个请求生成完整批才会释放资源。一个很形象的类比是全班一起考试必须等最后一个交卷才能下课。先答完的请求明明已经空闲却还占着GPU资源和批内位置后面的请求只能排队干等。这样忙闲不均GPU经常处于“一部分人在陪你等”的状态吞吐自然上不去。尤其当各请求输出长度差异很大时短的几秒钟就结束长的要跑几十秒长尾效应会把整个批次的效率拖垮。1.3 真正需要高吞吐推理引擎的场景传统框架这些短板在单请求场景下完全感受不到因为资源绰绰有余。只有当你面对真实业务负载时才暴露比如同时来了几十个对话请求或者多个业务方共用同一个推理服务又或者需要长时间稳定运行。此时显存利用率直接决定了你能挂住多少并发批调度策略直接决定了有效吞吐量。如果你只是偶尔在Notebook里调一次模型接口这篇文章里的很多优化你暂时用不上。但如果你是给团队或业务方搭一个内部推理服务要求同时服务十几个甚至几十个请求还希望服务能稳定跑一整天那vLLM基本就是我默认推荐的第一选择。2. PagedAttention与连续批处理高吞吐背后真正起作用的两件事2.1 分页式注意力把KV Cache从“连续大块”变成“按页分配”vLLM的PagedAttention是我认为最值得先理解的部分。它的思路来自操作系统里的虚拟内存分页。传统做法要求KV Cache在物理显存上连续存放但输出长度是动态的谁也没法预先知道最终要多大。于是要么按最大长度预留要么中途不够再整体搬移。vLLM把KV Cache拆成固定大小的逻辑块默认每个块缓存16个token的KV状态通过一张块表来记录每个请求用到了哪些物理块。生成过程中按需申请块用多少申请多少物理上零散分布也没关系——块表负责把它们映射起来。这就好比虚拟内存中的页表程序看到的是连续的虚拟地址背后是零散分布的物理页框随时可以按需映射。这个设计带来的直接收益是显存利用率大幅提升。原来可能40%甚至一半显存花在预留和碎片上现在这部分损耗被压得很低。显存利用率高了同一张卡能同时处理的请求数量就明显增加这是高并发服务的第一个基础。2.2 连续批处理不再等最慢的人第二个核心机制是连续批处理。传统静态批处理就像全班一起考试等最后一个交卷而vLLM的调度器会逐个检查请求状态一旦某个请求完成生成——不管是达到了max tokens还是遇到了停止符——马上释放它的槽位让排队的请求补进来。这个过程按迭代持续进行几乎是实时的。这样每个批次的成员是动态变化的先来的先走排队的补上GPU始终在处理“活”的请求而不是空转等待。连续批处理和分页注意力叠加的效果非常明显显存利用率高了能并行的序列多了动态调度又让序列之间的切换和补位非常平滑。很多人的误区是以为vLLM只是优化了显存设置实际上它把推理框架里最核心的两个瓶颈都重写了。2.3 前缀缓存被不少人忽略的吞吐加速器除了上面两个机制vLLM还有一个很适合真实业务场景的优化Prefix Caching。如果多个请求的输入前缀相同比如都带同一段超长system prompt或同一组few-shot示例vLLM可以共享这些前缀的KV Cache。新请求进来时直接复用前缀部分不需要重新做预填充计算。这个功能在常规压测脚本里不太容易体现但实际业务里很多请求都带着类似的长提示词效果非常可观。启用参数是--enable-prefix-caching后面调优部分我会再展开。到这里原理层面的东西够了接下来进入实操。3. 本地部署从环境准备到API服务跑通3.1 环境要求与模型选型先说环境。vLLM主体支持NVIDIA GPU依赖CUDA工具链。Python版本建议固定3.10或3.11能省去很多依赖兼容问题。显存是硬指标一个7B到8B的模型用BF16或FP16加载大约需要16GB以上开高并发再叠加KV Cache最好有24GB以上。显存不够也不是不能玩可以换AWQ或GPTQ量化模型占用能降到一半以下。模型选型方面首次上手建议优先选生态兼容性好的主流开源模型。vLLM对主流几条模型线的支持最完善预构建模块和调度逻辑都踩过坑很少遇到格式问题。我自己第一次就选了个小众模型结果tokenizer加载和对话模板适配花了大半天后来换回主流型号才顺利跑通。3.2 安装与启动命令安装很简单提前具备CUDA环境的前提下执行pip install vllm想跑新特性可以指定版本安装但线上环境我一般更推荐固定版本号避免升级带来的依赖不兼容。装好后直接启动服务vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --max-num-seqs 64这里几个参数逐个解释。--gpu-memory-utilization 0.9表示允许vLLM用掉90%显存做推理服务剩下10%留给CUDA context和其他开销--max-model-len 8192限制上下文长度这个值太大的话KV Cache会吃掉几乎所有显存直接压缩并发能力--max-num-seqs 64是允许同时运行的序列数上限它决定并发能力的上限但又不能设到让显存爆掉。启动后看到类似“Uvicorn running on http://0.0.0.0:8000”的日志服务就起来了。最直接的验证方式是请求OpenAI兼容接口curl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d { model: Qwen/Qwen2.5-7B-Instruct, messages: [{role: user, content: 你好介绍一下你自己}], max_tokens: 100 }能正常返回JSON说明链路已经通了。3.3 为什么OpenAI兼容API是个省心选择这里值得单独说一句。vLLM提供的是OpenAI兼容接口也就是/v1/chat/completions和/v1/completions。这意味着原来对接OpenAI接口的客户端、SDK、前端组件只需要把base_url改成你的本地服务地址其他逻辑几乎不用改。我在实际项目里多次受益于这一点。业务团队把配置文件里的API地址一切换就能从云端模型切换到本地部署的模型代码一行不动。对于需要从在线服务迁移到本地推理的场景这个兼容性少走了很多弯路。4. 吞吐量翻倍的参数调优一组实测有效的配置4.1 显存利用率与并发数怎么搭配服务跑起来只是第一步真正决定吞吐量的是显存与并发参数的匹配。--gpu-memory-utilization很多人习惯默认0.9但经验值是模型本身不算大时可以适当放到0.92给KV Cache更多空间显存很紧张时反而要下调到0.8给系统留余量否则长文本请求容易OOM。这个参数最好配合可观测性来调观察GPU利用率和显存峰值而不是凭感觉拍脑袋。--max-num-seqs直接决定vLLM内部最多同时处理多少序列。设太小并发能力受限设太大每个序列能分到的KV Cache块不足容易触发调度器做抢占式淘汰反而增加延迟。我常用的起步值是64中短上下文可以到128长上下文场景要降到32以下。我在某实验环境的一张A100 40G上用主流的8B模型实测过一组数据max-num-seqs从16提高到64输出吞吐从大约150 tokens/s涨到接近420 tokens/s几乎翻了三倍但继续调到128频繁出现抢占吞吐反而回落到360左右首token延迟也明显上升。参数不是越大越好得找到自己的拐点。4.2 关键参数速查表我常用的基准值参数默认值作用调整建议--gpu-memory-utilization0.9vLLM可占用的GPU显存上限显存充足可到0.92紧张时降到0.8--max-num-seqs256最大并发序列数中短上下文64~128长上下文32以下--max-model-len模型默认最大上下文长度按业务95%请求长度设置别盲目拉满--swap-space4CPU交换空间GB抢占频繁时可加大到8~16--tensor-parallel-size1单机多卡并行度按卡数设置注意卡间带宽--enable-prefix-caching关闭复用相同前缀的KV Cache业务有公共system prompt时打开这套基准值适合大多数单卡或双卡场景。它的逻辑是先把显存利用率提上去再调并发序列数最后用swap-space和前缀缓存做兜底和加速。如果一开始就同时动好几个参数出了性能问题反而不知道是哪个参数引起的所以建议一次只调一个变量。4.3 前缀缓存、量化模型与多卡并行的进阶取舍--enable-prefix-caching这个参数在业务场景下建议打开。启用后vLLM会自动检测相同前缀并复用KV Cache。在我们某次测试中如果业务请求普遍带一段长度超过千字的system prompt启用后相同前缀的请求预填充计算量能减少一半以上等于直接白送吞吐量。显存不够时可以考虑AWQ或GPTQ量化模型。vLLM对这两种量化格式都有原生支持加载方式如下vllm serve TheBloke/Llama-2-7B-AWQ \ --quantization awq \ --gpu-memory-utilization 0.9量化模型的显存占用能省将近一半但精度多少有损失生成质量与量化位宽直接相关。如果只是内部工具、对答案质量宽容度高量化很划算如果模型回答质量是核心指标我更建议用原模型配合--tensor-parallel-size做多卡并行。双卡环境启动命令vllm serve Qwen/Qwen2.5-14B-Instruct \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.9vLLM会通过张量并行把模型按层切分到多张卡上KV Cache也会分散存放。需要提醒的是多卡并行对卡间通信带宽要求高如果只是PCIe连接吞吐提升可能不如预期NVLink环境效果才明显。5. 并发压测实录从几个请求到几十个请求的完整对比5.1 一个足够简单的压测脚本性能数据不能靠猜。我习惯用最短的脚本验证并发能力基于asyncio和aiohttp指定并发数连续打请求import asyncio import aiohttp import time async def send_one(session, url, payload, i): async with session.post(url, jsonpayload) as resp: data await resp.json() return data[usage][completion_tokens], i async def main(): url http://localhost:8000/v1/chat/completions payload { model: Qwen/Qwen2.5-7B-Instruct, messages: [{role: user, content: 用三句话介绍大语言模型推理中的KV Cache是什么。}], max_tokens: 200 } concurrency 32 async with aiohttp.ClientSession() as session: start time.time() tasks [send_one(session, url, payload, i) for i in range(concurrency)] results await asyncio.gather(*tasks) elapsed time.time() - start total_tokens sum(r[0] for r in results) print(f并发数: {concurrency}) print(f总耗时: {elapsed:.2f}s) print(f总输出tokens: {total_tokens}) print(f吞吐: {total_tokens / elapsed:.2f} tokens/s) asyncio.run(main())这个脚本不追求严谨的负载模型但用来观察不同并发数下吞吐变化趋势非常直观。实际场景中还可以把并发数作为参数传入连续跑66组数据。如果要更专业的报告可以上k6、locust这类工具但做迭代调优时上面的简单脚本已经够画趋势图了。5.2 不同并发下的数据对比我拿某次在一张A100 40G上跑8B模型BF16、上下文8192、max-num-seqs64的实测数据做个参考。以下数字只代表那一次环境不同模型和GPU会有差异但趋势非常有代表性并发数平均TTFT秒总吞吐tokens/s单请求平均生成速度tokens/s10.35323280.4218022160.5534021320.7842016641.338010这里有一个很典型的信号并发从1到32总吞吐几乎线性增长这是连续批处理在发挥作用但从32到64TTFT明显恶化总吞吐甚至开始回落说明已经触到了显存和调度瓶颈。生产环境我会把并发目标定在曲线拐点附近而不是最大并发数。如果业务峰值为50个请求配置上宁可在64以下留余量也不要盲目调大。5.3 压测时要盯哪些指标压测时很容易只盯着总耗时我建议至少同时记录三样输出token总数、TTFT、每个请求实际生成速度。三者反映的问题完全不同。总吞吐tokens/s代表系统整体能力是评估“高吞吐”的第一指标TTFT代表用户的等待感知并发上来它必然上升但如果并发还没到预期目标TTFT就已经突破好几秒说明max-num-seqs已经逼近上限单请求生成速度被压得很低说明批内计算资源被摊得太薄用户体验会明显变差。这三者永远需要平衡没有一劳永逸的参数组合。6. 生产环境踩坑记录显存碎片、长上下文与多实例管理6.1 显存OOM与preemption的区别和处理我见过不少同学把“显存不够”和“调度器抢占”混为一谈处理方式完全不同。如果真是OOM崩溃日志里会有CUDA out of memory服务直接挂掉。这时优先降低--max-num-seqs、下调--gpu-memory-utilization或者缩减--max-model-len。注意显存占用的大头不只是模型权重还有KV Cache长上下文和并发数是一对天然的矛盾。如果是preemption频繁表现为请求变慢但不崩溃日志里能看到相关调度信息。这说明并发序列数设置过高挤压了KV Cache空间。优先调低max-num-seqs并按需把--swap-space从默认值加大给调度器更多CPU侧存储。vLLM的swap机制可以把部分请求的KV临时搬到内存避免因显存不足直接丢请求但代价是会拖慢响应不是万灵药。6.2 长上下文请求的隐形炸弹还有一个常见坑是--max-model-len设置得过于宽松。有人直接给到32768甚至更长结果并发能力大幅缩水。原因很简单这个值定多少KV Cache就要为最坏情况预留多少资源。如果实际业务根本用不到那么长这个参数就是给显存凭空加负担。我习惯的做法是看历史请求的平均上下文长度设置成能覆盖95%请求的长度而不是最大值。比如业务平均输入800 token偶发到3000那max-model-len设4096就足够再留一点余量。有人把模型默认的128K上下文照搬进vLLM并发直接掉到个位数这不是引擎不行是配置给它背了不该背的包袱。6.3 多实例和路由管理本地或内网环境经常遇到多个模型同时服务的问题。vLLM官方主推一个实例对应一个模型。要提供多个模型时建议分别起不同端口的实例前端加一层简单路由。我在项目里就是这样模型A跑在8001模型B跑在8002外层用Nginx按路径或模型名转发对上层业务完全透明。这套方案的关键好处是故障隔离。一个模型OOM或卡死不会拖垮另一个升级模型时可以逐个实例滚动替换不影响整体服务。它比在一个实例里反复切换模型稳定得多。实际生产里只要遇到“同时在线多模型”的需求我都推荐先上多实例加路由。6.4 版本升级与日志排障的务实建议最后聊几个容易被忽略的点。vLLM更新速度很快升级版本前一定要看更新说明。底层依赖会跟着变比如transformers、torch的版本兼容性升级完模型起不来是常有的事。我习惯固定主要版本只在有明确修复或新功能时才升级。生产环境的日志别只开INFO级别至少要把每个请求开始和结束的关键信息记录下来。我曾经排查一个偶发超时问题只有笼统的“慢”报告根本没有请求层面的时间戳和状态定位起来非常费劲。如果给每个请求记录排队时长、token消耗、结束原因后续分析会轻松很多。还有一个隐蔽问题模型格式与vLLM的兼容性。我有一次遇到加载后生成结果乱码查了很久才发现是模型config里某个字段vLLM不识别。这类问题不会直接报错只是行为异常遇到这种情况建议先怀疑模型导出格式换个标准的safetensors模型再对比一次。我个人最大的体会是vLLM给本地推理带来的不只是“快”而是把显存和调度这两个过去靠运气和反复试错解决的事情变成了有明确设计逻辑的工程问题。你花时间理解分页注意力、理解连续批处理再顺着调参、压测、排障这条线去做基本不会走太大弯路。最后分享一个我自己的习惯每次给新环境部署都把压测脚本、关键参数和业务负载特征记录到一个固定文档里下次调参和排查都有据可依。这不是什么高级技巧但确实帮我省掉了大量重复试错的时间。