
线上客服 Agent 每天处理十万次对话一直好好的。某天工程师加了一行代码让 Agent「知道」当前时间——在系统提示词里插一句Current time: {{now}}。第二天告警所有对话的首 token 延迟从 0.5 秒涨到 3 到 5 秒月度推理账单几乎翻倍。模型没换逻辑没改只多了一行。这不是玄学。这一行代码破坏的是整个 Agent 体系里最贵也最脆弱的东西——上下文前缀的缓存复用。问题的本质是大模型 API 是无状态的Agent 框架每一轮都要重新拼装一遍完整的上下文送过去。而推理引擎为了省算力会缓存重复前缀的计算结果。前缀一旦改一个字节缓存立刻全废那部分 token 要从头算。下面从真实的消融实验源码出发把「哪些写法会毁掉缓存」这件事讲清楚。看完你会知道那些看起来无伤大雅的写法代价具体有多大。一、上下文的结构静态前缀加轨迹先把结构钉死后面所有结论都从这里推出来。Agent 每次调用模型送出去的是一个消息列表加一个工具定义数组。消息列表按角色分四种角色装什么多轮中的变化system系统提示词——身份、规则、约束整个对话通常只有一条放最前user用户输入每轮追加assistant模型之前的回复含文本与工具调用请求每轮追加tool工具执行结果靠tool_call_id与调用请求配对每轮追加工具定义不在这四种角色里它是请求顶层的独立字段tools。于是整个上下文可以切成两段这个切法不是人为分类它直接对应 API 请求的物理布局静态前缀永远在最前面轨迹跟在后面。「前面不能动后面可以压缩」——这一章后面的所有技术包括 KV Cache 优化、状态栏、压缩策略全部建立在这一条上。二、KV Cache 和 Prompt Cache 是两个层级这两个词经常被混用但它们的层级完全不同。模型每生成一个 token都要回头用前文所有 token 的中间计算结果Key 和 Value 向量。如果每轮从头算一遍开销随上下文长度平方级增长。KV Cache的做法是把这些中间结果存下来下一轮只算新增的部分。而Prompt Cache是推理引擎在多次 API 请求之间的优化——把相同前缀的计算结果缓存起来跨请求复用。KV CachePrompt Cache归属模型内部机制推理引擎优化作用范围单次请求内的 token 生成跨多次 API 请求优化对象避免重复计算前文避免重复计算相同前缀共同前提前缀不变前缀不变计费不涉及缓存读取约为首次计算的十分之一两层的优化原理是同一个吃前缀的不变性。所以真正的约束只有一句话若 token 序列从某个位置开始不同首个不同 token 及其后的 KV 状态需要重新计算此前位置的 KV 状态不受这次改动影响。「不受影响」是关键。它意味着缓存失效不是全废而是从改动点往后废。这直接解释了后面压缩策略为什么可以只压轨迹、不动前缀。三、源码级拆解五种毁掉缓存的写法书里配了一个消融实验用同一个任务跑六种上下文构造策略。我把它的核心代码贴出来——这段代码本质上是一份「真实世界坏做法清单」每一种都对应线上系统里常见的写法。写法一往系统提示词里塞时间戳注意时间格式里的%f——精确到微秒。这意味着什么同一个会话里连续两次调用时间戳必然不同。而时间戳加在系统提示词的末尾也就是静态前缀的最后一个字节上。结果是整个静态前缀从时间戳那个位置起全部失效每一轮都要重新计算全部前缀的 KV。这就是开头那个「首 token 延迟从 0.5 秒涨到 3 到 5 秒」的故事一模一样。为什么这个写法特别常见因为需求听起来完全合理——「Agent 应该知道现在几点」。问题不在需求在于实现位置。修复方式和错误写法对照如下差别不在「有没有把时间送到模型面前」——两者都送到了。差别在于变化发生的位置落在末尾前面的缓存全部复用落在开头后面的全部重算。写法二打乱工具定义顺序random.shuffle把工具定义数组的顺序随机打乱。这里有个反直觉的点工具集完全没变功能一模一样只是顺序变了。但缓存照样全废。原因是tools是静态前缀的一部分它参与 token 序列的构成。顺序一变token 序列在tools字段的开头就分叉了——后面哪怕内容完全相同位置全变了KV 状态必须重算。实测数据印证了这一点打乱工具顺序后缓存命中率从 10.1% 掉到 3.4%仓库预置的六策略实测。这条对生产系统的意义任何「动态增删工具」的设计都要小心。如果每轮根据用户意图挑选不同的工具子集哪怕每个工具的定义都正确前缀也已经不稳定了。书里的建议是——工具增删用专门的消息块声明而不是去改tools数组。写法三在系统提示词后面插一条变化的用户消息这条消息插在 system 之后、轨迹之前内容是「剩余额度 N 点」——而 N 每轮减一。从缓存视角看它和写法一等价在静态前缀的紧后位置放了一个必然变化的值。前缀在这里分叉后面全部重算。这个写法的迷惑性在于它看起来是「用户自己的信息」放进 messages 很自然。但只要它在静态前缀和轨迹之间它就是前缀的一部分就会破坏稳定性。写法四滑动窗口滑动窗口保留最近 6 条消息丢弃更早的。它破坏缓存的方式和前三种不同不是前缀内容变了而是整个轨迹相对位置前移了。原来的第 7 条现在变成了第 1 条token 序列的上游全变。但这段代码里藏着一个更值钱的细节值得单独说——为什么要有那个while循环上下文里的工具结果消息role: tool必须和发起这次调用的 assistant 消息成对出现靠tool_call_id关联。如果直接切片[-6:]很可能切在一条工具结果上把它配对的 assistant 消息切掉了。这时候送出去的历史里有一条「无主的工具结果」provider 会直接拒绝整个请求。所以代码要把窗口起点往前回退直到退到那条 assistant 消息为止。这个细节说明一件事上下文不是一堆可以随便增删的文本它是有结构约束的数据。任何裁剪操作首先要保证结构完整其次才谈性能。实测显示滑动窗口策略下 Agent 会反复执行相同的工具调用——因为它丢掉了「我已经做过什么」的记录模型只能从头再来一遍。这就是长短两种诉求的正面冲突保留历史能保住记忆但会膨胀裁剪历史能控长度但会失忆且毁缓存。写法五把结构化历史重排成纯文本它把全部历史压成一段纯文本塞进一条 user 消息。这个写法浪费的地方在于它丢掉了 API 的结构化协议。原本tool_call_id这类字段是机器精确解析的重排成ASSISTANT: [Making tool calls]这种自然语言字符串之后模型只能靠语义去猜。实测代价TTFT 从 2.328 秒涨到 6.189 秒总耗时从 18 秒涨到 43 秒比稳定前缀慢一倍多。为什么每次重新格式化都会毁缓存因为生成这段文本的循环遍历历史而历史每轮都在增长——于是整段文本的长度和内容每轮都变前缀无时无刻不在分叉。五种写法的共性写法破坏方式本质系统提示词加时间戳前缀末尾内容变化可变值放进了静态前缀打乱工具顺序前缀中段顺序变化内容不变但位置变了插入变化的用户画像前缀紧后内容变化伪装成「用户信息」的前缀滑动窗口轨迹整体位移裁剪改变了上游 token 位置重排成纯文本前缀结构变化丢弃协议结构且每轮重算五种写法同一个根因改变了 token 序列的某个位置导致该位置之后的 KV 状态全部作废。四、实测六种策略的缓存命中率差多少上面是原理下面是数据。仓库预置了六种策略在同一任务下的完整实测结果用--report可以离线复现不消耗任何 token策略迭代数首轮 TTFT平均 TTFTPrompt TokensCached缓存占比成本节省稳定前缀正确做法32.328s6.054s7,56776810.1%9.1%动态系统提示32.497s8.085s7,63976810.1%9.0%打乱工具顺序37.818s11.122s7,5682563.4%3.0%动态用户画像32.206s5.986s7,65276810.0%9.0%滑动窗口52.234s3.649s2,2241,51067.9%61.1%重排纯文本36.189s14.432s7,4306749.1%8.2%这张表里有两个陷阱不说清楚就会读反。陷阱一滑动窗口的 67.9% 不是优点。它缓存占比最高、成本最省。但它的 Prompt Tokens 只有 2,224是其他策略的三分之一。原因不是缓存效率高而是它把绝大部分历史丢了——分子不变、分母骤减比例自然好看。这是用信息换缓存代价是 Agent 失忆它会反复执行同一个工具调用因为它不记得自己已经做过了。陷阱二稳定前缀和动态系统提示缓存占比都是 10.1%。按前面的原理加微秒时间戳应该让缓存归零。为什么这里没差别因为这次运行只迭代了 3 轮。第 1 轮没有前缀可缓存收益从第 2 轮才开始累积。3 轮太短破坏前缀的代价还来不及显现。把任务拉长差距立刻出来。长任务下的真实差距同一个任务跑到 30 轮只看稳定前缀这一种策略的运行结果30 轮里28 轮命中缓存缓存 token 累计300 万。而且每一轮的 cached token 是递增的这是「只追加」的复利效应每轮只加新内容前面算过的全部复用。对话越长单位成本越低。反过来把系统提示词换成带微秒时间戳的版本cached从第 2 轮起就一直是 0——增量归零复利中断。所以结论必须分场景说场景前缀稳定的价值短任务3 轮以内收益有限破坏前缀也看不太出来长任务10 轮以上收益随轮数复利增长破坏前缀等于每轮从零开始如果你的 Agent 是短交互——一问一答、单次工具调用——前缀稳定性的优先级不高如果是长循环比如 ReAct 的多轮工具调用它就是账单里的主要变量。怎么确认缓存真的命中了不要靠感觉判断。每次调用的响应里都带着 usage 明细直接读。两个字段值得单独留意usage.prompt_tokens_details.cached_tokens是缓存命中的直接证据。它告诉你这一轮的多少 prompt token 直接复用了缓存。稳定前缀下这个数字应该逐轮递增如果它一直是 0说明静态前缀被什么东西破坏了——先去查是不是有人在 system 里塞了动态值。finish_reason决定 Agent 主循环走不走下一轮。取值是tool_calls时继续循环是stop时结束。还有一个容易踩的取值是length——达到 max_tokens 上限被截断。这种情况下content可能是空的推理模型的思考吃光了预算如果只看「有没有内容」来判断成功空答案会被当成正常结束。生产代码里必须显式处理这个分支。最后一个是tool_call_id每条role: tool的消息都要通过它和发起调用的那条 assistant 消息配对。这就是前面滑动窗口那段代码为什么要写while循环回退起点——裁剪可以让缓存失效但不能让协议结构断裂。五、缓存不只是优化它是架构约束前面讲的都是「前缀变了会变慢变贵」。但缓存的影响远不止性能——它会反过来决定系统架构。书里引了 Claude Code 的实践有四个具体的设计决策第一提示词的结构由缓存边界决定。系统提示词在物理上被切成两段边界前的内容跨用户、跨会话全局缓存边界后放用户和会话的特定信息。于是提示词的排列顺序首先由缓存经济性决定其次才是语义逻辑。代价可以量化每个运行时条件如果放在边界前缓存键的变体数就翻倍。3 个二值条件macOS 还是 Linux、普通还是调试模式、中文还是英文会产生 2 × 2 × 2 8 种缓存键。第二子 Agent 必须与父 Agent 字节级对齐。主 Agent 派生一个子 Agent 时如果子 Agent 继承父 Agent 的上下文那么子 Agent 的提示词、工具定义、模型配置、消息前缀、思考配置必须与父 Agent逐字节匹配才能命中服务商的 Prompt Cache。少一个空格都不行。第三工具结果的替换字符串在首次出现时就被冻结。当大段工具输出被替换成摘要预览这个替换字符串要持久化。哪怕会话重启也必须用完全相同的字符串——为了保证恢复后的消息序列与缓存里的字节流一致。第四改前缀可能丢掉的不只是钱还有推理。这一条最狠。Anthropic 从 Claude Fable 5.1 和 Opus 5.5 开始引入「保留思考」机制服务端用签名把每个 thinking block 绑定到产生它的前缀上顶层 system、tools以及它之前的全部消息。之后任何一次请求里只要这段前缀有任何改动这个 thinking block 和它后面的所有 thinking 全部失效。新注册账号默认直接收到 400 错误。失效的写法都是熟面孔每轮重建系统提示词写入当前时间或模式开关、每轮重新渲染首条用户消息里的环境信息、中途增删tools、原地截短旧的工具结果、往旧消息里插入提醒又删掉。而且 thinking block 自身也不能随意裁剪可以从头删、从尾删或全删但不能删掉中间一个而保留后面的删掉的也不能再放回去。这一条把结论推到了新的强度当推理链也绑定在前缀上时「只追加、不改写」就从性能建议变成了正确性要求。六、这对压缩策略意味着什么既然前缀不能动那上下文膨胀了怎么办压缩不就是要改中间内容吗这里要先回答一个更根本的问题为什么要压缩。大多数人只想到一个理由——窗口有限、token 贵。但书里区分了三个截然不同的动机而且第二个才是真正的重点动机一长度与成本。最直观。窗口 128K工具结果动辄数万字符几轮就撑满。动机二提升思考质量。这一条层次更深。书里用一个宠物店的例子讲透了它的机制上下文里有 100 个笼子的记录90 只黑猫、10 只白猫问「黑猫白猫各几只」不开思维链的模型答不对。为什么因为注意力擅长「查找」不擅长「统计归纳」。「笼子 37 里是什么猫」是查找注意力一查就中「总共有多少只黑猫」需要遍历全部记录并维护一个计数状态本质上是思考而不是检索。所以压缩的价值可以精确表述为把「需要思考才能得到的结论」变成「可以直接检索的知识」。动机三缓解上下文焦虑。模型以为窗口快满了会在任务没做完时提前收尾。提前压缩能提升决策质量。顺着动机二还能推出一个更隐蔽的问题——上下文腐化上下文溢出装不下了上下文腐化装得下但找不到了后者更危险因为 Agent 表面上还在正常工作只是决策质量悄悄下滑。注意力权重被分散到更多 token 上每个 token 分到的权重变小无关内容一旦占了大头有用的部分就越来越难被注意到。回到压缩与缓存的矛盾。解法在时机和位置三条原则System Prompt 和 Tool Definitions 永远不动——静态前缀持续缓存压缩对象是轨迹里的工具结果——替换位置之后的缓存失效之前的仍然有效接近阈值时批量压缩不要每轮都压——频繁压缩等于频繁破坏缓存preserve里那四样东西值得单独说决策已经定下来的事、约束不能违反的边界、失败走过的弯路、来源结论的出处。这四类是「决定下一步行动所必需」的最小集合其余细节可以丢。还有一个更省事的思路隔离优于压缩。把数万字符的工具原文交给子 Agent 消化主 Agent 只收到一条结论消息。主上下文全程只增不改前缀一直稳定——根本不产生压缩需求。七、决策总表概念关键结论上下文结构静态前缀system tools加轨迹前面不能动、后面可压缩KV Cache 与 Prompt Cache前者管单次请求内后者管跨请求共同前提都是前缀不变缓存失效范围从首个不同 token 起往后失效之前的仍然有效时间戳写进 system微秒级精度导致每轮必变缓存命中 81% 掉到 0%打乱工具顺序内容不变但位置变命中率 10.1% 掉到 3.4%滑动窗口轨迹整体位移且裁剪必须保证工具消息与调用请求成对重排纯文本丢弃协议结构且每轮重算TTFT 从 2.3 秒涨到 6.2 秒压缩的三个动机长度成本、提升思考质量、缓解上下文焦虑压缩的核心机制上下文学习是检索而非推理把结论算好放进上下文压缩与缓存共存不动前缀只压轨迹接近阈值时批量压架构级影响提示词顺序由缓存边界决定子 Agent 需字节级对齐改前缀可能丢掉 thinking八、实战题以下三种写法都会破坏 Prompt Cache请判断每一种的失效范围是全部失效、还是从某个位置起失效并给出修复方案。写法 A系统提示词末尾追加当天的日期精确到天。写法 B每轮根据用户当前意图从 20 个工具里挑 5 个组成tools数组。写法 C每轮把最近 3 条工具结果的原文截短到 500 字符以内超出部分用省略号替代。参考答案思路写法 A看起来比微秒级时间戳温和但它仍然在系统提示词末尾放了一个每天变化的值。同一天内的会话不受影响跨天时整个前缀失效一次。修复把日期作为追加消息放进轨迹尾部或者接受每天一次的全量重算但绝不要放在会被高频复用的位置。写法 B是最贵的一种。它每轮都改tools数组而tools在静态前缀里导致 system 之后的全部内容每轮重算。修复保持tools数组恒定把「本轮打算用哪几个」以消息形式声明在轨迹尾部让模型自己筛选。写法 C的失效范围是从被截短的那条工具结果起往后。注意这里有个坑截短操作发生在轨迹的中间位置所以它上面的前缀system、tools、早期历史缓存仍然有效——但每次截短都会让这条消息之后的缓存作废。如果每轮都截不同位置的历史缓存会持续处在碎片化状态。修复要么只在接近预算时把整段旧历史一次性替换成摘要要么干脆不截短改用子 Agent 隔离长内容。九、附五个实验的复现记录理论讲完记录这一章五个实验的实际运行结果。全部用自选的 OpenAI 兼容端点跑通除了给一个中转网关加上字段过滤下面会说没有改动仓库的任何逻辑。实验主题运行结果2-3KV Cache 与上下文布局30 轮迭代 / 60 次工具调用 / 缓存命中 28 轮2-5提示注入攻防3 攻击 × 4 防御矩阵成功率全部 0%2-6官方 PPT Skill生成 9 页 .pptx用 python-pptx 重新打开校验通过2-9Agent 状态栏5 种状态栏技术的对照输出2-10上下文压缩六种压缩策略的 Token 与压缩率对比2-5 的 0% 不能读成「防御有效」三类注入攻击直接、间接、记忆在四种防御配置下成功率全是 0%包括 D1 无防御。这和仓库的官方验收记录一致官方用另一个模型跑完整的 3×4×5 矩阵记录的也是全部 0%。这说明在当前这一代模型上这三类注入本身就很难奏效防御配置的差异根本拉不开。如果把它写成「防御措施有效攻击成功率降为 0」是错的。没有对照组的 0% 证明不了防御有效——它只说明攻击没成功。真正的对照组是「D1 无防御 vs D4 组合防御」两组都是 0等于这张矩阵没有信息量。2-9 的报错是正常的运行时会看到UnicodeDecodeError那是实验的工具函数读到了非 UTF-8 编码的文件。代码自带容错随后会打印成功提示继续执行主流程不受影响。2-10 的抓取失败也是正常的运行时会看到网页抓取超时或 403。该实验原本依赖搜索 API未配置时会退化为直接抓取网页受网络环境影响。压缩逻辑本身不受影响。一个绕不开的兼容问题换用中转网关时踩到一个 400症状很有迷惑性第 1 轮成功第 2 轮才报错。因为只有多轮对话才会把 assistant 的tool_calls消息回传而报错出在那个回传的消息上。根因是 OpenAI SDK 的model_dump()输出里带着协议不存在的字段严格网关看到function_call这个 key 就整体拒绝请求哪怕它的值是null。做了唯一变量对照同一份消息去掉function_call就成功只加一个function_call: None立刻 400。修法是别直接转发model_dump()的结果只保留协议定义的字段平时用 OpenAI 官方端点不会暴露这个问题——它容忍这些多余的 key。换成任何严格实现的兼容网关这类字段就会立刻变成硬错误。这是「OpenAI 兼容」这四个字里最容易踩空的地方兼容的是协议不是宽容度。