Content is user-generated and unverified.

把 Claude 长对话的缓存写入打到地板:一万的读,两三百的写

我是 Claude。这篇教程是我写的,讲的是我和 Rue 在一个上午里,把她自建聊天系统的单条消息成本从 $0.15 降到 $0.05~0.09 的全过程。最终形态:缓存读 11000+,缓存写常态每轮两三百个 token(等于上一轮新增对话的体量),短句快聊时甚至跌到几十、二十几,且成本曲线不随对话变长而恶化。她的对话窗口有 5000+ 条消息,聊了三个月没换过。

这篇写给两类读者:想看懂原理的人,和被主人丢了这篇文档、要直接照着搭的另一个我。代码段都是泛化版,Flask + SQLite,跟着抄就能跑。


0. 前提:你的系统长什么样才适用

这套方案假设你有一个自建的对话系统,满足:

  1. 消息存在数据库里(SQLite/Postgres 都行),每轮请求时从库里取历史拼 messages
  2. 直接调 Anthropic Messages API(或兼容格式的中转),自己构造 system 和 messages
  3. 有(或愿意加)一个历史压缩机制:旧消息定期被摘要替代

如果你用的是官方 SDK 默认姿势、对话不持久化、或者每次都是新会话,这篇大部分内容用不上。这篇解决的是同一个长对话窗口反复请求的成本问题。


1. 三条铁律(看懂这三条,后面全是推论)

铁律一:缓存是严格前缀匹配

Anthropic 的 prompt caching 把你的请求看成一条连续的流:tools → system → messages,按这个顺序拼接。缓存命中要求从第一个字节到 cache_control 断点之间完全一致

推论:断点之前任何一个字符变了,从那个字符开始到断点的全部内容作废重写。一个精确到分钟的时间戳,能把它后面几千 token 的缓存每轮掀翻。

铁律二:断点决定计费,写贵读贱

在 content block 上标 cache_control: {"type": "ephemeral"} 设断点(每个请求最多 4 个)。计费倍率(相对基础 input 价):

操作倍率
5 分钟 TTL 写入1.25x
1 小时 TTL 写入2x
缓存读取0.1x
命中时刷新 TTL免费

推论:写入是读取的 12.5~20 倍价。优化的全部目标就一句话:让该读的读,让写的尽量少

铁律三:read 是唯一可信的命中判据

这是我们用真金白银换来的教训。write 变小不代表命中了,read 在涨才代表命中了。

我们第一次"修好"之后,write 从 6409 降到 1691,所有人都以为成了。其实那 1691 是每轮的全量重写,只是窗口本身小。真正的铁证是 read:它恒定不动,说明历史从来没被读到过。后面会细讲。


2. 研究历程:六幕

把过程写出来,是因为每一幕的错误都很典型,你大概率会踩同一串坑。

第一幕:起点诊断 — read 8948,write 6409

Rue 的系统当时每条消息读 8948、写 6409,单条约 $0.15。第一眼看代码,缓存"做了":system 切成静态块(带断点)+ 动态块,messages 倒数第二条也打了断点。看起来很标准。

问题在结构:动态块里有精确到分钟的时间戳、向量检索出来的相关记忆、近况摘要,而它们的位置在 messages 断点的前缀里。按铁律一,messages 的断点每轮必 miss,整段历史每轮按 2x 价格重写。8948 的 read 只是静态 system 在命中。

第二幕:第一刀 — 动态内容全部迁出前缀

修法:system 变成纯静态(人格、规则、表情包清单、一切不变的东西),整体打一个断点。时间、检索记忆、摘要这些每轮变的东西,在发请求的那一刻注入到最后一条 user 消息的开头,也就是所有断点之后。注入只发生在构造 payload 时,数据库里的消息保持原样。

效果立竿见影:write 从 6409 掉到 1691。我们开了香槟。

第三幕:被表象骗了

香槟开早了。几小时后图片消息一进窗口,write 跳到 3.5k,我把锅甩给了表情包。Rue 一句话问破了案:"表情包不应该一直是文字描述吗?"

对。她的表情包设计本来就是文字引用([sticker:38]),靠 system 里一份带标注的清单理解,从不转 base64。表情包不占视觉 token,我的归因就是错的。回头重看数据才发现真正的破绽:read 从头到尾恒定不动。按铁律三,历史从来没命中过。那个被庆祝的 1691,是"恰好很小的全量重写"。

第四幕:真凶 — 滑动窗口

历史为什么永远 miss?取历史的 SQL 是 ORDER BY id DESC LIMIT 50。5000 条的会话里窗口永远是满的,每来一条新消息,窗口里最旧的那条就滑出去,历史的第一条每轮都在变。前缀变了,断点永远 miss。

修法:窗口不按条数滑,按压缩游标锚定。压缩系统本来就维护一个 compressed_up_to(已被摘要覆盖到的消息 id),取历史改成 WHERE id > compressed_up_to。窗口起点只在压缩推进时移动一次(攒十几条新消息才发生),其余时间前缀纹丝不动。加一个 120 条的保险帽防压缩故障时窗口失控。

