最近在学 Agent,也在看 DeepSeek Harness,就是 DSH。
DSH 里面有一个很核心的底层项目叫 Cordis,继续往下看 DSH 的很多实现,最后都会碰到它。
Cordis 本身的核心代码其实不算多:
context.ts
registry.ts
fiber.ts
service.ts
reflect.ts
events.ts
不过还是老习惯,看代码之前先看这个项目到底要解决什么问题。
DSH 有一个很重要的设计:
Everything is Plugin.
Tool、LLM、文件系统、Shell,甚至 Agent Loop 本身,都可以通过 Plugin 的方式挂进来。
这样做的好处很明显,核心代码不用把所有能力都写死。
比如一个 Agent 可能需要:
Agent
├── LLM
├── Tools
├── File System
├── Shell
├── Permission
├── MCP
├── Skills
└── Agent Loop
这些东西如果全部写在一个 Runtime 里面,后面想换、想加、想删都会越来越麻烦。
如果都是 Plugin,就简单很多:
Cordis Runtime
├── LLM Plugin
├── Tool Plugin
├── File Plugin
├── Shell Plugin
├── MCP Plugin
└── Agent Loop Plugin
需要什么就装什么。
不过新的问题马上就来了:
Plugin 怎么注册?
Plugin 之间怎么使用对方的能力?
Plugin A 依赖 Plugin B 怎么办?
Plugin 之间怎么通信?
Plugin 被卸载以后,
之前注册的事件、连接、Service 怎么处理?
Cordis 基本就是围绕这些问题设计出来的。
1. Plugin 先得有人管
最直接的入口就是:
ctx.plugin(plugin)
表面看就是注册一个 Plugin,但 Cordis 并不会简单地:
plugin(ctx)
执行完就算了。
Plugin 注册之后,还有自己的状态、依赖、配置,以及后面需要清理的资源。
所以这里先出现了:
RegistryService
它负责管理 Plugin 的注册。
但 Registry 也不能只保存:
plugins.push(plugin)
因为 Cordis 还需要知道:
这个 Plugin 当前有没有运行?
它属于哪个 Context?
它依赖哪些 Service?
它创建了哪些资源?
什么时候应该卸载?
所以每一个真正运行起来的 Plugin,Cordis 都会给它建立一个对应的 Fiber。
可以先简单理解成:
Fiber 就是 Plugin 的运行实例。
比如:
Root Fiber
├── LLM Plugin Fiber
├── Tool Plugin Fiber
└── Agent Plugin Fiber
Plugin 是那段代码。
Fiber 则记录这段 Plugin 现在是怎么运行的。
所以:
ctx.plugin(plugin)
背后大概就是:
Registry 注册 Plugin
↓
创建 Fiber
↓
检查依赖
↓
依赖满足
↓
执行 Plugin
后面 Plugin 注册出来的 Event、Service、Effect,也都会跟这个 Fiber 产生关系。
2. Plugin 之间怎么使用能力
Plugin 能注册了,接下来还有一个问题:
Plugin 之间总得互相调用。
比如一个 Metrics Plugin 提供统计能力,其他 Plugin 想记录:
metrics.record(...)
这个 metrics 从哪来?
Cordis 的答案就是 Service。
DSH 官方文档里给了一个很简单的例子:
import { Service, type Context } from '@deepseek-ai/cordis'
export default class MetricsService extends Service {
static inject = ['llm']
constructor(ctx: Context) {
super(ctx, 'metrics')
}
record(event: string, value: number) {
// ...
}
}
最值得注意的是:
super(ctx, 'metrics')
这里的 'metrics' 就是这个 Service 的名字。
Service 内部最终会做类似:
ctx.reflect.provide(
'metrics',
this,
)
也就是:
当前 Plugin
提供了一个叫 metrics 的 Service
这样其他 Plugin 就可以:
export const inject = ['metrics']
export function apply(ctx: Context) {
ctx.metrics.record('tool_call', 1)
}
这里其实就已经把 Plugin 和 Service 的关系说明白了:
Metrics Plugin
↓
提供
↓
metrics Service
↓
其他 Plugin inject
↓
ctx.metrics.record()
所以 Service 可以简单理解成:
Plugin 对其他 Plugin 暴露出来的能力。
Plugin 是模块。
Service 是模块对外提供的接口。
3. inject 又是干嘛的
上面的消费方还有一句:
export const inject = ['metrics']
这个东西很重要。
它相当于告诉 Cordis:
我这个 Plugin 运行之前必须先有 metrics。
如果 metrics 还不存在,那这个 Plugin 就不能正常启动。
等 MetricsService 注册以后,依赖它的 Plugin 才能继续运行。
所以:
Service
解决的是:
我能提供什么能力。
而:
inject
解决的是:
我运行需要什么能力。
比如:
export const inject = ['tools']
就是:
没有 tools,我这个 Plugin 就不要启动。
DSH 也支持可选依赖。
如果只是偶尔需要一个 Service,可以不写 inject,而是在使用的时候:
export function apply(ctx: Context) {
const metrics = ctx.get('metrics')
metrics?.record('plugin_loaded', 1)
}
所以这两种语义是不一样的。
inject
= 这是我的运行条件
ctx.get()
= 有就用,没有也无所谓
4. ctx.metrics 为什么能直接拿到 Service
这里有一个挺有意思的地方。
我们前面并没有写:
ctx.metrics = metricsService
但使用的时候却可以直接:
ctx.metrics.record(...)
这是因为 Cordis 的 Context 本身是一个 Proxy:
constructor() {
const self = new Proxy<this>(
this,
ReflectService.handler,
)
}
所以访问:
ctx.metrics
并不一定是在读取一个普通 JS 属性。
Proxy 会先拦截:
get metrics
然后再去 Cordis 的 Service 系统里查。
大概可以先理解成:
ctx.metrics
↓
Proxy get
↓
当前 Context / Fiber 有没有 metrics
↓
有
↓
返回 MetricsService
如果当前 Fiber 没有,还可能沿着父 Fiber 继续找。
所以 Context 更像是一个:
带作用域的 Service 容器。
而 ReflectService 主要就在做这些事情:
Service 注册
Service 查找
Service 依赖
Context Proxy 解析
前面看到:
ctx.reflect.provide('metrics', service)
实际上就是把这个 Service 放进这套系统里面。
5. Service 不适合所有通信,所以还有 Event
Plugin 之间并不是什么事情都适合直接调用。
比如:
ctx.metrics.record(...)
这种很明确:
我就是需要 metrics 这个能力。
这时候 Service 很合适。
但如果只是:
某件事情发生了。
比如:
ctx.emit('user/created', user)
调用方其实并不关心是谁来处理。
其他 Plugin 如果关心,就自己监听:
ctx.on('user/created', (user) => {
// ...
})
所以两者很好区分:
Service
= 我明确需要一个能力
Event
= 我通知一件事情,谁关心谁处理
这也是为什么 Cordis 里同时存在:
ReflectService
EventsService
EventsService 里面就是比较熟悉的:
ctx.on(...)
ctx.emit(...)
除此之外还有:
parallel
serial
bail
waterfall
分别处理并行、串行、中断和中间件这种不同场景。
6. Plugin 卸载以后怎么办
这其实是 Cordis 很重要的一部分。
Plugin 启动以后可能干很多事情:
注册 Service
注册 Event
创建 Timer
打开 Connection
注册子 Plugin
如果 Plugin 被卸载了,但这些东西还在,那插件系统其实就不完整。
所以 Cordis 里面经常能看到:
ctx.effect(() => {
const connection = createConnection()
return () => connection.close()
})
前面负责创建。
返回的函数负责清理。
这些 Effect 会跟当前 Fiber 绑定。
比如一个 Plugin Fiber 最后可能是:
Plugin Fiber
├── metrics Service
├── user/created listener
├── timer
├── connection
└── child Plugin
当这个 Plugin 被卸载:
Fiber dispose
↓
执行 disposer
↓
移除 Event
↓
移除 Service
↓
关闭 Connection
↓
卸载子 Plugin
这时候再回头看 Fiber,就会发现它其实很关键。
它把:
Plugin 创建出来的东西
和:
Plugin 本身的生命周期
绑在了一起。
所以 Cordis 不只是:
怎么把 Plugin 注册进来。
它更关心:
Plugin 注册以后,怎么保证它最后还能被完整地撤销。
一个前端看 Cordis 的小插曲
看这个项目的时候还有一个挺有意思的感觉:
好多东西前端开发其实都见过。
比如 EventsService:
ctx.on(...)
ctx.emit(...)
这不就是以前经常写的 EventBus。
再看:
ctx.effect(() => {
const connection = createConnection()
return () => connection.close()
})
是不是又有点眼熟?🤣
和 React:
useEffect(() => {
const connection = createConnection()
return () => connection.close()
}, [])
思路其实很像。
都是:
setup
↓
返回 cleanup
↓
生命周期结束
↓
cleanup
还有 Context:
const self = new Proxy<this>(
this,
ReflectService.handler,
)
Proxy 这个东西,写 Vue 的时候就更熟了。
Vue 的响应式源码里面到处都是 Proxy。
只不过 Vue 拦截:
state.xxx
主要是为了依赖收集和响应式。
Cordis 拦截:
ctx.xxx
是为了 Service 查找和 Context 作用域。
目的完全不同,但这种:
表面看是普通属性访问
实际上 Proxy 在背后接管
的感觉非常像。
所以前端转过来看这种运行时框架,有些东西其实并没有想象中陌生。
最后
到这里再回来看 Cordis 的核心文件:
context.ts
registry.ts
fiber.ts
service.ts
reflect.ts
events.ts
基本就能对上了。
Registry
↓
Plugin 怎么注册
Fiber
↓
Plugin 怎么运行和销毁
Service / Reflect
↓
Plugin 怎么提供和依赖能力
Events
↓
Plugin 怎么松耦合通信
Effect
↓
Plugin 创建的资源怎么清理
整个设计其实都是从:
Everything is Plugin
这句话往后推出来的。
既然所有能力都尽量通过 Plugin 装进来,那 Cordis 就必须解决:
Plugin 怎么注册
Plugin 怎么依赖
Plugin 怎么通信
Plugin 怎么提供能力
Plugin 怎么卸载
所以 Cordis 表面看是一个 Plugin Framework,实际上做的是一整套 Plugin Runtime。
而 DSH 把 LLM、Tools、Agent Loop 等能力建立在这个 Runtime 上,后面很多设计也就自然能够组合起来了。