Skip to content

高并发 AI 推理服务的架构设计:SSE 流式输出与队列调度 ​

一个 AI 客服系统上线后流量暴涨,峰值 200 路并发请求砸向推理服务。单卡 A100 80G 的显存被瞬间吃满,请求超时率飙到 40%,流式输出频繁断流。这篇复盘我们怎么一步步把它救回来。

问题诊断:高并发下到底卡在哪 ​

先别急着上方案,得搞清楚瓶颈在哪。用 Prometheus + Grafana 监控后,三个瓶颈清清楚楚:

┌───────────────────────────────────────────────────────────────┐
│                    高并发下的三个瓶颈                           │
├───────────────┬──────────────┬───────────────────────────────┤
│   瓶颈一       │   瓶颈二      │       瓶颈三                  │
│   GPU 显存     │   请求排队    │       流式输出断流            │
├───────────────┼──────────────┼───────────────────────────────┤
│ 每个请求加载   │ 推理是串行的  │ 前端 SSE 连接在 30s 后超时     │
│ KV-Cache 占用  │ 200 路请求    │ Nginx proxy_read_timeout 60s ││
│ 大量显存       │ 排队等 1 个   │ 长回答生成超时连接断开         │
│               │ GPU 逐个处理  │                               │
│ 80G 显存      │ 等待时间      │ 用户看到:                    │
│ 只能扛 ~15 路  │ 随队列线性    │ 回答到一半突然断了             │
│ 并发           │ 增长         │                               │
└───────────────┴──────────────┴───────────────────────────────┘

整体架构:三层解耦 ​

核心思路是把同步推理变成异步流水线,用队列做削峰填谷:

┌─────────────────────────────────────────────────────────────────────┐
│                          接入层                                      │
│   ┌──────────┐  ┌──────────┐  ┌──────────────┐                    │
│   │ 限流器    │→│ 请求入队  │→│ SSE 中继层    │ ← 维持前端长连接    │
│   │ (令牌桶)  │  │ (Redis)  │  │ (WebFlux)    │                    │
│   └──────────┘  └──────────┘  └──────┬───────┘                    │
└───────────────────────────────────────┼─────────────────────────────┘
                                        │ 订阅结果流
                                        ▼
┌─────────────────────────────────────────────────────────────────────┐
│                          调度层                                      │
│   ┌──────────────┐  ┌───────────────┐  ┌──────────────────┐       │
│   │ 优先级队列    │→ │ 动态批处理器   │→ │  结果分发器       │       │
│   │ (VIP 优先)   │  │ (Batch 合并)  │  │  (按 reqId 路由)  │       │
│   └──────────────┘  └───────────────┘  └──────────────────┘       │
└──────────────────────────────────────────┬──────────────────────────┘
                                           │ 批量推理请求
                                           ▼
┌─────────────────────────────────────────────────────────────────────┐
│                          推理层                                      │
│   ┌──────────────────────────┐  ┌──────────────────────────────┐  │
│   │ vLLM 推理引擎             │  │  连续批处理 (Continuous      │  │
│   │ - PagedAttention 显存优化 │  │   Batching)                  │  │
│   │ - 并发请求合并处理         │  │  动态插入/移除请求           │  │
│   └──────────────────────────┘  └──────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

接入层:限流 + 队列削峰 ​

令牌桶限流 ​

不能让 200 路请求直接打穿到 GPU。先用令牌桶控制进入推理管道的速率:

python
import time
from collections import deque

class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        """
        capacity: 桶容量(最大并发请求数)
        refill_rate: 每秒补充令牌数(实际推理吞吐量)
        """
        self.capacity = capacity
        self.refill_rate = refill_rate
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self.waiting = deque()  # 排队等待的请求

    def acquire(self, timeout: float = 30.0) -> bool:
        """获取令牌,超时返回 False"""
        deadline = time.monotonic() + timeout
        while True:
            now = time.monotonic()
            # 补充令牌
            elapsed = now - self.last_refill
            self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
            self.last_refill = now

            if self.tokens >= 1:
                self.tokens -= 1
                return True

            if now >= deadline:
                return False
            time.sleep(0.1)

