高并发 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。先用令牌桶控制进入推理管道的速率:
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 队列。好处是服务重启不丢请求,还能做优先级:
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 启动配置:
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 连接:
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 都可能断开。解决方法是定期发心跳:
@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"));
}超时兜底
即使有心跳,也要设一个最大生成时间限制,防止某些请求卡死永远不结束:
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 延迟 | 8s | 1.5s | 5.3 倍 |
| 请求超时率 | 40% | 2% | 20 倍 |
| 流式断流率 | 25% | 0.1% | 250 倍 |
| 单卡 QPS | 1.2 | 4.5 | 3.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 利用率拉满。
技术选型清单:
| 组件 | 方案 | 理由 |
|---|---|---|
| 推理引擎 | vLLM | Continuous Batching + PagedAttention |
| 消息队列 | Redis List | 轻量、够用、支持优先级 |
| SSE 中继 | Spring WebFlux | 非阻塞、适合长连接 |
| 限流 | 令牌桶 | 平滑限流、可 burst |
| 监控 | Prometheus + Grafana | 实时观察队列深度和 GPU 利用率 |
最后一句:高并发推理优化的本质,是在显存约束下最大化 GPU 利用率。先上动态批处理,再调队列调度,最后抠显存优化,这个顺序不能反。