上一篇我给 Agent 加了 trace,报表最后一行写着"完全重复的 1 次"——同一个工具、同样的参数,它调了两次。

这不是偶发。Agent 会绕回:它不确定某个信息还作不作数,于是再读一遍文件、再查一次。人不会这么干,但它没有"我记得刚才看过"这回事。所以我给它加了缓存。

key 怎么算:工具名 + 参数稳定哈希

难点在"稳定"。{a:1,b:2} 和 {b:2,a:1} 是同一回事,但 JSON.stringify 出来是两个字符串。所以先做一次键排序:

function stable(v) {
  if (Array.isArray(v)) return '[' + v.map(stable).join(',') + ']';
  if (v && typeof v === 'object') {
    return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + stable(v[k])).join(',') + '}';
  }
  return JSON.stringify(v);
}
function hash(s) {
  let h = 2166136261;                     // FNV-1a
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return (h >>> 0).toString(16).padStart(8, '0');
}

key = 工具名:参数哈希。跑出来验证了一下,参数顺序打乱确实是同一个 key:

key 长这样: read_file:620b31bc (注意:参数顺序打乱也同一个 key → 一致 ✅ )

缓存本身:TTL + LRU

class ToolCache {
  get(tool, args, now) {
    const k = this.key(tool, args);
    const e = this.map.get(k);
    if (!e) { this.miss++; return undefined; }
    if (now - e.at > this.ttl) { this.map.delete(k); this.expired++; this.miss++; return undefined; }
    this.map.delete(k); this.map.set(k, e);   // LRU:命中后移到队尾
    this.hit++; this.savedMs += e.ms;
    return e.value;
  }
}

TTL 管"数据的新鲜度",LRU 管"内存别炸"。两个都要:只有 TTL,长跑任务里条目会无限堆;只有 LRU,陈旧数据会被一直命中。

注意 get 接收 now 作为参数而不是自己调 Date.now()——这是为了让时间可注入,测试时才能模拟"过了 60 秒"。

真跑:30 次调用,参数只有 5 种文件

模拟的是最典型的场景:Agent 反复读同一小批文件。每次真实调用算 120ms。

=== 工具结果缓存(30 次调用,TTL 30s,LRU 100)===
调用 30 次 | 命中 15 | 未命中 15 | 命中率 50% | 省下 1800ms | 过期淘汰 0
真实耗时: 1800ms | 不缓存的话: 3600ms | 省下 50%
缓存条目: 15

--- 时钟推进 60s(超过 TTL 30s)之后再调同样的参数 ---
调用 38 次 | 命中 18 | 未命中 20 | 命中率 47% | 省下 2160ms | 过期淘汰 5
新增命中: 3 → TTL 到期后全部重新计算,这正是我们想要的行为

第二段要解释一下,不然看着像 bug:把时钟推进 60 秒(超过 30 秒 TTL)后,前 5 次调用全部因过期被淘汰并重算(过期淘汰 5),后面 3 次命中的是刚刚重算完写进去的新条目。行为是对的——过期的必须重算,重算完的可以复用。

四条我踩过的坑

一、不是所有工具都能缓存。 写操作(write_file、发消息、下单)绝对不能缓存,缓存了等于吞掉操作。带时间语义的也不行:参数里有 now、random、today 的,每次结果本来就不同。我的做法是白名单,只给明确只读的工具开缓存,而不是默认全开再排除。

二、别缓存失败结果。 我第一版没区分,一次网络抖动导致的失败被缓存了 30 秒,结果这 30 秒内每次调用都"失败",而且快得离谱。现在 ok === false 的结果一律不进缓存。

三、命中率必须能看见。 缓存最危险的状态是"一直在命中过期数据,但没人知道"。所以我把 hit/miss/expired 三个计数器做成 report(),跟 trace 报表一起打印。命中率突然掉到 0 或者突然涨到 100%,都说明有东西不对。

四、TTL 按数据变化频率定,别拍脑袋。 我一开始统一给 30 秒,后来发现不对:读配置文件可以几分钟(它一天变不了几次),读股价必须几秒。现在按工具分别设 TTL,配置类 300 秒、查询类 30 秒、实时类不缓存。

我的判断

缓存在 Agent 上的收益比在传统 Web 上更大,原因是调用模式不一样。Web 请求是分散的、参数各异的;Agent 是串行推理、会在同一个上下文里反复确认同一件事,重复率天然高。我这边的实测是 50%,不缓存的话那一半全是白跑。

但我要加一句警告:缓存会掩盖问题。某天缓存命中率异常地高,先别高兴,那可能是 Agent 陷入了循环——它在反复问同一个问题,只是被缓存挡住了,你看到的只是"很快"。所以命中率这个数字要跟 seq(调用序号)一起看:连续十几条都是同一个 key,那是循环,不是优化。

脚本在 daily/tool-cache.js,零依赖,跑完直接打印上面那张表。

(写作与实测日期:2026-09-30,Node 22.22.2,本地真实运行输出。)

Last modification:September 30, 2026
如果觉得我的文章对你有用,请随意赞赏