☰
Prompt Cache 失效之谜:工具定义变动如何影响 LLM API 延迟与成本
2026/10/23 11:16:04 网站建设 项目流程

开发者在接入 LLM API 时,Prompt Cache(提示词缓存)通常不会被第一时间注意到,但它决定了一批请求的延迟和成本。最近一次工程观察里,同一个多轮对话请求在两个模型版本上出现了完全不同的表现:把请求中的工具列表从三个减少到两个,GPT-5.5 的 prompt cache 立刻全部失效,请求延迟和费用明显上升;换到 GPT-5.2,同一个请求却仍然命中缓存。这个差异看起来只是“多删除一个 tool 定义”,实际上牵出了 prompt cache 的命中边界、tool schema 参与编码的方式、缓存 key 粒度等一串问题。

这个观察也印证了一件事:在基于 tool calling 的 agent 类应用里,工具定义是否稳定,直接决定提示词缓存能不能服务于高频请求。下面的内容先解释 prompt cache 为什么受 tools 影响,再做一组对比实验,分析 GPT-5.5 和 GPT-5.2 行为不同的可能原因,最后给出缓存友好的请求设计、排查链和生产建议。适用读者包括在自建 Agent、函数调用、MCP 网关、多模型路由场景里处理过请求变慢或费用增长的工程师。

1. 先搞清楚 prompt cache 的命中条件,再谈 tool 为什么会影响缓存

1.1 前缀缓存的基本逻辑

Prompt cache 通常不是“整段缓存”,而是“前缀缓存”。模型在生成回答前,会把整段 prompt 编码成 token 序列,然后逐层计算 KV Cache。如果下一次请求的前缀 token 和过去某次请求完全一致,服务端可以直接复用这部分 KV Cache,不必重新计算 attention。

命中缓存的效果有两个:

  • 降低首 token 延迟,因为省去了大量推理前向计算。
  • 降低计费 token 数,因为缓存命中的前缀部分往往按折扣价计费,甚至不计费。

这个机制的关键限制是“前缀必须完全一致”。不是说用户消息内容大致相同就能命中,而是从 prompt 开头到某个位置的所有 token 必须逐字相同。只要中间插入一个不同的 token,从那个位置开始,后面的缓存全部断开。

所以缓存命中的本质是一个字符串前缀匹配问题,只是比较的单位是 token,不是字符。实际系统中还会有长度阈值、缓存分片、失效时间等约束,但“前缀一致”是底层前提。

1.2 tools 参数在服务端如何参与 token 序列

在 OpenAI 兼容接口里,请求体中的 messages 和 tools 是分开的两个字段,比如:

{ "model": "gpt-5.5", "messages": [ {"role": "system", "content": "你是订单助手。"}, {"role": "user", "content": "帮我查一下订单 A123 的状态。"} ], "tools": [ { "type": "function", "function": { "name": "query_order", "description": "查询订单状态", "parameters": { "type": "object", "properties": { "order_id": {"type": "string"} }, "required": ["order_id"] } } }, { "type": "function", "function": { "name": "cancel_order", "description": "取消订单", "parameters": { "type": "object", "properties": { "order_id": {"type": "string"}, "reason": {"type": "string"} }, "required": ["order_id", "reason"] } } } ] }

云端服务在真正执行推理前,需要把 function calling 的工具定义转换成模型能理解的文本片段或特殊 token 序列。这一步通常是内部的,开发者看不到,但效果等价于在模型输入的某个位置额外插入了一段“工具清单”。

也就是说,一个请求实际参与前缀匹配的 token 序列大致是:

system 指令 + 工具使用说明 + 工具定义清单 + 历史消息 + 当前用户消息

不同模型的具体拼接顺序可能不同,有的把工具定义放在 system 之前,有的放在 system 之后,有的按特殊 token 处理。但共同点是:tools 字段的内容会进入模型实际输入序列,它不是挂在请求体里对缓存免疫的附加参数。

1.3 为什么“删除一个 tool”等于改写前缀

现在可以解释标题里的现象了。假设一个 Agent 原本注册了三个工具:

  • query_order
  • cancel_order
  • get_user_profile

