Skip to content

1. 工具从代码到模型眼前的六站 ​

上一篇讲完了设计。设计出来的东西要真的出现在模型眼前,中间还有一段路。

拿工具设计第 10 节那个代码搜索工具做主角,跟着它走一遍。

第一站,声明。 开发者在代码里写下一个对象:面向模型的名字、说明、参数结构;面向宿主的输出声明、执行函数、超时、并发安全标记。这一站决定了"模型将来看到什么"与"宿主将来知道什么",两者要划清。

第二站,注册。 这个对象进入注册表,落到某一层。如果同一层里已经有一个同名工具,要按顺序裁决谁赢。

第三站,发现。 如果这个工具来自文件系统(例如一份 Skill),它要先被扫描出来;如果来自代码注册,它随进程启动就在。

第四站,过滤。 按当前作用范围裁剪:这个会话该看到它吗?它被显式禁用了吗?

第五站,渲染。 转成面向模型的形式。这一步有预算——目录有长度上限,超限截断。

第六站,失效。 之后如果这个工具改了、删了、或者它所在的服务重连了,变更要能传到已经读过它的人那里。

六站里,第一站与第五站决定模型看到什么;第二、三、四站决定"哪些能到模型眼前";第六站决定"到的时候是不是最新的"。

站做什么典型失效
声明结构、说明、执行体结构与实现不一致
注册进注册表,按层归位重名覆盖
发现从来源扫描出来缓存未失效
过滤按作用范围与开关裁剪漏挂或错挂
渲染转成面向模型的形式超预算被截断
失效变更传导缓存不更新

四家的差异分布在这六站上:前两站设计不同,中间两站差别最大,后两站都有但成熟度不同。


2. 第一站:声明 ​

DeepSeek Harness 的工具定义是一个完整对象,包含面向模型的结构、输出声明、执行函数、宿主使用的调度元数据、可选的界面展示投影。

几个字段的分工值得看。

字段作用是否进模型请求
参数结构面向模型的参数定义是
输出声明输出结构与渲染函数否
执行函数返回规范值否
超时工具级超时否
并发安全能否与其他调用并发否
展示投影界面怎么显示否

文档里有一条硬约束:输出声明、执行函数、内容投影、超时、并发安全、展示投影,绝不能泄漏到模型请求中。

这条约束的意义在于分清"模型看到什么"与"宿主知道什么"。实现细节、超时、并发安全都是宿主的调度信息,模型不需要知道,知道了反而占空间。

输出声明用独立结构,不复用入参结构,原因是输出通常比输入复杂(分页、错误、部分成功),而且需要被校验。

2.1 其余三家的声明层 ​

Codex 的可加载工具与常驻工具分开,搜索索引的条目保存搜索文本与工具规格,并支持只迭代工具名而不构造结构的操作——这个优化避免了大批工具的结构在搜索时被全部实例化。

OpenCode 的叶子工具各自是一个插件单元,声明所需权限动作。

Claude Code 的工具定义在源码里,说明可以是静态常量,也可以是运行时计算的函数。后者有个隐患:说明的运行时变化会让结构缓存失效。修法是把会变的内容移到附件,第 5 站展开。


3. 第二站:注册 ​

只有 DeepSeek Harness 有完整的分层注册表。

注册落入调用方作用范围对应的层:宿主行与仓库插件落全局层,由预设常驻组合挂载的插件落该预设的层。提供方名称在每层内唯一。

读取时把全局层与观察范围的链合并,最近层的条目赢得重名。

这个设计的价值在两处。第一,它让"不同的预设看到不同的工具集"成为注册表的能力,不再依赖装配时的特例。第二,它让重名裁决有明确的层级依据,不必靠注册顺序。

另外三家没有等价机制。Claude Code 用白名单加延迟集合分层,Codex 用"常驻与可加载"二分,OpenCode 用装配配置。

三者的共同点是分层发生在装配时,注册表本身不带分层。这个差别在工具数量不大时不明显,在需要多套配置共存时(例如同一进程里跑不同预设)会显现。

单层内的裁决顺序是提权字段、提供方顺序、本地顺序,摘要按名称排序。顺序固定意味着同名覆盖可预测。


4. 第三站:发现 ​

DeepSeek Harness 的 Skill 发现规则:扫描项目、自定义、用户三个根;支持目录 bundle(含一份说明文件)与平铺文件两种形态;刻意不支持发现嵌套的说明文件;解析元数据,必填名字与说明;监视这些根,新增、改名、删除无需重启即可到达。

