大多数 Agent 框架的核心循环锁死在代码里,想改调度逻辑只能 fork。DeepSeek Harness(DSH)选了一条更激进的路:连 agent loop 本身都是插件。支撑这套设计的底层引擎叫 Cordis------本文拆透它的五个核心机制:插件、生命周期与副作用、服务、事件、可配置插件。
1. 问题:Agent 框架的"黑盒困境"
大多数 Agent 框架------LangChain、AutoGen、CrewAI------都采用同一套模式:一个固定的核心引擎,外挂一些可扩展的工具和模型适配器。你能在边缘加东西,但核心循环、上下文管理、调度策略都锁死在框架内部。
想换一个 agent loop 的调度逻辑?要么 fork 整个框架,要么提交一个 PR 等人 review。想替换 session 存储方式?对不起,核心代码里写死了。
DeepSeek Harness(下称 DSH)选了一条不同的路:一切皆插件。模型适配器是插件、工具注册表是插件、会话日志是插件、agent loop 本身也是插件。没有特权核心,没有不能替换的组件。
支撑这套设计的底层引擎叫 Cordis------一个由 Koishi 框架作者开发、经过 4000+ 社区插件验证的"元框架"。它只做三件事:管插件加载卸载、管服务依赖、管事件分发。所有 Agent 业务逻辑都在它之上以插件形式存在。

Cordis 分层架构------底层是元框架,中层是 DSH 核心插件,顶层是用户扩展。三层之间没有硬编码依赖,全部通过服务键和事件协作。
注意架构图里一个关键特征:三层之间没有箭头指向"核心"------因为不存在特权核心。agent-loop、session、tools 这些看起来像"框架骨架"的组件,和顶层的"自定义工具"是同一种东西:普通插件。你可以在顶层写一个插件替换掉中层的任何一个组件,不需要 fork,不需要 PR。
接下来五个章节逐一拆解这套架构的五大核心机制。
2. 插件:一块自带说明书的积木
在 Cordis 里,插件是什么?不是一段被注入的脚本,不是一个接口的实现类------它就是一个函数 ,接收一个 ctx(上下文),在里面干自己的活。就这么简单。
三种写法,同一个东西
Cordis 支持三种等价的插件定义方式:
javascript
// 写法一:纯函数(最常见)
export function apply(ctx) {
ctx.on('tool/call', (event) => {
console.log('工具被调用了', event.name)
})
}
// 写法二:带依赖声明和名字的对象
export const name = 'my-plugin'
export const inject = ['tools', 'session']
export function apply(ctx) {
// 此时 ctx.tools 和 ctx.session 一定已就绪
}
// 写法三:类
class MyPlugin {
static inject = ['tools']
constructor(ctx) {
// 等价于 apply(ctx)
}
}
三种写法在运行时完全等价。核心约定只有一个:插件需要一个 apply(ctx) 入口(函数本身就是 apply,类的 constructor 等价,对象需要 apply 方法)。
在 Harness 中,"一切皆插件"长什么样
DSH 默认部署有 159 个插件。这不是夸张------连 agent loop(负责驱动每一轮对话的核心调度器)都是一个普通插件,挂在 ctx.agentLoop 这个服务键上。你可以直接禁用它,换上自己的调度逻辑。
来看一个真实的例子。假设你想给 agent 加一个"每次调用工具前记录审计日志"的功能:
javascript
export const name = 'audit-log'
export const inject = ['tools'] // 依赖工具服务
export function apply(ctx) {
// 监听 tools/pre-execute 事件(waterfall 类型)
ctx.on('tools/pre-execute', (event, next) => {
console.log(
`[审计] 工具=${event.name} 参数=${JSON.stringify(event.args)}`
)
next() // 继续执行链,不阻塞
})
}
这就完了。不需要继承某个基类,不需要实现某个接口,不需要注册到某个全局注册表。apply(ctx) 里你拿到了上下文,你就可以监听事件、注册工具、提供服务。Cordis 负责把你的插件挂到插件树上,在合适的时机调用 apply。
关键:插件的本质不是"实现某个接口",而是"拿到 ctx 后在里面注册副作用"。ctx 是一切的中介------你不需要 import 任何具体实现,所有协作都通过 ctx 上的服务和事件完成。
3. 生命周期与副作用:来得干净,走得也干净
插件系统的头号难题不是"怎么加载",而是"怎么卸载"。
一个插件启动时可能注册了 5 个事件监听器、开了 2 个定时器、连了一个数据库、注册了 3 个工具。卸载它的时候,你怎么保证这些全都被正确清理?传统做法是让插件作者自己写 cleanup() 方法------但人总会忘,一旦忘了就是内存泄漏。
Cordis 的答案是:把所有副作用收口到一个原语 ctx.effect(),让框架自动追踪和回滚。
Fiber 状态机:插件的一生
每个插件在 Cordis 中被包装成一个 Fiber 实例,有自己的状态机:

Fiber 状态机------从等待依赖到完成卸载的完整生命周期。DISPOSED 后如果依赖重新出现,会自动回到 PENDING 重新加载。
这里要先澄清一个容易混淆的点:Fiber 是插件的生命周期容器,不是副作用的生命周期容器。 副作用是挂在 Fiber 上的"子项",由 Fiber 统一管理回收,但两者的地位不同:
css
Fiber(插件的生命周期容器)
├── 状态机:PENDING → LOADING → ACTIVE → DISPOSING → DISPOSED
├── 依赖声明:inject = ['tools', 'session'] ← Fiber 负责解析
│
├── 副作用 1: ctx.on('agent/pre-step', handler)
├── 副作用 2: ctx.provide('notify', {...})
├── 副作用 3: ctx.effect(() => clearInterval(timer))
└── 子 Fiber(如果 ctx.plugin(child) 被调用)
└── 又有自己的状态机和副作用...
简单说:Fiber 是壳,副作用是壳里装的东西。 你 dispose 的是 Fiber(壳),Fiber 负责把里面的副作用逐个清掉。副作用本身没有状态机------只有"已注册"和"已清理"两个状态。
几个关键细节:
- PENDING → LOADING :插件声明了
inject: ['tools', 'session'],Cordis 会等这两个服务都就绪后才执行apply()。不需要手写轮询逻辑。 - ACTIVE → DISPOSING:当插件被卸载,或者它依赖的服务被卸载时,Fiber 进入 DISPOSING 状态,开始逆序执行所有 disposer。
- DISPOSED → PENDING:如果依赖的服务重新出现(比如热重载),插件会自动重新加载。这就是热插拔的基础。
ctx.effect():副作用的"可逆注册"
核心机制是 ctx.effect()。它接收一个函数,函数里做副作用操作,并返回一个撤销函数:
javascript
export function apply(ctx) {
ctx.effect(() => {
// === 做副作用 ===
const timer = setInterval(() => {
console.log('心跳')
}, 5000)
const off = ctx.on('tool/call', handler)
// === 返回撤销函数 ===
return () => {
clearInterval(timer)
off()
}
})
}
当这个插件被卸载时,Cordis 自动调用那个返回的撤销函数。定时器被清除,事件监听被移除------全自动化。
如果一个插件注册了多个 effect,它们按 LIFO(后进先出) 顺序执行撤销,类似退栈:

可逆副作用是 LIFO 回滚------后注册的先撤销,保证依赖关系不被破坏。
在 Harness 中的真实作用
DSH 的热重载(HMR)插件就是这个机制的受益者。当你修改了一个工具插件的代码,Cordis 会:
- 卸载旧插件 → 自动回滚它注册的所有工具、监听器、定时器
- 加载新插件 → 重新执行
apply(ctx),注册新的副作用 - 整个过程对其他插件透明------它们只看到"工具列表变了",不需要知道是谁在热重载
这就是Cordis 的 '时间可组合性':一个组件的副作用在移除时可以完全回退。不是靠人写 cleanup 代码,而是靠框架自动追踪。
需要注意的是 :
ctx.effect()只能回滚通过 Context 做的修改------注册事件、提供服务、挂子插件。对于外部世界的不可逆操作(已发送的 HTTP 请求、已写入数据库的数据、已发出的邮件),框架无法自动回滚。插件作者需要自己处理这类边界。
什么时候需要手动调用 ctx.effect()?
一条规则记住:Cordis 内置 API 注册的东西自动回收,外部资源的手动清理才需要 ctx.effect()。
javascript
export function apply(ctx) {
// ✅ 这些不用包 effect,Fiber 卸载时自动撤销
ctx.on('agent/pre-step', handler) // listener 自动移除
ctx.provide('notify', { ... }) // service 自动注销
ctx.middleware((next, send) => { ... }) // 中间件自动摘除
ctx.plugin(childPlugin) // 子 Fiber 自动 dispose
// ❌ 这些是外部资源,Cordis 管不到,必须手动注册 effect
const timer = setInterval(() => heartbeat(), 5000)
ctx.effect(() => clearInterval(timer)) // 否则定时器泄漏
const db = await connectDatabase(url)
ctx.effect(() => db.close()) // 否则连接泄漏
const watcher = fs.watch('./config.json', reload)
ctx.effect(() => watcher.close()) // 否则 watcher 泄漏
const server = app.listen(3000)
ctx.effect(() => server.close()) // 否则端口泄漏
}
判断口诀:这个资源是 Cordis 的 API 创建的吗?是 → 不用 effect;否 → 用 effect。 常见需要 effect 的:setInterval、setTimeout、EventEmitter.on、fs.watch、数据库连接、HTTP server、WebSocket、child_process、第三方库的订阅。
4. 服务:插件之间的"接头暗号"
插件之间怎么协作?如果插件 A 需要调用插件 B 的功能,直接 import B 的代码吗?不行------那样就硬耦合了,B 被替换掉 A 就坏了。
Seam 不是可选的设计模式,是插件体系的根本协作方式
先回答一个关键问题:Seam 到底是什么?是自定义服务时用的一种设计模式,还是插件体系本身的东西?
答案是后者。Seam 是 Cordis 插件体系唯一的跨插件协作模型。 不存在"用 Seam"和"不用 Seam"两种选择------只要你通过 ctx.xxx 访问另一个插件的能力,你就在消费一个 Seam;只要你通过 ctx.provide('xxx', ...) 注册能力,你就在提供一个 Seam。
DSH 里所有核心服务------ctx.tools、ctx.llm、ctx.sessions、ctx.agentLoop------全部是 Seam。它们不是"碰巧用了这个模式",而是 Cordis 框架内置的服务注册表机制本身。框架只认服务键,不认具体实现。这意味着:
- 核心服务也是 Seam :
core/tools插件通过ctx.provide('tools', ...)注册工具服务,和你的自定义插件注册ctx.provide('myService', ...)走的是同一条路径,没有特权。 - 替换核心服务 = 提供新 provider :想换掉默认的工具执行管道?写一个插件,
ctx.provide('tools', yourImpl),原消费者自动切到新实现。 - 没有"旁路":你不能绕过 Seam 直接 import 另一个插件的代码。Cordis 的模块隔离机制保证了插件之间只能通过 ctx 上的服务键通信。
一个 Seam 的完整生命周期:定义 → 提供 → 消费
来看一个完整的例子。假设 DSH 里没有"通知服务",你想自己建一个------让其他插件可以发送桌面通知。
第一步:定义服务接口(契约)
typescript
// 通知服务的接口契约------约定了消费者能调用什么方法
interface NotificationService {
notify(title: string, body: string): void
setEnabled(enabled: boolean): void
}
在 DSH 中,服务接口通常以 TypeScript 类型声明存在,作为插件之间的"合同"。消费者看接口就知道能调什么方法,不需要看提供者的实现代码。
第二步:提供者------注册服务实现
javascript
// desktop-notify.js --- 通知服务的提供者
export const name = 'desktop-notify'
export function apply(ctx) {
let enabled = true
// 注册服务:把实现挂到 'notify' 这个键上
ctx.provide('notify', {
notify(title, body) {
if (!enabled) return
// 调用系统通知 API
process.stdout.write(`\x1b]9;${title}^${body}\x07`)
},
setEnabled(val) {
enabled = val
}
})
// 提供者卸载时,框架自动注销 'notify' 服务键
// 消费者会感知到服务消失,自动进入 PENDING 等待
}
第三步:消费者------通过 ctx 使用服务
javascript
// task-reminder.js --- 通知服务的消费者
export const name = 'task-reminder'
export const inject = ['notify'] // 声明依赖
export function apply(ctx) {
// 到这里,ctx.notify 一定已就绪
// 因为 inject 声明了依赖,框架保证了加载顺序
ctx.on('task/completed', (event) => {
ctx.notify.notify('任务完成', `「${event.taskName}」已完成`)
})
}
三个角色各司其职:定义者管"能调什么",提供者管"怎么实现",消费者管"什么时候调"。提供者可以被随时替换,消费者代码一行不用改。

