一、引言:为什么推理服务是另一个物种

训练一次大模型动辄几百万美金,但推理才是真正烧钱的部分。一个 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%
吞吐提升1x23x
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 策略RecomputevLLM 默认
备选策略Swap-in直接拷回显存
block_size16长文本场景可调到 32
最大并发显存 / 单序列 KV80G 卡 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_percKV Cache 显存使用率< 90%> 95% 触发扩容
vllm:num_preemptions_total抢占次数(反映显存压力)< 5/min> 20/min 紧急扩容
vllm:time_to_first_token_seconds首 token 延迟(TTFT)P99 < 2sP99 > 5s 用户感知
vllm:request_success_total成功请求数持续增长5min 无增长即异常
vllm:e2e_request_latency_seconds端到端请求延迟P99 < 30sP99 > 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

  1. 显存配置:--gpu-memory-utilization 设到 0.90-0.92,别贪满
  2. 批处理:必开 --enable-chunked-prefill,max-num-batched-tokens 从 4096 往上试
  3. 前缀缓存:RAG/Agent 场景必开 --enable-prefix-caching,命中率 50%+ 才有效
  4. 并发上限:--max-num-seqs = 显存剩余 / 单序列 KV 占用,80G 卡 7B 模型建议 128-256
  5. 投机解码:同源小模型做 draft,异构模型直接放弃
  6. 量化选型:AWQ (4bit) 性价比最高,显存降 60%,精度损失 < 1%
  7. 网络拓扑:tensor-parallel-size 不超过单 NUMA 节点 GPU 数
  8. 流式响应:客户端必带超时和连接清理,服务端配 keep-alive timeout=300
  9. 监控告警:TTFT P99、Cache 使用率、抢占频率三件套
  10. 弹性扩缩:根据 num_requests_waiting 自动扩 K8s 副本

🎯 总结

大模型推理服务的生产化,本质是在显存、延迟、吞吐三个维度做精细平衡:

  • PagedAttention 解决了显存碎片问题,让 32k 上下文不再 OOM
  • 连续批处理 把 GPU 利用率从 35% 拉到 90%+,吞吐提升 20 倍
  • Chunked Prefill 平衡了 TTFT 和吞吐,长文本场景延迟降低 60%
  • Prefix Caching 让 RAG/Agent 场景的重复前缀零成本

但 vLLM 跑起来只是开始,真正的难点在参数调优 + 监控告警 + 容量规划。这期没讲完的还有:多 LoRA 热加载、动态量化切换、K8s 弹性扩缩容,留给下期继续。


更多推荐