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

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

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

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

大模型上线之后,很多团队会很快遇到一个现实问题:模型能跑,不代表能稳定、便宜、低延迟地跑

我自己做这类系统时,最常见的场景不是“模型精度不够”,而是下面这些更工程化的问题:

  • 同一张卡,吞吐上不去
  • 首 Token 很慢,用户感觉“卡住了”
  • 长上下文一来,显存直接爆
  • 并发一高,延迟抖动严重
  • 量化后虽然便宜了,但效果掉得没法上线

这篇文章不讲“某一个神奇参数”,而是从系统视角把大模型推理优化串起来:从量化KV Cache并发调度,给出一套更适合生产环境落地的方法论。读完你应该能回答三个关键问题:

  1. 为什么推理慢,到底慢在什么阶段?
  2. 不同优化手段分别优化了哪一段瓶颈?
  3. 如何把这些手段组合起来,而不是互相打架?

背景与问题

大模型推理通常有两个完全不同的阶段:

  1. Prefill(预填充):把整段 Prompt 一次性喂给模型,计算出首轮隐藏状态与 KV Cache
  2. Decode(逐 Token 生成):每次只生成一个 Token,并复用历史 KV Cache

这两阶段的性能特征完全不同:

  • Prefill:更偏计算密集,关注矩阵乘效率
  • Decode:更偏显存带宽和访存,关注 KV Cache 的读写与调度

很多优化失败,就是因为把这两个阶段混为一谈。比如:

  • 你以为是算力不够,实际是 KV Cache 占满显存
  • 你以为量化能解决一切,实际 decode 受制于缓存访存
  • 你以为加 batch 就能提吞吐,实际把首 Token 延迟拖爆了

一个典型症状表

症状常见原因优先排查方向
首 Token 延迟高Prefill 太重、动态 batch 不合理Prompt 长度、prefill batching
长对话显存爆炸KV Cache 持续累积cache 分页、回收策略、最大上下文
QPS 上不去调度粒度粗、decode 阶段串行continuous batching、请求分桶
单卡成本高模型权重太大、精度过高4/8bit 量化、张量并行策略
延迟抖动大长短请求混跑、调度不公平队列拆分、SLO 分级调度

从系统视角看整体链路

先把系统结构画清楚,后面的优化才有落点。

flowchart LR
    A[客户端请求] --> B[API 网关]
    B --> C[请求队列与限流]
    C --> D[Tokenizer]
    D --> E[Prefill 阶段]
    E --> F[KV Cache 分配]
    F --> G[Decode 调度器]
    G --> H[模型执行器 GPU]
    H --> I[采样器]
    I --> J[流式返回]
    G --> K[Cache 回收与统计]

这个链路里,性能问题通常落在三层:

  • 模型层:量化、算子融合、attention 实现
  • 缓存层:KV Cache 布局、分页、复用、回收
  • 服务层:并发调度、动态 batch、优先级、超时与限流

如果你只盯模型层,往往会发现“bench 数据很漂亮,线上却没改善多少”。


核心原理

1. 量化:先解决“权重太重”的问题

量化的本质,是把模型权重从高精度表示压缩到低精度表示,比如:

  • FP16 / BF16
  • INT8
  • INT4

量化带来的收益

  • 降低模型权重显存占用
  • 提升加载速度
  • 在部分硬件上提升推理吞吐

量化带来的代价

  • 精度可能下降
  • 某些算子需要反量化,收益不一定线性
  • 不同硬件对低比特支持差异很大

一个简单估算

假设模型参数量为 P,权重精度为 b bit,那么仅权重显存近似为:

显存 ≈ P × b / 8

比如一个 7B 模型:

  • FP16:约 7e9 × 2 byte ≈ 14 GB
  • INT8:约 7 GB
  • INT4:约 3.5 GB

但注意,这只是权重。真实推理还要加上:

  • KV Cache
  • 中间激活
  • CUDA kernel workspace
  • 框架管理开销

所以线上“明明 7B INT4 只有 3.5GB,为什么 24GB 卡还不够”,这并不奇怪。

量化适用边界

  • 短上下文、低并发:量化收益明显,尤其是单卡部署
  • 长上下文、高并发:KV Cache 可能变成主瓶颈,单靠量化不够

2. KV Cache:真正决定长上下文成本的关键

Transformer 在生成第 t 个 Token 时,不需要重新计算前面所有 Token 的 K/V,只要把历史 K/V 缓存起来复用即可,这就是 KV Cache。

为什么 KV Cache 这么重要?

没有 KV Cache 时,每生成一个 Token 都要重算历史上下文,复杂度接近灾难级增长。
有了 KV Cache 后,decode 阶段变成“追加式”计算,速度大幅提升。

但问题是:KV Cache 很占显存

KV Cache 的粗略估算

设:

  • 层数为 L
  • 隐藏维度为 H
  • 序列长度为 S
  • 数据类型字节数为 D
  • batch 大小为 B

则 KV Cache 量级近似为:

KV Cache ≈ B × S × L × H × 2 × D

其中乘以 2 是因为要存 K 和 V。

这意味着:

  • 模型越长上下文,cache 增长越快
  • 并发越高,cache 呈线性膨胀
  • decode 阶段很多时候不是算不动,而是“搬不动”

Paged KV Cache 的思路

传统连续内存分配容易产生碎片,也不利于灵活回收。工程上更常见的是分页式 KV Cache

  • 把 KV Cache 切成固定大小 block
  • 请求只持有 block 列表
  • 回收时按 block 粒度释放
  • 更容易做共享、扩容和重排
flowchart TD
    A[请求 A 上下文] --> A1[Block 1]
    A --> A2[Block 2]
    A --> A3[Block 3]

    B[请求 B 上下文] --> B1[Block 4]
    B --> B2[Block 5]

    C[空闲块池] --> A3
    C --> B2

KV Cache 的几个工程关键点

  1. Block 大小不是越小越好
    太小会增加索引管理成本,太大会浪费尾部空间

  2. 长短请求混用会造成资源争抢
    长请求长期占住大量 block,短请求容易排队

  3. Cache 回收要有策略
    请求结束即回收只是基础,还要考虑:

    • 超时中断
    • 客户端断连
    • 流式输出异常
    • 多轮会话保活上限

3. 并发调度:吞吐和延迟的平衡术

大模型服务不是简单 Web 服务。它的难点在于:每个请求不是一次性执行完,而是跨多个 decode step 持续占用资源

常见调度方式

静态批处理

  • 固定时间窗口收集请求
  • 拼成 batch 一起跑

优点:

  • 实现简单
  • 硬件利用率高

缺点:

  • 用户首 Token 延迟容易变差
  • 长短请求容易互相拖累

Continuous Batching(连续批处理)

  • 每个 decode step 结束后,允许新请求动态加入
  • 已完成请求立刻移出 batch

这是目前更适合在线推理的方式,因为它兼顾了吞吐和实时性。

sequenceDiagram
    participant U1 as 请求1
    participant U2 as 请求2
    participant S as 调度器
    participant G as GPU执行器

    U1->>S: 到达
    S->>G: prefill(U1)
    G-->>S: ready

    U2->>S: 到达
    S->>G: decode step(U1) + prefill(U2)
    G-->>S: token1(U1), ready(U2)

    S->>G: decode step(U1,U2)
    G-->>S: token2(U1), token1(U2)

调度优化的关键策略

1)按阶段拆分队列

不要把 prefill 和 decode 混成一个统一队列。
更好的做法是:

  • prefill 队列:按 prompt 长度分桶
  • decode 队列:按活跃请求数调度

这样可以减少“超长 prompt 把所有请求拖慢”的问题。

2)按长度分桶

把请求按输入长度分成几个 bucket,例如:

  • 0~512
  • 513~2048
  • 2049~8192

这样一个 batch 内的 shape 更接近,padding 浪费更少。

3)SLO 分级

如果线上同时有两类流量:

  • 聊天交互:要求低延迟
  • 离线生成:要求高吞吐

那就不要用一套调度策略。
可以做:

  • 高优先级:小 batch,限制最大等待时间
  • 低优先级:更大 batch,追求吞吐

方案对比与取舍分析

下面把最常见的几种优化手段放在一起看。

手段优化对象收益代价适用场景
权重量化权重显存、部分算力降低成本、提升单卡可部署性精度波动、兼容性问题单卡部署、成本敏感
KV Cachedecode 重复计算显著降低生成成本显存占用高所有生成场景
Paged KV Cachecache 管理降低碎片、提升并发稳定性实现复杂长上下文、高并发
Continuous batchingGPU 利用率提升吞吐、降低空转调度复杂在线服务
长度分桶padding 浪费提升 batch 有效利用率队列管理更复杂多样长度请求
优先级调度延迟保障降低关键流量抖动公平性更难处理混合业务场景

