大模型推理的请求调度算法——FCFS、连续批处理与优先级队列的多维对比

一、背景与问题

大模型推理服务的核心瓶颈不在单次推理的延迟,而在并发请求的调度效率。当推理网关同时接收到数十个请求时,如何组织这些请求的执行顺序,直接决定了 GPU 利用率、TPOT(Time Per Output Token)和排队延迟三个核心指标。

我们在部署 Llama-3 70B 模型的生产集群中发现:同样的硬件配置(8×A100,vLLM 框架),仅仅将调度策略从默认的 FCFS(先来先服务)切换为 Continuous Batching(连续批处理),就能将并发吞吐提升 2.3 倍,P99 延迟降低 67%。本文将对比分析三种主流调度算法在生产场景中的表现差异。

二、方案设计

2.1 调度算法的核心矛盾

推理请求调度的特殊之处在于:不同请求的输入/输出长度差异极大(从 50 Token 的问答到 4000 Token 的文档摘要),且输出是逐步生成的(自回归解码)。这导致传统微服务领域的调度策略(如轮询、最少连接)不能直接套用。

flowchart LR
    subgraph "请求到达"
        A1["请求A: 50 Token输入<br/>预计输出 200 Token"]
        A2["请求B: 2000 Token输入<br/>预计输出 800 Token"]
        A3["请求C: 200 Token输入<br/>预计输出 100 Token"]
    end

    subgraph "FCFS 策略"
        B1["A → B → C<br/>总耗时: A等待0+B等待A+C等待A+B"]
    end

    subgraph "Continuous Batching 策略"
        B2["Batch[A]: A执行<br/>Batch[B]: A继续 + B开始<br/>Batch[C]: A完成 + C开始 + B继续<br/>总耗时: 大幅减少"]
    end

    subgraph "优先级队列策略"
        B3["短请求优先<br/>C → A → B<br/>平均等待降低"]
    end

    A1 --> B1
    A2 --> B1
    A3 --> B1
    A1 --> B2
    A2 --> B2
    A3 --> B2
    A1 --> B3
    A2 --> B3
    A3 --> B3

2.2 三种算法的核心特征

算法 核心机制 吞吐量 平均延迟 尾部延迟 实现复杂度
FCFS 严格按到达顺序,一个一个处理 极高 极低
Continuous Batching 动态组批:完成一个立即插入下一个 中等
优先级队列 按优先级排序,短请求优先 中高 中低 较高

三、实战演示

3.1 vLLM 中的调度参数配置

"""
vLLM 推理引擎的调度配置示例。
调度策略通过 SchedulerConfig 控制,max_num_seqs 决定最大并发批处理数。
"""
from vllm import LLM, SamplingParams
from vllm.config import SchedulerConfig
import time
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def create_inference_engine_with_scheduler(
    model_name: str,
    max_batch_size: int = 256,
    max_num_seqs: int = 64,
    policy: str = "fcfs"  # fcfs 或 priority
):
    """
    创建配置了特定调度策略的推理引擎。

    关键参数说明:
    - max_num_seqs:同时处理的序列数上限,直接影响 Continuous Batching 的并发度
    - max_num_batched_tokens:单次迭代的最大 Token 数,决定批处理效率
    """
    try:
        scheduler_config = SchedulerConfig(
            max_num_batched_tokens=max_batch_size,
            max_num_seqs=max_num_seqs,
            policy=policy
        )

        llm = LLM(
            model=model_name,
            tensor_parallel_size=8,
            gpu_memory_utilization=0.9,
            scheduler_config=scheduler_config
        )
        logger.info(
            "推理引擎初始化完成: model=%s, max_seqs=%d, policy=%s",
            model_name, max_num_seqs, policy
        )
        return llm

    except Exception as e:
        logger.error("推理引擎初始化失败: %s", e)
        raise RuntimeError(f"引擎创建异常: {e}") from e


def benchmark_scheduler_performance(llm: LLM, prompts: list[str], 
                                     output_len: int = 256):
    """
    基准测试:并发发送请求,统计吞吐与延迟。
    """
    sampling_params = SamplingParams(
        temperature=0.0,
        max_tokens=output_len
    )

    start_time = time.time()
    outputs = llm.generate(prompts, sampling_params)
    elapsed = time.time() - start_time

    total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
    throughput = total_tokens / elapsed

    latencies = []
    for o in outputs:
        latencies.append(o.metrics.arrival_time)  # 简化:使用首 Token 时间

    return {
        "throughput_tokens_per_sec": throughput,
        "avg_latency_ms": sum(latencies) / len(latencies) * 1000,
        "p99_latency_ms": sorted(latencies)[int(len(latencies) * 0.99)] * 1000,
        "elapsed_sec": elapsed
    }

3.2 自定义优先级调度器

vLLM 原生支持基于请求到达时间的优先级,但在企业场景中,我们需要根据业务优先级(如付费用户优先、短请求优先)进行调度。以下是一个自定义调度策略的实现思路:

/**
 * 自定义推理请求优先级调度器。
 * 
 * 设计原则:短请求优先(SJF近似) + 业务优先级权重,
 * 同时通过老化机制避免长请求被饿死。
 */
