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

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

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

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

做大模型服务时,很多团队最先遇到的不是“模型不够聪明”,而是“模型太慢、太贵、扛不住并发”。

我自己在落地推理服务时,最常见的现场是这样的:离线测试看起来还行,到了线上一压测,首 token 延迟飙升、GPU 显存打满、吞吐上不去,最后只能一边降并发一边加机器。问题不在某一个点,而在于推理链路是一个系统工程:模型结构、KV Cache、量化方式、批处理策略、请求调度、上下文长度,都会互相影响。

这篇文章不追求“把所有理论讲完”,而是站在工程落地视角,带你把一条常见优化路径走通:

  1. 先理解性能瓶颈在哪里
  2. 再抓住最关键的三个杠杆:KV Cache、量化、并发调度
  3. 最后用一套可运行代码做一个最小实践,并给出排查清单

如果你已经能跑通 Hugging Face 或 vLLM 的基础推理,这篇内容会比较适合你。


背景与问题

大模型推理慢,通常不是一个模糊概念,而是几类指标出了问题:

  • TTFT(Time To First Token):首 token 时间过长
  • TPOT(Time Per Output Token):每个生成 token 的平均耗时高
  • 吞吐量低:单位时间处理不了足够多请求
  • 显存压力大:上下文一长或者并发一高就 OOM
  • 成本高:单次调用太贵,GPU 利用率还不高

这些问题往往来自推理阶段两个不同过程:

  1. Prefill 阶段:把输入上下文整段送进模型,计算历史 token 的注意力表示
  2. Decode 阶段:每次只生成一个新 token,并不断追加状态

这两个阶段的瓶颈并不一样:

  • Prefill 更偏计算密集
  • Decode 更偏访存密集 / cache 密集

也正因为这样,很多“看起来合理”的优化,放到具体阶段未必有效。比如:

  • 量化通常对显存和吞吐有帮助,但不同量化方式对首 token 和生成速度影响不完全一样
  • KV Cache 会显著降低重复计算,但也会持续吞噬显存
  • 连续批处理能提升 GPU 利用率,但排队策略不对会让尾延迟很难看

先看一张总览图。

flowchart TD
    A[用户请求到达] --> B[Tokenizer]
    B --> C[Prefill 阶段]
    C --> D[建立 KV Cache]
    D --> E[Decode 阶段]
    E --> F[流式返回 Token]
    C --> G[计算瓶颈]
    D --> H[显存瓶颈]
    E --> I[调度瓶颈]

前置知识与环境准备

这篇文章默认你知道这些基本概念:

  • Transformer 的注意力机制
  • 自回归生成的基本过程
  • PyTorch 基础推理用法
  • Hugging Face Transformers 的基础加载方式

环境建议

为了让示例更容易跑起来,我这里给两套思路:

方案 A:本地最小实验

适合验证 KV Cache 与量化的效果。

  • Python 3.10+
  • PyTorch 2.2+
  • transformers 4.40+
  • accelerate
  • bitsandbytes(如果你在 NVIDIA GPU 上做 4bit/8bit 量化)

安装示例:

pip install torch transformers accelerate
pip install bitsandbytes

方案 B:工程化服务验证

适合进一步做并发调度和吞吐实验。

  • vLLM 或 TGI
  • 一张 24GB 以上显存 GPU 更舒服
  • locust / wrk / vegeta 做压测

核心原理

这一节只讲工程上最有用的部分,不绕太深。

1. 为什么 KV Cache 能加速推理

在自回归生成里,模型每生成一个 token,都需要关注前面所有 token。如果不做缓存,那么第 t 步生成时,会把前 1..t-1 的 Key/Value 重新算一遍,代价非常高。

KV Cache 的做法是:

  • 第一次 prefill 时,把每一层 attention 的 K/V 张量保存起来
  • 后续 decode 时,只计算新 token 的 Q/K/V
  • 其中历史 token 的 K/V 直接复用缓存

这会把大量重复计算省掉。

sequenceDiagram
    participant Client as Client
    participant Model as LLM
    participant Cache as KV Cache

    Client->>Model: 输入 prompt
    Model->>Cache: 写入历史 token 的 K/V
    Model-->>Client: 首个 token

    loop 每生成一个 token
        Client->>Model: 继续生成
        Model->>Cache: 读取历史 K/V
        Model->>Cache: 追加新 token K/V
        Model-->>Client: 下一个 token
    end

