Cordis 入门:读懂「一切皆插件」的地基
专栏:DeepSeek Harness · 第 4 / 14 篇从这篇开始进入地基。dsh 的架构文档开宗明义:不存在需要打补丁的特权内核——模型适配器、工具注册表、会话日志、甚至 agent loop 本身,都是插件。让这个说法成立的,是底下的 Cordis 框架。dsh 把它以源码形式 vendor 在 vendor/cordis/(核心仅约 2700 行),改名 @deepseek-ai/cordis。
心智模型:ctx 是一个依赖容器
Cordis 里没有「主程序调用库」这回事,只有插件向共享上下文(Context,下称 ctx)贡献东西:
- 服务(service):一个挂在 ctx 键下的对象(
ctx.tools、ctx.llm、ctx.sessions……),其他插件按名注入使用。 - 类型化事件:插件间通信不互相 import,而是向事件总线发事件/听事件。
- 副作用(effect):每一个注册(服务、监听器、子插件)都登记一个撤销函数;插件卸载时全部自动回滚。
ctx 本身是一个 Proxy——读 ctx.tools 时动态解析服务,extend()/isolate() 创建「不改父级」的子上下文。整个 dsh 进程就是一棵 ctx 树。
插件的三种形态
// ① 函数插件:导出 apply(name 是可选的显示名)
export const name = 'greeter'
export function apply(ctx: Context) { /* 注册…… */ }
// ② 类插件:Service 子类,new 出来即注册
export class GreeterService extends Service {
constructor(ctx: Context) { super(ctx, 'greeter') }
}
// ③ 对象插件:{ name?, Config?, inject?, apply(ctx, config) }
以官方教程的最小服务为例,一个「提供方 + 消费方」的完整故事:
// docs/cordis-tutorial/03-services.zh.md:12-34 —— 提供方
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService // 类型键:让 ctx.greeter 有类型
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // 运行时键:注册进 ctx
}
greet(who: string) { return `Hello, ${who}!` }
}
export const name = 'greeter'
export function apply(ctx: Context) { ctx.plugin(GreeterService) }
// docs/cordis-tutorial/03-services.zh.md:49-56 —— 消费方
export const name = 'consumer'
export const inject = ['greeter'] // 声明依赖:greeter 就绪前我保持等待
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world'))
}
两件事值得注意。其一,super(ctx, 'greeter') 内部就是 ctx.reflect.provide(name, this)——服务注册本身是一个 effect(vendor/cordis/src/service.ts:57),随提供方 fiber 卸载自动注销。其二,inject 不是一次性检查:声明了依赖的插件在依赖就绪前保持 PENDING;服务消失会级联卸载依赖方,恢复后自动重新加载。这就是 dsh 能做到「配置热重载」「换适配器不重启」的机制基础。
类型化事件与五种分发模式
事件同样走声明合并,名字约定 namespace/action:
declare module '@deepseek-ai/cordis' {
interface Events {
'stats/report'(name: string, count: number): void
}
}
ctx.emit('stats/report', name, next)
ctx.on('stats/report', (name, count) => { /* ... */ }) // 返回 disposer
分发模式是事件公开契约的一部分(dsh 用 JSDoc @mode 标签记录,并有 lint 强制 waterfall 监听器必须带尾参):
| 模式 | await? | 顺序 | 返回值 |
|---|---|---|---|
emit | 否 | 注册序 | 无(观察型;监听器失败被隔离) |
parallel | 是 | 全部并发 | 等全部结束 |
serial | 是 | 注册序依次 | 第一个非空返回值后停止 |
bail | 否 | 注册序依次 | 同 serial 的同步版 |
waterfall | 监听器可 async | 环绕组合 | 最外层监听器的返回值 |
waterfall 是 dsh 里承重的模式,值得单独看一段教程示例:
// docs/cordis-tutorial/04-events.zh.md:99-126 —— waterfall 环绕与短路
declare module '@deepseek-ai/cordis' {
interface Events {
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
export function apply(ctx: Context) {
// 监听器 1:包装下游结果
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// 监听器 2:拥有决策权时短路
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
void (async () => {
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
// → HELLO:1 调 next() 进入 2,2 委托内置默认值,返回途中被 1 大写
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => '...'))
// → ** BLOCKED **:2 不调 next(),短路,内置逻辑从未运行
})()
}
纪律也随之而来:只想观察的监听器必须调用 next();不调用即否决。dsh 的工具把关(tools/pre-execute 的 allow/deny/ask)、审批分发(approval/request)、模型配置替换(agent/request)全是这个语义。
effect 与 fiber:可逆的副作用
每个插件应用是一次 fiber(ctx.plugin() 的返回值),状态机 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED(失败进 FAILED)。所有经 Cordis API 的注册都把 disposer 挂到当前 fiber,卸载时按注册逆序启动(异步清理并发等待)。教程里的心跳插件一句话讲完 effect:
// docs/cordis-tutorial/02-lifecycle-and-effects.zh.md:18-42(节选)
function heartbeat(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => { // 这个返回值就是撤销函数
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}
真实代码里的同构例子——dsh 的工具注册表 register() 返回精确 disposer,工具插件卸载即从模型可见工具列表里消失:
// packages/core/tools/src/index.ts:1028-1053(节选)
register(definition: ToolDefinition): () => void {
const name = definition.name
// ……校验 output 声明、timeoutMs、保留名 run_code……
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
}
还有一个真实的插件入口,看它如何把「注入 + 双注册表 + effect」串起来——todo_write 工具包:
// packages/todo/tool-todo/src/index.ts:122-145(节选)
export const inject = ['tools', 'sessionProjections'] // 声明依赖
export function apply(ctx: Context, config: Config): void {
ctx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
init: () => null,
apply: (state, event) => {
if (event.type === 'todo/write') return event.todos
if (event.type === 'turn/start') return null
return state
},
stateVersion: 2,
})
// ……ctx.tools.register(defineTool({ ... }))……
}
作用域:每个 agent 一棵子树
dsh 在 Cordis 之上加了一层「按 agent 划分作用域」的原语(packages/core/scope):每个 agent 启动时 createScope(loopCtx, this) 得到独立子上下文,agent-loop 里就这样用:
// packages/core/agent-loop/src/agent.ts:106-108
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
经 agent.ctx 注册的工具、prompt 段只对该 agent 可见,并随 agent dispose 整体撤销;事件经载体做作用域过滤(无标签监听器全局收,带标签只收本作用域及子孙)。
插件生命周期
把上面的机制串成一张时序图(这是后面所有篇章的底层节拍):
Cordis 概念 ↔ dsh 模块对照
学 Cordis 时最有效的办法是带着 dsh 的实例去对照每一个概念:
| Cordis 概念 | 在 dsh 里的实例 | 详解 |
|---|---|---|
| 服务(ctx 键) | ctx.tools / ctx.llm / ctx.sessions | 第 6~11 篇 |
| inject 依赖门控 | tool-fs 声明 inject = ['tools','fs'] | 第 9、10 篇 |
| 类型化事件 waterfall | tools/pre-execute(allow/deny/ask) | 第 9 篇 |
| effect 可逆副作用 | ctx.tools.register() 返回的 disposer | 第 9、14 篇 |
| 作用域 isolate | 每个 agent 一棵子树(agent.ctx) | 第 7 篇 |
| 配置校验 Config | 每个插件的 static Config schema | 第 5 篇 |
dsh 插件树的全景
dsh 插件树的全景
最后把第 2 篇那份 162 行的配置树,翻译成结构图——这就是「运行中的 dsh」:
读 dsh 源码时的固定问句:这段代码是提供了一个服务(extends Service / ctx.provide)、消费了服务(inject = [...])、还是监听了事件(ctx.on)?三个答案覆盖 dsh 里几乎全部插件。例外只有一种——既是消费方又向别人提供组合能力的「注册表」插件(如 ctx.tools 本身)。
地基打好,下一篇看配置树是怎么一层层叠出来的:Profile 与组合包。