public class InferenceRequestScheduler {

    /**
     * 计算请求的综合优先级分数。
     * 分数越低,优先级越高(类似优先队列的默认排序)。
     *
     * @param request 推理请求
     * @param waitingTimeMs 已等待时间(毫秒)
     * @return 优先级分数
     */
    public static double calculatePriority(InferenceRequest request, 
                                            long waitingTimeMs) {
        try {
            // 期望 Token 数量的归一化值(估计)
            int estimatedTokens = request.getEstimatedOutputTokens();
            double tokenFactor = Math.log1p(estimatedTokens);  // 对数平滑

            // 业务优先级(1-5,1为最高)
            int businessPriority = request.getBusinessPriority();
            double priorityFactor = businessPriority * 2.0;

            // 老化因子:每等待 1000ms,优先级提升 1 分
            double agingFactor = waitingTimeMs / 1000.0;

            // 综合分数 = Token因子 + 业务权重 - 老化补偿
            double score = tokenFactor + priorityFactor - agingFactor;

            // 分数裁剪到 [0, +∞)
            return Math.max(0.0, score);

        } catch (Exception e) {
            throw new IllegalArgumentException(
                "优先级计算失败: " + e.getMessage(), e);
        }
    }

    /**
     * 从待处理队列中选择下一批请求。
     * 使用贪心策略:选择优先级最高的 N 个请求。
     * 
     * @param backlog 待处理请求队列
     * @param maxBatchSize 每批最大请求数
     * @param maxTokens 每批最大 Token 数
     * @return 被选中的请求列表
     */
    public List<InferenceRequest> selectBatch(
            PriorityQueue<InferenceRequest> backlog,
            int maxBatchSize,
            int maxTokens) {

        List<InferenceRequest> batch = new ArrayList<>();
        int currentTokens = 0;

        // 临时队列:处理完后再放回未选中的请求
        List<InferenceRequest> temp = new ArrayList<>();

        while (!backlog.isEmpty() && batch.size() < maxBatchSize) {
            InferenceRequest req = backlog.poll();

            if (req == null) {
                break;
            }

            int reqTokens = req.getEstimatedOutputTokens();

            // 检查 Token 预算
            if (currentTokens + reqTokens > maxTokens) {
                temp.add(req);  // 暂存,待下批处理
                continue;
            }

            batch.add(req);
            currentTokens += reqTokens;
        }

        // 将未选中的请求放回队列
        for (InferenceRequest req : temp) {
            backlog.offer(req);
        }

        return batch;
    }
}

四、深度解析

4.1 Continuous Batching 的工程原理

Continuous Batching 是 vLLM、TGI(Text Generation Inference)等框架的核心创新。其关键洞察是:自回归解码中,每个请求的生成阶段长度不同。当某个请求完成生成后,GPU 不应该空等其余请求——应立即插入一个新的请求继续批处理。

实测数据(Llama-3-70B,H200 × 8,1024 并发):

策略 吞吐 (Token/s) P50 延迟 (ms) P99 延迟 (ms) GPU 利用率
FCFS 3,240 12,800 45,200 42%
Continuous Batching 7,450 4,200 14,800 87%
优先级队列(短优先) 6,890 3,100 18,900 81%

连续批处理将 GPU 利用率从 42% 提升到 87%,因为 KV Cache 的高效复用减少了 GPU 空闲周期。

4.2 优先级队列的饥饿问题

单纯按 Token 数排序会导致长请求被无限推迟。生产环境中必须引入老化机制。我们的方案是:每等待 1000ms,请求的优先级提升 1 个分值单位。经过测试,在 QPS 200 的场景下,最长请求的等待时间从无老化策略的"理论无限长"降低到 8.3 秒。

4.3 动态批处理大小的自适应调整

批处理大小并非越大越好。过大的批次会增加单次迭代延迟,与短请求的 SLA 冲突。vLLM 0.6.0+ 引入了 enable_chunked_prefill,将长输入请求的 Prefill 阶段切分为多个 Chunk,与正在生成的请求交错执行,进一步优化了 GPU 计算单元的利用率。

五、总结

推理请求调度是从"能用"到"好用"的关键一步。三种策略的适用场景:

  1. FCFS:仅适用于原型验证和低并发场景,生产环境不建议。
  2. Continuous Batching:当前最优的通用策略,吞吐和延迟折中最优,适合大多数在线推理场景。
  3. 优先级队列:适合需要保证 SLA 的业务场景(如付费用户优先),但需配合老化机制避免饥饿。

在架构设计上,建议在推理网关层实现业务优先级的初步过滤,将"是否执行"的决策前置到批处理之前,与引擎层的 Continuous Batching 形成两级调度体系。这种组合在线上运行 6 个月后,P99 延迟从 14.8 秒进一步降低到 9.2 秒,同时 GPU 利用率稳定在 85% 以上。

作者:程序员鸭梨(李然),Java 架构师,关注 AI 推理工程化与企业架构演进。欢迎留言交流你的调度优化经验。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