
插件通信只能一对一?
发一条消息,所有插件都收到
事件系统实战
Events · emit / on · waterfall
DSH · Cordis 学习系列
📦 4 Parts + Conclusion
👉 滑动
PART 01
最简单的例子
统计 + 报告
PART 02
五种分发模式
不只是广播
PART 03
深入 Waterfall
洋葱模型
PART 04
事件 vs 服务
何时用哪个
PART ///
写在最后
四课知识树
服务是“点对点”的电话,事件是“群发”的朋友圈——你只管发,谁在看、谁回应,你不需要知道。
在第三课中,我们学会了通过“服务(Service)”让插件之间点对点协作——就像你拿起电话,拨通对方的号码,直接对话。
但现实中的系统往往更复杂:一个插件发出通知,可能有多个插件需要响应;一个插件产生数据,可能有多个插件需要消费。如果每个都要通过服务去“一对一”调用,那代码就会变成一团乱麻——就像你要通知100个人开会,不可能一个个打电话,更高效的作法是在群里发一条消息。
Cordis的事件系统(Events)就是为了解决这个场景而设计的。它让插件之间可以解耦地通信:发送方只负责“广播”,接收方只负责“收听”,双方不需要知道彼此的存在。
今天这一课,我们就来学习如何在DSH插件中使用事件系统,实现“一对多”的松耦合通信。
01
PART
先看一个最简单的例子:统计服务 + 报告监听器
FIRST EXAMPLE
想象一个场景:我们有一个“统计服务”,它记录某些事件(比如“工具被调用了”“Prompt被发送了”)发生的次数,每次计数变化时,它要通知其他人。而我们还有一个“报告插件”,它负责监听这些变化并打印出来。
这种“一产生变化,多出响应”的场景,是事件系统的典型用武之地。
创建统计服务(stats.ts)——它提供服务,也发出事件
import { Service, type Context } from '@deepseek-ai/cordis'
// 声明事件:'stats/report'事件会携带两个参数:事件名称和当前计数
declare module '@deepseek-ai/cordis' {
interface Context {
stats: StatsService
}
interface Events {
'stats/report'(name: string, count: number): void
}
}
export class StatsService extends Service {
private counts = new Map<string, number>()
constructor(ctx: Context) {
super(ctx, 'stats')
}
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
// 关键:计数变化后,发出事件
this.ctx.emit('stats/report', name, next)
}
}
export const name = 'stats'
export function apply(ctx: Context) {
ctx.plugin(StatsService)
}
创建报告插件(reporter.ts)——它消费服务,也监听事件
import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts' // 仅用于类型声明
export const name = 'reporter'
export const inject = ['stats'] // 依赖stats服务
export function apply(ctx: Context) {
// 监听'stats/report'事件
ctx.on('stats/report', (name, count) => {
console.log(`[stats] ${name} -> ${count}`)
})
// 触发几次统计
ctx.stats.bump('tool_call')
ctx.stats.bump('tool_call')
ctx.stats.bump('prompt')
}
组合配置(cordis.yml)
- name: './stats.ts'
- name: './reporter.ts'
运行后输出:
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1
你看到的事件系统的核心机制:
声明事件:通过interface Events声明事件名称和参数类型,让TypeScript提供类型安全。
发送事件:ctx.emit('stats/report', name, next)。
监听事件:ctx.on('stats/report', (name, count) => { ... })。
自动清理:ctx.on()本身是effect,当监听器插件卸载时,监听器自动移除,无需手动removeListener。
这就好比你在群里发了一条消息(emit),群里的任何人(on)都能看到,不需要你单独 @ 他们。
监听器插件被卸载时会自动退群(effect 自动清理监听),不会留下“已读不回”的幽灵。
02
PART
五种事件分发模式:不只是“广播”
5 DISPATCH MODES
上面用的是最简单的emit——它只是把事件发出去,不关心监听者有没有收到、处理结果如何。但Cordis提供了5种分发模式,分别对应不同的通信需求:
| emit | ctx.emit(name, ...args) | 同步广播 |
| parallel | await ctx.parallel(name, ...args) | 并发执行所有监听器 |
| serial | await ctx.serial(name, ...args) | |
| bail | ctx.bail(name, ...args) | serial |
| waterfall | await ctx.waterfall(name, ...args, next) | 洋葱模型 |
emit:就像你在群里发了一条消息,发完就不管了,爱回不回。
parallel:就像你在群里发了一份问卷,等所有人填完才汇总。
serial:就像你挨个打电话问问题,第一个人回答了,后面的人就不用再问了。
waterfall:就像你在群里发了一句话,每个人都可以在上面修改,最后变成一个完整的结果(类似中间件)。
03
PART
深入 Waterfall:理解“洋葱模型”与短路机制
WATERFALL DEEP DIVE
waterfall是Cordis中最强大、也最需要理解的一种模式。它在Harness中被广泛用于决策流程,比如agent/request事件(插件可以修改模型调用配置)和approval/request事件(插件可以代替用户审批)。
我们来看一个演示waterfall的完整例子:
waterfall-demo.ts
import type { Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Events {
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
export const name = 'waterfall-demo'
export function apply(ctx: Context) {
// 监听器1:后注册,但它是“外层”
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// 监听器2:先注册,但它是“内层”
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
// 触发两次waterfall
void (async () => {
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'hello'))
})()
}
运行结果:
HELLO
** BLOCKED **
为什么输入变了?这背后是waterfall的“洋葱模型”:
监听器的执行顺序从先注册到后注册。但关键在于每个监听器中的next()调用会“跳转”到下一个监听器。
形成了层层包裹的结构:监听器1(外层)包裹监听器2(内层);外层通过await next()等待内层执行完,然后对结果做后处理(转大写);内层如果检测到'blocked',直接返回拦截信息,不调用next(),从而短路了更内层的逻辑(本例中最内层是async () => 'hello')。
即使内层短路,外层依然能捕获结果,并做统一处理(转大写)。
这个模式在Harness中的实际应用:
权限校验:外层负责记录日志,内层负责校验权限。校验不通过时,内层直接返回错误,外层记录日志后也返回错误。
缓存拦截:外层负责查缓存,内层负责查数据库。缓存命中时,外层直接返回,不再调用内层。
!踩坑提示 🕳
在 waterfall 监听器中,如果你只是想“观察”数据,必须调用 next()。忘记调用,下游监听器就得不到执行,链条被“悄无声息地截断”——这是最容易踩的坑。
04
PART
事件 vs 服务:什么时候用哪个?
EVENT VS SERVICE
这是一个很实际的问题,我总结了一个简单原则:
服务(Service):用于“我需要一个特定的能力”。比如“我需要一个能统计的服务”,你会明确知道你要调用ctx.stats.bump()。
事件(Event):用于“我想通知别人发生了什么事”或“我想在某个时机插入逻辑”。比如“用户提交了表单,我想让所有对此感兴趣的插件都能收到通知”。
一个直观的区别:服务是“主动调用”,事件是“被动触发”。你用服务时,是你主动去“拿”东西;你用事件时,是“扔”出去一个消息,等别人响应。
///
LAST
写在最后
CONCLUSION
这一课我们学习了Cordis的事件系统。如果说服务是“有线电话”(点对点),那事件就是“微信群聊”(一对多广播)。加上waterfall提供的“洋葱模型”,事件不仅能广播,还能让监听器像中间件一样层层处理、按需短路。
回顾四课的知识树:
第一课:插件是什么——导出一个apply函数。
第二课:如何善后——用effect管理资源清理。
第三课:如何协作——用Service提供能力,用inject消费能力。
第四课:如何通知——用Event广播消息,用waterfall实现中间件链。
下一课,我们将进入“配置(Config)”的世界——如何让插件通过cordis.yml接收外部参数,变得可定制、可复用。
最后,我想问一个问题:在你看过的软件或系统中,有没有哪里用到了“事件驱动”的设计?比如“当文件变化时自动重新编译”“当新用户注册时发送欢迎邮件”?
欢迎在评论区分享你观察到的事件驱动案例。点赞最高的3个案例,我会在下期分析它如何用Cordis的事件机制实现。
我是 violetdream,热衷于分享 AI 观察与干货。
关注本公众号,下周更新【Cordis学习第五课】——配置:让你的插件通过YAML“定制”行为,不再写死。
既然看到这里了,如果觉得有用,随手点个赞、在看、转发三连吧。
THANKS FOR READING
夜雨聆风