关键参数怎么定:

  • capacity(桶容量)= GPU 能扛的最大并发路数,A100 80G 跑 13B 模型大约 15-20 路
  • refill_rate(补充速率)= 每秒能处理完的请求数,实测大约 2-3 req/s

Redis 请求队列 ​

限流通过的请求不直接调推理引擎,而是写入 Redis 队列。好处是服务重启不丢请求,还能做优先级:

python
import redis
import json

r = redis.Redis()

def enqueue_request(request_id: str, prompt: str, priority: str = "normal"):
    """请求入队,支持优先级"""
    queue_name = f"llm:queue:{priority}"  # llm:queue:vip / llm:queue:normal
    r.lpush(queue_name, json.dumps({
        "request_id": request_id,
        "prompt": prompt,
        "timestamp": time.time()
    }))

调度层:动态批处理是核心 ​

这是整个架构里最关键的一环。GPU 推理的最大瓶颈不是算力,而是没有把并发请求合并成 batch 一起算。

为什么动态批处理能大幅提速 ​

无批处理(串行):                     有批处理(动态合并):

请求1 ████████████████  8s             请求1 ┐
请求2           ████████████████  8s   请求2 ├→ 合并为一个 batch ████████ 4s
请求3                     ██████  6s   请求3 ┘
请求4                           ████ 4s

总耗时:26s                            总耗时:4s(3倍以上提速)
GPU 利用率:30%                        GPU 利用率:85%+

GPU 的并行计算能力在 batch 处理时才能充分发挥。单个请求推理时 GPU 大量算力闲置,合并成 batch 后利用率直接拉满。

连续批处理实现 ​

vLLM 自带 Continuous Batching,比传统静态批处理强在哪:

静态批处理:                           连续批处理:

时间→  t1    t2    t3    t4    t5      时间→  t1    t2    t3    t4    t5
      ┌──────────────────────┐               ┌──────────────────┐
Req1  │██████████████████████│      Req1  │██████████████████│ ✓ 完成, 移出
      └──────────────────────┘               └──────────────────┘
      ┌──────────────────────┐               ┌──────────────────┐
Req2  │████████████  ████████│      Req2  │██████████        │ ✓ 完成, 移出
      └──────────────────────┘               └──────────────────┘
                                            ┌──────────┐
      ┌──────────────────────┐      Req3  │██████████│ ← 新请求动态插入
Req3  │           ███████████│              └──────────┘
      └──────────────────────┘

等待最慢的请求完成才能释放           每个请求完成即释放,新请求立刻插入
GPU 利用率低                         GPU 持续满载

vLLM 启动配置:

bash
python -m vllm.entrypoints.api_server \
    --model /models/Qwen2-13B-Chat \
    --tensor-parallel-size 1 \
    --max-model-len 4096 \
    --max-num-seqs 32 \          # 最大并发序列数(关键参数)
    --gpu-memory-utilization 0.9 \  # GPU 显存利用率上限
    --enable-chunked-prefill      # 开启分块预填充(提升吞吐)

--max-num-seqs 32 是最关键的参数,决定同时处理多少路请求。设太大 OOM,设太小浪费算力,需要根据显存实测调优。

结果分发器 ​

推理结果是流式的(逐 token 生成),需要按 request_id 分发到对应的 SSE 连接:

python
import asyncio

class ResultDispatcher:
    """按 request_id 分发流式结果到对应的 SSE 连接"""

    def __init__(self):
        self.subscriptions: dict[str, asyncio.Queue] = {}

    def subscribe(self, request_id: str) -> asyncio.Queue:
        """前端 SSE 连接订阅结果流"""
        q = asyncio.Queue()
        self.subscriptions[request_id] = q
        return q

    def publish(self, request_id: str, token: str):
        """推理引擎产出 token 后发布"""
        if request_id in self.subscriptions:
            self.subscriptions[request_id].put_nowait(token)

    def complete(self, request_id: str):
        """请求完成,关闭订阅"""
        if q := self.subscriptions.pop(request_id, None):
            q.put_nowait(None)  # 发送结束信号