Seam 模型 ------这不是某个自定义服务"碰巧用了"的模式,而是 Cordis 插件体系的根本协作方式。所有 ctx.xxx 访问都是 Seam。
DSH 中的核心服务全部遵循这个模型:
| 服务键 | 提供者 | 能力 |
|---|---|---|
ctx.tools |
core/tools 插件 | 工具注册表和受保护的执行管道 |
ctx.llm |
llm/llm 插件 | 消息词汇表和模型适配器接缝 |
ctx.sessions |
core/session 插件 | 追加式事件日志和内存存储 |
ctx.agentLoop |
core/agent-loop 插件 | 默认的 Turn/Step 驱动实现 |
ctx.systemPrompt |
core/system-prompt 插件 | Prompt 段落和工具 schema 组装 |
注意:这些"核心"服务和上面例子里的 ctx.notify 走的是完全相同的注册路径。core/tools 插件里写的也是 ctx.provide('tools', {...}),没有特权 API。
替换一个 provider = 换了半个产品
Seam 最强大的地方在于:换一个 provider,消费方代码一行都不用改。
来看 DSH 里的一个真实场景。默认情况下,文件系统 provider 指向本地磁盘------Bash 工具在本地执行,文件编辑器改本地文件。现在你想把所有执行都搬到远程沙箱:
javascript
// remote-sandbox.js --- 替换 fs 服务的提供者
export const name = 'remote-sandbox'
export function apply(ctx) {
// 提供新的 fs 服务实现,覆盖默认的本地文件系统
ctx.provide('fs', {
readFile: (path) => rpc.call('remote_read', path),
writeFile: (path, data) => rpc.call('remote_write', path, data),
exec: (cmd) => rpc.call('remote_exec', cmd),
})
}
挂上这个插件后,Bash、PTY、LSP 三个工具自动迁移到远程沙箱 ------因为它们消费的是 ctx.fs 这个服务键,而不是 import 某个具体的本地文件系统模块。provider 换了,消费方无感知。

替换 provider 的效果------从本地文件系统到远程沙箱,零代码修改。这就是"核心服务也是 Seam"的直接好处:连文件系统这种基础设施都能被一个普通插件替换。
inject:声明的依赖,自动的加载顺序
插件通过 inject 声明它需要哪些服务。Cordis 根据这个声明自动推导加载顺序:
javascript
// 这个插件需要 tools 和 session 两个服务
export const inject = ['tools', 'session']
export function apply(ctx) {
// 到这里,ctx.tools 和 ctx.session 一定已就绪
// 不需要 if (ctx.tools) 之类的判断
ctx.tools.register({
name: 'search',
execute: (args) => { ... }
})
}
如果 tools 服务还没就绪(提供者还没加载),这个插件的 Fiber 会停在 PENDING 状态,直到 tools 可用才进入 LOADING 。反过来,如果 tools 服务的提供者被卸载了,这个插件会先被自动卸载(因为依赖没了),等 tools 重新出现时再自动加载。
这就是 Cordis 的 '空间可组合性':组件之间通过服务声明依赖,框架自动管理加载和卸载的因果关系。你不需要写一行"等对方准备好"的代码。
5. 事件:插件的神经系统
服务解决了"插件怎么调用彼此的能力",但还有一类问题服务解决不了:插件怎么在关键节点插一脚?
比如:每次模型请求前,检查一下消息是否包含敏感信息。每次工具执行后,记录一下耗时。每次 turn 结束前,决定是否要追加一个 step。这些不是"调用某个服务"------它们是"在某个时机拦截或观察"。
Cordis 用类型化事件解决这个问题,有四种派发模式:
四种事件模式
| 模式 | 行为 | 类比 |
|---|---|---|
emit |
发射即忘,所有监听器同步执行,忽略返回值 | 广播通知------"我发生了一件事,听到的自己处理" |
waterfall |
链式传递,每个监听器收到上一个的结果,必须调 next() 才继续 |
中间件管道------"数据经过我手,我可以改它,也可以直接拦下来" |
serial |
串行执行,无 next(),不能委托 |
逐一询问------"每个人说一句,没有反驳权" |
parallel |
并行扇出,所有监听器同时执行 | 群发任务------"大家一起干,等最慢的那个" |

