跳转到内容
123xiao | 无名键客

《大模型推理性能优化实战:从 KV Cache、量化到批处理调度的系统化落地指南》

字数: 0 阅读时长: 1 分钟

大模型推理性能优化实战:从 KV Cache、量化到批处理调度的系统化落地指南

做大模型应用的人,通常都会经历一个很真实的阶段:模型先跑起来,再想办法把账单和时延打下来

我自己做推理服务时,最初也以为“换一张更大的卡”就能解决问题,后来发现真正影响体验和成本的,往往不是单点参数,而是一整条链路:Prefill/Decode 的计算特征、KV Cache 的内存占用、量化后的吞吐变化、批处理调度策略、甚至请求长度分布。这些因素叠在一起,才决定了服务到底是“能用”,还是“能规模化落地”。

这篇文章不只讲单个技巧,而是从系统视角把一套推理优化方法串起来,重点覆盖:

  • KV Cache:为什么它既能提速,也会吞掉显存
  • 量化:为什么有时吞吐提升明显,有时却不如预期
  • 批处理调度:为什么“批更大”不一定更快
  • 容量估算与取舍:如何把优化手段落到可运营的架构上

如果你已经会基本部署 LLM 服务,这篇文章会更适合你。


背景与问题

为什么大模型推理这么容易“又慢又贵”?

从服务角度看,一次生成请求大致分成两个阶段:

  1. Prefill 阶段:把整段输入 prompt 编码进模型
  2. Decode 阶段:逐 token 生成输出

这两个阶段的性能瓶颈不完全一样:

  • Prefill 更偏计算密集
  • Decode 更偏内存带宽和访问模式
  • 当上下文变长时,KV Cache 会迅速膨胀
  • 当并发变高时,调度策略 会直接影响 GPU 利用率和尾延迟

很多团队上线初期会遇到这些典型症状:

  • 单卡显存明明还有剩余,但吞吐上不去
  • 平均延迟还行,P95/P99 尾延迟非常差
  • 开了量化后,成本下降了,但效果质量波动明显
  • 请求一多,长短 prompt 相互拖累,服务抖动严重

本质上,这是一个典型的系统工程问题,不是只靠某一个“黑科技参数”就能解决。

一个更贴近真实业务的目标

如果你的目标只是“跑通 demo”,那很多问题都不会暴露。但真正上线时,通常要同时满足:

  • 首 token 延迟(TTFT)可接受
  • tokens/s 吞吐足够高
  • 单位请求成本可控
  • 长上下文不会把显存打爆
  • 高并发下尾延迟仍稳定

因此,推理优化不是“越激进越好”,而是要围绕业务目标做取舍。


整体架构视角:先看全局,再做局部优化

先给出一个我更推荐的推理优化思路:按数据流和资源瓶颈拆层治理

flowchart TD
    A[客户端请求] --> B[请求接入层]
    B --> C[Tokenizer/长度预估]
    C --> D[批处理调度器]
    D --> E1[Prefill Worker]
    D --> E2[Decode Worker]
    E1 --> F[KV Cache 管理器]
    E2 --> F
    F --> G[量化模型执行引擎]
    G --> H[采样/后处理]
    H --> I[响应返回]

    J[监控系统] --> D
    J --> F
    J --> G

这张图表达一个核心原则:

推理优化不是只优化模型,而是优化“请求进入 GPU 到结果返回”的整个链路。

在这条链路中,最关键的三个抓手通常是:

  1. KV Cache 管理
  2. 模型量化
  3. 批处理调度

后面我们按这三个部分展开。


核心原理

1. KV Cache:为什么它能显著提速?

Transformer 在生成时,每个 token 都需要和历史 token 做 attention。如果每一步都把历史重新算一遍,代价会非常高。

KV Cache 的做法是:

  • 在前面 token 的计算过程中,把每一层 attention 的 Key/Value 保存下来
  • 后续生成新 token 时,直接复用过去的 K/V
  • 这样就避免重复计算历史上下文

直观理解

如果没有 KV Cache,生成第 t 个 token 时,模型要“重新看一遍”前 1...t-1 的历史。

