底座 01:Agent 的 token 成本怎么砍------Redis 缓存实战
这是「Agent 工程化」系列的生产底座 分支第一篇。正片把 Agent 的循环、工具、记忆讲透了,第 10 篇算过一笔账:多步 Agent 的 token 消耗是单次对话的 10-100 倍。这篇回答一个更具体的问题:钱是怎么烧的,又怎么用缓存把它真实砍下来。 知识点是后端的(Redis),场景是 Agent 的------这也是本分支的写作方式:Agent 系统的每一层底座,都值得单独拆开讲。
一、先看事故现场
旅行助手上线第三天,客服群里炸了:
"你们这 Agent 是不是在烧钱?我一句话它要算好几遍。"
运营贴了个截图:用户问"北京明天天气怎么样",一小时内被问了 30 次。每次都是一次完整的 LLM 调用------同一个问题,一模一样的内容,付了 30 份钱。
这就是 Agent 和普通接口最大的不同:普通接口查一次数据库,边际成本趋近于零;Agent 每次回答都是模型现算的,回答得越多,账单越厚。
但换个角度看,这恰恰是最好优化的钱------因为大部分 Agent 对话都是重复的:
- 高频问法高度集中("今天天气""帮我订票"占了大头)
- 纯查询类请求(查天气/查航班/查政策)答案短期不变
- 就算表达不同,意思可能完全一样("北京明天天气""明天北京天气如何")
于是答案只有一个方向:把算过的答案缓存起来,命中一次,省一次钱。
二、第一版:直连模型
先看没缓存的样子(用 Spring 风格写,方便后面接 Redis):
java
@Service
public class WeatherAgentService {
private final LlmClient llmClient;
public String queryWeather(String query) {
// 每次都是真金白银
return llmClient.chat(buildPrompt(query));
}
}
问题一眼可见:queryWeather("北京明天天气") 被调用 30 次,llmClient.chat 就跑 30 次。
一次天气查询对话按 1500 token 算,假设线上用 2 元/百万 token 的模型,单次成本 0.003 元------听起来不多?放大到一天 10 万次查询、其中 40% 是重复问题,每天就烧掉 40000 × 0.003 = 120 元,一个月 3600 元,而这只是"天气"这一个工具。
三、上缓存:Redis 为什么是默认答案
缓存方案很多(本地 Map、数据库、文件),但 Agent 系统里 Redis 几乎是标准答案。为什么?
Redis = 内存 KV 存储,数据结构上天生匹配"问题 → 答案"这种映射。 key 放归一化后的 query,value 放模型答案,TTL 设过期时间------三个能力正好覆盖需求,不用自己造轮子。
java
@Service
public class WeatherAgentService {
private final StringRedisTemplate redis;
private final LlmClient llmClient;
private static final Duration CACHE_TTL = Duration.ofMinutes(15);
public String queryWeather(String query) {
String key = "agent:weather:" + normalize(query);
// 1. 先查缓存
String cached = redis.opsForValue().get(key);
if (cached != null) {
return cached; // 命中:零成本
}
// 2. 没命中才调模型
String answer = llmClient.chat(buildPrompt(query));
redis.opsForValue().set(key, answer, CACHE_TTL);
return answer;
}
}
就这么几行,重复问题直接短路。但这里有个值得停下来问的"为什么"------Redis 为什么能这么快? 这是后端基本功,也是理解后面所有 Redis 设计的前提:
- 纯内存:数据在内存里,没有磁盘 IO。这是它快的第一性原因
- 单线程模型:所有命令串行执行,没有锁竞争、没有线程切换开销。代价是慢命令会阻塞一切(后面讲)
- IO 多路复用:用 epoll 同时监听海量连接,一个线程处理千级并发
- 高效数据结构:字符串用 SDS(预分配、O(1) 取长度),有序集合用跳表,都不是教科书式朴素实现
一句话版本:Redis 快,是因为它在内存里用单线程把 IO 多路复用玩明白了------用简单换速度。
四、key 别乱存:query 归一化
缓存第一步就翻车:"北京明天天气" 和 "北京 明天 天气" 是同一个问题,但字符串不同,key 不同,缓存两条,照样付两份钱。
所以要归一化------把表达差异抹平,让同一问题的 key 唯一:
java
String normalize(String query) {
return query
.trim() // 去首尾空格
.toLowerCase() // 统一小写
.replaceAll("[,。!?、,?!]", " ") // 标点归一为空格
.replaceAll("\\s+", " ") // 连续空白合并
.replace("明天", "明日") // 同义归一(按业务定制)
;
}
注意一个度:同义改写要克制。 把"明天"换成"明日"是安全的(同一天);但如果把"便宜"和"打折"强行归一到同一个 key,命中后返回的答案可能不符合提问语境------归一化过头,会把不同问题当成同一个问题。
五、进阶:语义缓存
精确匹配有个天花板:用户表达千奇百怪,"北京明天天气""明天北京天气如何""北京明儿啥天气",归一化之后依然对不上。命中率上不去,缓存就省不了多少钱。
于是有进阶玩法------语义缓存:不匹配字符串,匹配意思。
text
用户 query → embedding 向量
↓
向量库里找:有没有相似度 > 0.95 的旧 query?
有 → 直接复用那个答案(省一次模型调用)
没有 → 调模型,答案连同本 query 向量一起入库
思路(开源 GPTCache 就是这么干的):把"答案"挂在"语义邻居"上。相似度超过阈值,说明用户问的是同一件事,答案可以复用。
但天下没有免费午餐,语义缓存的代价很实:
| 维度 | 精确匹配缓存 | 语义缓存 |
|---|---|---|
| 命中率 | 低(表达一改就 miss) | 高(意思相近就命中) |
| 成本 | 一次 Redis GET | embedding 计算 + 向量检索(本身有开销) |
| 误命中风险 | 几乎为零 | 相似 ≠ 相同,阈值设低会答非所问 |
| 实现复杂度 | 低 | 高(要维护向量库) |
结论:语义缓存适合高价值大 query 的复用(比如长文本生成、报告类),普通小 query 用精确匹配 + 归一化就够。 别一上来就上语义缓存------先看精确命中率够不够,不够再升级,这是性价比排序。
六、生产陷阱:缓存三兄弟
缓存不是加了就完事,生产环境有三位老朋友蹲在坑里等你------穿透、击穿、雪崩。每个都有标准解法。
1. 缓存穿透:查了一个不存在的东西
用户问"火星明天天气",模型也查不到,返回空。空结果不缓存,于是每次都要调模型------恶意循环刷就烧钱烧穿了。
解法:空值也缓存(短 TTL),或者前置校验拦截。
java
String answer = llmClient.chat(buildPrompt(query));
if (answer == null || answer.isBlank()) {
// 空值也缓存,TTL 设短(比如 1 分钟),防止打穿
redis.opsForValue().set(key, "", Duration.ofMinutes(1));
return "暂未查到该城市的天气";
}
redis.opsForValue().set(key, answer, CACHE_TTL);
2. 缓存击穿:热点 key 过期瞬间被击穿
"北京明天天气"是热点 key,缓存过期的那一瞬间,几百个请求同时 miss,全部涌入模型------比平时更贵。
解法:互斥锁(只有一个请求去调模型,其他请求等它回来读缓存)。
java
// 用 setnx 抢锁:抢到的去调模型,抢不到的等一下读缓存
String lockKey = "lock:" + key;
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(locked)) {
try {
String answer = llmClient.chat(buildPrompt(query));
redis.opsForValue().set(key, answer, CACHE_TTL);
return answer;
} finally {
redis.delete(lockKey); // 用完必须释放
}
}
Thread.sleep(50); // 没抢到:等一下
return redis.opsForValue().get(key);
⚠️ 注意锁的坑:释放锁要放在 finally,否则中途异常锁就永远不释放(更专业的做法是给锁设过期时间 + Redisson 看门狗续期,这里不展开)。
3. 缓存雪崩:大量 key 同时过期
凌晨 4 点,缓存里几万个 key 同时到点过期,一瞬间全部 miss,模型被瞬间打满。
解法:过期时间加随机抖动,让过期时间错开。
java
Duration ttl = CACHE_TTL.plusSeconds(
ThreadLocalRandom.current().nextLong(300) // 额外加 0~5 分钟随机
);
三兄弟对应一张图:
#mermaid-svg-etio9xHgBQUFusy5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-etio9xHgBQUFusy5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-etio9xHgBQUFusy5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-etio9xHgBQUFusy5 .error-icon{fill:#552222;}#mermaid-svg-etio9xHgBQUFusy5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-etio9xHgBQUFusy5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-etio9xHgBQUFusy5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-etio9xHgBQUFusy5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-etio9xHgBQUFusy5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-etio9xHgBQUFusy5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-etio9xHgBQUFusy5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-etio9xHgBQUFusy5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-etio9xHgBQUFusy5 .marker.cross{stroke:#333333;}#mermaid-svg-etio9xHgBQUFusy5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-etio9xHgBQUFusy5 p{margin:0;}#mermaid-svg-etio9xHgBQUFusy5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-etio9xHgBQUFusy5 .cluster-label text{fill:#333;}#mermaid-svg-etio9xHgBQUFusy5 .cluster-label span{color:#333;}#mermaid-svg-etio9xHgBQUFusy5 .cluster-label span p{background-color:transparent;}#mermaid-svg-etio9xHgBQUFusy5 .label text,#mermaid-svg-etio9xHgBQUFusy5 span{fill:#333;color:#333;}#mermaid-svg-etio9xHgBQUFusy5 .node rect,#mermaid-svg-etio9xHgBQUFusy5 .node circle,#mermaid-svg-etio9xHgBQUFusy5 .node ellipse,#mermaid-svg-etio9xHgBQUFusy5 .node polygon,#mermaid-svg-etio9xHgBQUFusy5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-etio9xHgBQUFusy5 .rough-node .label text,#mermaid-svg-etio9xHgBQUFusy5 .node .label text,#mermaid-svg-etio9xHgBQUFusy5 .image-shape .label,#mermaid-svg-etio9xHgBQUFusy5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-etio9xHgBQUFusy5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-etio9xHgBQUFusy5 .rough-node .label,#mermaid-svg-etio9xHgBQUFusy5 .node .label,#mermaid-svg-etio9xHgBQUFusy5 .image-shape .label,#mermaid-svg-etio9xHgBQUFusy5 .icon-shape .label{text-align:center;}#mermaid-svg-etio9xHgBQUFusy5 .node.clickable{cursor:pointer;}#mermaid-svg-etio9xHgBQUFusy5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-etio9xHgBQUFusy5 .arrowheadPath{fill:#333333;}#mermaid-svg-etio9xHgBQUFusy5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-etio9xHgBQUFusy5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-etio9xHgBQUFusy5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-etio9xHgBQUFusy5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-etio9xHgBQUFusy5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-etio9xHgBQUFusy5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-etio9xHgBQUFusy5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-etio9xHgBQUFusy5 .cluster text{fill:#333;}#mermaid-svg-etio9xHgBQUFusy5 .cluster span{color:#333;}#mermaid-svg-etio9xHgBQUFusy5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-etio9xHgBQUFusy5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-etio9xHgBQUFusy5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-etio9xHgBQUFusy5 .icon-shape,#mermaid-svg-etio9xHgBQUFusy5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-etio9xHgBQUFusy5 .icon-shape p,#mermaid-svg-etio9xHgBQUFusy5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-etio9xHgBQUFusy5 .icon-shape .label rect,#mermaid-svg-etio9xHgBQUFusy5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-etio9xHgBQUFusy5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-etio9xHgBQUFusy5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-etio9xHgBQUFusy5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
未命中
穿透: 问题不存在
击穿: 热点 key 过期
雪崩: 大批 key 同时过期
用户请求
查 Redis
返回 零成本
是哪种坑
空值也缓存
互斥锁 只放一个请求进模型
TTL 加随机抖动
调模型
写回 Redis
七、缓存一致性:Agent 的答案会过期
缓存最怕的质疑是:缓存里的答案是旧的怎么办?
对 Agent 系统,答案是分场景的:
1. 时效敏感的信息 → TTL 就是一致性手段。 天气 15 分钟一变,那 TTL 设 15 分钟------数据"旧"的程度被 TTL 天然限定在可接受范围内。查询类 Agent 工具(天气/航班/价格)几乎全靠这一招,简单可靠。
2. 用户主动改的数据 → 写操作要管一致性。 比如用户改了偏好"以后都订靠窗座位",如果偏好结果也被缓存了,下次还是旧的。这时候用 Cache Aside 模式:先更新数据库,再删缓存(下次查询 miss 重新生成)。
java
// 更新用户偏好:先写库,再删缓存(下次 miss 时重新生成)
userPrefRepository.update(userId, pref);
redis.delete("agent:user:" + userId + ":pref");
3. 模型升级了 → 缓存里的答案是旧模型产的。 这是 Agent 系统特有的坑:线上模型从 V1 切到 V2,行为变了,但缓存还是 V1 的答案。解法:缓存 key 带版本号。
java
String key = "agent:weather:v" + modelVersion + ":" + normalize(query);
// 模型升版 → 版本号变 → 旧 key 自然失效
八、Redis 挂了怎么办:降级
缓存是有状态的,Redis 一挂,所有查询都 miss → 模型被全量打满,比没有缓存还糟。所以必须设计降级路径:
java
public String queryWeather(String query) {
String key = "agent:weather:" + normalize(query);
try {
String cached = redis.opsForValue().get(key);
if (cached != null) return cached;
} catch (RedisConnectionException e) {
log.warn("Redis 不可用,走降级路径", e);
// 降级 1:本地二级缓存兜底
String local = localCache.getIfPresent(key);
if (local != null) return local;
// 降级 2:直接调模型(保证可用性,牺牲成本)
}
String answer = llmClient.chat(buildPrompt(query));
localCache.put(key, answer, Duration.ofMinutes(5));
return answer;
}
生产上常见的组合是两级缓存:
#mermaid-svg-CeJpERy6kP6Ojoqi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CeJpERy6kP6Ojoqi .error-icon{fill:#552222;}#mermaid-svg-CeJpERy6kP6Ojoqi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CeJpERy6kP6Ojoqi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CeJpERy6kP6Ojoqi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CeJpERy6kP6Ojoqi .marker.cross{stroke:#333333;}#mermaid-svg-CeJpERy6kP6Ojoqi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CeJpERy6kP6Ojoqi p{margin:0;}#mermaid-svg-CeJpERy6kP6Ojoqi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi .cluster-label text{fill:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi .cluster-label span{color:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi .cluster-label span p{background-color:transparent;}#mermaid-svg-CeJpERy6kP6Ojoqi .label text,#mermaid-svg-CeJpERy6kP6Ojoqi span{fill:#333;color:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi .node rect,#mermaid-svg-CeJpERy6kP6Ojoqi .node circle,#mermaid-svg-CeJpERy6kP6Ojoqi .node ellipse,#mermaid-svg-CeJpERy6kP6Ojoqi .node polygon,#mermaid-svg-CeJpERy6kP6Ojoqi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CeJpERy6kP6Ojoqi .rough-node .label text,#mermaid-svg-CeJpERy6kP6Ojoqi .node .label text,#mermaid-svg-CeJpERy6kP6Ojoqi .image-shape .label,#mermaid-svg-CeJpERy6kP6Ojoqi .icon-shape .label{text-anchor:middle;}#mermaid-svg-CeJpERy6kP6Ojoqi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CeJpERy6kP6Ojoqi .rough-node .label,#mermaid-svg-CeJpERy6kP6Ojoqi .node .label,#mermaid-svg-CeJpERy6kP6Ojoqi .image-shape .label,#mermaid-svg-CeJpERy6kP6Ojoqi .icon-shape .label{text-align:center;}#mermaid-svg-CeJpERy6kP6Ojoqi .node.clickable{cursor:pointer;}#mermaid-svg-CeJpERy6kP6Ojoqi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CeJpERy6kP6Ojoqi .arrowheadPath{fill:#333333;}#mermaid-svg-CeJpERy6kP6Ojoqi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CeJpERy6kP6Ojoqi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CeJpERy6kP6Ojoqi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CeJpERy6kP6Ojoqi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CeJpERy6kP6Ojoqi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CeJpERy6kP6Ojoqi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CeJpERy6kP6Ojoqi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CeJpERy6kP6Ojoqi .cluster text{fill:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi .cluster span{color:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CeJpERy6kP6Ojoqi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CeJpERy6kP6Ojoqi rect.text{fill:none;stroke-width:0;}#mermaid-svg-CeJpERy6kP6Ojoqi .icon-shape,#mermaid-svg-CeJpERy6kP6Ojoqi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CeJpERy6kP6Ojoqi .icon-shape p,#mermaid-svg-CeJpERy6kP6Ojoqi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CeJpERy6kP6Ojoqi .icon-shape .label rect,#mermaid-svg-CeJpERy6kP6Ojoqi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CeJpERy6kP6Ojoqi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CeJpERy6kP6Ojoqi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CeJpERy6kP6Ojoqi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
未命中
命中
未命中
请求
本地缓存 Caffeine
返回
Redis 缓存
回填本地缓存
调模型
写 Redis 和本地
本地缓存(Caffeine,进程内)扛 Redis 抖动,Redis 扛模型调用。Redis 是"省钱层",本地缓存是"保命层",两层职责不同。
九、效果:命中率就是钱
缓存的收益完全由命中率决定。怎么知道命中率?
Redis 自带的 INFO stats 里有现成指标:
text
keyspace_hits: 145203 # 命中次数
keyspace_misses: 9823 # 未命中次数
命中率 = hits / (hits + misses) ≈ 93.7%
命中率 90% 意味着:原来 10 次模型调用,现在只花 1 次的钱。这是 Agent 系统里性价比最高的降本手段,没有之一------不换模型、不改 prompt、不动架构,只加一层缓存。
十、容易被忽略的细节
几个生产里真会踩的细节,提前排掉:
1. 缓存结果要不要标注时效? 要。命中缓存返回给用户时,附一句"基于 15 分钟前的数据",避免用户拿旧数据做决策后投诉。Agent 的缓存答案尤其要标------用户分不清你是"现算的"还是"翻出来的"。
2. 别用 KEYS 命令。 Redis 单线程,KEYS agent:weather:* 会扫描全库阻塞所有请求。要遍历就用 SCAN(游标式,不阻塞)。
3. 内存被打满怎么办? 缓存无限增长会撑爆内存,要配淘汰策略 。热点答案用 LFU(最不常用先淘汰,Redis 4.0+ 默认近似 LFU 可选)、普通缓存用 LRU、无所谓的用 allkeys-random。maxmemory-policy 一行配置,选错了会高频误淘汰。
4. 缓存命中率不是越高越好。 如果为了命中率把 TTL 拉长到一天,天气数据就彻底过期了------命中率和新鲜度是同一个旋钮的两头,转哪边看业务容忍度。
总结:一句话记住它
缓存就是把"花过钱算过的答案"存起来复用------命中一次,省一次钱;而 Redis 就是那个专门干这事的快速仓库。
这篇是「生产底座」分支的开篇:Redis 作为 Agent 系统的第一层底座,解决"重复计算"的钱。下一层底座解决另一个问题------等不起的操作:订机票要调外部接口 30 秒,用户和 Agent 都不能干等,怎么办?下一篇写消息队列。
下一篇:《底座 02:Agent 系统要不要上消息队列------Kafka 在异步任务里的角色》