1. 为什么 AI Agent 的缓存层不能照搬传统 Web 架构
很多人第一次给 AI Agent 加 Redis 缓存时,脑子里想的还是那套“查库之前先查缓存,命中就返回”的经典套路。我一开始也是这么干的,结果上线第二天就发现缓存命中率不到 15%,而且偶尔还会返回驴唇不对马嘴的答案。问题出在哪?出在 AI Agent 的请求特征和传统 CRUD 接口完全不是一回事。
传统 Web 请求是“参数确定,结果确定”。用户 ID 是 123,查出来的用户信息永远一样,缓存 key 直接拼user:123就完事了。但 AI Agent 的输入是自然语言,同一个意图可以有几十种说法。“帮我查一下北京明天的天气”和“北京明天天气怎么样”在语义上等价,但字符串层面完全不同。如果你用原始 query 做 key,缓存命中率低得可怜。
更麻烦的是,AI Agent 的响应往往带有上下文依赖性。同一个问题,在对话历史不同的情况下,期望的答案可能完全不同。用户先问“帮我订一张去上海的机票”,再问“那后天呢”,第二个问题的答案取决于第一个问题的上下文。如果你把“那后天呢”直接做 key 缓存,下一个用户问同样的话,拿到的可能是完全无关的答案。
所以给 AI Agent 做缓存,第一件事就是放弃“请求-响应”级别的粗粒度缓存思维,转向更细粒度的缓存策略。我后来把缓存拆成了三层:语义缓存、工具调用缓存、LLM 推理缓存。每一层的 key 设计、失效策略、数据结构都不一样。下面逐个拆开讲。
1.1 语义缓存:用向量相似度替代字符串匹配
语义缓存解决的就是“同一个意思不同说法”的问题。核心思路是:把用户 query 通过 Embedding 模型转成向量,然后在 Redis 里做向量相似度搜索,如果找到相似度超过阈值的已有 query,就直接返回对应的缓存答案。
Redis 从 8.0 开始原生支持向量搜索(Vector Similarity Search),通过FT.CREATE创建向量索引,用FT.SEARCH做 KNN 查询。如果你用的是 Redis Stack,这个能力是开箱即用的。我实测下来,用text-embedding-3-small生成 1536 维向量,在 Redis 里存 10 万条 query 向量,单次 KNN 查询延迟在 8-15ms 之间,完全可以接受。
具体操作步骤:
# 创建向量索引 FT.CREATE idx:semantic_cache ON HASH PREFIX 1 "sc:" SCHEMA \ query_vector VECTOR HNSW 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE \ answer TEXT \ created_at NUMERIC \ hit_count NUMERICimport redis import numpy as np from openai import OpenAI r = redis.Redis(host='localhost', port=6379, decode_responses=False) client = OpenAI() def get_embedding(text): resp = client.embeddings.create(model="text-embedding-3-small", input=text) return np.array(resp.data[0].embedding, dtype=np.float32).tobytes() def semantic_cache_lookup(query, threshold=0.92): vec = get_embedding(query) # KNN 查询,返回最近的 1 条 results = r.execute_command( 'FT.SEARCH', 'idx:semantic_cache', f'*=>[KNN 1 @query_vector $vec AS score]', 'PARAMS', '2', 'vec', vec, 'SORTBY', 'score', 'DIALECT', '2', 'RETURN', '3', 'answer', 'score', 'hit_count' ) if results[0] > 0: fields = results[2] answer = fields[fields.index(b'answer') + 1] score = float(fields[fields.index(b'score') + 1]) similarity = 1 - score # COSINE 距离转相似度 if similarity >= threshold: return answer.decode('utf-8') return None这里有个关键参数:相似度阈值。设太高(比如 0.98),命中率低;设太低(比如 0.85),容易返回错误答案。我的经验是,对于事实型问答(天气、汇率、百科),阈值设 0.90-0.93 比较稳;对于创意型任务(写文案、编故事),语义缓存基本没用,因为每次期望的输出都不一样,这时候应该直接跳过语义缓存层。
还有一个坑:Embedding 模型版本变更。如果你从text-embedding-ada-002换到text-embedding-3-small,向量空间完全变了,旧缓存全部失效。我的做法是在索引名里带上模型版本号,比如idx:semantic_cache:v3small,切换模型时直接建新索引,旧索引保留一段时间做灰度对比。
1.2 工具调用缓存:把外部 API 结果按参数指纹存储
AI Agent 经常需要调用外部工具——查数据库、调天气 API、搜索网页。这些工具调用的特点是:参数确定,结果在一段时间内稳定。比如“北京明天天气”这个工具调用,参数是{city: "北京", date: "2025-01-16"},结果在当天内基本不变。
这层缓存的设计要点是参数指纹。不能简单地把参数拼成字符串做 key,因为参数顺序、格式可能变化。我通常用 JSON 规范化(key 排序)+ SHA256 哈希来生成指纹:
import hashlib import json def tool_cache_key(tool_name, params): normalized = json.dumps(params, sort_keys=True, ensure_ascii=False) fingerprint = hashlib.sha256(normalized.encode()).hexdigest()[:16] return f"tool:{tool_name}:{fingerprint}"TTL 的设置要根据工具的数据更新频率来定。天气 API 设 30 分钟,汇率 API 设 5 分钟,百科类 API 设 24 小时。我见过有人把所有工具缓存的 TTL 统一设成 1 小时,结果汇率数据过期了还在用,导致 Agent 给出的换算结果偏差很大。
注意:工具调用缓存一定要记录缓存写入时间,而不只是 TTL。因为有些工具的结果虽然 TTL 没过期,但业务上已经需要刷新了。比如股票价格,TTL 设 1 分钟,但如果 Agent 在盘中查询,1 分钟前的价格可能已经变化很大。我的做法是在缓存值里带上
cached_at时间戳,Agent 在生成最终答案时可以根据这个时间戳决定是否要提示用户“数据可能有延迟”。
1.3 LLM 推理缓存:最贵的一层,也最值得做
LLM 推理是 AI Agent 里成本最高的环节。一次 GPT-4 调用可能花掉几毛钱,如果同一个问题被问 100 次,那就是几十块。LLM 推理缓存的核心是缓存完整的 prompt-response 对,key 是 prompt 的哈希(包括 system prompt、对话历史、用户输入)。
但这里有个微妙的地方:temperature 参数。如果 temperature > 0,同样的 prompt 每次输出都不一样,缓存就没意义了。所以 LLM 推理缓存只适用于 temperature=0 或接近 0 的场景。对于需要创意输出的场景,可以考虑缓存“推理中间结果”而不是最终输出,比如缓存 Agent 的思考链(Chain of Thought),这样至少能省掉一部分推理 token。
我在实际项目里用的是一个组合策略:语义缓存优先,工具缓存兜底,LLM 缓存最后。请求进来先查语义缓存,命中直接返回;没命中则让 Agent 正常执行,但在工具调用和 LLM 调用两个环节分别查各自的缓存。这样即使语义缓存没命中,工具和 LLM 层面的缓存也能省下不少成本。
2. Redis 数据结构选型:别再用 String 存一切了
我见过太多项目,Redis 里清一色的 String 类型,所有缓存值都JSON.stringify之后塞进去。对于 AI Agent 这种数据结构复杂的场景,这种做法既浪费内存又限制能力。Redis 提供了丰富的数据类型,选对了能让你的缓存层性能和可维护性都上一个台阶。
2.1 Hash:存储 Agent 会话状态的最佳选择
AI Agent 的会话状态(conversation state)通常包含多个字段:对话历史、当前意图、已调用的工具列表、中间变量等。用 String 存整个 JSON 的话,每次更新一个字段都要读出整个 JSON、反序列化、修改、再序列化写回。用 Hash 就简单多了,HSET直接更新单个字段,HGET读取单个字段,不需要全量读写。
# 用 Hash 存储会话状态 session_id = "sess:abc123" r.hset(session_id, mapping={ "history": json.dumps(conversation_history), "current_intent": "book_flight", "called_tools": json.dumps(["search_flight", "get_price"]), "last_active": str(int(time.time())) }) r.expire(session_id, 3600) # 1 小时过期 # 只更新意图,不动其他字段 r.hset(session_id, "current_intent", "cancel_flight")Hash 的另一个好处是内存效率。当 Hash 的字段数量较少(比如小于 128 个)且值较小时,Redis 会用 ziplist(现在叫 listpack)编码,内存占用比 String 存 JSON 低 30%-50%。对于大规模会话场景,这个差距很可观。
但要注意:Hash 不支持对单个字段设 TTL。你只能对整个 key 设过期时间。如果会话里有些字段需要更长的保留时间(比如用户偏好),有些字段可以快速过期(比如临时中间变量),那就需要拆成多个 key,或者用 Sorted Set 配合时间戳来做字段级过期。
2.2 Sorted Set:给缓存加一个“热度排序”
AI Agent 的缓存有一个特殊需求:热门 query 应该保留更久,冷门 query 可以早点淘汰。Redis 的 LRU 淘汰策略是全局的,不能针对单个 key 做精细控制。这时候可以用 Sorted Set 来维护一个“热度排行榜”。
具体做法:每次缓存命中时,用ZINCRBY给对应的 query 加 1 分。然后定期(比如每小时)扫描 Sorted Set,把分数低于阈值的 key 主动删除,或者延长高分 key 的 TTL。
# 缓存命中时增加热度分 r.zincrby("cache:hotness", 1, cache_key) # 定期清理冷门缓存 cold_keys = r.zrangebyscore("cache:hotness", 0, 5) # 分数低于 5 的 for key in cold_keys: r.delete(key) r.zrem("cache:hotness", key)这个策略在电商客服 Agent 场景下特别有效。用户问得最多的问题(“怎么退货”“运费多少”)会一直留在缓存里,而那些一次性的个性化问题会自然淘汰。我实测下来,加了热度排序之后,整体缓存命中率从 42% 提升到了 67%,Redis 内存占用反而下降了 20%。
2.3 Stream:异步缓存更新的可靠通道
AI Agent 的缓存更新往往不需要同步完成。比如用户问了一个新问题,Agent 生成了答案,这个答案需要写入缓存,但没必要让用户等缓存写完再返回。这时候可以用 Redis Stream 做异步缓存更新。
Agent 生成答案后,往 Stream 里扔一条消息,后台的消费者进程负责把答案写入缓存(包括生成 Embedding、更新向量索引等耗时操作)。这样用户侧的感受是“秒回”,缓存更新在后台慢慢做。
# 生产者:Agent 生成答案后 r.xadd("stream:cache_update", { "query": user_query, "answer": agent_answer, "timestamp": str(int(time.time())) }) # 消费者:后台进程 while True: messages = r.xread({"stream:cache_update": "$"}, block=5000, count=10) for stream, msgs in messages: for msg_id, fields in msgs: query = fields[b'query'].decode() answer = fields[b'answer'].decode() # 生成 Embedding 并写入向量索引 vec = get_embedding(query) r.hset(f"sc:{hashlib.md5(query.encode()).hexdigest()}", mapping={ "query_vector": vec, "answer": answer, "created_at": time.time(), "hit_count": 0 }) r.xack("stream:cache_update", "cache_group", msg_id)Stream 的好处是可靠。消费者处理失败可以重试,消息不会丢。而且支持多个消费者组,可以水平扩展缓存写入能力。相比用 List 做队列,Stream 的 ACK 机制和消费者组功能更适合生产环境。
2.4 数据类型选型对照表
| 场景 | 推荐类型 | 原因 | 避坑提示 |
|---|---|---|---|
| 会话状态 | Hash | 字段级读写,内存效率高 | 不支持字段级 TTL |
| 语义缓存向量 | Hash + Vector Index | 支持 KNN 查询 | 需要 Redis Stack 或 8.0+ |
| 热度排行 | Sorted Set | 按分数排序,支持范围查询 | 定期清理防止无限增长 |
| 异步更新队列 | Stream | 可靠消费,支持消费者组 | 注意 Stream 长度上限 |
| 简单 KV 缓存 | String | 最简单,适合小数据 | 大 JSON 浪费内存 |
| 去重/布隆过滤 | Bitmap / Bloom | 极省内存 | 有误判率,不适合精确场景 |
3. 缓存失效与一致性:AI Agent 场景下的特殊挑战
缓存失效是缓存领域的老大难问题,在 AI Agent 场景下尤其棘手。传统 Web 缓存失效通常有明确的触发条件——数据库更新了,缓存就失效。但 AI Agent 的“数据源”可能是 LLM 本身,而 LLM 的知识是不断更新的(模型版本升级、微调),你没法像监听数据库 binlog 那样监听 LLM 的变化。
3.1 基于时间的分层失效策略
我的做法是分层设置 TTL,不同层级的缓存用不同的过期时间:
- 语义缓存:TTL 设 6-24 小时。因为语义缓存的 key 是 query 向量,LLM 知识更新对它的影响相对间接。但如果你的 Agent 依赖实时数据(比如股票价格),语义缓存的 TTL 要缩短到 5-15 分钟。
- 工具调用缓存:TTL 根据工具的数据更新频率来定,从 1 分钟到 24 小时不等。
- LLM 推理缓存:TTL 设 1-7 天。LLM 的输出相对稳定,但要注意模型版本升级后需要主动清空。
这里有个经验:不要给所有缓存设统一的 TTL。我见过一个项目把所有缓存 TTL 都设成 1 小时,结果实时性要求高的数据过期太慢,而稳定性高的数据又频繁失效,两头不讨好。
3.2 主动失效:当 Agent 的“知识”变了怎么办
有些情况下,你需要主动让缓存失效。比如:
- 你更新了 Agent 的 system prompt,希望所有旧缓存失效。
- 你切换了 Embedding 模型,向量空间变了,旧向量缓存全部作废。
- 业务规则变了(比如退货政策从 7 天改成 15 天),相关缓存需要立即刷新。
主动失效的实现方式有几种:
方案一:版本号前缀。在缓存 key 里加一个全局版本号,比如v2:sc:xxx。需要失效时,把版本号从 v2 升到 v3,所有旧 key 自然不再被访问,等 TTL 到期后自动清理。这个方案最简单,但会有一段时间新旧缓存并存,内存占用翻倍。
方案二:按标签批量删除。用 Redis 的 Set 结构维护“标签- key”的映射关系。比如所有和“退货政策”相关的缓存 key 都加入tag:return_policy这个 Set。需要失效时,直接遍历 Set 删除所有相关 key。
# 写入缓存时打标签 cache_key = f"sc:{hashlib.md5(query.encode()).hexdigest()}" r.hset(cache_key, mapping={...}) r.sadd("tag:return_policy", cache_key) # 失效时按标签删除 keys = r.smembers("tag:return_policy") for key in keys: r.delete(key) r.delete("tag:return_policy")方案三:发布订阅通知。多个 Agent 实例共享 Redis 缓存时,一个实例触发了失效,需要通知其他实例。可以用 Redis 的 Pub/Sub 机制广播失效消息,所有实例收到后清理本地缓存(如果有的话)或标记远程缓存为不可用。
3.3 缓存穿透、击穿、雪崩在 AI Agent 场景下的表现
这三个经典问题在 AI Agent 场景下有新的表现形式:
缓存穿透:用户问了一个缓存里没有、数据库里也没有的问题(比如“帮我查一下张三的手机号”,但张三不在系统里)。传统做法是缓存空值,但 AI Agent 的空值不好定义——Agent 可能返回“抱歉,我没有找到相关信息”,这个回答本身也是需要缓存的。我的做法是给这类“否定回答”设一个较短的 TTL(比如 5 分钟),避免同一个无效问题反复穿透。
缓存击穿:某个热门 query 的缓存突然失效,大量请求同时打到 LLM。AI Agent 的 LLM 调用本来就慢(几秒到几十秒),击穿会导致请求堆积。解决方案是用 Redis 的分布式锁,只让一个请求去调用 LLM,其他请求等待或返回旧缓存。
import redis import time r = redis.Redis() def get_with_lock(query, cache_key, ttl=300): # 先查缓存 cached = r.get(cache_key) if cached: return cached # 缓存未命中,尝试获取锁 lock_key = f"lock:{cache_key}" acquired = r.set(lock_key, "1", nx=True, ex=30) # 30 秒锁 if acquired: try: # 获取到锁,调用 LLM result = call_llm(query) r.setex(cache_key, ttl, result) return result finally: r.delete(lock_key) else: # 没获取到锁,等待一段时间后重试 time.sleep(0.5) return get_with_lock(query, cache_key, ttl)缓存雪崩:大量缓存同时过期,请求全部打到 LLM。解决方案是在 TTL 上加随机抖动,比如原本 300 秒的 TTL,实际设置为 300 + random(0, 60) 秒。这样缓存过期时间分散开,不会同时失效。
提示:分布式锁的过期时间要设置合理。设太短,LLM 还没返回锁就过期了,其他请求会重复调用;设太长,如果持有锁的进程崩溃了,其他请求要等很久。我的经验是设为 LLM 平均响应时间的 2-3 倍,比如 LLM 平均 5 秒返回,锁设 15 秒。
4. 高并发下的 Redis 性能调优与监控
AI Agent 的并发压力往往比传统 Web 应用更大,因为每个请求的处理时间更长(LLM 调用几秒到几十秒),连接池容易被占满。我经历过一次线上事故:Agent 的 QPS 只有 50,但 Redis 连接池被打满,导致所有请求超时。排查后发现是 LLM 调用太慢,每个请求都占着一个 Redis 连接等 LLM 返回,连接池自然不够用。
4.1 连接池配置:别让慢请求拖垮整个池子
Redis 连接池的大小要根据并发请求数和平均请求处理时间来算。公式是:
连接池大小 = 并发请求数 × (Redis 操作时间 / 请求总处理时间) + 缓冲
举个例子:Agent 每秒处理 100 个请求,每个请求总处理时间 5 秒(其中 Redis 操作累计 50ms),那么同时进行的请求有 500 个。Redis 操作占总时间的 1%,所以理论上只需要 5 个连接。但考虑到突发流量和连接创建开销,实际设置 20-50 个连接比较稳妥。
但这里有个陷阱:如果你的代码在 LLM 调用期间一直持有 Redis 连接,那连接池大小就必须等于并发请求数。正确的做法是“用完即还”,Redis 操作完成后立即释放连接,不要跨 LLM 调用持有。
# 错误做法:持有连接等 LLM conn = pool.get_connection() cached = conn.get(key) if not cached: result = call_llm(query) # 这期间一直占着连接 conn.set(key, result) conn.release() # 正确做法:用完即还 with pool.get_connection() as conn: cached = conn.get(key) if not cached: result = call_llm(query) with pool.get_connection() as conn: conn.set(key, result)4.2 慢查询与热 Key 的发现和处理
Redis 的SLOWLOG命令可以查看慢查询日志。对于 AI Agent 场景,要特别关注两类慢查询:大 Key 操作和向量搜索。
大 Key 操作:如果某个会话的 Hash 字段特别多(比如对话历史很长),HGETALL会阻塞 Redis。解决方案是限制单个 Hash 的字段数量,或者用HSCAN分批读取。
向量搜索:KNN 查询的延迟和向量数量、维度成正比。如果向量索引里有 100 万条 1536 维向量,单次查询可能超过 50ms。优化方法包括:减少向量维度(用 PCA 降维)、限制返回数量(KNN 1 而不是 KNN 10)、使用更快的索引类型(FLAT 比 HNSW 快但精度低)。
热 Key 的发现可以用redis-cli --hotkeys(需要 maxmemory-policy 设为 lfu),或者用 Monitor 命令采样分析。发现热 Key 后,可以考虑在应用层加本地缓存(比如 Caffeine),减少对 Redis 的访问。
4.3 监控指标:哪些数字必须盯着
AI Agent 的 Redis 缓存层,我重点监控这几个指标:
| 指标 | 含义 | 告警阈值 | 处理建议 |
|---|---|---|---|
| 缓存命中率 | 命中次数/总请求 | < 30% | 检查 key 设计、TTL 设置 |
| 平均延迟 | Redis 命令平均耗时 | > 10ms | 检查慢查询、大 Key |
| 连接池使用率 | 已用连接/总连接 | > 80% | 扩大连接池或优化持有时间 |
| 内存使用率 | used_memory/maxmemory | > 85% | 清理冷数据或扩容 |
| 淘汰 Key 数 | evicted_keys 增速 | > 100/秒 | 检查 TTL 设置,考虑扩容 |
| 向量搜索延迟 | FT.SEARCH 耗时 | > 50ms | 优化索引或减少向量数 |
这些指标我通常用 Prometheus + Grafana 做可视化,Redis 自带的INFO命令提供了大部分数据。关键是设置合理的告警阈值,不要等 Redis 挂了才发现问题。
4.4 持久化策略:缓存丢了能不能重建
AI Agent 的缓存和传统缓存有一个重要区别:重建成本极高。传统缓存丢了,从数据库读一次就行。但 AI Agent 的语义缓存丢了,需要重新调用 Embedding 模型生成向量;LLM 推理缓存丢了,需要重新调用 LLM 生成答案。这些都是真金白银的成本。
所以 AI Agent 的 Redis 缓存建议开启 AOF 持久化(appendonly yes),而且用everysec模式(每秒同步一次)。这样即使 Redis 重启,最多丢失 1 秒的缓存写入,大部分缓存还能保留。RDB 快照可以作为辅助,但不建议只依赖 RDB,因为快照间隔通常较长(5 分钟以上),丢失的缓存太多。
如果缓存数据量特别大(比如几百 GB),AOF 文件会很大,重启恢复时间很长。这时候可以考虑混合持久化(aof-use-rdb-preamble yes),AOF 文件前半部分是 RDB 格式,后半部分是 AOF 格式,兼顾恢复速度和数据安全性。
5. 实战中踩过的坑和对应的解决方案
这一节不讲理论,只讲我在实际项目里踩过的坑。每个坑都附带了排查过程和最终解决方案,你可以直接对照自己的项目检查。
5.1 坑一:Embedding 模型调用超时导致缓存写入失败
现象:Agent 回答完用户问题后,后台异步写入语义缓存时,偶尔报 Embedding API 超时,导致缓存没写进去。用户下次问同样的问题,还是走完整流程。
排查:查看日志发现,Embedding API 的 P99 延迟在 2 秒左右,而我们设置的超时是 1 秒。高峰期并发请求多,Embedding API 限流,延迟进一步升高。
解决:把 Embedding 调用的超时从 1 秒改成 5 秒,同时加了重试机制(最多重试 2 次,指数退避)。另外,把缓存写入从“同步等待 Embedding”改成“先写一个占位符,Embedding 完成后更新”。这样即使 Embedding 失败,也不会阻塞主流程。
def async_cache_write(query, answer): cache_key = f"sc:{hashlib.md5(query.encode()).hexdigest()}" # 先写占位符,标记为 pending r.hset(cache_key, mapping={ "answer": answer, "status": "pending", "created_at": time.time() }) r.expire(cache_key, 86400) # 异步生成 Embedding try: vec = get_embedding_with_retry(query) r.hset(cache_key, mapping={ "query_vector": vec, "status": "ready" }) except Exception as e: r.hset(cache_key, "status", "embedding_failed") log.error(f"Embedding failed for {cache_key}: {e}")5.2 坑二:向量索引的精度和召回率不平衡
现象:语义缓存的命中率忽高忽低,有时候明明是很相似的问题,就是匹配不上。
排查:用FT.SEARCH手动测试了几组相似 query,发现相似度分数在 0.85-0.95 之间波动。有些语义等价的 query,余弦相似度只有 0.87,低于我们设的 0.92 阈值。
解决:把阈值从 0.92 降到 0.88,同时增加了“二次验证”机制——当相似度在 0.85-0.92 之间时,不直接返回缓存答案,而是把缓存答案作为“参考”传给 LLM,让 LLM 判断是否可以直接使用。这样既提高了命中率,又避免了错误答案。
def semantic_cache_lookup_with_verify(query, threshold_high=0.92, threshold_low=0.85): vec = get_embedding(query) results = r.execute_command('FT.SEARCH', 'idx:semantic_cache', ...) if results[0] > 0: answer = ... similarity = ... if similarity >= threshold_high: return {"hit": True, "answer": answer, "confidence": "high"} elif similarity >= threshold_low: # 中等相似度,交给 LLM 验证 return {"hit": False, "reference": answer, "confidence": "medium"} return {"hit": False, "confidence": "low"}5.3 坑三:Redis 内存碎片率过高
现象:Redis 的used_memory_rss远大于used_memory,内存碎片率(mem_fragmentation_ratio)超过 1.5,导致实际内存占用比预期高 50%。
排查:AI Agent 的缓存值大小差异很大——有些是短文本(几十字节),有些是长对话历史(几十 KB)。频繁的分配和释放导致内存碎片。
解决:开启activedefrag yes,让 Redis 自动整理内存碎片。同时,对于大 Value(超过 10KB),考虑压缩后再存储(用 gzip 或 zstd)。另外,把maxmemory-policy从noeviction改成allkeys-lru,让 Redis 在内存不足时自动淘汰冷数据。
# redis.conf 中的相关配置 activedefrag yes active-defrag-ignore-bytes 100mb active-defrag-threshold-lower 10 active-defrag-threshold-upper 100 maxmemory-policy allkeys-lru5.4 坑四:分布式锁的“锁过期”问题
现象:两个请求同时发现缓存未命中,都去获取分布式锁。第一个请求获取到锁,开始调用 LLM。但 LLM 调用时间超过了锁的过期时间(30 秒),锁自动释放。第二个请求获取到锁,也去调用 LLM。结果两个请求都调用了 LLM,缓存被写了两次。
解决:用 Redisson 的看门狗机制,或者自己实现锁续期。核心思路是:获取锁后,启动一个后台线程,每隔一段时间(比如锁过期时间的 1/3)检查锁是否还被当前线程持有,如果是则延长锁的过期时间。
import threading import time class RedisLock: def __init__(self, redis_client, key, expire=30): self.redis = redis_client self.key = key self.expire = expire self.lock_value = str(uuid.uuid4()) self.renew_thread = None self.stop_renew = False def acquire(self): acquired = self.redis.set(self.key, self.lock_value, nx=True, ex=self.expire) if acquired: self.stop_renew = False self.renew_thread = threading.Thread(target=self._renew) self.renew_thread.daemon = True self.renew_thread.start() return acquired def _renew(self): while not self.stop_renew: time.sleep(self.expire / 3) # 检查锁是否还被当前线程持有 current = self.redis.get(self.key) if current and current.decode() == self.lock_value: self.redis.expire(self.key, self.expire) def release(self): self.stop_renew = True # 只有锁的值匹配才删除,防止误删别人的锁 current = self.redis.get(self.key) if current and current.decode() == self.lock_value: self.redis.delete(self.key)5.5 坑五:缓存 key 命名冲突
现象:语义缓存和工具调用缓存的 key 前缀都是cache:,结果偶尔出现 key 冲突,工具调用的结果被语义缓存覆盖。
解决:建立严格的 key 命名规范,不同用途的缓存用不同的前缀:
sc:— 语义缓存(semantic cache)tool:— 工具调用缓存llm:— LLM 推理缓存sess:— 会话状态lock:— 分布式锁tag:— 标签集合
同时,在代码层面做一层封装,所有缓存操作都通过统一的 CacheManager 类进行,避免直接操作 Redis 导致 key 混乱。
class CacheManager: PREFIX_SEMANTIC = "sc:" PREFIX_TOOL = "tool:" PREFIX_LLM = "llm:" PREFIX_SESSION = "sess:" PREFIX_LOCK = "lock:" def __init__(self, redis_client): self.redis = redis_client def semantic_key(self, query): return f"{self.PREFIX_SEMANTIC}{hashlib.md5(query.encode()).hexdigest()}" def tool_key(self, tool_name, params): normalized = json.dumps(params, sort_keys=True, ensure_ascii=False) fingerprint = hashlib.sha256(normalized.encode()).hexdigest()[:16] return f"{self.PREFIX_TOOL}{tool_name}:{fingerprint}" def llm_key(self, prompt): return f"{self.PREFIX_LLM}{hashlib.sha256(prompt.encode()).hexdigest()}"6. 从单机到集群:AI Agent 缓存层的扩展路径
单机 Redis 在 AI Agent 项目初期完全够用,但当 QPS 超过 1000、缓存数据超过 10GB 时,就需要考虑集群方案了。这一节讲我从单机迁移到集群的完整过程,包括踩过的坑。
6.1 什么时候该从单机迁到集群
判断标准不是“数据量大了就迁”,而是看瓶颈在哪里。如果瓶颈是内存,单机加内存就能解决;如果瓶颈是 QPS,单机 Redis 通常能扛 5-10 万 QPS,一般 AI Agent 项目达不到这个量级;如果瓶颈是单点故障,那确实需要集群。
我的经验是:当出现以下情况之一时,考虑迁移到集群:
- Redis 单机内存使用超过 80%,且无法继续扩容(比如云服务商的实例规格上限)。
- 需要 99.99% 以上的可用性,单机 Redis 重启会导致服务中断。
- 需要跨可用区部署,单机 Redis 无法满足容灾要求。
6.2 Redis Cluster 的槽位分配和 key 设计
Redis Cluster 把数据分成 16384 个槽位(slot),每个节点负责一部分槽位。key 的槽位由CRC16(key) % 16384决定。这意味着不同的 key 可能落在不同的节点上,跨节点的操作(比如事务、Lua 脚本)会受限。
对于 AI Agent 的缓存,key 设计要注意:
- 语义缓存的向量索引:Redis Cluster 对向量搜索的支持有限,通常需要把所有向量放在同一个节点上(用 hash tag 强制路由)。比如 key 设计成
sc:{global}:xxx,大括号里的内容相同,保证所有语义缓存 key 落在同一个槽位。 - 会话状态:同一个会话的所有 key 应该落在同一个节点,用
sess:{session_id}:xxx的格式。 - 分布式锁:锁的 key 和它保护的资源 key 应该在同一个节点,否则可能出现锁在节点 A、资源在节点 B 的情况,增加复杂性。
# 用 hash tag 强制路由到同一个槽位 def semantic_key_with_tag(query): # {semantic} 是 hash tag,保证所有语义缓存 key 在同一节点 return f"sc:{{semantic}}:{hashlib.md5(query.encode()).hexdigest()}" def session_key(session_id, field): return f"sess:{{{session_id}}}:{field}"6.3 集群模式下的向量搜索方案
Redis Cluster 原生不支持跨节点的向量搜索。如果你的语义缓存数据量很大,需要分片存储,有几种方案:
方案一:单节点存向量索引。把所有向量放在一个专门的节点上,其他节点只存普通缓存。这个方案简单,但向量节点会成为瓶颈。
方案二:应用层分片。在应用层根据 query 的哈希值决定去哪个节点搜索,每个节点存一部分向量。查询时向所有节点发请求,然后合并结果。这个方案复杂但扩展性好。
方案三:用专门的向量数据库。把向量搜索从 Redis 剥离出来,用 Milvus、Qdrant 等专门的向量数据库。Redis 只负责普通缓存。这个方案架构最清晰,但引入了新的组件。
我目前用的是方案一,因为语义缓存的数据量还没大到需要分片的程度(10 万条向量,约 600MB 内存)。如果将来数据量继续增长,我会考虑方案三。
6.4 集群迁移的平滑过渡
从单机迁到集群,最大的挑战是数据迁移和客户端兼容。我的做法是:
- 双写阶段:新代码同时写单机和集群,读优先从集群读,集群未命中则从单机读并回写集群。
- 灰度切换:按用户 ID 哈希,逐步把读流量从单机切到集群。比如先切 10% 的用户,观察一周没问题再切 50%,最后全量。
- 单机下线:全量切换到集群后,单机保留一周作为备份,确认没问题后下线。
这个过程中,客户端的配置要支持动态切换。我用的是redis-py-cluster,它支持在运行时更新节点列表。切换时只需要更新配置中心的节点地址,客户端会自动重连。
from rediscluster import RedisCluster # 集群模式客户端 startup_nodes = [ {"host": "redis-node-1", "port": 6379}, {"host": "redis-node-2", "port": 6379}, {"host": "redis-node-3", "port": 6379}, ] rc = RedisCluster(startup_nodes=startup_nodes, decode_responses=True) # 读写操作和单机类似,但要注意跨槽位限制 rc.set("sc:{semantic}:abc", "value") rc.hset("sess:{user123}:state", mapping={"intent": "book_flight"})注意:集群模式下,
KEYS命令被禁用(因为会扫描所有节点),要用SCAN替代。MGET如果 key 不在同一个槽位也会报错,需要用 hash tag 保证 key 在同一槽位,或者改用 pipeline 逐个获取。
7. 成本核算:缓存到底省了多少钱
做技术决策不能只看性能,还要算经济账。这一节我用真实数据算一下 AI Agent 缓存层的投入产出比。
7.1 缓存成本构成
AI Agent 缓存层的成本包括:
- Redis 实例费用:云服务商的 Redis 实例,按内存大小计费。比如 8GB 标准版,约 800 元/月。
- Embedding 调用费用:生成语义缓存的向量,OpenAI 的 text-embedding-3-small 是 $0.02/1M tokens。假设每条 query 平均 20 tokens,10 万条 query 就是 200 万 tokens,约 $0.04,折合人民币不到 3 毛钱。这个成本几乎可以忽略。
- 开发维护成本:缓存层的开发、调试、监控,大约需要 1-2 人周。
7.2 缓存带来的节省
假设你的 Agent 每天处理 10 万次请求,每次请求平均调用 LLM 1.5 次(包括工具调用后的二次推理),每次 LLM 调用平均消耗 2000 tokens(输入+输出),GPT-4 的价格是 $0.03/1K input tokens + $0.06/1K output tokens。粗略估算,每次 LLM 调用约 $0.09,每天 LLM 成本约 $13,500。
如果缓存命中率达到 50%,每天节省 $6,750,一个月节省超过 $200,000。即使缓存命中率只有 20%,一个月也能省 $80,000 以上。相比之下,Redis 实例的 800 元/月简直是九牛一毛。
7.3 不同缓存策略的 ROI 对比
| 缓存策略 | 实现复杂度 | 预期命中率 | 月节省(估算) | ROI |
|---|---|---|---|---|
| 仅 LLM 推理缓存 | 低 | 15-25% | $60k-100k | 极高 |
| + 工具调用缓存 | 中 | 30-40% | $120k-160k | 高 |
| + 语义缓存 | 高 | 45-60% | $180k-240k | 中高 |
| + 热度排序优化 | 中 | 55-70% | $220k-280k | 高 |
从表格可以看出,LLM 推理缓存的 ROI 最高,因为实现简单、命中率可观。语义缓存虽然实现复杂,但能把命中率再提升 15-20 个百分点,对于大规模 Agent 来说仍然值得投入。
我的建议是分阶段实施:先做 LLM 推理缓存(1-2 天就能上线),验证效果后再做工具调用缓存,最后做语义缓存。不要一上来就追求大而全的方案,容易陷入过度设计的陷阱。
7.4 缓存失效带来的隐性成本
缓存失效不只是“少省了点钱”,还可能带来用户体验下降。比如用户问了一个问题,缓存里有旧答案,但旧答案已经过时了(比如退货政策变了),用户拿到错误答案,可能导致投诉甚至流失。
所以缓存失效策略要平衡“省钱”和“准确”。我的做法是:对准确性要求高的场景(法律、医疗、金融),缓存 TTL 设短一些,甚至不做语义缓存;对准确性要求相对低的场景(闲聊、推荐),缓存 TTL 可以设长一些。
另外,可以在缓存值里带上“置信度”标记。如果缓存答案的置信度低于阈值,Agent 在返回时加上“根据历史数据”之类的提示,让用户知道这个答案可能不是最新的。这样既省了钱,又管理了用户预期。
8. 我个人的一些经验体会
写了这么多,最后分享几个我在实际项目中总结的、文档里不会写的经验。
第一,缓存 key 的设计要“可读”。不要只用哈希值做 key,要在 key 里保留一些可读信息。比如sc:weather:beijing:20250116比sc:a3f8b2c1好得多。出问题时,你一眼就能看出这个 key 是干什么的,不用去翻代码。
第二,缓存命中率不是越高越好。如果命中率突然从 50% 飙升到 90%,不一定是好事——可能是缓存了太多不该缓存的内容,导致用户拿到过时或错误的答案。要结合业务指标(用户满意度、投诉率)一起看。
第三,定期做“缓存审计”。每隔一段时间,随机抽样一些缓存条目,人工检查答案是否正确、是否过时。我每个月会抽 100 条缓存记录做审计,发现过好几次因为 TTL 设置不当导致的过时答案。
第四,不要忽视缓存的“冷启动”问题。服务刚上线或 Redis 刚重启时,缓存是空的,所有请求都会穿透到 LLM。这时候要做好限流和降级准备,避免 LLM 被瞬间打挂。我的做法是预热——上线前用历史 query 批量生成缓存,或者在前 10 分钟对非核心请求做限流。
第五,监控要覆盖“缓存未命中”的路径。很多人只监控缓存命中率,不监控未命中的请求去了哪里。如果未命中的请求大量失败(比如 LLM 超时),你从命中率指标上是看不出来的。要单独监控未命中请求的成功率和延迟。
第六,文档和注释比代码更重要。缓存层的逻辑往往很复杂——什么情况下用哪个缓存、TTL 怎么设、失效怎么触发。这些决策背后的“为什么”一定要写清楚,否则三个月后你自己都忘了当初为什么这么设计。