news 2026/10/21 14:05:04

vLLM高并发与流式推理实战:调优路径与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
vLLM高并发与流式推理实战:调优路径与避坑指南

在基模组实习那段时间,我接到的第一个正经任务,就是把一个基于 vLLM 部署的对话推理服务从"能出结果"调成"高并发稳定输出",同时把流式推理的接入做完。vLLM 这个名字,圈子里早就听腻了,但真正上手才发现,它核心解决的无非两件事:一是在有限的 GPU 显存里塞下更多并发请求,二是让 Token 一个接一个地吐出来而不断顿。这两件事听起来都不难,可真跑起来,并发和流式就像两条共享同一份 KV Cache 却各有脾气的战线。如果你正在做推理服务的并发优化、流式接口接入,或者手头正好有一张 GPU 想跑 vLLM,这篇内容应该能帮你少走不少弯路。

我在实习期间走过很多弯路,也把社区里零散的知识点拼成了一套能落地的调优路径。这篇文章不打算讲文档里已经写明白的 API 手册,而是把"为什么并发上不去""为什么流式会卡顿""为什么调个参数反而更慢"这些实际遇到的问题,连同排查思路和数据一起交代清楚。

1. 实习第一周:先让服务跑起来,再谈并发

1.1 我最初对并发和流式的理解全是错的

没进组之前,我以为"并发推理"就是写多线程疯狂调 API,"流式推理"就是网络层把一个大 response 切成碎片往前传。这个理解不能说全错,但基本不在点子上。

vLLM 的并发,指的是在一次推理迭代里,GPU 同时处理多少条请求序列(sequence)。这些请求在 decode 阶段共享同一个 forward 过程,也就是说,同一时刻参与计算的 Token 越多,GPU 越不容易闲着。而流式推理,指的是模型每生成一个 Token,服务端就立刻把增量结果返回给客户端,不等整段内容生成完。

这两个概念之所以容易混淆,是因为它们在业务上总是一起出现:客户端接入流式后,正常情况下用户几乎立刻看到第一个字,服务端却还在持续生成。此时若服务端并发没拉起来,全链路就会表现为"收到一个 Token 要等很久"。所以流式考验的是全链路的消费节奏,并发考验的是服务端的调度与显存管理,两者根本不是同一个层面的事情。

1.2 一个能用的 vLLM API 服务是怎么起来的

先把最基础的部署命令贴出来,这是我们当时内部环境中实际使用的方式。假设你要部署一个 34B 的代码模型,机器上有两张 80G 显卡:

python -m vllm.entrypoints.openai.api_server \ --model /data/models/code-34b \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --max-num-seqs 64 \ --gpu-memory-utilization 0.90 \ --port 8000

这里每个参数都有讲究。--tensor-parallel-size 2是因为 34B 模型按 BF16 精度加载,光权重就要约 68GB,单张卡放不下,需要用两张卡做张量并行。--max-model-len 8192表示单条序列的最大长度限制,输入加输出一共不能超过 8192 个 Token。--max-num-seqs 64是单次迭代内最多同时处理的序列数,这个值直接决定并发上限。--gpu-memory-utilization 0.90表示允许 vLLM 占用单卡 90% 的显存来做 KV Cache 和运行时缓存,留出 10% 给模型权重加载和临时中间张量。

启动之后,vLLM 会暴露一个 OpenAI 兼容的/v1/chat/completions接口。也就是说,任何会调用 OpenAI 接口的客户端,只要改一下base_url就能直接切换过来。

1.3 拆解三个和并发直接相关的启动参数

新手最容易做的事情,是把--max-num-seqs拼命调大,以为并发越高越好。我一开始也这么干过,结果服务直接开始疯狂抢占,整体吞吐不升反降。下面这三个参数是联动关系,不能单独看待:

参数作用调大的直接后果实际经验取值
--max-num-seqs单次迭代最多同时处理的序列数并发吞吐提高,但 KV Cache 占用变大,抢占概率上升8~64,按显存余量定
--max-model-len单条序列的最大 Token 数能处理长上下文,但 KV Cache 单位长度被稀释,并发能力下降按业务 P95 长度定,不贪大
--gpu-memory-utilization允许 vLLM 占用的显存比例KV Cache 余量变大,并发承载更强,但太高容易 OOM0.85~0.92