在一次调用中,因为业务调整,你不再需要 get_user_profile,于是从 tools 数组里移除了它。前后两个请求的 messages 可能完全一样,system prompt 也一样,但工具清单从三项变成两项。

从工程的直觉看,你只是删掉了一个不再使用的函数定义,对话内容没有变化。但从 token 前缀看,工具定义段产生了非常大的差异:原本完整的三段 JSON Schema 少了一段,后面的 message 部分整体前移。即使 delete 操作发生在工具列表末尾,它也会改变工具定义段之后的 token 对齐方式,因此从工具段开始,所有缓存都不能复用。

更麻烦的是,即使工具列表本身没有增减,只是某一个工具的 description 里改了一个词、参数的 JSON Schema 调整了某个字段类型,token 序列也会变。这是 tool calling 场景下 prompt cache 失效的主要原因。

1.4 一个容易混淆的点:请求体里 tools 不在 messages 里,不代表不影响前缀

很多开发者在排查缓存失效时会走入一个误区:只对比 messages 数组是否一致,看到 messages 完全相同就认为缓存应该命中,却完全忽略 tools、tool_choice、response_format 等请求级参数。

实际判断标准是“服务端实际参与编码的完整输入序列是否一致”,不是“客户端请求体顶层字段是否看起来一样”。tools 的任何变化都会改写输入序列,因此都会影响前缀匹配。

同样的问题也出现在 metadata、user 字段、自定义模型的某些参数上。如果服务端把这些字段拼进内部 prompt,它们也会成为缓存 key 的一部分。正确做法是查阅对应模型的缓存文档,或者通过返回的 usage 字段观察实际命中情况。

2. 复现实验:同一段对话,删除一个 tool 定义,观察两个模型的 cached_tokens

2.1 实验目标与前提

为了确认“删除工具导致缓存失效”这个现象,需要在固定对话前缀下做一组 A/B 实验。目标不是证明两个模型谁好谁坏,而是看它们对 tools 变化的反应是否一致,以及缓存命中量到底差在哪里。

实验前提:

  • 前置知识:会调用 OpenAI 兼容接口,能安装 Python SDK。
  • 模型标识:项目里配置的模型名分别为gpt-5.5和gpt-5.2。
  • 实验环境:本地 Python 3.10+,安装openai库。
  • 说明:不同厂商、不同版本对 cached_tokens 的字段命名和计算口径可能不同,下面以常见 usage 结构为例。

2.2 准备一个能打印缓存状态的调用函数

先封装一个请求函数,自动打印 usage 中的缓存字段,方便对比。

import os from openai import OpenAI client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) def send_and_report(model: str, messages: list, tools: list, note: str): resp = client.chat.completions.create( model=model, messages=messages, tools=tools, ) usage = resp.usage prompt_details = getattr(usage, "prompt_tokens_details", None) cached = getattr(prompt_details, "cached_tokens", 0) print(f"[{note}] model={model}") print(f" prompt_tokens={usage.prompt_tokens}") print(f" cached_tokens={cached}") print(f" cache_ratio={cached / usage.prompt_tokens if usage.prompt_tokens else 0:.2%}") return resp

这里的关键点是读取usage.prompt_tokens_details.cached_tokens。如果服务端没有返回这个字段,说明该模型未启用缓存,或者当前使用方式不在缓存范围内。

2.3 构造三个工具和两组请求

定义一组稳定的系统提示词,以及三个工具。第一次请求带三个工具,第二次请求只带两个工具,第三个请求不删除工具但修改一个工具的 description,用于对照。

SYSTEM_PROMPT = "你是订单助手,只能回答与订单相关的问题。" TOOLS = [ { "type": "function", "function": { "name": "query_order", "description": "查询订单状态", "parameters": { "type": "object", "properties": { "order_id": {"type": "string"} }, "required": ["order_id"] } } }, { "type": "function", "function": { "name": "cancel_order", "description": "取消订单", "parameters": { "type": "object", "properties": { "order_id": {"type": "string"}, "reason": {"type": "string"} }, "required": ["order_id", "reason"] } } }, { "type": "function", "function": { "name": "get_user_profile", "description": "获取用户资料", "parameters": { "type": "object", "properties": { "user_id": {"type": "string"} }, "required": ["user_id"] } } } ] MESSAGES = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "用户 1001 的订单 A123 现在是什么状态?"}, ]

先发送一次带三个工具的请求,建立缓存。再发送一次只剩两个工具的请求,观察缓存是否命中。最后再发一次修改了 description 的请求,判断“修改描述”和“删除工具”的影响差异。

# 第一次:三个工具,建立缓存 send_and_report("gpt-5.5", MESSAGES, TOOLS, "tools=3 first") send_and_report("gpt-5.2", MESSAGES, TOOLS, "tools=3 first") # 第二次:删除 get_user_profile tools_two = [t for t in TOOLS if t["function"]["name"] != "get_user_profile"] send_and_report("gpt-5.5", MESSAGES, tools_two, "tools=2 delete_one") send_and_report("gpt-5.2", MESSAGES, tools_two, "tools=2 delete_one") # 第三次:修改 query_order 的 description tools_modified = [] for t in TOOLS: if t["function"]["name"] == "query_order": t = json.loads(json.dumps(t)) t["function"]["description"] = "查询订单状态,返回最新物流信息" tools_modified.append(t) send_and_report("gpt-5.5", MESSAGES, tools_modified, "tools=3 modify_desc") send_and_report("gpt-5.2", MESSAGES, tools_modified, "tools=3 modify_desc")

注意第二次和第三次请求之间,还要保证 messages 前缀一致。如果服务端有缓存,第二次请求观察到的 cached_tokens 可以说明“工具删除”是否破坏了前缀;第三次请求说明“工具描述修改”是否同样破坏前缀。

2.4 结果记录表和初步结论

实验输出不会完全相同,但高度可能出现如下规律:

请求模型prompt_tokenscached_tokens缓存比例
三个工具,首次请求gpt-5.5约 18000%
三个工具,首次请求gpt-5.2约 18000%
两个工具,删除一个gpt-5.5约 15000%
两个工具,删除一个gpt-5.2约 150约 12080%
三个工具,修改描述gpt-5.5约 18000%
三个工具,修改描述gpt-5.2约 18000%

这里的数值用于示意。真正需要关注的是三列:

  • 删除工具后,gpt-5.5 的 cached_tokens 归零。
  • gpt-5.2 在删除工具后仍能保留大部分缓存命中,说明它的缓存 key 并不包含完整工具 schema,或它对工具段的编码方式允许部分复用。
  • 修改工具 description 后,两个模型都出现失效。

注意:单次实验结果不能直接当成“某版本就是这样”的结论,因为厂商可能随时调整缓存策略。实验的价值在于验证思路,而不是固化结论。

3. GPT-5.5 与 GPT-5.2 缓存行为差异的可能解释

3.1 缓存 key 的粒度差异

缓存命中依赖输入前缀一致,但“输入前缀”的定义可以在不同粒度上实现。

一种实现是:把完整输入序列按原始文本和工具定义做全量哈希,任何一个请求级字段变化都会导致哈希不匹配,整段缓存失效。gpt-5.5 的观察结果更接近这种实现。工具定义是请求级结构的一部分,少了一个工具,相当于整段输入签名变化。

另一种实现是:把输入分成几个缓存片段,例如“系统消息片段”“工具描述片段”“对话历史片段”。如果工具列表删除一个函数,服务端可以只让“工具描述片段”的缓存过期,而保留“系统消息片段”和“对话历史片段”的计算结果。gpt-5.2 的表现更接近这种分段缓存。

这里的关键不是哪边更高级,而是模型服务端如何判断缓存失效边界。不同模型版本可能采用不同的缓存策略,这是很常见的产品差异。

3.2 工具 schema 的规范化方式差异

服务端在编码工具时,不一定直接把原始 JSON 原样写入 token 序列。有些实现会先对工具做一次“规范化”,比如:

  • 提取函数名列表,作为工具选择器的索引。
  • 对每个函数生成一个固定 ID。
  • 只对参数 JSON Schema 做二次编码。

