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

《大模型推理服务实战:从模型量化、KV Cache 优化到高并发部署的性能调优指南》

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

大模型推理服务实战:从模型量化、KV Cache 优化到高并发部署的性能调优指南

做大模型推理服务时,很多团队一开始都盯着“模型多大、显卡多贵、TPS 多高”,但真正上线后,问题往往出在更细的地方:显存不够、首 Token 延迟过高、并发一上来吞吐骤降、KV Cache 抖动、量化后回答质量不稳定。

这篇文章我想换一个更贴近落地的角度:把大模型推理服务看成一条完整的数据通路,从模型权重、注意力缓存、调度策略,到多卡部署和流量治理,逐层分析性能瓶颈和优化方法。目标不是“把每个点讲得最学术”,而是帮你搭出一套能跑、能测、能持续调优的推理架构。


背景与问题

大模型推理服务和传统 Web 服务最大的不同,是它的资源模型非常“偏态”:

  • 显存是硬约束
  • 延迟由 Prefill 和 Decode 两段组成
  • 吞吐与并发并不是线性关系
  • 同样的 QPS,不同请求长度对系统压力完全不同

如果把问题拆开看,线上常见痛点通常有这几类:

  1. 模型太大,单卡放不下

    • 需要量化、张量并行、流水并行,或者更小的蒸馏模型。
  2. TTFT(Time To First Token)太高

    • 用户觉得“系统卡住了”,即使后续生成很快,体验仍然差。
  3. Decode 阶段吞吐低

    • GPU 利用率不高,但请求堆积越来越严重。
  4. KV Cache 占满显存

    • 尤其在长上下文、多轮对话、工具调用场景下非常明显。
  5. 高并发下尾延迟飙升

    • 平均值看着不错,P95/P99 已经不可用了。

换句话说,大模型推理优化不是单点优化,而是多目标权衡

  • 模型质量
  • 显存占用
  • TTFT
  • TPS
  • 尾延迟
  • 成本

这也是为什么很多团队“换了更强的卡”但效果仍然不理想,因为瓶颈往往不只在硬件。


先给结论:一条实用的优化路径

如果你是中级工程师,准备把服务从“能跑”优化到“可上线”,我建议按下面这个顺序推进:

  1. 先测基线

    • 不量化、不并发优化,先测 TTFT、tokens/s、显存占用、P95 延迟
  2. 做模型量化

    • 优先试 8bit,再试 4bit,验证质量损失边界
  3. 优化 KV Cache

    • 使用 paged attention / prefix cache / chunked prefill
  4. 接入动态批处理

    • 合并请求,提高 GPU 利用率
  5. 做并发治理

    • 队列、限流、超时、优先级调度
  6. 最后才是多卡扩展

    • 单机吃满后,再做张量并行或多实例横向扩容

这个顺序的好处是:每一步都可观测、可回滚、可比较收益


核心原理

这一节我们重点讲三个核心对象:模型量化、KV Cache、并发调度

1. 模型量化:先解决“放得下”和“跑得起”

大模型权重一般是 FP16/BF16 存储。以一个 7B 模型为例:

  • FP16:约 14GB
  • INT8:约 7GB
  • INT4:约 3.5GB

这只是权重,还不包括:

  • KV Cache
  • 激活内存
  • 框架开销
  • CUDA 工作区

量化的本质

量化就是把高精度权重映射到低比特整数表示,再在推理时通过缩放因子恢复近似值。常见方案有:

  • FP16/BF16
    • 精度好,显存大
  • INT8
    • 精度损失较小,部署友好
  • INT4 / GPTQ / AWQ
    • 显存收益大,但对不同模型敏感
  • KV Cache 量化
    • 不是量化权重,而是量化注意力缓存

量化的收益和代价

方案显存收益速度收益质量风险适用场景
FP16/BF16基线质量优先
INT8通用线上服务
INT4中到高成本敏感场景
KV Cache量化视实现而定长上下文高并发

我的经验是:业务模型先试 INT8,再看是否有必要下探到 4bit。因为很多时候瓶颈并不在权重,而在 KV Cache 和调度。


2. KV Cache:真正吃掉显存的“隐形大户”

生成式模型在 Decode 阶段,为了避免重复计算历史 token,会把每层注意力的 Key/Value 存下来,这就是 KV Cache。

