Claude Code 的工程哲学:缓存与工具设计的真实教训 | 经验分享

Claude Code 的工程哲学:缓存与工具设计的真实教训 | 经验分享

最近读了 Claude Code 团队公开的两篇工程博客,一篇讲 Prompt Caching,一篇讲工具设计。读完之后最大的感受是:做 Agent 产品,技术选型和算法只是门槛,真正拉开差距的是工程细节上的反复打磨。

这两篇文章表面上讲的是两个独立话题,但放在一起看,它们其实在回答同一个问题------如何让一个长时间运行的 AI Agent 在成本、延迟和能力之间找到平衡。


缓存不是优化,是地基

Prompt Caching 的原理并不复杂:API 会缓存请求前缀的计算结果,下次遇到相同前缀时直接复用。但 Claude Code 团队把这件事做到了什么程度呢?他们对缓存命中率设了告警,命中率下降会被当作生产事故来处理。

这个态度本身就说明了问题。缓存命中率直接决定了每个用户的使用成本和响应速度,而成本又反过来决定了订阅计划能给多大的用量额度。换句话说,缓存效率不只是一个技术指标,它直接影响商业模型。

缓存的核心机制是前缀匹配。不是模糊匹配,不是语义匹配,就是从第一个字符开始逐字比对。这意味着请求内容的排列顺序变得异常重要:静态的东西放前面,动态的东西放后面。Claude Code 的排列是这样的:

复制代码
┌──────────────────────────────────────┐
│  1. 静态系统提示词 & 工具定义        │  ← 全局缓存(所有用户共享)
├──────────────────────────────────────┤
│  2. Claude.MD(项目配置)            │  ← 项目级缓存
├──────────────────────────────────────┤
│  3. Session Context(会话上下文)    │  ← 会话级缓存
├──────────────────────────────────────┤
│  4. Conversation Messages(对话消息)│  ← 每轮递增
└──────────────────────────────────────┘

从上往下,内容的变化频率递增,缓存的共享范围递减。所有用户共享最顶层的缓存,同一项目下的会话共享第二层,同一次会话共享第三层,只有最底部的对话消息是每轮都在增长的。

道理很简单,但执行起来处处是坑。他们踩过的那些坑,现在回看都不大,但当时每一个都足以让缓存完全失效:

踩坑操作 后果
在静态系统提示中放入精确到秒的时间戳 每秒都在打破缓存
工具定义的顺序在代码里是非确定性的 每次请求顺序不同,前缀不匹配
动态更新工具参数(如可调用的代理列表) 参数一变,后续缓存全部失效

围绕缓存做设计,而不是事后补救

一旦理解了前缀匹配的脆弱性,很多设计决策就变得清晰了,虽然其中不少是反直觉的。Claude Code 团队踩过坑之后总结出了四条策略,每一条都指向同一个原则:不要碰前缀,把变化推到请求的末尾。

策略 1:用消息传递更新,不要改系统提示

问题:信息过时了(如日期、用户修改了文件),直觉是更新系统提示 后果:缓存未命中,整个前缀需要重新计算

Claude Code 的做法是在下一轮对话的用户消息里插入一个标签来传递更新:

xml 复制代码
<!-- 在下一轮 user message 中加入 -->
<system-reminder>现在是星期三</system-reminder>

缓存前缀完全不受影响,模型也能读到最新信息。变化被推到了消息末尾,而不是去碰前面已经缓存好的内容。

策略 2:不要在会话中途切换模型

缓存是模型专属的,这会导致一个反直觉的现象:

如果你已经和 Opus 对话了 100k tokens,想问一个简单问题, 切换到 Haiku 反而比继续用 Opus 更贵------因为需要为 Haiku 从零重建整个缓存。

如果确实需要用不同模型处理子任务,正确的做法是用子代理:

rust 复制代码
主会话 (Opus) --> 准备精简的"交接消息" --> 子代理 (Haiku) 独立完成任务

