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

《大模型推理优化实战:从量化、KV Cache 到并发调度的性能提升路径》

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

大模型推理优化实战:从量化、KV Cache 到并发调度的性能提升路径

做大模型应用时,很多人一开始关注的是“模型够不够聪明”,但项目一上线,最先把人打醒的往往不是效果,而是延迟、吞吐、显存和成本

我见过不少团队在 PoC 阶段用一张卡跑得挺开心,等用户一多,问题就集中爆发:

  • 首 token 很慢,用户觉得“像卡住了”
  • 多用户并发一上来,吞吐掉得厉害
  • 显存不够,模型一加载就 OOM
  • 同样的 GPU,别人能跑得更快,自己却总是“跑不满”

这篇文章不讲空泛概念,而是从实战角度把大模型推理优化拆成三条主线:

  1. 量化:先把模型“塞得下、跑得动”
  2. KV Cache:再把生成阶段加速起来
  3. 并发调度:最后把单请求优化,变成整体吞吐优化

如果你已经会基本的 Transformers 推理,这篇内容应该能帮你建立一个比较完整的优化路径。


背景与问题

大模型推理的性能问题,通常不是单点造成的,而是多个瓶颈叠加:

  • 模型参数量大:权重加载占用大量显存
  • 注意力计算昂贵:上下文越长,计算和显存压力越大
  • 生成是逐 token 的:天然难像训练那样充分并行
  • 服务端存在多租户并发:不同请求长度不同,调度稍差就浪费算力

可以把一次推理粗略分成两个阶段:

  1. Prefill 阶段:把整段输入 prompt 编码进模型
    • 计算密集
    • 对长上下文更敏感
  2. Decode 阶段:逐 token 生成输出
    • 强依赖 KV Cache
    • 对调度和批处理策略更敏感

下面这张图能帮助你先建立全局视角。

flowchart LR
    A[用户请求] --> B[Tokenizer]
    B --> C[Prefill<br/>处理整段输入]
    C --> D[建立 KV Cache]
    D --> E[Decode<br/>逐 token 生成]
    E --> F[后处理/流式返回]

    G[量化] --> C
    G --> E
    H[KV Cache] --> D
    H --> E
    I[并发调度/批处理] --> C
    I --> E

常见性能指标

优化前,先统一指标口径,不然后面很容易“感觉变快了”,但没法复现。

建议至少看这几个指标:

  • TTFT(Time To First Token):首 token 延迟
  • TPOT(Time Per Output Token):每个输出 token 平均耗时
  • 吞吐:tokens/s 或 requests/s
  • 显存占用:静态权重 + 动态 KV Cache
  • P95/P99 延迟:比平均值更有意义

一句话概括:

  • 量化主要解决“模型太大、算太慢”
  • KV Cache主要解决“重复算历史上下文”
  • 并发调度主要解决“GPU 没吃满、请求互相拖累”

前置知识与环境准备

本文示例使用 Python,尽量用你本地就能跑起来的方式演示。
建议环境:

  • Python 3.10+
  • CUDA 11.8+(如使用 NVIDIA GPU)
  • PyTorch 2.x
  • transformers
  • accelerate
  • bitsandbytes

安装示例:

pip install torch transformers accelerate bitsandbytes

如果你没有 GPU,示例中的部分代码仍然能运行,但性能优化效果就不明显了。


核心原理

这一节我会把三类优化放到同一张“性能地图”里讲清楚。

1. 量化:降低权重存储和访存成本

量化的核心思想是:
把原来用 FP16 / BF16 存储的模型权重,转换成更低位宽,比如 8bit、4bit。

这会带来两个直接收益:

  • 显存占用下降
  • 访存带宽压力减轻

对于推理来说,很多时候瓶颈不完全是算力,而是内存带宽和数据搬运。所以量化常常比大家想象中更有效。

常见量化方式:

  • INT8:精度损失通常较小,兼容性较好
  • 4bit:压缩更狠,适合显存紧张场景
  • AWQ/GPTQ:离线量化,推理表现通常更好
  • SmoothQuant:适合某些服务端部署方案

但要注意:
量化不是“白捡性能”,它也可能带来:

  • 生成质量下降
  • 某些层不稳定
  • 特定硬件/内核支持不佳
  • 首次加载时间变长