它的特点是:

  • 会随着上下文长度增长
  • 会随着 batch 增长
  • 会随着层数、头数、hidden size 增长

粗略看,KV Cache 占用可以近似理解为:

KV Cache ≈ batch_size × seq_len × num_layers × hidden_factor × dtype_size

这意味着两个事实:

  1. 长上下文用户非常贵
  2. 高并发下,显存经常不是被模型权重打满,而是被 KV Cache 吃光

KV Cache 优化的几种常见手段

Paged Attention

把 KV Cache 分页管理,而不是给每个请求预留一大块连续内存。这样可以减少内存碎片,提升请求复用效率。

Prefix Cache

如果很多请求共享相同前缀,例如:

  • 系统提示词
  • 固定工具描述
  • 相同知识库模板

那么共享前缀部分的 KV Cache,可以显著降低 Prefill 成本。

Chunked Prefill

长 prompt 不一次性塞进去,而是分块预填充。这样能减少峰值显存和长请求对短请求的阻塞。

Sliding Window / Context Truncation

对超长会话保留最近窗口,或者摘要历史后再拼接,避免无上限增长。


3. 并发调度:吞吐提升的关键不只是“开更多副本”

很多团队会误以为高并发就是多开几个进程。实际上,大模型服务中的吞吐优化更依赖于调度策略

Prefill 与 Decode 的差异

  • Prefill
    • 输入整段 prompt,计算密集
    • GPU 更容易被打满
  • Decode
    • 每次只生成少量 token
    • 更偏 memory-bound,效率受批处理影响很大

这导致一个很现实的问题:
如果把长 prompt 请求和短对话请求混在一起,系统很容易互相拖慢。

动态批处理

动态批处理会在极短时间窗口内收集多个请求,组成一个 batch 一起送入 GPU。收益是:

  • 提高 GPU 利用率
  • 增加 tokens/s
  • 降低单位 token 成本

但代价也明显:

  • 请求排队时间增加
  • TTFT 可能变差
  • 如果 batch 内长度差异太大,padding 浪费严重

所以动态批处理不该“越大越好”,而应根据业务指标找到平衡点。


推理服务整体架构设计

先看一张全链路图:

flowchart LR
    A[Client / SDK] --> B[API Gateway]
    B --> C[Request Queue]
    C --> D[Scheduler]
    D --> E[Prefill Worker]
    D --> F[Decode Worker]
    E --> G[KV Cache Manager]
    F --> G
    E --> H[Model Runtime]
    F --> H
    H --> I[GPU]
    G --> I
    H --> J[Streaming Response]
    J --> A

这张图里最关键的不是“有多少层”,而是三个职责是否分清:

  • Scheduler:决定请求何时进入 batch
  • KV Cache Manager:决定缓存怎么分配、回收、复用
  • Model Runtime:负责真正推理执行

如果这三块揉成一团,后面几乎没法系统调优。


方案对比与取舍分析

1. 单实例大模型 vs 多实例小模型

方案优点缺点适用场景
单实例大模型效果通常更好成本高,扩展慢高价值问答、复杂推理
多实例小模型易扩展,吞吐高能力上限较低客服、摘要、分类改写

如果你的业务是高频、低客单价请求,往往更小的模型 + 更好的调度,比盲目追大模型更划算。

2. 只做权重量化 vs 同时做 KV Cache 优化

方案立竿见影实施复杂度长上下文收益
只做权重量化
加上 KV Cache 优化很高中到高

实际线上里,长对话、多轮 Agent、RAG 这类场景,KV Cache 优化经常比权重量化更值钱。


容量估算:上线前别拍脑袋

一个简单实用的估算方式:

输入参数

  • 模型:7B INT4
  • 单卡显存:24GB
  • 平均输入长度:1500 tokens
  • 平均输出长度:300 tokens
  • 并发目标:32
  • 每请求最大上下文:4K

估算思路

  1. 权重占用

    • 7B INT4 大约 4~5GB,加上运行时开销按 6GB 估
  2. KV Cache 预留

    • 对 32 并发、4K 上下文,往往远超 10GB,具体看实现和精度
  3. 留出安全余量

    • 至少保留 10%~15% 显存,避免碎片和突发 OOM

