文章

LLM 工具结果缓存生产实战:用幂等键、TTL 与失效策略降低 Agent 外部调用成本

本文讲解如何为大模型 Agent 的外部工具调用建立结果缓存,覆盖幂等键设计、TTL 语义分层、事件驱动失效、状态隔离、审计回放和上线检查清单,帮助团队降低重复 API 调用成本与延迟,同时避免缓存污染和跨租户泄露。

背景:Agent 成本不只来自模型 token

很多团队优化大模型应用时,第一反应是压缩 prompt、换小模型、做 Batch API 或减少上下文长度。这些方法有价值,但对 Agent 型应用 来说,另一类成本经常被低估:外部工具调用

一个客服 Agent 可能会反复查询订单、会员权益、物流轨迹;一个数据分析 Agent 可能会多次调用 SQL、搜索接口、内部配置服务;一个研究型 Agent 可能会对相似关键词重复执行网页搜索。模型本身只是「决定调用什么工具」,真正耗时、耗钱、触发限流甚至带来不稳定性的,往往是工具背后的外部系统。

OpenAI 的 Function Calling 文档把工具调用描述为模型与外部系统交互的多步流程:先把可调用工具给模型,模型返回工具调用,应用侧执行代码,再把工具结果回填给模型。Anthropic 的工具使用文档也强调,工具可以由应用侧执行,也可以由服务端工具执行,并且 server-side tools 可能产生额外使用费用。因此,工具结果缓存不是一个边缘优化,而是 Agent 平台进入生产后必须考虑的基础能力

本文讨论的是 Tool Result Cache,也就是缓存「某一次工具调用在某个输入和上下文边界下的结果」。它不同于 Prompt Cache,也不同于 KV Cache,更不是 LoRA adapter cache。它位于 Agent Runtime 与外部工具之间,目标是减少重复调用、降低尾延迟、缓解第三方 API 限流,并保持结果可审计、可失效、可回放。

核心原理:缓存的不是自然语言,而是可验证的工具输出

工具调用结果必须结构化

不要把模型生成的自然语言答案直接作为工具结果缓存。生产系统应缓存工具返回的原始结构化数据,例如:

{
  "tool_name": "get_order_status",
  "arguments": {
    "tenant_id": "t_001",
    "order_id": "O20260707001"
  },
  "result": {
    "status": "shipped",
    "carrier": "SF Express",
    "updated_at": "2026-07-07T02:40:00-04:00"
  },
  "source": "order-service-v3",
  "fetched_at": "2026-07-07T03:00:00-04:00"
}

模型最终如何组织语言,可以每次重新生成;工具返回的事实数据,才是可缓存、可复核、可追踪的对象。

缓存键要由「工具名 + 规范化参数 + 隔离边界」组成

工具缓存最常见的事故不是缓存失效太慢,而是缓存键设计过粗。一个看似相同的查询,在不同租户、用户、权限、语言、时间窗口、工具版本下,可能不是同一个结果。

推荐缓存键至少包含以下部分:

tool_result:v1:{tenant_id}:{user_scope}:{tool_name}:{tool_version}:{normalized_args_hash}:{policy_scope}

其中,normalized_args_hash 应来自排序后的 JSON 参数,而不是模型生成的原始字符串。比如 {"city":"Paris","unit":"celsius"}{"unit":"celsius","city":"Paris"} 应命中同一个 key。

import hashlib, json

VOLATILE_FIELDS = {"trace_id", "request_id", "timestamp"}

