1. 为什么 Agent 多轮调用里 Prompt Cache 是架构问题
做 Agent 的人大多经历过这个阶段:第一轮对话又快又便宜,跑到第二十轮、第三十轮,账单开始失控,首字延迟从几百毫秒涨到好几秒。你回头查代码,发现 prompt 措辞没变、模型没换、工具也没加,但每一轮都在重新处理几十万 token 的上下文。
这不是模型的问题,是上下文组织方式的问题。Prompt Cache(也叫 Prompt Caching、Prefix Cache)的核心机制很朴素:如果两次请求从开头起有一大段完全相同的内容,模型可以复用上一次已经算好的前缀状态,只处理后面新增的部分。反过来,只要前缀里有一个字节变了,整段缓存就作废,模型得从头算一遍。
把它类比成数据库索引就很好理解。你不会每次查询都全表扫描,而是让高频访问的路径走索引。Prompt Cache 就是 Agent 请求链路上的索引:稳定不变的部分走缓存,变化的部分才真正计算。区别在于,数据库索引建错了顶多慢一点,Prompt Cache 用错了是每一轮都在付全价。
长程 Agent 的请求结构天然适合缓存。一次编码任务里,系统规则、工具定义、项目说明、历史对话这些内容跨轮次高度稳定,真正每轮变化的往往只有最新一条用户消息、一个工具返回结果、一段报错。如果把这些稳定内容放在前面、变化内容沉到后面,命中率可以做到很高。但很多系统的实际布局恰好相反——时间戳、请求 ID、随机排序的工具列表被塞在 system prompt 顶部,等于主动把缓存打碎。
我试过在一个多轮工具调用场景里对比:把动态时间戳从 system 顶部挪到消息尾部后,同样的任务链路,缓存命中 token 占比从个位数涨到七成以上,首字延迟明显下降。改动只有几行,收益却比换模型更直接。
所以这篇不讲怎么调 prompt 措辞,而是把 Prompt Cache 当成系统架构来设计:上下文怎么分层、前缀怎么稳定化、缓存失效边界在哪、命中率怎么观测。最后会在 TaoToken 统一 Key 通道下跑一次真实的命中与成本对比,让你看到数字变化。
适合谁看:正在做多轮 Agent、编码 Agent、RAG 问答,或者任何上下文会持续膨胀的 LLM 应用的开发者。如果你还在单轮问答阶段,这篇的收益暂时不明显;但只要你的请求开始带历史消息,下面每一节都能直接用。
2. TaoToken 统一 Key 与 Prompt Cache 观测前置准备
要验证缓存命中,前提是你能稳定地发出请求、并且能读到返回里的缓存相关字段。TaoToken 在这里的价值是提供一个统一的 API 通道和 Key 管理,让你不用为每个模型单独维护一套鉴权和计费逻辑,切换模型时 Base URL 和 Key 保持不变,只改 Model ID。
先说清楚它是什么:TaoToken 是一个大模型 API 聚合与统一接入平台,提供兼容主流协议的统一入口,你用一个 Key 就能调用不同厂商的模型。对做 Agent 的人来说,这意味着你的缓存观测代码不用为每个供应商写一套适配,请求结构、usage 字段解析可以复用。
前置准备分三步。
第一步,拿到 API Key。访问控制台创建:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite在 API Keys 页面生成一个 Key,复制保存。这个 Key 后面会同时用于对话请求和缓存观测,不要写死在代码里,用环境变量。
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite第二步,确认 Base URL。TaoToken 的 API 入口是:
https://taotoken.net/api注意这个地址不带任何查询参数,直接作为 OpenAI 兼容协议的 base_url 使用。如果你用的是 Anthropic 协议风格的客户端,接入文档里有对应的路径说明:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite第三步,选一个支持 Prompt Cache 的模型。不同厂商对缓存的暴露方式不一样:有的在请求里显式标记缓存断点,有的默认开启、只在返回的 usage 里体现命中量。你要做的第一件事是确认目标模型返回里有没有缓存相关字段,比如prompt_cache_hit_tokens和prompt_cache_miss_tokens。如果没有,说明这个模型或这条通道不暴露缓存观测,换一个再试。
环境变量这样设,后面所有脚本都复用:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"验证 Key 是否可用,先发一个最小请求:
curl -s "$TAOTOKEN_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "你的模型ID", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 16 }'返回里能看到choices和usage就说明通道通了。如果返回 401,先检查 Key 有没有多余空格、有没有带Bearer前缀。这一步过了,再进入缓存结构设计。
这里要强调一个前置认知:缓存命中不是自动发生的,它依赖你的请求前缀逐字节一致。所以下一节的配置不是"连上就能用",而是"连上之后必须按这个结构发请求,缓存才会生效"。
3. 可复制的缓存友好请求结构与配置
这一节是全文的核心。缓存能不能命中,取决于你的请求体长什么样。下面给出一套可以直接复制的结构,从上下文分层到具体 JSON,再到客户端配置。
先讲分层原则。把一次请求的内容按"变化频率"从低到高排列:
第一层,系统规则与工具定义。跨所有轮次完全不变,放最前面。工具定义的顺序必须固定,不能每轮随机序列化。
第二层,项目级上下文。比如项目说明、代码规范、CLAUDE.md 这类内容。同一个项目内稳定,跨项目才变。
第三层,历史对话。append-only,只追加不修改。已经发生过的消息不要回头编辑。
第四层,当前轮变量。最新用户消息、工具返回结果、临时状态、报错信息。全部沉到最后。
关键约束:前三层一旦确定,后续轮次只能往后追加,不能改动前面任何字节。这就是"append-only log"的含义。
下面是一个缓存友好的请求体示例。注意 system 部分完全静态,动态信息全部放在最后的 user 消息里:
{ "model": "你的模型ID", "messages": [ { "role": "system", "content": "你是一个编码助手。以下是固定工具定义与项目规则,顺序不可变。\n[TOOL_DEFINITIONS_FIXED]\n[PROJECT_RULES_FIXED]" }, { "role": "user", "content": "第一轮任务:读取 foo.py 并解释其结构。" }, { "role": "assistant", "content": "已读取 foo.py,结构如下……" }, { "role": "user", "content": "<system-reminder>当前时间 2026-05-08T09:41+08:00,处于只读模式。</system-reminder>\n第二轮任务:找出其中的性能问题。" } ], "max_tokens": 1024 }注意动态的时间戳和模式状态被包在<system-reminder>标签里,放在最后一条 user 消息中,而不是塞进 system。这样 system 前缀保持逐字节稳定,缓存可以持续命中。
如果你用的是 Anthropic 协议风格、需要显式标记缓存断点,配置片段长这样。把cache_control加在稳定前缀的末尾:
{ "model": "你的模型ID", "system": [ { "type": "text", "text": "固定系统规则与工具定义……", "cache_control": {"type": "ephemeral"} } ], "messages": [ {"role": "user", "content": "当前轮任务……"} ] }cache_control标记的位置就是缓存边界:它之前的内容会被缓存,之后的内容每轮重新计算。所以断点要打在稳定内容的末尾,不要打在动态内容后面。
如果你用 Cline、Claude Code 这类客户端,配置通常落在 settings 文件里。以 OpenAI 兼容配置为例,Base URL、Key、Model ID 三件套必须齐全:
{ "apiProvider": "openai-compatible", "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的key", "modelId": "你的模型ID", "contextStrategy": "append-only" }三个字段缺一不可:Base URL 指向 TaoToken 的 API 入口,apiKey 用你在控制台生成的 Key,modelId 填你要调用的模型。少任何一个都会连接失败。
工具定义这块单独说。如果你有多个工具,序列化时一定要保证顺序稳定。不要用无序的 map 直接 dump,先按工具名排序再序列化:
import json tools = [ {"name": "read_file", "description": "...", "parameters": {...}}, {"name": "write_file", "description": "...", "parameters": {...}}, ] # 按 name 排序,保证每轮序列化结果一致 tools_sorted = sorted(tools, key=lambda t: t["name"]) tools_json = json.dumps(tools_sorted, ensure_ascii=False, sort_keys=True)sort_keys=True保证字典内部字段顺序也稳定。这一步看起来琐碎,但工具 schema 抖动是隐性成本的大头。
最后是模式切换的处理。不要通过增删工具来切换 Plan Mode,而是保留完整工具集,用消息表达状态:
{ "role": "user", "content": "<system-reminder>进入计划模式,只能分析,不能执行写操作。</system-reminder>" }这样工具定义前缀不变,缓存链不断。系统行为变了,但上层结构稳定。
4. 验证缓存命中与成本对比的实测请求
配置写好了,接下来要证明它真的生效。这一节给出完整的观测脚本,跑两次请求,对比命中与未命中的差异。
核心思路:第一次请求是冷启动,缓存未命中,全部 token 走 miss;第二次请求复用完全相同的前缀,只追加一条新消息,理论上大部分 token 走 hit。对比两次返回的 usage 字段即可。
先写一个 Python 脚本,构造一个足够长的稳定前缀,然后连续发两次请求:
import os import time import requests API_KEY = os.environ["TAOTOKEN_API_KEY"] BASE_URL = os.environ["TAOTOKEN_BASE_URL"] MODEL = "你的模型ID" # 构造一段足够长的稳定前缀,模拟真实 Agent 的 system + 历史 stable_prefix = "你是一个编码助手。\n" + ("固定项目规则与工具定义。" * 500) def build_messages(extra_user_msg): return [ {"role": "system", "content": stable_prefix}, {"role": "user", "content": "第一轮:分析项目结构。"}, {"role": "assistant", "content": "已完成分析。"}, {"role": "user", "content": extra_user_msg}, ] def call(messages): resp = requests.post( f"{BASE_URL}/v1/chat/completions", headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", }, json={ "model": MODEL, "messages": messages, "max_tokens": 64, }, timeout=60, ) resp.raise_for_status() return resp.json() # 第一次:冷启动 r1 = call(build_messages("第二轮:找出性能问题。")) print("第一次 usage:", r1.get("usage")) time.sleep(2) # 第二次:前缀完全相同,只追加新消息 r2 = call(build_messages("第三轮:给出优化建议。")) print("第二次 usage:", r2.get("usage"))跑完之后重点看两次返回的 usage。如果模型暴露缓存字段,你会看到类似这样的结构:
{ "prompt_tokens": 8200, "completion_tokens": 48, "prompt_cache_hit_tokens": 7800, "prompt_cache_miss_tokens": 400 }第一次请求里prompt_cache_hit_tokens通常是 0 或很小,prompt_cache_miss_tokens接近全部 prompt_tokens。第二次请求里,命中量应该大幅上升,miss 量只剩新增的那部分。
如果两次的命中量都是 0,说明前缀没有真正稳定。常见原因:system 里混进了动态内容、工具顺序变了、或者两次请求之间前缀有细微差异。回到上一节逐项检查。
再做一个对照实验,故意破坏前缀,看命中率怎么掉。把时间戳塞进 system 顶部:
import datetime def build_messages_broken(extra_user_msg): ts = datetime.datetime.now().isoformat() return [ {"role": "system", "content": f"当前时间 {ts}\n" + stable_prefix}, {"role": "user", "content": "第一轮:分析项目结构。"}, {"role": "assistant", "content": "已完成分析。"}, {"role": "user", "content": extra_user_msg}, ]用这个函数再发两次请求,你会发现命中量几乎归零。这就是"cache killer"的直观效果:一个每轮都变的时间戳,放在最前面,等于每轮都重建缓存。
成本对比可以直接从 usage 算。假设命中 token 的单价是 miss token 的十分之一(不同厂商比例不同,以实际计费为准),那么:
| 场景 | miss tokens | hit tokens | 相对成本 |
|---|---|---|---|
| 前缀稳定 | 400 | 7800 | 基准 |
| 前缀被时间戳破坏 | 8200 | 0 | 约 10 倍 |
这个表格不是精确计费,而是让你看到量级差异。前缀稳定与否,成本可能差一个数量级。延迟上,命中缓存的请求首字时间通常也明显更短,因为模型跳过了大段前缀的预填充。
跑通这个脚本,你就有了一个可复用的缓存观测基线。后面每次改上下文结构,都用它回归一次,命中率掉了立刻能发现。
5. 缓存命中率上不去的常见报错与排查
缓存不命中很少报错,它只是安静地让你多付钱。但有些错误会直接暴露问题,下面按真实报错逐条排查。
401 Unauthorized。返回体里通常是invalid api key或authentication failed。先确认 Key 没有多余空格,请求头是Authorization: Bearer sk-xxx格式。如果你把 Key 写进了配置文件,检查有没有被引号或换行污染。TaoToken 的 Key 在控制台生成后立即复制,页面刷新后不再完整显示。
local proxy failed / connection refused。这类报错说明请求根本没到服务端。检查 Base URL 是不是写成了https://taotoken.net/api/带了多余斜杠,或者客户端把 base_url 和完整路径拼错了。OpenAI 兼容客户端通常要求 base_url 到/api为止,路径/v1/chat/completions由客户端自己拼。如果你手动拼了完整 URL,就会变成/api/v1/chat/completions/v1/chat/completions。
reading 'choices' of undefined。这是解析返回时choices字段不存在。常见原因是请求体格式不对,服务端返回了错误对象而不是正常响应。打印完整返回体看error字段。另一个原因是模型 ID 写错,服务端返回了模型不存在的错误。确认 modelId 和你在控制台看到的模型标识完全一致。
OAuth / token expired。如果你用的是 Claude Code 这类带 OAuth 流程的客户端,报 OAuth 相关错误说明鉴权方式选错了。用 API Key 接入时,要在配置里明确选择 API Key 模式,而不是 OAuth 登录模式。Base URL、Key、Model ID 三件套要同时配好,缺一个都会走到错误的鉴权分支。
命中率始终为 0,但请求成功。这是最隐蔽的一类。请求能返回、回答也正确,就是缓存不命中。排查顺序:
先看 system 里有没有动态内容。时间戳、请求 ID、随机数、当前用户 ID,任何一个每轮变化的东西放在前缀里,都会让缓存失效。把它们挪到最后的 user 消息,用<system-reminder>包起来。
再看工具定义顺序。打印两轮请求的 tools 序列化结果,逐字节对比。如果顺序不同,用sorted加sort_keys=True固定下来。
然后看历史消息有没有被修改。append-only 的意思是只追加,不回头编辑。如果你在每轮开头重新构造整个 messages 数组、并且对历史消息做了任何格式化处理,前缀就可能变。保持历史消息原样传递。
最后看模型有没有换。缓存通常和模型绑定,中途切模型会重建缓存。如果你的 Agent 会根据任务难度动态选模型,主会话尽量固定一个模型,需要切换时新开一条链路。
命中量有,但比预期低很多。说明前缀只有一部分稳定。检查缓存断点打在哪:如果断点打在动态内容之后,那断点之前的内容才被缓存,之后每轮重算。把断点往前挪到稳定内容的末尾。另外确认前缀长度是否达到模型的最小缓存阈值,太短的前缀有些平台不缓存。
排查时建议把每轮请求的 usage 打日志,按时间序列看命中率曲线。命中率突然掉下去的那个时间点,往往对应某次代码改动。把缓存命中率当成一个正式监控指标,而不是调试时才看一眼的东西。
6. 把缓存命中率纳入 Agent 监控与后续接入
缓存命中率低不该只是"最近有点贵"的模糊感受,它应该是一个会触发告警的架构指标。在长程 Agent 里,命中率下滑往往意味着上下文结构退化了,而结构退化会同时推高成本和延迟。
至少要盯这几个指标:prompt_cache_hit_tokens占比,判断长前缀有没有被真正复用;prompt_cache_miss_tokens的绝对值,定位哪些请求在反复重算;首字延迟的冷热分布,命中缓存的请求通常明显更快;前缀长度变化,观察有没有动态内容混进了上层;模型切换前后的命中率对比,判断省下的小模型单价有没有被缓存重建成本吃掉;工具定义变更次数,schema 抖动是隐性成本的大头。
把这些指标接到你的日志或监控面板里,按会话维度聚合。一个健康的编码 Agent 会话,随着轮次增加,命中率应该稳定在高位,miss 量只对应每轮真正新增的内容。如果命中率随轮次下降,说明前缀在被逐步污染。
落地顺序建议这样:先把 system 和工具定义固定下来,确保逐字节稳定;再把动态状态全部下沉到消息尾部;然后用第 4 节的脚本建立基线;最后把 usage 打点接入监控。每一步都能独立验证,不用一次性重构。
如果你要长期跑编码 Agent 或工具调用密集的 Agent,可以考虑用 Coding Plan 这类面向持续编码场景的通道,配合统一 Key 管理,把模型切换和计费收敛到一处:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite需要快速验证某个模型的缓存字段长什么样,可以直接在模型对话里发两轮相同前缀的请求,对比返回:
https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite接入细节和协议差异查文档:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite最后回到那句原则:把 Agent 的上下文当成 append-only log 来设计,不要把 prompt 当成每轮都能随手重写的模板。缓存命中率是这句话最直接的量化反馈。你不需要记住所有细节,只要在每次改动上下文结构后跑一遍第 4 节的脚本,看命中量有没有掉。掉了就回滚,稳了就继续。这个习惯坚持下来,长程 Agent 的成本曲线会明显平缓。