多轮对话中的 KV 复用与显存增长
在 **** 的 ****(8 卡,单卡 64GB HBM,卡间互联 392 GB/s)上用 vLLM 0.6.x 起 OpenAI 兼容服务,接多轮对话负载。客户端每轮把完整历史消息重发(Chat 接口没有服务端会话状态),服务端每轮用 apply_hf_chat_template 从全部 messages 重建整段 prompt,因此第 N 轮的输入含前 N−1 轮的全部 token。表现出两件事:单会话的 TTFT 从某一轮起明显上升;随并发会话数增加,vllm:gpu_cache_usage_perc 持续爬升,触顶后 vllm:num_requests_waiting 出现。两件事同源:历史被反复送入 prefill,而历史 KV 是否复用、复用多少,同时决定重算量与 KV 池占用量。
服务端无状态:每轮重建整段 prompt
Chat 入口 serving_chat.py 把整个 request.messages 交给 _preprocess_chat,后者经 chat_utils.py 的 parse_chat_messages_futures 逐条拼出 conversation,再调 apply_hf_chat_template 套模板得到单一 prompt,最后以一个新的 request_id 提交给引擎。conversation 与 prompt 都是局部变量,不挂在服务对象上,代码里没有会话 id 或会话存储。因此历史只能由客户端全量重发,跨轮 KV 复用只能由自动前缀缓存隐式提供。第 N 轮的输入长度约等于前 N 轮累计,prompt 长度随轮次单调增长。
前缀缓存:满块才可复用
开启 --enable-prefix-caching 后,进程按块复用完全一致的前缀。PrefixCachingBlockAllocator 为每块计算内容哈希,哈希函数是 hash_block_tokens(is_first_block, prev_block_hash, cur_block_token_ids),返回 hash((is_first_block, prev_block_hash, *cur_block_token_ids)):由父块哈希、本块 token id、是否首块三项决定,用 Python 内置 hash() 而非 hashlib。分配器按块哈希查表 _cached_blocks,命中即复用该块。
两条关键约束:
- 只对满块计算哈希。块未满时内容哈希直接返回
None(if not self.is_full: return None),allocate_immutable_block断言block.content_hash is not None。不满block_size的尾段不进缓存,也就不能跨请求复用。 - 该版本的哈希只由父块哈希与 token id 递推,源码注明内容哈希尚不支持 LoRA。
块表按满块分流:token 数等于 block_size 的块走可共享路径,不足的尾块走可变块路径。多轮的复用粒度因此是「整块前缀」——前几轮的完整块能命中,最后一轮后段不满一块的部分要重算。
校验与约束
| 约束 | 触发 | 结果 |
|---|---|---|
| 滑动窗口注意力 | 开启前缀缓存且 sliding_window 非空 |
NotImplementedError: Prefix caching is not supported with sliding window. Run with --disable-sliding-window to use prefix caching. |
| chunked prefill + 前缀缓存 | 同时开启且 max_num_batched_tokens 不是 block_size 整数倍 |
ValueError: When enabling chunked prefill and prefix caching, max_num_batched_tokens (chunk size) must be dividable by block size, but got ... |
| 非 decoder-only 模型 | 开启前缀缓存 | attention 后端断言 Only decoder-only models support prefix caching |
--enable-prefix-caching 默认关闭,须显式开启。--block-size 的默认值是 16。
显存增长的两条路径
- 复用命中:前轮块命中后不立即回自由链,而是留在池中等待复用,直到被驱逐。命中省下 prefill 计算,但命中的块本身占显存。
- 未命中:每轮新增的历史 token 走正常 prefill 并写入新块,池占用按新 token 增量上升。
两条路径在多轮下叠加:会话数越多、轮次越长,被各级历史块占用的 KV 越多,新请求可用的空闲块越少。池子占满时,PrefixCachingBlockAllocator 委托 LRUEvictor(基于 OrderedDict)按最近访问时间淘汰未被引用的缓存块。淘汰与重算交替出现,表现为吞吐波动。
现象
- 单会话 TTFT 随轮次阶梯上升,前几轮平缓、后段抬升明显。
vllm:gpu_cache_usage_perc随会话数与轮次持续爬升,触顶后vllm:num_requests_waiting出现。- 开启前缀缓存后
vllm:gpu_prefix_cache_hit_rate变为非负,但在会话切换、历史顺序变化时下降。 vllm:num_preemptions_total上升,对应淘汰与重算。- 关闭前缀缓存时上列现象仍在,只是 prefill 重算量更大、TTFT 更高。
定位步骤
- 确认前缀缓存是否生效:读
vllm:cache_config_info的标签,或 5 秒统计行里的Prefix cache hit rate。 - 看增长曲线:把
vllm:gpu_cache_usage_perc与vllm:num_requests_waiting按时间对齐,确认爬升由会话数与轮次驱动。 - 按会话看复用:比较同会话相邻两轮的输入长度差与命中块数,判断新增历史是否落在满块内。
- 核对块边界:历史拼接顺序是否稳定,轮次增量与
block_size的对齐关系。 - 核对约束:模型是否滑动窗口、chunked prefill 的
max_num_batched_tokens是否被block_size整除。 - 关闭前缀缓存做对照:区分「复用省下的 prefill」与「复用带来的占用」。
# 多轮服务的启动(参数名取自框架,取值按本机规格填)
python -m vllm.entrypoints.openai.api_server \
--model <model-path> \
--enable-prefix-caching \
--block-size 16 \
--max-model-len 32768 \
--gpu-memory-utilization 0.9 \
--max-num-seqs 64
处置
| 动作 | 作用 | 代价 / 前提 |
|---|---|---|
| 限制单会话历史长度(截断或摘要) | 压小每轮 prompt 增量 | 需应用层策略 |
| 固定历史拼接顺序 | 让前缀哈希稳定、命中率回升 | 不改变长度增长 |
开 --enable-prefix-caching 并固定 --block-size |
复用历史块,降低 prefill 计算 | 命中块常驻 KV,占用上升 |
调大 --gpu-memory-utilization 或转 fp8 KV |
给池子增加预算 | 激活余量与精度代价 |
调小 --max-num-seqs |
压缩并发会话占用的块 | 峰值并发下降 |
开 chunked prefill 并令 chunk 为 block_size 整数倍 |
长历史 prefill 切块 | 须同时满足整除约束 |
记录字段
# 一条多轮 KV 复用记录(字段名示意)
session:
turns:
history_policy: # 全量重发 / 截断 / 摘要
prompt_len_per_turn: # 每轮输入长度
concat_order_stable: # 拼接顺序是否稳定
prefix_cache:
enabled: # --enable-prefix-caching
block_size:
hit_rate_gpu: # vllm:gpu_prefix_cache_hit_rate
reuse_granularity: # 满块
memory:
gpu_cache_usage_perc_trace:
num_requests_waiting_trace:
num_preemptions_total:
constraints:
sliding_window:
chunked_prefill:
max_num_batched_tokens: # 是否被 block_size 整除
perf:
ttft_first_turn:
ttft_late_turn:
status:
小结
Chat 接口服务端无状态,客户端每轮重发完整历史,服务端用 apply_hf_chat_template 重建整段 prompt,输入长度随轮次单调增长。开启 --enable-prefix-caching 后历史前缀按满块复用,未满块部分与新增历史仍走 prefill,命中块常驻 KV 池等待复用,于是显存随会话数与轮次持续爬升。判定时先确认前缀缓存生效,再把 vllm:gpu_cache_usage_perc 与等待队列的时间轴对齐,最后核对滑动窗口、chunked prefill 整除两类约束。