
字节笔记本
2026年10月6日 · 约 8 分钟读完
DeepSeek Harness 插件事件系统精读
DeepSeek Harness(简称 dsh)是 DeepSeek AI 开源的 agent harness,采用 MIT 协议,目前处于开发者预览阶段。它的架构原则只有一句话:一切皆插件。整个产品构建在 Cordis 插件框架之上,插件之间互相不知道对方的存在,协作靠两条通道完成:服务解决「我知道该找谁」的直接调用,事件解决「我不知道谁在听」的广播通知。Harness 大量使用事件来实现松耦合的扩展点,插件开发指南里专门有一篇讲事件系统,本文把它的要点整理出来。
监听与触发
事件的用法只有两个动作。监听用 ctx.on,触发用 ctx.emit:
ctx.on('event-name', (payload) => {
// 处理事件
})
ctx.emit('event-name', payload)同一个事件名可以有任意多个监听器,emit 一次,所有监听器都会被调用。
四种派发模式
Cordis 的事件不只是「发出去就算了」,一个事件采用哪种派发模式,决定了监听器返回值的命运。指南列了四种模式,各自对应一种交互契约。

emit 是广播。所有监听器同步执行,返回值直接被忽略,适合「某件事发生了」这类纯通知:
ctx.emit('my-plugin/ready', { id: 'worker-1' })
ctx.on('my-plugin/ready', ({ id }) => {
console.log(`${id} is ready`)
})bail 是短路。监听器按注册顺序运行,第一个返回值不是 null、false 或 undefined 的监听器会成为最终结果,后面的监听器不再执行。它适合检查、表决类场景:一旦有人给出明确结论,就没有必要再问其他人:
const result = ctx.bail('some-check', input)
ctx.on('some-check', (input) => {
if (shouldBlock(input)) return 'blocked'
// 返回 null、false 或 undefined,表示交由下一个监听器处理
})serial 是顺序执行的异步版本。监听器按注册顺序依次执行,异步结果会被等待,第一个不是 null、false 或 undefined 的返回值会终止后续执行,适合初始化这类必须一环接一环的阶段:
await ctx.serial('setup-phase', context)waterfall 是流水线。每个监听器除了接收事件参数,还会拿到一个 next() 函数,调用 next() 就把控制权交给链条的下一环,拿到下游返回值后可以再加工,层层包装形成处理链:
const output = await ctx.waterfall('my-plugin/transform', input, async () => input)
ctx.on('my-plugin/transform', async (_input, next) => {
const downstream = await next()
return downstream.trim()
})这里有一条必须记住的纪律:waterfall 监听器必须调用 next()。不调用就等于把整条流水线短路,这是故意的设计,用来实现拦截和网关逻辑。换句话说,如果你写的监听器只想观察或标注而不是想拦截,漏调 next() 会无声无息地吞掉所有下游行为,而且不会有任何报错。
类型安全的事件
事件名是字符串,拼错了怎么办?Harness 用 TypeScript 的声明合并解决这个问题:在模块声明里给 @deepseek-ai/cordis 的 Events 接口补充成员,把事件名和监听器签名登记进去,之后 ctx.on 和 ctx.emit 都能自动推断类型,事件名拼错、参数个数不对,编译期就会报错:
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>
}
}命名约定与会话事件的边界
Harness 的 Cordis 事件遵循 namespace/action 命名,例如 agent/step、agent/request、agent/request-error、tools/result 和 session/event。每个事件的完整签名与触发模式,可以查仓库子系统文档里生成的 cordis-surface 区块。

容易混淆的是另一类名字:turn/、step/、tool/call、tool/result 和 compaction/* 是持久化的会话事件类型,并不是同名的 Cordis 事件。想观察它们,不能直接监听这些字符串,正确做法是监听 session/event,在回调里检查 event.type。
监听器也是效果
通过 ctx.on() 注册的监听器是一个 effect,生命周期与插件绑定,插件卸载时自动移除,不需要手写解绑逻辑:
export function apply(ctx: Context) {
// 插件卸载时,这个监听器会被自动移除
ctx.on('tools/result', handler)
}示例:工具日志插件
把上面几件事拼起来,就是一个能用的日志插件:监听 tools/result,把每次工具调用的名字、参数和结果前 100 个字符记下来。
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)}`)
})
}开头那行对 dsh-tools 的导入只为引入工具子系统的类型声明,让 tools/result 的回调参数带着完整类型;真正的逻辑只有一个 ctx.on。
小结
四种派发模式对应四种交互契约:emit 负责通知,bail 负责表决,serial 负责串行把关,waterfall 负责链式加工与拦截。写插件前先想清楚自己需要哪一种契约;要观察会话层面的持久化事件,记住统一走 session/event。事件加类型合并,构成了这个框架松耦合而不失控的底层机制。