2. KV Cache:避免重复计算历史 token

在自回归生成中,第 t 步生成时,并不需要把前面所有 token 再完整算一遍。
注意力机制里,历史 token 对应的 Key/Value 可以缓存起来,后续直接复用。

如果没有 KV Cache:

  • 第 1 个 token 算一次
  • 第 2 个 token 又把历史重算
  • 第 100 个 token 还在不断重复算前面 99 个 token

有了 KV Cache:

  • 历史 K/V 存下来
  • 新 token 只补增量计算

这就是为什么长输出场景下,KV Cache 几乎是必选项

不过 KV Cache 也不是没有代价:

  • 它会显著增加显存占用
  • 上下文越长、并发越高,缓存越大
  • 容量管理不好会直接 OOM

3. 并发调度:把 GPU 真正跑满

单请求快,不代表整体服务快。
在线服务里更常见的问题是:请求长度不均、到达时间随机、资源被切碎

并发调度的目标是:

  • 通过动态批处理提高 GPU 利用率
  • 尽量减少短请求被长请求拖慢
  • 在延迟和吞吐之间找到平衡

常见策略包括:

  • Static Batching:固定批大小,简单但不灵活
  • Dynamic Batching:按时间窗聚合请求
  • Continuous Batching:生成过程中动态插入/移除请求
  • 长度分桶:把相近长度请求放一起,减少 padding 浪费

下面这张图展示三种优化作用在哪个阶段。

flowchart TD
    A[推理请求生命周期] --> B[模型加载]
    B --> C[Prefill]
    C --> D[Decode]
    D --> E[返回结果]

    F[量化] --> B
    F --> C
    F --> D

    G[KV Cache] --> D

    H[动态批处理/并发调度] --> C
    H --> D

一个很重要的判断:先优化哪一层?

在真实项目里,我建议按这个顺序排查:

  1. 先看模型是否装得下、显存是否稳定
    不稳定先做量化
  2. 再看 decode 是否慢
    输出长时优先看 KV Cache
  3. 最后看多用户并发吞吐
    服务化后重点看调度策略

这不是绝对顺序,但通常是最省时间的路线。


实战代码(可运行)

下面我们做三个逐步实验:

  1. 基线推理
  2. 开启量化
  3. 对比 KV Cache 开关
  4. 用一个简化版并发调度器演示动态批处理思路

说明:示例使用 Hugging Face Transformers,模型用较小的指令模型以方便测试。你可以替换成自己的模型。


实战一:基线推理与指标采集

先写一个最小可运行版本,并记录耗时。

import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"

device = "cuda" if torch.cuda.is_available() else "cpu"

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()

prompt = "请用简洁语言解释什么是 KV Cache,以及它为什么能提升大模型推理速度。"

inputs = tokenizer(prompt, return_tensors="pt").to(device)

start = time.perf_counter()
with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=128,
        do_sample=False,
        use_cache=True,
    )
end = time.perf_counter()

text = tokenizer.decode(outputs[0], skip_special_tokens=True)

print(text)
print(f"总耗时: {end - start:.3f}s")

if device == "cuda":
    print(f"显存占用: {torch.cuda.max_memory_allocated() / 1024**2:.2f} MB")

这段代码看什么

先别急着改参数,先记录三件事:

  • 总耗时
  • 最大显存
  • 输出质量是否正常

如果你连这个基线都没记录,后面任何“优化”都很难证明是有效的。


实战二:开启 4bit 量化

下面用 bitsandbytes 演示 4bit 量化加载。

import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig

MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"

device = "cuda" if torch.cuda.is_available() else "cpu"

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=quant_config,
    device_map="auto"
)
model.eval()

prompt = "请解释量化对大模型推理性能和显存的影响,并说明可能的副作用。"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

start = time.perf_counter()
with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=128,
        do_sample=False,
        use_cache=True,
    )
end = time.perf_counter()

text = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(text)
print(f"4bit 量化总耗时: {end - start:.3f}s")

if torch.cuda.is_available():
    print(f"显存占用: {torch.cuda.max_memory_allocated() / 1024**2:.2f} MB")

量化实验怎么判断值不值

建议对比以下项目:

维度未量化4bit 量化
模型加载显存
首次响应时间可能较稳定可能略有变化
输出速度视硬件而定可能更快,也可能持平
质量稳定性需验证