KV Cache 的收益

  • 降低 decode 阶段重复计算
  • 提升长输出场景下的 token/s
  • 对多轮对话尤其有效

KV Cache 的代价

  • 显存占用会随着:
    • batch 增大
    • 上下文变长
    • 层数变多
    • hidden size 变大
      持续增长
  • 并发高时,cache 管理会成为服务核心问题

一句话概括:KV Cache 本质上是在用显存换速度


2. 量化为什么能显著降低成本

量化的目标,是把模型权重从高精度表示压缩成低精度表示。

常见情况:

  • FP16 / BF16:2 字节
  • INT8:1 字节
  • INT4 / 4bit:0.5 字节左右(实际还有额外元数据)

直观好处很明显:

  • 模型占用显存更少
  • 同一张卡能放更大的模型
  • 同一张卡能开更高并发
  • 数据搬运带宽压力降低

但量化不是“白拿收益”,它涉及三类取舍:

精度取舍

量化后可能有困惑度上升、幻觉增多、格式稳定性下降的问题,尤其在:

  • 代码生成
  • 数学推理
  • 严格结构化输出

算子支持取舍

有些量化方式推理框架支持非常好,有些只是“能跑”,但速度不一定快。

运维复杂度取舍

量化模型、权重格式、推理引擎、GPU 架构之间有兼容性差异。

一个比较实用的经验是:

  • 先用 8bit/4bit 做容量验证
  • 再针对核心业务集做 A/B 精度评估
  • 最后决定是否大规模切换

3. 并发调度为什么常常决定最终吞吐

很多人以为“把模型优化好了,服务就自然快”。实际上,服务层调度策略对线上体验影响极大。

同样一张 GPU,为什么有的服务吞吐高很多?常见差异就在这里:

  • 是否做了 动态批处理(dynamic batching)
  • 是否做了 连续批处理(continuous batching)
  • 是否区分 prefill 和 decode 的资源竞争
  • 是否对长请求、短请求做队列隔离
  • 是否有 cache 回收和请求抢占机制

下面这张图可以帮助理解:

flowchart LR
    A[请求队列] --> B{调度器}
    B --> C[Prefill Batch]
    B --> D[Decode Batch]
    C --> E[GPU 执行]
    D --> E[GPU 执行]
    E --> F[结果流式返回]
    E --> G[KV Cache 分配/回收]

4. Prefill 与 Decode 要分开看

这是很多性能分析里最容易忽略的一点。

Prefill 特征

  • 输入长时,耗时陡增
  • 更依赖算力和大批量计算效率
  • 影响 TTFT 很明显

Decode 特征

  • 一次只生成一个 token
  • 更依赖 cache 读写和调度效率
  • 影响稳定 token/s 和整体吞吐

所以工程上经常要做不同优化:

  • 优化 TTFT:优先看 prompt 长度、prefill batching、模型大小、prompt cache
  • 优化吞吐:优先看 KV Cache、连续批处理、量化、并发调度

一条实用的工程落地路径

如果你现在有一个“能跑但不快”的大模型服务,我建议按这个顺序推进,而不是同时改一堆东西:

阶段 1:先建立基线

先测这些指标:

  • P50 / P95 TTFT
  • P50 / P95 TPOT
  • 平均 token/s
  • GPU 显存占用
  • GPU 利用率
  • 每请求平均输入长度 / 输出长度

阶段 2:启用 KV Cache

目标:

  • 验证 decode 提速是否明显
  • 观察显存增长曲线
  • 建立 cache 生命周期监控

阶段 3:尝试量化

目标:

  • 降低显存占用
  • 提升可承载 batch / 并发
  • 评估精度损失边界

阶段 4:改造调度

目标:

  • 让 GPU 少空转
  • 减少短请求被长请求拖慢
  • 把吞吐做上去,同时控制尾延迟

阶段 5:做压测闭环

目标:

  • 找到最优 batch / 并发点
  • 明确 OOM 拐点
  • 给出 SLA 边界条件

实战代码(可运行)

下面我用 Hugging Face 给一个最小可运行示例,演示三件事:

  1. 比较启用 / 不启用 KV Cache 的生成性能
  2. 使用 4bit 量化加载模型
  3. 做一个简单的并发请求模拟

说明:示例使用较小模型,方便你在普通 GPU 上验证流程。真正线上服务建议迁移到 vLLM / TGI 之类更成熟的推理引擎。


示例 1:对比 KV Cache 开关

import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL_NAME = "distilgpt2"  # 演示用小模型,方便跑通

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

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME).to(device)
model.eval()

prompt = "Explain KV cache in transformer inference in simple words."
inputs = tokenizer(prompt, return_tensors="pt").to(device)

def benchmark(use_cache: bool, max_new_tokens: int = 64):
    torch.cuda.empty_cache() if torch.cuda.is_available() else None
    start = time.perf_counter()
    with torch.no_grad():
        output = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            do_sample=False,
            use_cache=use_cache,
            pad_token_id=tokenizer.eos_token_id,
        )
    end = time.perf_counter()
    text = tokenizer.decode(output[0], skip_special_tokens=True)
    return end - start, text

t1, text1 = benchmark(use_cache=False)
t2, text2 = benchmark(use_cache=True)

print(f"use_cache=False 耗时: {t1:.4f}s")
print(f"use_cache=True  耗时: {t2:.4f}s")
print("输出预览:")
print(text2[:300])

你应该关注什么

  • use_cache=True 通常会更快,尤其在生成 token 较多时更明显
  • 小模型差距可能不夸张,但在大模型长输出场景下收益会放大
  • 如果你发现没变快,要考虑:
    • 输出太短
    • 模型本身太小
    • CPU 推理不明显
    • 测试方式把 tokenizer / I/O 时间也算进去了

示例 2:4bit 量化加载模型

如果你使用 NVIDIA GPU,并且安装了 bitsandbytes,可以试试 4bit 量化。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"

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

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)

model.eval()

prompt = "请用简单语言解释什么是 KV Cache。"
messages = [
    {"role": "system", "content": "你是一个简洁清晰的技术助手。"},
    {"role": "user", "content": prompt},
]

text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True
)

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

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

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

这段代码验证的重点

  • 量化后模型是否能在你的显存内稳定加载
  • 输出质量是否还能满足业务要求
  • 同样 GPU 下,是否能承载更长上下文或更高并发

一个很现实的提醒

4bit 并不总是“最快”,它往往首先带来的是:

  • 更低显存占用
  • 更好的部署可行性

速度收益是否明显,要看:

  • 模型大小
  • GPU 架构
  • 框架优化程度
  • batch 大小
  • 是否受带宽限制

示例 3:简单并发压测脚本

下面给一个非常轻量的并发测试示例,模拟多个请求同时调用模型。
注意:这不是最佳生产方案,只是为了帮你建立“调度影响吞吐”的直觉。

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

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

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME).to(device)
model.eval()

prompts = [
    "What is KV cache?",
    "Explain quantization in LLM inference.",
    "Why does dynamic batching improve throughput?",
    "What causes OOM during long context generation?",
    "How to reduce time to first token?",
] * 2

def run_inference(prompt: str):
    inputs = tokenizer(prompt, return_tensors="pt").to(device)
    start = time.perf_counter()
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=64,
            do_sample=False,
            use_cache=True,
            pad_token_id=tokenizer.eos_token_id,
        )
    end = time.perf_counter()
    return {
        "prompt": prompt,
        "latency": end - start,
        "text": tokenizer.decode(outputs[0], skip_special_tokens=True)
    }

async def worker(prompt: str):
    return await asyncio.to_thread(run_inference, prompt)

async def main():
    start_all = time.perf_counter()
    results = await asyncio.gather(*[worker(p) for p in prompts])
    end_all = time.perf_counter()

    print(f"总耗时: {end_all - start_all:.4f}s")
    print(f"请求数: {len(results)}")
    avg_latency = sum(r["latency"] for r in results) / len(results)
    print(f"平均单请求耗时: {avg_latency:.4f}s")

    for i, r in enumerate(results[:3]):
        print(f"\n--- 请求 {i+1} ---")
        print(f"latency={r['latency']:.4f}s")
        print(r["text"][:200])

if __name__ == "__main__":
    asyncio.run(main())

这段代码的意义

它会让你看到一个现象:
“并发发请求”不等于“GPU 吞吐最优”。