SSE 中继层:维持前端长连接 ​

前端和推理服务之间不能直连,需要一层 SSE 中继维持长连接,解决超时断流问题。

中继架构 ​

前端浏览器                     SSE 中继层                    推理服务
┌──────────┐                ┌──────────────┐             ┌──────────┐
│ Event    │ ← SSE 长连接 → │ WebFlux      │ ← 订阅 →    │ Dispatcher│
│ Source   │   (心跳保活)   │ (维持连接)    │             │ (结果流)  │
│          │                │ (超时兜底)    │             └──────────┘
└──────────┘                └──────────────┘

心跳保活机制 ​

SSE 连接超过 30 秒没有数据,浏览器和中间的 Nginx 都可能断开。解决方法是定期发心跳:

java
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String requestId) {
    // 订阅推理结果
    Flux<String> resultStream = dispatcher.subscribe(requestId);

    // 每 15 秒发一个心跳注释(SSE 注释不会触发前端事件)
    Flux<String> heartbeat = Flux.interval(Duration.ofSeconds(15))
        .map(i -> ": heartbeat\n\n");

    // 合并结果流和心跳流
    return Flux.merge(resultStream, heartbeat)
        .takeUntil(s -> s == null)  // 收到结束信号停止
        .onErrorResume(e -> Flux.just("data: [ERROR]\n\n"));
}

超时兜底 ​

即使有心跳,也要设一个最大生成时间限制,防止某些请求卡死永远不结束:

java
return Flux.merge(resultStream, heartbeat)
    .takeUntil(s -> s == null)
    .timeout(Duration.ofSeconds(120))  // 最长 120 秒
    .onErrorResume(TimeoutException.class, e ->
        Flux.just("data: [TIMEOUT] 生成超时,请重试\n\n")
    );

效果对比 ​

优化前后的关键指标:

指标优化前优化后提升幅度
峰值并发15 路200 路13 倍
GPU 利用率30%85%+2.8 倍
首 token 延迟8s1.5s5.3 倍
请求超时率40%2%20 倍
流式断流率25%0.1%250 倍
单卡 QPS1.24.53.75 倍

四个关键经验 ​

1. 动态批处理是最大的性能杠杆 ​

没有动态批处理,其他优化都是小修小补。vLLM 的 Continuous Batching 是目前最成熟的方案,生产环境首选。

2. 队列解耦是稳定性的基石 ​

请求不直接打 GPU,走队列削峰。好处是:流量突增时请求排队而不是打挂服务;推理服务重启时队列里的请求不丢。

3. SSE 心跳不是可选的 ​

没有心跳,长连接必断。15 秒一个心跳注释是最保险的间隔。Nginx 的 proxy_read_timeout 也要相应调大。

4. 显存是硬约束,算力不是 ​

A100 80G 的算力很强,但显存是硬约束。max-num-seqs 不是设多少都行,要看模型的 KV-Cache 占用。用 PagedAttention(vLLM 内置)能显著降低显存碎片,提升并发上限。

复盘总结 ​

高并发 AI 推理服务的核心矛盾是:GPU 是串行批处理设备,但用户请求是并发的。解法就一条主线——用队列削峰 + 动态批处理把并发请求合并,把 GPU 利用率拉满。

技术选型清单:

组件方案理由
推理引擎vLLMContinuous Batching + PagedAttention
消息队列Redis List轻量、够用、支持优先级
SSE 中继Spring WebFlux非阻塞、适合长连接
限流令牌桶平滑限流、可 burst
监控Prometheus + Grafana实时观察队列深度和 GPU 利用率

最后一句:高并发推理优化的本质,是在显存约束下最大化 GPU 利用率。先上动态批处理,再调队列调度,最后抠显存优化,这个顺序不能反。

📖本文阅读--次|📊全站访问--次|👥访客--人