一个经验判断

如果你发现:

  • 模型权重只占显存的一小半
  • 长请求一来就 OOM 或吞吐抖动

那大概率就不是模型太大,而是 KV Cache 策略和批处理策略不对


KV Cache 生命周期示意

stateDiagram-v2
    [*] --> Allocated: 请求进入
    Allocated --> Prefill: 写入前缀KV
    Prefill --> Decode: 增量生成
    Decode --> Reused: 命中共享前缀
    Decode --> Evicted: 超时/显存紧张
    Reused --> Decode: 继续生成
    Evicted --> [*]

这张图提醒我们一个常被忽视的点:
KV Cache 不是“存进去就完了”,而是需要像数据库缓存一样有生命周期管理。


实战代码(可运行)

下面用一个简化版的 Python 示例,模拟一个推理服务的核心调度逻辑:

  • 请求进入队列
  • 调度器按时间窗口收集 batch
  • 模拟 Prefill / Decode 成本
  • 提供基础指标输出

这个例子不会真的调用 GPU,但能帮助你理解动态批处理和队列延迟的关系。

import asyncio
import random
import time
from dataclasses import dataclass, field
from typing import List


@dataclass
class Request:
    request_id: int
    prompt_tokens: int
    output_tokens: int
    created_at: float = field(default_factory=time.time)


@dataclass
class Result:
    request_id: int
    queue_ms: float
    ttft_ms: float
    total_ms: float


class MockInferenceEngine:
    def __init__(self, prefill_speed=8000, decode_speed=1200):
        # tokens per second
        self.prefill_speed = prefill_speed
        self.decode_speed = decode_speed

    async def run_batch(self, batch: List[Request]) -> List[Result]:
        now = time.time()

        # Prefill 阶段:按 batch 的总输入 token 近似模拟
        total_prompt_tokens = sum(r.prompt_tokens for r in batch)
        prefill_cost = total_prompt_tokens / self.prefill_speed

        # Decode 阶段:按 batch 的最大输出长度分轮次模拟
        max_output_tokens = max(r.output_tokens for r in batch)
        decode_cost = max_output_tokens / self.decode_speed

        await asyncio.sleep(prefill_cost + decode_cost)

        results = []
        finish = time.time()
        for r in batch:
            queue_ms = (now - r.created_at) * 1000
            ttft_ms = (queue_ms / 1000 + prefill_cost) * 1000
            total_ms = (finish - r.created_at) * 1000
            results.append(Result(r.request_id, queue_ms, ttft_ms, total_ms))
        return results


class DynamicBatchScheduler:
    def __init__(self, engine: MockInferenceEngine, max_batch_size=8, batch_wait_ms=20):
        self.engine = engine
        self.max_batch_size = max_batch_size
        self.batch_wait_ms = batch_wait_ms
        self.queue = asyncio.Queue()
        self.running = True

    async def submit(self, req: Request):
        await self.queue.put(req)

    async def worker(self):
        while self.running:
            batch = []
            try:
                first = await asyncio.wait_for(self.queue.get(), timeout=1.0)
                batch.append(first)
            except asyncio.TimeoutError:
                continue

            start = time.time()
            while len(batch) < self.max_batch_size:
                remain = self.batch_wait_ms / 1000 - (time.time() - start)
                if remain <= 0:
                    break
                try:
                    item = await asyncio.wait_for(self.queue.get(), timeout=remain)
                    batch.append(item)
                except asyncio.TimeoutError:
                    break

            results = await self.engine.run_batch(batch)
            for r in results:
                print(
                    f"[done] req={r.request_id} "
                    f"queue={r.queue_ms:.1f}ms "
                    f"ttft={r.ttft_ms:.1f}ms "
                    f"total={r.total_ms:.1f}ms"
                )

    async def shutdown(self):
        self.running = False


async def producer(scheduler: DynamicBatchScheduler, n=20):
    for i in range(n):
        req = Request(
            request_id=i,
            prompt_tokens=random.randint(200, 2000),
            output_tokens=random.randint(50, 300),
        )
        await scheduler.submit(req)
        await asyncio.sleep(random.uniform(0.005, 0.03))


