中文资讯 掘金 AI Thu, 10 Sep 2026 正文 ok 1162 words

DeepSeek Harness 系列(02):万物皆插件——Cordis 核心设计深度解读

英文标题:DeepSeek Harness 系列(02):万物皆插件——Cordis 核心设计深度解读

dsh 的所有能力都建立在 Cordis 插件框架之上。这篇文章结合真实源码,深度解读 Cordis 五个核心机制:Plugin、Context、Service、Event、Effect
打开原文相关 2 · 机会 0
media3.2 KB media589 B media6.3 KB

中文

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 系统。最朴素的写法大概是这样:

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()

这样写能跑,但有几个问题:

  1. 换个组件很麻烦 :想把 LLMAdapter 换成另一家的,要找到所有 import 的地方改
  2. 加载顺序靠手工保证 :AgentLoop 必须在其他三个都初始化之后才能创建
  3. 没有热重载 :改一个配置只能重启整个进程
  4. 清理是噩梦 :session 要关连接,loop 要停定时器,谁来保证顺序?

Cordis 的解法 :不直接 import,通过一个共享的 ctx 容器互相发现;加载顺序由依赖声明自动推导;每个注册都有配套的清理函数,卸载时自动执行。

这就是为什么 dsh 选了它:dsh 要支持热重载、任意替换模型/沙箱/工具提供方,Cordis 天生就是为这个场景设计的。

最小示例:五行理解核心思路

import { Context, Service } from 'cordis'


const root = new Context()



await root.plugin(ToolsService)



await root.plugin(AgentLoop)



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):

constructor() {
  
  
  const self = new Proxy<this>(this, ReflectService.handler)
  this.root = self
  
}

为什么用 Proxy?因为 dsh 要实现服务按需查找 :当你写 ctx.tools 时,不是从一个普通对象上读一个属性——而是触发代理,去服务注册表里找名为 'tools' 的服务实例。

这个设计解决了一个核心问题:消费方不需要 import 提供方 。

import { ToolsService } from './tools-service'
const tools = new ToolsService()



ctx.tools.register(myTool)

当你换掉工具服务的实现(比如切换到远程工具执行),消费方代码一行都不用改。

Context 是有层级的

每个插件运行在自己的 子 Context 里,子 Context 继承父 Context 的服务,但可以有自己的隔离服务实例。这是 dsh 实现「每个 Agent 有自己的工具集」的底层机制。


二、Plugin + Fiber:插件的生命周期

插件的三种写法

Cordis 接受三种形式的插件:

import { Service, type Context } from '@deepseek-ai/cordis'


export function apply(ctx: Context) {
  console.log('插件已加载')
}


export const myPlugin = {
  name: 'my-plugin',
  apply(ctx: Context) {  },
}


export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myService')  
  }
}

Fiber:插件实例的状态机

每个被加载的插件实例都有一个对应的 Fiber 。Fiber 是 Cordis 追踪插件生命周期的核心对象。

源码(vendor/cordis/src/fiber.ts)里定义了六个状态:

export const enum FiberState {
  PENDING,   
  LOADING,   
  ACTIVE,    
  FAILED,    
  UNLOADING, 
  DISPOSED,  
}

状态转换图:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                  ↘ FAILED

PENDING 状态是关键 :当一个插件声明了 inject: ['tools'],而 tools 服务还没有被加载,这个插件就会停在 PENDING,什么都不做,既不报错也不启动。等 tools 服务就绪,它才会自动进入 LOADING。

这个设计的好处:插件不需要关心加载顺序。你可以在 cordis.yml 里按任何顺序列插件,Cordis 会根据依赖关系自动排序。


三、Effect:可逆的注册

这是 Cordis 最重要也最容易被忽视的设计。

问题:谁来清理?

传统的事件监听或资源注册,如果你忘了手动清理,会造成内存泄漏:

emitter.on('data', handler)

Cordis 的解法是:每个注册都是一个 Effect,有对应的 disposer(清理函数) ,插件卸载时自动执行所有 disposer。

ctx.effect():包装可逆操作

源码(vendor/cordis/src/fiber.ts)的核心逻辑:disposer 在插件卸载时按注册顺序的逆序 执行。

export function apply(ctx: Context) {
  ctx.effect(() => {
    
    const timer = setInterval(() => console.log('心跳'), 1000)
    
    
    return () => {
      clearInterval(timer)
      console.log('定时器已清理')
    }
  })
}

内置的 Effect

