摘要
前缀缓存(Prefix Cache / KV 复用)的官方说法只有一句:「从 prompt 开头完全一致的部分才命中」。但工程上真正决定命中率的是你怎么排列 prompt ,不是引擎。本文在本机(Windows 10 / Python 3.11.5 / 32 逻辑核)写了一套按「累积块哈希 + 连续前缀匹配」建模的仿真台,脚本可运行、结果可复现。实测:同样 12 轮 4.7 万 token 的会话,把长文档放在易变字段之前 命中率 49.5%、放在之后 只剩 19.2%,相对 Prefill 成本 0.554 对 0.827;一段 2000 字的公共模板,只要把一个时间戳顶到最前面,命中率从 98.0% 直接跌到 0.0%;分块从 8 加到 256,命中率只降 2.4 个百分点但末块补齐多花 42 倍 token。所有数字来自本机 stdout,同实验至少 3 轮并给出区间。
一、先说清楚命中的判据
前缀缓存不是「内容一样就复用」,而是三条同时成立才复用:
- 命中必须从第 0 个 token 开始连续,中间断一块,后面全部重算;
- 匹配以块为粒度(常见实现按固定 token 数切块再做哈希),不足一块的尾巴不参与匹配;
- 块的索引是累积的------第 k 块的哈希里含着前 k−1 块的全部内容,所以同一段文字出现在不同位置算两个不同的块。
这三条推出一个反直觉的结论:你能控制的只有「排列」,而排列里最重要的是易变字段的位置。
#mermaid-svg-AGfxmP7JxpsyXgET{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AGfxmP7JxpsyXgET .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AGfxmP7JxpsyXgET .error-icon{fill:#552222;}#mermaid-svg-AGfxmP7JxpsyXgET .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AGfxmP7JxpsyXgET .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AGfxmP7JxpsyXgET .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AGfxmP7JxpsyXgET .marker.cross{stroke:#333333;}#mermaid-svg-AGfxmP7JxpsyXgET svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AGfxmP7JxpsyXgET p{margin:0;}#mermaid-svg-AGfxmP7JxpsyXgET .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AGfxmP7JxpsyXgET .cluster-label text{fill:#333;}#mermaid-svg-AGfxmP7JxpsyXgET .cluster-label span{color:#333;}#mermaid-svg-AGfxmP7JxpsyXgET .cluster-label span p{background-color:transparent;}#mermaid-svg-AGfxmP7JxpsyXgET .label text,#mermaid-svg-AGfxmP7JxpsyXgET span{fill:#333;color:#333;}#mermaid-svg-AGfxmP7JxpsyXgET .node rect,#mermaid-svg-AGfxmP7JxpsyXgET .node circle,#mermaid-svg-AGfxmP7JxpsyXgET .node ellipse,#mermaid-svg-AGfxmP7JxpsyXgET .node polygon,#mermaid-svg-AGfxmP7JxpsyXgET .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AGfxmP7JxpsyXgET .rough-node .label text,#mermaid-svg-AGfxmP7JxpsyXgET .node .label text,#mermaid-svg-AGfxmP7JxpsyXgET .image-shape .label,#mermaid-svg-AGfxmP7JxpsyXgET .icon-shape .label{text-anchor:middle;}#mermaid-svg-AGfxmP7JxpsyXgET .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AGfxmP7JxpsyXgET .rough-node .label,#mermaid-svg-AGfxmP7JxpsyXgET .node .label,#mermaid-svg-AGfxmP7JxpsyXgET .image-shape .label,#mermaid-svg-AGfxmP7JxpsyXgET .icon-shape .label{text-align:center;}#mermaid-svg-AGfxmP7JxpsyXgET .node.clickable{cursor:pointer;}#mermaid-svg-AGfxmP7JxpsyXgET .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AGfxmP7JxpsyXgET .arrowheadPath{fill:#333333;}#mermaid-svg-AGfxmP7JxpsyXgET .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AGfxmP7JxpsyXgET .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AGfxmP7JxpsyXgET .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AGfxmP7JxpsyXgET .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AGfxmP7JxpsyXgET .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AGfxmP7JxpsyXgET .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AGfxmP7JxpsyXgET .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AGfxmP7JxpsyXgET .cluster text{fill:#333;}#mermaid-svg-AGfxmP7JxpsyXgET .cluster span{color:#333;}#mermaid-svg-AGfxmP7JxpsyXgET div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AGfxmP7JxpsyXgET .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AGfxmP7JxpsyXgET rect.text{fill:none;stroke-width:0;}#mermaid-svg-AGfxmP7JxpsyXgET .icon-shape,#mermaid-svg-AGfxmP7JxpsyXgET .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AGfxmP7JxpsyXgET .icon-shape p,#mermaid-svg-AGfxmP7JxpsyXgET .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AGfxmP7JxpsyXgET .icon-shape .label rect,#mermaid-svg-AGfxmP7JxpsyXgET .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AGfxmP7JxpsyXgET .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AGfxmP7JxpsyXgET .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AGfxmP7JxpsyXgET :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
请求 prompt
第0块命中?
全部重算
命中率 0%
第1块命中?
只复用第0块
后面全重算
...连续命中
复用最长连续前缀
断开点之后重算
淘汰中间块
⇒ 后代全部作废
二、仿真台怎么搭的
没有 GPU、没有模型权重,所以我仿的是匹配与淘汰逻辑 ,不是真实显存。下面两段是脚本里真正干活的部分(demos/prefix_cache_lab.py,逐字节摘自源文件)。
python
def block_keys(toks, block):
"""返回每个块的"累积前缀哈希"及该块 token 数。
key 含全部历史内容 ⇒ 同内容出现在不同位置不会被误当同一前缀复用。
"""
keys, cum = [], ""
total = len(toks)
for b in range((total + block - 1) // block):
seg = "".join(toks[b * block:(b + 1) * block])
cum = hashlib.blake2b((cum + seg).encode("utf-8"), digest_size=8).hexdigest()
keys.append((cum, min((b + 1) * block, total) - b * block))
return keys
python
class PrefixCache:
def __init__(self, capacity_blocks, touch_on_hit=True):
self.cap = capacity_blocks
self.touch_on_hit = touch_on_hit
self.entries = OrderedDict() # key -> (parent, n_tokens),插入序即淘汰序
self.children = {ROOT: set()}
self.hits = self.misses = 0
self.evictions = 0 # 主动淘汰条数
self.cascaded = 0 # 因祖先被删而连带作废的后代条数
self.tail_waste = 0 # 末块不足一块、无法参与匹配的 token 数
_drop、_evict_to_cap、orphan_entries 三个辅助方法到实验六的结论里才用到,源码见 demos/prefix_cache_lab.py。命中判据与复用计算是这一段:
python
def reuse_and_insert(self, toks, block):
"""返回 (可复用 token 数, 需重算 token 数),并把重算部分写回缓存。"""
keys = block_keys(toks, block)
if keys:
self.tail_waste += block - keys[-1][1] # 末块补齐的浪费
reuse, parent, broken = 0, None, False
for key, nt in keys:
if key in self.entries:
self.hits += 1
if self.touch_on_hit:
self.entries.move_to_end(key) # LRU touch
if not broken:
reuse += nt
parent = key
else:
self.misses += 1
broken = True # 断开点之后不再计入复用
self.entries[key] = (parent, nt)
self.children.setdefault(parent if parent else ROOT, set()).add(key)
parent = key
self._evict_to_cap()
return reuse, len(toks) - reuse
口径必须写死 :命中率 = 复用 token / 总 prompt token;相对成本 = (复用×0.1 + 未复用) / 总 token,其中 0.1 是「命中部分按 1 折计价」这个我选的假设 ,用来把成本压到 0~1 区间和「完全不复用 = 1.000」对照,不是任何厂商的报价。分块粒度、缓存容量都是脚本入参,真实引擎里由部署方配置,两者数值不可直接迁移。
三、实验一:长文档放在哪,差 2.7 倍成本
会话负载是 8/12/16 轮的三种规模,长文档 1000/1500/2200 字,block=16、缓存容量给足,每档跑一轮共 3 轮。
| 负载 | 长文档在易变段之前 | 长文档在易变段之后 |
|---|---|---|
| 8 轮 / 文档 1000 字 | 51.5% · 成本 0.536 | 25.0% · 成本 0.775 |
| 12 轮 / 文档 1500 字 | 49.5% · 成本 0.554 | 19.2% · 成本 0.827 |
| 16 轮 / 文档 2200 字 | 49.8% · 成本 0.552 | 15.0% · 成本 0.865 |
| 区间 | 49.5%--51.5% / 0.536--0.554 | 15.0%--25.0% / 0.775--0.865 |
会话越长、文档越大,第二种排列掉得越狠(49.8% → 15.0%)。原因不是文档变贵,而是文档被排在了「时间戳/会话号」后面,而那一段每轮都在变,导致连续前缀在文档之前就断了。
四、实验二:短易变字段的位置,能一票否决
12 个会话 × 10 条请求、2000 字公共模板,只改易变字段的位置,3 轮(3 个随机种子)结果完全一致:
| 易变字段位置 | 命中率 | 相对成本 |
|---|---|---|
| 模板之后(tail) | 98.0%--98.0% | 0.118--0.118 |
| 插在模板中间(between) | 49.0%--49.0% | 0.559--0.559 |
| 顶在最前面(front) | 0.0%--0.0% | 1.000--1.000 |
front 那一行是整个实验里最贵的一个数字:一个 20 来 token 的会话号/时间戳顶在最前面,2000 字模板一条都没复用出去,成本等于完全没有缓存。中间位置只切断后半段,所以还剩 49%。这就是「稳定性梯度」------越容易变的内容,越要往后排。
五、实验三:块大小几乎不影响命中率,但影响浪费
固定 12 轮负载扫 block ∈ {8, 16, 64, 256},因为时间戳位数固定、不改变块边界,每个粒度取 3 个种子的结果一致,下表按单值给出:
| block | 命中率 | 末块补齐浪费(token) | 缓存条目数 |
|---|---|---|---|
| 8 | 49.70% | 36 | 2999 |
| 16 | 49.52% | 84 | 1508 |
| 64 | 48.78% | 372 | 387 |
| 256 | 47.30% | 1524 | 104 |
块从 8 涨到 256,命中率只掉了 2.4 个百分点 ,但末块补齐从 36 token 涨到 1524 token(42 倍)。也就是说大块的代价主要不是「少命中」,而是每条请求末尾都有一截注定参与不了匹配,外加条目数少到 104 之后,缓存粒度变粗、被淘汰时连带作废的后代更多。
六、实验四:模板批次里,公共段越长越划算
60 条同模板请求、block=16、3 轮(尾参随机):
| 公共模板长度 | 命中率区间 | 相对成本区间 |
|---|---|---|
| ≈500 字 | 86.9%--87.2% | 0.215--0.218 |
| ≈2000 字 | 94.9%--95.0% | 0.145--0.146 |
| ≈6000 字 | 97.2%--97.3% | 0.125--0.125 |
收益边际递减很明显:500→2000 字涨 8 个百分点,2000→6000 字只涨 2.3 个百分点。要省成本,优先把公共段做长到 2000 字这个量级,而不是无限堆模板。
七、实验五:换 system prompt 只贵一条请求
把版本号放在 system prompt 最开头(第 0 块就变),120 条请求、第 40 条切版本,3 轮:
seed=20261020 切换前均值 96.3% | 切换后第 1 条 0.0% | 第 2 条 98.8% | 切换后 <50% 的条数 1
seed=20261021 切换前均值 96.3% | 切换后第 1 条 0.0% | 第 2 条 98.8% | 切换后 <50% 的条数 1
seed=20261022 切换前均值 96.3% | 切换后第 1 条 0.0% | 第 2 条 98.8% | 切换后 <50% 的条数 1
「改一个标点整个缓存就废了」这句话要精确理解:废的是切换后的第一条请求,之后立刻恢复。真正怕的不是改动本身,而是灰度期新旧两版同时在跑------两棵树各自预热,瞬时内存翻倍、命中率各砍一半。
八、实验六:容量吃紧时,淘汰策略差一倍命中
同一 12 轮负载,block=16,把缓存容量压到 64/128/256/512/2048 块,对比「命中即刷新(LRU)」和「命中不刷新(FIFO)」,3 轮区间:
| 容量(块) | LRU 命中率 | FIFO 命中率 | LRU 孤儿条目 | FIFO 孤儿条目 |
|---|---|---|---|---|
| 64 | 0.0% | 0.0% | 64 | 64 |
| 128 | 0.0% | 0.0% | 128 | 128 |
| 256 | 27.0% | 18.0% | 256 | 256 |
| 512 | 49.5% | 40.5% | 156 | 512 |
| 2048 | 49.5% | 49.5% | 0 | 0 |
「孤儿条目」是我额外埋的指标:还留在缓存里、但祖先链已断开的块------占着显存却永远命中不了 。FIFO 在 cap=512 时 512 条全是孤儿,等于一整份缓存容量被死数据占满;LRU 因为命中会把浅层热块往后推,同期只剩 156 条。多租户模板流(300 条不同尾参)下孤儿只有 0--3 条,说明孤儿是浅层前缀被深层尾部挤掉造成的,租户越多、前缀越浅,问题越重。
九、避坑指南
- 把 request_id / 时间戳 / traceId 放在 prompt 开头------本文实测这一条就能让命中率归零(98.0% → 0.0%)。这些字段要往末尾塞,或者干脆放 HTTP header。
- 把检索到的文档拼在用户问题之后:文档每轮不同可以理解,但用户问题更不同。按「稳定性梯度」排:固定 system → 长文档/少变 → 会话历史 → 本轮输入 → 易变元数据。
- 以为改 system prompt 是「一次性小成本」而随意灰度:切换瞬间只贵 1 条请求,但新旧并跑期会同时维护两棵前缀树,容量翻倍。版本切换要挑低峰、且一次性切完。
- 只调大 block 来省内存:命中率代价看着小(本文 47.3% vs 49.7%),但末块浪费按块大小线性放大(36 → 1524 token),条目数骤减还会放大级联淘汰。
- 命中率跌了先怀疑引擎 :先看淘汰策略 和浅层热块有没有被当成普通块淘汰。本文的孤儿条目就是这种「钱花了、缓存却没在用」的隐性浪费,光看命中率指标看不出来。
- 拿仿真数字当上线承诺 :本文所有值都是 token 口径的相对量,没有一次真实 GPU 前向;真实收益还受显存水位、并发排队、多实例路由一致性影响。上线前必须用自己的流量回放去量网关的 cache 命中埋点,而不是引用任何别人的百分比(包括本文的)。
- 多实例部署不做前缀亲和:同一会话被轮询到 N 个实例,等于把命中率除以 N。这一条本文没有实测(本机只有单实例逻辑),只作为已知的架构约束列出,数值不敢给。
十、结论
- 排列 > 配置 :同样内容、同样长度,只改长文档与易变字段的相对位置,相对成本从 0.536--0.554 变成 0.775--0.865(3 轮区间),差最多 1.6 倍;把一个时间戳顶到最前面,成本直接到 1.000。
- 该做的是「稳定性梯度」这条排版纪律:越易变越靠后,模板公共段做到 2000 字量级(成本能从 0.215 压到 0.145),system prompt 版本一次性切换。
- 容量与淘汰策略要在压测里单独看:本文 LRU 与 FIFO 在 512 块容量下差 9 个百分点命中率(49.5% vs 40.5%),FIFO 还会留下 512 条完全不可达的孤儿块。
- 别把本文数字当结论搬走:这是匹配逻辑的仿真,唯一能直接复用的是「易变字段位置一票否决」这条定性的、机制决定的规律。
附:运行环境与复现方式
- Windows 10(10.0.26200)/ Python 3.11.5 (Anaconda) / Intel64 Family 6 Model 183 / 32 逻辑核;只用标准库
hashlib、random、collections,无第三方依赖、不联网、不调用任何真实模型 API。 - 复现:
PYTHONIOENCODING=utf-8 python demos/prefix_cache_lab.py,完整 stdout 留档在demos/prefix_cache_lab.transcript.txt。 - 近似 tokenizer 只保证「同一文本每次切法一致」(每个汉字 1 token,连续非汉字串每 4 字符约 1 token),与任何真实 BPE 词表无关;结论以块为单位,对绝对 token 数不敏感。