
字节笔记本
2026年10月6日 · 约 7 分钟读完
Cordis 教程 4:事件与五种派发模式
本文是 Cordis 插件开发系列教程的第四篇,主题是事件系统。Cordis 是开源 agent 项目 deepseek-harness 所依赖的插件框架,系列前三篇走完了第一个插件、生命周期与效果、服务与依赖注入,这一篇解决另一个问题:插件之间互不相识时,如何互相通知。

事件解决什么问题
服务支持直接调用,前提是你得知道对方的名字。但很多场景里,插件只想广播一句"某件事发生了",既不关心谁在听,也不应该关心。事件就是为这种解耦准备的:发出方调用 emit,监听方用 on 订阅,双方互不知晓。在 deepseek-harness 里,工具结果、模型请求、审批决定这些关键交互都走事件通道。
声明、发出与监听
事件名和监听器签名通过声明合并写进框架类型。看一个计数服务的核心代码:
declare module '@deepseek-ai/cordis' {
interface Events {
'stats/report'(name: string, count: number): void
}
}
export class StatsService extends Service {
private counts = new Map<string, number>()
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
this.ctx.emit('stats/report', name, next)
}
}给 interface Events 增加成员,框架会自动把它合并进事件命名空间,这与用 interface Context 声明服务属性是同一套机制:此后 ctx.emit 和 ctx.on 都有完整类型提示,事件名拼错、参数个数不对,编辑器当场报错。namespace/action 的命名约定(比如 stats/report)让扁平的事件列表依然井井有条。
监听方是另一个独立插件:
export const name = 'reporter'
export const inject = ['stats']
export function apply(ctx: Context) {
ctx.on('stats/report', (name, count) => {
console.log(`[stats] ${name} -> ${count}`)
})
ctx.stats.bump('tool_call')
ctx.stats.bump('prompt')
}有个细节值得留意:import type {} from './stats.ts' 这一行在运行时不导入任何东西,它的唯一作用是让 TypeScript 看到上面的声明合并。把两个插件写进 cordis.yml 组合运行,输出符合直觉:
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1另一个容易被忽略的点:ctx.on() 本身是 effect,监听器的生命周期与插件绑定。插件卸载时监听器自动消失,从头到尾不需要手写 removeListener,也就不存在忘记解绑导致的内存泄漏。
五种派发模式
emit 只是五种派发模式之一。一个事件采用哪种模式,是它约定的一部分,这决定了监听器能不能返回值、能不能并发执行、能不能互相短路:
| 模式 | 调用方式 | 语义 |
|---|---|---|
| emit | ctx.emit(name, ...args) | 同步广播,返回值不等待也不收集 |
| parallel | await ctx.parallel(...) | 全部监听器并发运行,结果一起等待 |
| serial | await ctx.serial(...) | 按顺序执行,第一个非 null、false、undefined 的返回值胜出,并拦停后续监听器 |
| bail | ctx.bail(...) | serial 的同步版本 |
| waterfall | ctx.waterfall(name, ...args, next) | 环绕中间件,可转换结果也可短路 |

waterfall:转换或否决
waterfall 是实现拦截的关键模式。每个监听器除了收到事件参数,还会拿到一个 next() 函数:调用 next() 就把控制权交给链条的下一环,拿到下游结果后可以再加工;不调用 next() 直接返回,则整条链条短路,Cordis 文档把这种行为称为否决。
官方教程给了一个直观的例子:监听器 1 调用 next() 并把结果转成大写;监听器 2 检查输入,发现包含 blocked 就直接返回替换文案。对普通输入,默认逻辑正常执行,再被外层逐层加工;对含 blocked 的输入,传给 ctx.waterfall 的最内层默认函数从未运行,最终输出的是监听器 2 给出的否决结果。
由此得到一条重要的纪律:只负责观察或标注的 waterfall 监听器,必须调用 next()。一个日志监听器如果忘了调用 next(),会无声无息地吞掉所有下游插件的默认行为,而且不会有任何报错,这类问题排查起来非常痛苦。这也是 deepseek-harness 仓库里反复强调的常设规则。
harness 里的真实用法
deepseek-harness 把需要协作插件"包装或作答"的决策点都做成了 waterfall 事件:agent/request 允许插件在模型调用前替换请求配置,approval/request 允许策略插件代替用户给出审批答案。事件加上五种派发模式,构成了这个框架"解耦而不失控"的底层机制:谁都能参与决策,但每一步的类型和生命周期都由框架兜底。



