1. 为什么 AI Agent 的缓存层不能照搬传统 Web 那套
很多人第一次给 AI Agent 加 Redis 缓存,脑子里浮现的还是那套经典套路:查数据库之前先查 Redis,命中就返回,没命中就回源写缓存。这套逻辑在传统 CRUD 业务里跑了十几年,稳得很。但把它原封不动搬到 AI Agent 场景,你会发现缓存命中率低得可怜,甚至出现"缓存了反而更慢"的诡异现象。
根本原因在于,AI Agent 的请求特征和传统 Web 请求完全不是一个物种。传统 Web 请求的参数空间是离散且有限的,比如用户 ID、商品 ID、分页页码,组合数量可控,缓存键容易收敛。而 AI Agent 的一次调用,输入往往是一整段自然语言提示词、一段对话历史、一组工具描述、若干检索到的上下文片段,这些东西拼在一起,几乎每次都不一样。你如果直接拿整个 prompt 做缓存键,命中率基本趋近于零。
我在实际项目里做过统计,一个客服类 AI Agent,如果按"完整 prompt 哈希"做缓存键,连续一周的命中率只有 3% 左右。但换成"意图分类 + 关键实体 + 工具调用签名"这种结构化键之后,命中率能拉到 40% 以上。这个差距就是理解 Agent 缓存本质的分水岭。
所以这一章我想先把认知层面的事情讲透:AI Agent 的缓存,缓存的到底是什么?我的答案是三个层次。
第一层是语义结果缓存。同一个问题,用户可能用十种不同说法问出来,但底层意图和答案是一样的。这一层缓存的是"意图到结果"的映射,需要做语义归一化,不能靠字符串精确匹配。
第二层是工具调用结果缓存。Agent 在推理过程中会调用外部工具,比如查天气、查订单、检索知识库。这些工具调用的结果往往有 TTL 特性,短时间内重复调用完全没必要。这一层缓存的是"工具名 + 参数"到"返回结果"的映射,键的设计要稳定。
第三层是中间推理状态缓存。多轮对话里,Agent 的思考链、已确认的槽位、已排除的选项,这些状态如果每轮都重新计算,token 消耗会爆炸。这一层缓存的是会话级的中间态,通常和 session 绑定。
这三层的 TTL、键结构、失效策略都不一样,混在一起做必然出问题。下面我会逐层拆开讲,每一层都会给出可落地的键设计、序列化方案和踩坑记录。
提示:如果你现在的 Agent 还在用"整个 prompt 做 key"这种粗暴方案,先别急着优化代码,先把上面这三层想清楚,否则优化方向从一开始就是错的。
2. 语义结果缓存:把"换个说法"的请求收敛到同一个键
2.1 为什么精确匹配在 Agent 场景必然失效
传统缓存用MD5(prompt)做键,逻辑上没问题,但 Agent 的用户输入太自由了。举个真实例子,同一个退款诉求,用户可能说"我要退款"、"这个订单能退吗"、"帮我处理一下退货"、"东西不想要了怎么退钱"。这四句话的 MD5 完全不同,但 Agent 的处理路径和最终答案高度相似。
如果每句话都走一遍完整的 LLM 推理,成本是四次推理的 token 费用,延迟也是四次完整链路。而如果能把它们收敛到一个缓存键,后三次直接命中,省下的就是真金白银。
这里的关键动作是语义归一化,也就是在写缓存和读缓存之前,先把用户输入映射到一个稳定的语义标识上。常见做法有两种:一种是轻量级的意图分类模型,一种是向量相似度检索。我两种都用过,各有适用场景。
2.2 意图分类方案:快但需要维护标签体系
意图分类的思路是,先用一个小模型(或者规则引擎)把用户输入归类到预定义的意图标签,再用"意图标签 + 关键实体"作为缓存键。比如上面四句话都会被归到intent:refund,实体是order_id,那么键就是agent:semantic:refund:order_12345。
这个方案的好处是快,分类模型可以做到 10ms 以内,而且键的可读性极强,排查问题的时候一眼就能看出缓存的是什么。坏处是标签体系需要人工维护,业务一变就得加标签,而且边界 case 容易分错。
我在实际项目里踩过一个坑:早期意图标签只有 20 个,结果上线两周后发现大量请求落到intent:other,缓存完全没起作用。后来把标签扩到 80 多个,并且加了一个"低置信度不缓存"的兜底逻辑,命中率才稳定下来。
# 意图分类 + 实体抽取后构造缓存键的简化示例 import hashlib import json def build_semantic_key(intent: str, entities: dict, confidence: float) -> str | None: # 置信度太低不缓存,避免污染缓存 if confidence < 0.75: return None # 实体排序后序列化,保证键稳定 entity_str = json.dumps(entities, sort_keys=True, ensure_ascii=False) entity_hash = hashlib.md5(entity_str.encode()).hexdigest()[:12] return f"agent:semantic:{intent}:{entity_hash}"注意这里对实体做了排序再哈希,因为字典顺序不稳定会导致同一个实体集合生成不同的键。这个细节看起来小,但线上真的会因为字段顺序变化导致缓存穿透。
2.3 向量相似度方案:召回率高但成本要算清楚
向量方案的思路是,把用户输入 embedding 之后,在向量库里找最相似的历史请求,如果相似度超过阈值,就复用那条历史请求的缓存结果。这个方案召回率明显更高,能覆盖意图分类覆盖不到的边角 case。
但它的成本不能忽略。每次请求都要做一次 embedding 计算,还要做一次向量检索,这两步加起来通常 30ms 到 80ms。如果你的 Agent 本身推理只要 500ms,那这个开销还能接受;但如果你的 Agent 是轻量级的,推理只要 200ms,那向量检索的开销就占比过高了。
我的经验是,向量方案适合"推理成本高、请求量大、语义多样性高"的场景,比如知识问答类 Agent。而意图分类方案适合"业务边界清晰、意图可枚举"的场景,比如订单处理、客服工单。两者也可以组合:先用意图分类快速判断,分类置信度低的时候再走向量检索兜底。
还有一个容易被忽略的点:向量缓存本身也要存 Redis。我一般会把 embedding 结果缓存起来,键是agent:embedding:{md5(text)},TTL 设 7 天。这样同一句话重复出现时,不用重复调 embedding 接口。这个优化在高峰期能省下不少钱。
2.4 语义缓存的失效策略:别让过期答案害了你
语义缓存最大的风险是"答案过期"。用户问"我的订单到哪了",缓存里存的是昨天的物流状态,今天命中直接返回旧答案,用户会炸。
我的处理原则是:凡是和实时状态相关的意图,一律不缓存,或者只缓存极短时间。具体来说,我会给每个意图打一个"时效性标签":
| 意图类型 | 时效性 | 建议 TTL | 说明 |
|---|---|---|---|
| 政策咨询 | 低 | 24 小时 | 政策变动不频繁 |
| 产品说明 | 低 | 12 小时 | 产品信息相对稳定 |
| 订单状态 | 高 | 不缓存 | 实时性要求极高 |
| 物流查询 | 高 | 60 秒 | 可短暂缓存 |
| 退款进度 | 中 | 5 分钟 | 状态变化有延迟 |
这张表是我根据实际业务总结的,你可以根据自己的场景调整。核心思路是:缓存的价值 = 命中带来的收益 - 过期带来的风险,时效性越高的意图,这个值越可能是负的,那就别缓存。
3. 工具调用结果缓存:Agent 省钱的关键战场
3.1 工具调用为什么是缓存收益最高的地方
一个成熟的 AI Agent,一次完整推理可能调用 3 到 8 次工具。每次工具调用要么消耗外部 API 配额,要么查数据库,要么调检索服务,都是有成本的。而工具调用的参数往往比自然语言规整得多,键容易设计,命中率天然就高。
我做过一个对比:同一个 Agent,只做语义结果缓存,成本下降约 25%;加上工具调用缓存之后,成本下降能到 55% 以上。工具调用缓存的性价比明显更高,因为它缓存的是"确定性的中间结果",不像语义缓存那样有归一化误差。
3.2 工具缓存的键设计:工具名 + 规范化参数
工具缓存的键结构我一般这样设计:
agent:tool:{tool_name}:{params_hash}其中params_hash是对工具入参做规范化之后的哈希。规范化的关键动作包括:去掉无意义的空格、统一时间格式、对数组排序、剔除默认值字段。这些动作的目的是让"语义相同但字面不同"的参数收敛到同一个键。
举个具体例子,一个查天气的工具,入参可能是{"city": "北京", "date": "2024-06-01"},也可能是{"date": "2024-06-01", "city": "北京"}。如果不做排序,这两个会生成不同的键。做了排序之后,它们就是同一个键。
import hashlib import json def normalize_params(params: dict) -> str: # 剔除 None 和空字符串 cleaned = {k: v for k, v in params.items() if v not in (None, "", [])} # 排序后序列化 return json.dumps(cleaned, sort_keys=True, ensure_ascii=False, default=str) def build_tool_key(tool_name: str, params: dict) -> str: params_hash = hashlib.sha256(normalize_params(params).encode()).hexdigest()[:16] return f"agent:tool:{tool_name}:{params_hash}"这里用 SHA256 而不是 MD5,是因为工具参数可能包含较长的文本,SHA256 的碰撞概率更低。截取前 16 位是为了控制键长度,实际碰撞概率依然可以忽略。
3.3 TTL 怎么定:按工具的数据特性分类
工具缓存的 TTL 不能一刀切,要按工具返回数据的"变化频率"来定。我一般把工具分成四类:
第一类,静态数据工具。比如查汇率牌价的历史数据、查产品规格、查知识库文档。这类数据可能几天甚至几周不变,TTL 可以设 6 到 24 小时。
第二类,准静态数据工具。比如查商品库存、查门店营业时间。这类数据一天变几次,TTL 设 10 到 30 分钟比较合适。
第三类,动态数据工具。比如查实时股价、查当前排队人数。这类数据变化快,TTL 设 30 到 120 秒,甚至不缓存。
第四类,用户私有数据工具。比如查用户自己的订单、查个人账户余额。这类数据不仅变化快,还有隐私问题,缓存键必须带上用户标识,TTL 要短,而且要做好隔离。
注意:用户私有数据的缓存键一定要包含用户 ID,否则会出现 A 用户命中 B 用户缓存的严重事故。这个坑我在早期项目里亲眼见过,排查了半天才发现是键设计漏了用户维度。
3.4 工具缓存的穿透与雪崩防护
工具缓存有一个特殊风险:如果某个热门工具的参数被大量并发请求,而缓存刚好失效,就会瞬间打爆下游服务。这就是缓存雪崩。
我的防护手段有三个。第一是TTL 加随机抖动,比如本来设 600 秒,实际写入时随机加 0 到 60 秒,避免大批键同时过期。第二是互斥锁回源,缓存失效时只允许一个请求去调工具,其他请求等待或者返回旧值。第三是空值缓存,工具返回空结果时也缓存一个短 TTL 的占位符,防止恶意参数反复穿透。
import random def ttl_with_jitter(base_ttl: int, jitter_ratio: float = 0.1) -> int: jitter = int(base_ttl * jitter_ratio) return base_ttl + random.randint(0, jitter)这个抖动函数看起来简单,但在高并发场景下能显著削峰。我实测过一个场景,加抖动之前 Redis 的 QPS 曲线是尖刺状的,加抖动之后变成平滑的波浪,下游服务的压力小了很多。
4. 会话状态缓存:多轮对话的 token 省钱术
4.1 会话状态缓存的本质是"避免重复推理"
多轮对话里,Agent 每一轮都要重新理解上下文。如果每轮都把完整对话历史塞进 prompt,token 消耗会随轮次线性增长,到第十轮的时候,光历史上下文就可能占掉几千 token。
会话状态缓存要解决的就是这个问题:把已经确认的槽位、已经排除的选项、已经生成的中间结论存起来,下一轮直接读取,不用重新推理。这本质上是用存储换 token。
我做过一个测算,一个平均 8 轮的客服对话,不做状态缓存的话,总 token 消耗约 12000;做了状态缓存之后,降到约 6500,几乎砍半。对于按 token 计费的场景,这个优化直接体现在账单上。
4.2 状态结构怎么设计:槽位 + 意图栈 + 已排除项
会话状态我一般存三部分。第一部分是槽位(slots),就是已经收集到的结构化信息,比如{"order_id": "12345", "refund_reason": "质量问题"}。第二部分是意图栈,记录用户当前的主意图和子意图,因为多轮对话里意图可能切换。第三部分是已排除项,记录 Agent 已经尝试过但被用户否定的方案,避免重复推荐。
{ "session_id": "sess_abc123", "slots": { "order_id": "12345", "refund_reason": "质量问题" }, "intent_stack": ["refund", "refund_reason_collection"], "excluded_options": ["换货", "补偿优惠券"], "last_updated": 1717200000 }这个结构存 Redis 的时候,我一般用 Hash 类型而不是 String,因为 Hash 可以单独更新某个字段,不用整体读写。比如只更新slots里的一个字段,用HSET就够了,比GET整个 JSON 再SET回去要高效得多。
4.3 会话 TTL 与内存控制
会话状态的 TTL 要结合业务场景定。客服类对话,一般设 30 分钟到 2 小时,因为用户可能中途离开再回来。而任务型对话,比如订票流程,可能设 15 分钟就够了,超时就让用户重新开始。
内存控制是另一个重点。会话状态如果无限增长,Redis 内存会被吃光。我的做法是给每个会话设一个大小上限,比如序列化后不超过 32KB,超过就触发压缩或者清理历史轮次。同时用 Redis 的maxmemory-policy设为allkeys-lru,让不活跃的会话自动淘汰。
提示:会话状态和语义缓存、工具缓存最好用不同的 Redis 实例或者不同的 db 隔离,因为它们的淘汰策略和内存特性完全不同。混在一起容易互相影响。
4.4 会话状态与语义缓存的联动
这两层缓存其实可以联动。当会话状态里已经确认了某些槽位,语义缓存的键就可以带上这些槽位信息,进一步提高命中率。比如用户第一轮说了订单号,第二轮问"能退吗",这时候语义缓存的键就可以是agent:semantic:refund:order_12345,而不是单纯依赖第二轮的输入。
这个联动做得好,能让多轮对话的缓存命中率显著提升。我在一个项目里做过对比,不做联动的时候,第二轮之后的命中率只有 15% 左右;做了联动之后,能到 45%。原因很简单,因为槽位信息把模糊的自然语言输入"锚定"到了具体的业务实体上。
5. 序列化选型:别让序列化成为性能瓶颈
5.1 JSON、MessagePack、Protobuf 的取舍
缓存值的序列化方式,直接影响读写性能和内存占用。我三种都用过,说说实际感受。
JSON 最通用,可读性最好,排查问题的时候直接GET出来就能看懂。但它的体积大,序列化速度中等。对于小对象,JSON 完全够用;对于大对象,比如几 KB 的会话状态,JSON 的体积劣势就明显了。
MessagePack 是二进制格式,体积比 JSON 小 30% 到 50%,序列化速度也更快。缺点是可读性差,排查问题需要工具解码。我在会话状态缓存里用 MessagePack 比较多,因为会话状态体积大、读写频繁。
Protobuf 体积最小、速度最快,但需要预先定义 schema,改字段要重新编译。对于结构稳定的缓存值,Protobuf 是最优解;但对于快速迭代的业务,schema 维护成本太高。
| 序列化方式 | 体积 | 速度 | 可读性 | 适用场景 |
|---|---|---|---|---|
| JSON | 大 | 中 | 好 | 小对象、调试期 |
| MessagePack | 中 | 快 | 差 | 会话状态、大对象 |
| Protobuf | 小 | 最快 | 差 | 结构稳定的高频数据 |
我的建议是:先用 JSON 跑通,等性能压测发现序列化是瓶颈了,再针对性替换。不要一上来就上 Protobuf,维护成本会让你后悔。
5.2 压缩的时机:什么时候该上 Snappy 或 Zstd
当缓存值超过一定大小,比如 4KB,压缩就开始划算了。我一般用 Snappy,因为它的压缩和解压速度都很快,压缩率虽然不如 Zstd,但在缓存场景下速度更重要。
压缩的阈值我设的是 2KB。小于 2KB 不压缩,因为压缩本身也有开销,小对象压缩可能得不偿失。大于 2KB 就压缩,能省下不少内存。
import snappy COMPRESS_THRESHOLD = 2048 def serialize_value(obj) -> bytes: raw = msgpack.packb(obj, use_bin_type=True) if len(raw) > COMPRESS_THRESHOLD: return b"\x01" + snappy.compress(raw) # 前缀标识压缩 return b"\x00" + raw # 前缀标识未压缩这里加了一个字节的前缀来标识是否压缩,读取的时候根据前缀决定是否解压。这个设计比用两个不同的键前缀要简洁,而且不会增加键的数量。
5.3 序列化兼容性:字段增删怎么办
业务迭代的时候,缓存值的结构会变。如果新旧结构不兼容,读取旧缓存就会报错。我的处理原则是:新增字段给默认值,删除字段保留兼容读取,绝不改字段类型。
具体做法是在反序列化的时候做一次"结构适配",把旧结构补齐成新结构。这样即使缓存里还有旧数据,也能正常读取,等 TTL 自然过期就完成了平滑过渡。
def adapt_session_state(data: dict) -> dict: # 新增字段给默认值 data.setdefault("excluded_options", []) data.setdefault("intent_stack", []) # 兼容旧字段名 if "reason" in data and "refund_reason" not in data: data["refund_reason"] = data.pop("reason") return data这个适配层看起来是额外工作,但它能避免"上线新版本必须清空缓存"这种粗暴操作。清空缓存意味着瞬间全部回源,对下游是灾难性的。
6. 线上真实踩坑:那些文档不会告诉你的问题
6.1 缓存键里的时间戳:一个隐蔽的命中率杀手
早期我在工具缓存的键里带了精确到秒的时间戳,本意是让缓存按时间分片。结果发现命中率极低,排查半天才发现,同一个查询在不同秒发起,键就不同,根本命中不了。
正确的做法是把时间粒度对齐。比如按小时缓存的数据,键里的时间就精确到小时;按天缓存的数据,精确到天。这样同一时间窗口内的请求才能命中同一个键。
from datetime import datetime def time_bucket(granularity: str = "hour") -> str: now = datetime.now() if granularity == "hour": return now.strftime("%Y%m%d%H") if granularity == "day": return now.strftime("%Y%m%d") return now.strftime("%Y%m%d%H%M")这个坑的教训是:缓存键里的每一个变量,都要问自己"它会不会导致本应命中的请求被拆散"。时间戳是最典型的例子,但类似的还有请求 ID、随机数、会话 ID 等。
6.2 大 key 问题:一个会话状态把 Redis 拖垮
有一次线上告警,Redis 某个实例的响应时间突然飙升。排查发现是一个会话状态键膨胀到了 2MB,因为那个用户进行了上百轮对话,历史记录全堆在状态里。
大 key 的危害不只是占用内存,更严重的是它会阻塞 Redis 的单线程。读写一个 2MB 的键,耗时可能是读写 1KB 键的几百倍,期间其他请求全部排队。
我的解决方案是三层防护。第一层是写入时限制大小,超过 32KB 就截断历史轮次,只保留最近 N 轮。第二层是定期扫描大 key,用redis-cli --bigkeys或者自己写脚本扫描,发现异常及时处理。第三层是拆分存储,把会话状态拆成"核心状态"和"历史记录"两个键,核心状态小且常读,历史记录大且少读。
6.3 缓存与数据库的一致性:Agent 场景下的取舍
传统业务里,缓存一致性是个大话题,通常用"先更新数据库再删除缓存"或者"延迟双删"来保证。但 Agent 场景下,很多数据源不是数据库,而是外部 API,你根本没有"更新"的主动权。
我的处理原则是:对于外部 API 的数据,接受最终一致性,用短 TTL 来兜底。比如查物流状态,缓存 60 秒,即使这 60 秒内状态变了,用户最多看到 60 秒前的数据,这个误差在业务上可以接受。
而对于 Agent 自己产生的数据,比如会话状态,一致性要求高,我会用"写穿"策略,更新状态时同时更新缓存和持久化存储,保证两边一致。
6.4 缓存预热:别让冷启动拖慢首屏
Agent 服务重启或者缓存实例切换之后,缓存是空的,所有请求都会回源,这时候延迟会明显上升。这就是冷启动问题。
我的做法是缓存预热。在服务启动阶段,把高频的语义缓存和工具缓存提前加载进去。预热的来源可以是历史访问日志,也可以是人工整理的热门问题列表。
预热不需要全量,抓住 Top 20% 的高频请求就够了,因为缓存命中本来就符合二八定律。我一般预热 500 到 2000 个键,启动时间增加几秒,但首屏延迟能明显改善。
def warmup_cache(redis_client, hot_keys: list): for key, value, ttl in hot_keys: redis_client.setex(key, ttl, value) print(f"warmed up {len(hot_keys)} keys")6.5 监控指标:没有监控的缓存就是黑盒
缓存上线之后,必须有一套监控指标,否则你根本不知道它有没有在工作。我必看的指标有这几个:
命中率,这是最核心的指标,低于预期就说明键设计有问题。平均响应时间,缓存读写应该稳定在毫秒级,如果飙升说明有大 key 或者网络问题。内存使用率,接近上限就要考虑扩容或者优化 TTL。淘汰速率,如果淘汰很快,说明内存不够或者 TTL 太短。大 key 数量,定期扫描,防患于未然。
这些指标我一般接到 Prometheus + Grafana 上,设好告警阈值。命中率跌破某个值、响应时间超过某个值,都会触发告警。有了这套监控,缓存出问题的时候能第一时间发现,而不是等用户投诉。
7. 一套可复用的 Agent 缓存分层方案
把前面讲的东西串起来,我给出一套我在多个项目里复用过的分层方案。这套方案的核心思想是按数据特性分层,每层独立配置 TTL、序列化和淘汰策略。
第一层是语义结果缓存,键结构agent:semantic:{intent}:{entity_hash},TTL 按意图时效性定,序列化用 JSON,适合放在独立的 Redis db 里。
第二层是工具调用缓存,键结构agent:tool:{tool_name}:{params_hash},TTL 按工具数据特性定,序列化用 MessagePack,加 Snappy 压缩。
第三层是会话状态缓存,键结构agent:session:{session_id},用 Hash 类型存储,TTL 30 分钟到 2 小时,序列化用 MessagePack。
第四层是embedding 缓存,键结构agent:embedding:{text_hash},TTL 7 天,序列化用二进制直接存。
这四层用不同的键前缀区分,可以放在同一个 Redis 实例的不同 db,也可以分实例部署。分实例的好处是隔离性好,一层出问题不影响其他层;同实例的好处是运维简单,成本低。中小规模用同实例分 db 就够了,大规模再考虑分实例。
最后分享一个我在实际使用中的体会:缓存优化是一个持续迭代的过程,不是一次配置就完事。业务在变,用户行为在变,缓存的键设计和 TTL 也要跟着调。我一般每个月会看一次缓存命中率的趋势,如果发现某个意图或者某个工具的命中率持续下降,就说明它的键设计或者 TTL 需要重新审视了。把这个当成例行工作,缓存才能持续发挥价值。