核心概念与架构:推理引擎的通用架构、请求生命周期、批处理策略

聊推理引擎,我习惯先画一张大图。你想想看,一个请求从用户手里发出来,到模型吐回结果,中间到底经历了什么?这背后就是推理引擎的通用架构在兜底。

一句话总结:推理引擎就是个“翻译官+调度员+执行者”的组合体。它把上层框架的请求,翻译成底层硬件能跑的命令,再高效地调度起来。

用户请求 调度层(Scheduler) 动态批处理 / 连续批处理 / 请求排队 执行层(Executor) 模型加载 / 内存管理 / 算子调度 GPU / CPU / NPU 硬件执行 推理结果 请求生命周期

通用架构:三层分离

我个人习惯把推理引擎拆成三层:前端接口层、调度层、执行层。这三层各司其职,互不干扰。

  • 前端接口层:负责接收各种框架的请求。比如 PyTorch 的 TorchScript、TensorFlow 的 SavedModel、ONNX 格式。说白了,就是做“格式统一”。
  • 调度层:这是引擎的“大脑”。它决定请求什么时候执行、怎么组合、要不要排队。我见过不少项目,调度层写得太简单,结果 GPU 利用率惨不忍睹。
  • 执行层:真正干活的地方。加载模型、分配显存、调用 CUDA 核函数。这里最容易出性能瓶颈。

我的经验:刚开始做推理引擎时,我总想把三层揉在一起写。后来发现,一旦要支持多模型、多硬件,耦合的代码根本改不动。三层分离,后期维护成本能降一半。

请求生命周期:一个请求的完整旅程

一个请求从生到死,大概走这么几步。我习惯用“五阶段”来记:

  1. 接收与解析:引擎拿到请求,解析出模型名、输入数据、超参数(比如 max_tokens、temperature)。
  2. 预处理:Tokenization、padding、attention mask 生成。这一步容易被忽略,但实际耗时不少。
  3. 排队与调度:请求进入调度队列。调度器决定它和谁一起跑、什么时候跑。
  4. 模型推理:前向传播,逐 token 生成(如果是自回归模型)。这是最耗时的阶段。
  5. 后处理与返回:Detokenization、过滤特殊 token、组装响应。

注意:我曾经踩过一个坑——预处理和后处理在 CPU 上做,推理在 GPU 上做。如果 CPU 处理太慢,GPU 就会空转。这叫“CPU-GPU 流水线气泡”。解决办法是异步预处理,或者把 tokenizer 也放到 GPU 上。

批处理策略:动态批处理 vs 连续批处理

批处理是推理引擎的核心优化手段。没有批处理,GPU 的算力根本喂不饱。但怎么批,学问很大。

策略 核心思想 优点 缺点
静态批处理 固定 batch size,等凑够再执行 实现简单,延迟可控 利用率低,空闲等待多
动态批处理 设置超时时间,超时或凑够就执行 平衡延迟和吞吐 超时参数难调
连续批处理 请求随时加入、随时退出,不等待 利用率极高,延迟低 实现复杂,内存管理难

动态批处理

动态批处理,说白了就是“等一会儿,凑一车再走”。引擎设置一个最大等待时间(比如 5ms),时间到了或者请求数够了,就启动推理。

我在项目中遇到过一个问题:如果超时设得太短,batch size 太小,GPU 利用率上不去;设得太长,延迟又超标。后来我改用自适应超时——根据最近几秒的请求到达率动态调整超时时间。效果还不错。

// 伪代码:动态批处理调度
class DynamicBatcher:
    def __init__(self, max_batch=8, timeout_ms=5):
        self.queue = []
        self.max_batch = max_batch
        self.timeout = timeout_ms
    
    def add_request(self, req):
        self.queue.append(req)
        if len(self.queue) >= self.max_batch:
            self.execute_batch()
    
    def timer_tick(self):
        if self.queue and time_since_last_exec > self.timeout:
            self.execute_batch()

连续批处理

连续批处理是近年来的热门。它的核心思想是:不等待,随时插队,随时下车

你想想看,自回归模型生成 token 时,每个请求的生成速度不一样。有的请求生成了 10 个 token 就结束了,有的要生成 100 个。如果等所有请求都结束再释放资源,那 GPU 就一直在空转。

连续批处理的做法是:每个时间步,检查哪些请求已经完成,把它们踢出去,腾出 slot 给新请求。这样 GPU 始终在处理有效计算。

关键点:连续批处理需要引擎支持“动态 KV Cache 管理”。因为每个请求的序列长度不同,KV Cache 的大小也不同。我见过一些实现用“分页式显存管理”,类似操作系统的虚拟内存,把 KV Cache 切成固定大小的 page,按需分配。

// 伪代码:连续批处理核心逻辑
class ContinuousBatcher:
    def __init__(self, max_slots=64):
        self.slots = [None] * max_slots  # 每个 slot 对应一个请求
        self.active_count = 0
    
    def step(self):
        # 1. 对每个活跃请求做一次前向
        for slot in self.active_slots():
            logits = model.forward(slot.input_ids, slot.kv_cache)
            next_token = sample(logits)
            slot.append_token(next_token)
        
        # 2. 检查完成,释放 slot
        for slot in self.active_slots():
            if slot.is_finished():
                self.free_slot(slot)
        
        # 3. 从等待队列中拉新请求,填充空 slot
        while self.has_free_slot() and waiting_queue:
            new_req = waiting_queue.pop()
            self.assign_slot(new_req)

避坑指南:我曾经在实现连续批处理时,忽略了“请求优先级”。结果长文本请求一直占着 slot,短文本请求饿死了。后来我加了优先级队列,短请求优先处理,整体延迟反而更稳定。

三种策略怎么选?

说实话,没有银弹。我一般这样建议:

  • 静态批处理:适合离线批量推理,对延迟不敏感的场景。比如数据清洗、批量生成。
  • 动态批处理:适合在线服务,延迟要求中等(比如 100ms 以内)。大部分场景够用。
  • 连续批处理:适合高并发、低延迟场景(比如 10ms 以内)。但实现成本高,需要团队有较强的系统能力。

嗯,这里要注意:连续批处理虽然好,但不是所有模型都支持。比如一些非自回归模型(如 T5 的 encoder-decoder 结构),连续批处理的收益就没那么大。选型前一定要先分析模型结构。


公众号:蓝海资料掘金营,微信deep3321