Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/DeepSeek Harness/第2章

第2章:cordis 底座 —— 一切皆插件的地基

9 分钟 · 更新于 2026-09-01

第2章:cordis 底座 —— 一切皆插件的地基

dsh 的 185 个包能拼成一个运行时,靠的是一个叫 cordis 的依赖注入框架。本章讲清 6 个概念:Context、Service、Plugin、Fiber、inject、dispose。理解了它们,后面每一章的"某某插件注册某某服务"才不是黑话。

好消息:cordis 包带完整 TypeScript 源码src/*.ts 共约 90KB),不是打包产物。本章的每处引用都能点开原文。


一、先问一个问题:为什么 agent 运行时需要 IoC 容器?

假设你要写一个 agent 运行时。刚开始很简单:

typescript
const fs = new LocalFileSystem();
const shell = new LocalShell();
const agent = new Agent({ fs, shell, model });

然后需求来了:

  1. 要支持沙箱 —— 于是 fs 可能是 LocalFileSystem,也可能是 SandboxedFileSystem
  2. 沙箱后端还要分平台 —— Linux 用 Landlock、macOS 用 Seatbelt、Windows 用受限令牌
  3. 要能热重载插件 —— 用户改了配置,某个能力要在运行中被替换掉,且旧实例的监听器、定时器、临时文件都得清干净
  4. 每个 agent 要有自己的作用域 —— 子 agent 注册的工具不能污染父 agent
  5. 有些插件依赖别的插件 —— 沙箱要等策略解析器就绪,工具要等注册表就绪

到这一步,手工 new 和参数传递已经撑不住了。你需要的东西有名字:依赖注入容器 + 作用域 + 生命周期管理

dsh 没有自己造,而是用了 cordis。

cordis 的来历

cordis 原本是 Koishi(一个 TypeScript 聊天机器人框架)的插件系统内核。聊天机器人和 agent 运行时看起来八竿子打不着,但它们的结构性需求高度重合

需求聊天机器人agent 运行时
插件热插拔装/卸一个功能插件换一个能力实现
作用域隔离不同群组不同配置不同 agent 不同工具集
生命周期清理插件卸载要清掉监听器会话结束要清掉一切副作用
依赖声明插件 A 要等数据库就绪沙箱要等策略解析器就绪
配置驱动YAML 配置装载插件树YAML 配置装载插件树

dsh 把它作为 @deepseek-ai/cordis 重新发布(本机版本对应 cordis 4.x 线)。


二、六个概念

cordis 六个概念关系|1420

配图说明:root Context 通过 ctx.plugin() 启动插件,每个插件返回一个 Fiber(一次运行的生命周期);Service 子类构造即注册、卸载即注销;inject 声明依赖;ctx.fiber.dispose() 逆序清理一切副作用。底部是 Loader —— 把插件树搬进 YAML,dsh 的 cordis.patch.yml 每一行都是这样的 EntryTree 条目。

2.1 Context —— 依赖容器,也是你的世界

Context(上下文,代码里通常写作 ctx)是 cordis 里最核心的对象。它有两个身份:

身份一:服务的查找表。 你要用文件系统,就 ctx.fs;要用工具注册表,就 ctx.tools。所有能力都挂在 ctx 上。

身份二:你所在的作用域。 每个插件拿到的 ctx 都不是同一个对象,而是一个子上下文。你在自己的 ctx 上注册的东西,只在你的作用域里生效,你被卸载时它们全部消失。

源码里 Context 的实现有个关键细节 —— 它是个 Proxy

typescript
constructor() {
  this[symbols.isolate] = Object.create(null)
  this[symbols.intercept] = Object.create(null)
  const self = new Proxy<this>(this, ReflectService.handler)
  this.root = self
  // ...
  return self
}

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 一套独立工具集的机制。

2.2 Service —— 挂在 ctx 上的具名能力

Service 是一个抽象基类。继承它、在构造函数里 super(ctx, '服务名'),这个服务就自动注册到了 ctx 上:

typescript
constructor(protected ctx: Context, name: string) {
  name ??= this.constructor['provide'] as string
  // ...
  self.ctx.reflect.provide(name, self, this[symbols.check])
  return self
}

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 都会顺畅很多。

2.3 Plugin —— 一个函数,或一个类

插件在 cordis 里可以简单到只是一个函数:

typescript
const greeter = Object.assign((ctx: Context) => {
  ctx.on('app/ready', (message) => {
    ctx.logger.info('%s #%d', message, ctx.counter.next())
  })
}, {
  inject: ['counter'],
})

cordis/README.md

拿到 ctx、干活、结束。注册监听器、提供服务、启动定时器 —— 都在这个函数里做。

ctx.plugin() 启动它:

typescript
const root = new Context()
await root.plugin(Counter)   // 类插件:提供 counter 服务
await root.plugin(greeter)   // 函数插件:消费 counter 服务

2.4 inject —— 声明依赖,而不是猜时序

上面 greeter 有一行 inject: ['counter']。README 的解释是:

inject 告诉 Cordis 哪些服务必须在插件运行前存在。 (cordis/README.md,译)

这解决了插件系统最经典的坑:加载顺序。没有 inject,你只能靠"把 A 写在 B 前面"来保证时序,配置一改就崩。有了 inject,cordis 会等依赖就绪才启动你,依赖消失时把你停掉。

dsh 里到处是这个模式。比如 dsh-agent-loop 声明它注入 5 个服务:

注入的服务:agentssessionsllmtoolssystemPrompt:全部 5 个接口服务。 (dsh-agent-loop/README.zh.md

注意 —— 注入的是接口服务,不是具体实现。agent loop 不知道文件系统是沙箱版还是本地版,它只知道 ctx.tools 存在。第 4 章会展开这件事。

2.5 Fiber —— 一次插件运行的生命周期

ctx.plugin() 返回一个 Fiber。它代表"这个插件的这一次运行",是 cordis 生命周期管理的最小单位。

Fiber 负责的事在源码注释里说得很直白:

由 effect 返回、在销毁期间释放资源的函数。disposer 在拥有它的 fiber 卸载时按注册的逆序执行;它们可以是异步的,此时卸载会等待它们。 (cordis/src/fiber.ts:69-73,译)

三个要点:

  1. 逆序执行 —— 后注册的先清理,和栈的语义一致
  2. 支持异步 —— 卸载会 await,不会留下"还在跑的旧实例"
  3. 自动收集 —— 你注册的服务、监听器、effect 都自动挂到当前 fiber 上

2.6 dispose —— 一次调用,撤销一切

这是 cordis 最关键的承诺。README 的原话:

Effect、事件监听器和服务,都会在拥有它们的 fiber 被销毁时移除。 (cordis/README.md,译)

一行 await root.fiber.dispose(),整棵插件树连同它们注册的所有东西干净消失。

为什么这对 agent 运行时是刚需? 因为 agent 的生命周期是动态的:

  • 会话结束 → 该会话的插件作用域要撤销
  • 子 agent 完成 → 它注册的工具、监听器要消失,不能污染父 agent
  • 用户改配置 → 旧插件实例要停干净,新的才能起
  • 插件依赖的服务没了 → 插件自己也得停

dsh 的文档里反复出现"随调用方 fiber 一同 dispose"、"dispose 时全部撤销"这样的表述,讲的都是这个机制。第 5 章会看到 agent 创建失败时的事务性回滚,靠的也是它。


三、Loader —— 让插件树由配置文件驱动

前面六个概念是 cordis 的核心。但 dsh 还用了一个关键的官方插件:Loader

它做的事很简单:把插件树从代码里搬到 YAML 配置里

typescript
const root = new Context()
await root.plugin(Loader, { baseUrl: import.meta.url })

const id = await root.loader.create({
  name: './plugins/example',
  config: { enabled: true },
})

await root.loader.await()
root.loader.update(id, { config: { enabled: false } })

cordis-plugin-loader/README.md

Loader 维护一棵 EntryTree(条目树),每个条目的字段是:

字段含义
id稳定 ID,用来解析、更新、移除这个条目
nameLoader 要 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 的配置能力就齐了:

  • cordis-plugin-include —— YAML/JSON 配置文件的 include 支持
  • cordis-plugin-group —— 嵌套插件组(dsh 里的 planning / compaction / delegation 分组就是它)
  • cordis-plugin-hmr —— 热模块替换

四、把 cordis 的概念映射回 dsh

现在回头看 dsh,一切都能对上号了。

4.1 每个 dsh 包 = 一个 cordis 插件

打开任意一个 dsh 包的打包产物,第一行都是同一个模式:

javascript
import { Service } from "@deepseek-ai/cordis";

dsh-agent-loop/lib/index.js:1

185 个包,185 个(或更多)cordis 插件。没有例外。

4.2 "ctx 键"就是服务名

dsh 包服务类ctx 键
dsh-toolsToolRuntimectx.tools
dsh-sessionSessionStorectx.sessions
dsh-agentAgentRegistryctx.agents
dsh-agent-loopAgentLoopctx.agentLoop
dsh-skillSkillRegistryctx.skills
dsh-jobsJobRegistryctx.jobs
dsh-sandboxSandboxProviderctx.sandbox

4.3 agent 作用域 = 一个子 Context

dsh 文档里这句话现在读得懂了:

Agent.ctx 是 agent 的作用域上下文(dsh-scope,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。 (dsh-agent/README.zh.md

翻译成 cordis 术语:每个 agent 持有一个由 extend() 派生的子上下文,它在这个上下文里注册的一切,随它的 fiber 一起消失。

dsh 还专门包了一个 dsh-scope 包做"作用域标签 + 作用域过滤事件分发",让事件也能按 agent 隔离。

4.4 配置里的每一行 = 一个 EntryTree 条目

本机 profile 配置的片段 [实测]:

yaml
- id: python-workdir-guard
  name: 'dsh-toolbelt/python-workdir-guard'
  disabled: false
  config:
    toolNames: ['bash', 'pwsh']

对照 Loader 的条目字段表:id 定位、name 指定模块、disabled 控制启停、config 传参。四个字段全部来自 cordis,不是 dsh 发明的。


五、这个选择的代价

诚实地说,用 IoC 容器当 agent 运行时的地基是有成本的。

成本一:调用链变长,调试变难。 你想知道 ctx.fs.read() 到底走到了哪个实现,要先搞清当前上下文的 isolate 映射,再看是哪个包 provide 的这个名字。相比之下,Pi 那种直接 import 函数的写法,Ctrl+点击就能到底。

成本二:类型系统要靠声明合并撑住。 ctx.fs 这个属性在 Context 接口上原本不存在,是各个包用 TypeScript 的 declare module 往上加的:

typescript
declare module 'cordis' {
  interface Context {
    counter: Counter
  }
}

cordis/README.md

好处是类型安全,坏处是 IDE 跳转经常跳到声明而不是实现。

成本三:理解门槛前置。 你要改 dsh 的任何行为,都得先理解 fiber、dispose、inject、isolate。Pi 的扩展只要会写 TypeScript 函数就行。这是 dsh 最大的入门障碍,也是本章必须放在第 2 章的原因。

那为什么还值得? 因为前面列的那五个需求 —— 沙箱可替换、平台后端可切换、插件热重载、agent 作用域隔离、依赖顺序声明 —— 每一个手写都是坑,加在一起是灾难。dsh 用一次性的理解成本,换掉了 185 个包各自处理生命周期的重复劳动。

第 13 章会重新评估这笔账划不划算。


六、动手复核

powershell
# cordis 带完整 TS 源码,不是打包产物
Get-ChildItem "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\cordis\src"

# 六个概念各自的实现文件
#   context.ts  —— Context / extend / isolate / intercept
#   service.ts  —— Service 基类与自动注销
#   fiber.ts    —— 生命周期、disposer 逆序执行
#   registry.ts —— ctx.plugin / inject
#   events.ts   —— 事件总线
#   reflect.ts  —— Proxy 服务解析

# 验证"每个 dsh 包都是 cordis 插件"
Get-ChildItem "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-*\lib\index.js" |
  Select-String -Pattern 'from "@deepseek-ai/cordis"' | Measure-Object

# 看 Loader 的条目字段在真实配置里长什么样
Get-Content "$env:DSH_HOME\profiles\web\cordis.patch.yml"

七、总结

cordis 给 dsh 提供了六件东西:

  1. Context —— 带作用域的服务查找表,isolate() 让同名服务在不同分支解析到不同实现
  2. Service —— 具名能力的基类,构造即注册,fiber 卸载即注销
  3. Plugin —— 一个拿到 ctx 的函数或类
  4. Fiber —— 一次插件运行的生命周期,disposer 逆序执行且支持异步
  5. inject —— 声明依赖,把加载顺序交给容器而不是靠人肉排序
  6. dispose —— 一次调用撤销一切副作用

再加一个 Loader,把插件树搬进 YAML —— 这就是 dsh"一切皆插件"的全部物理基础。

下一章看这套机制在 dsh 里的实际形态:一个会话的插件图,是怎么被四层配置一层层叠出来的。


  • README-教程总览
  • 第1章-开篇-DeepSeek-Harness总览
  • 第3章-Profile与Patch分层-一个会话如何被组装
  • 第4章-Seam架构-抽象服务与具体实现的分离

本章目录
一、先问一个问题:为什么 agent 运行时需要 IoC 容器?二、六个概念三、Loader —— 让插件树由配置文件驱动四、把 cordis 的概念映射回 dsh五、这个选择的代价六、动手复核七、总结Related Documents
苏ICP备2025204887号-2