频繁工具调用的成本与延迟陷阱
在多轮推理或多智能体循环中,Agent 经常会在短时间内针对相同的参数重复调用查询工具(如天气查询、汇率换算、公共文档检索)。
通过在 MCP Server 中间层引入 Redis 缓存,根据工具名与输入参数的规范化哈希生成缓存键,命中后直接从内存中返回历史结果,能够极大减轻后端服务压力并提高整体交互流畅度。
缓存键设计:先做参数规范化
缓存能不能命中,取决于缓存键是否稳定。只要同一个逻辑请求生成出两种不同的键,命中率就会莫名掉下来。可行的做法是把「工具名 + 规范化后的参数」作为键:参数对象按键名排序后序列化,字符串去掉首尾空白并统一大小写,数字与布尔保持原始类型,同时显式排除掉 trace_id、request_id、时间戳这类每次都变的字段。
const KEYLESS = new Set(["trace_id", "request_id", "timestamp"]);
function canonical(v: unknown): unknown {
if (Array.isArray(v)) return v.map(canonical);
if (v && typeof v === "object") {
return Object.fromEntries(
Object.entries(v as Record<string, unknown>)
.filter(([k]) => !KEYLESS.has(k))
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, val]) => [k, canonical(val)]),
);
}
return typeof v === "string" ? v.trim() : v;
}
// key = `${toolName}:${sha256(JSON.stringify(canonical(args)))}`
键前缀建议带上工具名与指纹版本(如 v1:),改了规范化规则后旧缓存自然失效,不会继续返回错值。
接入位置与 TTL 分层
缓存放在工具执行的入口,也就是模型已决定调用哪个工具、参数已生成之后。改造形态通常是一个包装函数:先查缓存,未命中才真正执行并回写;同时要处理并发同键,否则一批相同请求会同时打到后端(可用短锁或请求合并缓解)。
TTL 按数据的易变性分层,不要全局一个值:
| 数据类型 | 经验 TTL | 说明 |
|---|---|---|
| 几乎不变(汇率基准、文档索引) | 数小时到一天 | 命中收益最高 |
| 慢变(榜单、统计汇总) | 1–5 分钟 | 兼顾新鲜度 |
| 快变(实时状态、库存) | 不缓存,或仅做去重 | 错一次代价大于省一次收益 |
选型取舍
| 方案 | 跨进程共享 | 命中后开销 | 失效复杂度 | 适用场景 |
|---|---|---|---|---|
| 不缓存 | 无 | 每次真实执行 | 无 | 工具本身很轻 |
| 进程内 LRU | 不支持 | 最低 | 低 | 单实例、生命周期短 |
| Redis 单实例 | 支持 | 一次内网往返 | 中 | 多副本 Server,推荐起点 |
| Redis Cluster | 支持 | 一次内网往返 | 高 | 缓存量大、需要横向扩展 |
验证是否真的加速
命中率与端到端耗时必须一起看。做法是保留一份固定的参数集合连续调用若干轮,统计命中次数与总次数;同时对比开启缓存前后同一批请求的尾部延迟(P95 更稳定,均值容易被少数慢请求带偏)。命中率高但延迟没变,通常说明瓶颈根本不在被缓存的那一步。还要单独验证正确性:改一次后端数据,确认缓存过期后新值能读出来,避免「快而错」。
常见故障速查
| 现象 | 原因 | 处理 |
|---|---|---|
| 命中率长期接近 0 | 键里含时间戳/随机 ID,参数未规范化 | 排除易变字段,键前缀加指纹版本 |
| 缓存返回旧数据 | TTL 过长或缺少主动失效路径 | 缩短 TTL,写操作时按前缀清理 |
| 加了缓存延迟反而升高 | Redis 跨网访问、连接未复用 | 换内网实例、用连接池、就近部署 |
| 同一瞬间大量重复请求穿透 | 并发未合并,缓存同时失效 | 加短锁或请求合并,TTL 加随机抖动 |
| 工具偶发返回空结果 | 把失败响应也写进了缓存 | 只缓存成功结果,失败短 TTL 或不缓存 |
小结
判断这层缓存该不该保留,看两条硬指标:稳定参数集合下的命中率是否明显高于预期,以及端到端 P95 是否真的下降。如果后端本身很轻、或者数据要求强一致,缓存带来的正确性风险会超过收益,此时只做请求合并和超时保护更划算。若被缓存的是外部接口类工具,可先在 /mcp 查它的官方 Server 与限流说明,再定缓存粒度。