乐于分享
好东西不私藏

给 Agent”卸载”技能,是一次昂贵的洁癖

给 Agent”卸载”技能,是一次昂贵的洁癖

AI Agent 上下文缓存系列第二篇。动态加载 Skill 很美好,但"用完就卸"会让你的 prompt 缓存反复清零——工具集的正确姿势是只增不减。附 reducer + 中间件核心代码。

上一篇(《缓存命中率只有一半?你的 Agent 可能正在为一个时间戳买单》)的结论是一句话:system prompt 里不要放任何会变的东西。

这一篇讲它的续集,一个更隐蔽的地方——工具集(tools schema)也是前缀的一部分。 而且在大多数模型服务的序列化里,它排在对话历史之前。

背景:为什么要动态加载 Skill

Agent 的能力越攒越多。我们的技能市场里有几十个 skill,每个 skill 带一套指令 + 若干工具。如果把它们全量注入每次请求:

  • 工具 schema 本身就是几千上万 token,轮轮全款
  • 更糟的是干扰模型——几十个工具摆在面前,选错、误触的概率陡增

业界的共识方向是渐进式披露(progressive disclosure):默认只给模型一份轻量的 skill 索引(名字 + 一句话描述),模型判断本次任务需要什么,调 load_skills 按需加载,加载后对应的工具才出现在工具集里。

到这一步都很美好。坑在下一个问题:加载之后的轮次,工具集怎么维护?

直觉的陷阱:"用完就卸"

工程直觉会说:上下文要保持干净。这一轮用完了绘图 skill,下一轮在做表格,那就把绘图的工具卸掉,只留当前需要的——每轮的工具集都"最小且精确"。

这个直觉在没有缓存的世界里是对的。在有前缀缓存的世界里,它是一台碎钞机。

回忆上一篇的规则:隐式缓存做的是从第一个字节开始的最长前缀匹配。而一次典型请求的序列化顺序是:

[tools schema] [system prompt] [messages...]      ↑工具集在这里——排在最前面,连 system prompt 都在它后面

以 Anthropic 为例,官方文档明确缓存层级是 tools → system → messages,任何一级的改动会让该级及其后所有层级失效;各家序列化细节略有差异,但工具集都排在对话历史之前。

工具集每变一次——增一个、删一个、甚至只是顺序变了——从工具这一级开始,system prompt 和整段对话历史的缓存连坐全灭,按全价重算。

现在算一笔账。卸掉一个 skill 的工具,省下的是它的 schema,几百到一两千 token;打掉的是排在它后面的全部历史缓存,长会话里是几万 token 的量级。省小头,炸大头。 而且"卸了又装"在多步任务里是常态——这一轮卸掉的,三轮之后又要用,再装回来,再 miss 一次。工具集来回抖动,缓存就反复清零,这就是 cache thrash。

加载 skill 的那一次 cache miss 是必须付的入场费;卸载造成的 miss,是纯粹的浪费。

正确姿势:工具集只增不减

我们把它定成一条状态设计约束:一个会话 run 内,skills_loaded 单调递增,永不收缩。

在 LangGraph / deepagents 的状态体系里,这条约束可以直接编码成 reducer——不是靠 prompt 里求模型"别卸载",而是状态合并逻辑上就不存在"减"这个操作

defaccumulate_skills(current: list[str] | None, new: list[str] | None) -> list[str]:"""累积 reducer:合并去重,保持首次出现顺序。只有并集,没有差集。"""    out = list(current or [])for s in new or []:if s notin out:            out.append(s)return outclassSkillState(DeepAgentState, total=False):    skills_loaded: Annotated[list[str], accumulate_skills]

有人会问:这不是和开头"全量注入干扰模型"矛盾了吗?不矛盾——只增不减的作用域是单次任务。一个 run 内工具集的上界,是这个任务真正用到的 skill 集合,天然有界;下一次任务重新从轻量索引轻装出发。会膨胀的是"这个任务需要的",永远不是"市场里有的"。