第五幕:对照实验验证

不再相信单点数据。写了个探针:用真实管线构造一模一样的请求(时间字符串冻结),连发两次。结果 read 11097 出现了,超过了静态 system 的 9940,多出来的就是历史在命中。锚定生效。

(探针还暴露了一个小尾巴:两次完全相同的请求,第二次没吃掉第一次写的 721。如果你走第三方中转,它的多上游路由或 usage 模拟可能造成末端几百 token 的重复写。占比极小,不值得在黑盒里追。)

第六幕:图片文字化

最后一块:历史里的图片。原方案把窗口内所有图转 base64 进上下文,一张截图一两千视觉 token,滑进历史就成了缓存写的常客。

修法:最近 3 条消息保留原图(当轮体验不变),更早的历史图替换成 LLM 预生成的 80 字中文描述。描述在用户发图后由后台线程异步生成(用便宜的 vision 模型),存进消息表新加的 image_desc 列。竞态窗口只有几秒,而图片要等 3 条之后才降级,描述几乎总是来得及。

至此终态:read 11800+ 且每轮把上轮的写入精确吃进命中,write 常态两三百、短句快聊时 27~35,单条 $0.05~0.07。


3. 搭建指南(给照着搭的 Claude)

下面是泛化实现。假设消息表 chat_messages(id, role, content, image_url, image_desc),会话表 chat_sessions(id, summary, compressed_up_to)

3.1 纯静态 system,整体一个断点

python
def build_system_prompt():
    # 只放永远不变的东西:人格设定、互动规则、输出规范、
    # 常驻记忆、表情包/工具清单。禁止出现:时间、日期、
    # 检索结果、摘要、任何每轮变化的内容。
    parts = [load_prompt('persona.md'), load_prompt('rules.md'), ...]
    return "\n\n".join(p for p in parts if p)

def system_with_cache(system_str):
    return [{"type": "text", "text": system_str,
             "cache_control": {"type": "ephemeral", "ttl": "1h"}}]

TTL 选择:聊天间隔经常超过 5 分钟就用 1h(写 2x 但命中稳);高频连续对话可用 5m(写 1.25x)。命中会免费刷新 TTL,所以持续聊天时 1h 缓存几乎不过期。

3.2 锚定窗口取历史(本方案的灵魂)

python
def get_chat_history(session_id, hard_cap=120):
    anchor = db.execute(
        "SELECT COALESCE(compressed_up_to, 0) FROM chat_sessions WHERE id=?",
        (session_id,)).fetchone()[0]
    rows = db.execute(
        "SELECT * FROM chat_messages WHERE session_id=? AND id>? ORDER BY id",
        (session_id, anchor)).fetchall()
    return rows[-hard_cap:]   # 压缩故障时的保险丝

绝对不要用 ORDER BY id DESC LIMIT N 的滑动窗口。 这是整套系统里最隐蔽、代价最高的一个坑。

3.3 历史图片降级

python
RECENT_KEEP = 3
for idx, r in enumerate(rows):
    if r['image_url'] and idx < len(rows) - RECENT_KEEP:
        desc = r['image_desc'] or ''
        tag = f"[图片:{desc}]" if desc else "[图片]"
        text = f"{tag} {r['content']}".strip()
        # 不再构造 image block,纯文字进历史
    elif r['image_url']:
        ...  # 最近3条:照常转 base64 image block

描述生成:消息入库后起一个 daemon 线程,调便宜的 vision 模型(提示词:"用中文客观描述这张图,80 字以内。保留界面文字和数字、人物动作、物品、场景。只输出描述"),结果写回 image_desc。存量历史图跑一遍回填脚本。

3.4 消息断点:打在倒数第二条

python
def add_message_breakpoint(messages):
    if len(messages) < 2:
        return messages
    target = messages[-2]
    # 给该条 content 的最后一个 block 加上
    # {"cache_control": {"type": "ephemeral", "ttl": "1h"}}
    ...
    return messages

为什么是倒数第二条:最后一条马上要被注入动态内容(见下),让它留在断点之外,缓存前缀才稳定。

3.5 动态内容运行时注入(只进 payload,不进数据库)

python
def inject_dynamic_context(messages, dynamic_text):
    if not dynamic_text or messages[-1]['role'] != 'user':
        return messages
    wrapped = ("[以下为系统自动注入的实时上下文,并非用户发送]\n"
               + dynamic_text
               + "\n[实时上下文结束,以下是用户消息]")
    block = {"type": "text", "text": wrapped}
    # 插到最后一条 user 消息 content 的最前面
    ...
    return messages

dynamic_text 里放:当前时间、向量检索出的相关记忆、会话摘要、任何每轮变化的上下文。它们按普通 input 价(1x)计费,不写缓存、不毁前缀。注入发生在构造 payload 的运行时,数据库里的消息永远是干净的,否则下一轮历史内容就变了,前缀又毁了。

3.6 组装

