vLLM 部署 Qwen2.5 的性能调优实录:8GB 显存下,我是怎么和 KV Cache 打架的

· 约 8 分钟 更新于 2026-09-20 ·
本文目录(41 节)

第一次使用 vLLM 部署 Qwen2.5 时,我的想法很简单。

既然模型能加载:

那不就结束了吗?

后来发现:

模型能加载,只能证明故事刚刚开始。

真正做推理服务以后,你会开始接触一堆以前训练模型时不太关注的东西:

TTFT
TPOT
KV Cache
PagedAttention
Continuous Batching
Chunked Prefill
max_model_len
max_num_seqs
gpu_memory_utilization
QPS
P95

尤其当你的显卡不是 80GB A100,而是:

RTX 4070 Laptop
8GB VRAM

的时候,这些概念就不再是论文术语。

它们会直接变成:

CUDA Out of Memory。

这篇文章记录一下我部署 Qwen2.5 系列模型时真正经历过的调优思路。


一、我的两个主要部署环境

我主要经历过两类环境。

本地开发环境

Windows 11
WSL2
RTX 4070 Laptop
8GB VRAM

这一套环境的目标不是高并发。

更多是:

  • 本地开发;
  • RAG 联调;
  • API 测试;
  • Prompt 验证;
  • 小模型部署。

服务器实验环境

另外也使用过:

V100 × 2
Tensor Parallel = 2

来运行更大的模型。

两种环境最大的区别在于:

本地首先考虑:

能不能稳定跑。

服务器环境开始考虑:

并发和吞吐。

所以 vLLM 调优没有一个“万能最佳参数”。

必须先明确:

你是在做 Chat Demo,还是做真正的 Serving。


一次请求的延迟拆分

图解:这是一张测量边界图,不是性能曲线。客户端与服务端计时边界不同,比较前必须统一。

记录请求的到达、首 token 和完成时间,同时保存实际生成 token 数。不要把字符数当 token 数。TPOT 的定义和单 token 输出的处理方式要写清。失败、超时和取消请求也进入报告,避免只统计成功且较短的请求造成虚假的加速。

动手核对: 固定输入长度分布和最大输出限制,比较两组并发。报告成功率、TTFT、端到端分位数和实际输出长度;若质量或输出长度改变,先解释差异,再谈性能。

二、最初我为什么换到 vLLM

普通 Transformers 推理当然可以:

model.generate(...)

但当后端开始接入:

FastAPI
+
多个请求
+
SSE Streaming

以后,问题出现了。

多个用户请求不是整齐排队来的。

可能:

Request A: 200 tokens prompt
Request B: 2000 tokens prompt
Request C: 500 tokens prompt

传统 Batch 很难充分利用 GPU。

vLLM 最吸引我的地方,就是:

PagedAttention
+
Continuous Batching

简单理解:

请求不需要等到凑齐一个固定 Batch 才一起跑。

引擎可以持续:

进请求
出 Token
插新请求
继续调度

这对于在线 LLM 服务非常合适。


三、第一版启动:能跑就行

以 MindTrip 后期使用的模型为例:

ZhiluAI-3B-SFT-merged

它是基于:

Qwen2.5-3B-Instruct

完成 LoRA 微调并合并后的模型。

一个比较基础的启动方式类似:

vllm serve ./ZhiluAI-3B-SFT-merged \
  --dtype float16 \
  --max-model-len 2048 \
  --port 8080

然后就可以通过 OpenAI Compatible API:

/v1/chat/completions

调用。

FastAPI 后端甚至可以直接按照 OpenAI SDK 的方式接入。

这个体验确实很好。

但是很快第一个问题出现。


四、第一个误区:max_model_len 当然越大越好?

最开始看到模型支持较长上下文,我天然会想:

2048
↓
4096
↓
8192
↓
32000

能不能直接开最大?

理论上当然很好。

RAG 可以塞更多资料。

聊天可以保存更多历史。

但很快你会发现:

上下文窗口不是配置文件里免费的一个数字。

它直接影响:

KV Cache

