作者用gemma-4-12b实测发现409个工具的JSON schema占用80k token上下文的一半,工具描述远比他预估的冗余得多。
Atlas 是负责处理文件、照片、办公文档和项目的 Agent。它跑在 gemma-4-12b 上,拥有 80,000 个 token 的上下文窗口,运行在本地,和其他所有组件在同一台机器上。某天下午,它装不下了。
不是"回答变差了"。是装不下了。它被赋予的应用工具模式(tool schema)本身就已经超过了模型可用于思考的空间。
今年三月我写了 #07 — Your AI agent is wasting 90% of its context,讲的是一个 Agent 的窗口有多少被浪费在描述它永远不会调用的工具上。这是那篇文章的续篇,四个月后,用真实测量替代了估算。同时也是一次自我更正:我最有把握的两件事结果是错的,我诊断出的一个 Bug 实际上根本不存在。
我们的 Agent 通过一个门面(facade)连接到后端,门面将每个域暴露为一个类型化工具(#13 有架构说明)。tools/list 返回目录。以下是它的重量:
tools/list → 508,782 bytes
409 个工具,半兆字节的 JSON schema。有趣的问题不是"这很多"——而是里面到底有什么。
于是我数了一下。两个字段占了主导:
AUTH_TOKEN_FIELD: z.string().describe("…")
CONVERSATION_ID_FIELD: z.string().describe("…")
每个工具都声明了这两个字段,因为门面需要调用者的身份和它所属的会话。它们的描述,乘以 409 个工具,共约 133,000 个 token。而描述这两个字段的文本——模型从来不会填充它们——自 #14 起,一个主机端的 hook 会在调用到达服务器之前就注入令牌。
第二个问题是每个工具描述中都有一段样板文字——211 个字符解释如何传递 auth token——重复了 408 次。出现一次是说明。出现 408 次是家具。
T1:把这两个身份字段的 .describe() 清空。它们仍然被声明——这很重要,我后面会解释——但不携带任何说明文字。
T3:从 408 个描述中删除样板文字,放到 Agent 的 TOOLS.md 中,只放一次,放在它该在的地方:
- Token: the auth_token of every claw_* tool is injected AUTOMATICALLY (host-side identity hook).
DON'T fill it in yourself — if you do, it's ignored.
- Params: build each tool's parameters ONLY from the user's CURRENT request;
do NOT reuse or copy the ones from a previous call.
结果,线上验证:
−45.3%,且每个工具仍能执行。整个改动就是删除。
MCP SDK 在 strip 模式下用 zod 对象验证工具参数:handler 接收到解析后的 args,未声明的字段在到达之前就被丢弃了。如果我们去掉了 schema 中的 auth_token 来省下最后几个字节,主机端的注入就会写入一个被 SDK 丢弃的字段——所有 Agent 会在同一时刻停止认证。
这就是为什么剩下的优化被推迟而不是随本次发布。一个解决方案是移除 $schema 并从公布的 schema 中省略这两个身份字段(同时在解析后的 schema 中保留它们),大约能为每个 Agent 节省 25,000 个 token——但需要绕过 SDK 的便捷封装。这是另一个独立的改动,有独立的验证,因为失败模式是"所有东西同时崩溃"。
开始时我相信的是:408 个工具泄露到了模型的上下文中。
数学似乎支持这个结论。半兆的目录,除以每个 token 约 3.6 个字符,得到一个看起来很灾难的数字。
这是一个假阳性,错误一旦看到就令人尴尬。密集的 JSON 不是按 3.6 个字符每 token 来 tokenize 的——更接近 1.3,因为每个花括号、引号和冒号都是独立的 token。按这个比率,408 个工具大约是 360,000 个 token,超过了所涉及模型的上下文窗口。这个数字不可能,而我居然写了下来。
实际发生的是:模型收到了约 65 个工具。运行时的每个 Agent 否认列表(deny-list)一直正常工作着。
检查的方法不是算术。是追踪:
~/.openclaw/agents/<agent>/sessions/<id>.trajectory.jsonl
→ event: context.compiled
→ data.tools = the EXACT array sent to the model
那个文件有确切的答案,不需要估算。我花了一天推理一个我可以直接读取的数字。
我还假设门面的 tools/list 按身份过滤——当一个 Agent 请求目录时,它拿到的是属于自己的目录。
实际上不是。tools/list 返回所有内容。每个 Agent 的修剪 100% 由运行时通过工具名称的否认列表完成(claw-os__<tool>)。门面不是执行点;从来都不是。
这很重要,因为它改变了修复必须放在哪里。我们想要的任何每个 Agent 的修剪都必须表达为那个否认列表中的条目——这正是下一部分做的事。
我们注册表中的每个能力都可以标记 tier: 'core' | 'full'。从这些 tier 计算每个 Agent 否认列表的管道也已经存在。它几个月前就建好了,然后几乎没被用过:只有两个域给任何东西标记了 full。
所以工作不是构建机制。是做判断,123 次:对于每个高级能力,这是日常的还是稀有的?
标准:core 是 CRUD 加上必要的读取。full 是高级的和稀有的。按域计算,core 轻 57–65%。
然后是一个让整个练习值得做的细节:tier 一直是惰性的。过滤器只对暴露为每个动作一个工具的域生效,而那些域还没迁移。Tag 存在了,但没有任何作用,没人注意到——包括一个被特意标记为 full 的 docker.inspect_container,用来防止原始转存在 core 模式下运行。迁移这些域激活了一个一直休眠的安全决策,没有为此多写一行代码。
我的直觉是:把基础设施域迁移到每个动作一个工具,同时顺便拆分 core 和 full。实际测量后,顺序相反。
六个基础设施域共持有 88 个能力。将它们迁移到每个动作一个工具并以 full 模式作为默认,会让目录增长 48%——直接的倒退。在 core 模式下,50 个工具的成本与之前 6 个胖工具相同,而且模型不再需要猜测一个 34 值动作枚举。
对于基础设施 Agent——gemma,80k 窗口——这是 +1,083 token 而不是 +12,793。
所以这个拆分不是叠加在迁移上的可选优化。它是让迁移能够存活的东西。注释现在坐在代码里 default 所在的位置:
* The six INFRASTRUCTURE manuals are the exception, and the reason is measured […]
* In core they're 49 tools and cost the SAME as the six fat tools before (−0.1%) —
* with the bonus that the 34-action enum the model had to guess through disappears.
那个 default 曾经被复制在三个独立文件中:可见性计算、TOOLS.md 生成器和上下文预算估算器。现在只有一个函数,附带着原因:
* Effective mode of ONE skill. Single source: the default lived copied in three
* places (visibility, TOOLS.md and context budget) and with copies it's a matter
* of time before the catalogue says one thing and the deny-list says another.
一个目录和否认列表不一致的 Agent 是一个表现为"模型幻觉出了一个工具"的 Bug。
Core 模式引出了一个显而易见的问题:当一个 Agent 真正需要一个高级能力时会发生什么?
它会请求。有一个能力为此而生(skills.elevate,审批级别 2——你会收到一张卡),审批通过后应用切换到 full 模式,否认列表重新同步,所以完整的工具在下一轮进入 Agent 的上下文。
这个提升是临时的,这个决定比功能本身更有价值:
// The elevation is ephemeral, "until the session closes"[…] with NO new table
// and no new cron.
// Gotcha: a Core restart loses this registry → the app stays 'full'
// (which is the system DEFAULT, harmless).
一个以会话为 key 的内存注册表,在会话删除时还原。没有迁移,没有定时任务,失败模式是退回到已存在的默认状态而不是退回到一个新奇的状态。在数据库和运行时层面验证:core(33 个工具被否认)→ elevate → full(0 个被否认)。
没有"这个 Agent 现在有多满?"的答案,以上都没用。于是:一个端点,加上仪表板中的一根进度条,显示 base + system prompt + apps + 剩余空间相对于模型窗口的大小。
它最重要的一点是它拒绝做的事:
// It's ALL ESTIMATES: there's no tokenizer, so we use the ratios already established
// [](prose /4) and the JSON tool schema (/1.3, denser).
// Fail-soft by construction: a missing workspace file counts 0; if the model's window
// can't be resolved, model_window stays null (it is NOT invented).
const CHARS_PER_TOKEN_PROSE = 4;
const CHARS_PER_TOKEN_SCHEMA = 1.3;
export const CONTEXT_BUDGET_BASE_TOKENS = 4000;
* Calibrated estimate of OpenClaw's "base" prompt + its NATIVE tool definitions[…]
* NOT measurable server-side: that text is injected by the runtime binary, not in any
* file Core reads. Exposed as a CONSTANT and marked source:'estimate'.
在仪表板中,那段被画成条纹的,所以它看起来像是一个估算。一个无法测量的数字不应该渲染得和一个可以测量的数字一样——那是预算和带进度条的猜测之间的区别。
我们指向的第一个 Agent 是 Francis,我们的协调者,九个应用处于 full 模式:
91,000 / 80,000 tokens · −11,000 over budget
红色。超标的是 finance(27k)、projects(15k)和 files(13k)——正是 core 模式价值最大的三个域。
那个读数是在目录削减落地之前取的。今天把同样的进度条指向同一个 Agent:
78,000 / 80,000 tokens · 97% · free: 2,300
仍然不舒适地接近天花板——九个应用很多——但现在装得下了,之前装不下。仍然昂贵的是那些仍默认为 full 模式的应用:photos(15k)、mail(8.6k)、calendar(7.6k)。
再来一个更正,这是我最爱的。
在性能分析时,我注意到 gemma 报告的 usage.input 大约是前一次调用的两倍,反复出现。我诊断出了一个幻影重试:有什么东西在静默地重新发送 prompt,我们每次对话都在付双倍的钱。
然后我检查了相关性而不是相信那个故事。在一组对话快照中:
ratio ≈ 2.0 ⇔ 有工具调用:11 次中 11 次
无工具调用:ratio 1.0,每次都是
没有幻影重试。这是工具调用的正常形态:调用一发出工具调用,调用二读取结果并回答。usage.input 是两者的总和。系统工作得完全符合设计,而我给它写了一个 Bug 报告。
这个重新框架比那个 Bug 本身更有价值:既然每个工具调用中的 prompt token 都被预填充了两次——而且本地运行时不会在两次调用之间重用它的 KV cache(cacheRead: 0)——我们从目录中删除的每个 token 价值翻倍。45% 的削减不是一次预填充的 45%,是两次的 45%。
这指向了下一个杠杆,而且不是我们能控制的:让本地运行时在这两次调用之间重用 KV 前缀。同样的 prompt,同样的工具,连续调用。
在动手算术之前先读追踪。我的两个错误结论都来自于估算某些我可以直接读取的东西,这些东西就在我已经有的一个文件里。估算是用来对付无法观察的事物的。发送给模型的工具数组是可以观察的。
在写 Bug 报告之前先检查相关性。"usage 加倍"是一个有正确解释的错误观察。十一个数据点花了十分钟,化解了我本来会花好几天追查的一个 Bug。
在构建新机制之前先找已存在但闲置的机制。tier 系统已经完全构建好但在干坐着。如果我重新设计一个,我会再发布一套机制,然后把那个休眠的留给下一个困惑的人。
让估算看起来像估算。那个条纹段落是四行 CSS,它是没人会把 base 数字当作测量值来引用的原因。
45% 的削减是线上验证过的。tiers 是针对数据库和运行时验证的。提升周期是在数据库和运行时层面验证的——不是在真实聊天对话中端到端验证的,那是另一回事,我不打算声称做了。 非基础设施应用仍然默认为 full;只有六个基础设施应用默认为 core。这里没有以欧元或延迟计量的测量:这篇文章讲的是字节和估算的 token,这是我们真正能测量的。
工程是减法:两个空的 .describe() 调用和一段移到它该在位置的文字,换来了目录的 45%。
昂贵的部分是收回两个自信的结论和一个不存在的 Bug——三样都来自于推理我可以直接读取的数字。
如果一个数字重要到需要行动,就找到系统把它写在了哪里。
下一步:#15 中的七个 Agent 现在都装得下窗口了。当你要它们同时做同一件事时会发生什么?