vLLM并发与流式推理实践:从KV Cache到性能调优

发布时间:2026/10/21 4:49:30
vLLM并发与流式推理实践:从KV Cache到性能调优 1. 基模组实习里的vLLM到底在忙什么1.1 为什么基模组会盯上vLLM刚把“基模组实习”的方向确定下来任务就落到了“vllm 并发/流式推理相关”这一堆。起初我以为不过是装个框架、把大模型跑通就算完事结果跟团队里的同学聊完才发现基模组关注的从来不是“模型能不能推理”而是“大量请求涌进来时推理服务还能不能保持稳定吞吐和可接受延迟”。这个环节里的承重墙就是vLLM。大模型真正部署到生产环境时核心矛盾在于模型是自回归结构生成每个token都依赖之前的全部token天然是一条串行计算链。单条请求单独跑非常快但几十、上百个请求同时进来如果还按最朴素的“先排队、一个个跑”方式处理用户只会看到光标转圈。vLLM之所以被基模组选为主力推理框架不是因为它支持的模型多而是它在调度层做了三件关键的事把KV Cache存储改造成类似操作系统分页的机制PagedAttention把多个请求动态合并成batch一起计算Continuous Batching再尽可能压缩显存中的KV Cache占用。这三件事直接决定并发场景下的吞吐上限和延迟水平。所以实习第一周我始终带着一个问题去读代码“一个请求到达服务端后vLLM到底把它放进哪个队列什么时候开始算算到哪一步把结果吐给客户端”这个问题看起来简单真正读懂之后并发参数调优就不再是瞎试了。1.2 实习起步先跑通最小验证流程读代码之前先得有一个能跑的vLLM服务。我强烈建议所有刚接触vLLM的同学也从这一步开始用一台单卡机器、一个适中的模型先跑通非流式和流式两条请求链路再做并发压测。我当时在模拟项目X里用的是一台单卡机器显存不算宽裕所以选了一个已量化的模型。启动命令大概是这样python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --served-model-name demo-model \ --dtype auto \ --gpu-memory-utilization 0.85 \ --max-model-len 4096 \ --port 8000跑通后先用curl验证一次非流式请求curl -s http://127.0.0.1:8000/v1/chat/completions \ -H Content-Type: application/json \ -d { model: demo-model, messages: [{role: user, content: 用一句话解释什么是分页}], max_tokens: 128 }看到正常JSON返回之后再验证流式请求把请求体里的stream: true加上去观察终端是不是出现一串data:开头的行。这一步的价值在于建立“基准”。后面所有并发参数的调整、流式行为的改动都要拿这套最早的请求结果做对照。先有正确性再谈性能这是我在实习期间踩过几次坑之后才真正理解的顺序。2. 并发推理从“能跑”到“高吞吐”的调参路径2.1 并发背后最重要的三块机制并发推理不是简单地用多线程跑多个模型副本。vLLM的核心思路是用一个模型实例同时服务多个请求也就是动态批处理。先说PagedAttention。自回归推理过程中每个请求都需要保存历史token的Key和Value这部分缓存就是KV Cache。如果给每个请求预留一整块连续显存浪费极其严重。PagedAttention仿照操作系统内存分页的思路把KV Cache切成固定大小的块需要时再分配显存碎片也能利用起来。直观类比就是以前每家每户都给自己留一整层楼现在改成公共仓库按需租格子空间利用率高很多。再说Continuous Batching。传统批处理是凑够一批请求后一起算一批里有快有慢慢的没跑完快的也得跟着等。Continuous Batching允许在一个请求生成结束后立刻从等待队列里拉一个新请求补位让GPU始终处于“满负荷干活”的状态。所以并发上去以后服务层能抗多少请求很多时候取决于调度器能不能及时把新请求调度进计算迭代。最后是KV Cache的显存规划。vLLM启动时会根据gpu_memory_utilization预留一块显存专门放KV Cache剩下的给模型权重和计算使用。这块缓存越大能同时容纳的token数量越多但留给计算和激活层的显存太低又会拖累计算速度。所以后面调参本质上是在“多缓存”和“快计算”之间找平衡点。2.2 关键参数与选择逻辑实战中我反复调整的参数主要有下面几个参数作用经验取值--max-model-len限制输入输出的最大总长度直接影响KV Cache预分配不要盲目按模型原始最大长度设实际业务多长够用就设多长--max-num-seqs一次迭代中最多同时处理的序列数从32、64开始观察显存和延迟表现--max-num-batched-tokens单次迭代最多处理的token数默认值通常可用显存充裕时可适当调大--gpu-memory-utilizationvLLM允许使用的显存比例0.85-0.92之间比较常见要留运行余量--enable-prefix-caching开启前缀缓存相同系统提示词可复用KV Cache对话类场景强烈建议开启这几个参数之间互相牵制。max_model_len设得越大每个序列平均占用的KV Cache就越多相同显存下能并发处理的请求数就越少。实习时我有一次把max_model_len从4096提到8192相同显存下压测P99延迟直接翻倍原因就是KV Cache能覆盖的序列数变少等待队列变长。压回4096之后并发容量立刻恢复。启动命令我不建议每次都拼接一长串命令行参数太容易出错。可以写成YAML配置文件再用--config加载。下面是一份调稳后的参考配置model: /path/to/model served_model_name: demo-model dtype: auto gpu_memory_utilization: 0.88 max_model_len: 4096 max_num_seqs: 64 max_num_batched_tokens: 8192 enable_prefix_caching: true port: 8000这里max_num_seqs: 64不是随便拍的。我先用64做压测显存余量充足TPOT也稳定然后试着提到128首token延迟和单token生成时间都明显上升说明GPU算力已经进入饱和区。对业务流量来说64已经是吞吐和延迟的平衡点。2.3 压测与给服务“上强度”调参不能空对空得让服务真正“上强度”。vLLM仓库自带的benchmark_serving.py是一个不错的起点它支持用固定输入token数和输出token数模拟请求能输出TTFT、TPOT、吞吐量等统计。不过我更推荐自己写一个轻量压测脚本原因有两个一是自写脚本可以同时验证流式和非流式混合请求二是能模拟真实业务的不固定到达时间而不是固定频率打点。我用一个简单Python脚本配合ThreadPoolExecutor控制并发数每个线程循环发请求并记录耗时import time import json import requests from concurrent.futures import ThreadPoolExecutor def send_one(req_id, concurrency_tag): payload { model: demo-model, messages: [{role: user, content: 请介绍一下流式推理与传统批处理的核心区别}], max_tokens: 256, stream: False, } start time.time() resp requests.post( http://127.0.0.1:8000/v1/chat/completions, jsonpayload, timeout120, ) resp.raise_for_status() data resp.json() cost time.time() - start usage data.get(usage, {}) return { req_id: req_id, cost_s: round(cost, 3), output_tokens: usage.get(completion_tokens, 0), } with ThreadPoolExecutor(max_workers50) as pool: results list(pool.map(send_one, range(100), [50] * 100))压测时我通常分三档50并发、100并发、200并发。每一档跑两轮第一轮只看总吞吐第二轮同时统计每个请求的耗时。核心指标包括QPS每秒能处理的请求数TTFT从发起请求到收到第一个token的耗时流式请求尤其关注TPOT平均每个输出token的生成耗时总吞吐每秒生成的token总量印象最深的一次压测是把并发从50提到100之后总吞吐几乎没变但TPOT从60ms涨到了120ms。这说明服务已进入饱和区继续堆并发只是让所有请求一起变慢并不能提升有效吞吐。后来我通过观察/metrics里的等待队列长度才定位到是调度器来不及消费请求。3. 流式推理让用户看见字在“蹦”3.1 流式与非流式的本质差异聊流式之前要先分清两个概念流式接口和非流式接口的差别不是“返回快慢”而是“返回时机”。非流式时服务端必须等整个回复生成完毕一次性把完整JSON返回给客户端。对长文本生成任务用户可能要等十几秒才看到第一个字体验非常差。流式则是模型每生成一个token服务端就通过同一个HTTP连接推给客户端一个事件实现文字逐字蹦出的效果。这种体验差异在实时对话、代码生成、智能客服场景里感知尤其明显。我在模拟项目X里产品侧提得最硬性的要求就是接口必须支持流式且首token要在用户可感知的合理时间内出现。所以“流式”不是锦上添花是上线前提。流式返回的格式通常叫SSEServer-Sent Events本质是HTTP长连接上不断输出文本块。每个数据块以data:开头空行结束最后再单独发一个data: [DONE]表示结束。前后端都按照这个约定解析。3.2 用OpenAI兼容接口做流式请求vLLM默认启动的是OpenAI兼容接口对业务方来说省了很多适配工作。客户端只需要把stream参数设为true再按SSE格式逐行解析。一个最简单的Python流式客户端长这样import json import requests def stream_chat(prompt): payload { model: demo-model, messages: [{role: user, content: prompt}], stream: True, } resp requests.post( http://127.0.0.1:8000/v1/chat/completions, jsonpayload, streamTrue, timeout120, ) for line in resp.iter_lines(): if not line: continue if line.startswith(bdata: ): data_str line[len(bdata: ):] if data_str b[DONE]: break chunk json.loads(data_str) delta chunk[choices][0][delta] content delta.get(content) if content: print(content, end, flushTrue) stream_chat(用一段话说明什么是流式推理)这里有几个细节容易踩坑。第一必须设置streamTrue否则requests会一直缓冲整个响应体根本看不到逐块输出。第二解析时要注意每一行之间都可能有空行末尾的[DONE]也要单独处理。第三delta里的content可能为空尤其是流式过程中第一个chunk往往只含role字段而没有内容直接取delta.get(content)拿空字符串即可不要因为空值就报错。3.3 服务端要处理好的流式细节客户端解析是明线服务端实现才是大头。vLLM本身已经实现了OpenAI兼容接口的流式逻辑多数时候我们不需要从头写生成器。但如果业务需要自定义转发、鉴权、缓存或改造接口就绕不开服务端流式处理的原理。服务端的流式响应通常是一个生成器函数每生成一个token就yield一个格式化后的SSE块。vLLM内部的伪代码逻辑大致是def generate_streaming_response(prompt): result_generator engine.generate(prompt) yield data: json.dumps(create_first_chunk(prompt)) \n\n for output in result_generator: text output.outputs[0].text yield data: json.dumps(create_delta_chunk(text)) \n\n yield data: [DONE]\n\n这里最容易出问题的是响应格式的一致性。如果业务层对chunk做二次包装一定要保证每个chunk都包含id、object、created、choices这些字段否则客户端解析库会直接报错。我们还遇到过一种情况后端把多个token攒在一起批量返回虽然协议层次看还是流式但用户侧表现却是“半天不冒字然后突然冒半句话”首token指标被内部缓冲破坏了。排查后发现是网关层开了响应缓冲把SSE的流式输出缓存了。所以做流式服务记得在网关或反向代理层关闭响应缓冲。3.4 流式会话里的延迟指标流式接口除了整体耗时还需要额外看两个指标TTFT和TPOT。TTFT指从请求发出到收到第一个token的耗时。这个值主要受调度排队、PreFill阶段计算量和网络往返影响用户感知最明显最好控制在几百毫秒到一两秒内。TPOT是每个输出token的平均生成耗时决定整段回复的流畅程度。TPOT超过100ms时用户会明显感觉字是“一个字一个字挤出来的”。我压测流式接口时会额外统计每次流式响应中第一个chunk的到达时间和后续chunk之间的间隔。下面这个片段可以记录TTFTstart time.time() first_token_time None with requests.post(url, jsonpayload, streamTrue) as resp: for line in resp.iter_lines(): if line: if first_token_time is None: first_token_time time.time() - start # 继续记录每个chunk间隔统计ITL客户端侧统计也很重要因为客户端感知的TTFT是“全网链路”的包含DNS解析、TCP连接、网关转发时间。服务端指标再好网关加一层缓冲也会让实际TTFT劣化。实习后期我把压测机直接放在同一内网段排除网络抖动后再对比服务端和客户端指标定位问题才干净。4. 踩坑实录与排查套路4.1 显存OOM不是模型太大而是KV Cache规划问题实习期间最常遇到的是显存OOM。一开始我以为是模型权重太大后来看日志才发现不少OOM发生在KV Cache分配阶段。vLLM启动时会根据gpu_memory_utilization预留KV Cache但如果在某个请求里max_tokens设得很大或者max_model_len本身设得很大每个请求需要的KV Cache上限就高。并发一多缓存配额被打满调度器就会在日志里报显存不足。遇到这类OOM我先用nvidia-smi看显存分布再查vLLM日志里记录的KV Cache总容量和使用量。如果缓存使用量接近上限常见处理是调低max_model_len、降低max_num_seqs适当调高gpu_memory_utilization给缓存更多空间。但这里有度gpu_memory_utilization调到接近0.95时显存余量太小稍有波动就容易崩。后来我稳定用0.88既给KV Cache留了充足空间也保留了计算余量。4.2 明明streamtrue客户端却一个字节都不出流式排查里最磨人的问题是服务端日志显示token都在生成但客户端迟迟收不到数据。我把各个环节翻了一遍最大嫌疑在反向代理层。很多团队会在vLLM前面套一层反向代理做路由或鉴权但代理默认会对响应做缓冲攒够一定大小才转发给客户端。SSE响应本身不大一个token才几十字节很容易被缓冲策略卡住。解决方法是关掉代理对后端响应的缓冲比如在Nginx配置里把proxy_buffering设为off并确保对SSE路径生效。第二个容易被忽视的点是超时配置。SSE是一个长连接如果代理或负载均衡层设置了较短的读超时流式输出一次阻塞超时就会被断连。要适当调大proxy_read_timeout。4.3 并发上升后P99延迟陡增并发压测时我遇到过吞吐还在涨但P99延迟已经惨不忍睹的情况。最后定位下来问题出在max_num_batched_tokens和max_num_seqs配置不当导致单次迭代里塞入过多请求每个请求分不到足够的计算资源整体排队时间被拉长。排查时我先看vLLM暴露的/metrics重点盯两个指标等待队列长度和每次调度迭代的平均耗时。如果等待队列一直居高不下说明调度器消费请求的速度跟不上进入速度核心原因是并发数已经超过服务承载量。这时候不能继续调参而是先降低压测并发找到吞吐拐点。等基础参数稳定后再考虑多卡Tensor Parallel扩容。4.4 线上问题排查速查表实习结束后我整理了一张速查表排查并发/流式问题基本按这个顺序走问题现象可能原因先去查什么处理方向显存OOMKV Cache配额不足或模型长度设置过大nvidia-smi、vLLM日志调低max_model_len重设gpu_memory_utilization降低max_num_seqs流式不出字反代缓冲、长连接超时代理配置、抓包观察数据包关闭proxy_buffering调大read_timeoutP99延迟陡增并发超过服务承载、队列堆积/metrics中的队列长度、TPOT降低并发优化max_num_batched_tokens考虑多卡首token特别慢模型长度大导致PreFill计算慢、排队久TTFT分位值、吞吐曲线压缩max_model_len启用prefix caching客户端超时断开代理超时、心跳缺失代理日志、客户端网络日志调大超时应用层加心跳排查时我习惯先看“链路中离用户最近的那一层”很多问题表面上是vLLM行为异常实际是外层代理、客户端库或网络环境搞出来的。先排除外围再深入模型服务本身效率最高。5. 实习沉淀下来的可复用清单5.1 新服务上线的自检顺序实习这段时间我建立了一套上线前自检顺序可以复用到新模型、新业务接入时的场景第一步单请求回归。先验证非流式和流式两条链路都能正常返回确认模型对话基本正确。第二步低频并发压测。用50并发跑200个请求记录吞吐和TPOT基线。第三步拉高并发压测。分别测100、200并发找到吞吐拐点和服务饱和点。第四步混合请求压测。按业务比例混入流式与非流式请求因为流式请求占用连接时间更长对并发资源消耗模型不同。第五步长时间稳定性巡检。跑30分钟以上的持续请求观察显存曲线和时延曲线是否有缓慢恶化。五步都过完我才敢说这个服务可以交给线上流量。5.2 一套小规模场景的配置参考实习里做的最多的是单机场景。下面这套配置在模拟项目X里验证过适用于单卡、中等规模模型、短到中等对话场景。模型更大或并发要求更高时需要按实际调整但可以作为参考起点model: /path/to/model served_model_name: demo-model dtype: auto gpu_memory_utilization: 0.88 max_model_len: 4096 max_num_seqs: 64 max_num_batched_tokens: 8192 enable_prefix_caching: true disable_log_requests: false port: 8000disable_log_requests: false是为了上线早期能看到每个请求的内容方便定位问题流量稳定后可以改为true避免日志过大。enable_prefix_caching在对话场景里收益非常明显多轮对话每次都带完整历史前缀缓存能复用之前算过的KV Cache实测首token延迟能降低不少。5.3 后续可以扩展的优化方向实习结束不代表这套东西就学完了。如果要继续深入我会沿这几个方向扩展一是多卡场景下的Tensor Parallel配置。模型本身撑不住并发时增加并行度比单纯调参更有效。二是量化推理比如INT8、INT4量化在精度损失可控的前提下能显著提升吞吐。三是Chunked Prefill把长Prompt的预填充阶段切碎避免它堵住后续迭代对首token和整体吞吐都有帮助。四是把vLLM的/metrics接到监控系统里做成实时大盘并发/流式问题在大盘上几乎一眼可见。以个人的实际体会来说这次实习最大的收获不是背熟几个启动参数而是学会如何系统性拆解一个推理服务的性能问题先看链路再看调度再看显存最后才落到参数调整。vLLM把很多底层细节封装得很深但恰恰是并发和流式这两条线逼着人把这些细节一层层揭开。如果你现在也卡在并发上不去、流式不出字这类问题上希望这份记录能帮你把排查顺序理清楚。真正动手跑一遍压测会比看十遍文档都更有体感。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询