ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI Agent 缓存分层实战:语义、工具调用与会话状态优化

2026/10/6 6:20:38 拓冰建站 浏览量
AI Agent 缓存分层实战:语义、工具调用与会话状态优化 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_keysTrue, ensure_asciiFalse) entity_hash hashlib.md5(entity_str.encode()).hexdigest()[:12] return fagent: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_keysTrue, ensure_asciiFalse, defaultstr) def build_tool_key(tool_name: str, params: dict) - str: params_hash hashlib.sha256(normalize_params(params).encode()).hexdigest()[:16] return fagent: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 每一轮都要重新理解上下文。如果每轮都把完整对话历史塞进 prompttoken 消耗会随轮次线性增长到第十轮的时候光历史上下文就可能占掉几千 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_typeTrue) 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(fwarmed 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 需要重新审视了。把这个当成例行工作缓存才能持续发挥价值。