dsh 的 185 个包能拼成一个运行时,靠的是一个叫 cordis 的依赖注入框架。本章讲清 6 个概念:Context、Service、Plugin、Fiber、inject、dispose。理解了它们,后面每一章的"某某插件注册某某服务"才不是黑话。
好消息:cordis 包带完整 TypeScript 源码(src/*.ts 共约 90KB),不是打包产物。本章的每处引用都能点开原文。
假设你要写一个 agent 运行时。刚开始很简单:
然后需求来了:
到这一步,手工 new 和参数传递已经撑不住了。你需要的东西有名字:依赖注入容器 + 作用域 + 生命周期管理。
dsh 没有自己造,而是用了 cordis。
cordis 原本是 Koishi(一个 TypeScript 聊天机器人框架)的插件系统内核。聊天机器人和 agent 运行时看起来八竿子打不着,但它们的结构性需求高度重合:
| 需求 | 聊天机器人 | agent 运行时 |
|---|---|---|
| 插件热插拔 | 装/卸一个功能插件 | 换一个能力实现 |
| 作用域隔离 | 不同群组不同配置 | 不同 agent 不同工具集 |
| 生命周期清理 | 插件卸载要清掉监听器 | 会话结束要清掉一切副作用 |
| 依赖声明 | 插件 A 要等数据库就绪 | 沙箱要等策略解析器就绪 |
| 配置驱动 | YAML 配置装载插件树 | YAML 配置装载插件树 |
dsh 把它作为 @deepseek-ai/cordis 重新发布(本机版本对应 cordis 4.x 线)。

配图说明:root Context 通过 ctx.plugin() 启动插件,每个插件返回一个 Fiber(一次运行的生命周期);Service 子类构造即注册、卸载即注销;inject 声明依赖;ctx.fiber.dispose() 逆序清理一切副作用。底部是 Loader —— 把插件树搬进 YAML,dsh 的 cordis.patch.yml 每一行都是这样的 EntryTree 条目。
Context(上下文,代码里通常写作 ctx)是 cordis 里最核心的对象。它有两个身份:
身份一:服务的查找表。 你要用文件系统,就 ctx.fs;要用工具注册表,就 ctx.tools。所有能力都挂在 ctx 上。
身份二:你所在的作用域。 每个插件拿到的 ctx 都不是同一个对象,而是一个子上下文。你在自己的 ctx 上注册的东西,只在你的作用域里生效,你被卸载时它们全部消失。
源码里 Context 的实现有个关键细节 —— 它是个 Proxy:
(cordis/src/context.ts:71-84)
普通属性读取会走服务解析器 —— 也就是说 ctx.fs 不是一个普通字段,而是一次带作用域的服务查找。同一行代码 ctx.fs,在不同上下文里可能解析到不同的实现。这是后面所有"可替换"的物理基础。
Context 提供三个派生子上下文的方法,都不修改父级:
| 方法 | 作用 | 典型用途 |
|---|---|---|
| extend(meta) | 加一层元数据,原型继承父级所有属性 | 给上下文打标记 |
| isolate(name, label?) | 让某个服务名在子作用域里独立解析 | 给子 agent 换一套 fs 实现 |
| intercept(name, config) | 给某个服务注入一份配置,向下生效 | 给某个分支的工具改超时 |
isolate 的注释写得很清楚:
在返回的上下文之下,服务 name 的读写会解析到新的标签而非父级的,因此可以提供一个不同的实现而不影响父作用域。 (cordis/src/context.ts:110-120,译)
这就是 dsh 能给每个 agent 一套独立工具集的机制。
Service 是一个抽象基类。继承它、在构造函数里 super(ctx, '服务名'),这个服务就自动注册到了 ctx 上:
(cordis/src/service.ts:42-59)
注释里有本章最重要的一句话:
调用 ctx.reflect.provide(name, this, this[Service.check]),因此服务会在拥有它的 fiber 卸载时自动注销。 (cordis/src/service.ts:34-36,译)
注意"自动"两个字 —— 你不需要写任何清理代码。这个约定贯穿整个 dsh:所有 185 个包里,凡是提供能力的,都是一个 Service 子类。
回头看 dsh 的文档,你会发现所有服务描述都是同一个句式:
服务:ToolRuntime(ctx 键:tools) 服务:SessionStore(ctx 键:sessions) 服务:AgentRegistry(ctx 键:agents)
"ctx 键"就是 Service 构造时传的那个 name。 记住这个句式,读 dsh 任何一个包的 README 都会顺畅很多。
插件在 cordis 里可以简单到只是一个函数:
(cordis/README.md)
拿到 ctx、干活、结束。注册监听器、提供服务、启动定时器 —— 都在这个函数里做。
用 ctx.plugin() 启动它:
上面 greeter 有一行 inject: ['counter']。README 的解释是:
inject 告诉 Cordis 哪些服务必须在插件运行前存在。 (cordis/README.md,译)
这解决了插件系统最经典的坑:加载顺序。没有 inject,你只能靠"把 A 写在 B 前面"来保证时序,配置一改就崩。有了 inject,cordis 会等依赖就绪才启动你,依赖消失时把你停掉。
dsh 里到处是这个模式。比如 dsh-agent-loop 声明它注入 5 个服务:
注入的服务:agents、sessions、llm、tools、systemPrompt:全部 5 个接口服务。 (dsh-agent-loop/README.zh.md)
注意 —— 注入的是接口服务,不是具体实现。agent loop 不知道文件系统是沙箱版还是本地版,它只知道 ctx.tools 存在。第 4 章会展开这件事。
ctx.plugin() 返回一个 Fiber。它代表"这个插件的这一次运行",是 cordis 生命周期管理的最小单位。
Fiber 负责的事在源码注释里说得很直白:
由 effect 返回、在销毁期间释放资源的函数。disposer 在拥有它的 fiber 卸载时按注册的逆序执行;它们可以是异步的,此时卸载会等待它们。 (cordis/src/fiber.ts:69-73,译)
三个要点:
这是 cordis 最关键的承诺。README 的原话:
Effect、事件监听器和服务,都会在拥有它们的 fiber 被销毁时移除。 (cordis/README.md,译)
一行 await root.fiber.dispose(),整棵插件树连同它们注册的所有东西干净消失。
为什么这对 agent 运行时是刚需? 因为 agent 的生命周期是动态的:
dsh 的文档里反复出现"随调用方 fiber 一同 dispose"、"dispose 时全部撤销"这样的表述,讲的都是这个机制。第 5 章会看到 agent 创建失败时的事务性回滚,靠的也是它。
前面六个概念是 cordis 的核心。但 dsh 还用了一个关键的官方插件:Loader。
它做的事很简单:把插件树从代码里搬到 YAML 配置里。
(cordis-plugin-loader/README.md)
Loader 维护一棵 EntryTree(条目树),每个条目的字段是:
| 字段 | 含义 |
|---|---|
| id | 稳定 ID,用来解析、更新、移除这个条目 |
| name | Loader 要 import 的模块标识符 |
| config | 传给插件的配置 |
| group | 标记这是一个组,其 config 是子条目列表 |
| disabled | 停止该条目并阻止它启动 |
| inject | 为该条目追加所需服务或拦截配置 |
(cordis-plugin-loader/README.md)
这张表就是第 3 章的全部内容。 dsh 的 profile 配置文件里每一行,都是一个 EntryTree 条目。你在 cordis.patch.yml 里写的 id / name / disabled / config,就是上表的字段。
再看几个 Loader API,它们解释了 dsh 为什么能做到"改一行配置就换实现":
| API | 作用 |
|---|---|
| loader.create(options, parent?, position?) | 添加并启动一个条目 |
| loader.update(id, options, parent?, position?) | 更新、移动、重启一个条目 |
| loader.remove(id) | 停止并删除一个条目 |
| loader.await() | 等待挂起的导入和 fiber 重载完成 |
配合三个辅助插件,dsh 的配置能力就齐了:
现在回头看 dsh,一切都能对上号了。
打开任意一个 dsh 包的打包产物,第一行都是同一个模式:
(dsh-agent-loop/lib/index.js:1)
185 个包,185 个(或更多)cordis 插件。没有例外。
| dsh 包 | 服务类 | ctx 键 |
|---|---|---|
| dsh-tools | ToolRuntime | ctx.tools |
| dsh-session | SessionStore | ctx.sessions |
| dsh-agent | AgentRegistry | ctx.agents |
| dsh-agent-loop | AgentLoop | ctx.agentLoop |
| dsh-skill | SkillRegistry | ctx.skills |
| dsh-jobs | JobRegistry | ctx.jobs |
| dsh-sandbox | SandboxProvider | ctx.sandbox |
dsh 文档里这句话现在读得懂了:
Agent.ctx 是 agent 的作用域上下文(dsh-scope,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。 (dsh-agent/README.zh.md)
翻译成 cordis 术语:每个 agent 持有一个由 extend() 派生的子上下文,它在这个上下文里注册的一切,随它的 fiber 一起消失。
dsh 还专门包了一个 dsh-scope 包做"作用域标签 + 作用域过滤事件分发",让事件也能按 agent 隔离。
本机 profile 配置的片段 [实测]:
对照 Loader 的条目字段表:id 定位、name 指定模块、disabled 控制启停、config 传参。四个字段全部来自 cordis,不是 dsh 发明的。
诚实地说,用 IoC 容器当 agent 运行时的地基是有成本的。
成本一:调用链变长,调试变难。 你想知道 ctx.fs.read() 到底走到了哪个实现,要先搞清当前上下文的 isolate 映射,再看是哪个包 provide 的这个名字。相比之下,Pi 那种直接 import 函数的写法,Ctrl+点击就能到底。
成本二:类型系统要靠声明合并撑住。 ctx.fs 这个属性在 Context 接口上原本不存在,是各个包用 TypeScript 的 declare module 往上加的:
(cordis/README.md)
好处是类型安全,坏处是 IDE 跳转经常跳到声明而不是实现。
成本三:理解门槛前置。 你要改 dsh 的任何行为,都得先理解 fiber、dispose、inject、isolate。Pi 的扩展只要会写 TypeScript 函数就行。这是 dsh 最大的入门障碍,也是本章必须放在第 2 章的原因。
那为什么还值得? 因为前面列的那五个需求 —— 沙箱可替换、平台后端可切换、插件热重载、agent 作用域隔离、依赖顺序声明 —— 每一个手写都是坑,加在一起是灾难。dsh 用一次性的理解成本,换掉了 185 个包各自处理生命周期的重复劳动。
第 13 章会重新评估这笔账划不划算。
cordis 给 dsh 提供了六件东西:
再加一个 Loader,把插件树搬进 YAML —— 这就是 dsh"一切皆插件"的全部物理基础。
下一章看这套机制在 dsh 里的实际形态:一个会话的插件图,是怎么被四层配置一层层叠出来的。