多轮对话中的 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,命中即复用该块。

两条关键约束:

块表按满块分流: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。

显存增长的两条路径

两条路径在多轮下叠加:会话数越多、轮次越长,被各级历史块占用的 KV 越多,新请求可用的空闲块越少。池子占满时,PrefixCachingBlockAllocator 委托 LRUEvictor(基于 OrderedDict)按最近访问时间淘汰未被引用的缓存块。淘汰与重算交替出现,表现为吞吐波动。

现象

定位步骤

  1. 确认前缀缓存是否生效:读 vllm:cache_config_info 的标签,或 5 秒统计行里的 Prefix cache hit rate。
  2. 看增长曲线:把 vllm:gpu_cache_usage_perc 与 vllm:num_requests_waiting 按时间对齐,确认爬升由会话数与轮次驱动。
  3. 按会话看复用:比较同会话相邻两轮的输入长度差与命中块数,判断新增历史是否落在满块内。
  4. 核对块边界:历史拼接顺序是否稳定,轮次增量与 block_size 的对齐关系。
  5. 核对约束:模型是否滑动窗口、chunked prefill 的 max_num_batched_tokens 是否被 block_size 整除。
  6. 关闭前缀缓存做对照:区分「复用省下的 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 整除两类约束。