在 DSH 中怎么选?
- 需要拦截/改写数据 → waterfall(如 agent/pre-step 可拒绝或改写消息)
- 需要观察/记录 → emit(如 session/created 不影响流程)
- 需要逐一决策 → serial(如 agent/turn-stopping 每个监听器投票)
实战:Agent Loop 里的事件流
DSH 的 agent loop 是事件系统最好的教学案例。一轮对话(Turn)被切成多个步骤(Step),每个关键节点都有对应的事件:

上图是Agent Loop 的完整事件流------标记了"扩展点"的是可拦截事件(waterfall/serial),其余是 durable 持久化事件。
注意图中两种节点的区别:
- durable 节点:持久化事件,写入 session log。用于记录"发生了什么"------fork、resume、replay 都从这条事件流派生。
- 扩展点节点:waterfall 或 serial 事件,是插件可以拦截的"接缝"。
举个实际的拦截例子。假设你想做一个"敏感词过滤"插件------每次模型请求前检查消息,发现敏感词就拦截:
javascript
export const name = 'sensitive-filter'
export const inject = ['agent']
export function apply(ctx) {
// agent/pre-step 是 waterfall 事件
ctx.on('agent/pre-step', (event, next) => {
const messages = event.messages
const hasSensitive = messages.some(m =>
m.content.includes('密码') || m.content.includes('token')
)
if (hasSensitive) {
// 不调 next(),直接 reject------短路整条链
return { kind: 'reject', reason: '检测到敏感信息' }
}
// 没问题,放行
next()
})
}
关键在于 next()。waterfall 事件中,每个监听器收到 (event, next) 两个参数。调用 next() 就把控制权交给下一个监听器;不调用就直接短路------后面的监听器和默认行为都不会执行。这和 Koa 的中间件、Express 的 middleware 是同一个思路。
事件 vs 服务:什么时候用哪个? 有一条简单的判断原则:拦截和策略用事件,直接调用稳定能力用服务方法 。比如"每次工具调用前检查权限"是策略,用
tools/pre-execute事件;"注册一个新工具"是直接能力,用ctx.tools.register()服务方法。
6. 可配置插件:用配置文件拼乐高
到目前为止,我们说的都是"用代码写插件"。但 DSH 还有一层更高级的能力:用配置文件组合插件,不需要写一行代码就能定制你的 Agent。
这套系统由三个概念组成:Bundle、Profile、Patch。
四层配置,从粗到细

