文章

Tool Result Caching 生产实战:用缓存命中率降低 Agent 外部调用成本

本文系统拆解大模型工具调用中的结果缓存设计,覆盖缓存键、TTL、幂等、失效、观测指标与上线检查,帮助 Agent 在保证准确性的前提下降低外部 API 成本与尾延迟。

背景:Agent 变慢,很多时候不是模型慢,而是工具调用慢

在生产环境里,LLM Agent 的一次回答往往不是单次模型生成,而是一条由模型、工具、外部 API、数据库、检索系统和二次总结组成的链路。OpenAI 的 Function Calling 文档把工具调用拆成多个步骤:模型收到请求后可能返回 tool call,应用侧执行函数,再把 tool output 送回模型生成最终回答。Anthropic 的 Tool Use 文档也强调,client-side tools 由应用执行,模型返回 tool_use,应用再提交 tool_result

这意味着,Agent 的延迟和成本不只来自模型 token。天气、汇率、库存、订单状态、客户画像、权限查询、搜索结果、内部知识库检索、价格计算、风控规则命中——这些外部工具每调用一次都会引入网络延迟、下游限流、第三方费用和失败概率。并且很多请求在短时间内高度重复:同一个用户反复问同一订单状态,同一个团队频繁查询同一服务价格,同一个客服场景不断读取同一条政策说明。

Tool Result Caching 要解决的就是这个问题:当模型发起一个可缓存、可复用、可定义新鲜度的工具调用时,不直接打到外部系统,而是先查询工具结果缓存。如果命中且未过期,就把缓存结果作为 tool_result 返回给模型;如果未命中或已过期,再执行真实工具调用并写入缓存。

它不是 Prompt Caching,也不是 KV Cache。Prompt Caching 面向模型输入前缀复用,KV Cache 面向推理过程中的注意力状态复用,而 Tool Result Caching 面向外部工具执行结果。它属于 Agent Runtime / Tool Gateway / LLM Application Backend 的工程层能力。

核心原理:在 tool call 和外部工具之间加一层结果缓存

一个最小闭环如下:

  1. 模型输出工具调用,例如 get_weather({"city":"Tokyo"})
  2. Agent Runtime 对工具名和参数做规范化。
  3. 生成缓存键,例如 tenant:user:tool:version:normalized_args:auth_scope
  4. 查询缓存。
  5. 命中且新鲜:直接返回缓存结果给模型。
  6. 未命中、过期或不可缓存:执行真实工具调用。
  7. 工具调用成功后,将结果、来源、时间、TTL、版本和审计信息写入缓存。
  8. 模型基于 tool result 生成最终回答。

这个设计的关键点不是「加一个 Redis」这么简单,而是要回答五个问题:

  • 什么可以缓存:只读查询、稳定数据、短时间重复率高的结果。
  • 缓存多久:不同工具、字段和业务场景要有不同 TTL。
  • 谁可以复用:租户、用户、权限、数据范围必须进入缓存边界。
  • 如何失效:TTL、事件通知、版本升级、手动清理都要支持。
  • 如何观测:命中率、节省调用、陈旧率、错误率、成本节省要可见。

缓存键设计:不要只用工具名和参数

很多缓存事故不是因为没有缓存,而是缓存键过于粗糙。例如:

// ❌ 危险做法
const badKey = tool_name + JSON.stringify(args);

这在 Demo 中能跑,但在多租户生产环境里会出问题:

  • 两个用户都调用 get_order_status({orderId:"123"}),但他们是否有权查看同一订单?
  • 同一个 search_policy({keyword:"退保"}),不同地区、产品线、语言、发布时间是否一样?
  • 同一个 get_price({sku:"A"}),是否区分渠道、币种、会员等级和时间窗口?

生产环境的缓存键至少应包含以下维度:

维度说明
tenant_id租户标识
user_or_service_scope用户或服务授权范围
tool_name工具名称
tool_version工具版本
normalized_arguments规范化后的参数
authorization_scope授权范围哈希
data_region_or_locale数据区域或语言
freshness_policy_id新鲜度策略 ID
schema_version数据模式版本

参数规范化要做三件事:

  • 对 JSON key 排序,避免 {a:1,b:2}{b:2,a:1} 产生不同 key。
  • 去掉无意义字段,例如 traceIdrequestId、UI 来源。
  • 对时间参数做桶化,例如「最近 24 小时」转换成明确时间区间。

推荐生成方式:

import crypto from "node:crypto";

function stableStringify(value: unknown): string {
  if (Array.isArray(value)) {
    return `[${value.map(stableStringify).join(",")}]`;
  }
  if (value && typeof value === "object") {
    return `{${Object.entries(value as Record<string, unknown>)
      .filter(([key]) => !["traceId", "requestId"].includes(key))
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([key, val]) => `${JSON.stringify(key)}:${stableStringify(val)}`)
      .join(",")}}`;
  }
  return JSON.stringify(value);
}