一个实际的决策顺序

如果你是从零开始搭建推理服务,我建议按这个顺序推进:

  1. 先打通 基础推理链路
  2. KV Cache
  3. 长度分桶 + continuous batching
  4. 再评估 量化
  5. 最后再做 分页缓存、优先级调度、细粒度回收

原因很简单:
量化虽然显眼,但很多线上瓶颈其实先出在缓存管理和调度策略


容量估算:别等显存炸了才算账

上线前最好做一个简化容量模型。

显存预算公式

总显存 ≈ 权重显存 + KV Cache 显存 + 临时激活 + 系统保留

可以粗略预留:

  • 权重:40% ~ 60%
  • KV Cache:30% ~ 50%
  • 其余:10% ~ 20%

简化示例

假设:

  • GPU 显存:24 GB
  • 7B 模型 INT4 权重:约 4~5 GB(考虑额外开销)
  • 框架与 workspace:约 3 GB
  • 剩余可分配给 KV Cache:约 16 GB

如果单请求平均上下文 + 输出总长度较大,那么高并发会很快顶满这 16 GB。
此时继续压权重帮助有限,更有效的是:

  • 限制最大上下文长度
  • 控制活跃 decode 请求数
  • 对长会话做 cache 回收或截断
  • 使用分页式 KV Cache

实战代码(可运行)

下面用 Python 写一个可运行的推理调度模拟器,演示三个核心点:

  • 请求进入 prefill / decode 两阶段
  • KV Cache 按 token 数增长
  • 调度器做简单的连续批处理

它不是完整模型实现,但能帮助你从系统层理解资源变化。

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


@dataclass
class Request:
    req_id: int
    prompt_len: int
    gen_len: int
    generated: int = 0
    stage: str = "prefill"   # prefill or decode
    kv_tokens: int = 0
    done: bool = False

    def prefill_cost(self):
        return self.prompt_len

    def decode_cost(self):
        return 1

    def total_tokens(self):
        return self.prompt_len + self.generated


@dataclass
class Scheduler:
    max_batch_size: int = 4
    max_kv_tokens: int = 12000
    prefill_queue: Deque[Request] = field(default_factory=deque)
    decode_queue: Deque[Request] = field(default_factory=deque)
    active: List[Request] = field(default_factory=list)
    current_kv_tokens: int = 0
    tick_id: int = 0

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

    def _can_allocate_kv(self, tokens: int) -> bool:
        return self.current_kv_tokens + tokens <= self.max_kv_tokens

    def step(self):
        self.tick_id += 1
        print(f"\n===== tick {self.tick_id} =====")

        # 1. 先处理 decode,模拟在线服务优先保障流式输出
        next_active = []
        decode_batch = []

        while self.decode_queue and len(decode_batch) < self.max_batch_size:
            req = self.decode_queue.popleft()
            if req.done:
                continue
            decode_batch.append(req)

        if decode_batch:
            print(f"[decode] batch={ [r.req_id for r in decode_batch] }")

        for req in decode_batch:
            if req.generated < req.gen_len:
                req.generated += 1
                req.kv_tokens += 1
                self.current_kv_tokens += 1
                print(f"  req={req.req_id} generated={req.generated}/{req.gen_len}, kv={req.kv_tokens}")
            if req.generated >= req.gen_len:
                req.done = True
                # 回收全部 KV
                self.current_kv_tokens -= req.kv_tokens
                print(f"  req={req.req_id} done, release kv={req.kv_tokens}")
                req.kv_tokens = 0
            else:
                next_active.append(req)

        # 2. 再尝试接入 prefill
        prefill_batch = []
        while self.prefill_queue and len(prefill_batch) < self.max_batch_size:
            req = self.prefill_queue[0]
            needed = req.prompt_len
            if not self._can_allocate_kv(needed):
                print(f"[prefill] req={req.req_id} blocked, need_kv={needed}, current={self.current_kv_tokens}")
                break
            self.prefill_queue.popleft()
            prefill_batch.append(req)

        if prefill_batch:
            print(f"[prefill] batch={ [r.req_id for r in prefill_batch] }")

        for req in prefill_batch:
            req.kv_tokens = req.prompt_len
            self.current_kv_tokens += req.prompt_len
            req.stage = "decode"
            next_active.append(req)
            print(f"  req={req.req_id} prefill_done, allocate kv={req.kv_tokens}")

        # 3. 活跃请求重新进入 decode 队列
        for req in next_active:
            self.decode_queue.append(req)

        print(f"[stats] current_kv_tokens={self.current_kv_tokens}, "
              f"prefill_q={len(self.prefill_queue)}, decode_q={len(self.decode_queue)}")


