Cordis 是什么
在开始讲机制之前,先搞清楚这个问题:Cordis 是什么东西,它从哪里来,为什么 dsh 要用它?
它不是 DeepSeek 自己写的
Cordis 是一个独立的开源 TypeScript 插件框架 ,不是 DeepSeek 写的,来自 cordiverse 社区。DeepSeek 把它 vendor(内嵌)进了 dsh 的代码仓库(放在 vendor/cordis/),并做了一些定制化修改。
它的定位,用官方 README 的话说:
a TypeScript plugin framework for applications that need explicit dependency injection, scoped services, lifecycle-managed cleanup, and optional configuration-driven loading.
翻译成人话:一个帮你把应用拆成插件的框架,每个插件按需加载,卸载时自动清理,插件之间通过「服务」和「事件」通信而不是相互 import。
插件框架解决什么问题
假设你要从零搭一个 Agent 系统。最朴素的写法大概是这样:
typescript
// 所有东西揉在一起,直接 import
import { ToolsRegistry } from './tools'
import { SessionStore } from './session'
import { AgentLoop } from './agent-loop'
import { LLMAdapter } from './llm-deepseek'
const tools = new ToolsRegistry()
const session = new SessionStore()
const llm = new LLMAdapter()
const loop = new AgentLoop(tools, session, llm)
loop.run()
这样写能跑,但有几个问题:
- 换个组件很麻烦 :想把
LLMAdapter换成另一家的,要找到所有import的地方改 - 加载顺序靠手工保证 :
AgentLoop必须在其他三个都初始化之后才能创建 - 没有热重载:改一个配置只能重启整个进程
- 清理是噩梦 :
session要关连接,loop要停定时器,谁来保证顺序?
Cordis 的解法 :不直接 import,通过一个共享的 ctx 容器互相发现;加载顺序由依赖声明自动推导;每个注册都有配套的清理函数,卸载时自动执行。
这就是为什么 dsh 选了它:dsh 要支持热重载、任意替换模型/沙箱/工具提供方,Cordis 天生就是为这个场景设计的。
最小示例:五行理解核心思路
typescript
import { Context, Service } from 'cordis'
// 1. 创建根容器(整个应用共享这一个)
const root = new Context()
// 2. 把「工具注册表」作为插件挂上去
// 它注册为 ctx.tools,其他插件可以直接用 ctx.tools,不需要 import
await root.plugin(ToolsService)
// 3. 把「Agent 循环」作为插件挂上去
// 它声明 inject: ['tools'],会自动等 tools 就绪后才启动
await root.plugin(AgentLoop)
// 4. 卸载其中一个插件
// Cordis 自动执行所有清理,依赖它的插件也会自动卸载
await root.plugin(ToolsService).dispose()
一个 ctx,所有东西挂在上面,互相通过 ctx 发现对方,卸载时自动反向清理------这就是 Cordis 的核心思路。dsh 里有几十个插件,全都用这个方式组织。
为什么要先学 Cordis
dsh 没有传统意义上的「框架核心」。模型适配器是插件,工具系统是插件,Agent 循环本身也是插件------甚至 Session 日志、权限控制、沙箱隔离,都是以插件形式挂载的。
这不是设计夸张,是字面意思。官方文档直接说:
There is no privileged core to patch: you extend dsh by mounting a plugin beside the others.
驱动这一切的底层框架就是 Cordis 。理解了 Cordis,就理解了 dsh 的所有扩展机制。本文结合 Cordis 源码(位于 vendor/cordis/src/),逐一讲透它的五个核心概念。
一、Context:不是对象,是代理
我们先从最基础的开始。每个 Cordis 插件都会收到一个 ctx 参数,但这个 ctx 不是普通的对象------它是一个 Proxy(代理)。
源码(vendor/cordis/src/context.ts):
typescript
// Context 类的构造函数
constructor() {
// ...
// 用 Proxy 包装自身,所有属性读取都经过 ReflectService.handler 处理
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
// ...
}
为什么用 Proxy?因为 dsh 要实现服务按需查找 :当你写 ctx.tools 时,不是从一个普通对象上读一个属性------而是触发代理,去服务注册表里找名为 'tools' 的服务实例。
这个设计解决了一个核心问题:消费方不需要 import 提供方。
typescript
// ❌ 传统做法:直接 import,强耦合
import { ToolsService } from './tools-service'
const tools = new ToolsService()
// ✅ Cordis 做法:通过 ctx 查找,解耦合
// ctx.tools 在运行时通过代理解析,提供方可以任意替换
ctx.tools.register(myTool)
当你换掉工具服务的实现(比如切换到远程工具执行),消费方代码一行都不用改。
Context 是有层级的
每个插件运行在自己的 子 Context 里,子 Context 继承父 Context 的服务,但可以有自己的隔离服务实例。这是 dsh 实现「每个 Agent 有自己的工具集」的底层机制。
二、Plugin + Fiber:插件的生命周期
插件的三种写法
Cordis 接受三种形式的插件:
typescript
import { Service, type Context } from '@deepseek-ai/cordis'
// 形式一:函数插件(最常用)
export function apply(ctx: Context) {
console.log('插件已加载')
}
// 形式二:对象插件
export const myPlugin = {
name: 'my-plugin',
apply(ctx: Context) { /* ... */ },
}
// 形式三:Service 子类(需要对外暴露服务时用)
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myService') // 注册为 ctx.myService
}
}
Fiber:插件实例的状态机
每个被加载的插件实例都有一个对应的 Fiber。Fiber 是 Cordis 追踪插件生命周期的核心对象。
源码(vendor/cordis/src/fiber.ts)里定义了六个状态:
typescript
export const enum FiberState {
PENDING, // 等待所需服务就绪
LOADING, // apply() 正在执行
ACTIVE, // 加载完成,正常运行中
FAILED, // apply() 或配置校验抛出了异常
UNLOADING, // disposer 正在执行清理
DISPOSED, // 已完全卸载,不可再启动
}
状态转换图:
markdown
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
PENDING 状态是关键 :当一个插件声明了 inject: ['tools'],而 tools 服务还没有被加载,这个插件就会停在 PENDING,什么都不做,既不报错也不启动。等 tools 服务就绪,它才会自动进入 LOADING。
这个设计的好处:插件不需要关心加载顺序。你可以在 cordis.yml 里按任何顺序列插件,Cordis 会根据依赖关系自动排序。
三、Effect:可逆的注册
这是 Cordis 最重要也最容易被忽视的设计。
问题:谁来清理?
传统的事件监听或资源注册,如果你忘了手动清理,会造成内存泄漏:
typescript
// 传统写法,容易忘记清理
emitter.on('data', handler)
// 插件卸载时需要手动:emitter.off('data', handler)
// 如果忘了 → 内存泄漏 + 幽灵监听器
Cordis 的解法是:每个注册都是一个 Effect,有对应的 disposer(清理函数),插件卸载时自动执行所有 disposer。
ctx.effect():包装可逆操作
源码(vendor/cordis/src/fiber.ts)的核心逻辑:disposer 在插件卸载时按注册顺序的逆序执行。
typescript
// 用 ctx.effect() 包装任何需要清理的资源
export function apply(ctx: Context) {
ctx.effect(() => {
// 建立一个定时器
const timer = setInterval(() => console.log('心跳'), 1000)
// 返回 disposer:插件卸载时会自动调用
return () => {
clearInterval(timer)
console.log('定时器已清理')
}
})
}
// 当这个插件被卸载时(热重载/配置变更/进程退出),
// clearInterval 会自动被调用,不需要手动管理
内置的 Effect
好消息是,大多数情况下你不需要亲自写 ctx.effect(),因为 Cordis 的内置 API 已经自带 Effect:
typescript
export function apply(ctx: Context) {
// ✅ ctx.on() 已经是 Effect:插件卸载时监听器自动移除
ctx.on('tools/result', (exec, result) => { /* ... */ })
// ✅ ctx.plugin() 已经是 Effect:子插件随父插件一起卸载
ctx.plugin(ChildPlugin)
// ✅ ctx.tools.register() 已经是 Effect:
// 工具注册 disposer 附着到当前插件,卸载时自动注销
ctx.tools.register(myTool)
// ⚠️ 只有这些 Cordis 不管的资源才需要手动包装:
ctx.effect(() => {
const conn = createDatabaseConnection()
return () => conn.close()
})
}
为什么这个设计很重要
这是 dsh 实现**热重载(Hot Reload)**的基础。当你修改一个插件的配置,Cordis 会:
- 卸载旧版本(所有 Effect 的 disposer 自动执行)
- 用新配置加载新版本
整个过程不需要重启进程,也不会有任何遗留的监听器或未关闭的连接。
四、Service:依赖倒置的工程实现
提供服务
创建一个服务,需要继承 Service 类,在构造函数里传入服务名:
typescript
import { Service, type Context } from '@deepseek-ai/cordis'
// TypeScript 声明合并:让 ctx.greeter 有正确的类型
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
// 注册为 ctx.greeter
// 这个调用做了两件事:
// 1. 把 this 注册到服务注册表,key = 'greeter'
// 2. 把注册操作包装为 Effect,卸载时自动反注册
super(ctx, 'greeter')
}
greet(who: string) {
return `Hello, ${who}!`
}
}
源码(vendor/cordis/src/service.ts)里 Service 构造函数的核心逻辑:
typescript
constructor(protected ctx: Context, name: string) {
// ...
// 把自身注册到 ctx 的反射层
// 这个操作是 Effect,卸载时自动撤销
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}
消费服务
消费方只需要声明 inject,不需要知道是哪个包提供的服务:
typescript
export const name = 'my-plugin'
export const inject = ['greeter'] // 声明依赖
export function apply(ctx: Context) {
// 保证在这里 ctx.greeter 已经就绪
console.log(ctx.greeter.greet('dsh'))
}
动态追踪:不只是启动时检查
inject 不是一次性的启动检查。如果应用运行期间,greeter 服务被卸载(比如热重载了提供方插件),所有依赖它的插件会自动卸载 ,等服务恢复后自动重新加载。
这是 dsh 能做到「换一行配置,整个能力体系跟着换」的原因。比如把默认的本地 Shell 换成远程沙箱:
yaml
# 卸载本地 shell 提供方,挂载远程提供方
# 所有 inject: ['shell'] 的插件(包括 bash 工具)会自动重启,
# 使用新的远程实现,不需要改任何消费方代码
- id: shell-provider
name: '@deepseek-ai/dsh-sandbox-remote' # 换这一行
五、Event:五种分发模式,各有用途
Cordis 的事件系统不只有一种 emit,而是五种分发模式,对应不同的业务场景。
声明事件类型
先用 TypeScript 声明合并注册事件类型(interface Events):
typescript
declare module '@deepseek-ai/cordis' {
interface Events {
// 声明事件名 + 参数类型 + 返回类型
'tool/executed'(name: string, duration: number): void
}
}
这纯粹是 TypeScript 编译时的类型声明,不生成任何运行时代码,但让 ctx.emit、ctx.on 全部有正确的类型提示。
五种分发模式
看源码(vendor/cordis/src/events.ts)里的实现,每种模式的行为一目了然:
typescript
// 模式 1: emit ------ 广播,不等待,无返回值
// 源码:dispatch('emit', args).map(cb => cb(...args))
// 用途:通知性事件,监听者只是"旁观者"
ctx.emit('tool/executed', 'search', 120)
// 模式 2: parallel ------ 并发执行,等待全部完成
// 源码:await Promise.allSettled(listeners.map(cb => cb(...args)))
// 用途:需要等待多个异步操作,但顺序无关
await ctx.parallel('session/sync', sessionId)
// 模式 3: serial ------ 串行执行,第一个有效返回值获胜
// 源码:for (const cb of listeners) { result = await cb(); if (isBailed(result)) return result }
// 用途:找到第一个愿意处理这个请求的插件(策略选择)
const handler = await ctx.serial('approval/request', toolCall)
// 模式 4: bail ------ serial 的同步版本
// 用途:同步的策略选择
const result = ctx.bail('format/render', content)
// 模式 5: waterfall ------ 环绕中间件(最重要!)
// 见下文详解
Waterfall:dsh 的核心拦截机制
Waterfall 是理解 dsh 如何让插件"拦截和改写"行为的关键。
它的工作方式类似洋葱模型(Koa 的 middleware):每个监听器包裹着后面所有监听器,可以选择传递(调用 next())或短路(不调用 next())。
typescript
// 声明 waterfall 事件(最后一个参数是 next 函数)
declare module '@deepseek-ai/cordis' {
interface Events {
'agent/pre-step'(messages: Message[], next: () => Promise<Decision>): Promise<Decision>
}
}
// 插件 A:安全检查,发现敏感内容直接拒绝
ctx.on('agent/pre-step', async (messages, next) => {
if (containsSensitiveContent(messages)) {
return { type: 'reject', reason: '包含敏感内容' }
// ↑ 没有调用 next(),后面的监听器不会执行
}
return next() // 传递给后面的监听器
})
// 插件 B:日志记录,只观察,不干预
ctx.on('agent/pre-step', async (messages, next) => {
console.log(`[pre-step] 处理 ${messages.length} 条消息`)
const result = await next() // 必须调用 next(),否则会截断后续处理
console.log(`[pre-step] 结果: ${result.type}`)
return result
})
一条铁律 :只做观察/记录的 waterfall 监听器必须 调用 next()。不调用就代表"我拥有这个决策权,后面的都不用看了"。忘记调用 next() 会静默截断所有下游逻辑,这是 Cordis 文档里强调的一个常见错误。
dsh 中大量使用 waterfall 来实现可插拔的策略:
agent/pre-step:决定是否接受用户输入agent/request:可以替换模型调用配置(比如路由到不同模型)tools/pre-execute:工具执行前的权限检查approval/request:权限审批策略
六、Profile + Bundle:配置组合成产品
理解了上面的机制,再看 dsh 的 Profile/Bundle 系统就很直观了。
Bundle 就是一份插件配置列表(YAML 文件),描述「挂载哪些插件、用什么配置」:
yaml
# dsh-base bundle 的简化版(真实文件在 packages/bundle/base/cordis.patch.yml)
- insert:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek' # 模型适配器插件
- id: tools
name: '@deepseek-ai/dsh-tools' # 工具注册表插件
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl' # Session 持久化插件
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local' # 本地沙箱插件
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop' # Agent 循环插件
Profile 是按顺序叠加的 bundle 列表,后面的 bundle 可以覆盖前面的配置:
ini
web profile = dsh-base + dsh-web-app + 用户的 cordis.patch.yml
↑ 用户可以在这里覆盖任何行
用 --dump-config 看一眼实际加载的插件树:
sh
dsh --profile web --dump-config
# 输出:当前 profile 下所有插件的完整配置列表
# 每一行都是一个可以被你的 cordis.patch.yml 覆盖的插件
七、完整示例:写一个 dsh 插件
把上面所有概念串在一起,写一个完整的工具插件:
typescript
// my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool-plugin'
// 声明依赖:需要 tools 和 systemPrompt 服务就绪才会启动
export const inject = ['tools', 'systemPrompt']
export function apply(ctx: Context) {
// ctx.tools.register() 是 Effect:
// 插件卸载时,这个工具会自动从注册表里移除
ctx.tools.register(defineTool({
name: 'get_time',
description: '获取当前时间',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute() {
return new Date().toLocaleString('zh-CN')
},
}))
// ctx.on() 是 Effect:插件卸载时监听器自动移除
ctx.on('tools/result', (exec, result) => {
if (exec.name === 'get_time') {
console.log(`[日志] get_time 被调用,返回: ${JSON.stringify(result.content)}`)
}
})
}
通过 cordis.patch.yml 把这个插件加入 dsh:
yaml
- insert:
- id: my-tool
name: './my-tool-plugin.ts'
这就是完整的 dsh 插件开发流程。不需要 fork 框架,不需要修改任何内部代码,挂一个插件,能力就进来了。卸载插件,一切恢复原状。
设计哲学小结
回顾 Cordis 的五个核心机制,每一个都在解决一个具体问题:
| 机制 | 解决的问题 |
|---|---|
| Context(代理) | 消费方不依赖具体实现,可随时换掉提供方 |
| Fiber(状态机) | 插件按依赖顺序加载,PENDING 代替启动报错 |
| Effect(可逆注册) | 热重载和清理不需要手动管理,杜绝资源泄漏 |
| Service(依赖注入) | 通过 inject 声明依赖,运行时自动追踪,提供方可动态替换 |
| Event(五种模式) | 观察/拦截/策略决策/并发协调,每种场景用对应模式 |
这五个机制组合在一起,让 dsh 做到了:任何部分都可以被替换,但系统整体保持稳定运行。这不是偶然,是系统性设计的结果。
下一篇预告 :系列第三篇------工具系统 。我们会深入 ctx.tools 的完整实现:注册机制、schema 自动生成、三阶段执行 pipeline,以及权限审批是怎么拦截危险操作的。
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页