不支持嵌套的理由是成本:嵌套扫描的耗时随目录深度上升,而收益(更细的组织)小于同名冲突带来的麻烦。

缓存有一个细节:发现缓存以解析后的作用范围链为键。所以重设父级(空会话重组)无需注册表变更即可被下一次读取看到。

这个细节解决一个具体问题:会话中途切换工作目录后,哪些 Skill 适用会变。如果缓存不按作用范围链做键,切换后可能读到旧结果。


5. 第四、五站:过滤与渲染 ​

过滤决定哪些条目对当前作用范围可见。Skill 侧有几个控制字段,覆盖几种可见性:

组合模型可见用户可见适用
默认是是常规 Skill
禁模型调用否是危险操作、需人确认
禁用户调用是否内部策略
都禁否否临时停用,保留内容

渲染把条目转成面向模型的形式。这一站的核心约束是预算:目录渲染有说明长度上限,超限截断。

Claude Code 在渲染站做过两处改动,都有量化。

第一处,延迟工具只渲染名称,搜索提示不渲染。文档说明这是对照实验的结论:搜索提示没有带来收益。

第二处,代理列表从子代理工具的说明搬进附件消息。原因是列表随外部服务异步连接、插件重载、权限模式变化而变,进而使全部工具结构的缓存失效。文档给出的量化是约占全集群缓存重建的 10.2%。

两处改动的共同逻辑:渲染站决定"什么内容出现在哪个位置",而位置决定了它变化时会波及多少缓存。

5.1 反例:把超时值写进说明 ​

见过一种做法把当前超时值渲染进工具说明,好处是模型知道现在限多久。

代价是每次调超时都要重建全部工具结构——因为说明变了,前缀变了。而超时本来与模型的决策无关,它只需要知道"这个工具可能很慢"这一句定性描述。

判据是:会变的内容不该待在稳定位置。与超时同类的还有当前并发数、当前限流阈值、当前可用工具列表。


6. 第六站:失效 ​

前面五站都假设"读到的是最新内容"。这一站处理读到旧内容的情况。

注册表在以下情况发失效事件:提供方变更、运行时变更。事件不带差异内容,消费方用自身的查找选项重新取快照。

这是"失效即重取",没有做增量更新。好处是简单且不会漏;代价是每次失效都要全量重取,工具多时开销不小。

另一处细节:提供方的列举被拒绝时,系统记录日志并从不完整观测中省略该提供方的结果;显式的不完整观测提供可用候选项,但不使结果可缓存;格式错误的候选项快速失败。

三档处理对应三种情况:提供方挂了(省略)、部分可用(标记不完整,不缓存)、数据格式坏了(快速失败)。把三者分开,避免了"一个提供方挂掉导致全部缓存不更新"这类连锁。

6.1 延迟加载的触发信号 ​

延迟机制有两种触发信号:白名单(不在名单里就延迟)与数量阈值(超过 N 个才延迟)。

白名单更可预测,但要维护名单,且名单会腐化——只增不减。数量阈值更自动,但行为会随工具数变化而突变:昨天还能直接调的工具,今天要搜索了。这个突变在生产环境里会造成困惑。

机制细节在上一篇展开,这里补实现层面的对照。

实现延迟的触发条件加载后的可见性
Claude Code不在核心白名单即为延迟需显式搜索并执行
Codex进可加载集合加载后进入工具集
OpenCode无统一机制由装配决定
DeepSeek Harness无统一机制由装配决定

前两家有统一机制,后两家没有。这个分布与工具来源有关:当来源是"用户随时可能接入任意服务"时,统一延迟是必需品;当来源在部署时确定时,装配即可。


7. 六站的失效形态 ​

把每一站的失效列齐,排障时才能从症状反推到站。

站失效形态症状排查入口
声明结构与实现不一致接受了一个做不到的调用对照结构与执行体
声明该隐藏的字段泄漏模型请求里出现超时、并发标记检查请求体
注册重名覆盖调到了另一个同名的打印该层全部条目
注册层归属错某个预设看不到本该有的工具检查注册时的作用范围
发现缓存未失效新增的 Skill 不出现清缓存后重试
发现改名残留目录里出现新旧两个名字查增量更新是否处理删除
过滤漏挂该可见的对某些范围不可见检查控制字段组合
渲染超预算截断说明被砍半,匹配信号丢失统计目录总长度
渲染会变内容在稳定位置重连后缓存命中率骤降对比前缀哈希
失效增量更新漏删除删掉的内容仍在生效对比全量与增量结果