def build_requests(n=8):
    requests = []
    for i in range(1, n + 1):
        prompt_len = random.choice([64, 128, 256, 512, 1024])
        gen_len = random.choice([16, 32, 64])
        requests.append(Request(req_id=i, prompt_len=prompt_len, gen_len=gen_len))
    return requests


def main():
    random.seed(42)
    scheduler = Scheduler(max_batch_size=3, max_kv_tokens=4000)

    requests = build_requests(10)
    for req in requests:
        scheduler.submit(req)
        print(f"submit req={req.req_id}, prompt={req.prompt_len}, gen={req.gen_len}")

    while scheduler.prefill_queue or scheduler.decode_queue:
        scheduler.step()
        time.sleep(0.1)

    print("\nall requests done")


if __name__ == "__main__":
    main()

这段代码能看到什么?

运行后你会观察到:

  • prompt 长的请求会在 prefill 阶段一次性吃掉较多 KV 预算
  • decode 会不断追加 KV token
  • 当 KV 容量不够时,新请求会被阻塞
  • 请求结束后回收 KV,后续请求才有机会进入

这就是很多线上服务“QPS 突然掉下去”的本质:
不是模型挂了,而是KV Cache 预算被长请求占住了


一个更贴近生产的调度建议

如果你准备把上面的模拟思想落地成服务,可以用如下状态机来设计请求生命周期。

stateDiagram-v2
    [*] --> Waiting
    Waiting --> Prefill: 被调度
    Prefill --> Decoding: KV 分配成功
    Prefill --> Waiting: 资源不足重排队
    Decoding --> Streaming: 产生 token
    Streaming --> Decoding: 继续生成
    Decoding --> Finished: 达到停止条件
    Decoding --> Aborted: 超时/断连/取消
    Finished --> [*]
    Aborted --> [*]

这个状态机的价值在于:
它把“模型推理”变成“资源受控的任务调度问题”,更容易扩展监控、超时、抢占和回收逻辑。


常见坑与排查

这部分我尽量写得接地气一些,都是线上高频问题。

坑 1:量化后吞吐没涨,甚至变慢

现象

  • 权重显存下降了
  • 但 token/s 没明显提升
  • 某些输入下延迟还更高

原因

  • 硬件对低比特算子支持一般
  • 量化算子存在反量化开销
  • 真正瓶颈在 decode 的 KV 访存,不在权重计算

排查方法

  1. 分别测 prefill 和 decode 吞吐
  2. 记录显存占用与带宽利用率
  3. 对比 FP16 / INT8 / INT4 在不同上下文长度下的表现

建议

  • 不要只看单次请求 benchmark
  • 要看在线混合流量下的 p95 / p99 延迟
  • 对长上下文场景,优先看 KV 和调度,而不是盲目继续降 bit

坑 2:KV Cache 明明做了,为什么还是 OOM

现象

  • 单请求没问题
  • 一并发就 OOM
  • 或者跑一段时间后碎片越来越严重

原因

  • 只算了模型权重,没算 KV 增长
  • cache 没及时回收
  • 长会话持续保留上下文
  • 连续内存分配导致碎片化

排查方法

  1. 统计每个请求的:
    • prompt 长度
    • 已生成 token 数
    • KV 占用
  2. 统计活跃请求总 KV
  3. 观察是否存在异常长尾会话长期不释放

建议

  • 给会话设置最大上下文和最大存活时间
  • 引入 block 化缓存
  • 断连后强制回收资源

坑 3:并发一高,首 Token 延迟突然恶化

现象

  • 平均延迟还能看
  • 但 p95 / p99 首 Token 延迟明显上升

原因

  • prefill 请求堆积
  • 长 prompt 混入实时交互队列
  • batch 为了吞吐攒太久

排查方法

  1. 单独监控 TTFT(Time To First Token)
  2. 区分 prefill 等待时间和执行时间
  3. 按 prompt 长度分段统计延迟

