DeepSeek Harness 为什么需要 Cordis:从 Everything is Plugin 开始看

0 阅读7分钟

最近在学 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 上,后面很多设计也就自然能够组合起来了。