有了 KV Cache,模型只需要:

  • 读取历史缓存的 K/V
  • 为当前 token 计算新的 q/k/v
  • 更新缓存并继续生成

因此:

  • 计算量大幅下降
  • 但代价是:显存占用显著上升

KV Cache 的近似内存估算

KV Cache 内存通常可近似估算为:

KV Cache ≈ batch_size × seq_len × num_layers × num_kv_heads × head_dim × 2 × dtype_bytes

其中:

  • 2 表示 K 和 V 两份
  • dtype_bytes 取决于缓存精度,如 fp16 为 2 字节

如果你部署的是多并发、长上下文模型,这部分内存常常比你想象得更可怕。

KV Cache 的工程关键点

实际落地时,KV Cache 不是“开了就完事”,常见要关注:

  • 连续内存 vs 分页式管理
  • 缓存复用
  • 前缀共享(Prefix Caching)
  • 缓存淘汰策略
  • 长短请求混部造成的内存碎片

现代推理引擎里,Paged KV Cache 很常见,因为它更适合动态请求场景。

flowchart LR
    A[请求A Prefill] --> B[分配 KV 页]
    C[请求B Prefill] --> D[分配 KV 页]
    B --> E[统一 KV Page 池]
    D --> E
    E --> F[Decode 读取映射]
    F --> G[释放/复用空闲页]

KV Cache 不一定总是“越大越好”

这是我踩过的一个坑。很多人会想:“既然 cache 能提速,那就尽量保留更多历史。”
问题是:

  • 更大的 cache 占更多显存
  • 更高的并发下会挤压 batch 空间
  • 显存压力上去后,调度器可能不得不降批,反而整体吞吐下降

所以正确的思路不是“尽量缓存”,而是:

让缓存命中率、显存占用、调度灵活性三者平衡。


2. 量化:为什么它能降成本,但不总是线性提速?

量化的本质是把模型权重、激活或 KV Cache 用更低精度表示,比如:

  • FP16/BF16
  • INT8
  • INT4
  • AWQ/GPTQ 等权重量化方案

量化主要带来的收益

  1. 降低显存占用
  2. 提升加载效率
  3. 在某些硬件和内核上,提升吞吐

但量化的收益不是永远线性的,原因主要有三个:

原因一:瓶颈不一定在算力

如果你的 Decode 阶段主要受内存访问限制,那么只压缩权重未必能带来同等比例提速。

原因二:量化内核实现质量差异很大

同样是 INT4:

  • 不同框架内核性能差异可能非常大
  • 有些方案对小 batch 更友好
  • 有些方案只在特定 GPU 架构上收益明显

原因三:精度损失可能影响业务结果

尤其是:

  • 结构化输出任务
  • 数学推理
  • 长上下文检索
  • 代码生成

这些场景对量化更敏感。

如何选量化策略?

一个实用经验是:

  • 线上主力模型:优先考虑成熟度高的 BF16/FP16 + 高效 KV Cache + 调度优化
  • 成本敏感服务:考虑 INT8/INT4 权重量化
  • 长上下文/高并发:同时关注 KV Cache 量化 或缓存压缩能力
  • 高质量要求场景:先离线评估,再灰度上线

一个典型取舍表

方案显存占用吞吐潜力精度风险工程复杂度适用场景
FP16/BF16稳定生产环境
INT8中高低中通用在线服务
INT4中高中高成本优先、大规模服务
KV Cache 低精度更低中高长上下文高并发

3. 批处理调度:吞吐提升最容易被低估的一层

很多人以为批处理就是“把多个请求凑在一起跑”。这只说对了一半。

在 LLM 推理里,真正难的是:

  • 请求长度不一致
  • 输出长度不可预知
  • Prefill 和 Decode 的资源模式不同
  • 长请求会拖慢短请求
  • 静态批处理会造成 GPU 空转

所以现在更主流的是动态批处理(Dynamic Batching)连续批处理(Continuous Batching)

静态批处理 vs 连续批处理

