大多数 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 支持三种等价的插件定义方式:
// 写法一:纯函数(最常见)exportfunctionapply(ctx){ctx.on('tool/call',(event)=>{console.log('工具被调用了',event.name)})}// 写法二:带依赖声明和名字的对象exportconstname='my-plugin'exportconstinject=['tools','session']exportfunctionapply(ctx){// 此时 ctx.tools 和 ctx.session 一定已就绪}// 写法三:类classMyPlugin{staticinject=['tools']constructor(ctx){// 等价于 apply(ctx)}}三种写法在运行时完全等价。核心约定只有一个:插件需要一个apply(ctx)入口(函数本身就是 apply,类的 constructor 等价,对象需要 apply 方法)。
在 Harness 中,"一切皆插件"长什么样
DSH 默认部署有 159 个插件。这不是夸张——连 agent loop(负责驱动每一轮对话的核心调度器)都是一个普通插件,挂在ctx.agentLoop这个服务键上。你可以直接禁用它,换上自己的调度逻辑。
来看一个真实的例子。假设你想给 agent 加一个"每次调用工具前记录审计日志"的功能:
exportconstname='audit-log'exportconstinject=['tools']// 依赖工具服务exportfunctionapply(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 统一管理回收,但两者的地位不同:
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()。它接收一个函数,函数里做副作用操作,并返回一个撤销函数:
exportfunctionapply(ctx){ctx.effect(()=>{// === 做副作用 ===consttimer=setInterval(()=>{console.log('心跳')},5000)constoff=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()。
exportfunctionapply(ctx){// ✅ 这些不用包 effect,Fiber 卸载时自动撤销ctx.on('agent/pre-step',handler)// listener 自动移除ctx.provide('notify',{...})// service 自动注销ctx.middleware((next,send)=>{...})// 中间件自动摘除ctx.plugin(childPlugin)// 子 Fiber 自动 dispose// ❌ 这些是外部资源,Cordis 管不到,必须手动注册 effectconsttimer=setInterval(()=>heartbeat(),5000)ctx.effect(()=>clearInterval(timer))// 否则定时器泄漏constdb=awaitconnectDatabase(url)ctx.effect(()=>db.close())// 否则连接泄漏constwatcher=fs.watch('./config.json',reload)ctx.effect(()=>watcher.close())// 否则 watcher 泄漏constserver=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 的功能,直接importB 的代码吗?不行——那样就硬耦合了,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 里没有"通知服务",你想自己建一个——让其他插件可以发送桌面通知。
第一步:定义服务接口(契约)
// 通知服务的接口契约——约定了消费者能调用什么方法interfaceNotificationService{notify(title:string,body:string):voidsetEnabled(enabled:boolean):void}在 DSH 中,服务接口通常以 TypeScript 类型声明存在,作为插件之间的"合同"。消费者看接口就知道能调什么方法,不需要看提供者的实现代码。
第二步:提供者——注册服务实现
// desktop-notify.js — 通知服务的提供者exportconstname='desktop-notify'exportfunctionapply(ctx){letenabled=true// 注册服务:把实现挂到 'notify' 这个键上ctx.provide('notify',{notify(title,body){if(!enabled)return// 调用系统通知 APIprocess.stdout.write(`\x1b]9;${title}^${body}\x07`)},setEnabled(val){enabled=val}})// 提供者卸载时,框架自动注销 'notify' 服务键// 消费者会感知到服务消失,自动进入 PENDING 等待}第三步:消费者——通过 ctx 使用服务
// task-reminder.js — 通知服务的消费者exportconstname='task-reminder'exportconstinject=['notify']// 声明依赖exportfunctionapply(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 工具在本地执行,文件编辑器改本地文件。现在你想把所有执行都搬到远程沙箱:
// remote-sandbox.js — 替换 fs 服务的提供者exportconstname='remote-sandbox'exportfunctionapply(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 根据这个声明自动推导加载顺序:
// 这个插件需要 tools 和 session 两个服务exportconstinject=['tools','session']exportfunctionapply(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 事件,是插件可以拦截的"接缝"。
举个实际的拦截例子。假设你想做一个"敏感词过滤"插件——每次模型请求前检查消息,发现敏感词就拦截:
exportconstname='sensitive-filter'exportconstinject=['agent']exportfunctionapply(ctx){// agent/pre-step 是 waterfall 事件ctx.on('agent/pre-step',(event,next)=>{constmessages=event.messagesconsthasSensitive=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 定位要替换或新增的配置:
# cordis.patch.yml# 替换默认的 LLM 适配器,改用自定义 provider-id:llm-deepseekreplace:plugin:my-custom-llmconfig:apiKey:${env.MY_API_KEY}model:deepseek-v4-pro# 新增一个审计日志插件-id:audit-loginsert:plugin:@my-org/dsh-auditconfig:logPath:/var/log/dsh-audit.jsonl想看你的机器实际启动了什么?一行命令:
dsh--profileweb --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 的首个大规模验证案例