好消息是,大多数情况下你不需要亲自写 ctx.effect(),因为 Cordis 的内置 API 已经自带 Effect:

export function apply(ctx: Context) {
  
  ctx.on('tools/result', (exec, result) => {  })

  
  ctx.plugin(ChildPlugin)

  
  
  ctx.tools.register(myTool)

  
  ctx.effect(() => {
    const conn = createDatabaseConnection()
    return () => conn.close()
  })
}

为什么这个设计很重要

这是 dsh 实现**热重载(Hot Reload)**的基础。当你修改一个插件的配置,Cordis 会:

  1. 卸载旧版本(所有 Effect 的 disposer 自动执行)
  2. 用新配置加载新版本

整个过程不需要重启进程,也不会有任何遗留的监听器或未关闭的连接。


四、Service:依赖倒置的工程实现

提供服务

创建一个服务,需要继承 Service 类,在构造函数里传入服务名:

import { Service, type Context } from '@deepseek-ai/cordis'


declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    
    
    
    
    super(ctx, 'greeter')
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

源码(vendor/cordis/src/service.ts)里 Service 构造函数的核心逻辑:

constructor(protected ctx: Context, name: string) {
  
  
  
  self.ctx.reflect.provide(name, self, this[symbols.check])
  return self
}

消费服务

消费方只需要声明 inject,不需要知道是哪个包提供的服务:

export const name = 'my-plugin'
export const inject = ['greeter']  

export function apply(ctx: Context) {
  
  console.log(ctx.greeter.greet('dsh'))
}

动态追踪:不只是启动时检查

inject 不是一次性的启动检查。如果应用运行期间,greeter 服务被卸载(比如热重载了提供方插件),所有依赖它的插件会自动卸载 ,等服务恢复后自动重新加载 。

这是 dsh 能做到「换一行配置,整个能力体系跟着换」的原因。比如把默认的本地 Shell 换成远程沙箱:

- id: shell-provider
  name: '@deepseek-ai/dsh-sandbox-remote'  

五、Event:五种分发模式,各有用途

Cordis 的事件系统不只有一种 emit,而是五种分发模式,对应不同的业务场景。

声明事件类型

先用 TypeScript 声明合并注册事件类型(interface Events):

declare module '@deepseek-ai/cordis' {
  interface Events {
    
    'tool/executed'(name: string, duration: number): void
  }
}

这纯粹是 TypeScript 编译时的类型声明,不生成任何运行时代码,但让 ctx.emit、ctx.on 全部有正确的类型提示。

五种分发模式

看源码(vendor/cordis/src/events.ts)里的实现,每种模式的行为一目了然:

ctx.emit('tool/executed', 'search', 120)




await ctx.parallel('session/sync', sessionId)




const handler = await ctx.serial('approval/request', toolCall)



const result = ctx.bail('format/render', content)

Waterfall:dsh 的核心拦截机制

Waterfall 是理解 dsh 如何让插件"拦截和改写"行为的关键。

它的工作方式类似洋葱模型(Koa 的 middleware):每个监听器包裹着后面所有监听器,可以选择传递(调用 next())或短路(不调用 next())。

declare module '@deepseek-ai/cordis' {
  interface Events {
    'agent/pre-step'(messages: Message[], next: () => Promise<Decision>): Promise<Decision>
  }
}


ctx.on('agent/pre-step', async (messages, next) => {
  if (containsSensitiveContent(messages)) {
    return { type: 'reject', reason: '包含敏感内容' }
    
  }
  return next()  
})


