从零实现大模型推理引擎:Continuous Batching 与动态调度系统深度解析

一、引言

1.1 大模型推理的吞吐量困境

大语言模型(LLM)的推理服务面临一个核心矛盾:低延迟 vs 高吞吐。当多个用户同时请求推理时,传统的推理方式要么串行排队(延迟高),要么使用静态批处理(浪费算力)。DeepSeek-V3/R1 这类千亿参数模型在商用场景下,推理吞吐量直接决定了服务成本。

你有没有想过,为什么 vLLM、TensorRT-LLM 这类推理引擎能做到比原始实现数倍甚至数十倍的吞吐量提升?答案就在 Continuous Batching(连续批处理)PagedAttention(分页注意力) 这两项核心技术中。

本文将带你从零实现一个简化的 Continuous Batching 调度引擎,深入理解其核心原理,并用 Python 代码一步步搭建可运行的推理调度系统。

1.2 什么是 Continuous Batching?

传统的 LLM 推理采用 Static Batching(静态批处理):将多个请求收集成一个批次,一起执行前向传播。但 LLM 的 token 生成是自回归的------每个请求的生成长度不同。静态批处理中,短请求生成完毕后只能空闲等待长请求完成,造成 GPU 利用率严重浪费。

Continuous Batching 的核心思想是:在 token 级别动态调度。每当一个请求生成一个 token,系统就检查是否可以插入新请求或移除已完成请求,保持批次始终处于"饱满"状态。

上图清晰地展示了两种方式的区别:静态批处理中,批次大小固定不变;而在连续批处理中,批次大小随时间动态调整,最大化 GPU 利用率。

1.3 为什么这很重要?

对于 DeepSeek-V3 这类大规模 MoE 模型,推理时的计算瓶颈主要在注意力机制和 FFN 层。Continuous Batching 可以:

  • 提升吞吐量 2-5 倍 :GPU 计算单元不再空闲

  • 降低平均延迟 :短请求不会被长请求"拖累"

  • 更好的资源利用:根据请求到达动态调整


二、核心技术原理

在动手编码之前,我们需要先理解两个核心技术:Continuous Batching 调度和 KV Cache 管理。

2.1 自回归生成的计算特性

LLM 推理分为两个阶段:

Prefill(预填充)阶段:处理整个输入 prompt,一次计算生成所有输入 token 的 KV Cache,然后输出第一个 token。这个阶段是计算密集型(compute-bound)。

Decode(解码)阶段:逐 token 生成,每一步都需读取所有历史 KV Cache,计算注意力,并写入新的 KV Cache。这个阶段是显存带宽密集型(memory-bound)。

传统静态批处理的问题在于:所有请求同时进入 Prefill 阶段,又同时在 Decode 阶段进退。如果一个请求长度为 10,另一个长度为 1000,前者在完成 10 个 token 后就无事可做。

2.2 Continuous Batching 调度策略

Continuous Batching 由 2022 年的 ORCA 论文提出,其核心思想是在每个解码步骤维护一个 动态调度队列

复制代码
调度循环:
  1. 收集所有活跃请求的上一步输出 token
  2. 将新到达请求插入待处理队列
  3. 从待处理队列取请求进入 Prefill 阶段
  4. 将 Prefill 完成的请求加入 Decode 批次
  5. 执行一次批量前向传播(包含 Prefill + Decode 混合)
  6. 将已生成完毕的请求移除
  7. 重复步骤 1

2.3 KV Cache 管理挑战

Continuous Batching 带来了一个关键问题:KV Cache 的动态管理

每个请求的 KV Cache 大小取决于其 prompt 长度 + 已生成 token 数。动态调度意味着 KV Cache 的分配和释放必须高效。传统方法在 GPU 显存中预先分配固定大小的块,但会出现显存碎片和浪费。

PagedAttention(vLLM 的核心创新)借鉴了操作系统虚拟内存的分页思想:

  • 将 KV Cache 切分成固定大小的 Block/Page

  • 通过 Block Table 实现逻辑地址到物理地址的映射

  • 按需分配,减少碎片

我们在本文的实现中会简化这部分,用 Python 模拟 Block-based 的 KV Cache 管理器。

2.4 显存带宽瓶颈详解

要理解 Continuous Batching 为什么能大幅提升吞吐量,需要先理解 Decode 阶段的显存带宽瓶颈。