第五行与第十行同源:增量更新在"新增"上总是正确的,在"删除"上容易漏。原因是文件系统层面"改名"是删除加新增,而多数实现只处理了新增那一半。

一个便宜的防护是定期做一次全量重取并与增量结果对比。差异不为零就说明增量有问题。


8. 结果侧的截断 ​

截断这件事在前三站里没有位置——它发生在工具执行完之后。但它是"工程实践"里最容易出问题的一环,值得单独看。

实现阈值超限动作
Claude Code20,000 字符(内容搜索)触发落盘,完整内容写文件
Codex按内容类型选策略截断加外置
OpenCode2,000 行 / 50 KiB原文落盘,返回预览与路径
DeepSeek Harness20 MB 原始输出 / 250 条匹配 / 2,000 字节单行溢出则失败;超内联上限写外置并给定位

四处差别值得注意。

量纲不同。 字符、行加字节、原始输出大小。这反映各家对"什么会爆"的判断不同:Claude Code 认为字符数最直接,OpenCode 认为行数与字节都要管,DeepSeek Harness 认为原始输出与内联展示要分开管。

超限动作分两类。 截断并继续(前三家),与直接失败(最后一家在原始输出溢出时)。后者的理由是不解析不完整的流——截断后解析出来的结果不完整,而模型无法知道它不完整。

只有部分实现区分内联上限与原始输出上限。 区分开的好处是内联可以小(省窗口),原始输出可以大(不丢数据),中间用外置存储衔接。

单行长度限制只有部分实现有。 这条处理压缩过的代码时必需——那种文件可能只有三行,每行几万字符。

8.1 完整的读取实现 ​

抽象说明不如看具体实现。一个文件读取工具用五个常量协同工作。

常量值作用
最大行数2,000单次返回的行数上限
最大字节50 KiB单次返回的字节上限
单行最大长度2,000 字符单行超长时截断并加后缀
首块读取256 KiB判定编码与是否二进制
媒体文件上限20 MiB图片与文档的内联上限

两个上限同时生效,先到者截断。 这个设计处理一类具体情况:代码文件通常行短,两千行可能只有几十 KB,先撞行数;但压缩过的代码或数据文件可能三行就几百 KB,先撞字节。只设一个上限会出现两种失效:只设行数,遇到长行文件时一次返回几兆;只设字节,遇到空行很多的文件时返回几万行。

分页是返回结构的一部分。 返回里有一个可选字段记录下一条的行号,被截断时才有该字段。这与"只在真截断时报上限"是同一条原则:不截断时不给分页信息,避免模型误以为还有更多而去翻页。

分页的另外两个细节:偏移超出范围时报错,不返回空(空结果会被理解成"文件是空的",实际是"你翻过头了");目录列表与文本读取用同一套分页结构,省掉一组重复的参数定义。

单行截断要带可见标记。 一行超过两千字符时,截断并在末尾加一段说明。这个标记的作用不是提示模型,是保证"内容被改过"这件事在文本里可见。没有标记的话,模型会看到一段看似完整的内容而它实际被截断了——这比截断本身更危险。

二进制要先判定。 读取前先读首块判断类型,判定为二进制直接报错。理由是二进制内容的截断没有意义:任何一段字节片段都不是"部分可见的内容",只是一堆乱码。返回乱码会让模型尝试解读,而解读结果完全是编造的。

图片与文档走另一条路径,有独立的上限。它们不作为文本读出,而是作为附件交给模型。

行定位的实现代价。 为了在不解码全文的前提下定位第 N 行,这套实现用一个按子树记录字节数与换行数的结构维护已读块,查找某一行变成一次顺序统计查询。

这个优化不是必需的——多数文件不大,直接解码再切分也能用。它只在两种情况有价值:文件极大(几十 MB)且需要随机偏移读取;同一文件在一个会话里被多次按行读取。

记录它是因为它说明一件事:读工具的分页不是简单的数组切片,当文件大到不能整读时,行号定位本身需要数据结构支持。


9. 权限的三种粒度 ​

粒度表达能力盲区
工具名级"这个角色不能写文件"不能表达"只能写某些路径"
权限动作级"能用命令,命令里的写操作单独授权"依赖每个工具正确声明
策略与命名空间级按命名空间整体开关粒度仍在工具层

三家都做不到参数级白名单。这类限制通常靠沙箱或文件系统权限实现,不在工具层的职责内。


10. 配置分层与可见性 ​

工具的运行时行为常受配置影响,而配置的层数决定了"同一份代码在不同部署里行为不同"的范围。

一个完整的配置分层通常有四到五层,优先级从高到低:命令行参数、环境变量、项目配置、用户配置、内置默认。

对工具系统来说,有两类配置要单独考虑。

影响工具可见性的配置。 例如"哪些外部服务被信任""哪些工具被禁用"。这类配置的改动会让工具集变化,进而让缓存失效。它们应当被当作装配期配置,改动后要求重启或显式刷新。

影响单次调用行为的配置。 例如超时、结果上限、是否沙箱。这类配置可以运行期改,但必须不进入模型请求——一旦进入,每次改动都会破坏前缀缓存。

区分两类的判据是:改动它会不会改变"模型看到什么"。会,属于装配期;不会,属于运行期。

把运行期配置写进工具说明是常见错误(第 5 站的反例)。后果是每次调超时都要重建全部工具结构,而超时本来与模型无关。


11. 完整接入的记录 ​

前面六站是分开讲的,把它们合到一次真实的接入上,才能看清每一项具体要落到什么程度。

假设要给已有的 Agent 加一个"查询工单状态"的工具。

声明。 参数结构只有两个字段:工单号(字符串,必填)与是否包含历史(布尔,默认否)。输出声明三段:状态(四种枚举)、摘要(一句话)、历史(可选数组)。

两个决定:工单号不设格式校验(各团队格式不同,校验过严会拒绝合法输入);历史做成可选(多数查询不需要,默认返回会让结果体积翻倍)。

注册。 归属全局层——它不依赖任何特定项目。提供方名称用"工单"这个领域词,不与已有条目重名。

发现。 它不是文件系统条目,是代码注册,不涉及扫描路径。

过滤。 需要决定它对哪些范围可见。它要访问外部系统,而某些范围可能不允许外部访问。这种情况下应当按范围分层注册,不放全局层。

渲染。 说明长度控制在两百字节内:说清查工单状态、排除"不用于创建或修改工单"、说明返回什么。不写实现细节。

失效。 代码注册的变更随版本发布生效,不涉及运行期失效。但要注意:如果说明里嵌了"当前支持的状态值"这类会随外部系统变化的内容,它就成了会变内容待在稳定位置。

六站过完之后还有一件事:用一个明显该选它的任务与一个明显不该选它的任务各测一次。前者验证能被选中,后者验证不会被误选。

两个测试都通过,才算接入完成。只测前者是常见疏漏——它能被选中,但不该被选中的时候也被选中,代价是每次误选一次无效调用。


12. 三条判断 ​

12.1 注册表分层何时必需 ​

判据是是否有两个以上的配置需要在同一进程内共存。没有,装配即可;有,需要分层。

这条判断的实际意义:分层是成本,它要求每个提供方想清楚自己属于哪一层。只有一套配置时,分层的收益为零。

12.2 延迟加载的触发信号 ​

白名单与数量阈值的选择,取决于工具集的变化频率。工具集稳定(部署时确定),用数量阈值;工具集频繁变化(用户随时接入),用白名单加显式标记。

12.3 Skill 与工具的取舍 ​

同一个能力可以用 Skill 描述,也可以用工具实现。

方式占用稳定性适用
工具固定声明空间执行稳定无需中间判断的动作
Skill目录少量加正文几 KB模型按步骤执行,可能偏需要中间判断的流程

判断标准是这个能力是否需要模型在中间做判断。需要,用 Skill(模型能变通);不需要,用工具(执行稳定且省 Token)。

一条能力既需要判断又步骤固定时,拆开:Skill 写判断原则与流程,工具写可执行步骤。

12.4 三条判断的共同前提 ​

三条都假设一件事:工具与 Skill 的数量会增长。如果数量固定(例如一个专用 Agent 只有五个工具),所有判断都不重要——五个工具全量注入即可,不需要分层、不需要延迟、不需要取舍。

数量增长带来的不只是成本,还有排序问题:新增的条目该放在哪一层、该不该常驻、与已有条目是否冲突。第 3 站的分层与第 11 节的接入记录,都是为了在增长时保持可预测。