def normalize_args(args: dict) -> str:
    stable = {k: v for k, v in args.items() if k not in VOLATILE_FIELDS}
    return json.dumps(stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"))

def build_cache_key(tenant_id: str, user_scope: str, tool_name: str,
                    tool_version: str, args: dict) -> str:
    digest = hashlib.sha256(normalize_args(args).encode("utf-8")).hexdigest()[:32]
    return f"tool_result:v1:{tenant_id}:{user_scope}:{tool_name}:{tool_version}:{digest}"

这里的 user_scope 很关键。对于公开天气查询,可以用 public;对于订单、账户、报表等权限相关工具,必须绑定用户、角色或授权范围,否则会发生跨用户缓存泄露。

TTL 不是统一配置,而是工具能力的一部分

Redis 等缓存系统支持带过期时间的字符串写入,但 Agent 工具结果不能简单统一设置为「10 分钟过期」。每个工具都应声明自己的 freshness contract

可以把工具按时效性分层:

tools:
  get_product_detail:
    cacheable: true
    ttl_seconds: 3600
    invalidation: product_updated_event
  get_weather:
    cacheable: true
    ttl_seconds: 600
    invalidation: ttl_only
  get_order_status:
    cacheable: true
    ttl_seconds: 30
    invalidation: order_status_changed_event
  send_refund:
    cacheable: false
    reason: side_effect

TTL 应来自业务语义,而不是缓存系统默认值。商品详情可以缓存较久,订单状态可能只允许短 TTL,支付、退款、发信、下单等副作用工具不应缓存执行结果,更不能用缓存命中代替真实执行。

工程落地:在 Tool Runtime 前放一个可审计缓存层

推荐链路

生产链路可以拆成五步:

  1. 模型返回工具调用。
  2. Tool Runtime 先做权限、参数校验和幂等性判断。
  3. Cache Policy 判断是否允许读缓存。
  4. 命中则返回缓存结果,未命中则调用真实工具。
  5. 无论命中还是未命中,都以标准 tool output 形式回填模型,并写入 trace。

关键点是:缓存层不应该让模型感知「工具没被执行」。对模型来说,它仍然收到一个工具输出;对工程侧来说,trace 中必须明确标记 cache_hit=truecache_keyttl_remainingsource_fetched_attool_version

async def execute_tool_with_cache(tool_call, context):
    policy = registry.get_policy(tool_call.name)
    if not policy.cacheable:
        result = await real_tool_execute(tool_call, context)
        return ToolOutput(result=result, cache_hit=False)

    cache_key = build_cache_key(
        tenant_id=context.tenant_id,
        user_scope=context.user_scope,
        tool_name=tool_call.name,
        tool_version=policy.version,
        args=tool_call.arguments,
    )
    cached = await cache.get(cache_key)
    if cached and not policy.must_revalidate(cached, context):
        return ToolOutput(result=cached["result"], cache_hit=True, cache_key=cache_key)

    result = await real_tool_execute(tool_call, context)
    await cache.set(cache_key, {
        "result": result,
        "tool_version": policy.version,
        "fetched_at": now_iso(),
        "scope": context.user_scope,
    }, ttl=policy.ttl_seconds)
    return ToolOutput(result=result, cache_hit=False, cache_key=cache_key)

幂等键和缓存键不要混用

幂等键用于防止同一个副作用请求重复执行,例如重复提交退款、重复发短信、重复创建工单。缓存键用于复用只读结果。二者可以共享规范化参数逻辑,但语义完全不同。

错误做法是:看到模型又调用了一次 create_ticket,就直接返回上次结果。正确做法是:如果业务请求携带同一个幂等键,则返回同一事务结果;如果没有幂等键,就不能把写操作当作缓存命中。

一个简单判断标准:如果工具执行会改变外部世界,就不要走普通结果缓存

失效策略要支持事件驱动

只靠 TTL 会留下明显窗口。比如订单状态已经从「待发货」变成「已发货」,但缓存还有 20 秒才过期;对普通问答可能可以接受,对客服、风控、支付等场景就不可接受。

更稳的做法是组合使用三类策略:

策略适用场景优点风险
短 TTL变化频繁但低风险的数据实现简单,无额外依赖存在命中过期数据的窗口
事件失效关键业务状态变更实时性好,精确失效依赖事件基础设施可靠性
版本前缀失效工具升级、口径变化批量失效,避免脏读需要版本管理规范

工程上不要追求一次性设计出「完美失效」。更重要的是让每个工具都声明缓存等级、TTL、失效事件和风险级别。

适用场景

工具结果缓存适合这些场景:

  • 多轮对话中重复查询同一订单、账户、配置或知识库片段。
  • Agent 在规划阶段反复调用相同搜索、SQL、内部 API。
  • 第三方 API 有严格 QPS、计费或稳定性限制。
  • 多个并行 Agent 任务访问相同只读数据。
  • 评测、回放、灰度测试需要复用稳定工具输出。

近期的 ToolCaching 研究也把问题描述得很直接:LLM tool-calling 中存在重复或冗余调用,但传统缓存策略很难直接适配,因为工具请求语义异构、工作负载动态、结果新鲜度要求不同。TVCACHE 进一步指出,在 Agent 后训练或 rollout 场景中,工具输出还依赖先前交互状态,因此必须考虑完整工具历史,而不是只看当前参数。

这对生产系统的启发是:不要把工具缓存做成一个通用 Redis wrapper。它应该是 Agent Runtime 的一等能力,能理解工具语义、状态边界和业务风险。

常见误区

误区一:把所有 GET 类工具都默认缓存

HTTP 方法不是充分条件。一个 GET 接口也可能依赖用户权限、实时状态、地理位置、AB 实验分组或后台配置。缓存前必须回答三个问题:结果是否只读?是否可复用?是否有明确时效边界?

误区二:只统计命中率,不看错误复用率

缓存命中率高不一定是好事。如果缓存命中的是过期、越权或错误数据,命中率越高,事故扩散越快。生产指标至少应包括:

指标说明
cache_hit_rate缓存命中率
cache_stale_hit_count命中过期数据的次数
cache_bypass_count跳过缓存的次数
cache_eviction_count缓存淘汰次数
tool_latency_saved_ms节省的工具调用延迟
external_api_calls_saved节省的外部 API 调用次数
cache_error_reuse_count错误结果被复用的次数
per_tool_cache_hit_rate按工具维度的缓存命中率

误区三:命中缓存后不写 trace

缓存命中不是「没有发生」。它改变了 Agent 的执行路径,也影响最终回答。每次命中都应进入 trace,便于回放时知道模型看到的是实时工具结果还是历史缓存结果。

误区四:把 tool output 原样长期保存

工具返回中可能包含 PII、权限数据或商业敏感信息。缓存层必须继承数据分级策略:该脱敏的脱敏,该加密的加密,该短 TTL 的短 TTL,该禁止跨租户复用的严格隔离。

上线检查清单

上线前建议按以下清单逐项验收:

  • 每个工具都有 cacheablettl_secondsrisk_leveltool_version 配置。
  • 写操作、副作用操作默认禁止结果缓存。
  • 缓存键包含租户、用户授权范围、工具名、工具版本和规范化参数摘要。
  • 参数规范化会删除 trace_idrequest_idtimestamp 等易变字段。
  • 权限变化、数据更新、工具版本升级能触发失效或版本前缀切换。
  • trace 中记录 cache_hitcache_key_hashttl_remainingsource_fetched_at
  • 灰度期间按工具维度观察命中率、延迟节省、错误复用、外部 API 调用下降。
  • 支持按 tenant、tool、cache_key_hash 快速禁用缓存。
  • 支持回放同一对话时选择「使用缓存结果」或「重新执行工具」。

总结

工具结果缓存是 Agent 平台走向生产不可绕过的工程基础。它不是简单的 Redis SETEX,而是一套需要与业务语义、权限边界、工具版本和审计链路深度集成的策略体系。核心原则可以归纳为三条:

  1. 缓存结构化工具输出,不缓存模型生成的自然语言。
  2. 缓存键必须包含隔离边界(租户、用户、工具版本),不能只靠参数摘要。
  3. 失效策略必须多管齐下:TTL + 事件驱动 + 版本前缀,三者互补而非互斥。

做好这些,你不仅能显著降低外部 API 调用成本和延迟,还能在不牺牲正确性的前提下,让 Agent 执行路径变得可审计、可回放、可治理。

参考资料

常见问题

哪些工具调用结果适合缓存?
适合缓存的是只读、确定性较强、结果有明确时效边界的工具,例如汇率查询、商品详情、文档检索结果、静态配置查询和部分搜索结果。不适合缓存的是支付、下单、发邮件、写数据库、审批流等有副作用的操作。
工具结果缓存和 Prompt Cache 是一回事吗?
不是。Prompt Cache 缓存的是模型输入前缀或上下文 token;工具结果缓存缓存的是外部工具执行后的结构化返回值,两者命中条件、失效策略和风险边界不同。
缓存命中后还需要把结果回填给模型吗?
通常需要。缓存只是替代真实工具执行,仍应把结果以标准 tool output 形式回填给模型,并在 trace 中标记 cache_hit,避免破坏工具调用流程和审计链路。
工具结果缓存会不会降低答案实时性?
会有这个风险,所以 TTL 必须来自业务语义而非统一默认值。对实时性敏感的工具可只缓存几十秒或要求事件失效;对高风险工具可完全禁止缓存。