async def main():
    engine = MockInferenceEngine(prefill_speed=10000, decode_speed=1500)
    scheduler = DynamicBatchScheduler(engine, max_batch_size=4, batch_wait_ms=15)

    worker_task = asyncio.create_task(scheduler.worker())
    await producer(scheduler, n=20)

    await asyncio.sleep(5)
    await scheduler.shutdown()
    await asyncio.sleep(1)
    worker_task.cancel()


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

如何运行

python mock_llm_scheduler.py

你可以怎么验证

建议改这几个参数观察输出变化:

  • max_batch_size
  • batch_wait_ms
  • prefill_speed
  • decode_speed

你会很直观地看到:

  • batch 变大,吞吐提高,但排队可能变长
  • batch wait 太大,TTFT 明显变差
  • prompt 越长,Prefill 成本越明显
  • 输出越长,Decode 成本越明显

请求处理时序图

sequenceDiagram
    participant U as User
    participant G as Gateway
    participant S as Scheduler
    participant K as KV Cache
    participant M as Model Runtime
    participant GPU as GPU

    U->>G: 发送请求
    G->>S: 入队
    S->>K: 检查前缀缓存
    alt 命中前缀
        K-->>S: 返回已存在KV
    else 未命中
        K-->>S: 分配KV页
    end
    S->>M: 组成动态批次
    M->>GPU: Prefill
    GPU-->>M: 首Token
    M-->>G: 流式返回
    loop Decode
        M->>GPU: 增量生成
        GPU-->>M: 新Token
        M-->>G: 持续流式输出
    end
    M->>K: 回收或保留KV
    G-->>U: 请求结束

实战中的关键指标

性能调优如果没有指标,基本等于盲飞。建议最少采集这些:

延迟指标

  • TTFT
  • TPOT(Time Per Output Token)
  • End-to-End Latency
  • P50 / P95 / P99

资源指标

  • GPU Utilization
  • GPU Memory Used
  • KV Cache Hit Rate
  • Queue Length
  • Batch Size Distribution

业务指标

  • 请求成功率
  • 超时率
  • 降级率
  • 单请求平均成本

这里我特别建议把 TTFT 和 TPOT 分开看
因为:

  • TTFT 差,通常是 Prefill、排队、调度问题
  • TPOT 差,通常是 Decode、KV Cache、batch 策略问题

这两者混在一个“平均延迟”里,很容易误判。


常见坑与排查

下面这些问题,我基本都见过,甚至踩过。

1. 量化后吞吐没变,质量还变差

现象

  • 显存降了,但 tokens/s 提升不明显
  • 某些任务回答准确率明显下降

排查思路

  1. 看瓶颈是不是权重加载,而不是 KV Cache
  2. 看量化 kernel 是否真的被启用
  3. 对比不同任务集,不要只看单一 benchmark

建议

  • 先上 INT8
  • 对复杂推理、代码生成、结构化抽取分别做质量回归
  • 保留一条 FP16/BF16 基线做灰度对照

2. 并发一高就 OOM,但单请求完全正常

现象

  • 单测没问题
  • 线上高峰突然 OOM
  • 显存曲线呈阶梯式上升

常见原因

  • KV Cache 未及时回收
  • 长请求扎堆
  • batch 过大
  • 前缀缓存策略没有上限
  • CUDA 内存碎片

排查建议

  • 打印每个请求的输入长度、输出长度、缓存占用
  • 统计 active sequences 数量
  • 监控 cache 分配失败次数
  • 检查是否存在“流断了但请求没清理”的泄漏

3. GPU 利用率不高,但延迟就是高

这类问题最容易误导人

很多人看到 GPU Util 只有 40%,会以为“卡没跑满,说明还很闲”。
其实大模型推理里很可能是:

  • CPU 侧 tokenizer 或调度成了瓶颈
  • batch 太小
  • 请求长度差异太大
  • Decode 阶段本来就不是纯算力瓶颈

我通常怎么查

  1. 看请求是否大量排队在进入 GPU 前
  2. 看 batch size 分布是否长期偏小
  3. 看 prefill/decode 时间占比
  4. 看是否有 Python GIL、序列化、网络 flush 等外围问题

4. Prefix Cache 命中率低得离谱

典型原因

  • prompt 模板里有动态时间戳、trace id
  • system prompt 每次都不一致
  • tokenizer 前处理不一致导致 token 序列不同

