背景: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 和外部工具之间加一层结果缓存
一个最小闭环如下:
- 模型输出工具调用,例如
get_weather({"city":"Tokyo"})。 - Agent Runtime 对工具名和参数做规范化。
- 生成缓存键,例如
tenant:user:tool:version:normalized_args:auth_scope。 - 查询缓存。
- 命中且新鲜:直接返回缓存结果给模型。
- 未命中、过期或不可缓存:执行真实工具调用。
- 工具调用成功后,将结果、来源、时间、TTL、版本和审计信息写入缓存。
- 模型基于 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。 - 去掉无意义字段,例如
traceId、requestId、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 rate、stale serve rate、freshness violation 和 user correction rate。
误区二:缓存结果可以跨租户复用
默认不可以。除非是公开、无权限差异、无个性化差异的数据,例如公共天气、公开文档、公共汇率。多租户系统中,租户、用户权限和数据域必须进入缓存边界。
误区三:只要工具参数相同,结果就相同
很多工具有隐含上下文:用户角色、地区、语言、时间、产品线、渠道、灰度版本、模型版本、索引版本。缓存键没有包含这些因素,就可能产生错误复用。
误区四:缓存可以解决所有工具调用成本
缓存只解决「可复用的读结果」。对于高风险写操作,需要的是幂等、审批、回滚、审计和补偿,而不是普通缓存。
误区五:不需要把缓存状态告诉模型
不一定。对部分场景,模型需要知道结果是实时查询还是缓存结果。例如回答「这是刚刚查到的吗?」时,如果工具结果来自 10 分钟前缓存,就应该能说明时间。推荐把 observed_at、source、ttl、is_cached 等元数据以受控方式传给模型。
上线检查清单
上线前建议至少检查以下项目:
- 每个工具是否有
cacheable标记。 - 缓存键是否包含租户、权限、工具版本、Schema 版本和参数 hash。
- 是否禁止缓存写类工具结果。
- 是否对敏感字段做脱敏或不入缓存。
- 是否支持按工具、租户、版本强制失效。
- 是否有 negative cache,避免下游错误被无限放大。
- 是否区分 timeout、4xx、5xx、业务空结果。
- 是否支持 stale-on-error,并明确哪些工具可以使用。
- 是否记录 hit、miss、stale、bypass、evict、invalidate 指标。
- 是否在 trace 中展示 cache key hash、TTL、storedAt、expiresAt。
- 是否有缓存污染应急清理脚本。
- 是否有小流量灰度,确认命中结果不会改变业务语义。
总结
Tool Result Caching 是 Agent 工程化中一个容易被忽视但收益显著的能力。它不是简单地给工具调用套一层 Redis,而是在缓存键设计、TTL 策略、租户隔离、失效机制、观测体系和安全边界之间做系统性权衡。正确的缓存设计能让 Agent 在保证准确性的前提下,显著降低外部 API 成本、保护下游系统、改善尾延迟——而这些,正是生产级 Agent 从 Demo 走向规模化落地的关键一步。