大模型推理服务生产化实战 — vLLM 动态批处理 × KV Cache × 显存调度
一、引言:为什么推理服务是另一个物种
训练一次大模型动辄几百万美金,但推理才是真正烧钱的部分。一个 7B 模型在线服务,GPU 时租费可能比训练还贵三倍。
很多团队把推理当成"调个 API 就完事"的活儿,结果一上线就翻车:
- 并发一上来,显存 OOM 雪崩
- 长文本场景 TTFT 飙升到 10 秒,用户直接关页面
- 输入 32k 上下文,响应只输出 200 token,显存被 KV Cache 吃得死死的
- 流量低谷期 GPU 闲得发慌,高峰期又被打爆
💡 推理服务的核心矛盾:显存有限 vs. 请求无限。这跟传统 Web 服务完全是两码事——你要管理的不是线程池,而是显存里的 KV Cache。
今天我们就来拆解 vLLM 这套工业级方案的核心机制,以及生产中那些血泪踩过的坑。
二、静态批处理为什么不够用
最朴素的推理服务长这样:
# 教科书式反面案例:来一个请求处理一个
def handle_request(prompt: str) -> str:
input_ids = tokenizer(prompt, return_tensors="pt").to("cuda")
with torch.no_grad():
output = model.generate(
input_ids,
max_new_tokens=512,
do_sample=True,
temperature=0.7,
)
return tokenizer.decode(output[0])
# 问题:
# 1. GPU 利用率 10%~30%,大部分时间在等 HBM 读写
# 2. 长短请求混跑,慢请求拖死快请求(head-of-line blocking)
# 3. 显存碎片化,32G 卡只能塞 4~6 个并发
改进方案是 静态批处理(Static Batching):等凑够一批再一起跑。但这里有个致命问题——批里的最长请求没跑完,其他请求不能提前返回。
| 指标 | 静态批处理 | 动态批处理 (vLLM) |
|---|---|---|
| GPU 利用率 | 35-50% | 85-95% |
| 吞吐提升 | 1x | 23x |
| HoL 阻塞 | 严重 | 0 |
三、vLLM 三板斧:PagedAttention × 连续批处理 × 抢占
3.1 PagedAttention:把显存当虚拟内存管
传统做法是给每个请求预分配 max_seq_len 的 KV Cache 显存。1024 上下文还好,32k 上下文直接把人送走。
vLLM 的灵感来自操作系统的虚拟内存分页:把 KV Cache 切成固定大小的 block(默认 16 token),按需分配,用 block table 维护映射关系。
📐 PagedAttention 显存映射
请求 A (seq=512) → [Block 0] [Block 3] [Block 7] [Block 12]
请求 B (seq=1024) → [Block 1] [Block 4] [Block 8] [Block 13] [Block 15]
💡 物理 block 复用 · 碎片 < 4% · 显存利用率 96%+
3.2 连续批处理(Continuous Batching)
这是 vLLM 吞吐爆发的核心。每生成一个 token 就重新调度一次——批里某条序列跑完了,新请求可以立刻补位。
# vLLM 调度核心循环(简化)
class ContinuousScheduler:
def __init__(self, block_manager, max_num_seqs=256):
self.waiting = deque() # 待处理请求队列
self.running = [] # 正在推理的序列
self.block_manager = block_manager
def schedule(self) -> SchedulerOutput:
# Step 1: 把完成的请求释放,补位新请求
for seq in self.running[:]:
if seq.is_finished():
self.block_manager.free(seq)
self.running.remove(seq)
# Step 2: 抢占显存不足的请求(关键!)
if self._need_preempt(self.running):
victim = self._select_victim()
self._preempt(victim) # 显存不足时把请求 swap 到 CPU
# Step 3: 从 waiting 拉新请求补位
while not self._is_full() and self.waiting:
seq = self.waiting.popleft()
if self.block_manager.allocate(seq):
self.running.append(seq)
else:
self.waiting.appendleft(seq)
break
# Step 4: 组 batch 调 GPU 推理
return self._build_batch(self.running)
3.3 抢占机制:显存不够怎么办
高优先级短请求来了,显存不够?vLLM 会把低优先级长请求swap 到 CPU 内存,腾出位置。请求被换入时会触发 recompute(重新计算被换出的 token),所以会有一波延迟尖峰。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| Swap 策略 | Recompute | vLLM 默认 |
| 备选策略 | Swap-in | 直接拷回显存 |
| block_size | 16 | 长文本场景可调到 32 |
| 最大并发 | 显存 / 单序列 KV | 80G 卡 7B 模型 ~120 并发 |
四、生产部署架构:不只是跑个 vLLM
单机能跑不等于生产能用。真正的生产架构要考虑流量调度、监控告警、弹性扩缩容。
🏗️ 推理服务生产架构
[Client Web/App] → [Nginx/LB] → [API Gateway 限流/鉴权]
↓
[vLLM-Node-1] [vLLM-Node-2] [vLLM-Node-3] [vLLM-Node-N]
↓
[Prometheus] [Grafana] [Loki] [Jaeger]
4.1 启动配置:这些参数决定生死
# 7B 模型 8×A100 80G 生产配置(踩坑调优版)
vllm serve /models/Qwen2-72B-Instruct \
--host 0.0.0.0 \
--port 8000 \
--served-model-name qwen72b \
--tensor-parallel-size 4 \ # 4 卡张量并行
--pipeline-parallel-size 2 \ # 2 路流水线
--max-model-len 32768 \ # 上下文长度
--gpu-memory-utilization 0.92 \ # 显存利用率上限(留 8% 给激活)
--block-size 16 \ # PagedAttention block 大小
--max-num-seqs 256 \ # 最大并发序列数
--max-num-batched-tokens 8192 \ # 批处理总 token 上限
--swap-space 4 \ # CPU swap 空间(GB)
--enable-prefix-caching \ # 启用前缀缓存(系统提示词)
--enable-chunked-prefill \ # 分块预填充(关键!)
--disable-log-requests \
--trust-remote-code
⚙️ 关键参数说明
--max-num-batched-tokens:单次前向传播的最大 token 数,直接影响 TTFT 和吞吐平衡--enable-chunked-prefill:把长 prompt 拆成小块,与 decode 阶段混合调度,长文本场景 TTFT 降低 60%+--enable-prefix-caching:相同前缀(如 system prompt)只计算一次 KV Cache,适合 RAG / Agent 场景
4.2 客户端集成:流式响应 + 上下文传播
@Slf4j
@Service
public class LLMInferenceService {
@Value("${vllm.endpoint}")
private String endpoint;
@Value("${vllm.api-key:kong-2026}")
private String apiKey;
// 限流:每实例 QPS 上限 50,防止打爆 vLLM
private final RateLimiter rateLimiter = RateLimiter.create(50.0);
// 熔断:连续失败 5 次熔断 30 秒
private final CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("vllm");
@WithSpan // OpenTelemetry 埋点
public Flux<ChatChunk> streamChat(ChatRequest request, String traceId) {
return Flux.create(sink -> {
try {
rateLimiter.acquire();
// 上下文传播:把 traceId 传给下游
Map<String, String> headers = new HashMap<>();
headers.put("X-Trace-Id", traceId);
headers.put("X-User-Id", request.getUserId());
circuitBreaker.executeRunnable(() -> {
SseEmitter emitter = vllmClient.streamCompletion(
request, headers,
chunk -> {
// 首个 token 时间打点
if (chunk.isFirstToken()) {
Metrics.recordTTFT(traceId, chunk.elapsedMs());
}
sink.next(ChatChunk.from(chunk));
},
error -> {
log.error("vLLM stream error, traceId={}", traceId, error);
Metrics.recordError(traceId, error.getClass().getSimpleName());
sink.error(error);
},
() -> sink.complete()
);
});
} catch (Exception e) {
sink.error(e);
}
});
}
}
五、监控告警:你必须盯紧的 5 个指标
vLLM 自带 /metrics Prometheus 端点。生产环境必抓的指标:
| 指标 | 含义 | 健康阈值 | 告警级别 |
|---|---|---|---|
vllm:gpu_cache_usage_perc | KV Cache 显存使用率 | < 90% | > 95% 触发扩容 |
vllm:num_preemptions_total | 抢占次数(反映显存压力) | < 5/min | > 20/min 紧急扩容 |
vllm:time_to_first_token_seconds | 首 token 延迟(TTFT) | P99 < 2s | P99 > 5s 用户感知 |
vllm:request_success_total | 成功请求数 | 持续增长 | 5min 无增长即异常 |
vllm:e2e_request_latency_seconds | 端到端请求延迟 | P99 < 30s | P99 > 60s 熔断检查 |
# Prometheus 告警规则
groups:
- name: vllm_production
rules:
- alert: VLLMHighCacheUsage
expr: vllm:gpu_cache_usage_perc{instance=~".+"} > 0.95
for: 2m
annotations:
summary: "vLLM 节点 {{ $labels.instance }} KV Cache 使用率超 95%"
action: "检查并发是否异常飙升,考虑扩容或降低 max-num-seqs"
- alert: VLLMFrequentPreemption
expr: rate(vllm:num_preemptions_total[5m]) > 20
for: 3m
annotations:
summary: "vLLM 抢占频率过高: {{ $value }} 次/分钟"
action: "长文本请求激增,建议启用 chunked-prefill 或升级显存"
- alert: VLLMHighTTFT
expr: histogram_quantile(0.99, vllm:time_to_first_token_seconds_bucket) > 5
for: 5m
annotations:
summary: "vLLM 首 token 延迟 P99 超 5s"
action: "检查是否开启 chunked-prefill,排查上游限流"
六、生产踩坑实录
🚨 坑 1:max-num-batched-tokens 调太大直接 OOM
之前贪心调到 32768,想着批越大吞吐越高。结果一波长 prompt 进来,prefill 阶段激活值把显存撑爆,整个进程被 CUDA OOM Killer 干掉。
解法:开启 --enable-chunked-prefill,把长 prompt 拆成 8192 token 的小块,与 decode 混合调度。TTFT 降低 60%,显存峰值降低 40%。
🚨 坑 2:流式响应里隐藏的连接泄漏
Java 客户端用 SseEmitter 接 vLLM 流式响应,用户中途断开连接时,服务端没收到 FIN 包,导致 HTTP 连接长时间挂着不释放,5 分钟就堆了上千个 TIME_WAIT。
解法:三件套——① 设置 EmitterTimeout=60s;② 注册 onCompletion/onError/onTimeout 全部走 completeWithError;③ 用 try-with-resources 包住客户端,finally 块里显式 close()。
🚨 坑 3:Prefix Cache 命中率归零的玄学问题
启用 --enable-prefix-caching 后监控显示命中率一直 0%。排查半天才发现,客户端每次请求都把时间戳 "当前时间:2026-06-22T10:29:57" 拼在 system prompt 最前面,导致前缀永远不匹配。
解法:把动态内容(时间、用户 ID、随机种子)统一放在 user message 开头,system prompt 保持完全静态。命中率从 0% 飙升到 78%。
🚨 坑 4:tensor-parallel 卡数超过 NVLink 拓扑
8 卡 A100 服务器,设了 --tensor-parallel-size 8,发现推理速度比 4 卡还慢。查了下 NVLink 拓扑,原来第 5-8 卡跨 NUMA 节点,通信要走 QPI,延迟翻倍。
解法:用 nvidia-smi topo -m 看拓扑,把 tensor-parallel-size 限制在单 NUMA 节点内(通常 4-8 卡),跨节点用 pipeline-parallel。或者干脆 CUDA_VISIBLE_DEVICES=0,1,2,3 显式绑卡。
🚨 坑 5:量化模型 + 投机解码的兼容性陷阱
为了降本上了 AWQ 量化,又叠加 --speculative-model 做投机解码,吞吐量反而下降 15%。查文档才发现,投机解码要求 draft 模型和 target 模型词表必须完全一致,而 AWQ 量化改了 embedding 层结构。
解法:要么用同源的小模型做 draft(如 Qwen2-1.5B 给 Qwen2-72B 加速),要么放弃投机解码。实测同源 draft 能带来 1.8x 加速。
七、性能调优 Checklist
- 显存配置:
--gpu-memory-utilization设到 0.90-0.92,别贪满 - 批处理:必开
--enable-chunked-prefill,max-num-batched-tokens从 4096 往上试 - 前缀缓存:RAG/Agent 场景必开
--enable-prefix-caching,命中率 50%+ 才有效 - 并发上限:
--max-num-seqs= 显存剩余 / 单序列 KV 占用,80G 卡 7B 模型建议 128-256 - 投机解码:同源小模型做 draft,异构模型直接放弃
- 量化选型:AWQ (4bit) 性价比最高,显存降 60%,精度损失 < 1%
- 网络拓扑:
tensor-parallel-size不超过单 NUMA 节点 GPU 数 - 流式响应:客户端必带超时和连接清理,服务端配
keep-alive timeout=300 - 监控告警:TTFT P99、Cache 使用率、抢占频率三件套
- 弹性扩缩:根据
num_requests_waiting自动扩 K8s 副本
🎯 总结
大模型推理服务的生产化,本质是在显存、延迟、吞吐三个维度做精细平衡:
- PagedAttention 解决了显存碎片问题,让 32k 上下文不再 OOM
- 连续批处理 把 GPU 利用率从 35% 拉到 90%+,吞吐提升 20 倍
- Chunked Prefill 平衡了 TTFT 和吞吐,长文本场景延迟降低 60%
- Prefix Caching 让 RAG/Agent 场景的重复前缀零成本
但 vLLM 跑起来只是开始,真正的难点在参数调优 + 监控告警 + 容量规划。这期没讲完的还有:多 LoRA 热加载、动态量化切换、K8s 弹性扩缩容,留给下期继续。
更多推荐

所有评论(0)