解决方法

  • 固定可复用前缀
  • 动态字段后置
  • 统一 tokenizer 和 prompt 拼接规则

这个点特别值得做,因为前缀共享一旦命中,收益很直接


安全/性能最佳实践

这一节我把“工程上最值得坚持的做法”集中列出来。

1. 做分级限流,不要让长请求拖垮全局

至少区分两类:

  • 短请求:普通问答、摘要
  • 长请求:超长上下文、复杂工具调用

如果混在同一队列,长请求会明显拉高尾延迟。更好的做法是:

  • 独立队列
  • 独立配额
  • 独立超时
  • 甚至独立实例池

2. 为 KV Cache 设置硬上限和回收策略

不要幻想“缓存越多越好”。缓存策略一定要明确:

  • 最大缓存页数
  • 单租户最大占用
  • 空闲过期时间
  • 内存紧张时的驱逐顺序

没有这些上限,系统迟早会在高峰时失控。


3. 流式输出要配合超时与取消

用户断开连接后,如果服务端还在继续生成,就会白白占着 GPU 和 KV Cache。

建议至少实现:

  • 客户端断连检测
  • 服务端取消生成
  • 资源回收钩子
  • 超时中断

这是性能优化,也是安全边界控制。


4. 灰度发布时同时看“质量”和“性能”

推理优化不是单一维度。每次变更都建议同时比较:

  • 吞吐是否上升
  • TTFT 是否变差
  • P99 是否恶化
  • 业务任务准确率是否下降

尤其是量化、KV Cache 压缩、激进 batching 这三类优化,非常容易“看起来更快,但业务效果更差”。


5. 保护多租户隔离

如果服务面向多个业务方,要避免一个租户的超长请求占满全局资源。

建议做:

  • 租户级并发配额
  • 租户级 token 预算
  • 请求大小上限
  • 租户优先级策略

否则某个租户突然打满长上下文请求,整个平台都会抖。


一套可落地的调优流程

如果你现在正准备优化一个已有的推理服务,我建议按这个 checklist 来:

第一步:建立基线

记录以下指标:

  • 单请求显存占用
  • TTFT / TPOT / 总延迟
  • P50 / P95 / P99
  • batch size 分布
  • GPU 利用率

第二步:做最小改动实验

每次只改一个变量,比如:

  • 从 FP16 切到 INT8
  • 打开 prefix cache
  • 把 batch wait 从 5ms 调到 15ms

第三步:分请求类型测

至少拆成:

  • 短输入短输出
  • 长输入短输出
  • 长输入长输出

因为不同类型请求的性能画像完全不同。

第四步:上线前压尾延迟

不要只看平均值。很多服务“均值很漂亮,P99 很糟糕”,用户体验仍然差。

第五步:预留降级路径

比如:

  • 长上下文自动截断
  • 高峰期降级到更小模型
  • 超长输出强制停止
  • 非核心租户限流

真正线上稳定的系统,一定是“优化 + 降级”一起设计的。


总结

大模型推理服务的性能调优,核心不是某一个神奇参数,而是理解这三件事的耦合关系:

  • 模型权重决定基础显存和精度
  • KV Cache 决定长上下文和高并发下的生存空间
  • 调度与批处理决定吞吐、TTFT 和尾延迟的平衡

如果你只能先做三件事,我建议优先做:

  1. 建立 TTFT / TPOT / P99 的观测体系
  2. 先做稳妥量化,再做 KV Cache 优化
  3. 把长请求和短请求分流,做动态批处理治理

最后给一个很务实的边界判断:

  • 如果你的业务是低延迟交互,优先控制 TTFT,batch 不要太激进
  • 如果你的业务是离线批量生成,优先追求吞吐,batch 可以更大
  • 如果你的业务是长上下文 Agent/RAG,优先优化 KV Cache,而不是只盯权重量化

推理服务优化这件事,真正有价值的不是“跑出一次 benchmark”,而是让系统在真实流量下,持续稳定地快。这也是架构设计和工程实现最见功力的地方。


分享到:

上一篇
《AI Agent 在企业知识库中的落地实践:从 RAG 检索增强到多轮任务编排》
下一篇
《自动化测试体系落地实战:基于接口与UI分层设计提升回归测试效率》