如果你继续把这类测试升级成:

  • 请求分桶
  • 动态 batch
  • 长短 prompt 混合
  • 流式输出

你就会很快触达真正的服务调度问题,而不是停留在单请求优化。


逐步验证清单

我很建议你按这个顺序做,不然很容易“改了很多,却不知道哪一步有效”。

第一步:单请求基线

  • 固定 prompt
  • 固定输出长度
  • 记录 TTFT、总耗时、显存占用

第二步:开关 KV Cache

  • 比较 use_cache=False/True
  • 输出长度从 32、128、256 逐步增大
  • 观察收益是否随输出长度增加而放大

第三步:量化对比

  • FP16/BF16 vs INT8/INT4
  • 记录显存、速度、输出质量
  • 准备一小批业务真实样本做人工评估

第四步:并发实验

  • 固定模型和参数
  • 并发从 1、2、4、8、16 逐步压
  • 记录 P95 延迟和 OOM 点

第五步:长短请求混测

  • 短 prompt + 长 prompt 混合
  • 看尾延迟是否恶化
  • 看短请求是否被“拖车”

常见坑与排查

这一节我尽量讲得接地气一点,因为这些坑真的很常见。

坑 1:开了 KV Cache,却没看到明显提速

可能原因

  • 输出 token 太少
  • 模型太小,小到 cache 收益不明显
  • 主要瓶颈在 prefill,不在 decode
  • 你把 tokenizer、日志、网络传输时间也混进去了

排查建议

  • 拉大 max_new_tokens
  • 分开测 prefill 与 decode
  • 用 profiler 看 GPU kernel 时间分布

坑 2:量化后显存省了,但速度没提升,甚至变慢

可能原因

  • 某些量化算子在你的硬件上没充分优化
  • batch 太小,看不到吞吐优势
  • CPU/GPU 之间有额外数据搬运
  • 推理框架对该量化格式支持一般

排查建议

  • 先看显存收益是否达到预期
  • 再测不同 batch 大小
  • 换更适合生产的引擎,如 vLLM、TensorRT-LLM、TGI
  • 不要只盯着单请求延迟,也看吞吐

坑 3:高并发时 GPU 利用率不高,但延迟很高

这是线上最让人烦的情况之一。

常见原因

  • 请求没有有效 batching
  • 长短请求混在一个队列
  • decode 粒度太细,调度开销大
  • CPU 侧 tokenization / postprocess 成了瓶颈
  • 流式输出线程模型有阻塞

排查建议

  • 看队列等待时间
  • 看 prefill 和 decode 是否分离统计
  • 监控 CPU 使用率、线程切换、事件循环阻塞
  • 检查 tokenizer 是否需要迁移到更高效实现

坑 4:上下文一长就 OOM

常见原因

  • KV Cache 增长超出预估
  • batch size 设置过激进
  • 没有及时回收已完成请求的 cache
  • 多轮对话 session 累积上下文没有裁剪

排查建议

先建立一个基本意识:
OOM 不只是模型权重问题,更多时候是“权重 + 激活 + KV Cache + batch”的总和问题。

可以从这几个方向排查:

  • 限制最大上下文长度
  • 限制最大生成长度
  • 对会话历史做摘要或裁剪
  • 做 cache eviction
  • 把长请求和短请求分池

坑 5:压测结果很好,线上体验却不稳定

这个坑我自己踩过。离线压测通常过于理想化,线上会多出很多变量:

  • prompt 分布不均匀
  • 用户会中断、重试、取消请求
  • 流式连接数量变化大
  • 多租户抢资源
  • 上游限流不稳定

建议

压测时至少模拟这三种负载:

  • 短 prompt,短输出
  • 长 prompt,短输出
  • 长 prompt,长输出

否则很容易误判最佳参数。


安全/性能最佳实践

这一节把工程上最值得坚持的做法收拢一下。

1. 先定容量红线,再谈性能优化

上线前明确这些边界:

  • 最大上下文长度
  • 最大输出长度
  • 单实例最大并发
  • 单租户最大 QPS
  • OOM 后的降级策略

如果这些边界不清晰,任何优化都可能在流量上来后失效。


2. 把 KV Cache 当成一等公民管理

建议你监控这些指标:

  • 当前 cache 占用显存
  • 活跃 session 数
  • 平均上下文长度
  • cache 命中 / 复用情况
  • cache 回收耗时