两边各自维护自己的缓存,互不干扰。Claude Code 中的 Explore agents 就是这样使用 Haiku 的。

策略 3:不要增删工具,用工具建模状态转换

工具定义属于缓存前缀的一部分,增删任何工具都会使整个对话的缓存失效。但产品需求确实存在------计划模式下不应该让模型编辑文件,工具搜索场景下不可能把几十个 MCP 工具的完整定义都塞进请求。

Claude Code 对这两个场景分别设计了方案。

Plan Mode:用工具代替开关

直觉做法是进入计划模式时把工具集替换为只读工具,但这会破坏缓存。实际做法是保留所有工具,把状态切换本身做成工具:

lua 复制代码
所有工具始终存在于请求中
  |-- EnterPlanMode  (工具)  --> 进入计划模式
  |-- ExitPlanMode   (工具)  --> 退出计划模式

用户切换计划模式时,工具定义一个字都不变,模型只是收到一条消息说"你现在在计划模式,不要编辑文件,探索完了调用 ExitPlanMode"。缓存完美命中。附带的好处是,因为进入计划模式是一个工具调用而不是外部开关,模型在遇到复杂问题时可以自己决定先进计划模式想清楚再动手。

Tool Search:延迟加载而非移除

Claude Code 可能有数十个 MCP 工具,全部包含太昂贵,但中途移除会破坏缓存。解决方案是 defer_loading 机制:

css 复制代码
缓存前缀中始终包含:

  Tool A: { name: "tool_a", ...完整定义 }
  Tool B: { name: "tool_b", ...完整定义 }
  Tool C: { name: "tool_c", defer: true }   <-- 轻量级存根
  Tool D: { name: "tool_d", defer: true }   <-- 轻量级存根
  Tool E: { name: "tool_e", defer: true }   <-- 轻量级存根

  ToolSearch: { ... }                       <-- 发现工具用的工具

不需要的工具不会被删除,而是保留为只有名字的轻量级存根。模型需要时通过 ToolSearch 工具去查找和加载完整 schema。缓存前缀里永远是同一组存根,顺序固定,内容不变。

策略 4:上下文压缩要复用父对话的前缀

当对话超出上下文窗口需要压缩时,简单实现和缓存安全实现的差距非常大。

错误的做法:

lua 复制代码
新的 API 调用 {
  system_prompt: "请总结以下对话",   <-- 不同的系统提示
  tools: [],                        <-- 没有工具
  messages: [整个对话历史]           <-- 完全不匹配缓存
}
// 结果:全部 token 按全价计费

正确的做法:

lua 复制代码
新的 API 调用 {
  system_prompt: 与父对话完全相同,     <-- 缓存命中
  tools: 与父对话完全相同,             <-- 缓存命中
  user_context: 与父对话完全相同,      <-- 缓存命中
  messages: [
    ...父对话的所有消息,               <-- 缓存命中
    { role: "user", content: "请压缩以上对话" }  <-- 唯一的新 token
  ]
}
// 结果:只有压缩指令本身是新 token

代价是需要预留一部分上下文空间作为"压缩缓冲区",确保有足够空间容纳压缩消息和摘要输出。但比起全价重算所有 token,这点空间浪费可以忽略。


工具设计:匹配模型,而不是匹配需求

第二篇文章讲的是怎么给 Agent 设计工具,核心观点可以用一句话概括------工具要适配模型的能力和认知方式,不是你觉得合理就行。

团队用了一个类比:面对一道数学难题,你想要纸、计算器还是电脑,取决于你自己的能力。给一个不会编程的人一台电脑,不如给他一个计算器。Agent 的工具设计也是同样的逻辑,你得先理解模型擅长什么、会怎么使用工具,然后根据这些观察来设计。

这不是坐在那里想就能想明白的事情。团队反复提到的一个词是"观察"------读模型的输出,看它怎么调用工具,哪些调用是流畅的哪些是别扭的。