ctx.on('agent/pre-step', async (messages, next) => {
  console.log(`[pre-step] 处理 ${messages.length} 条消息`)
  const result = await 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 文件),描述「挂载哪些插件、用什么配置」:

- 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'  
  - id: sandbox
    name: '@deepseek-ai/dsh-sandbox-local'     
  - id: agent-loop
    name: '@deepseek-ai/dsh-agent-loop'        

Profile 是按顺序叠加的 bundle 列表,后面的 bundle 可以覆盖前面的配置:

web profile = dsh-base + dsh-web-app + 用户的 cordis.patch.yml
                                       ↑ 用户可以在这里覆盖任何行

用 --dump-config 看一眼实际加载的插件树:

dsh --profile web --dump-config

七、完整示例:写一个 dsh 插件

把上面所有概念串在一起,写一个完整的工具插件:

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'my-tool-plugin'


export const inject = ['tools', 'systemPrompt']

export function apply(ctx: Context) {
  
  
  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('tools/result', (exec, result) => {
    if (exec.name === 'get_time') {
      console.log(`[日志] get_time 被调用,返回: ${JSON.stringify(result.content)}`)
    }
  })
}

通过 cordis.patch.yml 把这个插件加入 dsh:

- 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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页

English / 原文

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 系统。最朴素的写法大概是这样:

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()

这样写能跑,但有几个问题:

  1. 换个组件很麻烦 :想把 LLMAdapter 换成另一家的,要找到所有 import 的地方改
  2. 加载顺序靠手工保证 :AgentLoop 必须在其他三个都初始化之后才能创建
  3. 没有热重载 :改一个配置只能重启整个进程
  4. 清理是噩梦 :session 要关连接,loop 要停定时器,谁来保证顺序?

Cordis 的解法 :不直接 import,通过一个共享的 ctx 容器互相发现;加载顺序由依赖声明自动推导;每个注册都有配套的清理函数,卸载时自动执行。

这就是为什么 dsh 选了它:dsh 要支持热重载、任意替换模型/沙箱/工具提供方,Cordis 天生就是为这个场景设计的。

最小示例:五行理解核心思路

import { Context, Service } from 'cordis'


const root = new Context()



await root.plugin(ToolsService)



await root.plugin(AgentLoop)



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):

constructor() {
  
  
  const self = new Proxy<this>(this, ReflectService.handler)
  this.root = self
  
}

为什么用 Proxy?因为 dsh 要实现服务按需查找 :当你写 ctx.tools 时,不是从一个普通对象上读一个属性——而是触发代理,去服务注册表里找名为 'tools' 的服务实例。

这个设计解决了一个核心问题:消费方不需要 import 提供方 。

import { ToolsService } from './tools-service'
const tools = new ToolsService()



ctx.tools.register(myTool)

当你换掉工具服务的实现(比如切换到远程工具执行),消费方代码一行都不用改。

Context 是有层级的

每个插件运行在自己的 子 Context 里,子 Context 继承父 Context 的服务,但可以有自己的隔离服务实例。这是 dsh 实现「每个 Agent 有自己的工具集」的底层机制。


二、Plugin + Fiber:插件的生命周期

插件的三种写法

Cordis 接受三种形式的插件:

import { Service, type Context } from '@deepseek-ai/cordis'


export function apply(ctx: Context) {
  console.log('插件已加载')
}


export const myPlugin = {
  name: 'my-plugin',
  apply(ctx: Context) {  },
}


export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myService')  
  }
}

Fiber:插件实例的状态机

每个被加载的插件实例都有一个对应的 Fiber 。Fiber 是 Cordis 追踪插件生命周期的核心对象。

源码(vendor/cordis/src/fiber.ts)里定义了六个状态:

export const enum FiberState {
  PENDING,   
  LOADING,   
  ACTIVE,    
  FAILED,    
  UNLOADING, 
  DISPOSED,  
}

状态转换图:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                  ↘ FAILED

PENDING 状态是关键 :当一个插件声明了 inject: ['tools'],而 tools 服务还没有被加载,这个插件就会停在 PENDING,什么都不做,既不报错也不启动。等 tools 服务就绪,它才会自动进入 LOADING。

这个设计的好处:插件不需要关心加载顺序。你可以在 cordis.yml 里按任何顺序列插件,Cordis 会根据依赖关系自动排序。


三、Effect:可逆的注册

这是 Cordis 最重要也最容易被忽视的设计。

问题:谁来清理?

传统的事件监听或资源注册,如果你忘了手动清理,会造成内存泄漏:

emitter.on('data', handler)

Cordis 的解法是:每个注册都是一个 Effect,有对应的 disposer(清理函数) ,插件卸载时自动执行所有 disposer。

ctx.effect():包装可逆操作

源码(vendor/cordis/src/fiber.ts)的核心逻辑:disposer 在插件卸载时按注册顺序的逆序 执行。

export function apply(ctx: Context) {
  ctx.effect(() => {
    
    const timer = setInterval(() => console.log('心跳'), 1000)
    
    
    return () => {
      clearInterval(timer)
      console.log('定时器已清理')
    }
  })
}

内置的 Effect

好消息是,大多数情况下你不需要亲自写 ctx.effect(),因为 Cordis 的内置 API 已经自带 Effect:

export function apply(ctx: Context) {
  
  ctx.on('tools/result', (exec, result) => {  })

  
  ctx.plugin(ChildPlugin)

  
  
  ctx.tools.register(myTool)

  
  ctx.effect(() => {
    const conn = createDatabaseConnection()
    return () => conn.close()
  })
}

