图解 DSH · 01 目录 术语表 上一章 下一章

一切都是插件:依赖、注册、卸载

上一章的循环里,ctx.llm、ctx.tools 都不是循环自己写的,而是别的插件提供的。这一章看这些插件是怎么接上去的。

Cordis插件框架
负责装上、卸下插件
插件本章的例子
bash 工具
tools工具服务
工具都注册在这里
system-prompt系统提示词服务
拼出给模型的说明
agent-loopAgent 循环
它也是一个插件
这一章回答一个问题:工具、界面,连 Agent 循环本身,都能单独装上、单独换掉。DSH 是怎么做到的?答案分三步:
  1. 每个插件只写两样:依赖(要用哪些服务),和启动的入口:通常是启动函数 apply;像 shell 这样写成一个类的插件,由构造函数启动。启动函数里,插件把自己的东西交给服务,这叫注册,可以注册好几样;插件也可以自己当服务给别人用,或者挂钩子。插件没有固定的种类。
  2. 插件框架 Cordis 只看依赖齐没齐:缺一个,插件就 PENDING(等着);补齐了,自动启动。
  3. 卸载插件时,它注册过的东西会被撤掉:常用的注册接口都替插件把“怎么撤掉”交给了 Cordis。

?先猜:哪些是插件?

在 DSH 里,下面哪种说法对?

答案是 C。DSH 没有改不动的核心:工具、网页上的按钮、Agent 循环,全都是插件。底下的插件框架叫 Cordis,它不认识这些插件是干什么的,只负责把它们装上、卸下。所以什么东西都能做成插件。

点一个选项。

1一个插件只写两样

回到开头的问题:每个部件都能单独装、单独换,是怎么做到的?第一步,先看一个插件长什么样。例子是 DSH 真实的 bash 工具。

bash 工具插件(简化)

export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', …]  // 依赖
 
export function apply(ctx) {                // 启动函数:依赖齐了,Cordis 才调用它
  ctx.systemPrompt.section({ name: 'tool:bash', order: …, text: '…' })  // 注册①:一段用法说明(order 是排序号,第 06 章讲)
  ctx.tools.register(bashTool)              // 注册②:一个叫 bash 的工具
}

inject 里写的 tools、shell、systemPrompt,也都是插件,只是专门给别的插件用,这类插件叫服务。注意同一个服务有两种写法:插件名用短横线(system-prompt),代码里的服务名用驼峰(systemPrompt),说的是同一个东西。但服务名不一定能从插件名推出来:同一个服务可以由不同的插件提供,比如 shell 这个服务,可以由 bash-local 或 bash-sandbox 提供(第 07 章)。插件通过 ctx.名字 用它们,上一章循环里的 ctx.llm 就是这么来的。启动时发生的事,用对话来说是这样:

Cordis
新插件?你的依赖是什么?
bash 工具
我要用 tools(管工具)、shell(执行命令)、systemPrompt(拼系统提示词)这几个服务。
Cordis
都在。启动吧。
bash 工具
那我开始注册:给 systemPrompt 一段用法说明,给 tools 一个叫 bash 的工具。
tools
收到。以后模型就能用 bash 这个工具了。
一个插件只写两样:依赖(inject)和启动的入口(通常是 apply)。
启动函数里每把一样东西交给一个服务,就是一次注册。bash 注册了两样。

不只是工具。下面四个插件,平常被叫成不同的“种类”,其实长得一样:依赖加启动函数。差别只在启动函数里用 ctx 做了哪几件事:

平常叫“工具插件”
bash 工具
依赖tools、shell、systemPrompt
做了注册:给工具服务一个工具,给系统提示词服务一段说明
平常叫“服务”
tools
依赖systemPrompt
做了提供服务:别的插件写 ctx.tools,用的就是它
注册:自己也往系统提示词服务放段落
平常叫“核心”
agent-loop
依赖agents、llm、tools 等 6 个
做了提供服务:agentLoop,负责让 Agent 跑起来
注册:往系统提示词服务放一个变量
平常叫“钩子插件”
超时插件
依赖tools
做了挂钩子:每次工具执行时插手,到点喊“停”(第 02、05 章)

插件没有固定的种类。“工具插件”“钩子插件”只是按它主要干的事起的外号。插件在启动函数里通过 ctx 做的事,只有三种:

  1. 提供服务:给别的插件一个 ctx.名字 来用。tools、system-prompt、shell、agent-loop 都是。
  2. 往服务里注册东西:工具、系统提示词段落、网页上的组件……交给哪个服务,那个服务到时候来用。bash、网页左上角的标志都是。
  3. 挂钩子:ctx.on('名字', 处理函数),别的插件喊这个名字时,你就插手一下。超时插件、下一章的报时插件都是。它其实也是一种注册,注册的是“到这一刻叫我”。

一个插件常常同时做好几种,像上面的 tools 和 agent-loop。看懂一个插件,就问三句:它提供了什么服务?往哪个服务注册了什么?挂了哪些钩子?这里用到的 ctx.tools.register、ctx.systemPrompt.section、ctx.on,都会替插件把“怎么撤掉”交给 Cordis,所以卸载时自动撤掉(第 3 节)。

提供服务,代码长什么样(简化过的真实代码)

