第 38 章:Deferred Tool Loading 与 Tool Search
"工具太多就延迟加载呗"——可工具数量是错的计量单位,而延迟错了一个工具,代价是每个会话的第一条回复慢 6 秒。
⏱️ 快速通道(5 分钟掌握核心)
- 按 Token 做预算,不是按工具数量——三十个小 Schema 可能比五个大的还便宜
- 两个触发条件:预算超限或者冷集合里有 always-defer 类别的工具
- 有些工具绝不能延迟——那些开场就要用的,一次检索往返全是纯延迟
- Warm Set 是 session 级的、每次从冷启动;用 Schema 指纹做 key,不是名字
- 延迟只是藏起 Schema,不是拒绝权限——这是两套系统
10 分钟路径:38.1-38.3 → 38.5 → Kocoro OSS Lab
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
38.1 从一个 60% 是目录的 Prompt 说起
你接了四个 MCP Server:GitHub、Postgres、Slack、浏览器自动化。加起来 74 个工具。
现在看看你每一个请求都在发什么:
system prompt 1,800 tokens
tool schemas 23,400 tokens ← 74 个工具的完整 JSON Schema
conversation 4,100 tokens
─────────────────────────────────
29,300 tokens
两万三千个 token 的工具定义,在每一轮的每一次迭代里重发一遍,就为了回答一个只碰两个工具的问题。模型在读一份 74 条的目录,只为从里面挑一行——而这份目录你每次都在付钱。
明显的解法是:别再发模型用不到的 Schema,让它自己来要。这就是 Deferred Loading,机制并不复杂。有意思的是,几乎每一种直觉版本都会让事情变糟——而且只在生产环境里现形。
38.2 工具数量是错的计量单位
第一反应通常是设个数量阈值:超过 30 个工具就切换到延迟模式。
但 Schema 的大小根本不是一回事。一个两个字符串参数的 read_file,几百个 token。一个有十五个可选参数、嵌套选择器对象、枚举等待条件的浏览器自动化工具,光它自己就能几千。三十个前者,比五个后者便宜。
所以,去给你真正在意的那个量做预算。公开 Runtime 直接估算 Schema token,再和预算比:
schemaTokenBudget = 8000 // 约 28K 字符的紧凑 Schema JSON
charsPerTokenSchema = 3.5 // 保守比率,与上下文估算器一致
八千 token,来自「约 28K 字符紧凑 Schema JSON、按一个刻意保守的字符/token 比率折算」。注意这个保守是故意的——把 Schema 成本估低了,等于捅穿你本来想守住的那个预算,所以比率宁可往「Schema 更贵」的方向偏。
给你真正花掉的东西做预算,不是给你数得出来的东西。 数量阈值会在某人接进来一个啰嗦 Server 的那天失效。
38.3 两个触发条件,因为只看预算会漏掉一种情况
这里比「查一下预算」有意思。真实的判断条件是一个「或」:
deferredMode := len(coldDeferred) > 0 &&
(shouldDefer(a.tools, a.tools.SortedNames(), schemaTokenBudget) ||
hasCategoricalDeferred(coldDeferred))
预算超限,或者冷集合里含有 always-defer 类别的工具。
第二个子句为什么要有?因为有些工具既贵,又在特定场景下几乎从不用。macOS GUI 自动化、调度、无头进程控制——一次性 CLI 任务一个都碰不到,但它们的 Schema 照样搭车进每个请求。always-defer 集合针对的就是这个:
Keeping them in the deferred set means cold-start
tools[]ships ~5K fewer tokens; sessions that DO need them pay one extratool_searchround-trip per session.
交易条件写得很清楚:每次冷启动省 5K token,代价是真的需要 GUI 工具的那些会话,每个多付一次检索往返。对一个大多数会话根本不碰桌面的 CLI 负载,这笔账明显划算——而且请注意,这是关于你的负载的判断,不是普遍真理。换成一个桌面自动化产品,正确的 always-defer 名单几乎是反过来的。
38.4 那些绝不能延迟的工具
现在是最容易做错的部分,也是本章开头那句话里数字的来历。
Gateway 工具默认都是可延迟的。但有两个被显式豁免,理由值得完整读一遍:
web_search/web_fetchare the most common opener of a NEW session (e.g. "what's the news on X") — deferring them forces the model into an extratool_searchround-trip before it can call the tool at all, adding ~6s of observed latency to the very first reply of a session, every session, since the WorkingSet warm cache is session-scoped and starts cold each time.
仔细读,这里面是两件不同的事实。
第一:Warm 缓存是 session 级的,而且每次从冷开始。 上个会话模型摸清的工具情况,全没了。每个新会话都要为它的第一次延迟查找付全价。
第二:这个代价落在第一条回复上。 不是落在某个用户已经投入了注意力的深层迭代上,而是落在他问完之后看到的第一个东西上。六秒钟什么都没有,就在那个决定他觉得这系统快不快的交互上。
所以规则不是「把贵的 Schema 延迟掉」,而是:延迟那些检索代价落在用户没在等的地方的 Schema。 一个用来开场的工具,是最不该被延迟的,无论它的 Schema 多大。
memory_recall 被豁免的理由相关但不同:它是隐式记忆注入什么都没捞到时的兜底路径。把它延迟掉,实测到的失败模式是模型在第一次尝试时幻觉出一个旧版本的 Schema,白烧一轮。修复的代价是大约 1.5K 额外 Schema 字节——而关键在于,这些字节搭在可缓存的 system 前缀里。每个会话付一次,不是每一轮付一次。
最后这个细节值得带走:当一个 Schema 住在缓存前缀里,它的成本会被整个会话摊薄。待在稳定前缀里的「贵」Schema,比看上去便宜得多。
38.5 Warm Set,以及它为什么用指纹做 key
模型检索过一次、拿到了 Schema 之后,再发一遍在注意力上不花钱,在 token 上很便宜。所以你把它留下——这就是 Warm Set,一个 session 级的、装着模型已经取回过的 Schema 的工作集。
隐蔽的失效:某个 MCP Server 升级了,它的工具现在参数形状不一样了。你的 Warm Set 还揣着旧 Schema,模型按旧形状发调用,工具拒收——或者更糟,默默接受然后做了别的事。
所以 Warm Set 是按 Schema 指纹失效的,不是按名字。底层有效工具集一变,从旧定义派生出来的 warm 条目就不再有效。按名字缓存是 bug,按内容缓存才是修复。
38.6 延迟不是授权
这一条值得说得直白,因为它长得像安全机制,但不是。
把 Schema 对模型藏起来,并不能阻止这个工具被调用。模型照样可以按名字发出调用——它可能在检索目录里见过这个名字,可能在某个 Skill 描述里见过,也可能就是猜的。Deferred Loading 是对能力描述做的检索优化。它塑造模型看到什么,不塑造模型能做什么。
权限是另一套系统,在执行时强制。如果你对一个危险工具的唯一防线是「它的 Schema 没进 Prompt」,那你没有防线。 真正的授权层在第 25 章和第 33 章;本章只谈 token 经济学。
顺带一提,同样的分离也出现在 Skill 限制上:allowed-tools 是以执行时拒绝实现的,而不是过滤 Schema——正是为了让 tools 数组保持字节稳定,喂给 Prompt Cache。两套系统,两种机制,刻意不合并。这也正是第 39 章建立在其上的那条纪律。
38.7 快照证据
| 观察 | 4ec6772 源码位置 |
|---|---|
| Schema token 预算 8,000 及其推导 | toolbudget.go L15 |
| 保守的 3.5 字符/token 比率 | toolbudget.go L18 |
| Deferred Mode 由预算或类别任一触发 | loop.go L2158 |
| always-defer 类别,冷启动省约 5K token | toolbudget.go L39 |
browser_* 按前缀延迟 | toolbudget.go L56 |
| never-defer 豁免与 6 秒的理由 | toolbudget.go L63 |
| Warm Set 按工具集指纹做 key | warmset.go L11 |
tool_search 的构造 | deferred.go L22 |
以上描述的是一个有日期的实现快照,不是普遍契约。
38.8 常见的坑
按工具数量做预算。 前面说过了。某人接进来一个啰嗦 Server 的那天它就废了。
把开场工具延迟掉。 六秒的税,落在第一条回复上,每个会话都收。选延迟对象之前,先去日志里看看到底是什么在开场。
Warm 条目按名字做 key。 一直好用,直到某个 Server 更新了 Schema,然后开始产出畸形调用——而且看起来像是模型出错。
忘了 Warm Set 从冷开始。 只按稳态行为推理、忘了每个新会话都从零开始,就是六秒问题在测试里被漏掉的原因——它在一次长的手工会话里永远不会出现。
把延迟当安全边界。 它不是。拒绝发生在执行时。
延迟得太狠,模型什么都发现不了。 如果兜底目录只列名字,而名字又晦涩,模型根本没法组织出一个好查询。兜底目录里的名字是承重的——把它们起得有描述性。
Kocoro OSS Lab(10 分钟上手)
- 读
neverDeferTools的注释,注意这个论证的结构:一个实测延迟数字,挂在一个具体的用户可见时刻上,再挂在一个具体的缓存性质上。 - 看
alwaysDeferTools,问自己:换成一个桌面自动化产品,哪几条会是错的?这份名单编码的是一个关于负载的假设。 - 估一下你自己的 Schema token 开销:把工具定义序列化成紧凑 JSON,字符数除以 3.5,跟你的上下文窗口比。超过百分之几,你就有这个问题。
划重点
- 按 Token 做预算,不是按工具数量。 Schema 大小能差一个数量级。
- 两个触发条件。 预算超限,或者冷集合里有 always-defer 类别——后者专门抓那些「贵但没人用、又永远撑不爆预算」的工具。
- 绝不延迟开场工具。 检索代价会落在第一条回复上,落在一个每个会话都从冷开始的缓存上。
- 缓存前缀里的 Schema 比看上去便宜。 成本被整个会话摊薄,「贵但常驻」可以赢过「便宜但延迟」。
- 延迟是检索,不是授权。 拒绝属于执行时;把两者合并,你两样都得不到。
下一章:第 39 章讲的,正是让这个缓存前缀值得守护的那套纪律。