建议

  • 设置最大组批等待时间
  • 长 prompt 请求单独分桶
  • 给实时对话流量更高优先级

坑 4:吞吐高了,但用户体验反而差

这个坑很常见。你把 batch 做大,GPU 利用率很漂亮,但用户觉得“怎么每次都要等一下才开始出字”。

原因

  • 吞吐优化压缩的是整体空转,不一定改善单请求感知
  • 用户更敏感的是:
    • 首 Token 时间
    • 输出是否稳定连续

建议

生产上至少同时看三类指标:

  • TTFT:首 Token 延迟
  • TPOT(Time Per Output Token):每输出 token 的平均时间
  • QPS / token/s:总体吞吐

只看其中一个,很容易把系统调偏。


安全/性能最佳实践

这里把我更推荐的落地原则归纳一下。

1. 先定 SLO,再选优化策略

不要一上来就说“我要最大吞吐”。
先定义清楚业务目标:

  • 聊天机器人:优先 TTFT
  • 批量生成:优先 token/s
  • 混合业务:做流量分级

不同目标,对 batch 大小、等待时间、优先级策略都不同。


2. 给 KV Cache 设置硬上限

这是稳定性的底线。建议至少做三层限制:

  • 单请求最大上下文长度
  • 单实例最大活跃请求数
  • 全局最大 KV tokens 预算

一旦超限,可以:

  • 拒绝新请求
  • 降级到短上下文
  • 将低优先级请求排队

不要等显存被动 OOM。


3. 监控拆到阶段级别

至少监控这些指标:

prefill_latency_ms
decode_latency_ms
ttft_ms
tpot_ms
active_requests
kv_cache_used_tokens
kv_cache_block_utilization
request_abort_count
oom_count
batch_size_prefill
batch_size_decode

如果监控只停留在“平均响应时间”,排查几乎无从下手。


4. 对异常会话要可中断、可回收

生产环境里一定会有这些情况:

  • 客户端断开连接
  • 请求超时
  • 用户主动取消
  • 下游 backpressure 导致阻塞

如果不能中断并立即回收 KV Cache,资源泄漏会非常快。


5. 量化要做任务级回归,不要只看通用 benchmark

尤其是:

  • RAG 场景
  • 代码生成
  • 多轮对话
  • 工具调用

这些场景对数值扰动更敏感。
我一般会建议:

  • 建一组自己的线上回放样本
  • 分别评估首 Token、完整输出质量、拒答率、格式稳定性
  • 量化收益达不到预期时,优先考虑更合适的 bit 配置或混合精度,而不是强推到底

6. 长短请求分池,通常比“一个大池子”更稳

如果资源允许,建议至少分成两类实例池:

  • 低延迟池:服务短 prompt、交互型请求
  • 高吞吐池:服务长文本或批量任务

这样做的好处是:

  • 调度策略更简单
  • 延迟更可控
  • 故障隔离更容易

它的代价是资源利用率可能略低,但线上稳定性通常更值钱。


总结

大模型推理优化,真正有效的方式不是“迷信某个单点技巧”,而是把它当成一个模型、缓存、调度三层协同的系统工程

可以把本文的核心结论浓缩成下面几句:

  1. 量化主要解决的是权重显存和部分算力问题
  2. KV Cache决定了长上下文和高并发下的真实成本
  3. 并发调度决定了线上吞吐、首 Token 延迟和抖动表现
  4. 生产优化必须分清 prefilldecode 两阶段
  5. 真正落地时,往往是缓存管理和调度比单纯“降 bit”更关键

如果你准备开始做一次系统化优化,我建议按下面顺序执行:

  1. 先拆分并监控 prefill / decode 指标
  2. 建立显存与 KV Cache 容量预算
  3. 上 continuous batching 和长度分桶
  4. 用业务样本验证量化收益
  5. 最后再做分页缓存、优先级调度与流量分池

这样推进,通常比“先把模型量化到极致”更稳,也更容易在线上拿到可验证的收益。

归根结底,大模型推理性能优化不是拼参数,而是拼你是否真正理解:资源花在哪,瓶颈卡在哪,系统怎么在边界条件下仍然稳定运行。这件事一旦想清楚,优化路径就会清晰很多。


分享到:

上一篇
《从 Prompt 到生产力:中级开发者实战构建基于大语言模型的企业知识库问答系统》
下一篇
《自动化测试中的测试数据治理实战:从环境隔离、数据构造到回放验证的落地方案》