// shell 服务的规定(shell 插件):一个类,构造时报上名字export abstract class ShellExecutor extends Service {  constructor(ctx) { super(ctx, 'shell') }   // 服务名就在这里定  abstract resolve(…)                         // 实现必须提供的方法  abstract execute(…)} // 一个实现(bash-local 插件):继承它,导出这个类export default class LocalBashExecutor extends ShellExecutor { … }

Service 是 Cordis 提供的服务基类。和前面导出 apply 的插件不同,服务类插件导出一个类,Cordis 启动它时调用构造函数,super(ctx, 'shell') 这一句把它挂到 ctx.shell 上;inject 里写着 shell 的插件,这时依赖才算齐,跟着启动。服务名来自 super(ctx, 'shell') 这一句,跟插件叫什么无关。

2依赖缺一个,插件会怎样?

第二步:插件只写了依赖,没写“谁先启动”。那启动顺序由谁定?由 Cordis 定:依赖不齐的插件不会启动,齐了才启动。互相没有依赖的插件,谁先谁后不保证。

DSH 启动时要装哪些插件,写在一份名单里,这份名单叫配置文件。在名单里删掉一行,就等于不装那个插件。

配置文件里删掉 system-prompt 那一行,其他不变。启动后,bash 工具会怎样?

答案是 C。依赖不齐,插件就停在 PENDING:不报错,也不启动。等依赖补齐,Cordis 会自动把它启动,不用谁去重启。

点一个选项。

自己试一试:现在 system-prompt 没装,好几个插件是 PENDING。把它打开,看谁会自己启动。

开关:

图里的名字:llm 负责连接模型;agent 管理正在跑的 Agent;shell 负责执行电脑指令;timeout-policy 是“跑太久就喊停”的超时插件(第 05 章);agent-loop 就是 Agent 循环。

写进 inject 的是必需的依赖:缺一个,插件就等着。还有一种可选的依赖:不写进 inject,插件照常启动,等真要用的时候再问一句 ctx.get('名字')。有就用;拿到的是 undefined,就由插件自己决定怎么办。比如 bash 用审批服务就是这样(第 05 章)。

3卸载插件,注册的东西跟着撤掉

第三步:能装,还得能卸,才谈得上“单独换掉”。像 ctx.tools.register、ctx.systemPrompt.section 这样注册的东西,插件不用自己写清理代码。

bash 工具执行指令时,在你的文件夹里新建了一个文件。之后把 bash 工具卸载,这个文件会怎样?

答案是 B。每次注册,ctx.tools.register(…) 也好,ctx.systemPrompt.section(…) 也好,都会把“怎么撤销”记在插件名下;卸载时 Cordis 逐个撤销。插件自己在外面做的事,比如新建的文件,框架不管。

点一个选项。

① 装上 bash 工具
② 模型调用 bash
③ 卸载 bash 工具
工具服务里
+ bash
bash
bash 自动清理
系统提示词里
+ bash 用法说明
bash 用法说明
bash 用法说明 自动清理
你的文件夹
—
+ 新建的文件
文件还在

怎么做到的?这些注册接口里面都做两件事:把东西放进去;同时把“怎么撤掉”交给 Cordis,记在这个插件名下。卸载时,Cordis 按交来的倒序,把这些撤销一个个执行。简化后大致是:

ctx.effect(() => {
  工具表.add(bashTool)                  // 做这件事
  return () => 工具表.delete(bashTool)  // 交回“怎么撤掉”,Cordis 记在插件名下
})

所以,只有把“怎么撤掉”交给了 Cordis 的事,卸载时才会被撤掉。插件自己开的定时器、网络连接、子进程,Cordis 不知道;要让它们在卸载时停下,就用同一个办法自己交给它。个别注册接口只把撤销函数还给你、自己不交,也照这样用 ctx.effect 包一层:

ctx.effect(() => {
  const 定时器 = setInterval(检查, 60000)
  return () => clearInterval(定时器)    // 卸载时 Cordis 会调用它
})

新建的文件是另一回事:那是用户要的成果,卸载插件本来就不该删它。

✓三道小题

一个插件只写哪两样?“注册”发生在哪儿?

想好了,看答案
依赖(要用哪些服务),和启动函数 apply(写成类的插件则是构造函数)。注册是在启动函数里做的事:把自己的东西交给服务,可以不止一次。

在配置文件里把 bash 工具挪到第一行,它会先启动吗?

想好了,看答案
不会。行的顺序保证不了谁先启动;bash 要等的服务写在依赖里,依赖齐了它才启动。

卸载 tools 服务,system-prompt 会跟着停吗?

想好了,看答案
不会。是 tools 依赖 systemPrompt 服务,反过来不成立。但依赖 tools 的插件,比如 bash 工具,都会退回 PENDING。上面的演示里可以试。
每个部件都能单独装、单独换,因为 DSH 里一切都是插件,而每个插件只写两样:依赖(inject)和启动的入口(通常是 apply);启动函数里,插件把自己的东西注册给各个服务。依赖缺一个就 PENDING,补齐了自己启动;卸载时,注册过的东西跟着撤掉,靠的是注册接口把“怎么撤掉”交给了 Cordis。插件自己开的定时器、连接,或者没替你交的注册,要用 ctx.effect 自己交。 插件还能在 Agent 干活的过程中途插手,比如工具跑太久就喊它停下。这靠的是钩子,下一章讲。