function buildToolCacheKey(input: {
  tenantId: string;
  authScopeHash: string;
  toolName: string;
  toolVersion: string;
  args: unknown;
  freshnessPolicyId: string;
  schemaVersion: string;
}) {
  const payload = stableStringify(input.args);
  const digest = crypto.createHash("sha256").update(payload).digest("hex");
  return [
    "tool-result",
    input.tenantId,
    input.authScopeHash,
    input.toolName,
    input.toolVersion,
    input.freshnessPolicyId,
    input.schemaVersion,
    digest,
  ].join(":");
}

安全提示authScopeHash 不应直接包含邮箱、手机号、身份证号等明文敏感信息。可以把授权范围、角色、资源集合和数据域做哈希,但不要把个人敏感数据当作缓存键的一部分直接暴露在 Redis key、日志和监控系统里。

TTL 不是统一配置,而是工具契约的一部分

缓存 TTL 应该跟工具语义绑定,而不是由基础设施团队统一拍一个默认值。可以按数据类型分层:

工具类型示例建议 TTL失效方式
静态规则帮助文档、政策说明、字段字典小时到天文档发布事件、版本号
准实时信息天气、汇率、公开价格秒到分钟TTL + 后台刷新
用户状态订单状态、工单状态、账户额度秒级或不共享业务事件 + 权限校验
高风险写操作支付、退款、发邮件、创建订单不做结果缓存幂等键和审计
搜索/检索结果Web 搜索、内部知识库检索分钟级索引版本 + query hash

HTTP 缓存标准 RFC 9111 强调,缓存通过复用已有响应降低延迟和网络开销,但必须基于 freshness、validation、cache key 和安全约束来决定是否复用。这个思想可以迁移到 Agent 工具结果缓存:缓存不是永久答案库,而是带有新鲜度、权限和版本边界的结果复用机制

幂等键和结果缓存要分清

很多团队会把幂等和缓存混在一起。两者有关,但不是一回事。

  • 幂等键解决的是「同一次写操作因为超时、重试、网络抖动而被执行多次」的问题。Stripe 的 API 文档说明,创建或更新对象时可以使用 idempotency key,后续相同 key 的请求会返回第一次请求保存的状态码和响应体,从而避免重复创建或重复更新。
  • 结果缓存解决的是「多个等价读请求是否可以复用同一个已知结果」的问题。

对 Agent 来说,规则可以这样定:

  • 读类工具:可以考虑 Tool Result Caching。
  • 写类工具:默认不用结果缓存,只使用幂等键、审批、审计和补偿。
  • 读写混合工具:拆分成 read preview 和 commit action,分别治理。

例如 calculate_shipping_fee 是读类计算,可以缓存;create_shipping_order 是写类动作,不应因为参数相同就复用旧结果,而应使用幂等键防止重复创建。

工程落地:推荐把缓存作为 Tool Gateway 能力

如果每个工具各自实现缓存,最终会出现 TTL 不一致、命中率无法统计、权限边界混乱、无法统一失效的问题。更稳妥的做法是在 Agent Runtime 和真实工具之间加一层 Tool Gateway

一个简化的执行流程如下:

type ToolCall = {
  tenantId: string;
  userId: string;
  toolName: string;
  toolVersion: string;
  args: unknown;
  authScopeHash: string;
};

type ToolPolicy = {
  cacheable: boolean;
  ttlSeconds: number;
  allowStaleOnError: boolean;
  negativeCacheSeconds?: number;
  schemaVersion: string;
  freshnessPolicyId: string;
};

async function executeToolWithCache(call: ToolCall) {
  const policy = await loadToolPolicy(call.toolName, call.toolVersion);
  if (!policy.cacheable) {
    return executeRealTool(call);
  }
  const key = buildToolCacheKey({
    tenantId: call.tenantId,
    authScopeHash: call.authScopeHash,
    toolName: call.toolName,
    toolVersion: call.toolVersion,
    args: call.args,
    freshnessPolicyId: policy.freshnessPolicyId,
    schemaVersion: policy.schemaVersion,
  });
  const cached = await cache.get(key);
  if (cached && !cached.isExpired) {
    recordMetric("tool_cache_hit", call.toolName);
    return {
      ...cached.value,
      _cache: {
        hit: true,
        key,
        storedAt: cached.storedAt,
        expiresAt: cached.expiresAt,
      },
    };
  }
  try {
    const result = await executeRealTool(call);
    await cache.set(key, result, policy.ttlSeconds);
    recordMetric("tool_cache_miss", call.toolName);
    return { ...result, _cache: { hit: false, key } };
  } catch (error) {
    if (cached && policy.allowStaleOnError) {
      recordMetric("tool_cache_stale_served", call.toolName);
      return {
        ...cached.value,
        _cache: { hit: true, stale: true, reason: "origin_error" },
      };
    }
    throw error;
  }
}