我自己的经验是:

  • 显存不足场景:量化几乎是必选
  • 高质量、强一致输出场景:先做回归测试再上线
  • 极致低延迟场景:不要只看量化,内核实现和服务框架也很关键

实战三:KV Cache 开关对比

很多人知道 use_cache=True,但没有真的做过对比。
下面用相同输入分别测试开关。

import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()

prompt = "请生成一段关于企业级大模型推理优化的说明,包含量化、KV Cache 与并发调度三部分,每部分两句话。"
inputs = tokenizer(prompt, return_tensors="pt").to(device)

def run_once(use_cache: bool):
    if device == "cuda":
        torch.cuda.reset_peak_memory_stats()
    start = time.perf_counter()
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=200,
            do_sample=False,
            use_cache=use_cache,
        )
    end = time.perf_counter()
    text = tokenizer.decode(outputs[0], skip_special_tokens=True)
    mem = torch.cuda.max_memory_allocated() / 1024**2 if device == "cuda" else 0
    return end - start, mem, text

t1, m1, _ = run_once(use_cache=False)
t2, m2, _ = run_once(use_cache=True)

print(f"use_cache=False -> 耗时: {t1:.3f}s, 显存: {m1:.2f} MB")
print(f"use_cache=True  -> 耗时: {t2:.3f}s, 显存: {m2:.2f} MB")

你大概率会观察到

  • use_cache=True 更快
  • 但显存占用更高
  • 输出越长,KV Cache 的收益越明显

KV Cache 的本质是用空间换时间
如果你是长对话、长生成、Agent 多轮场景,这个交换通常很值。


实战四:一个简化版并发调度器

真正的在线高性能服务,通常会用 vLLM、TGI、TensorRT-LLM 等框架。
但为了理解原理,我们可以先写一个简化版动态批处理器

这个示例不追求工业级完备,而是帮助你看懂“请求聚合”的核心思路。

import time
import queue
import threading
from dataclasses import dataclass
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()

@dataclass
class Request:
    prompt: str
    max_new_tokens: int
    result: dict

request_queue = queue.Queue()

def batch_worker(batch_size=4, wait_ms=50):
    while True:
        batch = []
        start_wait = time.time()

        while len(batch) < batch_size:
            timeout = max(0, wait_ms / 1000 - (time.time() - start_wait))
            try:
                req = request_queue.get(timeout=timeout)
                batch.append(req)
            except queue.Empty:
                break

        if not batch:
            continue

        prompts = [r.prompt for r in batch]
        max_new_tokens = max(r.max_new_tokens for r in batch)

        inputs = tokenizer(
            prompts,
            return_tensors="pt",
            padding=True,
            truncation=True
        ).to(device)

        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                do_sample=False,
                use_cache=True,
            )

        texts = tokenizer.batch_decode(outputs, skip_special_tokens=True)

        for req, text in zip(batch, texts):
            req.result["text"] = text
            req.result["done"] = True

def submit_request(prompt, max_new_tokens=64):
    result = {"done": False, "text": None}
    req = Request(prompt=prompt, max_new_tokens=max_new_tokens, result=result)
    request_queue.put(req)
    return result

worker = threading.Thread(target=batch_worker, daemon=True)
worker.start()

examples = [
    "解释什么是推理量化。",
    "解释 KV Cache 的作用。",
    "说明连续批处理为什么能提高吞吐。",
    "Prefill 和 Decode 有什么区别?",
]

results = [submit_request(x) for x in examples]

while not all(r["done"] for r in results):
    time.sleep(0.1)

for i, r in enumerate(results, 1):
    print(f"=== 请求 {i} ===")
    print(r["text"])
    print()

这个调度器体现了什么

它做了两件关键事:

  1. 短时间窗口聚合请求
  2. 把多个 prompt 合成一个 batch 一起推理

这能提升吞吐,但也有代价:

  • 等待窗口会增加首 token 延迟
  • 长短请求混在一起可能造成尾部拖累
  • padding 太多会浪费计算

所以在线上,调度策略通常不是“批越大越好”,而是要根据 SLA 调整。


并发调度策略对比

下面这张图可以帮助你理解为什么 continuous batching 更适合大模型服务。