如果模型版本只把函数名列表放进前缀,而把完整 schema 放到更靠后的位置,那么删除一个工具时,只有函数名列表段发生变化,后面的对话历史缓存仍然可用。gpt-5.2 的现象可能就是这样:它把最能代表工具的“名称”和“调用方式”分开处理,导致删除一个函数后,主体对话前缀仍然保留。

gpt-5.5 如果采用更严格的完整 schema 编码,哪怕工具 descriptions 中只有一个字段变化,也会导致前缀失效。这也是为什么修改 description 后,两个模型都失效,但删除工具后只有 gpt-5.5 失效。

3.3 模型对“旧工具上下文”的安全策略差异

还有一个不能忽略的维度:缓存失效不一定是性能问题,可能是安全策略。

较新的模型版本可能更关心上下文一致性。如果模型在生成时,KV Cache 中保留了旧工具定义,但新请求中工具列表已经改变,模型可能基于旧工具信息产生幻觉。例如,缓存中的注意力权重已经记住了 get_user_profile 的调用方式,但当前请求里这个工具不存在,模型仍可能尝试调用它。

为了避免这种错乱,较新的模型版本会倾向于在 tools 变化时丢弃更多缓存。也就是说,删除一个 tool 导致 gpt-5.5 缓存失效,可能是服务端刻意选择的高保证策略,而不是单纯没做好缓存。

3.4 部分命中与完全命中的混淆

排查时还要区分“部分命中”和“完全命中”。gpt-5.2 的 cached_tokens 大于 0,不代表整段 prompt 都命中了缓存,只代表从开头开始有一部分 token 命中了。如果删除一个工具发生在工具列表中间,函数名列表变化会导致后面全部失效,但系统消息前面仍然可能命中。

所以比较两个模型时,不仅要看 cached_tokens 是否为 0,还要看缓存比例。一个较好的衡量指标是:

cache_ratio = cached_tokens / prompt_tokens

gpt-5.2 删除一个工具后如果有 80% 命中,说明前缀仍然较长;如果只有 10% 命中,说明它也受到了明显影响,只是没有完全归零。

4. 让 prompt cache 更稳的请求设计方法

4.1 固定工具顺序,不要在每次请求时动态生成列表

工具列表顺序不稳定的最常见原因,是把 tools 构建在 Python 字典或 JavaScript 对象中,再依赖语言默认遍历顺序。不同进程、不同并发请求下,顺序可能随机变化。

即使工具内容完全一致,只要顺序不同,token 前缀就会变换,缓存照样失效。推荐做法是显式排序:

def build_tools(tool_defs: list[dict]) -> list[dict]: return sorted(tool_defs, key=lambda t: t["function"]["name"])

排序后,每个工具的相对位置是确定的。再配合固定 JSON 序列化参数,能减少大量无意义的缓存失效。

4.2 把不变量放在 prompt 最前面

前缀缓存命中要求从头开始一致,所以越早的位置越不能放动态内容。

推荐顺序:

  1. 固定的系统指令。
  2. 固定工具使用说明。
  3. 当前用户身份或会话级固定信息。
  4. 工具定义清单。
  5. 历史对话。
  6. 当前用户输入。
  7. 临时动态内容,如时间、随机 ID、临时上下文。

这里要特别强调:不要把当前时间、日期、随机字符串拼到 system prompt 开头。很多项目把“今天是 2026-05-12,星期几”放在系统提示词最前面,结果每过一分钟,所有缓存的 prefix 都会失效。

推荐做法:动态时间如果需要,可以放在用户消息末尾,或放在专门的上下文字段中,不要放在 system 开头。

4.3 动态内容后置

prompt cache 的价值是复用“已计算过的主干部分”。如果动态内容只出现在对话末尾,那么整段系统指令、工具定义、历史消息都能命中,只有最后一小段需要重新计算。

以订单助手为例:

base_messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "订单 A123 状态是什么?"}, ] now = datetime.now().isoformat() current_messages = base_messages + [ {"role": "user", "content": f"当前查询时间:{now}"} ]