静态批处理

  • 收集一批请求
  • 一起 Prefill
  • 一起 Decode 到结束
  • 批次中最慢的请求决定整体节奏

问题是:短请求会被长请求“绑架”。

连续批处理

  • 每个解码 step 都可以动态插入新请求
  • 已完成请求及时出队
  • 空出来的 slot 立即复用
  • GPU 利用率明显更高
sequenceDiagram
    participant C1 as 请求A
    participant C2 as 请求B
    participant S as 调度器
    participant G as GPU执行器

    C1->>S: 到达
    S->>G: A进入批次
    G-->>S: step1
    C2->>S: 到达
    S->>G: A+B连续批处理
    G-->>S: step2
    S-->>C1: A完成,出队
    S->>G: B + 新请求C

调度的关键指标

做批处理调度时,我建议至少盯住这几个指标:

  • TTFT(Time To First Token)
  • TPOT(Time Per Output Token)
  • 吞吐量(tokens/s)
  • 队列等待时间
  • GPU 利用率
  • P95/P99 延迟
  • OOM 次数 / Cache 分配失败次数

实际工程中的调度策略

常见可落地的策略包括:

1)按 prompt 长度分桶

把长度相近的请求放在一起,减少 padding 和拖尾。

2)Prefill / Decode 分离调度

因为两者资源特征不同,拆开调度往往更稳。

3)为长请求设置上限或单独队列

避免极端长请求拖垮常规业务流量。

4)基于 token 预算做 admission control

不是按“请求数”,而是按“预计 token 开销”入队,这比单纯限流更贴近真实资源消耗。


方案对比与取舍分析

在架构设计上,很多团队会纠结:先做哪个优化最划算?
我的建议是按收益/风险比排序。

一个务实的优化优先级

  1. 先把观测做好
    • 没有 TTFT、TPOT、KV Cache 命中率、显存水位,你几乎无法正确优化
  2. 上连续批处理
    • 往往是最直接的吞吐增益
  3. 治理 KV Cache
    • 尤其是长上下文服务
  4. 做量化
    • 在质量可接受前提下降本
  5. 再做更复杂的分离部署和多级调度

三种典型架构路线

路线优点缺点适用阶段
单模型单实例 + 基础批处理简单易维护吞吐和弹性一般早期验证
连续批处理 + Paged KV Cache性能和稳定性平衡较好实现复杂度上升主流生产
Prefill/Decode 分离 + 多队列调度资源利用率高架构和网络复杂度高大规模服务

容量估算:别只看“每秒请求数”

大模型服务更适合按 token 做容量估算。

一个简化模型:

总负载 ≈ QPS × (平均输入tokens + 平均输出tokens)

但更准确一些,建议拆成:

Prefill负载 ≈ QPS × 平均输入tokens
Decode负载 ≈ QPS × 平均输出tokens

因为二者对系统的压力模式不同。

如果你是在线问答场景:

  • 平均输入 1200 tokens
  • 平均输出 300 tokens
  • QPS = 5

那么:

  • Prefill token 负载约 6000 tokens/s
  • Decode token 负载约 1500 tokens/s

这种情况下,系统更可能先卡在 Prefill。

反过来,如果你做长文本生成:

  • 平均输入 200
  • 平均输出 1200

那往往是 Decode 和 KV Cache 压力更大。


实战代码(可运行)

下面我用一个可运行的 Python 示例,模拟一个简化版的连续批处理调度器,帮助你理解:

  • 请求如何入队
  • 如何按 token budget 组批
  • 完成的请求如何出队
  • 新请求如何填补批次空位

这个示例不是完整 LLM 引擎,但足够解释调度核心。

示例 1:简化版连续批处理调度器

import time
import random
from dataclasses import dataclass, field
from collections import deque
from typing import List, Deque


@dataclass
class Request:
    req_id: int
    prompt_tokens: int
    max_new_tokens: int
    generated_tokens: int = 0
    prefilled: bool = False
    arrival_time: float = field(default_factory=time.time)

    @property
    def finished(self) -> bool:
        return self.generated_tokens >= self.max_new_tokens


class ContinuousBatchScheduler:
    def __init__(self, max_batch_size=4, max_prefill_tokens=4096, max_decode_tokens=4):
        self.waiting_queue: Deque[Request] = deque()
        self.running_batch: List[Request] = []
        self.max_batch_size = max_batch_size
        self.max_prefill_tokens = max_prefill_tokens
        self.max_decode_tokens = max_decode_tokens

    def submit(self, req: Request):
        self.waiting_queue.append(req)

    def _select_prefill_requests(self) -> List[Request]:
        selected = []
        total_tokens = 0

        while self.waiting_queue and len(self.running_batch) + len(selected) < self.max_batch_size:
            req = self.waiting_queue[0]
            if total_tokens + req.prompt_tokens > self.max_prefill_tokens:
                break
            selected.append(self.waiting_queue.popleft())
            total_tokens += req.prompt_tokens

        return selected

    def prefill_step(self):
        new_reqs = self._select_prefill_requests()
        if not new_reqs:
            return

        for req in new_reqs:
            req.prefilled = True
            self.running_batch.append(req)

        print(f"[PREFILL] add={[(r.req_id, r.prompt_tokens) for r in new_reqs]} "
              f"running={[r.req_id for r in self.running_batch]}")

    def decode_step(self):
        if not self.running_batch:
            return

        active = self.running_batch[:self.max_decode_tokens]
        for req in active:
            req.generated_tokens += 1

        finished_ids = [r.req_id for r in self.running_batch if r.finished]
        self.running_batch = [r for r in self.running_batch if not r.finished]

        print(
            f"[DECODE] active={[r.req_id for r in active]} "
            f"generated={[(r.req_id, r.generated_tokens) for r in active]} "
            f"finished={finished_ids} "
            f"remaining={[r.req_id for r in self.running_batch]}"
        )

    def has_work(self):
        return bool(self.waiting_queue or self.running_batch)


def main():
    random.seed(42)
    scheduler = ContinuousBatchScheduler(
        max_batch_size=4,
        max_prefill_tokens=3000,
        max_decode_tokens=4
    )

    requests = [
        Request(req_id=1, prompt_tokens=500, max_new_tokens=5),
        Request(req_id=2, prompt_tokens=1200, max_new_tokens=2),
        Request(req_id=3, prompt_tokens=800, max_new_tokens=4),
        Request(req_id=4, prompt_tokens=400, max_new_tokens=6),
        Request(req_id=5, prompt_tokens=1500, max_new_tokens=3),
    ]

    for req in requests:
        scheduler.submit(req)

    step = 0
    while scheduler.has_work():
        step += 1
        print(f"\n--- step {step} ---")
        scheduler.prefill_step()
        scheduler.decode_step()
        time.sleep(0.1)

    print("\nAll requests finished.")


if __name__ == "__main__":
    main()

运行后你会看到什么?

这个示例体现了几个关键点:

  • Prefill 受 max_prefill_tokens 约束
  • Decode 受 max_batch_size 和活动请求数约束
  • 已完成请求会从 running batch 中移除
  • 后续请求可以继续补进来

这就是连续批处理的基本思想:批次不是一成不变的,而是流动的


示例 2:KV Cache 显存估算脚本

在真正上线前,我强烈建议先做 KV Cache 容量估算。下面这个脚本很适合放到压测前快速试算。

def estimate_kv_cache_gb(
    batch_size: int,
    seq_len: int,
    num_layers: int,
    num_kv_heads: int,
    head_dim: int,
    dtype_bytes: int = 2
) -> float:
    total_bytes = (
        batch_size
        * seq_len
        * num_layers
        * num_kv_heads
        * head_dim
        * 2
        * dtype_bytes
    )
    return total_bytes / (1024 ** 3)


if __name__ == "__main__":
    gb = estimate_kv_cache_gb(
        batch_size=16,
        seq_len=8192,
        num_layers=32,
        num_kv_heads=8,
        head_dim=128,
        dtype_bytes=2  # fp16/bf16
    )
    print(f"Estimated KV Cache: {gb:.2f} GB")