五、KV Cache 到底吃了什么?

Transformer 每生成一个 Token,都需要关注之前的 Token。

如果每生成一个 Token 都重新计算历史:

成本会非常高。

所以推理服务会缓存历史 Token 的:

Key
Value

也就是:

KV Cache

于是上下文越长:

KV Cache 越大。

并发越高:

KV Cache 继续变大。

这也是为什么你可能发现:

明明一个 3B 模型权重可以放进 8GB 显存,服务却还是 OOM。

因为 GPU 上不只有:

Model Weights

还有:

KV Cache
Activations
CUDA Context
Temporary Buffers

六、所以我的第一个重要优化:不要盲目追求长 Context

对于 MindTrip,我真正需要的场景其实是:

用户问题
+
少量历史
+
TopK 检索结果
+
模型输出

不是拿一本几十万字小说进去。

所以后来的思路是:

按真实请求 Token 分布决定 max_model_len。

本地开发阶段经常使用:

2048

或者针对具体任务再调整到:

4096

而不是:

模型支持多少,我就设置多少。

这一个改变通常比很多“小参数调优”更加重要。


七、gpu_memory_utilization 也不是 1.0 最爽

vLLM 中一个非常关键的参数:

gpu_memory_utilization

很多人第一反应:

当然越高越好。

比如:

0.95

甚至更高。

理论上能够给:

KV Cache

留下更多 GPU 空间。

但 Laptop GPU、WSL2 和桌面环境还有一个现实问题:

显存并不完全属于你的 vLLM。

系统本身、桌面程序、浏览器、CUDA Context 都可能占显存。

所以如果卡得太满:

启动可能成功

但运行一段时间:

突然 OOM。

相比峰值性能,我更愿意留安全余量。


八、显存优化和吞吐优化,有时候方向相反

比如你降低:

max_num_seqs

可以减少同时活跃请求。

显存压力下降。

系统更稳定。

但:

GPU batching 效率可能降低。

如果提高:

max_num_seqs

吞吐可能上升。

但是:

KV Cache
+
Active Sequence

都会增加。

所以这里不存在:

“最大就是最好”。

更合理的方式是压测:

8
16
32
64

观察:

QPS
P95
TTFT
显存
OOM

再选甜点位。


九、我后来不再只看 tokens/s

一开始测试模型,我最喜欢看:

tokens/s

数字越高越开心。

但真正做 API 服务以后发现:

一个系统 Token/s 很高,不代表用户体验好。

我后来更关心:

TTFT

Time To First Token。

用户点下发送,到看到第一个字。


TPOT

Time Per Output Token。

开始生成以后,每个 Token 出来的速度。


P95 Latency

95% 请求需要多久。


QPS

系统每秒能处理多少请求。


GPU Memory

服务是不是离 OOM 只差一个 Chrome 标签页。

最后这一项在 8GB Laptop 上尤其现实。


十、RAG 场景里,Prefill 经常比 Decode 更值得关注

一个 RAG 请求可能是:

Prompt = 3000 Tokens
Output = 300 Tokens

这时候模型首先需要:

Prefill 3000 Tokens

然后才开始逐 Token Decode。

如果 Context 很长:

用户等第一个 Token 的时间就会明显增加。

这也是为什么我后来越来越重视:

TTFT

而不是只看:

生成阶段 tokens/s。

十一、Chunked Prefill 是什么?

vLLM 支持:

Chunked Prefill

它的思路可以简单理解为:

原本一个超长 Prompt:

[======================]

一次性做完整 Prefill。

现在可以拆成:

[=====]
[=====]
[=====]
[=====]

让调度器更灵活地在:

Prefill
和
Decode

之间安排工作。

例如启动时:

--enable-chunked-prefill

对于:

长 Context
+
多并发请求

的场景,它有机会改善调度。

但这里我要特别强调:

不要因为看到“性能优化参数”四个字就默认打开。

不同请求长度、并发和 GPU 环境下,效果可能完全不同。

最可靠的方法仍然是:

压测。

十二、一个典型的错误优化:一次改五个参数

