最近整理了一下 Biny Agent 的工具系统。
给 Agent 增加一个工具其实不难:定义参数 Schema,注册工具,再实现执行函数。但接入 MCP、插件和各种扩展以后,工具数量越来越多,问题就变了。
只读一个文件,有必要把上百个工具的定义都交给模型吗?缺少能力时,模型能不能自己找到工具?不同来源的调用,能不能共用一套权限和执行机制?如果一次写入中途断线,又该不该重试?
我现在更关心的是,工具从被发现到真正产生副作用,中间的每一步能不能说清楚。
这篇文章就沿着这条链路,聊聊我在 Biny 中的实现和取舍。
1. 注册了工具,不代表模型每轮都要看到它
我先区分了两个集合:系统拥有的工具,以及模型当前可用的工具。
在 Biny 中,内置工具以及 MCP、Plugin、Skill、Subagent 相关的工具入口,统一进入 ToolRegistry。注册表维护名称、描述、参数 Schema、来源和能力信息。
但注册只是说明系统知道这个工具,不意味着每一轮请求都要把它的完整定义放进模型上下文。Skill 的说明文档也有自己的加载流程,不能把它和工具 Schema 混为一谈。
假设接入几个 MCP Server,一共提供 100 个工具。用户只是想修改一个配置文件,此时把日历、邮件、GitHub 等工具的参数定义全部发给主模型,会产生很多与当前任务无关的输入。
所以我的处理方式是:工具目录保留完整能力,模型侧只开放当前需要的一部分。
自动工具选择模式下,会保留 Read、Write、Edit、Bash 等基础 Coding 能力,以及工具发现、结果回读等必要入口;其他工具按任务选择。具体如何呈现,还会随模型的工具交互模式调整。
工具目录负责“有什么”,模型工具集负责“现在用什么”。 两者不需要始终一样。
2. 先预选,缺少能力时再搜索
只给基础工具也有问题:主模型后来需要访问 GitHub,却没有相关工具,怎么办?
工具定义我延迟加载。一开始我只给主模型基础能力和这一轮用得上的那几条,完整参数不跟全目录一起塞进去。
请求开始前:我先预选
自动选择时,我让辅助模型看当前请求和最近的对话,从目录里挑相关工具。它看到的是名称和短描述,不是每条工具的完整参数。
你要看某个 PR,它就可以先挑出相关的 GitHub 能力。你点名了某个有效工具,我在本地直接放进去,不靠它猜。
预选我设了超时和预算。失败了,我留下基础能力和还有效的历史能力,不退回把全部工具展开。
这个预算管的是这一轮新自动选进来的工具。已经在用的、必须留的,我不拿它去淘汰。所以它不是整个会话工具集的硬上限。
我拿同一批任务比过两条路。每回合都做辅助预选,8 个任务都做完。只留基础和历史工具、缺了再用 ToolSearch,做完 4 个。没做完的那一侧,常常一次搜索都没发出去。
预选更慢,也更贵。第一个主模型请求的中位延迟是 2.28 秒,另一侧是 0.106 秒。ToolSearch 次数是 0 对 2。主模型和辅助模型加起来,预选用了 95,982 token,按需搜索用了 65,312。后面这个数更低,是因为有一半任务没跑完,不能当成省下来的成本。
样本只有 8 个。我据此留下了每回合预选,没有按工具定义占窗口的比例自动切换。这个阈值还没做实验。
另外,固定留在主模型身边的只有读、写、改、命令、工具搜索和回读结果。其余工具留在注册表里,靠预选、搜索或你点名再进来。当时这组固定定义从 11,605 字降到 5,668 字。每次请求至少少带这一截工具说明。这是字符数,我没有把它折成 token,也没有单独测这一截省了多少延迟。
运行过程中:主动发现缺失能力
预选只是我在回合开始前的预测。多步任务后面还会缺工具,所以我留了 ToolSearch。
它区分两种情况:
- 知道完整工具名: 从本地注册表精确查找。
- 只知道要做什么: 把需求和候选工具的简短描述交给辅助模型,进行语义选择。
辅助模型返回的名称还要和真实注册表核对。凭空生成一个工具名,不能让系统获得不存在的能力。
成功发现后,只把匹配工具开放给后续模型步骤,而不是顺带展开整个 MCP Server。
图 1:目录可以很大,但每一步只需要披露当前相关的工具。发现成功后,下一步模型才能使用新增能力。
举个例子,用户先要求查看 PR,随后又要求读取关联 Issue。第一步预选未必包含 Issue 工具,主模型可以在执行过程中再搜索并补充它。
成功发现的工具还可以在会话后续回合中复用,前提是注册仍然有效。这样能减少重复搜索,也会带来累积问题:任务不断变化,工具集可能越来越大。
这是一项需要继续评测的取舍,而不是已经解决了所有上下文管理问题。
3. MCP 连接、工具发现和实际调用,要分开处理
接入 MCP 后,我发现还有三个动作容易混在一起:连接服务、发现工具、执行工具。
它们的状态并不相同。
| 环节 | 回答的问题 | 不能据此推断什么 |
|---|---|---|
| 连接 | 远端服务现在能否通信? | 所有工具都应该进入上下文 |
| 发现 | 目录中有哪些相关能力? | 缓存中的工具现在一定可执行 |
| 执行 | 这次调用能否获得结果? | 发出请求就代表操作已完成 |
Biny 会在 Runtime 启动时异步连接启用的 MCP Server,并给连接准备设置等待预算。慢服务可以继续在后台连接,不需要让整个 Agent 无限等待。
成功获取的工具目录,会在同一 Runtime Host 内短期缓存。符合缓存条件的服务重连时,可以先利用已有目录进行发现,实际执行仍然需要等待连接就绪。
缓存的元数据,不是服务在线的证明。
真正调用时,要检查连接和工具定义。如果服务更新了 Schema,旧调用需要重新发现和准备,不能直接沿用。单个服务的连接异常也独立处理,尽量避免影响其他服务。
这里还需要分清两种收益:目录缓存主要减少重复发现的工作;减少主模型无关输入的,是工具预选和按需披露。
辅助模型本身也消耗 Token,并增加等待时间。主模型输入变小,只能说明其中一项成本下降,不能直接证明总成本更低。
4. 调用形式可以不同,执行入口必须统一
模型发现一个工具,只代表它具备被调用的条件,不代表获得了执行权限。
我不希望内置文件工具、MCP 工具和脚本组合调用各自维护一套权限逻辑。来源越多,分散的执行边界就越难核对。
因此,Biny 用 ToolExecutionCoordinator 协调工具调用。把主要步骤简化后,大致是:
参数校验 → 执行准备 → 权限判断 → 资源调度 → 目标复核 → 执行与记录 → 返回结果。
执行准备阶段会确定真实路径、操作目标、风险和资源访问范围。权限判断尽量绑定具体操作,例如修改哪个文件、修改什么内容,而不只是批准一个工具名称。
调度层根据工具声明的访问范围处理冲突。涉及文件修改时,执行前还会复核目标,避免审批之后文件已经变化,却继续按旧预览写入。
图 2:这是主要执行步骤的概念图。状态记录贯穿调用过程,并不只在工具结束时发生。
Code Mode 我做成可选,默认还是一条一条直接调工具。打开之后,模型写一段 JavaScript,我放在 QuickJS 里跑。脚本里能用的是我核对过的只读能力:读文件、按名查找、按内容查找、回读已经归档的工具结果,后来又加上记忆召回、历史搜索、任务状态和技能查找。
写文件、跑命令、起进程、激活技能,以及插件和 MCP 自己报上来的工具,不因为名字像就放进去。能不能进,我认的是宿主登记过的那一份,不认调用方自己填的元数据。排上队、审批回来之后,我再对一次当前登记还在不在。
脚本里的每一次读取仍走协调器:同一套权限、预算、取消和记录。结果未知的调用我不重放。脚本本身碰不到文件系统、网络和进程。MCP 调用失败时,我把说明文字留在回给模型的内容里。前面如果先塞了一大段资源,不能把失败原因挤掉。凭据在送回之前打掉。
5. 文件编辑:既要改对位置,也要确认版本
文件编辑是我投入比较多的一部分。
最直接的方案,是让模型输出修改后的完整文件,再整体写回。它容易实现,但为了改几行代码重新生成大型文件,输出成本高,也可能误改无关内容。
所以我保留了几种局部编辑协议。
字符串替换:简单,但要处理歧义
模型提供 old_string 和 new_string,工具找到旧文本并替换。它适合明确的小范围修改,对模型也比较直观。
问题是,同一段代码可能出现多次,复制的缩进、空白或换行也可能不同。
Biny 优先精确匹配,必要时有限度地容忍格式差异。默认要求匹配位置明确;如果需要替换所有相同片段,则必须显式使用 replace_all。
出现歧义时,我更倾向于报错,让模型补充上下文,而不是静默选择第一个位置。
编辑失败可以重新定位,错误地修改成功反而更难发现。
Hashline:用行号和摘要核对锚点
Hashline 在读取时给每行附上类似 12#A1B2C3D4 的引用标识,编辑时重新核对当前行。
它解决的是:模型依据旧文件定位,但文件已经变化。如果对应行的位置或内容不再匹配,锚点就会被拒绝。
这里的示例摘要只是格式演示。它也不是对整份文件语义的证明,更不能代替写入前的版本检查。
Hashline 目前仍是实验性模式,我没有足够评测数据证明它在整体任务成功率上优于其他方式。
结构化 Patch:适配支持对应协议的模型
对于支持原生结构化 Patch 的模型,Biny 也提供 apply_patch 入口。模型通过路径、上下文和修改片段表达变更。
我没有强行把所有模型统一到一种格式,而是依据接口和明确声明的协议适配。几种方式可以放在一起看:
| 方式 | 模型如何描述修改 | 主要需要核对什么 |
|---|---|---|
| 字符串替换 | 旧文本、新文本 | 匹配是否明确,是否要全部替换 |
| Hashline | 行号与摘要锚点、修改操作 | 锚点是否仍有效,操作是否冲突 |
| Patch | 文件路径、上下文、增删片段 | 协议是否合法,上下文能否定位 |
但正确定位,还没有解决写入安全。
假设 Agent 读取文件后,用户在编辑器里改了同一个文件。Agent 继续写入,就可能覆盖用户的新内容。
因此,编辑链路还会核对相关文件快照,并检查准备或审批后的目标变化。写入时先生成临时文件,再按创建或覆盖场景提交,同时记录变更证据。
图 3:定位协议回答“改哪里”,文件校验回答“现在还能不能改”。图中是共用约束的归纳,各协议的具体校验步骤并非完全相同。
这不等于绝对的并发安全。版本校验和文件系统提交之间仍有竞态窗口,尤其是外部进程同时原子保存文件时;多文件修改也不能因此视为一个原子事务。
我目前追求的是减少明显的过期写入和错误覆盖,并在无法确认状态时返回错误。编辑协议可以变化,底层的一致性约束不能随之丢掉。
6. 工具结果也需要按需读取
工具 Schema 只是上下文的一部分,返回结果同样可能很大。
搜索整个项目、读取日志、执行一个输出很多内容的 Shell 命令,都可能一次产生大量文本。全部塞进后续请求,会挤占真正用于推理的空间。
Biny 对超过预算的结果采用归档与受限预览:可归档的完整结果保存在本地,模型先拿到长度受限的内容预览和归档引用,需要更多内容时再通过 read_tool_result 读取。
这里的预览主要是截取内容,不应理解成辅助模型已经完整读过并做了语义总结。归档本身也有大小和保留数量限制,引用并非永久有效。
例如命令返回一大段日志,模型可以先看预览定位报错,再回读相关区段。它不必为了查看几条异常,把整份日志反复带进上下文。
这和按需发现是同一个思路:工具定义按需披露,工具结果按需读取。
7. 报错不代表没执行,结果未知也不是普通失败
工具恢复最容易出问题的地方,是把“客户端收到错误”理解成“操作没有发生”。
例如,一个 MCP 工具已经向远端发送创建请求,但收到响应前连接断开。远端可能已经创建成功,只是客户端不知道。
直接重试,就可能重复创建。
所以 Biny 会记录执行状态,包括未开始、运行中、副作用已提交、成功、失败、取消和结果未知等。状态记录用于判断执行走到了哪里,而不是只保留一句报错。
图 4:客户端看见的是同一次断线,远端却可能处于不同状态。因此,断线本身不足以支持安全重试。
对于派发后发生的 MCP 连接错误,当前实现会返回结果未知,而不是自动重放原调用。后续需要结合操作 ID、执行日志和远端已有对象等证据核查。
操作 ID 能帮助关联记录,但只有远端协议真正支持幂等或去重,它才可能进一步帮助防止重复操作。单有一个本地 ID,并不能保证安全重试。
同样,记录了文件提交,也不代表后续测试通过;拿到了工具响应,也不一定能证明目标应用中的业务效果符合预期。
模型说“完成了”,不能替代执行证据。 恢复任务时,我依据记录下来的执行状态继续。
最后
做 Biny 的过程中,我逐渐觉得,工具系统的难点不在于接入多少工具,而在于管理好它们从被发现到真正执行的整个过程。
注册,不等于模型必须看到;看到,不等于获得执行权限;发出请求,也不等于任务已经成功。
我现在的设计基本围绕这几个边界展开:让模型按需获得能力,让工具共用一致的执行机制,让文件修改和外部操作有据可查。
相比继续增加工具数量,我觉得这些底层设计更值得投入。
项目地址: Biny Agent
实现索引: