工具注册表模式
问题
如果没有中心化的注册表,Agent 将工具定义分散在各个调用处,导致重复注册、不一致的默认值(有些工具忘记权限检查,有些意外被标记为可并发执行),以及没有单一位置来应用跨切面关注点如拒绝规则过滤或并发分类。功能标志控制的工具对编排者不可见,除非每个消费者独立复制相同的条件逻辑,导致代码路径间的工具池静默偏差。插件提供的工具与内置工具不可预测地交错,每当插件集变化时就破坏 prompt cache 稳定性。
这些问题出现在任何支持可扩展工具集的 Agent 运行时中——并非特定于某个具体实现。
黄金法则
在所有安全相关默认值上执行关闭式失败
当工具定义遗漏安全分类时,注册表必须假设最严格的姿态:不可并发安全、非只读、非破坏性。一个忘记声明并发类别的工具会串行运行——这是安全的,即使较慢。反之——默认为可并发——可能损坏共享状态。同样的逻辑适用于权限检查:遗漏的权限处理器应委托给通用权限系统,而非静默地自动允许或自动拒绝。
一个权威列表,逐层收窄
维护恰好一个函数返回完整的候选工具集,尊重构建时标志。每个消费者从这个单一来源收窄,而非自行组装列表。收窄链按层应用拒绝规则、模式门控和上下文特定过滤。如果第二个代码路径构建自己的工具列表,当新工具或拒绝规则添加时,两个列表将不可避免地偏离。
并发分类是按调用的,不是按工具类型的
同一个工具类型可能对某些输入安全(只读文件 glob)而对其他输入不安全(文件写入)。并发分类器必须在分派时评估解析后的输入,而非在注册时。连续的安全调用合并为一个并发批次;任何不安全调用开始一个新的串行批次。如果分类器抛出异常,将该调用视为不安全并回退到串行执行——永远不要让错误升级为崩溃。
分区、排序、拼接以保持 cache 稳定性
将内置工具和插件提供的工具组合成单一池时,先对每个分区独立排序再拼接。交错两组会在插件工具按字母顺序排在内置工具之间时使缓存的 prompt 键失效。内置工具始终出现在最终列表的前面,且内置工具在名称冲突时胜出——插件不能静默覆盖内置工具。
在列表构建时门控功能标志工具
条件工具在单一权威列表内部使用功能标志检查在构建时包含或排除,而非在调用时。这将所有门控逻辑集中在一处,防止每个下游消费者需要复制相同的条件。如果门控逻辑泄漏到消费者,添加新标志需要更新每个调用处——这是偏差的必然来源。
通过别名保持向后兼容
重命名工具时,在工具定义上保留旧名称作为别名。当工具调用到达一个未知名称时,执行层回退到完整工具列表,仅在传入名称匹配别名而非主名称时接受该工具。这支持重命名的工具而不破坏旧的对话记录或缓存的模型补全。
适用场景
- 你的 Agent 运行时支持可扩展的工具集(内置、插件或两者)。
- 你需要将跨切面默认值(权限、并发、破坏性)统一应用于每个工具。
- 你将来自多个来源(内置和插件)的工具组合成一个池,需要 cache 稳定的排序。
- 功能标志或环境变量控制每个会话可用哪些工具。
- 工具会随时间重命名,旧的记录必须继续正确解析。
- 你的工具目录足够大,在每轮发送所有 schema 会浪费 token。
- 拒绝规则或模式切换必须对模型隐藏工具而不留过时引用。
权衡
| 决策 | 收益 | 代价 |
|---|---|---|
| 关闭式失败的安全默认值 | 遗漏的分类是安全的,而非危险的 | 某些工具在被显式选入之前运行得比必要的慢 |
| 单一权威列表 | 一处添加工具,一处审计 | 所有工具添加路由到同一文件,造成合并竞争 |
| 按调用的并发分类 | 即使对多态工具也能准确批次化 | 分类在每次分派时运行,增加每次调用的开销 |
| 分区-排序-拼接排序 | prompt cache 键在插件集变化时存活 | 插件工具始终出现在内置工具之后,与名称无关 |
| 在列表构建时门控功能标志 | 消费者永远不需要复制门控逻辑 | 会话中途切换标志需要重新组装工具池 |
| 基于别名的向后兼容 | 旧记录和缓存补全继续工作 | 别名集单调增长,每次未知名称回退都需要检查 |
| 延迟工具加载 | token 预算随实际使用的工具扩展,而非目录大小 | 模型调用延迟工具前必须额外执行一次发现调用 |
| 在组装和执行两层进行拒绝规则过滤 | 针对过时工具池的纵深防御 | 权限逻辑对每个被拒绝的工具运行两次——组装一次,调用一次 |
实现模式
- 通过单一构建器定义每个工具,为所有安全相关字段填充安全默认值。永远不要直接构造原始工具对象——构建器是关闭式默认值的咽喉。
- 在单一权威列表内部使用条件包含注册功能标志工具。不要将条件逻辑分散到消费者。
- 覆盖并发安全分类器,仅对真正的只读、无状态操作返回 true。默认值为 false。
- 独立覆盖只读和破坏性标志。它们不是相互推导的——一个工具可以是只读的但被标记为破坏性的,如果它有安全相关的副作用。
- 为任何有安全相关副作用的工具提供分类器提示。空提示会静默跳过安全分类器——这对良性工具可能是正确的选择,但在敏感工具上意外遗漏则是危险的。
- 仅在工具需要超出通用权限系统的逻辑时才覆盖权限处理器(基于路径的访问控制、配额检查等)。默认应委托,而非拒绝。
- 重命名时为工具定义附加别名。不要立即移除旧名称——它们必须在至少一个完整的弃用周期中存活。
- 对于支持延迟加载的工具,附加简短的搜索提示(几个描述工具名称中未涵盖的功能的词),使发现机制可以在不发送完整 schema 的情况下将用户意图匹配到延迟工具。
- 为每个工具设置显式的最大结果大小。仅对持久化输出会产生循环依赖的工具使用无界输出(如文件读取工具对自身输出进行摘要)。
- 通过注册表的组装函数组装组合工具池,永远不要通过临时拼接。组装函数处理去重、分区稳定排序和拒绝规则过滤。
- 永远不要在热路径(每请求)中调用完整的未过滤列表。始终通过收窄链调用,使拒绝规则和模式门控得到应用。
- 对于会创建导入循环的工具(工具 A 依赖模块 B,模块 B 依赖工具注册表),在 getter 内部懒加载工具而非在模块顶层。这打破循环而不产生运行时开销。
踩坑指南
抛出异常的并发分类器被视为"不安全"。 分派层将分类器调用包装在错误边界中并回退到串行执行。分类时抛出异常的工具会静默串行化而非崩溃。这是安全的,但可能掩盖缺陷——监控应该并发但意外串行化的工具。
并发工具的上下文修改器被延迟,不是即时的。 当一批并发工具运行时,它们产生的任何上下文修改回调都被排队并在整批完成后应用,而非每个工具完成后。同一并发批次中的工具不能依赖兄弟工具在同一批次中做出的上下文更改。
受模式限制的工具从池中被移除,不仅仅是隐藏。 如果运行时模式限制哪些工具可用(例如,沙箱 REPL 模式用 VM 托管的等效工具替代原始文件工具),受限工具被完全从池中移除。在权威列表中注册工具并不够——如果当前模式排除它的话。
拒绝规则过滤在两层进行,且两层都必要。 组装函数按拒绝规则过滤,使模型永远看不到被阻止的工具。但如果工具池在拒绝规则更新之前就已组装(会话中途的配置变更、动态策略推送),模型仍可能尝试调用现在被拒绝的工具。执行层必须逐调用重新检查权限作为第二道防线。
内置工具在名称冲突时静默胜出。 如果插件注册了与内置工具同名的工具,内置工具无警告地胜出。出现这种情况时应记录或告警,以避免不可见的插件工具覆盖,这会浪费插件作者调试的时间。
延迟工具需要在调用前进行发现步骤。 标记为延迟加载的工具发送给模型时不带完整 schema。如果模型不先执行发现查询就尝试调用延迟工具,schema 验证将失败。执行层应返回指向发现机制的提示,但这会多花一次往返。
Claude Code 实证
Claude Code 的工具系统是这些原则的生产实现,管理来自多个来源的数十个工具:
关闭式失败的默认值在单一构建器中。 运行时定义一个默认值对象,将并发安全设为 false、只读设为 false、破坏性设为 false。每个工具通过构建器构造,将工具的覆盖合并到这些默认值上。权限处理默认为延迟允许,意味着遗漏自定义权限检查的工具完全委托给通用权限系统,而非静默放行或阻止。
单一权威列表与逐步收窄。 一个函数返回详尽的候选集,包括通过条件包含门控的功能标志工具。第二个函数通过剥离被拒绝规则和模式门控的工具来收窄此列表。第三个函数将收窄后的内置工具与插件提供的工具组合,对每个分区独立排序以保持 prompt cache 稳定性。消费者始终调用适合其上下文的最窄函数——未过滤列表仅保留用于别名查找和自省。
按调用的并发分类与输入相关的批次化。 分派层在分派时使用解析后的输入调用每个工具的并发分类器。例如,文件搜索工具对只读 glob 模式是安全的,但对触发写入的模式不安全。连续的安全调用合并为一个并发批次;任何不安全调用开始一个新的串行批次。如果分类器抛出异常,运行时记录警告并回退到串行执行而非崩溃。
重命名工具的别名回退。 当工具调用到达一个未知名称时,执行层加载完整的未过滤工具列表并检查名称是否匹配任何工具的别名集。如果匹配别名,工具被接受;如果匹配主名称(应该已通过正常路径找到),则被拒绝以避免掩盖更深层的缺陷。这一设计让旧的对话记录和缓存补全在工具重命名后无需手动迁移即可存活。
大型工具目录的延迟加载。 标记为延迟加载的工具发送给模型时带有延迟加载标志而不带 schema。模型必须调用发现机制(类似于工具描述和提示的搜索索引)来检索完整 schema 后才能调用。这使每轮 token 预算与实际使用的工具成正比,而非完整目录的大小。运行时为每个延迟工具附加简短的搜索提示,使发现机制可以在不每轮发送完整 schema 的情况下匹配用户意图。
通过 getter lambda 懒加载以打破循环依赖。 少量工具会创建导入循环,因为它们依赖的模块本身依赖工具注册表。这些工具在 getter lambda 内部而非模块顶层加载,将导入推迟到首次访问,打破循环而不产生运行时开销或架构妥协。