怎么用这个结果?

你至少要把下面几部分加总考虑:

  • 模型权重显存
  • KV Cache 显存
  • 激活和临时工作区
  • CUDA runtime / 框架额外开销
  • 留给碎片和峰值波动的余量

一个常见经验是:不要把卡打到 100%,否则很容易因为长度波动触发 OOM。


常见坑与排查

这一部分我尽量讲得“像现场一点”,因为很多问题不是理论不会,而是线上真的容易误判。

坑 1:开了 KV Cache,但 TTFT 反而没明显变好

原因

KV Cache 主要优化的是 Decode 阶段,对 Prefill 帮助有限。
如果你的 prompt 很长,TTFT 大头其实在 Prefill。

排查方法

  • 分开统计 Prefill 耗时和 Decode 耗时
  • 看 TTFT 是否主要被输入长度驱动
  • 检查 tokenizer 和前处理是否成为隐性瓶颈

建议

  • 对长 prompt 做裁剪/重写
  • 做前缀缓存
  • 考虑 Prefill 与 Decode 分离

坑 2:量化后显存降了,但吞吐没涨多少

原因

  • 当前瓶颈在 KV Cache/内存带宽
  • 量化内核不适配当前 GPU
  • 批处理太小,无法吃满量化 kernel 的优势

排查方法

  • 对比 FP16 与 INT8/INT4 的 GPU utilization、memory throughput
  • 固定 batch、固定输入长度做 AB 测试
  • 检查是否存在频繁 CPU/GPU 同步

建议

  • 先确认“是算力瓶颈还是带宽瓶颈”
  • 换更成熟的量化推理后端
  • 不要只看平均吞吐,也看质量回归

坑 3:批处理越大,尾延迟越差

原因

这在在线服务里非常常见。批次变大虽然有利于吞吐,但也会导致:

  • 队列等待更长
  • 长请求拖住短请求
  • decode step 周期变长

排查方法

  • 同时观察 QPS、平均延迟、P95、P99
  • 按 prompt 长度分层统计延迟
  • 查看长请求比例是否在高峰时升高

建议

  • 使用连续批处理,而不是盲目增大静态 batch
  • 引入长度分桶
  • 对超长请求单独限流或隔离

坑 4:明明算出来显存够,实际还是 OOM

原因

通常是以下几种叠加:

  • CUDA allocator 碎片
  • 临时 buffer 未计入
  • 长短请求混部造成峰值水位波动
  • 模型框架有额外 cache/workspace

排查方法

  • 对比理论显存与 nvidia-smi 峰值
  • 记录每次 batch 的最大 seq_len、活跃请求数
  • 检查是否在某类极端请求下稳定复现

建议

  • 预留 10%~20% 显存余量
  • 做 token budget admission control
  • 对超长上下文做硬上限

坑 5:Prefix Cache 命中率低,效果不明显

原因

前缀缓存只在“前缀重复度高”时才有价值。
如果你的请求高度个性化,命中率会非常一般。

排查方法

  • 统计标准化后的 prompt 前缀重复率
  • 区分系统提示词、模板提示词、用户输入

建议

  • 将系统提示词与用户变量分离
  • 固定模板前缀
  • 对检索增强场景,尽量稳定 instruction 部分

安全/性能最佳实践

推理优化不只是“跑得快”,还包括稳定、可控、不会被异常输入拖垮

1. 做好输入边界控制

至少限制:

  • 最大输入 tokens
  • 最大输出 tokens
  • 最大并发请求数
  • 单租户 token 速率

否则一个超长输入就可能占满 KV Cache,把服务拖死。

2. 按 token 预算调度,而不是按请求数

一个 8k prompt 的请求,和一个 200 token 的请求,资源消耗完全不是一个量级。
所以 admission control 最好这样做:

允许进入批次的条件 = 当前活跃token预算 + 新请求预估token开销 <= 阈值

这比“最多 32 个请求”更合理。

3. 保留显存余量,拒绝满载幻觉

