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 Code | 20,000 字符(内容搜索) | 触发落盘,完整内容写文件 |
| Codex | 按内容类型选策略 | 截断加外置 |
| OpenCode | 2,000 行 / 50 KiB | 原文落盘,返回预览与路径 |
| DeepSeek Harness | 20 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 节的接入记录,都是为了在增长时保持可预测。