Skip to content

06|能力不是一个函数:定义、实现者、消费者为什么必须成套 ​

先别往工具注册表里塞一个"跑命令"的函数 ​

第 04 章刚看完工具调用,第 07 章还要拆工具管道。中间这章专门挡住一个几乎人人都会走的捷径:

"加能力 = 写一个工具,执行逻辑里直接拉起一个子进程去跑命令。"

这条捷径能跑通第一天的 demo。第二天你要沙箱,把直接拉子进程换成沙箱调用,模型看见的参数格式跟着改。第三天要支持另一种 shell,再复制一份工具。第四天命令跑到远端,文件工具和语言服务还在本机,Agent 开始在两个世界上自相矛盾。

这个项目把拒绝写在面上:一个能力(seam)是可替换的,而且必须三个角色成套——能力定义声明合同该长什么样,实现者去实现它(可以有多个,比如本机执行和远端沙箱执行),消费者去使用它,常见的消费者就是模型面对的一个工具。一个包可以兼任多个角色,但单独一个角色不叫一个完整的能力。加能力,就是把三件都设计完。

这一章只证明:工具函数是敲门的手。能力是门后面那整个可替换的世界。


1. 能力是三人组,不是"接口"的别名 ​

先给判断:把能力理解成一个普通的类型接口,整套纪律会塌成一句口头禅。

能力定义不是随便一个类型声明,而是一个真正拥有身份和生命周期的服务——它拥有自己在共享环境里的那个键、以及一套词汇表。实现者往这个键上登记具体实现。消费者只注入这个键,不依赖任何具体实现者的类型细节。

为什么能力定义必须是一个有生命周期的服务,而不是一个普通接口?第 02 章已经有答案:注册是可逆的效果,服务跟它所属的插件生命周期一起生灭,第二个想登记同一个键的实现会被直接拒绝。普通接口没有生命周期,也没有"这个上下文里到底挂了谁"这个概念。一个能力定义包的典型写法是:定义一个抽象基类,具体实现者继承它、作为插件加载、登记到那个共享的键上——一个上下文一个实现,再挂一个就报错。

为什么要拆成三个包而不是捆在一起?因为合同、实现、消费面三者的变化速率不同。捆在一个包里,换本地执行器为沙箱执行器,会把模型看见的参数格式一起搅碎,尽管模型侧的合同根本没变。这里有一条容易忽略的边界:依赖注入机制本身已经解决了运行时"谁提供、谁等待"的问题——消费者声明自己需要这个键,它的加载会一直挂起,直到有人提供了这个键的实现。但包边界是另一件事。依赖注入不会阻止你把三件事写进同一个包,然后在换实现的时候误伤模型看到的参数格式。

术语也守得很死:能力(seam)指三人组整体。一个角色要叫角色名、类名、服务名、合同或扩展点,不要把某一个具体的定义包单独叫成"某某能力"。一个词只指一件事。

如果没有这层命名会怎样?每个人都说自己在加能力,有人加了个类型接口,有人加了个工具函数,有人加了个共享键。三个月后你有十几个"能力",没有一个能换实现而不改模型契约。


2. 用"执行命令"这个能力家族当尺子量一次 ​

判断:"执行命令"这个能力家族值得当尺子,不是因为它实现了 bash,是因为三件事被切在变化速率真正不同的地方。

能力定义拥有词表和两段式调用。 它一开篇就划清楚边界:前台命令和后台进程句柄归这个能力,后台任务的归属、轮询、通知归另一个专门的任务能力,好让执行器本身独立于会话状态管理。抽象定义只逼实现者做三件事:

  • 归一化:把调用方的请求补全、封顶,变成完全确定的执行规格
  • 前台执行:跑完整个命令,非零退出、超时、中止都归一化成一个结果,不直接抛异常
  • 后台启动:立刻返回一个句柄,后台执行没有执行器层面的超时限制

这条"归一化"方法本身升成了全仓模板:包边界上,默认值必须显式写出来,不能隐式散落。默认值应该体现在实现里"补全成完整规格"这一步,不是藏在"执行"方法里的某个兜底判断。调用方看见的请求形状(可选的工作目录、可选的超时)和实际执行时的规格形状(工作目录、超时都必填)是两种不同的类型,调用方不能把请求直接当规格塞进去执行方法。

实现者拥有怎么跑,不拥有模型怎么说话。 比如一个本机实现者:命令通过 shell 拉起,进程组通过底层子进程能力去启动。它拥有默认值、截止时间、失败原因分类、给模型看的终端环境、后台输出的合并方式。执行政策不在这里——政策在工具执行前的检查点,或另一个专门的沙箱实现者身上。这个本机实现者声明的运行时依赖只有能力定义本身和子进程能力,不包含工具注册表——它不登记任何工具描述。

