大模型推理服务的高并发架构:从请求排队到异步流式输出的全链路优化

推理服务不是简单的"模型+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的 onBackpressureBufferonBackpressureDrop 策略。对于推理场景,推荐 onBackpressureLatest:如果下游消费慢,丢弃中间token只保留最新的。原因在于:推理是token序列,跳过中间某些token后继续发送后续token对客户端毫无意义。正确的做法是当缓冲区满时触发降级——从流式模式切换为批量返回模式。

客户端断连处理在GPU资源昂贵的场景下至关重要。一个A100 GPU的推理实例成本约$3-5/小时,如果客户端断连后不回收,会导致资源泄漏。检测机制有两种:

  1. SSE心跳检测:每15秒发送 : heartbeat\n\n(SSE注释格式,不触发data事件),连续3次无响应视为断连
  2. 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决策:

  1. 排队策略:FIFO简单公平但存在队头阻塞;优先级队列改善了实时体验但引入了饥饿风险(需老化机制对冲);Continuous Batching通过动态组批将GPU利用率从20-30%提升到接近80%。

  2. 流式输出:SSE协议在单向流场景下比WebSocket更轻量;Reactive Streams的背压机制是防止内存堆积的关键屏障;每15秒一次的心跳检测能及时发现僵尸连接。

  3. 客户端断连:GPU资源是推理服务的核心成本——一个断连未回收的请求可能浪费$3-5/小时的算力。基于回调+定时扫描的双重检测机制是生产环境的标配。

  4. KV Cache管理:Prefix Caching在共享前缀场景下可节省数十GB显存;PagedAttention通过虚拟内存式的块管理将显存利用率提升到80%;Swapping策略用CPU内存作为GPU显存的"swap空间",实现延迟换吞吐。

这四个维度不是孤立优化的——排队策略影响批处理效率,批处理效率影响KV Cache的碎片化程度,KV Cache的碎片化又反过来限制并发数。系统性地理解它们之间的耦合关系,才能在给定的GPU预算下达到最优的吞吐量-延迟平衡。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