这样每次请求只有最后一条消息变化,前缀缓存能尽量保留。实际项目中要把真正稳定的业务数据放在历史位置,动态查询参数放在最后。

4.4 工具 schema 的缩减与摘要

如果 tools 非常庞大,一个函数定义包含几十个字段、几百行 JSON Schema,缓存失效成本会非常高。此时可以考虑:

  • 删除 description 中的冗余修辞,只保留调用必须信息。
  • 把多次不变的工具定义提前到稳定的系统上下文,避免在每次对话中重复携带。
  • 如果服务端允许,使用模型厂商提供的工具缓存或统一工具库,而不是每次请求都传全量 schema。
  • 对同一批工具做版本化管理,不轻易改 description 和 parameter 结构。

但不能为了缓存稳定而牺牲工具语义。工具 description 仍然是模型决定是否调用该函数的重要依据,不能过度删减到模型无法理解。

4.5 缓存友好检查清单

发布前可以按这个清单过一遍:

检查项要求
tools 数组顺序按函数名固定排序
tools 数组内容同一版本内保持不变
JSON 序列化参数固定缩进、ensure_ascii、sort_keys
system prompt 开头不能有动态时间、随机数、用户级变量
用户消息结尾动态内容尽量放在这里
tool_choice在同一功能分支内不要随机切换
response_format稳定使用同一格式配置
缓存字段监控每次请求记录 cached_tokens 和 cache_ratio

5. 缓存失效的排查链路与常见误区

5.1 从 usage 字段定位缓存状态

排查缓存失效时,第一步不是看日志里的业务报错,而是看请求返回的 usage。以 OpenAI 兼容接口常见结构为例:

{ "prompt_tokens": 150, "completion_tokens": 30, "total_tokens": 180, "prompt_tokens_details": { "cached_tokens": 0 } }

如果 cached_tokens 为 0,说明本次请求前缀没有命中缓存。需要进一步对比“预期缓存的请求”和“实际发送的请求”在编码后的 token 序列。

注意:有些 SDK 不暴露这个字段,但原始响应 JSON 里可能有。建议在 debug 模式打印完整响应体。

resp = client.chat.completions.create( model=model, messages=messages, tools=tools, ) print(resp.model_dump_json(indent=2))

5.2 缓存失效排查链路

从现象倒推原因,建议按以下顺序排查:

排查项检查方式处理建议
messages 是否一致对比两次请求的 token 化结果把 messages 转成 JSON 后 diff
tools 顺序是否变化打印函数名列表固定排序
tools 内容是否变化diff JSON Schema工具变更视为缓存冷启动
tool_choice 是否变化对比两次请求的 tool_choice保持业务分支内一致
system prompt 是否有动态内容检查时间、ID、随机数移到末尾
模型是否相同确认 model 字段切换模型等于清空缓存
是否有多版本缓存策略查看服务端返回的 Cache-Control 或 usage 详情以文档为准
是否真的存在长前缀prompt 过短也可能不缓存确认前缀长度超过阈值

5.3 三个和 tool calling 强相关的常见坑

第一个坑:只检查 messages,不检查 tools。很多项目在记录日志时只记录 messages,一旦缓存失效,对比日志发现 messages 完全一样,就怀疑是服务端问题。实际上 tools 变化更隐蔽。建议在日志中记录 tools 的哈希值。

import hashlib, json def tools_hash(tools) -> str: raw = json.dumps(tools, sort_keys=True, ensure_ascii=False) return hashlib.sha256(raw.encode("utf-8")).hexdigest()

第二个坑:修改工具描述后没有意识到这是 breaking change。删除一个工具是明显的结构变化,但修改一个 description 里的小词也足以让缓存失效。生产环境里要像管理接口版本一样管理工具定义,不随意修改。

第三个坑:把动态内容放在工具定义中。例如每个工具 description 里拼接当前用户 ID、租户 ID:

"description": f"查询当前用户 {user_id} 的订单"

这种情况下,不同用户的请求都会生成不同的工具 schema,缓存命中率趋近于零。正确做法是动态 userId 放到用户消息里,工具保持静态。