这个示例故意保留 _cache 元数据。实际返回给模型时,可以只传业务字段;但日志、trace 和监控系统必须记录缓存状态,否则后期排障会很困难。

适用场景

Tool Result Caching 最适合以下场景:

1. 外部 API 成本高

例如 Web Search、地理编码、汇率、天气、企业工商信息、物流轨迹、金融行情、OCR 结果等。只要结果在短时间内可复用,缓存就能减少调用费用和限流压力。

2. 查询重复率高

客服、运营、销售助手、内部知识库问答常出现大量重复工具调用。用户问法不同,但工具参数规范化后可能相同。

3. 工具延迟明显高于模型推理

如果模型 800ms 就能产生 tool call,但外部 API 平均 2 秒、P95 8 秒,缓存命中会显著改善用户体验。

4. 下游系统需要保护

老核心系统、CRM、ERP、风控系统、保险核心、工单系统通常不希望被 Agent 高并发直接打穿。缓存可以作为削峰层,但不能替代权限控制和限流。

常见误区

误区一:缓存命中率越高越好

不是。命中率高但答案过期,反而会伤害业务。对价格、库存、订单状态这类数据,应该同时看 hit ratestale serve ratefreshness violationuser correction rate

误区二:缓存结果可以跨租户复用

默认不可以。除非是公开、无权限差异、无个性化差异的数据,例如公共天气、公开文档、公共汇率。多租户系统中,租户、用户权限和数据域必须进入缓存边界。

误区三:只要工具参数相同,结果就相同

很多工具有隐含上下文:用户角色、地区、语言、时间、产品线、渠道、灰度版本、模型版本、索引版本。缓存键没有包含这些因素,就可能产生错误复用。

误区四:缓存可以解决所有工具调用成本

缓存只解决「可复用的读结果」。对于高风险写操作,需要的是幂等、审批、回滚、审计和补偿,而不是普通缓存。

误区五:不需要把缓存状态告诉模型

不一定。对部分场景,模型需要知道结果是实时查询还是缓存结果。例如回答「这是刚刚查到的吗?」时,如果工具结果来自 10 分钟前缓存,就应该能说明时间。推荐把 observed_atsourcettlis_cached 等元数据以受控方式传给模型。

上线检查清单

上线前建议至少检查以下项目:

  1. 每个工具是否有 cacheable 标记。
  2. 缓存键是否包含租户、权限、工具版本、Schema 版本和参数 hash。
  3. 是否禁止缓存写类工具结果。
  4. 是否对敏感字段做脱敏或不入缓存。
  5. 是否支持按工具、租户、版本强制失效。
  6. 是否有 negative cache,避免下游错误被无限放大。
  7. 是否区分 timeout、4xx、5xx、业务空结果。
  8. 是否支持 stale-on-error,并明确哪些工具可以使用。
  9. 是否记录 hit、miss、stale、bypass、evict、invalidate 指标。
  10. 是否在 trace 中展示 cache key hash、TTL、storedAt、expiresAt。
  11. 是否有缓存污染应急清理脚本。
  12. 是否有小流量灰度,确认命中结果不会改变业务语义。

总结

Tool Result Caching 是 Agent 工程化中一个容易被忽视但收益显著的能力。它不是简单地给工具调用套一层 Redis,而是在缓存键设计、TTL 策略、租户隔离、失效机制、观测体系和安全边界之间做系统性权衡。正确的缓存设计能让 Agent 在保证准确性的前提下,显著降低外部 API 成本、保护下游系统、改善尾延迟——而这些,正是生产级 Agent 从 Demo 走向规模化落地的关键一步。

参考资料

常见问题

所有工具调用结果都适合缓存吗?
不适合。读类、低风险、可定义新鲜度的数据适合缓存;支付、下单、发邮件、写数据库等有副作用的工具不应按普通结果缓存处理。
Tool Result Caching 和 Prompt Caching 是一回事吗?
不是。Prompt Caching 缓存的是模型输入上下文或前缀,Tool Result Caching 缓存的是外部工具执行结果,两者解决的问题、失效条件和风险边界不同。
如何避免缓存结果污染不同用户?
缓存键必须包含租户、用户授权范围、工具版本、参数规范化结果和数据版本;敏感数据默认不进入共享缓存,并提供审计与强制失效能力。