vLLM 里没有 session,只有 block hash。跨用户复用靠的是字节级一致的前缀。记录我们把 Agent 的 prompt 结构、工具序列化、多副本路由和租户隔离改到能稳定命中的过程。
把免费档的 Agent 对话切到自部署的 Qwen3-32B 之后(接入过程在这里),有一个数字我一直没想通。
Agent 每一轮请求的前面 6000 多个 token 是所有用户共用的:平台 system prompt 加上二十几个工具的 schema。按 vLLM 前缀缓存的原理,第一个用户把这段算过一遍,后面所有用户都应该直接命中。可 /metrics 里 vllm:prefix_cache_hits 除以 vllm:prefix_cache_queries 算出来的命中率,离这个预期差得很远。
之前写过一篇 API 厂商那边的 prompt caching,那时候缓存是别人的事,我们只管把 prompt 排好。现在卡是自己的,这件事变成了自己的问题,而且比想象中更细。
vLLM 里没有 session,只有 block hash
先把心里那个错误的模型拆掉。我一开始默认 vLLM 有某种"会话级"的缓存:同一个用户的连续请求能复用,不同用户之间要另外想办法。
实际上 vLLM 的每个请求都是无状态的。它不知道 session_id,也不知道 user_id。前缀缓存的单位是 KV block,默认 16 个 token 一块,每个 block 的 hash 由三样东西决定:
- 上一个 block 的 hash
- 这 16 个 token 的 id
- 额外的 key:LoRA id、多模态输入的 hash、还有后面会讲到的
cache_salt
block_hash = hash_block_tokens(parent_block_hash, block_token_ids, extra_keys)因为每个 hash 都把父 hash 包进去,一条请求的 block hash 是一条链。链上任何一个位置的 token 变了,从那里开始往后所有 block 的 hash 全变。
新请求进来,vLLM 从第一个 block 开始沿着链查 cached_block_hash_to_block 这张表,查到第一个 miss 为止,前面命中的 block 直接复用,只对后面的 token 做 prefill。
这意味着"跨用户复用"根本不是一个需要打开的功能。两个用户的请求只要前 N 个 token 完全一致,第二个人就命中第一个人算过的 KV。它也不是一个可以单独保护的功能:只要前缀在字节层面有任何差异,复用就没了。
和 API 厂商比,这套机制有三处不一样:没有最小长度门槛(OpenAI 要 1024 token 起),没有 TTL(只有 LRU 驱逐),没有折扣价——省下来的是自己的 GPU 时间和首 token 延迟。
用 /tokenize 找到前缀是在哪里断的
命中率低,说明不同用户的请求在很靠前的地方就分叉了。猜是没用的,得看模型实际吃到的 token 序列。
vLLM 的 /tokenize 端点接受和 /chat/completions 一样的 messages、tools 和 chat_template_kwargs,返回渲染完 chat template 之后的 token 列表。把两个不同用户的真实请求体拿出来各跑一次,对比第一个不同的位置:
import httpx
def tokenize(body: dict) -> tuple[list[int], list[str]]:
resp = httpx.post(
f"{VLLM_BASE}/tokenize",
headers={"Authorization": f"Bearer {VLLM_API_KEY}"},
json={**body, "add_generation_prompt": True, "return_token_strs": True},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data["tokens"], data["token_strs"]
ids_a, strs_a = tokenize({"model": "qwen3-32b", "messages": req_a["messages"], "tools": req_a["tools"]})
ids_b, strs_b = tokenize({"model": "qwen3-32b", "messages": req_b["messages"], "tools": req_b["tools"]})
split = next((i for i, (x, y) in enumerate(zip(ids_a, ids_b)) if x != y), min(len(ids_a), len(ids_b)))
print(f"shared prefix: {split} tokens = {split // 16} full blocks")
print("A:", repr("".join(strs_a[split - 8 : split + 8])))
print("B:", repr("".join(strs_b[split - 8 : split + 8])))第一次跑出来的结果是 shared prefix: 23 tokens = 1 full blocks。两个用户之间只共享一个 block。分叉点打印出来是这样的:
A: '创作助手。\n当前时间:2026-08-12 14:03'
B: '创作助手。\n当前时间:2026-08-12 14:07'system prompt 的第二行是当前时间,精确到秒。
前缀是我们自己弄碎的
顺着这个方法一路查下去,找到三处。每一处单独看都很合理,合起来把 6000 token 的共享前缀切成了几十个 token。
动态字段放在了最前面
system prompt 的开头几行是:
你是 OpenCreator 的创作助手。
当前时间:2026-08-12 14:03:17(Asia/Shanghai)
当前用户:Yuki,Pro 会员,偏好日系插画风格
用户记忆:
- 上次项目是宠物用品的短视频脚本
...时间精确到秒,意味着同一个用户隔一分钟发的两条请求,前缀在第 20 个 token 左右就断了。用户昵称和 Mem0 取回来的记忆更是每人不同。这些内容加起来不到 200 个 token,但它们后面跟着 6000 多个 token 的静态内容,全部被连坐。
改法看起来很直接:把动态字段挪到后面去。但"后面"具体是哪里,取决于 chat template。
Qwen 系列的 template 在有 tools 的时候,渲染顺序是先放第一条 system 消息的内容,紧接着在同一个 system block 里追加一段 # Tools 和所有工具的 JSON。也就是说工具定义排在 system 内容之后。这和 Anthropic 的 tools → system → messages 顺序正好相反。如果只是把动态字段挪到 system prompt 的末尾,它们依然排在工具定义前面,工具那 2000 多个 token 照样 miss。
所以动态字段必须整个离开第一条 system 消息。现在的结构是拆成两条 system 消息:
messages = [
{"role": "system", "content": PLATFORM_PROMPT}, # 所有用户相同
{"role": "system", "content": render_context(user)}, # 时间、用户、记忆
*history,
]Qwen3 的 template 会把第一条 system 和 tools 渲染在一起,之后按顺序渲染后面的消息,非首位的 system 消息会作为独立的 system block 出现在工具定义之后。渲染出来的前缀就是 [平台 prompt][tools][用户上下文][对话],前两段对所有用户一致。
时间字段顺手改成了只到日期。Agent 需要知道"今天",但没有任何一个工具依赖分钟级的当前时间。
工具的顺序和字段顺序会原样进 prompt
修完第一处,共享前缀涨到了 4000 多个 token,还没到 6000。再跑一次对比,分叉点落在工具 JSON 的中间。
工具 schema 进 prompt 的方式是 template 里的 tool | tojson。transformers 给这个过滤器的实现不排序 key,也不做任何规范化,我们传什么顺序它就渲染什么顺序。
我们的 ToolRegistry 按 skill 加载的顺序注册工具。Agent 服务跑了两个 worker 进程,skill 是懒加载的,哪个 worker 先加载了哪个 skill 取决于它先收到了什么请求。结果是两个进程各自维护了一套顺序不同的工具列表,渲染出来的 tools 段字节不同。同一台 vLLM 上等于同时存在两套"共享前缀",每套只能被一半的请求命中,还占了双份的 KV。
另外有几个手写的工具 schema,properties 是在运行时从配置拼出来的,dict 的插入顺序跟着配置文件的读取顺序走。
修法是在网关往上游发请求之前做一次规范化:
def canonical_tools(tools: list[dict]) -> list[dict]:
normalized = [json.loads(json.dumps(t, sort_keys=True, ensure_ascii=False)) for t in tools]
return sorted(normalized, key=lambda t: t["function"]["name"])同时给每个请求算一个 prefix_fingerprint:把第一条 system 消息和规范化后的 tools 序列化之后取 sha256 前 12 位,随请求日志一起打出来。这个字段的作用是让"共享前缀有几个版本"这件事可以被 grep 出来。理想状态下线上任意时刻它只有一个值,发版切换的那几分钟会有两个。
有些步骤故意不带工具
第三处和 Agent 的步骤设计有关。Agent 有一类"只回话不干活"的步骤,比如任务完成后的收尾总结。为了省 token,这类步骤的请求不带 tools。
不带 tools 的时候,Qwen 的 template 走另一个分支,system block 里没有 # Tools 段。于是这类请求的前缀在 system 内容结束的地方就和其他请求分开了,2000 多个 token 的工具定义没有命中的机会,后面的对话历史更没有。
改成始终带完整工具列表,用 tool_choice: "none" 禁止模型调用。vLLM 默认在 tool_choice 为 none 时仍然把工具定义渲染进 prompt,这正是我们要的;它有一个 --exclude-tools-when-tool-choice-none 开关会反过来把工具定义去掉,这个开关不能开。
每次请求多传 2000 多个 token,但这些 token 全部命中缓存,prefill 几乎不花时间,KV 也是和其他请求共享的同一份。原来那个"省 token"的优化,在自己的卡上省的其实是网络带宽。
三处改完,两个不同用户之间的共享前缀稳定在 6192 个 token,也就是 387 个满 block。
命中的单位是 block,不是 token
6192 这个数字不是巧合。共享前缀实际长度是 6200 出头,但 vLLM 只缓存写满的 block,最后那个不满 16 个 token 的尾巴永远不会被缓存。cached_tokens 永远是 16 的倍数。
还有一个容易误读的细节:如果一条请求的 prompt 从头到尾全部命中,vLLM 会故意少算最后一个 token。原因是至少要有一个 token 真正走一次前向才能拿到 logits,而分配 slot 的逻辑要求已计算的 token 数对齐到 block 边界,所以实际被重算的是最后一个 block。看到 cached_tokens 比 prompt_tokens 少了 1 到 16 个 token,不是缓存出了问题。预热请求和就绪探针这种重复发同一段 prompt 的场景,每次都会看到这个差值。
block 的边界也决定了发版时的冷启动。新的 vLLM 副本起来的那一刻缓存是空的,同一批被调度进去的十几个请求都会 miss,各自 prefill 一遍 6000 token;block 只有在算完、写满之后才进缓存表,同一批里的请求彼此帮不上忙。现在发版脚本在把副本挂到网关之前,先用平台 prompt 加完整工具列表发一次 max_tokens: 1 的请求,把共享前缀预热进去。
第二个副本让命中率对半砍
后来加了第二张卡。网关对两个 provider 做轮询,命中率又掉了一截。
共享前缀在两个副本上很快都会热起来,这部分没问题。掉的是会话历史那部分。用户第三轮的请求带着前两轮的历史,这段历史的 KV 只在处理了前两轮的那个副本上有。轮询之下,第三轮有一半的概率落到另一个副本,历史部分全部重算。
解法是让同一个会话尽量落到同一个副本。网关对 vLLM 这类 provider 加了一层按 session_id 的粘性路由:
def pick_replica(session_id: str, replicas: list[Replica]) -> Replica:
ranked = sorted(
replicas,
key=lambda r: hashlib.blake2b(f"{session_id}:{r.name}".encode(), digest_size=8).digest(),
)
for replica in ranked:
if replica.inflight < replica.max_inflight:
return replica
raise ProviderSaturated()用 rendezvous hashing 而不是简单取模,是为了加减副本的时候只有一小部分会话会换位置。inflight 满了就顺着排名往下找,宁可 miss 一次历史,也不要在一个副本上排队。
这是一种"近似"的前缀感知路由:它假设同一个会话的前缀在同一个副本上,不去验证。精确的做法是 vLLM 通过 --kv-events-config 往外发 block 存入和驱逐的事件,网关维护一份每个副本上有哪些 block hash 的索引,进来的请求算出自己的 block hash 之后按命中比例挑副本。llm-d 就是这么做的,为了让外部算出的 hash 和引擎内部一致,vLLM 还专门加了 --prefix-caching-hash-algo sha256_cbor 这个跨语言可复现的哈希选项。
两个副本用不上这套。它多一个事件流、一个索引服务、一个分词器副本,换来的是路由精度从"大概率对"提升到"确定对"。等副本数到了让会话粘性明显失效的规模再说。
共享前缀不会被踢,会话历史会
单卡上的 KV cache 是一个固定大小的 block 池,满了就按 LRU 驱逐。哪些东西活得久、哪些东西先死,决定了"会话级复用"真正的边界在哪里。
算一下这张卡上的账。Qwen3-32B 每个 token 的 KV 是 256KB,一个 16 token 的 block 是 4MB。FP8 权重占 33GB,--gpu-memory-utilization 0.92 之下 KV cache 大约 37GB,换算成 block 是 9000 多个,能装 15 万个 token。
共享前缀 387 个 block,1.5GB。一个聊了十几轮、带着几次工具结果的会话,历史部分大约 6000 到 10000 个 token,也就是 1.5 到 2.5GB。
共享前缀几乎不会被驱逐。任何一个正在跑的请求都持有这些 block 的引用,引用计数不为零的 block 不会进空闲队列。白天流量不断,它永远在被引用。凌晨没请求的时候它会进空闲队列,但 LRU 只在需要腾地方的时候才淘汰,而没请求也就没人需要腾地方。
会话历史是另一回事。用户看完一轮回复,想了五分钟再发下一条。这五分钟里,这个会话没有任何请求在跑,它的 block 全部进了空闲队列。同一时间有几十个活跃会话在吃 block,每个新请求 prefill 的时候都要拿空闲 block,拿的就是队列头上那些最久没用的。五分钟足够把一个会话的历史挤出去。
所以在单卡上,"session 级复用"的实际含义是:共享前缀基本总在,会话历史能不能命中取决于用户回来得多快、以及那段时间其他人有多忙。我们能做的是控制别的东西占多少:--max-model-len 从 40960 压到 32768,--max-num-seqs 压到 48,让极端长的会话和过高的并发不要把别人的历史挤掉。--kv-cache-dtype fp8 能把 KV 容量翻倍,但它会影响输出质量,中文创作类任务上我们还没做完对比,暂时没开。
判断有没有在互相挤,看两个指标够了:vllm:kv_cache_usage_perc 长时间贴着 1,说明 block 池是满的,驱逐在持续发生;vllm:num_preemptions 只要在涨,说明连正在跑的请求都在被抢占,这时候已经不是命中率的问题,是容量不够。
跨用户共享和租户隔离是同一个开关的两面
前面所有的努力都是为了让不同用户命中同一段前缀。但这件事有一个反面。
前缀缓存有时序侧信道:如果我能构造一段 prompt 并观察首 token 延迟,命中和不命中的延迟差能告诉我"这段内容最近有没有别人发过"。对于平台自己的 system prompt 这不算问题,内容本来就是我们写的。但企业租户配置的自定义 system prompt 里可能有他们的产品资料和话术,这段内容能不能被其他租户探测到,就是一个真实的隔离问题。
vLLM 给的工具是请求里的 cache_salt 字段。它被混进第一个 block 的 hash,因为后面每个 block 都链着前面的 hash,一个 salt 就把整条请求的缓存和其他 salt 的请求隔开了。
问题是它是全有或全无的。只要加了 salt,从第一个 block 开始就分叉,平台那 6192 个 token 的共享前缀也一起不共享了。社区有过"前 N 个 token 不加盐、之后再加盐"的提案,实现成 shared_prefix_tokens 参数的那个 PR 几天前刚被作者关掉,重做成按 token 区间加盐的 salt_regions 还没合进去。所以现阶段只能选一边。
我们的选法是按租户类型:
def cache_salt_for(request: GatewayRequest) -> str | None:
if request.tenant.kind == "enterprise" and request.tenant.has_private_prompt:
return f"tenant:{request.tenant.id}"
return None免费档和个人 Pro 用户走平台 prompt,不加盐,全量共享。企业租户如果配了私有 system prompt,以租户 id 加盐。他们失去的是平台前缀那部分命中,代价是每个租户第一次请求多算 6000 个 token 的 prefill;换来的是租户之间的缓存完全隔开。企业租户数量少、请求量也少,这个代价我们付得起。
还有一层我判断可以接受但要写下来的风险:用户记忆那段内容排在共享前缀之后,理论上也能被时序侧信道探测。但探测的前提是攻击者能逐字节猜出另一个用户的记忆内容,命中的信息量只有"有人发过这段文本",我们评估下来不值得为它放弃所有跨用户复用。
怎么知道改对了
服务级的数字看 /metrics。两个计数器都以 token 为单位,用 PromQL 算五分钟窗口的比值:
rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])请求级的数字是 usage.prompt_tokens_details.cached_tokens,需要启动时带 --enable-prompt-tokens-details。刚接入的时候这个字段在我们那版 V1 引擎上一直是 null,是 vLLM 一个拖了一年多的 bug,升到 0.23 之后才正常。
现在网关对每一条走 vLLM 的请求记 cached_tokens、prompt_tokens、prefix_fingerprint、replica、tenant_kind 五个字段。按 prefix_fingerprint 聚合能看出线上共享前缀有几个版本;按 replica 聚合能看出粘性路由有没有生效;按 tenant_kind 聚合能验证加盐的租户确实没有命中平台前缀。
告警只留了一条:发版之后半小时内,命中率相比发版前一小时的均值下降超过 15 个百分点。之前那篇里提过 Claude Code 用 cache_read_input_tokens 的突然下降来发现缓存被打破,我们照抄了这个思路。改 prompt 的人不一定知道自己动了前缀,但这条告警知道。
还没做的
两个副本之间的 KV 是完全独立的。一个会话被粘性路由送到副本 A,副本 A 重启,历史就没了。vLLM 有 KV connector 这层抽象,配合 LMCache 之类的外部存储能把 KV 放到 CPU 内存或者跨节点共享,--kv-transfer-config 就是这个入口。我看过一遍,暂时没上:它引入的是另一个有状态的服务,而我们现在两张卡的规模,重启一次损失的只是一批会话各多算一次历史。
回头看,这一轮真正改的东西都不在 vLLM 里。vLLM 从头到尾只做了一件事:前缀一致就命中。把前缀弄一致的,是 Agent 的消息结构、网关的序列化、部署脚本的预热和路由的粘性。它们原本没有一个是为缓存设计的,但每一个都在决定缓存的命运。