# lazy-tools 设计笔记:在「注册表作用域」这一层做工具门控 ## 定位(北极星,2026-10-23 用户原话,2026-10-30 补写在这里) > 「很多人是像我这样的小白,并不知道啥时候该啥时候不该开」 > 「准确来说我想的是让**用户感觉智能**,就够了」 三条推论,任何改动都拿它来判: 1. **装上不用配**:默认值就是正确值;用户不需要读文档、不需要打开设置页。 2. **用户不必知道「抽屉」存在**:他能感到的只有「用得顺、没被挡」。 3. **旋钮只是逃生门**:内部判据(`warmRatio`、`only` 上限、阈值)**不进设置页**; 真要调的人走配置文件或 `/policy`。 **默认值只能有一处**(2026-10-30 挑刺):同一个默认值在设置页 schema、运行时代码、README 各写一遍就会漂 —— 已经发生过一次(`autoGroups` 设置页写 `suggest`、实际跑 `on`,用户保存一次表单就静默丢功能)。 现在设置页的默认值**从运行时代码取**(`normalizeAutoMode()`),从结构上消除这种漂移。 **加字段/加旋钮的检查点**(2026-10-30):先问三句 —— ① 不加它,用户会不会遇到问题? ② 加了它,用户是否需要理解它?③ 它的默认值能不能一处定义、别处引用? 三句里有一句答不上来,就不加。`warmRatio` 一天内加了又撤,就是没走这三句。 **`healthy` 的边界**(2026-10-30 独立盲评后明确):只覆盖「宿主五个契约 / warnings / errors」, **不含客户端面板**(`client.seen`)——面板是可选件(`showDock` 默认关),没上报不等于插件有病, 把它并进 `healthy` 会让每个新会话开局都是红的,正是「训练人忽略警报」。 但**必须可见**:默认精简视图保留 `client` 整段(缺失≠正常),由它自己的 hint 解释。 **判定句**:如果一个改动让用户可以少懂一件事,它大概率对; 让用户多想一个「我该填什么」,它大概率错。 > 2026-10-23。写给想抄这个思路的人,也写给下一个维护它的我。 > 代码约 2800 行(`lib/*.js` + `client.js`,2026-10-29 实测)、零运行时依赖;真正值钱的是下面这几条判断。 ## 一句话 **要省掉「工具 schema 税」,最便宜的一层不是代理、不是请求改写、也不是语义路由,而是宿主自己的「作用域级工具掩码」。** 如果宿主提供了「按 agent 作用域隐藏全局工具」的接口(本机是 `agent.ctx.tools.restrict({ deny })`),那就用它: - 不加进程(不像 MCP 代理/网关) - 不加一跳(真实工具调用仍然是原生的,不是 `execute_tool({name, args})`) - 不加依赖(不需要 embedding 模型、不需要检索索引) - 不需要重新注册工具(掩码只影响可见性,注册表不动) ## 问题量级 一台机器上 197 个工具,其中桌面自动化 56、浏览器 34、视觉 14、TiddlyWiki 16…… 工具 schema 是**每轮都在 prompt 里**的固定成本。要量它,**跑 `lazy_tools_health { bench: true }`**—— README 的「省了多少」表格是这份数字的**唯一存放处**(2026-10-29 起:同一个事实不存两遍,存两遍必然漂)。 ## 五个可复用的模式 ### 1. 三态 `mode: hide | only | all`,不要二元开关 二元开关没法表达「只放 `get_window_state` 这一个,其余 18 个继续收着」。三态之后: `enable {group, only:[…]}` 就是「组留在 hide,但白名单里的放行」。实测:工具表里真的只多出那一个。 ### 2. 记住「上次被收起来的是谁」 组关着时工具**不可见**,于是 `status` / `list` 只能报 0,看的人会以为组里没东西。 每次 rebuild 时把 `hiddenNames` 存进该 agent 的状态,`status.matched` 和 `list` 才能报出「组里共有几个 / 现在可见几个」。 ### 3. 限制必须由施加它的组件持有生命周期 这是踩出来的 bug:热重载(改配置、升级插件)之后,旧实例建立的限制**没人解除**,新实例又看不见它——于是 `enable` 会**假成功**(`status` 报 `on:true`,`visible` 还是 0)。 修法:把 `restrict()` 返回的解除函数挂到插件生命周期上(本机 Cordis 是 `ctx.effect(() => dispose)`),并做幂等包装。**任何「施加限制」的插件都要问自己一句:限制跟着谁死?** ### 4. 漂移检测:`expect` 计数 + `stray` 扫描 分组靠正则/globs 写死名字。上游一升级新增工具,它就**默认可见**、静默烧 token——这是最难发现的一类失效。 - `expect: N`:每组**至少**应有的工具数(下限)。**少于**才报警(改名/没加载/正则漏);多出来是吸纳型组的正常状态(负向前瞻、前缀通配本来就会长新工具),不报 —— 否则 healthy 在默认状态长期 false,等于训练人忽略警报(2026-10-30 改)。 - `stray`:从组内已知名算公共前缀(`cua_driver_native__`…),凡是**长得像这族人却没被任何组匹配**的可见工具全列出来。 - `healthy = 契约全绿 && 无错误(宿主销毁竞态除外,它只留证据)&& 无告警`:一个布尔值判断有没有事。 ### 5. 「忘了有开关」要用一行常驻提示解决 按需加载的代价是「模型可能不知道自己有这能力」。两个便宜手段: - **一行 system prompt 段(~40 tokens/轮)**:**全部组 id**(出厂四组 + 用户手写组 + auto 组)+ `status`/`enable`/`health` 入口。 - **一个按需技能**:教「工具不见了怎么查、怎么只开一个」。 对比:靠模型自己记得 → 不可靠;靠 28 个 schema 常驻 → 4–6k tokens/轮。**40 tokens 换 6k,是这套东西里最划算的一笔。** **2026-11-01(0.18)改动:这一行从「列全部组」变成「列本会话挂载的组」。** 宿主的 prompt 段是**按组装方会话渲染**的(`AssembleContext = { scope }`),以前我们忽略这个 scope,于是: ① 被宿主权限门挡掉的组照样被广告(本机实测:广告 8 组,其中 cua-screen / cua-input 被 computer-use 门挡着,一个都调不到); ② 角色 preset 没挂的组也会被列出来。 现在挂载集 = `visible(scope) ∪ 我们自己藏的`,只列其中有成员的组;一组都没有就返回空串(宿主会丢空段)。 **读不到 scope / 读不到工具表 ⇒ 退回旧行为(列全部组)** —— 失败方向是「照旧」,不是「提示变短」。 顺带把每轮组装的成本从 **9 次全量 schema 深拷贝**(8 个组各一次 `allToolNames()` + 分类提示一次) 降到 **0 次**(宿主有 `tools.view` 时)/ **2 次**(没有视图的宿主:一次本会话 + 一次全局)。 注意「空视图」不能当权威:视图返回空 Map 时再用 `schemas` 复核一次,否则会把开着的组静默删掉。 ## 实测数据(Win11 26H2 / DSH profile desktop) | 项 | 值 | |---|---| | 工具总数 / 收起 / 可见 | 197 / 102 / 95(2026-10-23 的机况;当前机况请跑 `lazy_tools_health { bench: true }`) | | 四组 | `cua-screen 19`、`cua-input 37`、`browser-heavy 28`、`vision-heavy 8`(另有用户手写组 `cu-observe 5` / `cu-act 9` / `cu-audio 2`) | | 切分验证 | 56 个 CUA 真名按正则切 19+37,**零重叠零遗漏**(脚本验,不是手算) | | only 单工具 | `enable {group,'only':[get_window_state]}` 后工具表只多这一个 | | 冒烟测试 | 假宿主真跑(不依赖 DSH);**项数以 `node test/smoke.mjs` 的输出为准,这里不写死** | ## 边界(别把它当万灵药) 1. **只能藏全局注册的工具**。agent scope 内注册的(本机 `dsh-ops` 的 `ops_*` 就是)宿主明确拒绝,藏不了。 实测(2026-10-28):一个会话的 135 个可见工具里,**34 个不归工具注册表** —— 33 个是宿主内核自带的(`read` / `write` / `pwsh` / `glob` / `web_fetch` / `schedule_*` …),1 个是 `dsh-ops` 的 agent 作用域工具 `ops_discover_tools`。所以 `lazy_tools_health { bench: true }` 的 `visible` 会大于它在注册表里能统计到的 `visibleInRegistry`;这两个数对不上不是 bug,是这一条边界。`bench` 现在会把这些名字逐个放进 `outsideRegistryNames`,不用再猜。 2. **分组只能按名字**。宿主的 `ToolSchema` 只有 name/description/parameters,**没有 tags**,所以命名没规律就只能手写白名单。 3. **开关会打断前缀缓存**。工具表一变,那一轮 cache 就未命中——所以纪律是「一次开、别反复切」,`only` 优于整组开。 4. **不做自动回收**。TTL 与关键词触发都容易误判,先记用量(`health.usage`),攒够数据再决定。 5. **失效方向是「放开」不是「锁死」**:契约坏了最多是工具都回来、多花 token,不会让人没法干活。这是刻意的。 6. **不做客户端「渲染信标」**(2026-10-29 决定)。想给 `client.seen` 加一个「渲染了几个节点」的上报通道,被否掉了:输入框上方那排控件**默认关闭**、是可选件,追求「无感」的人根本不会开它。为一个可选控件新开一条上报通道,正是「低价值新增零件」。**界面有没有渲染出来,靠冷启动验证第 2 步用眼睛(或截图)看**;`MAINTAINING.md` 也写明了「重启 App ≠ 刷新页面」。将来要重提这件事,先回答:它比「看一眼」省下了什么、代价是什么。 7. **官方工具组调不出来 —— 2026-10-30 用官方给的两层 lookup 量到了具体卡点**(写在这里,防止下一个会话重新分析一遍)。 - 实测(`lazy_tools_health {tool:…}`):`cua_driver_native__list_apps` → `global=true`、`agent=true`、**不在本会话工具表**;`run_code` → `global=false`(这个已装版本里根本没有 PTC 那个名字)。 - 结论:**不是没注册、不是被本插件的 restrict 挡、也不是 PTC**;是被 `tools.schemas(agent)` 这一层滤掉。DSH 作者在 #11207 给的两条线索仍然有效:**「呈现模式(PTC / native)」是另一道闸,agent preset 可覆盖部署默认值**;**限制在作用域链上取交集**(别人的空 deny 抵消不了另一个过滤)。 - 抽屉的处置:不再写「宿主没挂载」(那是猜的),改成三态实测 —— `not-registered` / `agent-filtered` / `filtered-by-schemas`,notes 里带探针的两个布尔;**静音待命不变**(不是抽屉能解的)。 - 另:`/lazy-tools/api/policy` 收到解析失败的 body 时曾把策略文件写空(只丢档位,手写组不受影响);现在**拒绝空策略**(400)。 - **已回**(2026-10-30):三个实验 + 六问贴在 Discussion #11207([discussioncomment-19272444](https://github.com/deepseek-ai/deepseek-harness/discussions/11207#discussioncomment-19272444)),原始数据见 `perf-\11207-实验记录-20261030.md`。 - **第二封回信后的更正(2026-10-30 01:2x,重要)**:作者指出 `get(name)` **省略第二参数时用的是全局视图**, 即使通过 `agent.ctx.tools` 调用 ⇒ 早先那个 `agent=true` 是**重复测量全局**,不是作用域。换成 `get(name, agent)` 后实测(我们 deny 为空时): ``` global: true agent: true(重复全局) scoped: false scopedRunCode: false defaultPreset: cordis composedPreset: cordis ``` ⇒ **作用域链上有「别人」的限制**(不是抽屉、不是 PTC、`run_code` 在这个作用域里也不存在)。 这也解释了实验 2:`defaultPreset` 与 `composedPreset` 都是 `cordis`,那次切 `standard` **确实没生效**。 探针已按作者给的代码改成 `get(name, agent)` + `defaultPreset/composedPreset`。 **三态判定同步修正(2026-10-30 冷读)**:原来用 `p.agent` 判 `agent-filtered`,而 `agent = get(name)` 与 `global` 同源 ⇒ 那个分支永远走不到。现在用 `p.scoped`: `scoped === false` ⇒ `agent-filtered`(作用域视图不给它);`scoped === true` 但不在工具表 ⇒ `filtered-by-schemas`(呈现层滤掉)。假宿主也补上了「`get(name, agent)` 按 agent 过滤」,否则三态测不出来。 ## 已知代价与明确决定(2026-10-30 挑刺清单后补) 0. **手写组里能匹配任意名字的匹配式(`*` / `re:.*`)会被忽略并记账**(2026-10-31)。理由:宿主会换工具名 (2.0.21 把 `pwsh` 换成 `ops_*`),靠维护「保护清单」去追名字永远追不完;而「不许吞掉整张工具表」是 **与名字无关**的结构性规则。代价:真想用一个组收掉全部工具的人会收到一条 warn —— 那正是自愈链路会断掉的写法, 值得被拦。窄组场景仍由静态清单(`lazy_tools_*` + 常见宿主名)保护。 另:不做「可见工具 − 在册工具」的差集来抓宿主工具——两者在 rc.3 同源于 `schemas()`, 宿主内核工具既不在册、也不在其中,差集恒空。 1. **`firstTryRate` 的定义**:`1 - 自愈次数 / **组内调用数**`(2026-10-30 二次修正:分母只数属于某个组的调用 —— 核心工具永不入组、也不可能自愈,混进分母会把数稀释成假象:200 次里 190 次核心工具时,5 次自愈会被读成 97.5%, 组内其实是 50%)。**它测不到「模型压根没开口」的静默失能**(用户要能力、模型判断自己不会 → 零调用、零自愈, 这个数仍是 1.0),所以它是必要不充分条件,别当成「无感化成立」的证据。自愈 = 模型第一次叫错名字、同一轮补开并放行(2026-11-05 起); (2026-10-30 曾把 `autoOpened` 从分子里摘出去;2026-10-31 随关键词预开一起删除,现在分子只有 `selfHealed`。) 2. **能力目录列的是完整工具名,不是去前缀的短名**,且每组按**热度**取 top3(用量账本的最近调用时间; 都没用过则保持注册顺序)。之前按注册顺序取,37 个工具的组里等于取了最冷门的三个。短名 `click` 在注册表里可能**撞上别的插件的同名工具** (本机就有:第三方 computer-use 提供裸名 `click`),模型照着念会静默用错实现。 3. **关键词预开已于 0.17.0 删除**(`autoHints`)。理由:判定词无穷,且本机实测用户几乎不用工具类词汇(主要发文件 + 直接指令); 自动入口只剩自愈,其余靠模型主动 `enable`。旧配置里还写 `autoHints` 时会在 `health.notes` 里得到一条提示(不是 errors,不拖 `healthy`),不再静默。 4. **缓存账**:报的是字符数不是账单——稳态下工具 schema 本来吃缓存命中价,而中途改表会让缓存作废一次。 本插件只在「用户或模型当下就要用」时改表:自愈、显式 `enable`/`disable`/`mode`、`save_groups{applyNow}`。 5. **组热 = 热工具占比 ≥ `warmRatio`(默认 0.30,可配)**,不是「任一工具热」(2026-10-30 修)。 原实现里 `recordUsage` 同时写工具与组的时间戳,于是「组热 ⇔ 组内任一工具热」;`applyDefaults` 里组热就整组开, 组冷则所有工具都冷、`only` 分支永远为空 —— **单工具热度在真实数据下不可达**。 现在组热由占比判定;`only` 分支就是**热的全放,其余收起**。 曾经有两道上限(`slice(0,3)`、`max(3, ceil(成员数 × warmRatio))`),都是**恒不生效的死代码**: 走 only 的条件是 `hot < 成员数×ratio ≤ ceil(...)`,永远截不到 —— 2026-10-30 删掉, 免得文档把不存在的机制说成有效机制。 账本只记「最后调用时间」,能力目录样本按**最近用过的优先**排 —— 没有窗口内计数,就不假装能算「用得最多」。 `warmRatio` **不进设置页**(内部判据),只走配置文件与 `/policy`(夹到 0.05–1);**30% 这个值本身留待 `firstTryRate` 的真实数据调**,不拍脑袋。 `status.hot` 已改成与决策**同一语义**(占比 ≥ warmRatio),并新增 `hotTools` / `hotRatio`; 组级时间戳从此只用于 `lastUsed` 展示,不再参与任何决策。 ## 同类与差别 - `asadani/tool-attention`(arXiv 2604.21816):句向量做意图-工具相似度 + 两段式懒加载,自动化最强,但要 90MB 编码器、每轮一次检索,且**路由错就漏工具**。 - `PeterCha90/mcp-lazy-load`:代理层只暴露 `search_tools` / `execute_tool`,省得最狠,但**每次真实调用都要过一层**(参数变成通用 JSON、丢原生 schema 校验)。 - `MikotoMyWife/dsh-mcp-loader`:同样落在 DSH 的 `restrict` 掩码层,管 MCP server,粒度到 server;本笔记这套管的是**任何全局工具组**,并多了 `only` 与组概念。 - **`studyzy/dsh-lazy-tools`(2026-08-14 建,TypeScript,★3)—— 最直接的同类,而且占着我们改名前用的那个名字。** 机制:**不动注册表**,在 `system-prompt/assemble` 这条 waterfall 上对「发给模型的 per-scope 工具表」做减法;另注册两个常驻守卫工具 `tool_search`(按精确名 / 关键词检索不可见工具)和 `defer_execute_tool`(按精确名激活)。零配置,并按**项目**统计真实使用情况自动调优「哪些常驻、哪些延迟」。 它的已知限制(它自己写的,很诚实):激活后**下一轮**才生效;**搜索前模型看不到延迟工具的名字**;PTC 呈现下退化为不生效;状态不跨会话。 **与我们最关键的三处差别**: 1. **它拦截出站提示(waterfall),我们只调宿主官方的 `restrict` 掩码** —— 我们不改「提示怎么组装」,它改了; 2. **它的发现路径是确定的**(工具名不知道也能靠关键词搜到),**我们的发现路径依赖名字**(目录给 3 个样本 + `lazy_tools_list` 给全量 + unknown 守卫自愈)—— 这是我们 2026-10-30 补目录样本名的原因;它比我们更不容易「卡在没人知道名字上」; 3. **它能管住 agent 作用域工具与 MCP**;我们只能掩蔽全局注册表里的工具(文档里写明的边界)。 **我们从它身上学到的**:`tool_search` 这种「按用途检索、命中即激活」的入口,比「让模型记住名字」更稳。 我们**没有**照抄(红线:不加新部件),而是把成本压在已有的 `lazy_tools_list` / `lazy_tools_enable { name }` 上,并把它们写进常驻提示的指路里; 如果将来「猜名字失败」的比率(`health.stats.firstTryRate` 的反面)真的高,这条是最该重新评估的候选。 - 共同点:大家解的都是「工具 schema 税」;不同点在**动哪一层**。 ## 要抄的话,最小实现清单 1. 找到宿主的「按作用域隐藏全局工具」接口,确认它返回解除函数。 2. 读「该作用域现在可见的工具名」——这是唯一的事实来源,别自己维护一份。 3. 组 = { id, match(glob 或正则), defaultOn, expect }。 4. 每个 agent 一份状态:`modes: Map<组id, hide|only|all>`、`hiddenNames`、`lifts`。 5. 施加限制时把解除函数挂到生命周期;重复解除要幂等。 6. 加 `expect` + `stray` + 一行常驻提示。 7. 写一个假宿主的冒烟测试——**它比任何文档都更能防止下一个人改坏**。 ## 退场条件(前一任提醒,2026-10-23) - **DSH 哪天原生支持按需加载,这套就该退场。** 它已经有 `deferLoading` 字段和 tool-addition 消息——如果官方把「工具按需出现」做进宿主,就别护着自己的插件。 - **公开/发布前必须先解决「加载即崩」的风险**:插件在 apply 阶段抛错,DSH 可能起不来。现在只有三道保险——零依赖、全 try/catch、假宿主冒烟测试;没有更强的方案。这是它至今只活在本机 profile 的原因。 ## 三条红线(改动这个插件之前先撞一遍) 1. **会话中途不改工具表。** 工具表一旦在会话开始时确定,整场不动——这是前缀缓存不被打断的前提。任何"落盘后立刻生效"的路径必须默认关闭,只有显式开关能打开,并且在返回值里写明代价(例:`lazy_tools_save_groups` 的 `applyNow`)。 2. **自救工具永不入组。** `lazy_tools_*` 与基础文件/命令工具(`read` / `write` / `pwsh` …)写在 `CORE_TOOLS` 里,自动分组、用户手写组、模型生成的组都不能包含它们;`lazy_tools_save_groups` 会直接拒绝。 3. **失败必须响。** 关闭失败、组开着却一个工具都不可见、`expect` 数量不符——都要进 `health.warnings` 或 `errors`,不允许"看起来 healthy、其实什么都没生效"。这条来自 2026-10-27 的实测:`resolve` 假报成功,用户白等了一轮。 配套的一条隐规则:**不替换工具、不引入原生依赖。** 换实现是别人的赛道;我们只做可见性这一层,才能同时管任意插件的工具,并且随宿主升级活得久。 ## 动手前的六个问题(提问清单,不是规则) 红线说「不许做什么」;这六条说「动手前先问什么」。每加一个功能、每改一处默认值,过一遍。 1. **这个便利,代价落在谁身上?** 省下的可能是用户 30 秒,付出去的可能是整段上下文按 cache miss 重算——而后者正是这个插件存在的理由。 2. **边界在哪?破了会怎样?** `save_groups` 的边界是「会话中途改工具表」;`classify` 的边界是「把常驻工具分进组」。想不出边界,说明还没想清楚它是什么。 3. **状态机的每条路径都走过了吗?** 加载 / 卸载 / **热重载** / 重启 / 另一个 agent 并发。孤儿限制就是漏了「热重载」这条边。 4. **失败了会响吗?** 不响就等于假装在工作。`resolve` 假报成功、`health` 静默 healthy 都是这一条的反例。 5. **能不能用现成的东西,而不是加一个新部件?** 苏联 Ш-1 的防雾方案是「在面罩上长一根手指」——用用户自己的手指,不造机械臂。我们让会话里的 agent 分类、把组放进独立文件,也是同一个思路。 6. **两个消费者要的是同一个判据时,必须走同一个函数**(2026-10-30 补,来自一次真实漂移)。 `idleDays` 有两个取法:决策用 `sessionIdleDays(st)`,`status`/目录排序用 `policy.idleDays` —— 会话档位一改就打架(组已收起,`hot` 还报 true)。**不是「别复制常量」,而是「同一个判据只允许一个入口」**。 > **怎么让 AI 真的「不加东西」(2026-10-29,作者自己用出来的方法)**: > **告诉它为什么减少、并直接给出替代方法,而不是只说「别加」。** > 理由是构造性的:模型的输出通道只能生成 token,「不要加」是没有方向的指令,它只能靠猜填空; > 给「为什么减 + 用什么替代」才有地方可去。这句写在这里,是因为换个上下文下一个接手的模型就会忘。 > 这套清单不是从管理学抄的,是从本仓库四次翻车里长出来的:立刻生效、提示误报、loader 替换语义、热重载孤儿限制。