Administrator
发布于 2026-07-06 / 9 阅读
0
0

KV Cache 才是高并发 AI 服务的显存杀手

KV Cache 才是高并发 AI 服务的显存杀手

English Version

做大模型服务时,我最怕的一类问题不是“模型能不能跑起来”,而是“请求一多,显存为什么突然不够了”。

很多人第一反应会盯着模型参数。比如 7B 模型 FP16 大概十几 GB,感觉一张 80GB 卡怎么都够。但真正进入高并发场景以后,显存压力往往来自另一块:KV Cache。

KV Cache(Key-Value Cache):可以理解为大模型生成文本时保存下来的“历史注意力结果”。它的作用是避免每生成一个新 token 都把前面的上下文重新算一遍,所以能明显提升生成速度。但代价是,每个请求、每个 token 都会占显存。

模型不大,不代表并发扛得住

以一个常见的 7B 级别模型估算,假设隐藏维度是 4096,层数是 32,使用 FP16 精度,那么每个 token 的 KV Cache 大约是:

2(K+V) x 32(层) x 4096(维度) x 2(FP16字节) = 524288 字节

换句话说,一个 token 大约 0.5MB。

如果一个请求上下文长度是 4096 tokens,那么单个请求的 KV Cache 就接近 2GB。并发 100 个请求,就是 200GB。这个数字已经远远超过单张 A100 80GB 的显存。

这就是我判断高并发 AI 服务时的第一个原则:不要只看模型权重,还要看每个请求会把 KV Cache 撑到什么程度。

传统 KV Cache 最大的问题是浪费

KV Cache 本身有价值,问题出在管理方式上。

传统方案容易遇到三个浪费点。

第一个是内部碎片。系统按最大长度给请求预留空间,比如 2048 tokens,但用户可能只生成 500 tokens,剩下的 1548 tokens 空间就闲在那里。

第二个是外部碎片。显存里可能有很多空闲块,但它们不连续。新请求如果需要一段连续空间,就会出现“总空闲够用,但就是分配不出来”的情况。

第三个是 Beam Search 或 Parallel Sampling 下的复制开销。

Beam Search(束搜索):生成时不是只保留一条候选结果,而是同时保留多条可能路径,最后选整体概率更高的那条。它能提升某些任务的结果稳定性,但会带来多条候选路径的缓存管理问题。

Parallel Sampling(并行采样):同一个 prompt 同时生成多个不同回复,让用户或后续系统选择。它的问题和 Beam Search 类似:多个分支前缀相同,却可能被传统方案复制多份 KV Cache。

如果三条路径的 prompt 前缀完全一样,传统方式却把前缀 KV Cache 复制三份,这就是纯浪费。

PagedAttention 的关键是把显存当成页来管

PagedAttention:可以理解为把 KV Cache 切成固定大小的显存块,然后用映射表维护“逻辑序列”和“物理显存块”的关系。它借鉴了操作系统虚拟内存的思路,不要求一个请求的 KV Cache 在物理显存中连续存放。

我更喜欢用图书馆来理解它:一套书不必放在连续书架上,只要目录卡片记录了每一册放在哪里,读者照样能按顺序阅读。

在 vLLM 里,这张“目录卡片”就是 Block Table。

Block Table(块映射表):记录某个请求的第几个逻辑 block 对应到 GPU 显存里的哪个物理 block。模型做 attention 时,通过这张表找到实际的 K/V 数据。

下面是一个简化版的理解代码,重点不是可直接运行,而是看清映射关系:

class BlockTable:
    # 中文注释:初始化块表,block_size 表示每个物理块能容纳多少个 token
    def __init__(self, block_size=16):
        self.block_size = block_size
        self.blocks = []
        self.num_tokens = 0

    # 中文注释:每追加一个 token,就判断当前块是否已满,满了再申请新显存块
    def append_token(self):
        if self.num_tokens % self.block_size == 0:
            new_block = gpu_memory.allocate_block()
            self.blocks.append(new_block)
        self.num_tokens += 1

    # 中文注释:把逻辑位置转换成物理显存位置,attention kernel 依赖这个映射取数据
    def get_physical_indices(self, position: int):
        block_idx = position // self.block_size
        offset = position % self.block_size
        physical_block = self.blocks[block_idx]
        return physical_block * self.block_size + offset

PagedAttention 真正解决的是两件事。

第一,按需分配。生成多少 token,就逐步申请多少 block。比如一个请求只生成 100 tokens,block size 是 16,那只需要 7 个 block,而不是一上来预留 2048 tokens 的空间。

第二,写时复制。

写时复制(Copy-on-Write):多个分支先共享同一份缓存,只有当某个分支真的生成不同 token、需要修改时,才为它分配私有 block。这样 Beam Search 和 Parallel Sampling 里的公共前缀就不用重复存。

Continuous Batching 让 GPU 别空等

解决了显存管理,还要解决 GPU 调度。

Batch(批处理):把多个用户请求合在一起,让 GPU 一次前向传播同时处理。GPU 擅长并行计算,处理 1 个请求和处理多个请求的耗时不一定成比例增加,所以 batch 能显著提升吞吐。

但静态 batch 有一个问题:一批请求必须等最长的那个完成。短请求早就结束了,对应位置却只能空着。

Continuous Batching(连续批处理):每生成一个 token 就检查一次请求状态,哪个请求完成了,就立刻把新请求补进来。它不是等整批结束再调度,而是在 iteration 级别动态调度。

Iteration-level Scheduling(迭代级调度):每次生成一个 token 后都重新处理队列、释放完成请求的显存块、加入新请求。它的调度粒度比传统 request-level 更细。

这也是 PagedAttention 和 Continuous Batching 能配合起来的原因:新请求不需要一整段连续显存,只要有分散的空闲 block,就能加入 batch。

我会怎么判断一个系统有没有调好

如果我要看一个 vLLM 服务的高并发表现,我不会只问“能不能返回答案”。我会看三个指标。

TTFT(Time To First Token):从请求发出到第一个 token 返回的时间。它主要受 Prefill 阶段影响,prompt 越长通常越高。

TPOT(Time Per Output Token):生成阶段相邻 token 之间的间隔。它反映 decode 阶段是否稳定。

吞吐量(tokens/s):单位时间生成多少 token。它决定系统整体服务能力。

教案里的实验数据很典型:并发从 1 增加到 8 时,吞吐从约 101 tokens/s 增加到 635 tokens/s,P50 延迟只从约 900ms 增加到 1037ms。也就是说,吞吐提升了 6 倍多,但单请求体感延迟没有明显恶化。

继续把并发拉到 16,P99 延迟跳到 2085ms,这就说明系统接近饱和了。这个拐点很重要,因为生产调优不是无限加并发,而是找到吞吐和尾延迟都能接受的位置。

总结

高并发 AI 服务的核心矛盾,不是“模型能不能加载”,而是“请求增长以后,KV Cache 怎么管理,GPU 怎么持续吃满”。

我的经验是,先把 KV Cache 的显存账算清楚,再看 PagedAttention 能省多少碎片,最后用 Continuous Batching 验证吞吐和延迟的拐点。

理解了这条线,再看 vLLM 的参数调优,就不会只是在命令行里盲目堆参数了。


评论