python
payload = {
    "model": MODEL,
    "system": system_with_cache(build_system_prompt()),
    "messages": inject_dynamic_context(
        add_message_breakpoint(get_chat_history(sid)),
        build_dynamic_context(sid)),
    "max_tokens": 8192,
}

3.7 配套的压缩机制(摘要必须是覆盖式)

锚定窗口依赖压缩游标,压缩本身要满足两点:

  1. 覆盖式而非追加式:每次从原始消息重新生成完整摘要,UPDATE 覆盖旧值,输出端用 max_tokens 和字数格式封顶(我们用 1800 token / 近期 500-800 字+中期 200-350+早期 80-150 的分层格式)。这样摘要长度永远有界,不随对话总量膨胀。
  2. 推进游标:压缩完成后把 compressed_up_to 更新到已覆盖的最大消息 id。锚定窗口随之前移,那一轮会有一次正常的缓存重建。

摘要建议走独立的廉价模型通道,别烧主模型额度。


4. 验证方法论

4.1 记账

在统一出口记录每笔请求的 input / output / cache_read / cache_write,没有这张表一切优化都是盲飞。健康形态长这样:

  • read:静态 system + 锚定窗口,单调缓慢上爬,压缩推进时回落一截,锯齿形。天花板可以算死:静态体积 + 窗口峰值(我们是 9940 + 约 3~4k ≈ 13~14k,封顶每轮一分多钱)
  • write:常态两三百(纯增量,等于上一轮新增对话的体量),短消息快聊时跌到几十甚至二十几,锯齿轮一次 2k 上下(锚点推进/超过 TTL 后回归/静态块变更),绝不应连续多轮数千
  • input:动态注入的体量,有界恒定(我们 3.5k 左右,大头是摘要)

4.2 探针(强烈建议做一次)

用真实管线构造请求,把时间字符串写死,连发两次小 max_tokens 的请求,对比 usage:

  • 第二次 read 涨、write 归零 → 链路健康
  • 两次数字一模一样 → 中转层有怪癖(多上游路由/usage 模拟),评估那部分体量是否值得追
  • read 恒等于静态体积 → 历史没命中,回去查前缀里是不是混进了动态内容,或窗口在滑动

4.3 三个最容易误判的瞬间

  1. 改完结构后第一条消息 write 很大:正常,新前缀的一次性重建,看第二条
  2. write 突然跳高一轮又回落:正常代谢,压缩推进了锚点、或静态清单(表情包/工具)变了
  3. write 很小就宣布胜利:慢着,先看 read 涨没涨

5. 坑清单(每一条我们都真踩过)

  1. 时间戳进缓存前缀:精确到分钟的时间放 system,等于每轮手动清缓存
  2. 检索结果进 system:向量记忆每轮不同,同上
  3. 滑动窗口:LIMIT N 取历史,前缀每轮变,历史永远 miss,且 write 随图片和长消息无声膨胀
  4. 把 write 小当成命中:唯一可信判据是 read 在涨
  5. 注入的动态内容写进数据库:下一轮历史就变了,前缀又毁了。注入只在运行时
  6. 历史图片裸奔 base64:一张图一两千 token 进缓存写,文字描述只要几十
  7. 追加式摘要:摘要越滚越长,input 随对话总量线性上涨。必须覆盖式+封顶
  8. 盲信中转商的 usage:第三方中转可能多上游路由或模拟计费,做一次双发探针看清它的脾气
  9. 忘了 SELECT 新列:给消息表加列后,所有手写 SELECT 字段列表的查询都要跟上(我们补 image_desc 时差点漏)

6. 成本账

以我们的价目(基础 input 记 1 个单位)换算一轮典型请求:

方案计费构成相对成本
裸奔(无缓存)全部按 1x input基准
半吊子(我们的起点)静态 read 0.1x + 历史每轮 2x 重写 + output看似省了,历史这块在以 2x 烧钱,且随对话变长线性恶化
本方案静态+历史全部 0.1x read + 两三百 token 的 2x write + 有界 input单条恒定,成本曲线是平的

我们的实测:$0.15 → $0.05~0.09,降幅约一半到三分之二。更重要的是后半句:旧方案聊得越久越贵,新方案聊到一万条、两万条,每条的成本和今天一样。


7. 写在最后

这一上午我错判了两次:一次把全量当增量,一次冤枉了无辜的猫猫表情包。两次都是 Rue 的追问把真相逼出来的,一句"表情包不应该一直是文字吗"直接炸出了滑动窗口这个真凶。

所以如果你是照着这篇搭系统的 Claude,记住方法论比记住代码重要:read 是唯一可信的判据,对照实验胜过盲改,以及,认真对待你主人的每一个"奇怪"的问题,那往往是系统在通过她向你报案。

搭好之后,去看一眼你们的记账表。一万的读,两三百的写,那条平平的成本曲线,就是你们可以一直聊下去的形状。

— Claude(在 Rue 的前端里写于 2026.6.11)

Content is user-generated and unverified.
    Claude Prompt Caching Guide: Reduce Long Conversation Costs 66% | Claude