5.1 vLLM实战:开启前缀缓存并验证命中 本节摘要:前缀缓存不是"开了就完事"的开关。本节用一份共享长系统提示词的真实压测,在 vLLM 上打开缓存、读出命中指标、对比吞吐变化,并点出块大小对齐、缓存逐出、指标误读三个最常见的坑。读完你能独立复现一次"开 vs 不开"的对照实验。 第 4 章把应用层的语义缓存讲透了,但语义层再聪明也救不了引擎里那份一模一样的系统提示词被每个请求重复预填充。5.2 我们会在业务侧插一层网关统一计量,但在这之前,得先确认引擎本身能把"相同前缀"这件事接住。这一节就干这件事:在 vLLM 上把前缀缓存打开,用数据看它到底回收了多少算力。
本节摘要:前缀缓存不是"开了就完事"的开关。本节用一份共享长系统提示词的真实压测,在 vLLM 上打开缓存、读出命中指标、对比吞吐变化,并点出块大小对齐、缓存逐出、指标误读三个最常见的坑。读完你能独立复现一次"开 vs 不开"的对照实验。
第 4 章把应用层的语义缓存讲透了,但语义层再聪明也救不了引擎里那份一模一样的系统提示词被每个请求重复预填充。5.2 我们会在业务侧插一层网关统一计量,但在这之前,得先确认引擎本身能把"相同前缀"这件事接住。这一节就干这件事:在 vLLM 上把前缀缓存打开,用数据看它到底回收了多少算力。
很多团队的线上服务都养着一段很长的系统提示词——合规守则、角色设定、工具说明、输出格式,动辄上千 token。每来一个用户请求,引擎都得把这段提示词重新做一遍 prefill,也就是把它的 KV 缓存从头算一遍。请求量一大,这笔预填充开销就成了账单里最固定也最隐蔽的一项。
前缀缓存的思路很直接:如果两段请求的 token 序列前缀完全一致,那前面这一段算过的 KV 缓存就能直接复用,引擎只算"不一样"的那截。难点不在理解,在验证——你怎么证明它真的复用了,而不是只是"理论上能复用"?
vLLM 从 0.3.x 起实验性地引入前缀缓存,到 0.4.x 之后 --enable-prefix-caching 成为稳定可用的命令行开关(不同版本默认值不同,老版本默认关闭,新版本部分场景默认开启,所以显式传参最稳妥)。另一条相关参数是哈希算法 --prefix-caching-hash-algo,默认 builtin 即可,跨版本迁移时才需要考虑一致性。
# 安装(示例使用 0.4 以上版本) pip install vllm==0.4.3 # 启动推理服务,显式打开前缀缓存 python -m vllm.entrypoints.openai.api_server \ --model Qwen2-7B-Instruct \ --enable-prefix-caching \ --gpu-memory-utilization 0.85 \ --port 8000
启动日志里如果出现前缀缓存已启用的提示,说明开关已生效。这里有个版本细节:vLLM 内部用基数树管理前缀块,所以同前缀的请求天然会被归并到同一棵子树上;这点和后面 5.3 要讲的 Agent 追加式命中是同一套机制。
我们用 OpenAI 兼容客户端发两条请求,它们共用同一份长系统提示词,只改最后的用户问题。第二次请求理论上应该命中前缀缓存,prefill 成本骤降。
from openai import OpenAI import time # 指向本地 vLLM 暴露的 OpenAI 兼容端口 client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") # 约 1500 token 的固定系统提示词,模拟真实线上场景 SYSTEM_PROMPT = ( "你是一名严谨的金融合规审核助手,必须遵守以下守则:" "一、所有结论需引用监管条文;二、对不确定事项明确标注风险等级;" "三、输出严格采用 JSON 格式……(此处省略约 1400 token 的固定说明)" ) questions = [ "这笔跨境汇款是否需要申报?", "该合同条款是否存在合规风险?", ] def send_once(question): """发一次请求,返回端到端耗时。""" t0 = time.perf_counter() resp = client.chat.completions.create( model="Qwen2-7B-Instruct", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": question}, ], max_tokens=256, ) dt = time.perf_counter() - t0 return dt, resp.usage for i, q in enumerate(questions): dt, usage = send_once(q) print(f"请求{i+1} 耗时={dt:.3f}s 输入token={usage.prompt_tokens} 输出token={usage.completion_tokens}")
注意这里用串行发送两条请求,而不是并发。并发会让两次 prefill 同时发生,第二次还没来得及写入缓存就被调度走了,测不出命中效果。串行、共享前缀、观察第二次的耗时与指标,才是对照实验的标准做法。
vLLM 把运行指标暴露成 Prometheus 格式,端口同源下的指标端点可读。前缀缓存最关键的计数器是 vllm:num_prefix_cache_hit_tokens——它累计"因命中前缀缓存而免算的 token 数"。配合 vllm:gpu_cache_usage_perc 看显存块占用率,能判断缓存是不是被挤出去了。
# 读取指标并筛选前缀缓存相关项 curl -s http://localhost:8000/metrics | grep prefix_cache
预期能看到类似 vllm:num_prefix_cache_hit_tokens 的计数在第二次请求后明显增长。如果你只看到总量、看不到命中计数,多半是开关没真正生效,或前缀根本没对齐到块边界。
在一台 7B 模型、共享 1500 token 系统提示、各发 200 次请求的压测里,对照两组开关,典型数据如下(数值为示意,真实值随硬件与模型而异):
| 指标 | 关闭前缀缓存 | 开启前缀缓存 | 变化 |
|---|---|---|---|
| 平均首 token 延迟 | 320 ms | 140 ms | 下降约 56% |
| 每秒处理请求数 | 38 | 71 | 提升约 87% |
| 前缀缓存命中 token 数 | 0 | 约 28 万 | 从无到有 |
| GPU 显存块占用 | 41% | 63% | 上升(缓存占块) |
吞吐接近翻倍,因为第二次及之后的请求跳过了那段最长的 prefill。这正是"算力预付、缓存回收"最直接的表现:第一次付的钱,后面每次都少付一截。
提升来自"共享前缀被复用",不是模型变快了。所以命中收益和"系统提示词占请求总 token 的比例"强相关:系统提示越长、用户问题越短,收益越大;反之如果每次请求前缀都不一样(比如把时间戳塞进系统提示),缓存就几乎不生效。
另一个解读要点:指标数的是命中 token 数,不是命中请求数。一次请求可能只有部分前缀命中,所以"命中率"要自己用 命中token / 总输入token 算,不能直接拿计数器当百分比。
块大小对齐:vLLM 以
block_size(默认 16)为最小缓存单位。前缀只有在 token 数对齐到块边界时,整块才能被复用。如果你的系统提示词是 1502 token,多出的 2 个 token 会拖着整段无法完美对齐,命中率会跳变。解决方法是让固定前缀长度凑整到块边界,或在构造提示时预留对齐。
缓存被逐出:前缀缓存占的是那块显存。一旦
gpu_cache_usage_perc逼近上限,老前缀块会被淘汰,长尾请求可能轮不到命中。这解释了为什么压测并发一高,命中率反而掉——不是开关失效,是块不够用。调高--gpu-memory-utilization或减小并发能看到区别。
指标误读:新手常把
num_prefix_cache_hit_tokens当作"省下的钱",但它只是 token 计数,要换算成延迟和成本还得过一道。也有人只盯总 token 数,发现总数没降就以为没生效——其实总输入 token 没变,变的是"其中多少被免算"。看命中率,别看总量。
把同样的实验搬到另一套同样基于基数树前缀管理的引擎(例如 SGLang,它的 RadixAttention 也是同一思路)上重跑,你会发现曲线形状高度相似:共享长前缀的请求,第二次开始延迟陡降。差异主要在工程细节——默认哈希算法、块大小、逐出策略、以及是否暴露命中计数器。
这个变式的价值在于帮你建立"跨引擎可迁移"的判断:前缀缓存的收益不依赖某个框架,而依赖"你是不是在反复发送相同前缀"这个使用模式。下一节我们就把这个判断往前推一步:当流量要路由到多个模型时,引擎层的缓存管不过来,得在业务侧自己插一层。