先说说我为什么会对“Prefix Cache”这个话题这么上心。去年下半年我们在生产环境里上线了一套基于多轮对话和 Agent 的在线服务,模型用的是 7B 左右的规模,请求里几乎每条都带着一大段固定的 system prompt 和 few-shot 示例。起初延迟勉强能看,但并发一上来,prefill 阶段的计算开销直接吃掉了一大截 GPU 算力,TTFT 也开始跟着往上飙。当时团队里讨论的一件核心事情,就是怎么把“重复计算的前缀”省掉。也就是那个时候,我对比研究了 vLLM 和 SGLang 两个框架的 Prefix Cache 实现,发现它们虽然解决的是同一个问题,走的路线却截然不同:vLLM 用哈希表做块级缓存,SGLang 用 RadixTree 做 token 级前缀匹配。这个差异背后其实藏着对调度、显存管理、并发控制的一系列取舍,搞懂了它,你在选型和调参的时候就不会只靠猜。
1. 为什么所有推理框架都在卷 Prefix Cache
1.1 KV Cache:一次计算,多次复用
先把最底层的机制讲清楚,不然后面所有的对比都悬空。Transformer 模型在自回归解码时,每一步都要计算当前 token 对之前所有 token 的注意力。为了避免每生成一个 token 就把整个序列重新算一遍,框架会把历史 token 对应的 Key 矩阵和 Value 矩阵缓存下来,这就是我们常说的 KV Cache。有了它,解码阶段只需要拿新的 Query 去和缓存的 Key、Value 做注意力计算,省掉了重复的矩阵乘法。
但这里有个容易被忽视的前提:KV Cache 是从左到右逐步累积的,它天然对应“前缀”这个概念。如果两个请求的输入拥有完全相同的一段前缀,那么这段前缀对应的 Key、Value 在数学上也是完全一致的。这意味着第一个请求算完这一段之后,第二个请求完全可以直接抄作业,不需要再为这段前缀做一遍 prefill。这就是 Prefix Cache 能带来收益的根本原因。
我曾经给团队里刚入门的同学打过一个比方:想象你在写一篇文章,AI 帮你分析的时候每次都要先看你前面两页已经写好的内容才能开始给建议。如果它记得你上两页内容,下次你只改了第三页它就不用重新读前两页了。KV Cache 就是这段“读过的内容”,而 Prefix Cache 是决定这段内容能不能跨请求复用的索引系统。
1.2 共享前缀场景多到你想象不到
可能有人觉得,“我每次发出去的 prompt 都不一样,前缀复用哪来的机会?”但实际上,在大模型应用里,共享前缀几乎是常态而不是特例。
最典型的是多轮对话。后面每一轮的输入都会带上前面几轮的对话历史,假设系统 prompt 固定,那么第 N 轮和第 N+1 轮的输入共享的不仅是系统 prompt,还包括前 N 轮的所有内容。另一个典型场景是 Agent 应用。为了让模型知道它能调用哪些工具,开发者通常会在 system prompt 里塞一大段工具 schema,这段内容对同一 Agent 的所有请求都是相同的。RAG 场景也类似,query 前面往往固定拼接一段检索指令和文档格式说明。哪怕是离线批量推理,只要你的数据集用的是同一个 instructions 模板,前缀就大概率重复。
这些场景的共同特点是:共享前缀越长,重复计算越严重,Prefix Cache 能省的 prefill 计算量就越大。有实测数据表明,在长上下文多轮对话场景中,命中 Prefix Cache 后 TTFT 能下降 50% 以上,正常吞吐也能提升不少。这也是为什么 vLLM 和 SGLang 都愿意花大力气做这个功能,而不仅仅是把它当成一个可选优化项。
1.3 两个框架,两种哲学
既然前缀复用这么重要,接下来就看实现层面了。vLLM 的整个显存管理体系建立在 PagedAttention 和逻辑块(block)之上,KV Cache 是按固定大小的 block 分配的,因此它做 Prefix Cache 时,很自然地选择以 block 为单位生成哈希值,再用哈希表来索引这些可复用的 block。而 SGLang 的 RadixAttention 从设计之初就把“前缀”当成一棵树来管理,它用 RadixTree 存储所有请求的前缀 token 序列和对应的 KV Cache 位置,匹配时按 token 粒度去树里找最长公共前缀。
一个是“块级精确匹配”,一个是“token 级最长前缀匹配”。这两条路各有各的适用边界,也各有各的坑。下面我分别拆开讲。
2. vLLM 的哈希表路线:以 block 为粒度的精确命中
2.1 一切从 PagedAttention 说起
理解 vLLM 的 Prefix Cache,必须先理解它为什么是 block 粒度的。vLLM 把连续的显存逻辑抽象成固定大小的 block,默认每个 block 可以存放 16 个 token 的 KV 数据(实际 token 数可以通过--block-size调整)。每个请求的 KV Cache 不要求物理连续,而是通过 block table 记录逻辑块到物理块的映射,这就像操作系统里的分页内存管理。
在这种设计下,KV Cache 的最小管理单位天然就是 block。要做缓存索引,最直接的想法就是把每个 block 的内容哈希一下,然后放到一个全局哈希表里。计算请求前缀时,也是一块一块地哈希,再一块一块地去哈希表里查有没有相同内容的 block。这个思路和 CPU 缓存、磁盘缓存的思路基本一致,工程上简单直接,和 vLLM 现有的 block 分配器也能无缝衔接。
vLLM 里负责这件事的核心类是PrefixCachingBlockAllocator,它把哈希表直接嵌入到了原本的 block 管理流程中。分配 block 时优先复用哈希表中已经存在的、并且当前没有被引用的 block;释放 block 时则把它重新放回哈希表,供后续请求使用。整个过程对于上层调度器来说几乎是透明的,这也是 vLLM 能快速迭代这个功能的原因之一。
2.2 vLLM 是怎么算 block 哈希的
拿到一个 block,vLLM 会对它包含的 token id 序列计算一个哈希值,作为哈希表的 key。这里有一个非常关键的细节:vLLM 并不是只对单个 block 的 token 序列做简单哈希,而是采用了从底层往上逐 block 链式哈希的方式。也就是说,后面的 block 在计算哈希时,会把自己的 token 序列和 parent block 的哈希一起作为输入。这样做的目的是防止一种微妙的前缀混淆:假设两个请求在某一段拥有相同的局部 token,但它们之前的前缀不同,如果不做链式处理,这些局部相同的 block 可能会被误当成同一段缓存。
实际代码里,每个PrefixCachingBlock会维护_hash字段。只有当 block 被填满、且不会再被追加 token 时,它才会被计算并写入缓存表。这个条件是 vLLM 实现里很关键的一点:如果 block 还没满,说明它的内容是“进行中”的,后面可能继续追加 token,此时把它暴露给其他请求使用太危险了。
我基于源码逻辑简化一下,它做的事情大概是这样:
# 简化自 vllm/block/prefix_caching_block.py 的核心思路 def compute_block_hash(parent_hash, block_id, token_ids): # 链式哈希:父块哈希 + 当前块ID + 当前块token序列 return hash((parent_hash, block_id, token_ids)) def should_cache_block(num_filled_tokens, block_size): # 只有已经填满的 block 才允许进入缓存表 return num_filled_tokens == block_size请求进来之后,调度器会通过get_common_computed_block_ids计算输入前缀的 block 哈希序列,然后逐个去哈希表里比对。命中的 block 意味着该段 KV Cache 已经有人算过了,可以直接复用,不需要再分配新的显存做 prefill。
2.3 哈希表方案的边界和代价
哈希表的好处非常明显:查询复杂度是 O(1),匹配过程极快,而且实现、调试、并发控制都相对容易。但它的代价同样明显。第一,缓存粒度只能是 block。如果两个请求的前缀内容相同,但长度不是 block_size 的整数倍,那么不足一个 block 的尾部就无法被复用,这段 KV 还是要重新算。第二,哈希表匹配要求“完全相等”,某个 block 里的 token 序列只要有一个位置不同,哈希就不同,整个 block 就相当于 miss 了。对于特别长的共享前缀,只要在非 block 边界处被截断,vLLM 依然能复用前面的完整 block,但最后一个 block 的收益就丢了。
另一个容易被低估的问题是哈希冲突和误匹配。vLLM 虽然用哈希值做索引,但在真正使用缓存时并不会只相信哈希值,它还会进一步比对 token ids,确保内容一致才会复用。这个设计我们后面会再提到,它是保证正确性的兜底手段。
从我实际经验来看,vLLM 的 Prefix Cache 在“离线批处理 + 固定模板 + 前缀长度远超 block_size”这类场景下效果非常好。模板前缀往往整齐地落在 block 边界上,block 哈希的命中率很高。但一旦进入在线交互场景,请求前缀长度参差不齐,尾部那一段无法命中的比例就会明显上升,这时候 SGLang 的树方案就有它的优势了。
3. SGLang 的 RadixTree 方案:token 级最长前缀匹配
3.1 RadixTree 到底是个什么结构
SGLang 走的是另一条路。它把缓存索引做成了一棵 RadixTree(基数树)。这棵树的结构可以理解为:每个节点存储一段连续的 token ids(也就是一组 token),所有请求的前缀按路径共享。根节点是空序列,从根节点往下,每个分支代表不同的字符序列分支。RadixTree 和普通前缀树的区别在于,连续、唯一的分支会被压缩到一个节点里,减少树的层数,匹配时一次能跳过一串 token。
举个例子,假设有两个请求,前缀分别是:
请求A: [system_prompt, "今天天气"] 请求B: [system_prompt, "今天适合跑步"]那么树里会有一个节点存[system_prompt],下面分裂出两个节点:一个存["今天天气"],另一个存["今天适合跑步"]。如果又来一个请求C,前缀是[system_prompt, "今天"],那它匹配到公共部分[system_prompt]后,会在该节点下继续匹配["今天"]。由于["今天天气"]和["今天适合跑步"]的前两个 token(“今天”)是共同的,树需要在该节点处做分裂,把它拆成["今天"]加两个后续分支节点。
SGLang 的RadixCache实现里,每个节点大概包含这些信息:当前节点存的一段 token ids(key)、对应的 KV Cache 索引(value)、父节点指针、子节点字典、最近访问时间和引用计数。实际结构比这复杂,但核心思想就是这个。
3.2 match_prefix:一次匹配,拿到所有可复用位置
SGLang 的前缀匹配核心是match_prefix接口。给定一个请求的完整 token 序列,它从根节点开始,沿着树逐节点比较,返回最长能匹配到的前缀长度,以及这段前缀对应的所有 KV Cache 索引。调度器拿到这些索引后,就可以直接告诉模型执行器:“这部分不要算,直接把这个位置的 KV 张量拿过来用。”
这套机制的灵活性在于,它不要求前缀长度对齐任何 block 边界。即使共享前缀只有几个 token,只要树里存了这段路径,它就能精确复用。相比之下,vLLM 在共享前缀不足一个 block 时,连一点便宜都占不到。
SGLang 在插入和删除时会做节点的分裂与合并。插入新请求时,如果发现公共前缀在一个节点的中间位置结束,而新请求的后续 token 和该节点的剩余部分不一致,SGLang 会把该节点分裂成两个节点,公共部分保留在上层,分歧部分拆开。这个操作很像 LSM-Tree 或者 Git 的对象树,本质上都是用空间换查询效率,把公共前缀收敛到共享路径上。
下面是一段演示树结构变化的简化描述:
请求1: [A, B, C, D] 请求2: [A, B, E, F] 插入请求1后树结构: 根 -> [A,B,C,D] 插入请求2后树结构: 根 -> [A,B] -> [C,D] 和 [E,F]这里的节点[A,B]被两个请求共同引用,它的 KV Cache 可以同时服务于请求1和请求2 的前半段,这就是树结构带来的前缀共享。
3.3 淘汰策略不是简单 LRU
有缓存就得有淘汰,否则显存迟早被历史请求占满。SGLang 的 RadixCache 采用的是一种近似 LRU 策略,但它不是简简单单按时间戳排个序就完事,而是深度结合了引用计数机制。
每个 RadixNode 里维护了一个last_access_time,最近被匹配过的节点会刷新这个时间戳。淘汰时,SGLang 会倾向于优先淘汰那些“叶子节点”中访问时间最早的部分,因为如果一个节点下面还有仍在使用的子树,贸然删除它会导致整条路径不可用。但如果某个节点当前正在被某个请求的 KV Cache 引用(比如刚被调度器分派出去),它的引用计数会大于 0,这种节点会跳过淘汰,防止出现 KV 正在被 GPU kernel 读取时缓存却被回收的问题。
这个细节非常重要。我在看源码时发现,SGLang 会同时维护ref_count和访问时间,淘汰操作必须同时满足“引用计数为 0”和“长时间未访问”才会执行。这样做虽然让淘汰逻辑比简单的 LRU 复杂不少,但换来了安全性和命中率之间的平衡。
要注意的是,RadixTree 的查询和插入不是严格的 O(1)。匹配一个请求的前缀,最坏情况下要遍历到树深处,复杂度正比于匹配路径的长度。好在实际应用中,每个节点下挂的子节点数量通常很少,而且节点内部是一次性比较一串 token,所以整体性能依然不错。我在长上下文场景里实际测过,SGLang 的前缀匹配耗时远小于 prefill 的耗时,几乎可以忽略不计。
3.4 树方案的软肋
RadixTree 也不是银弹。第一,它的实现复杂度明显高于哈希表。节点分裂、合并、引用计数、并发访问控制,每一个环节都需要仔细处理。框架本身替你处理好了,但一旦你想深度定制或做二次开发,需要付出的理解成本会高不少。
第二,在共享前缀非常短、甚至几乎没有前缀复用的场景下,RadixTree 的维护和查询开销就是纯纯的额外负担。你插入了很多细碎的节点,匹配时每次都走不到什么公共路径,树的体积膨胀,缓存的收益却趋近于零。这种场景下哈希表的简单高效反而更适合。
第三,SGLang 的 RadixTree 更适合“长前缀强复用”的工作负载,比如多轮对话、Agent、工具调用这类应用。对这些应用来说,每一轮的系统 prompt 和对话历史往往占据整个输入 token 数的一大半,树上一次命中就能省掉很多 prefill 计算。反过来,如果每个请求都是随机短文本,几乎没有共享前缀,那树方案的收益就非常有限。
4. 正面PK:一张表看清两个框架的取舍
聊到这里,两者的差异已经很清晰了。我做了一张对比表,方便大家在实际选型时快速对照。
| 对比维度 | vLLM Prefix Cache | SGLang RadixCache |
|---|---|---|
| 核心数据结构 | 哈希表(block 哈希索引) | RadixTree(基数树) |
| 缓存粒度 | block(默认 16 token) | token 级,一段连续 token 作为一个节点 |
| 匹配方式 | 求哈希后精确匹配整个 block | 从根节点开始匹配最长公共前缀 |
| 查询复杂度 | O(1) 哈希查找 | O(前缀路径长度) |
| 插入/删除复杂度 | 以 block 为单位,逻辑相对简单 | 涉及节点分裂/合并,逻辑复杂 |
| 前缀长度要求 | 需要完整 block 才能入缓存 | 任意长度前缀都可复用 |
| 容量淘汰 | 由 block 分配器统一管理 | 近似 LRU + 引用计数保护 |
| 并发控制 | 主要关注 block 与哈希表的锁粒度 | 树结构本身的并发安全和节点引用 |
| 最擅长场景 | 固定模板、长度规整的批处理任务 | 多轮对话、Agent、长共享前缀的交互场景 |
| 最怕遇到的情况 | 共享前缀不足一个 block,或前缀长度参差 | 随机短文本,几乎无前缀可复用的场景 |
这里有几个点值得展开讲一下。首先是“为什么 vLLM 不选择树结构”。核心原因在于 vLLM 的整个调度和显存管理都建立在 block 之上,KV Cache 的分配、释放、拷贝都要走 block 表。如果在这个体系里引入 token 粒度的树结构,意味着它需要额外维护一套从 token 到 block 的映射,而且调度器在寻找可用 KV 空间时,可能还要处理“某个 block 的前半段被缓存、后半段没有被缓存”这种碎片状态,这会极大增加调度复杂度。vLLM 选择哈希表,本质上是选择了和现有 block 管理最契合、工程上最稳健的方案。
SGLang 从一开始就做 RadixAttention,KV Cache 的分配天然和前缀树绑定在一起。它的数据结构设计决定了它可以在 token 级别做精细的前缀共享,而不必牺牲调度效率。这也是为什么 SGLang 在交互式场景里表现更激进的原因。
但两者其实也不是绝对的“谁比谁好”。我在实际使用中的感受是:如果你的工作负载前缀整整齐齐,vLLM 的哈希表路线效率很高,维护成本也低;如果前缀长度参差不齐,或者有大量交互式请求,SGLang 的树方案能榨出更多复用价值。
5. 部署与调优实战:我更推荐场景化选型
5.1 拿到框架后先看什么指标
不管用哪个框架,第一步永远是确认 Prefix Cache 真的在起作用,而不是你以为它在起作用。
vLLM 启动时加上--enable-prefix-caching之后,你可以通过监控面板或日志观察请求的缓存命中情况。比较直接的指标是 prefix cache hit rate,vLLM 在较新版本中支持通过 metrics 输出相关数据。如果命中率是零,先不要急着怀疑框架,多半是请求前缀构造有问题,或者 KV cache 的显存预留太小,导致缓存刚写进去就被淘汰了。
SGLang 的 RadixCache 是默认行为,没有显式的开关。你可以通过日志和 profiling 工具观察匹配到的前缀长度。它的日志体系里会有和 cache 相关的统计输出,重点看hit_len和total_len的比例,这个值就是你的前缀命中质量。
我个人的观察习惯是:先用一组完全相同的 prompt 压测,看第二次请求的 TTFT 是不是显著低于第一次。如果这个差值没有出现,基本可以排除框架问题,回头查自己的请求构造和 tokenizer。
5.2 影响命中率的三件“小事”
有些人开了 Prefix Cache 但效果不明显,我总结下来,原因通常出在下面三件事上。
第一,请求的前缀构造不稳定。很多应用会在 system prompt 里动态拼上当前时间、随机用户 ID、或者日志序号。一旦这些内容出现在前缀的末尾,哪怕只差一两个 token,整段前缀的复用率都会被拉低。处理方法很简单:把真正固定、且尽量长的内容放到 prompt 的最前面,把动态内容尽量往后放,让固定部分形成长而稳定的共享前缀。这个原则我在排障时反复验证过,几乎每次都能立竿见影。
第二,vLLM 场景下要注意 block 对齐。vLLM 默认 block size 是 16,如果你的共享前缀长度是比如 17 个 token,那么前 16 个 token 可以命中,多出来的 1 个 token 还是要重新计算。虽然损失不算大,但在批量请求很多时,这种反复出现的对齐损耗会累积。SGLang 没有这个问题,但 SGLang 的树结构对一致性要求同样严格,前缀末尾多了个空格或者换行符也会破坏匹配。
第三,显存预留和缓存的平衡。Prefix Cache 本质上是拿显存空间换计算时间,如果 KV cache 的显存预留太少,缓存条目很快就会被淘汰,命中率自然上不去。vLLM 里配置gpu_memory_utilization时,不能只看模型权重占了多少,还要给 Prefix Cache 留出富余空间。一般来说,如果工作负载有明显共享前缀,可以把gpu_memory_utilization适当调大,让 KV cache 块更多一些。SGLang 也有类似的显存分配参数,道理相同。
5.3 部署时这些参数值得试一下
先声明,我不是让你照抄参数,模型规模、显存大小、请求模式不同,参数差异会很大。以下是我在 7B~13B 模型、单卡 A100/A800 上实测过的一组基线,可以参考后自行调整。
vLLM 侧,至少开这几个东西:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --gpu-memory-utilization 0.9 \ --max-num-seqs 128 \ --enable-prefix-caching \ --block-size 16--enable-prefix-caching是必须的显式开关,--block-size可以根据你的平均前缀长度尝试增大到 32 或 64。增大 block size 会让单个 block 的复用颗粒变大、哈希条目减少,但也会让“最后一个 block 不满导致无法缓存”的浪费更严重。这个参数需要结合请求日志里统计的 prompt 长度分布来调,不能拍脑袋。
SGLang 侧我主要关注启动时的并发参数和显存分配策略。它的默认配置对交互式负载已经比较友好,我一般不会大改,重点观察 RadixCache 的命中率和显存占用。如果前缀命中率很高但显存占用接近上限,可以考虑调低 max 并发数,或者调整缓存淘汰的阈值参数。
还有一个很容易被忽略的点:多机多卡部署时,Prefix Cache 是每张卡本地维护的。如果负载均衡把请求分散到不同卡上,同一个前缀可能在不同卡上各算一遍,最终实际收益低于单机场景下的表现。这个问题在 SGLang 和 vLLM 都存在,如果共享前缀非常明显,可以考虑在做路由时优先把相同前缀的请求调度到同一张卡上。
6. 踩坑实录:我从这些坑里学到了什么
6.1 vLLM 的“最后一个 block”陷阱
这是我最开始踩过的一个坑,也是社区里被讨论最多的问题之一。vLLM 的 Prefix Cache 只缓存完整填满的 block,最后一个区块如果不足 block_size,它是不会被写入哈希表的。原因我在前面也提过:这个 block 的内容还没稳定,接下来可能继续追加 token,如果提前缓存了,一旦后续追加导致内容变化,哈希缓存就失效了。
这个机制带来的直接后果是:即使两个请求共享前缀长度完全相同,只要这个长度不是 block_size 的整数倍,尾部那一小段就必须重新计算。我见过有人因此觉得 vLLM 的 Prefix Cache 是“假”的,其实不是,它只是把缓存策略设计在了块粒度上。
解决办法也很朴素:如果场景允许,尽量让固定系统提示词的长度对齐 block size。当然不可能每次都对齐,所以更实际的做法是接受这个限制,或者直接切到 token 粒度的 SGLang。
6.2 tokenizer 的“隐形”破坏
还有一次,我们的请求在日志里看起来前缀完全一样,可命中率就是上不去。排查到最后,发现是AttenionMask前面有没有加入特定的 special token 导致的。模型不同,tokenizer 对换行、空格和特殊 token 的处理方式也不同,有些 tokenizer 会自动在文本前面加一个<s>或im_start,有些则不会。你的请求如果一边是“直接消费 API 的 prompt”,一边是“从别的框架传过来的 prompt”,token 序列很可能在开头就分叉了。
这提醒我,前缀匹配是基于 token id 的逐位比对,不是基于文本的视觉相似度。所以想最大化命中率,不同请求之间的 prompt 必须真正经过同一个 tokenizer、同一个预处理链路生成,不能靠“看起来一样”来判断。
6.3 别用手动清缓存的方式“调优”
SGLang 的 RadixCache 里,一个节点可能同时被多个请求引用。KV Cache 是显存里的张量,有引用计数保护,框架会在合适的时机自动淘汰。我见过一些同学为了“给显存腾空间”,手动清空 KV cache 或者重启引擎,结果不仅缓存状态丢失,还可能造成正在执行的请求被中断。做性能调优时,应该通过框架提供的手段观测缓存状态,不要尝试绕过引用计数去改底层缓存。
同样的道理也适用于 vLLM。它的 block 分配器对缓存和被占用 block 是严格区分的,如果你在 Kubernetes 层做 GPU 显存降级或者动态迁移,很容易触发 block 状态不一致的问题。我建议在容器环境里部署推理服务时,尽量保持稳定的显存生命周期,不要频繁重启。
6.4 长上下文下的显存“幻觉”
最后说一个比较隐性但影响很大的问题:Prefix Cache 命中并不等于显存占用变低。恰恰相反,为了缓存尽可能多的前缀,框架会占用更多显存来存储 KV 块。有些人在压测时发现,开了 Prefix Cache 后吞吐反而没提升多少,一看显存已经顶满,batch size 被压缩,后面请求的 decode 阶段反而变慢了。
这个现象并不是反驳 Prefix Cache 的价值,而是提醒你要全局地看待显存预算。Cache 收益主要在 prefill,但如果它压缩了 decode 的并行度,整体延迟和吞吐可能会失衡。遇到这种情况,我的处理方法是实测一组不同gpu_memory_utilization配置,找命中收益和 decode 并行度之间的甜点,而不是盲目追求缓存空间最大化。
说回选型。如果一定要我给一个倾向性建议:我个人的习惯是,离线离线批量推理、请求模板高度统一的场景,优先用 vLLM,省心且稳定;在线交互、多轮对话、Agent 应用,优先用 SGLang,它把长前缀复用这件事做到了极致,TTFT 的优化空间更大。当然框架迭代很快,我这几条经验也只基于当前主流版本的观察,你上线前还是要拿自己的数据跑一跑,毕竟“命中率”三个字,最诚实的解释永远来自生产环境里真实请求的统计。
顺带分享一个小技巧:在你做前缀命中率调优的时候,可以在请求日志里按“前缀长度”维度做累计分析,把那些超长前缀且重复度高的请求单独拎出来,看看它们的命中路径到底在哪一层断掉。这个分析在 vLLM 和 SGLang 里都能通过日志间接实现,花不了多少时间,但往往能帮你快速定位到到底是提示词结构问题、tokenizer 问题,还是参数配置问题。