沙箱版本是另一个实现者,不是工具函数里的一个条件分支。它把跟本地完全相同的调用参数套进沙箱运行时,继承本地的进程处理机制,另外报告运行模式、强制程度、拒绝原因。就连沙箱本身的策略也不放在这个实现者身上——策略在专门的沙箱策略能力上,具体选哪种沙箱运行时又是另一个能力。执行世界被指到另一条能力链,这个 shell 实现者只负责套上它。

消费者拥有模型看见的那扇门,不拥有门后面的房间。 面向模型的工具自称"模型面对的消费者",它的运行时依赖是工具注册表和 shell 能力定义。真正跑命令的动作只有两步:先归一化请求,再交给能力定义的执行方法去跑。消费者代码里没有依赖任何具体实现者的类型。它声明的运行时依赖只有能力定义和工具注册表,没有任何具体实现者。后台执行的句柄也不由这个消费者自己管理任务语义——启动出来的句柄会交给专门的任务能力去处理。定义开头那句"任务不归执行器管"在这里兑现。

最终的组装配置,只需要选一个执行器实现,再选它需要的模型工具。要支持沙箱,再加一个沙箱实现者进配置。组合发生在启动配置里,不是在工具函数里写一个条件分支。第 09 章会回到这层组合。

如果没有这三刀会怎样?工具会直接拉子进程,沙箱版会复制一份参数格式,其它 shell 变体再复制一份。模型侧出现几个几乎一样的工具,提示词和权限矩阵一起爆炸。反过来,如果能力定义里写死了具体怎么拉起进程,远端沙箱就不是一个实现者,是一次代码分叉。


3. 换执行世界,模型契约不能跟着裂 ​

判断:能力值不值得受拆包的代价,就看你能不能只换实现者,让命令执行、文件访问、持久终端、语言服务一起搬家。

这个例子不是假想:文件系统和子进程共享同一个执行世界的底层抽象,把它们指到远端沙箱,命令执行、伪终端、语言服务跟着一起走,不需要为每个能力再单独写一份沙箱版本。本机 shell 实现者已经把真正的拉起动作交给了更底层的子进程能力,沙箱版 shell 实现者再把参数交给沙箱能力。更下面这层远端世界只要实现这两条更底层的能力,shell 消费者和它的模型参数格式可以一个字节都不动。

这就是第 00 章说的"贵的那件事"。玩具 Agent 也能跑 bash 命令。它不能在不改模型契约的前提下,把命令执行连同读文件、开语言服务一起搬到另一台机器上。

有些实验性的执行世界目前还只是探索性的实现,第一次路过可以先当它不存在;等你熟悉了标准样本之后,看见它时用这把尺子去量:它有没有换实现者而不碰模型面对的那个消费者?有,它就是在用能力机制。没有,它就是又一次代码分叉。

如果三件事捆在一个函数里会怎样?每次换执行世界都是一次产品分叉。不同产品形态各有一份参数格式,四份几乎一样又不完全一样的校验逻辑。模型在不同部署里学会四套微差,你自己也说不清哪一份才是真正的合同。


4. 不要预防性拆包,也不要只交一个角色 ​

判断:三人组成套,不是"每个函数都拆三个包"。

有一条明确的刹车规则:角色的变化速率真的不同才拆包;只有一个想得出来的实现者和一个消费者时,先放在一个包里,等第二个实现者出现再拆。模型接入这个能力就是一个反例:能力定义和消费者被折进同一个包,因为这里的消费面就是循环本身,不是一张可换的模型参数格式;各家模型的适配器才是需要独立拆开的实现者。预防性拆包,换来的是一堆空转发的包,不是真正的可替换性。

另一边也不能偷工:加能力意味着三件都设计完。只交一个工具注册,你加的是一个函数,不是一个能力。只交一个类型接口,你加的是一句注释,不是一个共享的键。只交一个没有消费者的实现者,模型走不到它,别的插件也不知道该依赖什么。

对照第 03 章:接口是合同,默认循环实现是默认司机。那是同一把尺子打在控制脊上。扩展依赖能力定义,组装层才有权点名某个具体实现者。"扩展插件依赖能力定义,绝不依赖具体实现者"这条纪律,现在有了操作含义——每个消费者包声明的运行时依赖列表,就是这句话的单元测试。

第 07 章会钻进工具管道:政策、超时、沙箱否决挂在可拦截的检查点上,不进循环,也不进某个具体实现者。读那章时记住,管道是消费者登记和执行的地方,不是能力本身。模型面对的工具站在管道里,能力定义站在管道后面。


5. 结语:带走一句话 ​

能力不是你登记进工具注册表的那个函数——那只是模型敲门的方式;真正可换的是门后面成套的定义、实现者和消费者,少一件,你换的就不是执行世界,只是又一次代码分叉。