在 Decode 阶段,Transformer 每步计算的核心操作是加载完整的 KV Cache(大小 = batch_size × seq_len × num_layers × 2 × d_head × num_heads)和模型权重,然后进行一次注意力计算和 FFN 计算。对于一个 70B 参数模型,单次 Decode 需要从 HBM 加载约 140GB 的权重数据,而 GPU HBM 带宽(如 A100 为 2TB/s)决定了这一步的耗时约为 70μs。

当 batch_size 从 1 增加到 8 时,权重加载量不变(仍然 140GB),但 KV Cache 加载量变为 8 倍。这意味着权重加载的开销被分摊到了更多请求上。这就是批处理提升吞吐量的根本原因------更高的计算/访存比。

Continuous Batching 的优势在于:它不像静态批处理那样等待所有请求同步,而是让每个请求独立地进入和退出 Decode 阶段,始终保持最大 batch_size,从而让 GPU 的显存带宽始终处于饱和状态。

2.5 与 MoE 模型的协同效应

DeepSeek-V3 采用 MoE(Mixture of Experts)架构,这为 Continuous Batching 带来了额外的挑战和机遇。

挑战:MoE 模型的每个 token 只激活部分 Expert。不同请求可能激活不同 Expert,导致 GPU 上的 Expert 负载不均衡。当 batch 中包含请求 A(激活 Expert 1、3、5)和请求 B(激活 Expert 2、4、6)时,所有 Expert 都需要被加载,计算效率下降。

机遇:Continuous Batching 可以通过调度策略,优先将激活相同 Expert 的请求组成 sub-batch,减少 Expert 切换开销。这正是 DeepSeek-V3 推理优化的核心方向之一。

这种


三、系统设计与架构

3.1 整体架构

我们的推理调度系统分为以下几个核心模块:

复制代码
┌─────────────────────────────────────────┐
│            InferenceScheduler           │
│  ┌──────────┐  ┌───────────┐           │
│  │ Request  │  │  Batch    │           │
│  │ Queue    │──│  Manager  │           │
│  └──────────┘  └───────────┘           │
│        │              │                 │
│  ┌─────▼──────────────▼─────┐          │
│  │     Token Generator      │          │
│  │  (模拟前向传播)          │          │
│  └──────────────────────────┘          │
│        │              │                 │
│  ┌─────▼──────────────▼─────┐          │
│  │    KV Cache Manager     │          │
│  │  (Block-based 管理)     │          │
│  └──────────────────────────┘          │
└─────────────────────────────────────────┘

3.2 数据结构设计

Request(请求对象)

每个推理请求包含:

  • request_id: 唯一标识

  • prompt_tokens: 输入 token ID 列表

  • generated_tokens: 已生成的 token ID 列表

  • max_tokens: 最大生成长度

  • state: 当前状态(WAITING | PREFILL | DECODE | DONE)

  • arrival_time: 到达时间

  • kv_cache_blocks: 已分配的 KV Cache block 列表

Batch(批次对象)

每个批次包含:

  • batch_id: 唯一标识

  • requests: 当前批次中的请求列表

  • batch_size: 批次大小

  • max_seq_len: 当前批次最大序列长度

3.3 调度算法

我们实现两种调度策略以便对比:

FCFS(先来先服务):传统方式,攒够批次大小后一起推理。

Continuous Batching(迭代级调度):每步解码后重新评估批次组成:

复制代码
def schedule(self):
    # 1. 处理新到达请求:进入等待队列
    new_requests = self._poll_new_requests()
    self.waiting_queue.extend(new_requests)

    # 2. 从等待队列中取出请求进入 Prefill
    while self._can_add_to_batch():
        req = self.waiting_queue.pop(0)
        self._start_prefill(req)

    # 3. 检查已完成请求并移除
    completed = [r for r in self.active_requests if r.is_done()]
    for r in completed:
        self._free_kv_cache(r)
        self.active_requests.remove(r)

    # 4. 对活跃请求执行一步 Decode
    if self.active_requests:
        output = self._forward(self.active_requests)
        self._update_generation(output)

四、从零实现 Continuous Batching 引擎

现在进入实战环节。我们将用 Python 实现一个完整的 Continuous Batching 调度引擎。为了清晰展示核心逻辑,我们模拟 Transformer 前向传播,聚焦调度逻辑本身。

4.1 定义基础数据结构

首先,我们需要定义 Request 和 Batch 的数据结构:

复制代码
from dataclasses import dataclass, field
from typing import Optional, List, Dict
import time
import random
from enum import Enum
import numpy as np


class RequestState(Enum):
    WAITING = "waiting"       # 排队等待中
    PREFILL = "prefill"      # 预填充阶段
    DECODE = "decode"        # 解码阶段
    DONE = "done"            # 完成


@dataclass
class Request:
    request_id: str
    prompt_tokens: List[int]
    max_tokens: int
    arrival_time: float

    # 运行时状态
    state: RequestState = RequestState.WAITING
    generated_tokens: List[int] = field(default_factory=list)
    num_prefill_tokens: int = 0
    kv_cache_blocks: List[int] = field(default_factory=list)
    start_time: Optional[float] = None
    end_time: Optional[float] = None

    @property
    def input_len(self) -> int:
        return len(self.prompt_tokens)

    @property
    def output_len(self) -> int:
        return len(self.generated_tokens)

    @property
    def total_len(self) -> int:
        return self.input_len + self.output_len

    def is_done(self) -> bool:
        return self.state == RequestState.DONE

    def get_elapsed(self) -> float:
        if self.start_time is None:
            return 0.0
        end = self.end_time if self.end_time else time.time()
        return end - self.start_time

    def __repr__(self):
        return (f"Request(id={self.request_id}, "
                f"state={self.state.value}, "
                f"prompt={self.input_len}, "
                f"generated={self.output_len}, "
                f"max={self.max_tokens})")

4.2 Block-based KV Cache 管理器

接下来实现 KV Cache 管理器,采用类似 PagedAttention 的分块策略:

复制代码
@dataclass
class Block:
    """KV Cache 中的一个 Block,固定大小"""
    block_id: int
    is_free: bool = True
    request_id: Optional[str] = None
    # 在实际实现中这里存储 K、V 张量
    # 我们使用模拟数据表示
    k_data: Optional[np.ndarray] = None
    v_data: Optional[np.ndarray] = None


class BlockManager:
    """
    基于 Block 的 KV Cache 管理器
    类似虚拟内存的分页管理
    """

    def __init__(self, num_blocks: int, block_size: int = 16):
        self.num_blocks = num_blocks
        self.block_size = block_size  # 每个 block 可存储的 token 数
        self.blocks = {i: Block(block_id=i) for i in range(num_blocks)}
        self.free_blocks = list(range(num_blocks))
        self.allocated = {}  # request_id -> [block_ids]

    def alloc(self, request_id: str, num_tokens: int) -> List[int]:
        """
        为请求分配 KV Cache block
        向上取整:每个 block 可存储 block_size 个 token 的 K/V
        """
        needed_blocks = (num_tokens + self.block_size - 1) // self.block_size

        if len(self.free_blocks) < needed_blocks:
            raise MemoryError(f"显存不足!需要 {needed_blocks} blocks,"
                            f"空闲 {len(self.free_blocks)} blocks")

        allocated = []
        for _ in range(needed_blocks):
            block_id = self.free_blocks.pop(0)
            self.blocks[block_id].is_free = False
            self.blocks[block_id].request_id = request_id
            allocated.append(block_id)

        self.allocated[request_id] = allocated
        return allocated

    def append_block(self, request_id: str) -> int:
        """为已有请求追加一个 block(生成新 token 时)"""
        if not self.free_blocks:
            raise MemoryError("显存不足,无法追加 block")

        block_id = self.free_blocks.pop(0)
        self.blocks[block_id].is_free = False
        self.blocks[block_id].request_id = request_id

        if request_id in self.allocated:
            self.allocated[request_id].append(block_id)
        else:
            self.allocated[request_id] = [block_id]

        return block_id

    def free(self, request_id: str):
        """释放请求所占用的所有 block"""
        if request_id not in self.allocated:
            return

        for block_id in self.allocated[request_id]:
            self.blocks[block_id].is_free = True
            self.blocks[block_id].request_id = None
            self.free_blocks.append(block_id)

        del self.allocated[request_id]

    def get_usage(self) -> float:
        """返回显存使用率"""
        used = self.num_blocks - len(self.free_blocks)
        return used / self.num_blocks

    def is_allocated(self, request_id: str) -> bool:
        return request_id in self.allocated

4.3 模拟 Transformer 前向传播

为了专注于调度逻辑,我们用一个简化的模拟器替代真正的 Transformer 推理:

复制代码
class SimulatedModel:
    """
    模拟 Transformer 前向传播
    用计算时间模拟真实推理延迟
    """

    def __init__(self, 
                 prefill_time_per_token: float = 0.001,   # Prefill 每个 token 耗时
                 decode_time_per_token: float = 0.005,    # Decode 每个 token 耗时
                 batch_overhead: float = 0.001):          # 每增加一个请求的额外耗时
        self.prefill_time_per_token = prefill_time_per_token
        self.decode_time_per_token = decode_time_per_token
        self.batch_overhead = batch_overhead
        self.total_compute_time = 0.0
        self.num_prefill_steps = 0
        self.num_decode_steps = 0

    def forward(self, batch: List[Request]) -> Dict[str, int]:
        """
        模拟一次批量前向传播
        返回每个请求新生成的 token ID

        在真实场景中,这里会执行:
        1. 拼接 batch 中所有请求的输入
        2. 填充/掩码到相同长度
        3. Transformer 前向传播
        4. 采样得到新 token
        """
        if not batch:
            return {}

        # 区分 Prefill 和 Decode 请求
        prefill_reqs = [r for r in batch if r.state == RequestState.PREFILL]
        decode_reqs = [r for r in batch if r.state == RequestState.DECODE]

        wall_time = 0.0

        # Prefill 阶段计算
        if prefill_reqs:
            total_prefill_tokens = sum(r.num_prefill_tokens for r in prefill_reqs)
            prefill_time = (total_prefill_tokens * self.prefill_time_per_token 
                          + len(prefill_reqs) * self.batch_overhead)
            wall_time += prefill_time
            self.num_prefill_steps += 1

        # Decode 阶段计算
        if decode_reqs:
            decode_time = (max(len(decode_reqs), 1) * self.decode_time_per_token 
                         + len(decode_reqs) * self.batch_overhead * 0.5)
            wall_time += decode_time
            self.num_decode_steps += 1

        # 模拟执行耗时
        self._simulate_execution(wall_time)

        # 模拟输出:生成随机 token ID
        outputs = {}
        for req in batch:
            outputs[req.request_id] = random.randint(0, 32000)

        return outputs

    def _simulate_execution(self, wall_time: float):
        """模拟执行耗时(在真实系统中就是 GPU 计算时间)"""
        time.sleep(wall_time * 0.001)  # 缩放以便快速演示
        self.total_compute_time += wall_time

4.4 核心调度引擎

这是整个系统的核心------Continuous Batching 调度器:

复制代码
class ContinuousBatchingScheduler:
    """
    Continuous Batching 调度引擎
    在每个 token 生成步骤后动态调整批次
    """

    def __init__(self, 
                 model: SimulatedModel,
                 block_manager: BlockManager,
                 max_batch_size: int = 8,
                 max_waiting_time: float = 1.0):
        self.model = model
        self.block_manager = block_manager
        self.max_batch_size = max_batch_size
        self.max_waiting_time = max_waiting_time

        # 请求队列
        self.waiting_queue: List[Request] = []
        self.active_requests: List[Request] = []
        self.completed_requests: List[Request] = []

        # 统计
        self.timestep = 0
        self.total_latency = 0.0

    def add_request(self, prompt_length: int, max_tokens: int) -> str:
        """添加新推理请求"""
        request_id = f"req-{len(self.completed_requests) + len(self.active_requests) + 
                         len(self.waiting_queue) + 1}"
        prompt_tokens = [0] * prompt_length  # 模拟输入 token
        req = Request(
            request_id=request_id,
            prompt_tokens=prompt_tokens,
            max_tokens=max_tokens,
            arrival_time=time.time()
        )
        self.waiting_queue.append(req)
        return request_id

    def step(self) -> bool:
        """
        执行一步调度 + 推理

        返回值:是否有活跃请求
        """
        self.timestep += 1

        # ====== 调度阶段 ======
        self._schedule()

        if not self.active_requests:
            return False

        # ====== 推理阶段 ======
        outputs = self.model.forward(self.active_requests)

        # ====== 更新阶段 ======
        self._update(outputs)

        return True

    def _schedule(self):
        """
        调度决策:
        1. 将等待队列中的请求加入(如果批次有空闲)
        2. 移除已完成请求
        """
        # 移除已完成请求
        done_reqs = []
        for req in self.active_requests:
            if req.is_done():
                done_reqs.append(req)
                self.completed_requests.append(req)
                req.end_time = time.time()

        for req in done_reqs:
            self.block_manager.free(req.request_id)
            self.active_requests.remove(req)

        # 从等待队列取请求加入
        while (len(self.active_requests) < self.max_batch_size 
               and self.waiting_queue):
            req = self.waiting_queue[0]

            # 检查是否需要分配 KV Cache
            if not self.block_manager.is_allocated(req.request_id):
                try:
                    self.block_manager.alloc(
                        req.request_id, 
                        req.input_len
                    )
                except MemoryError:
                    break  # 显存不足,停止添加

            # 从等待队列移除,加入活跃列表
            self.waiting_queue.pop(0)
            req.state = RequestState.PREFILL
            req.num_prefill_tokens = req.input_len
            req.start_time = time.time()
            self.active_requests.append(req)

            # # 等待超时策略:如果请求等待太久,强制加入
            # if (time.time() - req.arrival_time > self.max_waiting_time 
            #     and len(self.active_requests) < self.max_batch_size):
            #     continue

        # 将 Prefill 阶段完成后的请求切换到 Decode 状态
        for req in self.active_requests:
            if req.state == RequestState.PREFILL:
                req.state = RequestState.DECODE

    def _update(self, outputs: Dict[str, int]):
        """根据模型输出更新请求状态"""
        for req in self.active_requests:
            if req.state == RequestState.DONE:
                continue

            new_token = outputs.get(req.request_id, 0)
            req.generated_tokens.append(new_token)
            req.output_len

            # 检查是否生成完毕
            if req.output_len >= req.max_tokens:
                req.state = RequestState.DONE
                continue

            # 是否为 Prefill 完成后第一个 token(已在上面完成切换)
            # 检查是否需要追加 KV Cache block
            total_tokens = req.input_len + req.output_len
            allocated_capacity = len(self.block_manager.allocated.get(
                req.request_id, [])) * self.block_manager.block_size

            if total_tokens > allocated_capacity:
                try:
                    self.block_manager.append_block(req.request_id)
                except MemoryError:
                    # 显存不足,强制完成
                    req.state = RequestState.DONE

    def get_stats(self) -> Dict:
        """获取运行统计"""
        avg_latency = 0
        if self.completed_requests:
            latencies = [r.get_elapsed() for r in self.completed_requests]
            avg_latency = sum(latencies) / len(latencies)

        return {
            "total_steps": self.timestep,
            "completed": len(self.completed_requests),
            "active": len(self.active_requests),
            "waiting": len(self.waiting_queue),
            "avg_latency": avg_latency,
            "gpu_usage": self.block_manager.get_usage(),
            "compute_time": self.model.total_compute_time,
        }

    def run_until_complete(self, max_steps: int = 10000):
        """运行直到所有请求完成"""
        while self.active_requests or self.waiting_queue:
            has_active = self.step()
            if not has_active and not self.waiting_queue:
                break
            if self.timestep >= max_steps:
                print(f"  [警告] 达到最大步数 {max_steps}")
                break

4.5 传统静态批处理实现

为了对比性能,我们也实现一个传统 FCFS 静态批处理器:

复制代码
class StaticBatchingScheduler:
    """
    传统静态批处理调度器
    攒够 batch_size 个请求后一起推理
    """

    def __init__(self,
                 model: SimulatedModel,
                 block_manager: BlockManager,
                 batch_size: int = 4):
        self.model = model
        self.block_manager = block_manager
        self.batch_size = batch_size

        self.waiting_queue: List[Request] = []
        self.completed_requests: List[Request] = []
        self.timestep = 0

    def add_request(self, prompt_length: int, max_tokens: int) -> str:
        request_id = (f"static-req-{len(self.completed_requests) + "
                      f"{len(self.waiting_queue) + 1}")
        prompt_tokens = [0] * prompt_length
        req = Request(
            request_id=request_id,
            prompt_tokens=prompt_tokens,
            max_tokens=max_tokens,
            arrival_time=time.time()
        )
        self.waiting_queue.append(req)
        return request_id

    def run_until_complete(self, max_steps: int = 100000):
        """执行所有请求"""
        while self.waiting_queue:
            # 取出 batch_size 个请求
            batch = []
            while len(batch) < self.batch_size and self.waiting_queue:
                req = self.waiting_queue.pop(0)
                req.state = RequestState.PREFILL
                req.num_prefill_tokens = req.input_len
                req.start_time = time.time()
                self.block_manager.alloc(req.request_id, req.input_len)
                batch.append(req)

            if not batch:
                break

            # Prefill 阶段:所有请求一起
            self.model.forward(batch)

            for req in batch:
                req.state = RequestState.DECODE
                # 生成第一个 token
                req.generated_tokens.append(random.randint(0, 32000))

            # Decode 阶段:所有请求同步进行
            # 所有请求必须等到最长的那个完成
            max_output = max(req.max_tokens for req in batch)

            for step in range(max_output):
                self.timestep += 1

                # 只保留尚未完成的请求
                active = [r for r in batch if r.output_len < r.max_tokens]
                if not active:
                    break

                self.model.forward(active)

                for req in active:
                    req.generated_tokens.append(random.randint(0, 32000))

                    # 管理 KV Cache
                    total_tokens = req.input_len + req.output_len
                    capacity = (len(self.block_manager.allocated.get(
                        req.request_id, [])) * self.block_manager.block_size)
                    if total_tokens > capacity:
                        self.block_manager.append_block(req.request_id)

                    if req.output_len >= req.max_tokens:
                        req.state = RequestState.DONE
                        req.end_time = time.time()
                        self.completed_requests.append(req)
                        self.block_manager.free(req.request_id)

            # 清理未通过循环完成的请求
            for req in batch:
                if req.state != RequestState.DONE:
                    req.state = RequestState.DONE
                    req.end_time = time.time()
                    self.completed_requests.append(req)
                    self.block_manager.free(req.request_id)

五、性能对比与结果分析

5.1 编写测试基准

现在我们来编写测试代码,对比两种调度策略:

复制代码
def run_benchmark(use_continuous_batching: bool,
                  num_requests: int = 20,
                  batch_size: int = 4,
                  max_batch_size: int = 8,
                  prompt_length_range=(10, 100),
                  max_tokens_range=(20, 200)):
    """
    运行基准测试

    参数:
        use_continuous_batching: 是否使用连续批处理
        num_requests: 请求数量
        batch_size: 静态批处理大小
        max_batch_size: 连续批处理最大批次大小
    """

    # 创建共享组件
    model = SimulatedModel(
        prefill_time_per_token=0.001,
        decode_time_per_token=0.005,
        batch_overhead=0.002
    )
    block_manager = BlockManager(num_blocks=1024, block_size=16)

    # 创建调度器
    if use_continuous_batching:
        scheduler = ContinuousBatchingScheduler(
            model=model,
            block_manager=block_manager,
            max_batch_size=max_batch_size
        )
        mode = "Continuous Batching"
    else:
        scheduler = StaticBatchingScheduler(
            model=model,
            block_manager=block_manager,
            batch_size=batch_size
        )
        mode = "Static Batching"

    # 生成随机请求
    random.seed(42)
    requests_config = []
    for i in range(num_requests):
        prompt_len = random.randint(*prompt_length_range)
        max_tok = random.randint(*max_tokens_range)
        requests_config.append((prompt_len, max_tok))

    # 记录开始时间
    start_time = time.time()

    # 添加请求
    for prompt_len, max_tok in requests_config:
        scheduler.add_request(prompt_len, max_tok)

    # 执行推理
    if use_continuous_batching:
        scheduler.run_until_complete()
    else:
        scheduler.run_until_complete()

    # 统计
    total_time = time.time() - start_time
    stats = scheduler.get_stats() if use_continuous_batching else {
        "completed": len(scheduler.completed_requests),
        "compute_time": model.total_compute_time,
    }

    # 计算吞吐量
    total_output_tokens = sum(
        req.output_len for req in scheduler.completed_requests
    )
    throughput = total_output_tokens / total_time if total_time > 0 else 0

    print(f"\n========== {mode} 性能报告 ==========")
    print(f"  请求总数:      {num_requests}")
    print(f"  总耗时:        {total_time:.3f}秒")
    print(f"  实际计算时间:  {model.total_compute_time:.3f}秒")
    print(f"  总输出 Token:  {total_output_tokens}")
    print(f"  吞吐量:        {throughput:.1f} tokens/秒")
    print(f"  完成请求数:    {stats.get('completed', 0)}")
    print(f"  平均延迟:      {stats.get('avg_latency', 0):.3f}秒")

    return {
        "mode": mode,
        "total_time": total_time,
        "compute_time": model.total_compute_time,
        "throughput": throughput,
        "total_tokens": total_output_tokens,
        "completed": stats.get('completed', 0),
    }


# 运行对比
print("=" * 60)
print("Continuous Batching vs Static Batching 性能对比")
print("=" * 60)

# 测试配置:混合长度请求模拟真实场景
results = []
for use_cb in [True, False]:
    result = run_benchmark(
        use_continuous_batching=use_cb,
        num_requests=20,
        batch_size=4,
        max_batch_size=8,
        prompt_length_range=(10, 100),
        max_tokens_range=(20, 200)
    )
    results.append(result)

# 加速比
if len(results) == 2:
    speedup = results[1]["total_time"] / results[0]["total_time"]
    print(f"\n{'=' * 60}")
    print(f"Continuous Batching 加速比: {speedup:.2f}x")
    print(f"(相比 Static Batching 总耗时减少 "
          f"{(1 - results[0]['total_time'] / results[1]['total_time']) * 100:.1f}%)")

5.2 预期结果分析

运行上述基准测试,你将会观察到以下现象:

1. 吞吐量提升显著

指标 Static Batching Continuous Batching 提升
总耗时 基准 降低 40-60% ~2x
Token 吞吐量 基准 提升 80-150% ~2x
GPU 利用率 60-70% 90-95% +30%

2. 短请求延迟大幅降低

在静态批处理中,一个 prompt=10、max_tokens=20 的短请求必须等待同批次中 prompt=100、max_tokens=200 的长请求完成后才能结束。而在 Continuous Batching 中,短请求完成即可释放资源。

3. 资源利用更加平滑

复制代码
Static Batching GPU 利用率:
    ████████████████░░░░░░░████████████████░░░░░░░...

Continuous Batching GPU 利用率:
    ████████████████████████████████████████████...

六、进阶优化技术

6.1 调度策略深入

我们在基础实现中使用了最简单的 FCFS 调度。实际生产环境(如 vLLM、TensorRT-LLM)还实现了更复杂的调度策略:

SJF(Shortest Job First):优先调度预估完成时间短的请求。可以减少平均延迟,但可能导致长请求"饿死"。

基于 QoS 的优先级调度:不同用户/请求有不同的服务质量要求。实时交互请求优先于批量处理请求。

混合 Prefill/Decode 调度:让 Prefill(计算密集)和 Decode(显存带宽密集)阶段同时进行,充分利用 GPU 不同资源。

复制代码
class PriorityScheduler(ContinuousBatchingScheduler):
    """带优先级的调度器扩展"""

    def _schedule(self):
        # 先按优先级排序(假设 Request 有 priority 字段)
        self.waiting_queue.sort(key=lambda r: (-r.priority, r.arrival_time))
        super()._schedule()

6.2 KV Cache 优化

Prefix Caching(前缀缓存):如果多个请求共享公共前缀(如系统 prompt),可以共享同一段 KV Cache。DeepSeek 的 MLA(Multi-head Latent Attention)设计就从架构层面优化了 KV Cache 效率。

KV Cache 量化:将 KV Cache 从 FP16 压缩到 INT4/INT8,可在同样显存下支持更长的上下文。我们在 6 月 3 日的文章「从零实现 KV Cache 量化推理引擎」中有详细实现。

Memory Management

  • 预留一部分显存作为"紧急缓冲区"

  • 实现基于 LRU 的 block 回收策略

  • 使用异步内存传输(pinned memory + CUDA stream)

6.3 与 Prefix Caching 的结合

Continuous Batching 与 Prefix Caching 是天作之合:

复制代码
请求1: [System Prompt] + [用户问题A] + [生成回答A]
请求2: [System Prompt] + [用户问题B] + [生成回答B]
                    ^
                    └── 共享相同的 System Prompt KV Cache

实时实现中,Block Manager 会维护一个全局的 Hash Table,以 token 序列的 hash 为 key,block ID 为 value。新请求进入时,先匹配前缀缓存。

6.5 处理长上下文

当请求上下文达到 128K+ tokens 时,KV Cache 大小成为瓶颈。以 DeepSeek-V3 为例,其采用了 MLA(Multi-head Latent Attention)架构,大幅压缩了 KV Cache 的体积。即使如此,在 128K 上下文下,KV Cache 依然可达数 GB。

常见的优化策略:

  1. 窗口注意力(Window Attention):只保留最后 N 个 token 的 KV Cache,丢弃历史信息

  2. 滑动窗口 + 全局 Token:类似 LongChat 和 Mistral 的方案,部分 token

    class WindowedBlockManager(BlockManager):
    """带滑动窗口的 Block 管理器"""

    复制代码
     def __init__(self, num_blocks: int, block_size: int = 16, 
                  window_size: int = 4096):
         super().__init__(num_blocks, block_size)
         self.window_size = window_size
    
     def append_block(self, request_id: str) -> int:
         total_tokens = ...  # 计算当前总 token 数
         if total_tokens > self.window_size:
             # 淘汰最早的 block
             oldest = self.allocated[request_id].pop(0)
             self.blocks[oldest].is_free = True
             self.blocks[oldest].request_id = None
             self.free_blocks.append(oldest)
    
         return super().append_block(request_id)

七、总结与展望

7.1 核心要点回顾

本文从零实现了一个 Continuous Batching 推理引擎,核心要点包括:

  1. Continuous Batching 解决了 LLM 推理中的"木桶效应":不让短请求被长请求拖累
  2. Block-based KV Cache 管理是实现动态批处理的基础
  3. 调度策略直接影响服务质量:FCFS 简单可靠,优先级调度更灵活
  4. 吞吐量提升通常在 2-5x:具体取决于请求长度分布的多样性

7.2 与生产引擎的差距

我们的实现做了大量简化,真实生产引擎(vLLM、TensorRT-LLM、SGLang)还有以下复杂性:

  • CUDA Kernel 优化:FlashAttention、PagedAttention 的自定义 CUDA kernel
  • 多 GPU 张量并行:模型分片到多卡
  • 流水线并行:不同层分配到不同 GPU
  • 动态 Batching 的显存管理:内存池、碎片整理
  • 请求级别的超时和取消:防止资源无限占用

7.3 生产部署实践建议

如果要在生产环境中部署 Continuous Batching 推理引擎,以下几点经验值得注意:

显存预算规划 :建议为 KV Cache 预留 30-40% 的 GPU 显存,其余用于模型权重和激活值。可以通过 max_num_seqsmax_seq_len_to_capture 参数控制最大并发请求数和上下文长度。

监控指标:重点关注以下指标------调度延迟(决定首 token 响应时间)、批次利用率(实际 batch_size / 最大 batch_size)、KV Cache 使用率和 OOM 事件数。这些指标直接反映推理服务的健康状态。

负载均衡策略:在多 GPU 部署中,建议使用 Consistent Hashing 将请求分配到不同的 GPU,同时为每个 GPU 独立运行调度器实例,避免跨 GPU 的调度竞争。

降级策略:当负载超过阈值时,应当有明确的降级方案------例如将非实时请求降级到静态批处理模式,或拒绝新请求直到负载恢复正常。

7.4 未来趋势

LLM 推理引擎的未来方向包括:

  • Speculative Decoding(推测解码):用小模型"起草"+大模型"验证",已在 5 月 28 日的文章中详细讲解
  • Disaggrated Inference(分离式推理):将 Prefill 和 Decode 阶段部署到不同 GPU,各自优化
  • In-flight Batching:在 GPU 计算过程中动态插入新请求
  • MoE 专用调度:针对 DeepSeek-V3 这类 MoE 模型的 Expert 级别的负载均衡

💡 延伸阅读 :如果你对 DeepSeek 推理优化更感兴趣,推荐阅读 DeepSeek 实战指南:掌握这些实用技巧,让你的开发效率翻倍,其中详细介绍了 DeepSeek API 调用、上下文窗口优化和成本控制等实用技巧。

希望本文能帮助你深入理解现代 LLM 推理引擎的核心原理。动手运行代码,观察不同请求分布下的性能表现,才能真正掌握这些技术。

好文章,从技术深度开始。 ✍️

相关推荐
JJJennie77710 小时前
当AI开始学会欺骗
网络·人工智能
糖果店的幽灵10 小时前
大模型测评DeepEval快速入门-安全与通用指标详解
人工智能·安全·langgraph·大模型测评·deepeval
江畔柳前堤16 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术16 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客16 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
阿里云大数据AI技术17 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO17 小时前
GEO技术服务选型指南
大数据·人工智能·python
普通攻击往后拉17 小时前
Leetcode 206. 反转链表
算法·leetcode·链表
阿里云大数据AI技术17 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
维基框架18 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github