这三个参数的本质,是在"单请求长度"和"同时处理的请求数量"之间做取舍。因为它们共享同一个显存池:max_model_len决定了单条序列要预留的最大长度,max_num_seqs决定了并发序列数,gpu_memory_utilization决定了这个池子有多大。理解了这个关系,后面所有调优都有了解题框架。

2. vLLM 的高并发底牌:Continuous Batching 与 PagedAttention

2.1 为什么静态 Batching 会浪费大量显存

vLLM 之所以能成为这个领域的主流方案,核心是它重写了两个东西:调度方式和显存管理方式。要理解它的厉害,得先看传统静态 Batching 有多浪费。

如果把推理服务比作一辆公交车,静态 Batching 的做法是:先凑齐一车人,关门发车,等所有人到站才开门。问题是,有些请求生成 20 个 Token 就结束了,有些请求要生成 2000 个 Token。公交车必须等最慢的那个人下车,车里明明还有空座,站台上的人也只能干等。更麻烦的是,早期框架会按请求的最大长度一次性分配显存,一个只会生成 20 个 Token 的请求,也得预留下 2000 个 Token 的空间。

这就导致两个严重后果:一是显存利用率极低,模型权重之外的大片显存被空置;二是 GPU 在等待整个 batch 结束时,算力出现大量空窗。请求越多、长度越参差,浪费越明显。

2.2 Continuous Batching 的调度粒度小到"每步"

vLLM 的做法是把"等一批人全下车"改成"每到一个站点就处理一次上下车"。模型每执行一次 decode 步骤,vLLM 就把已经生成完的序列摘出去,同时从等待队列里补进新的请求。这样一来,每次 forward 的 batch 都是动态调整的,GPU 几乎一直处于满载状态。

这种机制叫 Continuous Batching,也叫连续批处理。它和静态 Batching 的差异不在于"是否动态插队",而在于检查完成的频率:静态 Batching 是在整个 batch 结束后才释放资源,Continuous Batching 是在每一个 Token 生成步骤之后都检查一遍。因为 decode 阶段每生成一个 Token 都是一次完整的 forward,这个频率就非常高了。

在并发请求多、长度差异大的场景下,Continuous Batching 能把吞吐拉高好几倍。这也是为什么同样的硬件,换成 vLLM 之后,单机 QPS 能产生质的飞跃。

2.3 PagedAttention:KV Cache 的页式管理

光有调度还不够,显存管理才是推理服务能支持多少并发的决定性因素。Transformer 推理时,每个已生成的 Token 都要缓存对应的 Key 和 Value 向量,这些缓存就是 KV Cache。一个 34B 模型,在 8192 Token 的长上下文场景下,KV Cache 的显存占用可能高达几十 GB,比模型权重还大。

传统做法是给每个请求预分配一个连续的大块显存缓冲区,不管它实际用到多少。如果预料之外出现更长的序列,就得重新分配、拷贝,极度浪费带宽。vLLM 借鉴了操作系统虚拟内存的思路,提出了 PagedAttention:把 KV Cache 切成固定大小的物理块,每个块可以存一定数量的 Token,逻辑上属于同一个序列,物理上可以分布在显存任意位置。

这么一改,短请求只占用几个块,长请求按需分配,显存碎片化问题也减轻很多。这也是为什么 vLLM 能在同样一张卡上支撑更高并发的最根本原因——它把显存从"按最大长度整租"变成了"按实际用量按页租"。

2.4 显存预算能装下多少并发:一个能实际用的估算方法

很多人在部署时不理解为什么并发总是上不去,其实只要做一次简单的显存预算,心里就有底了。KV Cache 每个 Token 的显存占用可以用下面的方式估算:

每 Token KV Cache 字节数 ≈ 层数 × KV 头数 × Head 维度 × 4

这个 4 来自 Key 和 Value 两组向量,加上 BF16 精度每个元素占 2 字节。以一个假设的 34B 模型为例,假设它有 40 层、8 个 KV 头、Head 维度为 128,那么每个 Token 的 KV Cache 约为:

40 × 8 × 128 × 4 = 163840 字节,约 160KB。

如果你给 KV Cache 分配了 20GB 显存,那大约能缓存 20GB / 160KB ≈ 131072 个 Token。如果业务平均序列长度是 4K Token,那么理论上同时只能支撑 131072 / 4096 ≈ 32 个序列。这就是为什么--max-num-seqs不是越大越好——它的物理上限由显存池子决定。

这个估算方法是推荐所有人动手做一遍的,它能帮你快速判断一张卡到底能扛多少并发,而不是靠猜。

3. 流式推理:从"等一整段"到"首字秒回"

3.1 非流式与流式:只差一个参数,结果完全不同

先看最常见的非流式调用,它会把整段回复全部生成完再一次性返回:

from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="EMPTY") resp = client.chat.completions.create( model="/data/models/code-34b", messages=[{"role": "user", "content": "用Python写一个快速排序"}], stream=False, ) print(resp.choices[0].message.content)

在长文本场景下,非流式调用最直接的体验问题就是"等待时间不可感知"。用户发出去一句话,界面转圈几秒甚至十几秒,突然一整段答案砸出来。流式调用只改一个参数:

resp = client.chat.completions.create( model="/data/models/code-34b", messages=[{"role": "user", "content": "用Python写一个快速排序"}], stream=True, stream_options={"include_usage": True}, ) for chunk in resp: delta = chunk.choices[0].delta if delta and delta.content: print(delta.content, end="", flush=True)

严格来说,stream=True并没有让服务端生成得更快,它只是让结果“随产随发”。但对用户而言,第一个 Token 能在几百毫秒内出现,整体体验就从"转圈等待"变成了"看着内容逐字出现"。

3.2 stream=True 之后服务端到底在做什么

开启流式之后,vLLM 后端不是把完整回复攒在内存里,而是通过 SSE(Server-Sent Events)协议,把每个增量 Token 作为一条独立的消息推给客户端。每次推送的数据格式大致长这样:

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"快"},"finish_reason":null}]}

客户端收到后,从delta.content里取出增量文本,逐个拼接。当生成结束时,会有一个finish_reason非空的 chunk 作为结束标记。注意,默认情况下 usage(Token 统计)不会出现在流式响应里,需要在请求里带上stream_options={"include_usage": True},或者老版本 vLLM 启动时加--enable-stream-usage。

理解这个机制很重要,因为"流式"本质上是一种通信协议的改变,而不是模型行为的改变。这也解释了为什么有些消费者拿到流式响应后,反而觉得"变慢了"——那通常不是生成变慢,而是消费端处理增量消息的逻辑出了问题。

3.3 流式场景下真正该盯的三个指标

做流式接入后,看监控不能只看总耗时,要把延迟拆开看。我最常盯的是这三个:

  • TTFT(Time To First Token):从请求发出到收到第一个 Token 的时间。这个指标直接决定用户的"首响体验",一般希望控制在 500ms 以内。
  • TPOT(Time Per Output Token):每个输出 Token 的平均生成时间。这决定了总耗时的下限。
  • ITL(Inter-Token Latency):相邻两个 Token 之间的实际间隔。流式场景下这个值如果有尖刺,用户体验就是"打字机卡顿"。

vLLM 的/metrics端点直接暴露了这些指标,部署之后用一条命令就能看到:

curl -s http://127.0.0.1:8000/metrics | grep "vllm:time_to_first_token"

这些指标在压测里的价值,比总耗时大得多:它们能帮你判断卡顿到底是发生在排队阶段、prefill 阶段,还是 decode 阶段。

3.4 流式消费端最容易踩的"前吞后吐"

我在实习期间遇到的最诡异问题,是服务端日志明明显示生成速度很快,客户端界面却呈现"刷几行字,停很长一段时间,再刷几行字"的现象。我把这种症状叫"前吞后吐"——前面生成的内容被憋在某个环节里,后面又一次性吐出来。