sequenceDiagram
    participant U1 as Request A
    participant U2 as Request B
    participant S as Scheduler
    participant G as GPU Worker

    U1->>S: 提交请求
    S->>G: 加入 batch 执行 prefill/decode
    U2->>S: 提交请求
    S->>G: 在后续轮次动态插入
    G-->>S: A 生成一个 token
    G-->>S: B 生成一个 token
    S-->>U1: 流式返回
    S-->>U2: 流式返回

再进一步抽象一下,调度器内部常见状态如下:

stateDiagram-v2
    [*] --> Waiting
    Waiting --> Batched: 达到时间窗/批大小
    Batched --> Prefill
    Prefill --> Decoding
    Decoding --> Decoding: 继续生成 token
    Decoding --> Finished: 请求完成
    Finished --> [*]

逐步验证清单

如果你准备把这些优化落到项目里,我建议按下面顺序做验证。

第一步:确认基线

  • 固定模型版本
  • 固定 prompt 集合
  • 固定 max_new_tokens
  • 记录 TTFT、tokens/s、显存、P95

第二步:验证量化收益

  • 对比加载显存下降比例
  • 看输出质量是否可接受
  • 观察首 token 是否异常变慢
  • 检查某些 prompt 是否出现明显退化

第三步:验证 KV Cache

  • 对短输出与长输出分别测试
  • 看速度提升是否随输出长度变大
  • 测显存增长曲线
  • 多并发下观察是否容易 OOM

第四步:验证并发调度

  • 单请求与多请求吞吐分别测试
  • 比较 batch size = 1/2/4/8
  • 比较等待窗 10ms/30ms/50ms
  • 观察短请求是否被长请求显著拖慢

常见坑与排查

这一节很重要,因为推理优化最烦人的地方不是“原理难”,而是现象很碎。我把常见坑按症状列出来。

1. 开了量化,反而不快

可能原因

  • 当前 GPU 对低比特推理支持一般
  • 模型虽然量化了,但某些算子没走高效 kernel
  • batch 太小,量化节省的访存不足以覆盖额外开销

排查方法

  • 看 GPU 型号和驱动版本
  • 用 PyTorch profiler 或 Nsight 看热点算子
  • 对比 load_in_8bitload_in_4bit
  • 比较不同 batch size 下的性能

建议

  • 如果重点是装得下,量化通常有效
  • 如果重点是绝对低延迟,要实测,不要凭感觉

2. KV Cache 开了以后 OOM

可能原因

  • 上下文太长
  • 并发请求太多
  • max_new_tokens 设置过大
  • 忘了控制会话生命周期,缓存一直积累

排查方法

  • 统计平均输入长度和输出长度
  • 限制最长上下文
  • 降低并发或 batch size
  • 检查服务端是否有“僵尸会话”

建议

我踩过一个很典型的坑:
离线测试时一切正常,上线后因为用户对话轮数越来越长,KV Cache 持续膨胀,最终显存被吃满。
所以别只测“单轮短对话”,一定要测长会话场景


3. 动态批处理吞吐高了,但用户觉得更慢

可能原因

  • 等待窗口太长
  • 为了凑 batch,牺牲了 TTFT
  • 长短请求混合导致 tail latency 上升

排查方法

  • 同时看平均延迟和 P95/P99
  • 单独统计短请求延迟
  • 缩小 batching window
  • 对请求做长度分桶

建议

如果你的产品是聊天应用,用户对首 token 非常敏感。
这时不要盲目追求大 batch,很多时候小窗口 + continuous batching更平衡。


4. 结果偶发异常、乱码或重复输出

可能原因

  • tokenizer 和 model 版本不一致
  • pad/eos 配置错误
  • 量化后个别 prompt 数值不稳定
  • 批处理时输出切分逻辑有问题

排查方法

print("pad_token_id:", tokenizer.pad_token_id)
print("eos_token_id:", tokenizer.eos_token_id)
print("model dtype:", next(model.parameters()).dtype)

还可以显式设置:

outputs = model.generate(
    **inputs,
    max_new_tokens=128,
    do_sample=False,
    use_cache=True,
    pad_token_id=tokenizer.eos_token_id,
    eos_token_id=tokenizer.eos_token_id,
)

安全/性能最佳实践