三次失败才做对一个提问工具

AskUserQuestion 工具的迭代过程是个好例子。目标很明确:让模型能更好地向用户提问,降低沟通来回的次数。

less 复制代码
graph LR
    A["尝试 #1<br/>ExitPlanTool<br/>加参数"] -->|混淆模型| B["尝试 #2<br/>修改输出格式<br/>解析 Markdown"]
    B -->|输出不可靠| C["尝试 #3<br/>AskUserQuestion<br/>独立工具 ✅"]
    
    style A fill:#ff6b6b,color:#fff
    style B fill:#ffa94d,color:#fff
    style C fill:#51cf66,color:#fff

三次尝试,每次失败的原因都不一样:

尝试 方案 失败原因
#1 在 ExitPlanTool 上加一个 questions 数组参数 同时要求出计划又要提问,模型搞不清重点。用户回答和计划冲突时更混乱
#2 修改输出指令,让模型用约定的 Markdown 格式提问 模型有时多加句子,有时漏选项,有时直接换格式。靠指令约束输出始终不可靠
#3 ✅ 创建独立工具,有明确的 schema 定义输入结构 结构化保证格式,多选项保证质量,模型调用自然流畅

第三版之所以成功,不只是因为 schema 能强制约束输出格式,更关键的是团队原文里提到的一句话:"Claude seemed to like calling this tool"。模型愿不愿意用这个工具、用起来是不是自然,比工具本身设计得是否优雅更重要。一个模型不爱用的工具,设计再精巧也是摆设。

曾经的帮手变成了枷锁

TodoWrite 到 Task Tool 的演化讲的是另一个教训:模型在进步,工具设计的假设需要跟着更新。

arduino 复制代码
TodoWrite(初期)
  │
  ├── 问题1:Claude 经常忘记 Todo
  │   └── 应对:每 5 轮插入系统提醒
  │
  ├── 问题2:提醒反而限制了模型
  │   └── Claude 觉得必须死守列表,不敢根据情况调整
  │
  └── 问题3:Opus 4.5 擅长子代理,但子代理怎么共享 Todo?
      └── 原有设计根本没考虑这种场景
  │
  ▼
Task Tool(进化版)
  ├── 支持任务间的依赖关系
  ├── 跨子代理共享和更新进度
  └── 模型可以自由修改、删除任务

底层理念从"帮模型记住该做什么"变成了"帮多个 Agent 协调工作"。这件事的启示很明确:每次模型升级,都应该重新审视现有工具是否还合适。坚持支持少数几个能力接近的模型,工具设计的迭代成本会低很多。

让 Agent 自己找信息,而不是喂给它

搜索工具的演化是这两篇文章里我觉得最有意思的部分。

less 复制代码
graph TD
    A["RAG 向量数据库"] -->|需要索引、环境脆弱、模型被动接收| B["Grep 工具"]
    B -->|模型主动搜索,但只有单层| C["Agent Skills + 文件引用"]
    C -->|递归发现,多层深入| D["嵌套搜索:跨多层文件精确定位"]
    
    style A fill:#868e96,color:#fff
    style B fill:#4dabf7,color:#fff
    style C fill:#845ef7,color:#fff
    style D fill:#51cf66,color:#fff

Claude Code 最早用的是 RAG 向量数据库,提前索引代码库,用户提问时检索相关片段塞进上下文。这个方案在受控环境下效果不错,但到了真实用户的各种开发环境里就很脆弱了------不同的语言、框架、项目结构都会影响索引质量。更根本的问题是,模型是被动接收上下文的,拿到的信息是否是它真正需要的,完全取决于检索系统的质量。

后来团队给模型加了 Grep 工具,让它自己搜索代码库。一个看似很小的改变,但效果出乎意料地好。模型知道自己在找什么,搜索策略会根据任务动态调整,不再依赖预建的索引。

这个思路后来被提炼成了一个设计模式:

渐进式发现(Progressive Disclosure)

让 Agent 通过探索来逐步发现相关上下文,而不是一次性塞给它所有信息。

具体做法:

  1. Claude 可以读取 Skill 文件
  2. Skill 文件中引用其他文件
  3. 模型可以递归地阅读这些引用
  4. 常见用法:通过 Skill 教 Claude 如何使用 API 或查询数据库

一年时间里,Claude 从"几乎不能自己构建上下文"进化到了"能跨多层文件做嵌套搜索来找到精确所需的上下文"。

渐进式发现不只用在代码搜索上,还解决了其他场景的信息供给问题。比如用户有时会问 Claude Code 自身的用法------怎么配置 MCP、某个斜杠命令是干什么的。团队评估了几种方案:

方案 优点 缺点
全写进系统提示 简单直接 用户很少问,平时占用上下文,干扰正常写代码
给文档链接让模型自行搜索 不增加工具 模型会把大段搜索结果塞进上下文,实际只需要一个答案
Guide 子代理 ✅ 不增加工具,专人专事 有专门的搜索指令,精准返回答案

最终做了一个 Guide 子代理,主模型检测到用户在问关于自身的问题时,把问题交给它处理。整个过程没有给主模型增加任何新工具,但能力确实扩展了。


加工具之前先想清楚

Claude Code 目前大概有 20 个工具,团队对新增工具的态度非常谨慎。每多一个工具,模型在每次决策时就多一个选项要考虑。工具集膨胀不只是维护成本的问题,它直接影响模型的决策质量。

从上面这些案例里可以提炼出几条判断标准:

能不能不加工具就解决? 子代理和 Skill 文件都是在不增加工具数量的前提下扩展能力的方式。Guide 子代理解决了文档查询的需求,渐进式发现解决了上下文构建的需求,都没有给主模型的工具列表添一项。

工具的 schema 能不能保证输出可靠? 如果同样的效果可以通过输出格式指令实现,但格式指令不能保证模型总是遵守,那就应该做成工具。AskUserQuestion 的第二版到第三版的转变就是这个逻辑。

模型用起来自然吗? 这一条没有量化标准,只能靠观察。给模型一个新工具之后,看它在各种场景下是怎么调用的,调用得是否合理、是否流畅。如果模型经常在不该用的时候用它,或者该用的时候不用,说明工具的定义和模型的认知之间有错位。

最后还有一条容易忘记的:这个工具以后还需要吗?模型的能力在持续提升,一年前需要的辅助轮,今天可能已经是多余的负担。


对于正在做 Agent 产品的人来说,最实际的建议可能就是:从第一天起就围绕缓存设计架构,然后花大量时间观察模型怎么使用你给它的工具。前者是工程问题,有明确的对错;后者更接近手艺,需要耐心和经验积累。

相关推荐
风象南3 小时前
最适合新手先装的 20 个 OpenClaw Skills 来了!
人工智能
小兵张健14 小时前
35岁程序员的春天来了
人工智能
大怪v14 小时前
AI抢饭?前端佬:我要验牌!
前端·人工智能·程序员
冬奇Lab14 小时前
OpenClaw 深度解析(六):节点、Canvas 与子 Agent
人工智能·开源
刀法如飞15 小时前
AI提示词框架深度对比分析
人工智能·ai编程
IT_陈寒17 小时前
Python开发者必知的5大性能陷阱:90%的人都踩过的坑!
前端·人工智能·后端
1G18 小时前
openclaw控制浏览器/自动化的playwright MCP + Mcporter方案实现
人工智能
踩着两条虫18 小时前
VTJ.PRO 双向代码转换原理揭秘
前端·vue.js·人工智能
扉川川18 小时前
OpenClaw 架构解析:一个生产级 AI Agent 是如何设计的
前端·人工智能