四层配置的层叠模型------从 Bundle 到 CLI overlay,逐层覆盖。
每一层的作用:
- Bundle :一组 Cordis 配置行 + 对应代码的分发格式。
dsh-base是所有 Profile 的第一层,提供核心能力。上面再叠dsh-web-app(加浏览器 UI)或dsh-headless(加无头运行器)。 - Profile Patch:针对特定 Profile 的覆盖文件。比如你的 web Profile 想换一个不同的模型适配器,就在这里 patch。
- Home Patch:全局覆盖,对所有 Profile 生效。比如你想全局禁用某个工具。
- CLI Overlay :命令行
--patch参数,临时最高优先级覆盖。适合调试和一次性实验。
Patch 长什么样
Patch 文件就是一个 YAML,通过行 ID 定位要替换或新增的配置:
yaml
# cordis.patch.yml
# 替换默认的 LLM 适配器,改用自定义 provider
- id: llm-deepseek
replace:
plugin: my-custom-llm
config:
apiKey: ${env.MY_API_KEY}
model: deepseek-v4-pro
# 新增一个审计日志插件
- id: audit-log
insert:
plugin: @my-org/dsh-audit
config:
logPath: /var/log/dsh-audit.jsonl
想看你的机器实际启动了什么?一行命令:
bash
dsh --profile web --dump-config
这会打印出合并后的完整插件树------每一行都能被你自己的 patch 覆盖。
在 Harness 中的作用:四种模式
DSH 内置了四种 Profile 模式,每种加载不同的插件集合:
| 模式 | 加载的插件 | 适用场景 |
|---|---|---|
| 标准模式 | 完整工具组合 + Web UI | 日常开发使用 |
| PTC 模式 | 程序化工具调用------模型生成代码来组合多轮工具 | 复杂工作流自动化 |
| 极简模式 | 仅 Shell + 文件编辑工具 | 最小环境下的模型基准测试 |
| 创造模式 | 可检查运行时、在内存中试验 Cordis 插件 | 组合和创作新的模式 |
这四种模式的区别仅仅是加载的插件集合不同------没有任何 if-else 分支写在代码里。切换模式就是切换 Profile,就是换一棵插件树。这就是"一切皆插件"在实践中意味着什么:连"产品形态"本身都是配置。
7. 这套架构的优势在哪里
五个章节拆完,回到最开始的问题:DSH 为什么要用 Cordis?这套架构到底好在哪?
优势一:零 fork 扩展
传统框架想改核心行为,路径是 fork → 改源码 → 维护差异。Cordis 的路径是写一个插件 → ctx.provide('xxx', newImpl) → 完了。
前面看到的远程沙箱替换就是典型案例:把本地文件系统换成远程 RPC,Bash/PTY/LSP 三个工具零代码修改自动迁移。在传统框架里这是大工程------你需要改框架源码里所有 fs.readFile 的调用点。在 Cordis 里,你只是提供了一個新的 Seam provider。
优势二:安全的热插拔
ctx.effect() + LIFO 回滚保证了插件"来得干净,走得也干净"。这意味着你可以:
- 热重载:修改插件代码后自动卸载旧的、加载新的,其他插件无感知
- 动态启停:运行时按需加载/卸载插件,不需要重启进程
- A/B 实验:同时加载两个实现不同策略的插件,通过配置切换哪个生效
这些能力的根基是框架自动追踪副作用------不是靠插件作者自觉写 cleanup,而是靠 ctx.effect() 的可逆注册机制。
优势三:依赖自组织
inject 声明 + Fiber 状态机 = 依赖关系自动推导。你不需要:
- 手动排插件加载顺序
- 写
if (ctx.tools)判断服务是否就绪 - 担心循环依赖------框架在加载阶段就能检测到
插件之间通过服务键声明依赖,框架负责拓扑排序和生命周期联动。依赖消失时自动卸载消费者,依赖恢复时自动重新加载。这一切都是声明式的。
优势四:配置即产品形态
四种 Profile 模式(标准/PTC/极简/创造)的差别仅仅是加载了不同的插件集合。没有任何 if (mode === 'ptc') 写在代码里。切换产品形态 = 切换配置文件 = 换一棵插件树。
这意味着你可以用同一套代码库,通过不同的 Bundle + Patch 组合,派生出完全不同的产品形态------开发工具、CI 机器人、基准测试平台------而不需要维护多个 fork。
一句话总结
当 Agent 领域还在快速演化------新的模型能力、新的工具类型、新的调度策略层出不穷------你需要的不是一个固定的框架,而是一个能让所有部件自由替换、自由组合、自由热插拔的底座。Cordis 就是这个底座:没有特权核心,一切皆插件,注册即可逆,依赖自组织。
参考:
- deepseek-ai/deepseek-harness --- GitHub 仓库
- cordiverse/cordis --- Cordis 元框架
- A Programming Paradigm for Spatiotemporal Composability --- DeepSeek AI & 北京大学, 2026-08-13
- cordis.moe --- Cordis 官方文档
- Koishi --- 四年开发,4000+ 社区插件,Cordis 的首个大规模验证案例