留意一个细节:**"保持首次出现顺序"不是顺手写的,是缓存要求。** 如果 reducer 里做了排序或依赖 set 的无序性,同样一批已加载 skill,两轮之间序列化出的工具顺序可能不同——字节变了,缓存照样死。去重 + 首次出现顺序,保证 skills_loaded 一旦稳定,每轮渲染出的工具列表字节级恒定

动作的一半:投影式过滤中间件

状态只记录"加载了什么",真正让工具出现/隐藏的是每次模型调用前的一个中间件——又是老朋友 wrap_model_call

classSkillToolFilterMiddleware(AgentMiddleware):"""每次模型调用前,按 skills_loaded 决定本轮可见工具集。"""defwrap_model_call(self, request, handler):        loaded = request.state.get("skills_loaded", [])        skill_names = self.registry.skill_tool_names()        # 全部 skill 工具名        visible = {t.name for t in self.registry.tools_for(loaded)}  # base + 已加载        kept = [            t for t in request.tools# 未加载 skill 的工具收窄掉;base 工具、框架默认工具一律保留if t.name notin skill_names or t.name in visible        ]return handler(request.override(tools=kept))

如果你读过系列前作会觉得眼熟——这和引用化投影是同一个手法:变换只作用于 request.override(...) 这一次请求视图,state 一个字不动。 全量工具在 registry 里躺着,"加载"只是让投影窗口变大。窗口只会变大不会变小,前缀就只会在加载那一轮断一次。

把 miss 压到只剩一次:turn-1 批量规划

只增不减保证了"不抖",但如果模型每轮反应式地追加一个 skill——第 1 轮装 A、第 3 轮装 B、第 5 轮装 C——每次追加仍是一轮 miss。miss 有界,但没到最优。

最后一块拼图是把加载动作前置到第一轮:模型在规划期(写 todos 的同一阶段)看着 skill 索引,判断整个任务需要什么,一次性load_skills([A, B, C]) 装满。

配套两条排序纪律,都是上一篇"分层沉积"思路的延续:

  • 基础工具 + load_skills 排在前(它们永远在,属于稳定前缀)
  • 动态加载的 skill 工具追加到末尾(变化只发生在尾巴上,最大化前面的可缓存段)

执行中途真发现漏了某个 skill,仍然允许反应式补装——那是少数情况的兜底,一次有界的 miss,可以接受。

复盘:三条经验

① 工具集是前缀,把它当 prompt 一样对待。 上一篇的全部纪律——不放易变内容、排序冻结、字节级洁癖——原封不动适用于 tools schema。很多团队盯死了 system prompt 却对工具集放任自流,而在 Anthropic 的缓存层级里,它排得比 system prompt 还靠前。

② "上下文最小化"在有缓存时要重新算账。 删掉暂时不用的东西省下的 token 是小头,它引发的下游缓存失效是大头。判断标准从"这轮用不用得上"换成"删它省的钱,够不够赔缓存"——对工具集来说,答案几乎永远是不够。

③ 约束要编码进状态结构,不要写进 prompt。 "请不要卸载技能"这种话对模型说了也白说。把 reducer 写成只有并集没有差集,"卸载"在类型层面就不存在——这才叫约束。

最后补一条边界条款:这笔账成立的前提是缓存还活着。前缀缓存的 TTL 以分钟计(Anthropic 默认 5 分钟),自动化多步任务轮次间隔以秒计,缓存一直是热的,账怎么算都成立;如果你的 Agent 是人机对话型、用户十分钟回一条消息,缓存本来就过期了,这套优化的收益要重新评估。

抽象一层,这个系列讲到现在其实在反复逼近同一条原则:

在带前缀缓存的世界里,上下文是一份 append-only 的日志,不是一张可以随手整理的工作台。 想"整理",用投影(改这一次发出去的视图);别用删除(改留下来的状态)。

上一篇我们把易变内容从前缀里搬走,这一篇我们让工具集只长不缩。方向是同一个:让缓存前缀的每一个字节,都活得越久越好。


AI Agent 上下文缓存系列:① 缓存命中率只有一半?你的 Agent 可能正在为一个时间戳买单② 给 Agent"卸载"技能,是一次昂贵的洁癖(本篇)

如果这篇对你有用,欢迎关注 / 在看,系列还会继续。