如果服务支持多轮对话,不管理 cache,后面几乎一定出问题。


3. 量化不要只看“能不能跑”,要看“业务质量是否可接受”

建议准备一个小型评测集,覆盖:

  • 问答
  • 摘要
  • 代码
  • 结构化输出
  • 多轮上下文理解

然后对比:

  • 原始模型输出
  • 量化模型输出
  • 是否出现格式错误、事实偏差、推理断裂

有些场景 4bit 很稳,有些场景就会明显掉点,别一刀切。


4. 并发调度上优先做“简单但有效”的策略

在早期版本里,我建议先做这几件事:

  • 动态批处理
  • 长短请求分桶
  • 限制超长 prompt
  • 流式输出时支持取消
  • 请求超时和背压机制

这些策略并不花哨,但收益通常非常大。


5. 做好可观测性

至少要有以下监控:

  • TTFT、TPOT、总延迟
  • P50/P95/P99
  • GPU 利用率、显存利用率
  • 每请求输入长度、输出长度
  • 队列等待时间
  • OOM 次数、重试次数、取消次数

没有这些数据,你很难判断问题到底出在模型、框架还是调度。


6. 对外暴露能力时要考虑安全与滥用风险

大模型推理服务不只是性能问题,也有资源安全问题:

  • 超长输入可能造成资源恶意占用
  • 高频请求可能拖垮实例
  • 多轮会话可能积累异常大的上下文

建议配套措施:

  • 输入长度限制
  • 用户级限流
  • 并发数配额
  • 请求超时
  • 输出 token 上限
  • 异常请求熔断

一个更贴近生产的方案对比

如果你准备从 demo 走向线上,通常会在三种路线中选择:

路线适合场景优点局限
Hugging Face 原生 generate单机验证、原型开发上手快、可控调度和吞吐能力有限
vLLM通用在线推理服务连续批处理、KV 管理优秀、吞吐高框架约束较多
TensorRT-LLM / 专项加速引擎极致性能场景性能强、硬件优化深工程复杂度更高

如果你问我一个偏实战的建议:

  • 个人实验 / 小规模内网服务:先用 Transformers
  • 生产级在线服务:优先评估 vLLM
  • 超大规模高性能场景:再考虑更重型的加速栈

一套排障思路:从现象反推问题位置

flowchart TD
    A[现象: 延迟高/吞吐低/OOM] --> B{先看哪类指标异常}
    B -->|TTFT 高| C[重点查 Prefill: prompt 长度/模型大小/输入 batching]
    B -->|TPOT 高| D[重点查 Decode: KV Cache/访存/调度]
    B -->|显存高| E[重点查 权重精度/KV Cache/上下文长度]
    B -->|GPU 利用率低| F[重点查 批处理/队列/CPU瓶颈]
    C --> G[限长、prompt cache、优化 prefill]
    D --> H[启用 KV Cache、连续批处理、优化流式调度]
    E --> I[量化、裁剪会话、cache 回收]
    F --> J[动态批处理、长短请求分桶、排队可视化]

这张图的核心意思很简单:
不要一上来就“换模型、换框架、开量化”,先定位瓶颈在哪一段。


总结

大模型推理优化,真正有效的方法很少是“单点神技”,而是几件事配合起来:

  • KV Cache:解决 decode 阶段重复计算,用显存换速度
  • 量化:解决显存与成本问题,为更高并发腾空间
  • 并发调度:决定 GPU 是否真正吃满,也是线上吞吐的关键

如果你要快速落地,我建议按下面这个顺序执行:

  1. 建立基线指标:TTFT、TPOT、显存、吞吐
  2. 先开 KV Cache:确认 decode 是否提速
  3. 再试量化:先看显存收益,再看精度边界
  4. 最后调度优化:动态 batch、长短请求分桶、背压与超时
  5. 用真实流量分布压测:找到容量上限和 SLA 边界

最后给一个很实用的结论:

如果你的服务已经进入“线上多人共用”的阶段,决定体验上限的,往往不只是模型本身,而是你如何管理 KV Cache、如何选择量化方案、以及如何调度请求。

把这三件事做好,大模型推理服务的性能通常就能从“能跑”迈向“能上线、能扛量、能控成本”。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》