在上一章中,我们学习了服务——它让一个插件可以向其他插件提供能力。通过 inject 声明依赖,插件 A 可以调用插件 B 提供的服务方法。这是一种直接的、点对点的通信方式。
但有时候,我们需要的不是“点对点”,而是“一对多”。
想象这样一个场景:你的插件要执行一个耗时操作,你希望在这个操作开始、进行中和结束时,其他插件能够感知到这些时刻并做出响应——比如记录日志、更新 UI、触发后续任务。你不可能提前知道有哪些插件会关心这些事件,也不应该让它们都来依赖你的服务。
这时候,事件就派上用场了。
事件是 Cordis 插件间通信的核心机制。与服务不同,事件不要求发送方知道接收方是谁——发送方只管“发出信号”,任何感兴趣的插件都可以“收听”。这是一种松耦合的通信方式,让插件之间可以“对话”,却不需要互相认识。
Harness 大量使用事件来实现可扩展的钩子点——比如工具执行结果、Agent 的每一步、会话状态的变更,都以事件的形式暴露出来,供其他插件监听和干预。
事件是什么?——插件的“广播站”
在 Cordis 中,事件本质上是一个命名的信号,附带一些数据(payload)。
发送方调用 ctx.emit('事件名', 数据) 来发出信号。
接收方调用 ctx.on('事件名', (数据) => { ... }) 来监听信号。
// 发送方:发出一个事件
ctx.emit('my-plugin/ready', { id: 'worker-1' })
// 接收方:监听这个事件
ctx.on('my-plugin/ready', ({ id }) => {
console.log(`${id} is ready`)
})发送方不关心谁会收到这个消息,接收方也不关心是谁发出的。它们只通过事件名来匹配——这就是“松耦合”的含义。
给初中级开发者的比喻:可以把事件想象成“广播电台”。发送方是电台,它只管广播信号(事件名 + 数据),不关心谁在听。接收方是收音机,它调到一个频道(事件名),就能收到广播内容。电台和收音机之间没有直接连接,全靠“频道”这个约定来匹配。
四种事件模式:不同的“对话方式”
Cordis 提供了四种事件分发模式,分别适用于不同的协作场景。
模式 | 是否异步 | 执行顺序 | 是否有返回值 | 适用场景 |
|---|---|---|---|---|
emit | 否 | 按注册顺序 | 否 | 通知、日志 |
bail | 否 | 按注册顺序 | 是(短路) | 拦截、权限检查 |
serial | 是 | 按注册顺序 | 是(短路) | 异步顺序处理 |
waterfall | 是 | 按注册顺序 | 是(流水线) | 数据转换、中间件链 |
emit——广播通知
emit 是最简单的事件模式。发送方发出事件后,所有监听器同步执行,返回的值被忽略。
// 发送
ctx.emit('my-plugin/ready', { id: 'worker-1' })
// 监听——所有监听器都会执行,返回值不重要
ctx.on('my-plugin/ready', ({ id }) => {
console.log(`Worker ${id} is ready`)
})什么时候用? 当你想“通知”其他插件某件事发生了,但不关心它们是否处理、如何处理。比如:
记录日志:ctx.emit('tools/result', { tool, result })。
更新状态:ctx.emit('session/update', { sessionId, data })。
触发 UI 刷新。
emit 的哲学是:“我告诉你一声,你爱咋办咋办。”
bail——短路拦截
bail 模式中,监听器按注册顺序执行,第一个返回非 null、非 false、非 undefined 的值会成为最终结果,后续监听器不再执行。
// 发送:检查某个操作是否被允许
const result = ctx.bail('some-check', input)
// 监听:返回非空值表示“拦截”
ctx.on('some-check', (input) => {
if (shouldBlock(input)) {
return 'blocked' // 这个值会作为最终结果返回,后续监听器不再执行
}
// 返回 null / false / undefined 表示“放行”,继续检查下一个监听器
return null
})什么时候用? 当你想实现“谁说了算”的决策逻辑——多个插件都可以对某个请求做出判断,但只要有任何一个说“不”,整个操作就应该终止。比如:
权限检查:任何插件都可以拒绝一个操作。
内容审核:任何插件都可以标记某个内容为违规。
特性开关:任何插件都可以决定某个功能是否启用。
bail 的哲学是:“你们一个一个来,谁先拍板就听谁的。”
serial——异步顺序执行
serial 和 bail 类似,但监听器是异步的(可以 await),并且按注册顺序逐个执行。
// 发送:按顺序执行一系列异步设置
await ctx.serial('setup-phase', context)
// 监听:可以执行异步操作
ctx.on('setup-phase', async (context) => {
await initializeDatabase(context)
// 返回非空值会停止后续执行
})什么时候用? 当多个插件需要按顺序完成一系列异步任务,且任何一个任务失败就应该停止后续任务时。比如:
插件初始化流程。
请求预处理链。
数据迁移步骤。
serial 的哲学是:“你们排好队,一个一个来,谁出问题就喊停。”
waterfall——流水线处理
waterfall 是最强大的事件模式。每个监听器可以包装下游的结果,形成一条处理链。
监听器接收一个 next 函数,调用 next() 将处理权交给下游,并拿到下游处理后的结果。
// 发送:输入是原始文本,经过流水线处理后得到最终输出
const output = await ctx.waterfall(
'my-plugin/transform',
input,
async () => input // 初始值:如果没有监听器,返回原始 input
)
// 监听:每个监听器都可以在调用 next() 前后对数据进行处理
ctx.on('my-plugin/transform', async (_input, next) => {
const downstream = await next() // 等待下游处理完
return downstream.trim() // 对下游结果做进一步处理
})关键规则:waterfall 的监听器必须调用 next()。如果不调用,流水线就会在此处中断——下游监听器不会被执行。这种设计使得 waterfall 既可以用于“拦截”(不调用 next() 直接返回),也可以用于“转换”(调用 next() 后包装结果)。
什么时候用? 当多个插件需要依次处理同一份数据,每个插件都可以对前一个插件的结果进行加工时。比如:
提示词(Prompt)处理链:多个插件依次追加或修改系统提示词。
工具结果格式化:多个插件依次对工具返回结果做后处理。
中间件模式:请求/响应的层层包装。
waterfall 的哲学是:“你们组成一条流水线,每个人加工上一道工序传来的半成品,最后产出成品。”
四种模式对比速查
为了帮你快速选择,这里做一个直观的对比:
你的需求 | 选择 |
|---|---|
通知大家某件事发生了,不关心返回值 | emit |
多个插件都可以否决某个操作,谁先否决听谁的 | bail |
多个插件按顺序执行异步任务,出问题就停 | serial |
多个插件依次加工同一份数据,每个都可以修改 | waterfall |
给初中级开发者的提示:在实际开发中,emit 是最常用的——大部分场景只是“通知”而已。waterfall 是最强大的,但也最需要谨慎设计——确保每个监听器都清楚地知道自己在链中的位置和作用。
类型安全事件:让 TypeScript 帮你检查
上面的 emit 例子中,事件名是字符串('my-plugin/ready'),payload 的类型也没有约束。这很容易出错——比如拼错了事件名,或者传错了数据类型。
Cordis 支持类型化事件,通过 TypeScript 的声明合约为事件提供类型安全:
import '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Events {
// 事件名 → 监听器函数类型
'my-plugin/ready': (payload: { id: string }) => void
'my-plugin/check': (input: string) => boolean | undefined
'my-plugin/transform': (input: string, next: () => Promise<string>) => Promise<string>
}
}
// 现在这些调用都有完整的类型提示和检查
ctx.emit('my-plugin/ready', { id: 'worker-1' }) // 类型匹配
ctx.on('my-plugin/ready', ({ id }) => { ... }) // id 是 string声明合并的规则:
事件名以 namespace/action 格式命名,比如 tools/result、agent/step。
监听器函数的参数类型由 Events 接口中的签名决定。
emit、bail、serial、waterfall 的调用都会根据签名进行类型检查。
给初中级开发者的建议 :在开发自己的插件时,强烈建议为自定义事件添加类型声明。这不仅能防止拼写错误,还能让其他开发者(以及未来的你)清楚地知道这个事件携带什么数据、期望什么返回值。类型声明是插件 API 的“文档”。
事件监听器是 Effect——自动清理
还记得我们在生命周期章节中学过的“自动清理”吗?事件监听器也不例外。
通过 ctx.on() 注册的监听器,在插件卸载时会自动移除:
export function apply(ctx: Context) {
// 这个监听器会在插件卸载时自动移除
ctx.on('tools/result', (exec, result) => {
console.log(`[tool] ${exec.tool} returned ${result}`)
})
}你不需要手动调用 ctx.off() 来清理监听器。框架会替你做——而且是在正确的时机、以正确的顺序。
给初中级开发者的提示:这是 Cordis“可逆副作用”设计理念的体现。所有通过 ctx 进行的注册——事件监听、工具注册、服务提供——都是“可逆的”。插件卸载时,这些注册自动撤销,不会留下任何残留。这让你可以放心地在插件中注册事件监听器,而不用担心内存泄漏或“幽灵监听器”。
完整示例:日志插件
让我们把本章的知识点串起来,写一个完整的日志插件。它监听 tools/result 事件,在每次工具调用完成时记录日志。
import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'
export const name = 'tool-logger'
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
const text = result.content
.map(block => block.type === 'text' ? block.text : '')
.join('')
console.log(`[tool result] ${text.slice(0, 100)}`)
})
}这个插件做了什么?
没有注册任何工具——它只是“听”。
没有依赖任何服务——事件监听不需要 inject。
在插件卸载时自动清理——监听器会自动移除。
这就是事件驱动的魅力:一个纯“观察者”插件,只需要几行代码,就能为系统增加有价值的可观测性,而完全不需要修改任何现有代码。
在本文的结尾,我们将把日志插件与 2.2 节的服务示例相结合,展示如何将其部署到实际环境中运行,使整个系统既能通过服务(Metrics)提供指标记录,又能借助事件监听(Logger)实现调用追踪。
通过这个实践,你将亲眼看到:
服务(Service)如何提供能力。
事件(Event)如何实现松耦合的观察。
两者如何在一个插件体系中协同工作。
Harness 中的内置事件
在实际开发中,你更多时候是在监听 Harness 提供的内置事件,而不是创建自己的事件。
Harness 使用 namespace/action 命名规范,内置事件包括:
事件名 | 触发时机 |
|---|---|
agent/step | Agent 执行每一步时 |
agent/request | Agent 发起 LLM 请求时 |
agent/request-error | LLM 请求出错时 |
tools/result | 工具执行完成时 |
session/event | 会话中发生任何事件时 |
重要区分:turn/*、step/*、tool/call、tool/result、compaction/* 是持久化会话事件类型,而不是同名的 Cordis 事件。要观察它们,需要监听 session/event 并通过 event.type 来判断具体类型,而不是直接监听 turn/step 这个事件名。
完整的签名和模式记录在子系统页面的 cordis-surface 区域中。
这些内置事件构成了 Harness 的扩展点体系——你可以在不修改核心代码的情况下,通过监听这些事件来注入自定义逻辑:记录日志、修改请求、拦截结果……一切皆可插拔。
事件与服务:如何选择?
现在你有了两种插件间通信的方式——服务和事件。什么时候用哪个?
维度 | 服务 | 事件 |
|---|---|---|
关系 | 点对点:一方提供,一方消费 | 一对多:一方发送,多方监听 |
耦合度 | 较高:消费方需要知道提供方的服务名 | 较低:发送方和接收方只通过事件名匹配 |
返回值 | 有:消费方调用方法获取返回值 | 视模式而定:emit 无返回值,bail/serial/waterfall 有 |
典型场景 | 能力共享(工具注册、LLM 调用) | 通知、拦截、数据转换、扩展点 |
经验法则:
如果你需要调用一个具体的能力(比如注册工具、发起 LLM 请求),用服务。
如果你需要通知其他插件某件事发生了,或者让其他插件有机会干预某个流程,用事件。
两者不是互斥的——一个复杂的插件往往同时使用服务和事件:通过服务提供能力,通过事件暴露扩展点。
总结
本章我们学习了 Cordis 事件系统的完整机制。核心收获如下:
概念 | 说明 |
|---|---|
事件 | 插件间松耦合通信的命名信号 |
ctx.emit() | 发送事件,所有监听器同步执行,返回值被忽略 |
ctx.on() | 注册事件监听器 |
emit 模式 | 广播通知,适合日志、状态更新 |
bail 模式 | 短路拦截,第一个非空返回值终止后续监听器 |
serial 模式 | 异步顺序执行,适合初始化流程 |
waterfall 模式 | 流水线处理,每个监听器可包装下游结果 |
类型安全事件 | 通过 declare module 为事件提供 TypeScript 类型 |
事件监听器是 Effect | 通过 ctx.on() 注册的监听器在插件卸载时自动移除 |
事件让插件之间可以“对话”,却不需要互相认识。这种松耦合的设计让 Harness 的插件体系具有极强的可扩展性——你可以在不修改任何现有代码的情况下,通过监听事件来注入自己的逻辑,或通过发送事件来触发其他插件的行为。
下一章,我们将进入实战进阶篇,学习如何将基础篇和框架篇的知识综合运用,构建生产级的 Harness 插件——包括能力的三种角色设计模式和 LLM 适配器的实现。
实践:将日志插件集成到服务示例中
在 2.2 的服务示例基础上,添加一个事件监听插件,让工具调用“看得见”
准备工作
我们继续使用 scratch-plugin 作为工作目录。请确保你已经完成了 2.2 节中“完整示例:从定义到使用”的所有步骤,即:
已有 metrics-service.ts(服务提供方)。
已有 consumer-plugin.ts(服务消费方,注册了 greet_with_metrics 工具)。
已有 cordis.yml,按顺序加载了 metrics 和 consumer。
如果还没有,请先回到 2.2 节完成那些步骤,再继续下面的操作。
第一步:创建日志插件
在 scratch-plugin/src/ 目录下新建一个文件,命名为 logger-plugin.ts,内容如下:
import type { Context } from '@deepseek-ai/cordis'
/**
* 插件名称,Cordis 框架通过此名称识别和加载插件
*/
export const name = 'logger-plugin'
/**
* 插件入口函数
*
* @param ctx - Cordis 框架上下文对象,提供事件监听等能力
*
* 该插件的核心职责:
* 1. 监听 tools/result 事件 —— 工具执行完毕后,记录工具名、入参和结果摘要
* 2. 监听 session/event 事件 —— 会话级别的工具调用与结果日志,覆盖更上层的事件流
*/
export function apply(ctx: Context) {
console.log('[logger] plugin loaded, listening to session tool events')
/**
* 监听低层的工具执行结果事件
*
* 每当一个工具完成执行,框架会通过此回调派发结果。
* 这里将工具的名称、序列化参数以及结果文本(截断至 100 字符)输出到控制台,
* 便于调试时快速查看每一次工具调用的输入与产出。
*/
ctx.on('tools/result', (exec, result) => {
console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
// 从结果的 content 块中提取所有 text 类型的文本内容
const text = result.content
.map(block => block.type === 'text' ? block.text : '')
.join('')
// 仅打印前 100 个字符,避免长文本刷屏
console.log(`[tool result] ${text.slice(0, 100)}`)
})
/**
* 监听会话级事件
*
* 与 tools/result 不同,session/event 携带更丰富的上下文信息,
* 可以同时捕获工具调用的发起(tool/call)和返回(tool/result)两个阶段。
*/
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') {
// 工具被调用时,记录工具名和传入参数
console.log(`[logger] tool "${event.data.name}" called with args:`, event.data.arguments)
}
if (event.type === 'tool/result') {
// 工具返回结果时,记录完整的返回消息内容
console.log(`[logger] tool result:`, event.data.message.content)
}
})
}这个插件非常纯粹——它只做一件事:监听事件并打印日志。它不需要依赖任何服务,也不需要注册任何工具。它是“无侵入”的,可以随时添加或移除,而不影响其他插件的逻辑。
给初中级开发者的提示:事件监听是观察者模式的一种实现。日志插件就像一个“偷听者”——它默默听着系统里发生的一切,但从不打扰其他人的工作。
这个插件非常纯粹——它只做一件事:监听事件并打印日志。它不需要依赖任何服务,也不需要注册任何工具。它是“无侵入”的,可以随时添加或移除,而不影响其他插件的逻辑。
第二步:更新 cordis.yml
现在,我们需要把日志插件也加到配置中。打开 scratch-plugin/cordis.yml,将其修改为:
- insert:
- id: metrics
name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/metrics-service.ts'
- id: consumer
name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/consumer-plugin.ts'
- id: logger
name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/logger-plugin.ts'注意:
将 name 对应的路径替换为你本地的实际路径(file://需要保留)。
加载顺序:metrics → consumer → logger。因为 consumer 依赖 metrics,必须先加载 metrics。logger 不依赖任何服务,放在最后即可。
每个插件都设置了唯一的 id,方便后续管理和调试。
第三步:启动并验证
启动 Web UI:
cd scratch-plugin/
pnpm dsh web --patch ./scratch-plugin/cordis.yml观察启动日志,你应该看到:
[logger] plugin loaded, listening to session tool events
[metrics] service initialized!
[metrics] consumer_loaded: 1
dsh web: http://127.0.0.1:3080这证明三个插件都已成功加载。
现在打开浏览器 http://127.0.0.1:3080,在对话框中输入:
Use the greet_with_metrics tool to greet Bob.模型会调用 greet_with_metrics 工具,返回 Hello, Bob!。
I greeted Bob using the
greet_with_metricstool. The tool responded with "Hello, Bob!" and recorded the interaction.
此时,终端日志中会输出两方面的信息:
来自 Metrics 服务的记录(在 consumer 中调用的):
[metrics] greet_tool_called: 1来自 Logger 插件的事件监听
[logger] tool "greet_with_metrics" called with args: {"name": "Bob"}
[tool] greet_with_metrics({"name":"Bob"})
[tool result] Hello, Bob!
[logger] tool result: [
{
type: 'tool-result',
toolCallId: 'call_00_TdxDus46RefM09FmV1hJ1931',
content: [ [Object] ],
isError: false
}
]你可能注意到 consumer_loaded: 1 在服务加载时就打印了,而 Logger 的日志是在工具被调用时才出现。两者的时序不同,但都正确地反映了系统的运行状态。
第四步:进一步测试——多次调用
你可以多次在对话框中要求模型调用 greet_with_metrics(例如 greet Alice、greet Charlie)。你会看到:
Metrics 服务的 greet_tool_called 计数每次加 1。
Logger 插件每次都会打印完整的调用参数和结果。
[logger] tool "greet_with_metrics" called with args: {"name": "Alice"}
[metrics] greet_tool_called: 2
[tool] greet_with_metrics({"name":"Alice"})
[tool result] Hello, Alice!
[logger] tool result: [
{
type: 'tool-result',
toolCallId: 'call_00_ET_x3BANkTCsNn43JkYgVQY5307',
content: [ [Object] ],
isError: false
}
]这完美地展示了:
服务提供了可累加的状态(计数)。
事件提供了即时的行为日志(每次调用的细节)。
两者相辅相成,却互不干扰——Logger 插件完全不知道 Metrics 的存在,Metrics 也不依赖 Logger。它们通过 tools/result 这个事件松耦合地联系在一起。
如果移除 Logger 插件会怎样?
你可以尝试注释掉 cordis.yml 中的 logger 行,然后重启(或使用 HMR 重载)。你会发现:
Metrics 和 Consumer 依然正常工作。
只是不再有 [logger] 的日志输出。
这说明日志插件是“可插拔”的——它的存在与否不会影响核心业务逻辑。这正是事件驱动设计的优势:观察者可以随时加入或离开,而不影响被观察者的行为。
至此, 我们完成了一个多插件协作的小型系统:
插件 | 角色 | 使用的机制 |
|---|---|---|
metrics-service | 服务提供方 | 继承 Service,对外暴露 record / increment 方法 |
consumer-plugin | 服务消费方 + 工具提供方 | inject 依赖 metrics,注册工具,并在工具中调用服务 |
logger-plugin | 事件监听方 | 通过 ctx.on() 监听 tools/result 事件,打印日志 |