大模型推理服务的高并发架构:从请求排队到异步流式输出的全链路优化
大模型推理服务的高并发架构:从请求排队到异步流式输出的全链路优化
推理服务不是简单的"模型+API",在生产环境中支撑数千QPS的推理请求,需要在排队策略、流式传输、内存管理和容错机制四个维度做系统性的架构设计。本文基于实际项目中vLLM推理框架的部署经验,逐一拆解这些关键环节。
一、请求入队策略:三种模式的适用场景与取舍
推理服务的请求到达是高度不均匀的——峰值可能是均值的5-10倍。入队策略决定了系统在流量洪峰下的行为模式,选错策略会在P99延迟和吞吐量之间产生不可接受的tradeoff。
FIFO队列(先入先出) 是最基础的实现。其优势是公平性——每个请求按到达顺序处理,无饥饿问题。但在长序列推理场景下,一个生成2048 tokens的请求会阻塞后续所有短文本请求,导致队头阻塞(Head-of-Line Blocking)。
优先级队列 通过引入QoS分级来缓解队头阻塞。实际落地中,将请求分为三个优先级:
- P0(实时交互):用户对话场景,TTFT要求<500ms
- P1(准实时):批量摘要生成,TTFT要求<2s
- P2(离线批处理):数据标注、向量化等异步任务
优先级队列的实现不是无代价的——存在低优先级请求的饥饿风险。解决方案是引入老化机制(Aging):低优先级请求在队列中等待超过阈值(如30秒)后自动升级优先级。
批处理合并(Batching) 是提升GPU利用率的核心手段。单条推理请求的GPU利用率通常只有20-30%,因为矩阵运算规模不足以填满计算单元。Continuous Batching技术允许在处理过程中动态插入新请求,而非等待整批完成。实测数据:在A100 GPU上,采用Continuous Batching后吞吐量从12 req/s提升到45 req/s,提升约3.75倍。
flowchart TB
subgraph Entry["请求入口层"]
A[HTTP/SSE Request] --> B{Header 解析}
B -->|X-Priority: 0| C[P0 实时队列]
B -->|X-Priority: 1| D[P1 准实时队列]
B -->|X-Priority: 2| E[P2 批处理队列]
end
subgraph Scheduler["调度层"]
C --> F[优先级调度器]
D --> F
E --> F
F --> G{Aging Check}
G -->|等待>30s| H[优先级提升]
G -->|正常| I[Continuous Batcher]
H --> I
end
subgraph Inference["推理引擎层"]
I --> J[Token 生成循环]
J --> K[KV Cache 管理]
K --> L[GPU Kernel 执行]
L --> J
L --> M[流式输出]
end
subgraph Output["输出层"]
M --> N[SSE Writer]
N --> O[背压检测]
O -->|客户端断开| P[资源回收]
end
生产环境的关键配置:
/**
* 优先级队列调度器 —— 支持老化机制的推理请求调度
*
* 核心设计:
* 1. 三级优先级队列(P0/P1/P2),P0最高优先
* 2. 老化机制防止低优先级饥饿:等待超过 agingThresholdMs 自动升级
* 3. 批处理合并:连续批处理模式,最大化 GPU 利用率
*/
public class PriorityAwareScheduler {
// 三级优先级队列,使用 PriorityBlockingQueue 保证线程安全
private final PriorityBlockingQueue<ScheduledRequest>[] queues;
// 老化阈值:P2 请求等待超过此值自动升级到 P1
private static final long AGING_THRESHOLD_MS = 30_000L;
// 调度器主循环开关
private volatile boolean running = true;
@SuppressWarnings("unchecked")
public PriorityAwareScheduler() {
this.queues = new PriorityBlockingQueue[3];
for (int i = 0; i < 3; i++) {
this.queues[i] = new PriorityBlockingQueue<>(
1024,
Comparator.comparingLong(ScheduledRequest::getEffectivePriority)
);
}
}
/**
* 提交推理请求到对应优先级队列
* @param request 推理请求包装对象
* @param priority 0=P0实时, 1=P1准实时, 2=P2批处理
*/
public void submit(InferenceRequest request, int priority) {
if (priority < 0 || priority > 2) {
throw new IllegalArgumentException("优先级必须在0-2之间, 收到: " + priority);
}
ScheduledRequest scheduled = new ScheduledRequest(
request, priority, System.currentTimeMillis()
);
queues[priority].offer(scheduled);
}
/**
* 获取下一个待处理的请求,带老化机制
* 先检查 P0 队列 → P1 队列 → P2 队列(含老化升级)
*
* @param maxBatchSize 最大批处理大小
* @return 从各队列中取出的请求批次
*/
public List<InferenceRequest> nextBatch(int maxBatchSize) {
List<InferenceRequest> batch = new ArrayList<>(maxBatchSize);
long now = System.currentTimeMillis();
// 按优先级依次拉取
for (int pri = 0; pri < 3 && batch.size() < maxBatchSize; pri++) {
PriorityBlockingQueue<ScheduledRequest> queue = queues[pri];
ScheduledRequest scheduled = queue.poll();
if (scheduled != null) {
batch.add(scheduled.getRequest());
continue;
}
// 如果当前优先级队列为空,检查更低优先级是否有老化请求
for (int lower = pri + 1; lower < 3; lower++) {
ScheduledRequest candidate = queues[lower].peek();
if (candidate != null
&& (now - candidate.getEnqueueTime()) > AGING_THRESHOLD_MS) {
// 老化升级:从低优先级队列取出
candidate = queues[lower].poll();
if (candidate != null) {
candidate.setEffectivePriority(pri);
batch.add(candidate.getRequest());
}
}
}
}
return batch;
}
/** 调度请求包装类 */
static class ScheduledRequest {
private final InferenceRequest request;
private final int originalPriority;
private long effectivePriority; // 动态优先级(老化后会降低数值)
private final long enqueueTime;
ScheduledRequest(InferenceRequest request, int priority, long enqueueTime) {
this.request = request;
this.originalPriority = priority;
this.effectivePriority = priority;
this.enqueueTime = enqueueTime;
}
InferenceRequest getRequest() { return request; }
long getEffectivePriority() { return effectivePriority; }
long getEnqueueTime() { return enqueueTime; }
void setEffectivePriority(int newPriority) {
this.effectivePriority = newPriority;
}
}
}
二、HTTP/SSE 流式输出的后端设计
大模型推理的核心体验差异在于"首Token延迟(TTFT)"。用户不能等5秒看到完整结果——必须在几百毫秒内看到第一个字。SSE(Server-Sent Events)是实现这一目标的标准协议。
SSE 相比 WebSocket 的优势在于单向数据流的场景下更轻量——无需握手升级协议,浏览器原生支持自动重连,且与 HTTP/2 多路复用天然兼容。
后端实现的核心模式是 Reactive Streams。以 Spring WebFlux 为例:
/**
* 推理结果流式推送控制器
*
* 使用 Spring WebFlux 的 Reactive 模型实现 SSE 流式输出。
* Flux 的背压机制能自动感知客户端消费速度,避免内存堆积。
*/
@RestController
@RequestMapping("/api/v1/inference")
public class StreamingInferenceController {
private final InferenceEngine inferenceEngine;
public StreamingInferenceController(InferenceEngine inferenceEngine) {
this.inferenceEngine = inferenceEngine;
}
/**
* 流式推理接口
*
* 返回类型 MediaType.TEXT_EVENT_STREAM 触发浏览器 SSE 解析。
* Flux 支持背压:如果客户端消费慢,上游自动降速。
*
* @param request 推理请求(prompt + 参数)
* @return SSE 事件流,每个事件包含一个生成的 token
*/
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamInference(
@RequestBody InferenceRequest request) {
// 参数校验:prompt 不能为空,max_tokens 需在合理范围
if (request.getPrompt() == null || request.getPrompt().isBlank()) {
return Flux.error(new IllegalArgumentException("prompt 不能为空"));
}
int maxTokens = Math.min(request.getMaxTokens(), 4096);
return inferenceEngine.generateStream(request.getPrompt(), maxTokens)
// 将每个 token 包装为 SSE 事件
.map(token -> ServerSentEvent.<String>builder()
.data(token)
.id(String.valueOf(System.nanoTime()))
.event("token")
.build())
// 发送完成信号
.concatWith(Flux.just(
ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()))
// 客户端断开时清理资源
.doOnCancel(() -> {
inferenceEngine.cancelGeneration(request.getSessionId());
})
// 异常处理:返回错误事件而非中断连接
.onErrorResume(e -> Flux.just(
ServerSentEvent.<String>builder()
.event("error")
.data("{\"error\": \"" + e.getMessage() + "\"}")
.build()));
}
}
三、背压控制与客户端断连处理
流式场景下,两个问题不可回避:一是客户端消费速度跟不上生成速度(需要背压),二是客户端中途断连(需要及时回收GPU资源)。
背压控制在Reactive框架中有天然支持——Flux/Mono的 onBackpressureBuffer 和 onBackpressureDrop 策略。对于推理场景,推荐 onBackpressureLatest:如果下游消费慢,丢弃中间token只保留最新的。原因在于:推理是token序列,跳过中间某些token后继续发送后续token对客户端毫无意义。正确的做法是当缓冲区满时触发降级——从流式模式切换为批量返回模式。
客户端断连处理在GPU资源昂贵的场景下至关重要。一个A100 GPU的推理实例成本约$3-5/小时,如果客户端断连后不回收,会导致资源泄漏。检测机制有两种:
- SSE心跳检测:每15秒发送
: heartbeat\n\n(SSE注释格式,不触发data事件),连续3次无响应视为断连 - ResponseBodyEmitter回调:Spring的
onCompletion/onTimeout回调直接感知连接状态
/**
* 推理会话生命周期管理器
*
* 关键职责:
* 1. 追踪每个活跃推理会话的 GPU 资源占用
* 2. 在客户端断开后及时回收 KV Cache 和 GPU 内存
* 3. 心跳检测僵尸连接
*/
@Component
public class InferenceSessionManager {
// sessionId → 推理会话元数据
private final ConcurrentHashMap<String, SessionContext> activeSessions
= new ConcurrentHashMap<>();
// 定时心跳检测线程
private final ScheduledExecutorService heartbeatExecutor
= Executors.newSingleThreadScheduledExecutor();
public InferenceSessionManager() {
// 每 15 秒扫描一次僵尸会话
heartbeatExecutor.scheduleAtFixedRate(
this::purgeZombieSessions, 15, 15, TimeUnit.SECONDS
);
}
/**
* 注册新会话,绑定 GPU 资源引用
*/
public void register(String sessionId, GpuContext gpuContext) {
activeSessions.put(sessionId, new SessionContext(
sessionId, gpuContext, System.currentTimeMillis()
));
}
/**
* 客户端正常断开时释放 GPU 资源
*
* @return 释放的 GPU 显存量(MB)
*/
public long release(String sessionId) {
SessionContext ctx = activeSessions.remove(sessionId);
if (ctx == null) return 0;
// 释放 KV Cache 和模型上下文
long freedMemory = ctx.gpuContext.freeKVCache();
// 确保没有残留的 CUDA 内存分配
ctx.gpuContext.releaseTensorMemory();
return freedMemory;
}
/**
* 清理心跳超时的僵尸会话
* 阈值:超过 45 秒无心跳的会话视为僵尸,强制回收
*/
private void purgeZombieSessions() {
long now = System.currentTimeMillis();
long zombieThreshold = 45_000L;
activeSessions.entrySet().removeIf(entry -> {
SessionContext ctx = entry.getValue();
if (now - ctx.lastHeartbeat > zombieThreshold) {
// 强制回收 GPU 资源
ctx.gpuContext.forceRelease();
return true;
}
return false;
});
}
static class SessionContext {
final String sessionId;
final GpuContext gpuContext;
volatile long lastHeartbeat;
SessionContext(String sessionId, GpuContext gpuContext, long startTime) {
this.sessionId = sessionId;
this.gpuContext = gpuContext;
this.lastHeartbeat = startTime;
}
}
}
四、GPU 内存管理与 KV Cache 复用
KV Cache 是推理服务内存占用的主要来源。一个简单的计算:对于 LLaMA-70B 模型(FP16),每个 token 的 KV Cache 约 2.5MB(40层×2×8192维×2字节×2(K+V))。生成 2048 tokens 的序列,单个请求占用约 5GB 显存。
KV Cache 的复用策略是降低成本的核心:
Prefix Caching:当多个请求共享相同的系统提示词(System Prompt)时,只计算一次前缀的 KV Cache,后续请求直接复用。实测效果:在客服机器人场景中,System Prompt 约 500 tokens,100 并发请求下节省约 8GB 显存。
PagedAttention(vLLM 方案):将 KV Cache 分割为固定大小的 Block(如 16 tokens),允许不同请求的 Block 在不连续的物理内存中存储,通过虚拟地址映射实现逻辑连续。这种方式将显存利用率从传统方案的 20-30% 提升到约 80%。
Swapping 策略:当显存不足时,将低优先级请求的 KV Cache 换出到 CPU 内存(速度降低约 20 倍),待 GPU 空闲时再换回。这是用延迟换吞吐的经典tradeoff——但对于 P2 批处理请求,多等 2 秒是可以接受的。
sequenceDiagram
participant Client as 客户端
participant Gateway as API Gateway
participant Scheduler as 优先级调度器
participant Batcher as Continuous Batcher
participant Engine as 推理引擎(GPU)
participant CacheMgr as KV Cache 管理器
Client->>Gateway: POST /chat (SSE)
Gateway->>Scheduler: 请求入队 (含优先级)
Note over Scheduler: 老化机制检查
Scheduler->>Batcher: 出队 + Continuous Batch
Batcher->>CacheMgr: 查询 Prefix Cache
CacheMgr-->>Batcher: 命中缓存 (System Prompt)
Batcher->>Engine: 提交推理批次
activate Engine
loop Token 生成循环
Engine->>CacheMgr: 写入 KV Cache Block
Engine-->>Batcher: Token + LogProb
Batcher-->>Client: SSE: token
alt 客户端断开
Client--xBatcher: TCP RST
Batcher->>CacheMgr: 回收 KV Cache
Batcher->>Engine: 取消生成
deactivate Engine
end
end
Engine-->>Batcher: [EOS] Token
deactivate Engine
Batcher-->>Client: SSE: [DONE]
Batcher->>CacheMgr: 标记 Cache 可回收
五、总结
推理服务的高并发架构本质上是在四个维度上做tradeoff决策:
-
排队策略:FIFO简单公平但存在队头阻塞;优先级队列改善了实时体验但引入了饥饿风险(需老化机制对冲);Continuous Batching通过动态组批将GPU利用率从20-30%提升到接近80%。
-
流式输出:SSE协议在单向流场景下比WebSocket更轻量;Reactive Streams的背压机制是防止内存堆积的关键屏障;每15秒一次的心跳检测能及时发现僵尸连接。
-
客户端断连:GPU资源是推理服务的核心成本——一个断连未回收的请求可能浪费$3-5/小时的算力。基于回调+定时扫描的双重检测机制是生产环境的标配。
-
KV Cache管理:Prefix Caching在共享前缀场景下可节省数十GB显存;PagedAttention通过虚拟内存式的块管理将显存利用率提升到80%;Swapping策略用CPU内存作为GPU显存的"swap空间",实现延迟换吞吐。
这四个维度不是孤立优化的——排队策略影响批处理效率,批处理效率影响KV Cache的碎片化程度,KV Cache的碎片化又反过来限制并发数。系统性地理解它们之间的耦合关系,才能在给定的GPU预算下达到最优的吞吐量-延迟平衡。
更多推荐




所有评论(0)