理论上跑得下,不代表线上稳。
我的经验是:

  • 单卡部署尽量留出 10%~20% 的显存余量
  • 高波动业务留更多
  • 尾部超长请求单独隔离

4. 用分层指标看问题

不要只看一个总体 QPS。建议至少拆成:

  • 按模型版本
  • 按输入长度区间
  • 按输出长度区间
  • 按租户/流量来源
  • 按 Prefill/Decode 阶段

这样你才能知道问题到底出在哪一层。

5. 灰度量化,不要全量硬切

量化模型切换建议这样做:

  1. 离线基准评测
  2. 小流量灰度
  3. 比较质量指标与延迟指标
  4. 再扩大流量

尤其是工具调用、代码生成、结构化 JSON 输出场景,量化精度回归一定要做。

6. 长短请求分池

如果业务允许,最好把:

  • 短问答请求
  • 长上下文分析请求
  • 批量离线生成请求

拆到不同实例池。这样调度更简单,SLO 更稳定。


一份可落地的优化清单

如果你正在接手一个“已经能跑,但性能不理想”的推理服务,我建议按下面顺序推进:

第一步:建立基线

先记录这些指标:

  • TTFT
  • TPOT
  • tokens/s
  • GPU 利用率
  • 显存占用峰值
  • P95/P99
  • 平均输入/输出 tokens
  • OOM 次数

第二步:识别主瓶颈

问自己三个问题:

  • 是 Prefill 慢,还是 Decode 慢?
  • 是算力瓶颈,还是显存/带宽瓶颈?
  • 是平均性能差,还是尾延迟差?

第三步:优先做高收益低风险优化

优先级一般建议:

  1. 连续批处理
  2. 长度分桶
  3. KV Cache 管理优化
  4. Prefix Cache
  5. 量化

第四步:做容量治理

  • 加 token budget admission control
  • 加最大上下文限制
  • 为长请求单独分流
  • 预留显存余量

第五步:压测验证

压测时至少覆盖:

  • 短输入短输出
  • 短输入长输出
  • 长输入短输出
  • 长输入长输出
  • 高峰并发混合流量

别只测“平均场景”,线上真正出问题的往往是混合流量。


一个推荐的落地架构

如果你问我中等规模团队最值得采用的路线,我会推荐这套相对平衡的方案:

flowchart TD
    A[API Gateway] --> B[Token预算限流]
    B --> C[长度分桶队列]
    C --> D[连续批处理调度器]
    D --> E[Prefill执行层]
    D --> F[Decode执行层]
    E --> G[Paged KV Cache]
    F --> G
    G --> H[量化模型引擎]
    H --> I[监控与告警]

    J[Prefix Cache] --> E

它的优点是:

  • 复杂度不算夸张
  • 吞吐、时延、显存三者比较均衡
  • 可以逐步演进到更复杂的多实例池/分离部署架构

总结

大模型推理优化,真正有效的方法不是某一个“银弹开关”,而是把系统拆开看:

  • KV Cache 决定了 Decode 是否高效,但也决定显存水位
  • 量化 可以显著降本,但收益取决于硬件、内核和任务类型
  • 批处理调度 往往是吞吐提升最直接的手段,也是最容易影响尾延迟的一层

如果只给一个落地建议,我会说:

先建立分阶段观测,再做连续批处理和 KV Cache 治理,最后再推进量化。

因为在大多数线上系统里:

  • 调度优化更容易立竿见影
  • KV Cache 管理直接决定能否承载长上下文与高并发
  • 量化则更适合在质量验证后,作为降本增效的进一步动作

最后给一个务实结论:

  • 如果你是刚上线:先把指标、限流、长度分桶做好
  • 如果你是吞吐不足:优先上连续批处理
  • 如果你是显存吃紧:先算清 KV Cache,再考虑量化
  • 如果你是尾延迟差:优先隔离长请求,别一味加大 batch

把这几件事做好,推理服务通常就能从“能跑”走到“能稳定赚钱地跑”。


分享到:

上一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-452》
下一篇
《自动化测试中接口与UI联动回归的实战方案:从用例分层到持续集成落地》