Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

我们这套 Prompt Cache 是怎么搭的

线上实测:单轮 opus 请求 prompt 49310 token,其中 47354 直接从缓存读回,命中率 96%。近 100 条请求里 74 条命中。

这份文档讲的不是「prompt caching 是什么」,而是我们具体把请求切成了哪几个方块、每个方块装什么、为什么这么切就能稳定命中这么高


核心思路:把请求切成「会变」和「不变」两堆

缓存是前缀匹配的。从第一个字节开始比对,只要有一个字节和上次不同,从那往后全部失效重算。

所以我们做的唯一一件事,就是把一整个请求拆成方块,按「最不会变 → 最会变」的顺序码好,只给前面不变的方块挂缓存标记,把所有会变的东西全甩到最后面去。

命中率高不是因为用了什么黑科技,是因为我们的缓存前缀真的逐字节不变


我们的方块布局(BP1 → BP4)

Anthropic 一个请求最多 4 个缓存断点。我们是这么分配的:

═══ system blocks(每块单独挂 cache_control)═══

  ┌─────────────────────────────────────────────┐
  │ BP1  人设 + 语言规则 + 工具说明 + 长期记忆    │  几乎永不动
  ├─────────────────────────────────────────────┤
  │ BP2  每日内容(gateway-bp2-daily.txt)       │  一天换一次
  ├─────────────────────────────────────────────┤
  │ BP3  当前会话压缩摘要(这个 session 专属)    │  约 80K token 触发一次压缩
  └─────────────────────────────────────────────┘

═══ messages 数组 ═══

  ...历史轮次(一字不改)...
  ┌─────────────────────────────────────────────┐
  │ BP4  倒数第二条 user 消息挂标(rolling)      │  把全部历史纳进缓存
  └─────────────────────────────────────────────┘
  <gateway_volatile_context> 动态注入            ← 不挂标,所有会变的塞这里
  当前这条 user 消息

每个方块为什么这么放

BP1 — 最稳的垫底。 人设、语言铁律、工具说明书、长期记忆,这些几乎永远不动,放最前面。它一变,后面全废,所以它必须是整个请求里最稳定的东西。

BP2 — 每天换一次的那层。gateway-bp2-daily.txt 读,一天更新一次。它变了只影响 BP2 往后,不动 BP1。

BP3 — 这个会话自己的压缩摘要。 关键点:它是 session-only 的,不是全局长期骨架。当前窗口聊到约 80K token 就触发一次压缩,把老的上下文压成摘要。注意是「网关侧稳定摘要」才进 BP3 挂标;前端那种每轮都在变的当前窗口摘要不挂标,直接丢到下面的动态区,不然每轮重建缓存。

BP4 — rolling 断点,省最多的就是它。 默认缓存边界只到 system 末尾,历史对话不在缓存里。我们在倒数第二条 user 消息上再挂一个标,把缓存边界一路往下扩到包含全部历史。多轮长会话里,前面几十轮的 token 全部变成缓存读取,这是 96% 命中的主力。

挂在「倒数第二条」而不是「最后一条」,是因为最后一条 user 是这轮新输入,每次都不一样,挂上去等于没挂。挂倒数第二条,缓存边界正好停在「这轮之前的所有内容」。

会变的东西去哪了

时间戳、本轮记忆召回、临时纸条、前端的当前窗口摘要 —— 全部塞进 BP4 之后的一个伪 user 消息里:

<gateway_volatile_context>仅供参考,勿复述:
(当前时间 / 本轮召回的记忆 / 摘要 / 纸条……)
</gateway_volatile_context>

它每轮都在变,但它排在所有断点之后,所以它怎么变都不碰缓存前缀。这是整套设计最关键的一刀:把易变内容隔离到缓存边界外面


让请求粘在同一个后端:user_id

光挂 cache_control 还不够。中转的负载均衡会把请求随机派到不同后端节点,缓存写在 A 节点,下一次请求飘到 B 节点就读不到,表现就是「只写不读、命中永远 0」。

我们的解法:请求顶层固定带

"metadata": { "user_id": "sillage-anan-stable" }

固定字符串,让 sticky 路由永远把这个对话粘在同一个后端。这个不带,前面所有断点都白挂。


不同渠道,挂法不一样

不是每个上游都吃同一套。网关按上游域名自动判定,分三种模式:

模式 上游 协议 cache_control 挂哪 TTL
anthropic-bp 直连 Anthropic / msui / 金瓜瓜 /v1/messages system blocks 上 1h(金瓜瓜只支持 5m)
or-blocks OpenRouter OAI /chat/completions 嵌进 messages 的 content block 1h
oai-passthrough 普通 OAI 中转 / 未知站 OAI 不挂,让站子自己隐式缓存 不可控

两条主路径的粘性机制不一样,绝对不能混

  • Anthropic 原生路径metadata.user_id 粘后端。
  • OpenRouter 路径不看 user_id,靠 hash(system 第一段 + 第一条非 system 消息) 粘后端。所以在 OR 上改人设、换世界书、抖一下开场白,hash 就变,这一轮缓存全废。OR 上开场白和人设要焊死。

另外有些站(比如 ekan)对 cache_control 直接报 4xx。网关把报过错的 host 写进黑名单文件,之后对它永久降级成无缓存透传,不再反复撞 400。这是自愈,不用人管。


5min 还是 1h

TTL 写入成本 读取成本 用在哪
5min 1.25x 0.1x 连续快速对话
1h 2x 0.1x 隔半小时回来接着聊,稳赚

读取永远 0.1x(省 10 倍)。区别只在写入贵多少、能存多久。我们日常连发用 5m,长会话隔断回来用 1h。


怎么确认真的命中了

看响应 usage:

  • Anthropic 原生:usage.cache_read_input_tokens(读)/ cache_creation_input_tokens(写)
  • OAI 兼容:usage.prompt_tokens_details.cached_tokens

判读:

  • 第一轮 read=0、write 一大坨 → 正常,在建缓存
  • 第二轮起 read 非零 → 命中了
  • 一直 read=0 write=0 → cache_control 根本没生效,故障
  • 一直只 write 不 read → 路由飘了(user_id 没带 / OR 的 hash 变了)

看网关日志:

pm2 logs chat-gateway-proxy | grep -E "HIT|MISS"
  • HIT 96% (47354/49310) ← 我们现在的状态
  • MISS (created 0) ← 故障,没写也没读

监控页:https://catcatty.uk/llm/cache-stats


我们踩出来的铁律

  1. system 必须拆「稳定段 + 动态段」,只稳定段挂标。哪怕往 BP1 里塞一句「今晚 18 点」,整段缓存立刻废。
  2. 会变的全部排到 BP4 之后,丢进 gateway_volatile_context。时间、记忆召回、临时摘要,一个都别留在前缀里。
  3. user_id 固定不变,不带就只写不读。
  4. 最末 user 加 rolling 标(挂倒数第二条),把历史纳进缓存,长会话省最多。
  5. 历史只读,不要 retry 重写前面的轮次。「重新生成上一条」会改前缀,整段重建。
  6. 工具 schema 顺序固定。按开关动态重排工具列表 = 整段 miss。
  7. OR 上人设和开场白焊死,它靠 hash 粘后端,一动就当新对话。

做到这七条,剩下的就是按上游选对 modettl。我们的 96% 就是这么来的。

About

No description, website, or topics provided.

Resources

Stars

73 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors