背景: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 前放一个可审计缓存层
推荐链路
生产链路可以拆成五步:
- 模型返回工具调用。
- Tool Runtime 先做权限、参数校验和幂等性判断。
- Cache Policy 判断是否允许读缓存。
- 命中则返回缓存结果,未命中则调用真实工具。
- 无论命中还是未命中,都以标准 tool output 形式回填模型,并写入 trace。
关键点是:缓存层不应该让模型感知「工具没被执行」。对模型来说,它仍然收到一个工具输出;对工程侧来说,trace 中必须明确标记 cache_hit=true、cache_key、ttl_remaining、source_fetched_at、tool_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,该禁止跨租户复用的严格隔离。
上线检查清单
上线前建议按以下清单逐项验收:
- 每个工具都有
cacheable、ttl_seconds、risk_level、tool_version配置。 - 写操作、副作用操作默认禁止结果缓存。
- 缓存键包含租户、用户授权范围、工具名、工具版本和规范化参数摘要。
- 参数规范化会删除
trace_id、request_id、timestamp等易变字段。 - 权限变化、数据更新、工具版本升级能触发失效或版本前缀切换。
- trace 中记录
cache_hit、cache_key_hash、ttl_remaining、source_fetched_at。 - 灰度期间按工具维度观察命中率、延迟节省、错误复用、外部 API 调用下降。
- 支持按 tenant、tool、cache_key_hash 快速禁用缓存。
- 支持回放同一对话时选择「使用缓存结果」或「重新执行工具」。
总结
工具结果缓存是 Agent 平台走向生产不可绕过的工程基础。它不是简单的 Redis SETEX,而是一套需要与业务语义、权限边界、工具版本和审计链路深度集成的策略体系。核心原则可以归纳为三条:
- 缓存结构化工具输出,不缓存模型生成的自然语言。
- 缓存键必须包含隔离边界(租户、用户、工具版本),不能只靠参数摘要。
- 失效策略必须多管齐下:TTL + 事件驱动 + 版本前缀,三者互补而非互斥。
做好这些,你不仅能显著降低外部 API 调用成本和延迟,还能在不牺牲正确性的前提下,让 Agent 执行路径变得可审计、可回放、可治理。