排查到最后,原因基本都落在消费端:

  1. 用同步循环逐条处理 SSE 消息,中间还夹杂着数据库写入或日志落盘,消费速度跟不上生成速度,网络缓冲区越积越大。
  2. 中间加了反向代理,而且默认开启了缓冲。Nginx 等组件默认会攒够一定数据再转发,对 SSE 这种长连接极不友好,需要在配置里关掉proxy_buffering off。
  3. 客户端没有用异步 IO,而是把生成结果先存内存再批量渲染。

解决方式也很直接:消费端必须采用异步方式处理增量消息,每条消息立即追加;代理层关闭缓冲;客户端对长时间没有任何消息的"静默段"设置独立超时告警,而不是依赖总超时。把这三件事做完,流式体验基本就稳了。

4. 压测与调优:把 QPS 从个位数拉起来

4.1 压测工具怎么选

没有压测数据,所有调优都是盲人摸象。市面上的压测工具不少,但用于 vLLM 这种长耗时、流式友好的接口,选择标准不太一样:

工具优点缺点适用场景
hey简单,一条命令跑完拿不到 TTFT/TPOT 分布,统计粗糙快速确认服务是否正常
wrk性能高,可写 Lua 脚本对 SSE 流式响应支持不友好短连接高并发测试
locust自带 Web 界面,监控全面配置较重,开销略大长期稳定性压测
自写aiohttp脚本完全可控,能记 TTFT/TPOT/错误码需要自己写代码调优期最推荐

调优阶段我最推荐自写脚本,因为它能记录每个请求的完整生命周期。这也是我整个实习期间主要使用的工具。

4.2 一个可复现的并发压测脚本

下面这个脚本用asyncio控制并发度,记录每个请求的耗时分布,适合在 vLLM 调优时反复跑:

import asyncio import time import aiohttp URL = "http://127.0.0.1:8000/v1/chat/completions" PAYLOAD = { "model": "/data/models/code-34b", "messages": [{"role": "user", "content": "用Python写一个快速排序,并解释时间复杂度"}], "max_tokens": 256, "temperature": 0.2, "stream": False, } async def one_call(session, sem, idx): metrics = {"idx": idx} async with sem: t0 = time.perf_counter() async with session.post(URL, json=PAYLOAD, timeout=aiohttp.ClientTimeout(total=300)) as resp: data = await resp.json() metrics["latency"] = time.perf_counter() - t0 return metrics async def main(concurrency, total): sem = asyncio.Semaphore(concurrency) timeout = aiohttp.ClientTimeout(total=300) async with aiohttp.ClientSession(timeout=timeout) as session: tasks = [asyncio.create_task(one_call(session, sem, i)) for i in range(total)] start = time.perf_counter() results = await asyncio.gather(*tasks) elapsed = time.perf_counter() - start latencies = sorted(r["latency"] for r in results) n = len(latencies) print(f"并发数: {concurrency}, 请求数: {total}, 总耗时: {elapsed:.2f}s") print(f"QPS: {total / elapsed:.2f}") print(f"P50: {latencies[int(n * 0.5)] * 1000:.0f}ms") print(f"P95: {latencies[int(n * 0.95)] * 1000:.0f}ms") print(f"P99: {latencies[int(n * 0.99)] * 1000:.0f}ms") if __name__ == "__main__": asyncio.run(main(concurrency=32, total=200))

注意两个细节。第一,aiohttp.ClientTimeout(total=300)必须给足,不然生成长文本时客户端会提前超时报错,把压测结果污染掉。第二,QPS 的计算用的是墙钟时间,也就是所有请求完成的总时长,不是请求耗时的累加。

如果要把流式也纳入压测,只需要在发送请求时设置"stream": True,然后逐条读取响应内容,记录"第一个 chunk 到达时间"和"最后一个 chunk 到达时间"。前者就是 TTFT,后者减去前者就是总的流式传输时间。

4.3 一次压测的完整数据:瓶颈在哪

拿我们当时那个 34B 模型举例,机器是双卡,max-model-len固定 8192,gpu-memory-utilization固定 0.9。我依次调整max-num-seqs,记录下来的数据大概是这样的:

max-num-seqs并发数QPSP50 耗时现象
81634480ms稳定,但吞吐明显偏低
3232142720ms吞吐最高,P50 还在可接受范围
64321172100msP50 恶化,抢占频繁

这个结果很反直觉:max-num-seqs从 32 调到 64,QPS 反而掉了,延迟还翻了两倍。原因就是显存预算不够:max-num-seqs=64意味着最多 64 条序列同时占用 KV Cache,按前面 160KB/Token 的估算,总 Token 预算很快被耗尽,vLLM 只能不停抢占老序列、重算被丢弃的部分,整体进入"越抢占越忙,越忙越抢占"的恶性循环。

所以在调优时,不要单看max-num-seqs绝对值,要看两个辅助指标:等待队列长度和显存中 KV Cache 的占用比例。

4.4 排查链路:先看排队、再看显存、最后看算力

当并发上不去或者延迟飙高时,我建议按固定的顺序排查,否则很容易被表象带偏。

第一步,看排队。vLLM 的 metrics 里有vllm:num_requests_running和vllm:num_requests_waiting两个指标。如果 waiting 一直不为零,说明请求到了服务端但没能立刻进入推理,瓶颈在调度或显存。如果 waiting 为零但延迟依然高,问题在推理自身。

第二步,看显存。用命令查看 GPU 显存占用,再对比 vLLM 启动时的 KV Cache 预留量。如果显存已经打满,而 waiting 又很高,说明max-num-seqs或max-model-len设得太激进,需要调低。

第三步,看算力。如果 GPU 利用率很低,但 waiting 也低,说明 batch 没有填满,请求数本身不够,或者 prefill 和 decode 没有交错好。这时候可以考虑开 chunked prefill,让长输入 prefill 任务拆分,不要阻塞后续短请求的首 Token 输出。

三步走完,瓶颈基本能定位到层。

5. 实习期间踩过的坑:流式卡顿、抢占式调度与 OOM

5.1 服务端"卡住"其实是 TTFT 拉满

有一次压测,客户端表现是"请求发出去,半天没有任何返回"。我第一反应是服务挂了,结果日志显示请求在正常处理,只是迟迟没有产出第一个 Token。

这个问题本质上不是卡死,而是 TTFT 被拉满了。TTFT 由两部分组成:排队时间加上 prefill 时间。当max-num-seqs已满,后来的请求只能在等待队列里耗着;而队列里若有几个超长输入的请求正在做 prefill,后面的短请求也得一起等。

这就引出一个经验:在混合负载场景下,长输入请求对整体延迟的影响会被放大。如果是长短请求混合的业务,建议关注 vLLM 的 chunked prefill 特性,让长输入的 prefill 被切成多个小 chunk,和短请求交错执行,避免单个长请求把整个流水线堵住。

5.2 并发数上去了,显存频繁触发抢占

抢占(Preemption)是 vLLM 里很容易被忽视的隐形杀手。当 KV Cache 不够用,又有新请求必须进入时,调度器会踢掉一些已有序列,把它们的缓存释放出来给新请求用。默认情况下,被踢掉的序列是"重算"模式——之前的 KV Cache 全部丢弃,等轮到它时重新生成。

这个代价非常大。想象一下,一个已经生成 200 个 Token 的请求,被迫从 0 重新生成一遍,期间又占用了新的缓存,很可能再次触发抢占。这在压测曲线上表现为:QPS 上不去,延迟直线上升,GPU 利用率却很高——因为所有算力都被重复的前向计算吃掉了。

我当时的解决方式,是把max-num-seqs从 64 降回 32,同时检查 metrics 里的抢占计数。如果抢占持续出现,宁可降低并发上限,也不要硬扛。

5.3 流式超时:网关断连的边界条件

流式接口接入生产环境后,还有一个很隐蔽的坑:长流式生成的连接空闲问题。当模型在思考下一个 Token 时,连接上可能长达几十秒没有任何数据包。这时候中间层的负载均衡或网关设备,很容易因为"连接空闲超过阈值"而把连接断开。客户端看到的症状是"生成了半截,突然断了"。

解决方案分两层。服务端方面,SSE 连接可以定期发送心跳注释行,例如: keep-alive\n\n,让链路层认为连接仍活跃。客户端方面,必须实现重连逻辑,并且在重连时要能处理"已经收到一部分内容"的中间状态,而不是粗暴地重新发起整段请求。

5.4 从日志和 metrics 里找证据

整个实习过程中,我养成的最重要习惯,是遇到问题先抓指标,而不是先改参数。vLLM 的/metrics端点提供的信息非常全,几乎每个性能问题都能从这里找到线索:

curl -s http://127.0.0.1:8000/metrics | grep "vllm:"

重点看这几类:请求运行数和等待数、TTFT 直方图、KV Cache 使用率、抢占次数。把这些指标和业务侧的请求日志放在一起,大多数问题的根因都能快速浮出水面。调优最怕的就是没有数据依托地乱改参数,改完也不知道是变好了还是变坏了,有了指标之后,每次调整都能形成闭环。

我自己后来做每次压测,都会先记录一组基线数据,然后只改一个参数,再跑一轮,对照指标变化。这个习惯让调优过程变得异常高效。

在基模组实习结束之后,我最大的体会是:vLLM 不是开箱即用的"性能保证",它只是把显存和调度这两块硬骨头帮你啃了一部分,剩下的部署层和业务层取舍仍然需要自己动手。并发和流式也从来不是两个独立问题——流式让用户感知延迟,并发决定服务能在多大压力下维持低延迟。刚上手的朋友,建议先按这篇文章的思路跑一轮压测,把 TTFT、TPOT、抢占次数三个数记下来,再动手改配置,基本上走完这一遍,你对推理服务的理解会扎实很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 14:04:56

SpringBoot+LangChain4j+Ollama:本地大模型智能客服落地实践

之前接了一个客服后台的改造需求,团队里最早的方案是直接调云端对话模型 API。结果一评审就卡住了:客服对话里全是用户账号、订单、售后记录,这种东西要出内网,产品同学第一个不同意。后来又试了几套方案,最后落在本地…

作者头像 李华
网站建设 2026/10/21 14:03:38

REA业务事件建模:用资源、事件、参与者解决对账难题

看到 rea 这个标题,估计不少人和我一样,第一反应是“少写了个 c 的 react”,或者“又是个 reactive 的缩写”。但在我最近折腾业务模型时,rea 代表的是 Resource-Event-Agent,也就是资源、事件、参与者三元组。这个不起…

作者头像 李华
网站建设 2026/10/21 14:03:33

VC++ MFC期末大作业完全指南:从选题到答辩一次讲清

前几天有个A同学找我说,MFC期末大作业憋了半个月,打开Visual Studio还是只会拖一个按钮上去。我问他课程要求是什么,他说“自拟题目,用VC和MFC写一个Windows桌面程序,要有数据存储和基本操作”。这几乎是我见过最常见的…

作者头像 李华
网站建设 2026/10/21 14:03:18

知识工作流插件化:从采集到输出的全链路方案

做知识工作的人,大多有过这种经历:看到一篇值得收藏的文章,顺手点了收藏,然后就再也没有打开过。年底做总结、准备分享、写复盘时,想找当初那条关键数据,翻遍浏览器书签、聊天记录、网盘和备忘录&#xff0…

作者头像 李华
网站建设 2026/10/21 14:03:06

StarRocks实时同步的系统级设计:模型选型、Flink加固与语义对齐

1. 为什么“实时同步到StarRocks”不是个简单任务,而是一道系统级考题 “如何实时同步数据到StarRocks”——这行标题在数据工程师的日常搜索记录里出现频率极高,但真正能把它讲透、讲准、讲出实操分寸感的分享却少之又少。我见过太多团队把这句话当成一…

作者头像 李华
网站建设 2026/10/21 14:02:59

Windows 小白也能上手的 OpenClaw 本地 AI 代理部署指南

最近不管你是混技术群还是在信息流里刷帖子,一定都能感觉到一个词的出现频率越来越高:AI 代理。一堆人晒自己的 Agent 在自动整理文件、写周报、抓某个页面的数据,一个人干出了半个助理的活。而被提到最多的名字之一,就是 OpenCla…

作者头像 李华