为什么这个设计很重要

这是 dsh 实现**热重载(Hot Reload)**的基础。当你修改一个插件的配置,Cordis 会:

  1. 卸载旧版本(所有 Effect 的 disposer 自动执行)
  2. 用新配置加载新版本

整个过程不需要重启进程,也不会有任何遗留的监听器或未关闭的连接。


四、Service:依赖倒置的工程实现

提供服务

创建一个服务,需要继承 Service 类,在构造函数里传入服务名:

import { Service, type Context } from '@deepseek-ai/cordis'


declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    
    
    
    
    super(ctx, 'greeter')
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

源码(vendor/cordis/src/service.ts)里 Service 构造函数的核心逻辑:

constructor(protected ctx: Context, name: string) {
  
  
  
  self.ctx.reflect.provide(name, self, this[symbols.check])
  return self
}

消费服务

消费方只需要声明 inject,不需要知道是哪个包提供的服务:

export const name = 'my-plugin'
export const inject = ['greeter']  

export function apply(ctx: Context) {
  
  console.log(ctx.greeter.greet('dsh'))
}

动态追踪:不只是启动时检查

inject 不是一次性的启动检查。如果应用运行期间,greeter 服务被卸载(比如热重载了提供方插件),所有依赖它的插件会自动卸载 ,等服务恢复后自动重新加载 。

这是 dsh 能做到「换一行配置,整个能力体系跟着换」的原因。比如把默认的本地 Shell 换成远程沙箱:

- id: shell-provider
  name: '@deepseek-ai/dsh-sandbox-remote'  

五、Event:五种分发模式,各有用途

Cordis 的事件系统不只有一种 emit,而是五种分发模式,对应不同的业务场景。

声明事件类型

先用 TypeScript 声明合并注册事件类型(interface Events):

declare module '@deepseek-ai/cordis' {
  interface Events {
    
    'tool/executed'(name: string, duration: number): void
  }
}

这纯粹是 TypeScript 编译时的类型声明,不生成任何运行时代码,但让 ctx.emit、ctx.on 全部有正确的类型提示。

五种分发模式

看源码(vendor/cordis/src/events.ts)里的实现,每种模式的行为一目了然:

ctx.emit('tool/executed', 'search', 120)




await ctx.parallel('session/sync', sessionId)




const handler = await ctx.serial('approval/request', toolCall)



const result = ctx.bail('format/render', content)

Waterfall:dsh 的核心拦截机制

Waterfall 是理解 dsh 如何让插件"拦截和改写"行为的关键。

它的工作方式类似洋葱模型(Koa 的 middleware):每个监听器包裹着后面所有监听器,可以选择传递(调用 next())或短路(不调用 next())。

declare module '@deepseek-ai/cordis' {
  interface Events {
    'agent/pre-step'(messages: Message[], next: () => Promise<Decision>): Promise<Decision>
  }
}


ctx.on('agent/pre-step', async (messages, next) => {
  if (containsSensitiveContent(messages)) {
    return { type: 'reject', reason: '包含敏感内容' }
    
  }
  return next()  
})


ctx.on('agent/pre-step', async (messages, next) => {
  console.log(`[pre-step] 处理 ${messages.length} 条消息`)
  const result = await 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 文件),描述「挂载哪些插件、用什么配置」:

- 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'  
  - id: sandbox
    name: '@deepseek-ai/dsh-sandbox-local'     
  - id: agent-loop
    name: '@deepseek-ai/dsh-agent-loop'        

Profile 是按顺序叠加的 bundle 列表,后面的 bundle 可以覆盖前面的配置:

web profile = dsh-base + dsh-web-app + 用户的 cordis.patch.yml
                                       ↑ 用户可以在这里覆盖任何行

用 --dump-config 看一眼实际加载的插件树:

dsh --profile web --dump-config

七、完整示例:写一个 dsh 插件

把上面所有概念串在一起,写一个完整的工具插件:

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'my-tool-plugin'


export const inject = ['tools', 'systemPrompt']

export function apply(ctx: Context) {
  
  
  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('tools/result', (exec, result) => {
    if (exec.name === 'get_time') {
      console.log(`[日志] get_time 被调用,返回: ${JSON.stringify(result.content)}`)
    }
  })
}

通过 cordis.patch.yml 把这个插件加入 dsh:

- 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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页