开发者在接入 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_tokens | cached_tokens | 缓存比例 |
|---|---|---|---|---|
| 三个工具,首次请求 | gpt-5.5 | 约 180 | 0 | 0% |
| 三个工具,首次请求 | gpt-5.2 | 约 180 | 0 | 0% |
| 两个工具,删除一个 | gpt-5.5 | 约 150 | 0 | 0% |
| 两个工具,删除一个 | gpt-5.2 | 约 150 | 约 120 | 80% |
| 三个工具,修改描述 | gpt-5.5 | 约 180 | 0 | 0% |
| 三个工具,修改描述 | gpt-5.2 | 约 180 | 0 | 0% |
这里的数值用于示意。真正需要关注的是三列:
- 删除工具后,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_tokensgpt-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 最前面
前缀缓存命中要求从头开始一致,所以越早的位置越不能放动态内容。
推荐顺序:
- 固定的系统指令。
- 固定工具使用说明。
- 当前用户身份或会话级固定信息。
- 工具定义清单。
- 历史对话。
- 当前用户输入。
- 临时动态内容,如时间、随机 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 和整个请求前缀是否稳定。