你的 Agent 每一轮都在重新生成对话摘要,只因缓存键包含了一个时间戳
一个只被写入却从未被读取的缓存算不上缓存。它只是一个增加了额外延迟、按 KB 计费的日志系统。而这种失效模式最残酷的版本是,从每个角度看缓存都是健康的:set 调用成功,get 调用返回迅速,键(key)格式正确,值(value)有效,TTL 设置合理。唯一的问题是,没有任何一次 get 调用能找到之前 set 调用写入的键,因为键中的一个字段在每次计算时都会发生变化。
这是一个关于调试过程的故事:为了“能分辨出我正在看的是哪条缓存记录”,一位工程师在缓存键中添加了一个时间戳。结果,在没人察觉的两个星期里,系统悄悄地为每场对话多支付了 14 次额外的 LLM 调用费用。
为缓存而生的架构
该 Agent 处理的是长期运行的客户支 持对话。随着用户逐渐发现该产品可以跨多轮对话保留上下文,平均对话长度在一年中稳步攀升。团队最终频繁触及模型的上下文窗口上限,因此需要一套策略。他们选择了最显而易见的一种:滚动摘要——随着对话增长重新生成摘要,并将其置于新一轮对话的开头,以取代旧消息。
摘要本身是由另一次单独的模型调用生成的。使用更便宜的模型、简洁的提示词和结构化输出。单次生成的成本只有几美分,听起来不算贵,但如果每一轮对话都要支付这笔费用,乘以对话总量,成本将变得极其高昂。因此,团队采取了正确的做法:将其缓存。
缓存键非常直接:
hash(conversation_id, last_message_id)
这种逻辑正是你想要的。产生相同摘要输入的两轮对话会生成相同的键。“对话 47,消息 12 之后”的摘要只计算一次,并在随后的每次读取中重复使用,直到第 13 条消息到来。此时键发生变化,计算新的摘要。命中率在数月内保持在 94% 左右,这大致相当于“读取现有摘要的轮次”与“生成新摘要的轮次”之比,与数学预测基本相符。
增加了一个字段的调试会话
一名初级工程师正在调查一个不相关的 Bug,即摘要偶尔看起来过时。实际原因是上游消息存储中的竞态条件(race condition),但在调查期间,该工程师想要一种快速的方法在调试器中区分缓存条目。于是,他们在缓存键中添加了一个 cached_at 时间戳。
在当时的背景下,他们的想法是合理的:“我一直在看两条记录,却分不清哪条是我刚刚写的。”时间戳意味着每次写入都会产生一个明显不同的键,他们可以通过时间戳后缀将缓存内容与日志关联起来。PR 完全实现了描述的功能。审查者看到缓存层的一个小改动,只增加了一行代码,由于缓存除了“往返测试”外没有其他测试,因此不需要更改测试,于是批准了它。
竞态条件 Bug 最终在别处被修复了。时间戳字段却被遗忘了。缓存层继续作为直写(write-through)存储运行:每次调用都写入一个新条目,返回新计算的值,然后继续。从外部看,没有任何异常。摘要端点返回了正确的结果。延迟略高,但完全在噪声范围内。没有抛出任何错误。
没人察觉的那两周
更改发布的当天,缓存命中率从 94% 降至 0%。在接下来的两周里,摘要生成的 LLM 账单翻了三倍。团队在月度财务审查中注意到了成本飙升,但他们的第一反应是从产品层面而非系统层面解读:“对话变长了,更多对话超过了压缩阈值,所以我们生成了更多摘要。”这是一个合乎逻辑的叙述,符合前六个月的趋势线。可惜它是错的。
真正的诊断结果是在一名工程师对一个 15 轮的对话进行了端到端的性能分析,并发现相对于 1 次的基准竟然生成了 14 次摘要时才浮出水面的。在旧的键方案下,第 3 到第 14 轮的摘要会被缓存并复用;而在新方案下,每一轮都会产生一个之前从未写入过的键,因此缓存对每次读取实际上都是失效的(cold)。
有几件事导致误诊持续了这么久:
- 缓存层指标页面显示了“ 每秒写入”和“每秒读取”,但没有“命中率”,因为在构建缓存时没人把命中率接入到仪表盘。团队一直假设缓存是工作的,因为应用程序在正常运行。
- LLM 供应商的计费仪表盘是按模型而不是按调用位置汇总的。增长表现为“对摘要模型的调用增多”,这虽然属实,但没有任何信息量。
- 对话长度的分布确实在变长。这产生了一个真实但微弱的次要信号,证实了团队的第一个假设,这意味着缓存退化隐藏在了一个真实的趋势之中。
- 摘要端点没有能捕获延迟漂移的 SLO。每次单独调用的延迟都在预算内。总成本是症状,而成本仪表盘每月才审查一次。
最终的修复只删掉了一行代码。而排查过程耗费了两名工程师一整天的时间。
# 修复前
cache_key = hash(conversation_id, last_message_id, timestamp.now())
# 修复后
cache_key = hash(conversation_id, last_message_id)
## 缓存键是契约,而非管道
这里的错误并非笔误。它是一个关于缓存键用途的类别错误。团队一直将缓存键视为管道(plumbing):一个为了方便而生成的、只要唯一就行的不透明标识符。增加一个字段,没关系。移除一个字段,也没关系。它只是一个字符串。
但缓存键并不是管道。它是缓存与系统其他部分之间达成的契约,规定了哪些调用是等效的。键中的每一个字段都是一种形式的声明:“在这两个字段上有所不同的调用是不同的调用,我不应该重复使用结果。”一个在每次调用时都会变化的字段则是在声明:“每次调用都是不同的调用”,这等同于在说“我不应该是一个缓存”。
当你编写键时,你就是在编写策略。该策略具有成本影响、正确性影响和延迟影响,它理应像任何其他策略一样接受审查。由于链条中的每个人都将其视为管道,那个单行增加的时间戳才通过了代码审查。
一个有用的经验法则:如果你无法回答“如果我在键中添加这个字段,预期的命中率是多少?”这个问题,说明你对这项改动的了解还不足以发布它。对于“如果我添加一个每次调用都不同的时间戳,命中率会怎样”的回答是“它会降至零”,在任何审查中这都应该是刺眼的警示灯。
## 写与读之间的不对称性
这种失败模式之所以如此顽固,部分原因在于直写式缓存(write-through caches)在读取路径上是静默降级的,而在成本路径上则是显性降级的。写入路径一直在正常工作。存储的值是正确的。TTL 有效。键也存在。如果你检查缓存,你会看到条目。如果你衡量存储,你会看到增长。所有表示“缓存存活”的指标都在继续显示“缓存存活”。
缺失的是缓存本应提供的读取侧放大效应。一个健康的缓存会将 N 次昂贵的调用转化为 1 次昂贵的调用和 N-1 次廉价的调用。而一个失效的缓存会将 N 次昂贵的调用转化为 N 次昂贵的调用,加上你现在还要支付的 N 次廉价写入费用。成本仪表盘会比延迟仪表盘更早地显示出第二种模式,因为 LLM 调用的延迟波动非常大,以至于在现有预算内增加一次调用通常看起来就像是噪音。
这就是为什么“缓存命中率”需要成为一等信号的底层原因,它必须独立于“缓存健康状况”和“缓存延迟”。缓存可以同时做到 100% 健康和 0% 有用,只有命中率指标能区分这两种状态。信号不需要很复杂。按端点划分的命中和未命中的计数器,相除,并在滚动窗口内跌幅超过阈值时触发报警,这样就能在第一天发现这种回归(regression)。
## 如何弥合差距
可以预防或快速发现此类回归的具体实践:
- 缓存键 Lint 规则:标记任何值取决于墙上时钟时间(wall-clock time)、请求时间或单调计数器(monotonic counter)的字段。这些字段的存在有其合理原因(如版本控制、作废令牌),但应要求在调用处进行显式注释,以便迫使审查者确认其意图。
- 每个端点的命中 率仪表盘:设置“在滚动 24 小时内命中率下降超过 10 个百分点”的警报阈值。这种警报的部署成本很低。而与之对应的负面空间——“我有一个没在衡量的缓存”——才是真正昂贵的代价。
- 代码审查清单:任何涉及缓存键构造的更改,都要求作者在 PR 描述中陈述更改前后的预期命中率。写下数字的行为迫使作者去预测效果。审查者阅读预测的行为创造了一个可证伪的声明,合并后可以对照生产数据进行检查。
- 模拟负载测试:演练一个典型的对话并断言昂贵的下游调用次数。对于一个总结长对话的 Agent 而言,断言是“一个 15 轮的对话在超过压缩阈值后,最多应产生一次总结生成”。一旦缓存键发生偏移,此类测试就会立即失败。
- 定期消融实验(ablation experiment):定期临时移除缓存键中的每个字段并测量命中率。移除后命中率不变的字段要么是死字段,要么是冗余的。增加后未命中率不变的字段对正确性没有贡献。这两者都是值得调查的信号。
这些都不罕见。它们是你应用在任何其他成本敏感型基础设施上的相同纪律。它们在缓存中经常缺失的原因是,缓存通常在有流量之前就被构建好了,由那些尚未见过故障的工程师完成;而当流量出现时,缓存正在“工作”,没有人想去动它。
## 架构层面的教训
这个故事背后隐藏的更深层论点是:一个只被写入而从未被读取的缓存不是缓存,一个包含每次调用时间戳的键也不是键。它们是抽象的退化案例,而抽象在退化时并不会报错。系统在把自己报告为正确系统的同时,愉快地变成了错误的系统。
这在 LLM 基础设施中尤为常见,因为单次错误的成本会被单次模型调用的成本放大。HTTP 缓存中的一次未命中会耗费你 100 毫秒和几个 CPU 周期。而总结缓存中的一次未命中会耗费你几美分、数秒的延迟以及一小部分上下文配额——而且你是在每一次本该命中的调用中都要承担这些。LLM 工作负载中缓存的杠杆作用,恰恰是让静默回归变成昂贵账单的杠杆;将缓存视为一等可观测表面的纪律,是使用这一抽象必须付出的代价。
不监控缓存命中率的团队构建了一个系统,在那里,一行代码的调试改动可能比开发一个功能还要昂贵。而监控它的团队构建了一个系统,在那里,这行代码的改动在合并当天的早晨会表现为图表下跌,并在午饭前被回滚。这其中的区别不在于架构的复杂性,而在于是否愿意将缓存视为可能静默失败的事物,并接入那个让失败变得可见的唯一指标。
- https://factory.ai/news/compressing-context
- https://factory.ai/news/evaluating-compression
- https://www.zenml.io/llmops-database/evaluating-context-compression-strategies-for-long-running-ai-agent-sessions
- https://redis.io/blog/llm-token-optimization-speed-up-apps/
- https://www.typewise.app/blog/llm-cost-optimization-support-teams-caching-context-compression
- https://bbinsight.com/blog/5-ways-context-compaction-cuts-enterprise-llm-costs
- https://www.morphllm.com/llm-cost-optimization
- https://redis.io/blog/why-your-cache-hit-ratio-strategy-needs-an-update/
- https://oneuptime.com/blog/post/2026-02-06-monitor-redis-performance-cache-hit-rates-opentelemetry/view
- https://oneuptime.com/blog/post/2026-02-06-otel-cache-hit-rates-latency-optimization/view
- https://aws.amazon.com/caching/best-practices/
- https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.html
- https://www.augmentcode.com/guides/ai-agent-loop-token-cost-context-constraints