我以前也干过:

max_model_len 调了
max_num_seqs 调了
gpu_memory_utilization 调了
chunked prefill 开了
swap space 也调了

然后一跑:

快了。

问题来了:

到底为什么快?

不知道。

下一次变慢:

到底为什么慢?

还是不知道。

后来我尽量一次只改变一个维度。

例如:

Test A
max_num_seqs = 8

Test B
max_num_seqs = 16

其他参数全部相同

记录:

TTFT
P95
QPS
Peak VRAM

这才真正叫性能调优。

否则只是:

参数炼丹。


十三、量化模型:AWQ 并不是单纯为了“模型更小”

我也使用过类似:

Qwen2.5-3B-Instruct-AWQ

并尝试:

awq_marlin

这样的量化执行路径。

量化最直接的好处当然是:

减少权重显存。

比如 FP16:

一个参数约 2 bytes。

4bit:

理论权重体积显著下降。

这样释放出来的显存,可以留给:

KV Cache

这对于 Serving 很关键。

因为:

节约下来的显存,不一定是为了再塞一个更大的模型,也可能是为了服务更多用户。


十四、但是量化不是免费的

4bit 并不意味着:

一定比 FP16 快 4 倍。

真实情况取决于:

  • GPU;
  • Kernel;
  • Batch;
  • Quant Format;
  • vLLM 版本;
  • Marlin 等执行路径。

有时候:

显存明显下降

但:

单请求延迟未必同比例下降。

所以我对量化的理解后来变成:

首先是 Memory Optimization,其次才可能是 Speed Optimization。


十五、Tensor Parallel:两张 V100 不意味着性能自动翻倍

在:

V100 × 2

环境里,我使用过:

tensor_parallel_size = 2

简单理解:

模型矩阵被拆到两张 GPU 上计算。

好处是:

更大的模型可以放进去。

但是通信也会带来成本。

所以两张卡:

不等于单卡速度 × 2。

尤其对于小模型:

通信成本甚至可能让 TP 没有什么必要。

所以我的判断标准是:

模型单卡放不下

优先考虑 TP。

模型单卡已经轻松放下

不要为了“我有两张卡”就强行 TP。


十六、SSE 让我重新理解性能

MindTrip 的生成服务最后通过:

vLLM
↓
FastAPI
↓
SSE
↓
UniApp

流式返回。

如果不用 Streaming:

第 0 秒:空白
第 1 秒:空白
第 2 秒:空白
第 3 秒:空白
第 5 秒:突然出现完整答案

如果使用 Streaming:

第 0.8 秒:第一句话
第 1.1 秒:继续生成
第 1.4 秒:继续生成
...

哪怕:

最终总时间相差不大,

体验都是完全不同的。

所以后来我把:

第一个 Token 是否足够快

看成一个产品指标,而不只是模型指标。


十七、RAG 还有一个隐藏性能杀手:Embedding

最开始测试:

LLM 响应慢。

我天然怀疑 vLLM。

后来拆链路发现一次请求其实是:

Query
  ↓
Embedding
  ↓
FAISS
  ↓
Context Build
  ↓
vLLM Prefill
  ↓
Decode

如果:

BGE-M3 在 CPU 上跑

Query Embedding 本身就可能占掉不少时间。

于是性能分析一定需要拆分:

T_total =
T_embedding
+ T_retrieval
+ T_context
+ T_prefill
+ T_decode

否则非常容易:

优化错地方。


十八、为什么 FAISS IndexFlatIP 反而很舒服

对于 MindTrip 当前的数据规模:

352 城市
47,124 条旅游问答

FAISS:

IndexFlatIP

其实非常够用。

因为这部分搜索并不是瓶颈。

如果为了追求“高级”换:

复杂 ANN Index

可能检索从:

非常快

优化成:

还是非常快。

但代码复杂度上升一倍。

这种情况我后来坚决不做。

性能优化最基本的原则仍然是:

先找到瓶颈。


十九、我的 vLLM 调优顺序最后变成这样

如果重新做一次,我会按照这个顺序:

Step 1:确定模型

先决定:

3B
7B
还是更大模型

Step 2:确定精度

FP16
BF16
AWQ
GPTQ

Step 3:根据真实请求决定 Context

而不是:

根据模型最大支持 Context。

Step 4:确定显存安全边界

观察:

Idle VRAM
Model VRAM
KV Cache
Peak VRAM

Step 5:调并发

测试:

max_num_seqs

Step 6:测试 Chunked Prefill

尤其:

Long Prompt + Concurrent Requests

场景。


Step 7:测试 Streaming

记录:

TTFT
TPOT

Step 8:再看 QPS / P95

最后比较吞吐时,还要检查成功请求比例与输出是否被截断。若某组参数通过超时或更短的输出降低了延迟,就不是同等服务质量下的加速。负载脚本、输入长度分布和最大输出限制都应与结果一起保存。

最后才是:

服务到底能扛多少人。


二十、一个更合理的启动配置思路

对于本地 3B 模型,我更倾向类似:

vllm serve ./ZhiluAI-3B-SFT-merged \
  --dtype float16 \
  --max-model-len 2048 \
  --gpu-memory-utilization 0.8 \
  --enable-chunked-prefill \
  --port 8080

但这里的:

0.8
2048
Chunked Prefill

都不应该理解为所谓:

“最佳参数”。

它们只是:

一个具有安全余量的起始点。

真正最佳值必须根据实际请求分布测试。


二十一、最大的坑:Benchmark 和真实业务不是一回事

你可以构造:

100 个完全相同、长度一致的 Prompt

然后跑出非常漂亮的 QPS。

但真实用户请求会是:

200 Token
1100 Token
4000 Token
600 Token

输出也可能:

50 Token
300 Token
1000 Token

所以最后真正值得测试的应该是:

Production-like Workload。

对于 MindTrip 就应该模拟:

短问答
+
三日行程
+
五日行程
+
多轮修改
+
同时请求

而不是只跑:

Hello

1000 次。


二十二、我最后真正关心的是稳定性

调到后面,我已经不太追求:

跑出最高的瞬时 tokens/s。

更希望:

连续跑几个小时不 OOM
P95 不突然爆炸
长请求不会堵死短请求
主服务不会因为模型异常退出

这也是从“模型实验”走向“工程部署”以后一个很明显的变化。

Benchmark 第一名很好看。

但线上真正重要的是:

第 1001 个请求还能不能正常回答。


二十三、vLLM 教会我的一个核心认知

训练模型的时候,我们经常关注:

参数量
Loss
Accuracy
Epoch
Learning Rate

做 Serving 以后,关注点变成:

Memory
Scheduling
Cache
Concurrency
Latency
Throughput

这两套知识其实完全不同。

一个模型效果很好:

不代表它适合部署。

一个模型能部署:

不代表它能支撑并发。

一个 Benchmark 吞吐很好:

也不代表真实用户体验好。

而 vLLM 最有意思的地方,就是逼着我真正理解:

LLM 不只是模型,它最后也是一个需要被调度、缓存、并发和监控的软件服务。


二十四、最后总结:别从参数开始优化

如果现在有人问我:

vLLM 应该怎么调?

我不会先告诉他:

gpu_memory_utilization=0.9
max_num_seqs=32

我反而会先问三个问题:

你的 GPU 是什么?
你的 Prompt 一般多长?
你的并发是多少?

因为没有这些信息:

所谓“最佳配置”基本都没有意义。

我现在更认可的优化公式是:

真实业务负载
        ↓
确定瓶颈
        ↓
一次改变一个变量
        ↓
记录 TTFT / TPOT / P95 / QPS / VRAM
        ↓
重新测试

而不是:

复制网上一串神秘启动参数
        ↓
祈祷。

如果说 MindTrip 让我真正理解了 RAG 的工程化,

那么 vLLM 则让我第一次真正意识到:

大模型部署的核心问题,有时候并不是“大模型”,而是“部署”。

💬 评论

这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。

正在连接评论服务,首次访问可能需要几秒…