ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

DeepSeek Harness 为什么敢说“一切皆插件“?拆透 Cordis 引擎的五大核心机制

DeepSeek Harness 为什么敢说“一切皆插件“?拆透 Cordis 引擎的五大核心机制

大多数 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-loopsessiontools这些看起来像"框架骨架"的组件,和顶层的"自定义工具"是同一种东西:普通插件。你可以在顶层写一个插件替换掉中层的任何一个组件,不需要 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 会:

  1. 卸载旧插件→ 自动回滚它注册的所有工具、监听器、定时器
  2. 加载新插件→ 重新执行apply(ctx),注册新的副作用
  3. 整个过程对其他插件透明——它们只看到"工具列表变了",不需要知道是谁在热重载

这就是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 的:setIntervalsetTimeoutEventEmitter.onfs.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.toolsctx.llmctx.sessionsctx.agentLoop——全部是 Seam。它们不是"碰巧用了这个模式",而是 Cordis 框架内置的服务注册表机制本身。框架只认服务键,不认具体实现。这意味着:

  • 核心服务也是 Seamcore/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.toolscore/tools 插件工具注册表和受保护的执行管道
ctx.llmllm/llm 插件消息词汇表和模型适配器接缝
ctx.sessionscore/session 插件追加式事件日志和内存存储
ctx.agentLoopcore/agent-loop 插件默认的 Turn/Step 驱动实现
ctx.systemPromptcore/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 的首个大规模验证案例
返回列表