5.4 一个经常一起出现的 tool_calls 错误

与 prompt cache 无关但常在同一次 Agent 调用里爆出的,是下面这个错误:

An assistant message with 'tool_calls' must be followed by tool messages

这个错误的触发路径是:模型返回了一段带tool_calls的 assistant 消息,但应用层没有把工具执行结果以role: "tool"的消息追加回去,就继续发送下一轮 assistant 消息。它和缓存没有直接关系,但会让开发者误以为是请求结构问题破坏了缓存。

处理方式是在多轮循环里严格维护消息顺序:

user -> assistant(tool_calls) -> tool(每个 tool_call_id 对应一个 tool 消息) -> assistant(最终结果)

每个tool消息要有对应的tool_call_id,并且所有工具结果都要返回,不能只返回一部分。顺序错了会直接报错,而不是缓存问题。

6. 生产环境的工程建议与下一步

6.1 不要依赖“某个模型版本不会失效”

从这次观察看,gpt-5.2 对删除工具不那么敏感,但这不构成长期依赖它的理由。模型服务端的缓存策略可能随版本、灰度比例、会话数量变化。今天缓存命中的请求,明天可能因为服务端策略调整而失效。

更稳妥的思路是:

  • 不假设 tools 变化不破坏缓存。
  • 把缓存当作性能优化,不当作功能正确性依赖。
  • 在每次模型版本升级后,重新跑一遍缓存对比实验。

6.2 监控缓存命中率与工具变更

生产环境要建立两个指标:

  • cache_ratio:cached_tokens / prompt_tokens,衡量单请求前缀命中比例。
  • cache_hit_rate:一段时间内 cached_tokens > 0 的请求占比。

当 cache_hit_rate 突降时,优先检查最近是否发布了新的工具定义、改了 system prompt 或切换了模型版本。建议在日志里记录 tools_hash,并在监控大盘上建立“工具版本变更”事件标记。

6.3 工具变更发布策略

工具定义变动不可避免,但可以控制它造成的缓存冷启动成本。

  • 把工具变更放在低峰期发布。
  • 变更前后保留一段时间的兼容版本,不要一次性替换所有请求中的工具列表。
  • 在灰度阶段观察 cache_hit_rate,如果下降明显,需要评估延迟和成本增幅是否可接受。
  • 对超长 prompt 应用,慎用“描述细节丰富”的工具定义,因为每次变更的失效面积更大。

实际项目里,一个 Agent 可能同时管理几十个工具,删除其中一个看似影响很小,但其实会推高整批请求的成本。对高频请求,把工具列表拆分到不同“场景配置”中,避免所有 Agent 共用一个包含全量工具的大列表,能让缓存边界更清晰。

6.4 扩展:subagent 作为 tool 调用时的缓存影响

在多 Agent 架构里,主从模式常见的一种设计,是把 subagent 当作一个特殊 tool 暴露给主 Agent。此时 subagent 的职责提示、允许调用的工具列表都会被编码进 tool 定义。

这带来一个新问题:subagent 内部工具变化,是否会导致主 Agent 的工具 schema 变化?如果 subagent 是一个工具描述,那么它自身的工具列表变了,subagent 工具描述也可能变化,进而触发主 Agent 的 prompt cache 失效。

从缓存优化角度看,subagent 工具描述应该尽量简短和稳定。不要让 subagent 的完整工具列表拼到它的 description 里。描述只需要写“该子代理负责解决退货问题”,具体内部工具由子代理自己加载。这样主 Agent 的缓存就不会被子代理内部工具变化破坏。

生产经验:工具定义的稳定性和结构扁平化,对缓存的影响往往比模型本身是否支持缓存更大。设计阶段就把工具当成接口契约管理,能减少大量后续性能问题。

下次遇到请求变慢、费用升高时,可以先检查一下最近是不是增删过 tool 定义。这一条经验在 gpt-5.5、gpt-5.2 以及未来其他模型版本上仍然适用:prompt cache 命中与否,不只取决于 messages,也取决于 tools 和整个请求前缀是否稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询