Cordis:DSH 底下那颗「心」,究竟是什么
古董级程序员,从大厂到创业公司,现在还在一线做 AI 相关开发。微信公众号「字与码」会继续更新工程实践、新技术判断,以及这些年踩过的坑。文章若对你有用,欢迎顺手关注。
上一篇写 DeepSeek Harness(DSH)时,反复出现一个词:Cordis。模型适配、工具、会话、Agent 循环乃至界面,都可以被描述成插件;这句话能成立,靠的不是某一层业务代码,而是 Cordis 提供的运行时组合语义。
Cordis 的官方定位是 “时空可组合性元框架”。它不是一个聊天框架,也不认识 LLM、消息或数据库这些领域概念。它处理的是更底层的问题:组件需要什么环境、组件为环境带来什么变化,以及组件离开后,如何让这些变化有序地撤销。
这篇不重复 DSH 的工具与会话源码拆解,而是把 Cordis 作为一层独立的底座来看:它为什么适合 Agent,又在哪些地方不能替团队做风险决策。
它不是“更多插件”,而是让插件能够离开
普通插件系统最难的时刻,不是安装,而是卸载。一个模块可能注册了事件、开了定时器、持有连接、挂载了工具,甚至修改了共享配置。代码停掉以后,这些副作用有没有被完整回收,通常只能依赖作者记得写清理逻辑。
Cordis 把这件事提升为运行时约定:注册副作用时,要么由框架的辅助 API 自动返回清理动作,要么由插件显式提供 disposer;当插件被 reload 或 teardown 时,这些注册会按预期撤销。官方的 Cordis 入门文档把这件事称为“可逆的副作用”。
这也是它对 Agent Harness 有吸引力的原因。Agent 运行时里,模型供应商、工具集、审批策略、提示词片段与 UI 不一定在进程启动时就固定。如果组件能被安全地加入、替换和移除,系统才有机会做热更新、分环境组合和逐步演进。
但“可逆”有严格边界:只能撤销由运行时记录、且仍在其控制边界内的状态。已经发出的网络请求、写入第三方系统的数据、给用户发出的消息,不会因为卸载插件而自动消失。把可逆副作用理解为完整事务或安全沙箱,是一个危险的误读。
五个核心概念
Plugin:没有特权的功能单元
Cordis 中的插件可以是带 inject 与 apply(ctx) 的函数,也可以是一个 Service 子类。它们通过当前上下文获得能力,而不是到处导入某个具体实现。
export const inject = ['database']
export function apply(ctx) {
// 只有 database 已就绪,插件才会进入这里。
ctx.on('ready', () => {
ctx.logger.info('database is ready')
})
}
这段代码值得注意的不是 ready 事件,而是依赖表达方式:database 不是一个写死的全局单例,插件只声明“我需要这个服务”。谁提供它、何时替换它,留给运行时和组合配置决定。
Context:服务的容器,而不是万能全局对象
服务占据稳定的 ctx.<key>,例如 ctx.tools、ctx.llm、ctx.sessions。其他插件按 key 查询能力,而非绑定到一个具体类或包。
这使得同一份业务插件可以在不同环境获得不同的实现:开发环境接本地存储,生产环境接远程存储;一个 Profile 使用某个模型适配器,另一个 Profile 换成别的实现。重要的是服务契约,而不是它由谁创建。
inject:把启动顺序变成依赖关系
inject 是插件对环境的声明。服务未就绪时,插件会等待;服务出现、消失或发生变化时,运行时可以据此决定哪些组件需要激活、停用或重新加载。
这比手工排一串“先启动 A,再启动 B”的脚本更适合长期演进,但调试体验也更陌生:一个插件没有报错、也没有输出,可能只是还在等待依赖。引入 Cordis 时,团队需要把“检查依赖状态”纳入日常诊断,而不是只看异常堆栈。
类型化事件:四种分发语义
Cordis 的事件不是只做通知。官方文档区分了四种分发方式:
| 模式 | 是否等待监听器 | 适合的场景 |
|---|---|---|
emit | 否 | 单纯观察与通知 |
waterfall | 否 | 拦截、包装、改写或短路决策 |
parallel | 是 | 可以并行完成的副作用 |
serial | 是 | 有顺序依赖的处理 |
waterfall 尤其适合策略点:监听器可以调用 next() 把控制权交给下游,也可以不调用而直接返回,形成有意的短路。反过来,稳定的直接能力调用应优先放在服务方法里;不要把所有函数调用都绕成事件,不然调用关系和错误边界会越来越难追。
可逆副作用:把清理写进生命周期
事件监听、工具 schema、提示词片段、适配器和 Provider 都是“对环境做了事”。Cordis 推荐它们通过 ctx.on()、ctx.effect() 等 API 注册,并在 effect 中返回对应的 disposer。
ctx.effect(() => {
const connection = createConnection()
return () => connection.close()
})
当生命周期结束时,运行时能以相反方向执行清理动作。它减轻的是“忘记收尾”的结构性风险,而不是替你保证每个外部副作用都可回滚。
论文里的“时空可组合性”说的是什么
Cordis 关联的论文把问题拆成两个正交维度。
- 时间可组合性:组件移除后,能够完整撤销它在受控环境中留下的副作用。
- 空间可组合性:组件可以声明自己需要的依赖,并对周围环境的变化作出反应。
论文把传统的 effect 与 coeffect 概念提升为运行时机制:前者记录环境变化及其逆操作,后者用依赖规格驱动组件的激活与重载。它还给出 Cordis 的实现,包括 effect tracking、coeffect resolution、配置协调与热模块替换。
这不是说任何接入 Cordis 的程序都自动正确;论文自己也仍是持续修订的预印本。它提供的是一个可以讨论、实现和检验的模型:当一个组件退场时,什么应当被撤销?当某个服务替换时,谁应当重新激活?这些问题不再只能靠约定俗成的生命周期钩子回答。
为什么 DSH 会把它 vendor 进来
DSH 文档明确说明,Cordis 是以 vendor 方式引入的插件框架。对 Agent 运行时而言,这带来一个很实际的好处:把运行时的组合边界放在一个可见、可复查的地方。
DSH 上层再定义 Agent 语义:模型流式输出、工具执行、会话持久化、审批、沙箱、工作流与子 Agent。Cordis 不需要理解这些词本身,只负责让服务、事件和副作用能够在对应 Context 中组合与撤销。
这也解释了为什么“Everything is a Plugin”不应该被理解为“所有模块一样重要”。在 Cordis 这一层,不区分 Agent Loop 和 Memory 是它作为元框架的克制;在 DSH 或具体产品层,哪些能力拥有更高权限、哪些资源可以被替换,仍然必须被明确建模。
真正的难点从运行时转向治理
插件化不是免费午餐。动态装载与热更新把组合能力给了团队,也把版本、权限和责任边界推到了台前。
- 插件声明的能力是否最小化,是否有明确的权限与配置来源;
- 某个服务替换后,哪些下游需要重启,哪些状态必须迁移;
- disposer 是否覆盖连接、定时器、监听器和后台任务;
- 插件生态升级时,如何锁版本、做兼容测试并保留回滚路径。
对于普通业务系统,如果组件并不需要在运行时频繁变更,引入这种元框架未必划算;它会带来更多抽象和诊断成本。对要做 Agent Harness、插件平台或可长期演进的工具链的团队,Cordis 的启发更大:不要只设计“怎么加载模块”,还要把“怎么安全地撤销、替换和观察模块”当成一等问题。
一条务实的试用路线
如果想理解它,不必先把整个系统迁过去。可以从一个边界清晰的插件开始:声明一个服务依赖、注册一个事件、持有一个可关闭的资源,然后验证四件事:依赖缺失时它是否按预期等待;依赖就绪时是否激活;reload 后旧资源是否关闭;失败时日志能否说明它卡在哪个阶段。
这比只看“能否热更新成功”更有价值。真正的验收标准不是页面上出现了新功能,而是反复加载、卸载、替换以后,系统还是否只保留当前应有的状态。
参考资料
- Cordis 官方仓库
- A Programming Paradigm for Spatiotemporal Composability(论文预印本)
- DeepSeek Harness:Cordis 入门
- ZiCode:DeepSeek Harness 开源后,我先看了它的运行时
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。