推理优化不只是“更快”,还要“更稳、更可控”。

1. 设定输入与输出上限

这是最基础也最容易被忽略的一点。

建议限制:

  • 最大输入 token 数
  • 最大输出 token 数
  • 单用户并发数
  • 单会话最大轮数

否则一个超长 prompt 就可能把整个服务拖垮。


2. 区分在线流量与离线批处理

两类场景优化目标不同:

  • 在线服务:优先 TTFT、P95
  • 离线批处理:优先吞吐、单位成本

不要拿离线最优参数直接套在线服务。


3. 做长度分桶,减少 padding 浪费

把长度接近的请求放进同一批次,常常是最划算的调优手段之一。

比如:

  • 1~256 tokens
  • 257~1024 tokens
  • 1025~4096 tokens

这件事实现不算复杂,但收益通常很稳定。


4. 把显存预算拆开看

不要只盯着“模型权重占多少”。

更合理的显存预算应包含:

  • 模型权重
  • 激活临时开销
  • KV Cache
  • 框架运行时开销
  • 碎片化冗余

很多人 OOM 的原因不是模型本身,而是低估了 KV Cache 和碎片


5. 优先使用成熟推理框架

如果你是业务团队,不一定要自己从零写调度器。
优先考虑成熟方案:

  • vLLM:KV Cache 管理与 continuous batching 很强
  • TGI:部署方便,生态成熟
  • TensorRT-LLM:对 NVIDIA 体系优化深入
  • SGLang:适合复杂推理编排场景

自己手写服务可以帮助理解原理,但线上大规模部署,还是尽量站在成熟框架肩膀上。


6. 监控要能区分 Prefill 和 Decode

很多团队只看总耗时,这会让问题定位非常模糊。

建议至少拆分监控:

  • prefill latency
  • decode latency
  • TTFT
  • output tokens/s
  • active KV cache size
  • batch size 分布

只有这样,你才能知道瓶颈到底在输入阶段、生成阶段,还是调度阶段。


一个可执行的优化路线图

如果你现在手上已经有一个“能跑但不够快”的服务,我建议这样推进:

场景 A:单卡显存紧张

优先级:

  1. 4bit/8bit 量化
  2. 限制上下文长度
  3. 控制 max_new_tokens
  4. 再考虑更高效的推理框架

场景 B:单请求生成太慢

优先级:

  1. 确认 use_cache=True
  2. 检查长输出场景收益
  3. 看是否能切到更高效 attention 内核
  4. 评估 speculative decoding 等更高级策略

场景 C:并发上来后吞吐掉太多

优先级:

  1. 动态批处理
  2. continuous batching
  3. 长度分桶
  4. 更好的 KV Cache 管理策略

场景 D:用户抱怨“首字太慢”

优先级:

  1. 缩短 batching window
  2. 优化 prefill
  3. 控制超长 prompt
  4. 做 prompt cache 或模板复用

总结

大模型推理优化,最容易犯的错就是:
只盯一个点,而忽略整个链路。

这篇文章的核心结论其实很朴素:

  • 量化解决“模型太大、资源太重”
  • KV Cache解决“生成阶段重复计算”
  • 并发调度解决“服务化后 GPU 利用率不高”

你可以把它们理解成三个层次:

  1. 先让模型跑起来
  2. 再让单请求跑快
  3. 最后让整体服务跑稳、跑满

如果你问我最实用的建议是什么,我会给这三条:

  • 先量指标,再谈优化
  • 先找主瓶颈,再选手段
  • 吞吐、延迟、显存,三者一定要一起权衡

最后给一个很接地气的边界条件判断:

  • 如果你还是在单机实验阶段,先把量化 + KV Cache吃透
  • 如果你已经准备上线,尽快引入成熟推理框架 + 调度监控
  • 如果你在做高并发生产服务,重点就不再是“会不会开参数”,而是缓存管理、调度策略和容量规划

推理优化这件事,没有一招鲜,但只要你按“量化 → KV Cache → 并发调度”这条路径一步步压实,性能提升通常是看得见的。


分享到:

上一篇
《从抓包到还原签名流程:一次典型 Web 逆向中前端加密参数生成的实战分析》
下一篇
《Java开发踩坑实战:定位并解决线程池误用导致的请求堆积与OOM问题》