夜雨聆风学习资料网

ARTICLE · 1352892

读懂 DSH 的插件机制

读懂 DSH 的插件机制
上一篇拆的是 evolve 怎么给 AI 造大脑,那是一个产品内部的认知设计。这一篇换个位置:站到要动手改它的那一侧——当二开的需求真的落到自己头上,你才会发现,决定这次改造难易的,不是代码写得漂不漂亮,而是它的组装机制允不允许你只加不改。

起因:一个不得不读源码的下午

项目上需要基于 dsh 做二次开发。

dsh 是 DeepSeek Harness 的缩写,开源的 AI 编程客户端。我们要做的事不复杂:给它接上公司内部账号,让用户必须先登录才能进工作区。

按经验,这类改动的第一反应是找配置文件。但翻了一圈发现行不通——它的登录逻辑不是配置项,而是代码。

于是只能往下翻。翻到一半,意识到一件更基础的事:我其实不是在看某个功能的实现,而是在看这个软件到底是怎么被组装起来的。

它的每一块能力——模型适配、工具注册、会话日志、界面——都不是写死在主流程里的,而是一个个独立单元。主流程干的事只有一件:把这些单元按依赖关系拼起来。

这个拼装机制叫 Cordis。

一、先说清楚它是什么

Cordis 是一个 TypeScript 的插件框架,以 vendor 方式内置在 dsh 里。它的作者也是 Koishi(一个知名聊天机器人框架)的作者,所以这套东西的成熟度不低。

但对从 Java 过来的人来说,最好的理解方式不是记概念,而是做个映射:

Cordis
Spring 对应物
说明
Cordis 容器
Spring IoC 容器
创建、组装、托管所有组件
插件
一个 @Configuration 类 / starter
独立功能单元
ctxApplicationContext
拿东西的入口
服务
@Service
 Bean
一个具名能力
inject: ['tools']@Autowired
声明"我要用这个"
cordis.ymlapplication.yml
声明装哪些组件

如果你按这个理解往下走,八成是对的。但有三处关键差异,恰恰是这次改造能不能做成增量的分水岭。

维度
Spring
Cordis
组装时机
启动时定死,运行中改不了
运行中随时装卸
依赖来源
按类型注入
按字符串名字找
卸载行为
没有这个概念
卸载即自动清理

第二条「按字符串名字找」,是理解后面一切的钥匙。

二、五个核心概念,其实只有两条规则

官方文档给了五个核心概念。但那五条压缩一下,其实是两条规则:

规则一:插件是个「贡献包」。

一段代码被加载后做三件事——注册服务、监听事件、挂载子插件。就这么多,没有别的要求。

规则二:所有人按名字找东西,谁也不认识谁。

这是整个机制的灵魂。看代码最直观。

一个提供方插件,想对外提供一个能力:

export class CorpAccount extends Service {  constructor(ctx: Context) {    super(ctx, 'corpAccount')   // 就这一句,注册成名为 corpAccount 的服务  }  async getState() { /* 读凭据 */ }}

一个消费方插件,想用这个能力:

export const inject = ['corpAccount']   // 我要这个服务export function apply(ctx: Context) {  const state = await ctx.corpAccount.getState()   // 拿得到,因为容器保证就绪}

注意两件事。

第一,消费方从来没有 import 过 CorpAccount 这个类。它只知道有个叫 corpAccount 的东西存在,符合某个接口。具体实现是谁写的、叫什么名字、在哪个包,它一概不知。

第二,容器看到 inject 声明后,会等到这个服务就绪才让插件启动。所以进到 apply 里面时,ctx.corpAccount 保证可用,不用写判空、不用写重试。

这两条合起来,就得到了一句很有分量的话:

换掉提供方,消费方一行都不用改。

这句话不是修辞,是这套机制的实际能力。而它,就是我们这次改造的着力点。

三、被严重低估的:装配层级

理解了「按名字找」,还要理解第二件事:这些插件是怎么被装进去的。

这是我觉得最容易被忽略、但工程价值最高的一层。dsh 的插件树不是一份配置文件拼出来的,而是多层叠加的:

图 1 插件树的装配层级与覆盖顺序

这套设计里有两个细节,值得单独说。

第一个:patch 是「按 id 整体替换」,不是字段级合并。

意思是,如果我要改 webserver 这一行的端口,写 patch 时必须把它的完整 config 都重述一遍,不能只写要改的那个字段。这看起来麻烦,但它消除了「我改了 A,结果 B 被谁悄悄覆盖了」这类问题——要么整行是我的,要么整行是别人的,没有中间地带。

第二个:patch 里插入的插件可以是文件路径。

这条让我当时愣了一下。插入的插件名允许是绝对文件系统路径、file URL 或者包名。也就是说,我可以在不发布 npm 包的前提下,把自己的插件挂进 dsh 的插件树——只要在 patch 里写个路径。

对二开来说这个口子太关键了:新增能力的成本,从「发一个包」降到了「写个文件、加一行配置」。

四、三个反直觉的地方

机制讲完,说三个第一次接触时一定会踩的点。这三条也是我翻源码时花时间最多的地方。

反直觉一:文件顺序不代表加载顺序

- name: './greeter.ts'    # 谁先加载?- name: './consumer.ts'   # 不一定

这两条是并发启动的。谁先谁后,由 inject 的依赖关系决定——consumer 声明了要用 greeter,它就一直等到 greeter 就绪才启动。把这两行顺序调换,结果完全一样。

这条直接回答了我们方案里的一个实际问题。我们要挂两个插件:一个账号服务,一个内部认证的对接层。谁写在前面?答案是:无所谓。只要对接层声明 inject: ['corpAccount'],容器会自己把顺序排对。

为什么这件事重要? 因为它把「启动顺序」从一个需要人工维护的隐性知识,变成了一个由声明推导出来的必然结果。人多手杂的时候,这类隐性知识最容易出错。

反直觉二:找不到依赖时,它会「等等等」而不是报错

这是最需要警惕的一条。

在 Spring 里,@Autowired 找不到 Bean,启动直接失败,报错告诉你少了哪个 Bean。

Cordis 不是。它会让插件停在 PENDING 状态,不加载、不报错、不输出,进程照常运行。

插件「没生效」时,第一件要做的事不是看代码,而是查它是不是卡在了 PENDING——多半是 inject 里写的某个服务,根本没人提供。

官方文档给了诊断办法,直接枚举插件注册表、把 PENDING 的挑出来:

for (const runtime of ctx.registry.values()) {  for (const fiber of runtime.fibers) {    if (fiber.state === FiberState.PENDING) {      console.log(`${fiber.name} is PENDING — a required service is missing`)    }  }}

插件的完整状态机是这样的:

图 2 插件 fiber 的状态流转

注意那条从 ACTIVE 绕回 PENDING 的虚线。它说明了一件事:依赖不是启动时检查一次,而是持续跟踪的。

如果运行中提供方被卸载,所有依赖它的插件会跟着卸载;提供方恢复,它们再自动加载回来。所以运行中替换实现是安全的——竞态和悬空引用这些问题,机制层面就已经消掉了。

反直觉三:注册会自动撤销,但你得「把东西交给它管」

用 Cordis 的注册接口搭起来的东西——事件监听、子插件、路由——卸载时都会自动清理。你不需要写 removeListener,不需要写 close()。

但前提是它们真的经过 Cordis 的手。 自己 new 出来的定时器、socket 连接、文件监听,不在管辖范围内,必须用 ctx.effect() 包住,并返回一个清理函数:

ctx.effect(() => {  const listener = createServer().listen(0)  return () => listener.close()   // 卸载时自动调用})

这条对我们的登录流程恰好是天然的契合。回环回调要起一个本地监听端口,方案要求「只接受一次请求,用完即关」——用 ctx.effect() 包住,登录成功或用户取消时端口自动释放,不用写额外的清理逻辑,也不会因为异常路径漏关。

五、为什么这套机制值得单独写一篇

回到最开始那个问题:我们到底要改什么。

把机制摸清之后,方案里那五处改动,性质一下子清楚了:

#
改动
性质
为什么
①
账号服务插件
纯新增
,往树上挂
靠服务名替换,上游无感
②
认证对接插件
纯新增
,往树上挂
同上
③
欢迎页改登录
改官方代码,但很小
这里不是服务而是页面,得动 UI
④
启动准入判定
改一个表达式
一行
⑤
更新源改内网
构建期配置
改环境变量指向,不碰代码

①② 是「加东西」,③④ 是「减东西」(删掉填 key 的表单、删掉准入的第二个条件),⑤ 是「改配置」。

没有一处需要重构官方架构。

之所以能这样,全靠第三节那个「按名字找服务」的机制。我核对过上游代码:账号能力就是一个标准的 Service 子类,占着 ctx.deepseekAccount 这个名字;界面层的账号控制器写的是 inject: ['deepseekAccount', 'agents']。

也就是说——只要我们的插件用同一个名字注册服务,上游的账号界面和 RPC 控制器就会自动用上我们的实现,它们那侧一行都不用改。

这就是「增量式改造」在工程上的确切含义。它不是一句「我们尽量少改」,而是一个由架构机制保证的结论。

顺便,机制也解释了另一个我们讨论过的问题:为什么改了授权之后必须改更新源。

因为我们的插件和 patch 文件,都躺在应用目录和用户数据目录里;而自动更新会覆盖整个应用目录。一次静默更新,插件、patch、页面改动全部回滚,表现就是「登录功能时好时坏」。

所以更新源必须和插件改动同批交付——这不是一个可以往后放的优化项,而是这套机制下的必然要求。

最后

这篇写的是一次二开过程中的机制梳理。但拉远看,我觉得它有价值的地方不在 dsh,而在那个对比。

我们习惯的框架,组装发生在启动那一刻——配置定死,跑起来就改不了,遇到变化只能重启、只能重新发布。这套思路在单体时代没问题。

但当软件开始需要「按环境长成不同的样子」,需要「不同客户装不同能力」,需要「改了不用重启」——启动时组装这件事,本身就成了约束。

Cordis 的选择是把组装变成运行时的、持续的能力。代价是引入了 PENDING 这种静默状态,需要一套新的排查直觉;换来的是,能力之间彻底解耦——谁提供、谁消费,两边互不相识,替换任何一方都不惊动另一方。

这个取舍的方向,和我们前一篇里 evolve 那句「模型越强,越应该把决策权交给模型」其实是同一种思路:不是在既有结构上加约束,而是先把结构做对。

结构对了,加法才能做轻。

而对做企业级落地的人来说,这一点格外实用:判断一个系统能不能低成本二开,不该看它有没有插件市场,而该看它的依赖是不是声明式的、组装是不是可叠加的。 前者是宣传口径,后者才是你改代码时会不会被拖住的原因。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料