Deepseek Harness 调研 - Cordis 框架核心
前几天发布了 Deepseek Harness, 我就琢磨预计这玩意可以对我们现有的 harness 项目有所帮助, 于是调研一番
目录
前几天发布了 Deepseek Harness, 我就琢磨预计这玩意可以对我们现有的 harness 项目有所帮助.
因此闲下来的时候研究了一下, 它的官网就放了对应的论文, 尝试用 AI 阅读的时候发现其的底层是一个叫做 Cordis 的插件框架, 这个框架竟然和多年前搭建 QQ 机器人 用的框架 koishi 的底层是同一个 (虽然我后来因为它管理太麻烦抛弃了 koishi 换用了 astroBot) . 但依然值得研究一下.
本文基于 Deepseek Harness 0.1.0 调研. 其中需要注意的是, 我印象中前几天事件分发模式只有 4 种, 但是今天看的时候变为 5 种了. 些许 0.1.2 更新了一些? 也有可能是我记错了, 总之我按照当前最新的版本来调研.
Cordis 五大核心概念
五大核心:
- 插件 Plugin
- 上下文 Context
- inject 依赖 Inject Dependencies
- 类型化事件 Typed Events
- 可逆副作用 Reversible Side Effects
前置说明 (框架内置, 无需手动实现)
以下为 Cordis 框架自带基础类型, 所有示例基于此:
type Cleanup = () => void;
// 全局上下文容器
interface Context {
// 五种事件分发
emit(name: string, ...args: any[]): void;
waterfall(name: string, ...args: any[], next?: () => any): unknown;
parallel(name: string, ...args: any[]): Promise<void>;
serial(name: string, ...args: any[]): Promise<unknown>;
bail(name: string, ...args: any[]): unknown;
// 可逆副作用注册
on(name: string, handler: Function): void;
effect(fn: () => Cleanup): void;
// 服务动态挂载
[key: string]: any;
}
// 服务基类
declare class Service {
static inject?: string[];
async start(ctx: Context): Promise<void>;
async stop(): Promise<void>;
}
1. 插件 Plugin (能力载体)
核心定义: 插件是承载 Service 服务的最小单元, 生命周期由 Cordis 框架托管, 支持两种写法.
核心特点
- 两种形态: 函数式插件, Service 类插件 (语法糖)
- 所有业务能力 (大模型, 工具, 会话) 都由插件提供
- 无需手动实例化, 启停, 框架自动调度
极简代码示例
// 写法 1: 函数式插件 (推荐, 简洁)
export const LLMPlugin = {
inject: [],
apply(ctx: Context) {
// 挂载 AI 服务能力
ctx.llm = { chat: async (text: string) => `AI 回复: ${text}` };
}
};
// 写法 2: Service 子类插件 (面向对象写法)
export class ToolPlugin extends Service {
static inject = [];
async start(ctx: Context) {
ctx.tools = { list: () => ["计算器工具"] };
}
}
2. 上下文 Context (服务容器)
核心定义: 统一的服务容器, 所有服务挂载在 ctx.xxx 键上, 实现模块解耦.
核心特点
- 统一入口: 所有服务通过
ctx.{key}访问 - 完全解耦: 不直接导入其他插件代码, 只依赖键名
- 可无缝替换: 更换服务实现, 上层业务代码无需改动
极简代码示例
// 不需要 import 导入 LLM 实现, 直接从上下文获取
export const AgentPlugin = {
inject: ["llm"],
apply(ctx: Context) {
const res = await ctx.llm.chat("你好");
console.log(res);
}
};
3. Inject 依赖声明 (自动启停调度)
核心定义: 插件声明依赖的服务, 框架自动拓扑排序, 等待依赖就绪后再启动当前插件.
核心特点
- 无需手动编排插件启动顺序
- 依赖未就绪时, 插件会等待
- 解决多模块加载顺序混乱问题
极简代码示例
export const AgentPlugin = {
// 声明依赖 llm, tools
inject: ["llm", "tools"],
async apply(ctx: Context) {
// 框架保证: 运行到这里, ctx.llm, ctx.tools 已经就绪
console.log("工具列表: ", ctx.tools.list());
}
};
4. 类型化事件 (模块通信机制)
核心定义: 插件之间通信方式, 使用 ctx.on 注册监听, 提供五种分发策略.
核心特点
ctx.on统一注册监听器; 行为差异由触发函数决定- 支持 TS 声明合并, 实现事件类型安全
- 五种模式适配不同业务场景
极简代码示例
export const EventPlugin = {
inject: [],
async apply(ctx: Context) {
// 注册监听
ctx.on("prompt.modify", (text: string) => {
return `『系统前缀』${text}` ;
});
// 1. emit: 广播通知, 丢弃返回值
ctx.emit("prompt.modify", "原始提问");
// 2. waterfall: 洋葱委托链, next() 往深委托, 返回最外层单个结果
const finalText = await ctx.waterfall("prompt.modify", "原始提问", () => "默认值");
// 3. parallel: 并发执行, 结果丢弃, 只等全部跑完
await ctx.parallel("collect.tool", {});
// 4. serial: 顺序执行, 第一个认领值即断链
const firstWinner = await ctx.serial("post.process", "回答内容");
// 5. bail: 同步短路认领, 第一个非空值即返回
const decision = ctx.bail("access.check", "输入");
}
};
5. 可逆副作用 (资源自动清理)
核心定义: 通过 ctx.on / ctx.effect 注册资源, 插件卸载/热重载时自动执行清理逻辑.
核心特点
- 避免手动解绑造成内存泄漏
- 所有副作用归属当前插件上下文
- 是框架支持安全热重载的基础
极简代码示例
export const LogPlugin = {
inject: [],
apply(ctx: Context) {
// 方式 1: ctx.on 自动托管监听, 卸载自动移除
ctx.on("user.message", (msg) => console.log("收到消息: ", msg));
// 方式 2: ctx.effect 自定义资源, 返回清理函数
ctx.effect(() => {
console.log("日志连接已打开");
// 返回清理逻辑
return () => {
console.log("日志连接关闭");
};
});
}
};
Cordis 五种事件分发模式
基础规则:
ctx.on(事件名, 处理函数): 统一用于注册监听器emit / waterfall / parallel / serial / bail用于触发事件, 行为差异仅由触发方法决定- 通过
ctx.on注册的监听器属于当前插件上下文, 插件销毁/重载时自动解绑serial的停止判据: 监听器返回非null/false/undefined即”认领”, 断链返回该值
1. ctx.emit() 广播通知
- 所有监听器接收原始入参
- 监听器返回值全部丢弃, 不会收集结果
- 适合: 日志埋点, 单纯事件通知, 不需要修改与获取数据
// 注册监听
ctx.on("user.message", (text) => {
console.log("收到消息: ", text);
return "无用返回值"; // 返回值直接丢弃
});
// 触发事件
ctx.emit("user.message", "你好");
2. ctx.waterfall() 洋葱委托链
- 监听器签名是
(input, next), **next()往下游委托**; 不调用next()= 否决整条链 (短路) - 委托从外到内, 返回值从内往外套壳, 最终返回最外层监听器的单个结果
- 关键: 输出顺序 = 注册序的逆序 (最后注册的监听器离内置行为最近)
- 适合: 请求中间件, 逐层加工提示词, 链式数据修改
// 注册监听
ctx.on("prompt.transform", async (text, next) => {
const downstream = await next();
return `『系统前缀』${downstream}` ;
});
ctx.on("prompt.transform", async (text, next) => {
const downstream = await next();
return `${downstream}\n 简洁回答` ;
});
// 触发事件 (最后一个参数是内置行为)
// 从内往外: 先 "简洁回答", 再套前缀
const result = await ctx.waterfall("prompt.transform", "原始提问", () => "原始提问");
console.log(result); // 『系统前缀』原始提问\n 简洁回答
3. ctx.parallel() 并行执行
- 所有监听器使用同一个原始参数, 数据互不传递
- 所有监听器并发同时运行,
await等全部完成 - 返回
void, 结果全部丢弃; 任一监听器抛异常 → 整体抛AggregateError - 适合: 并行预热/刷新缓存这类”只要跑完, 不要结果”的场景
const sleep = (ms) => new Promise(res => setTimeout(res, ms));
// 注册监听
ctx.on("collect.tools", async () => {
await sleep(100);
console.log("工具 A 完成");
});
ctx.on("collect.tools", async () => {
await sleep(200);
console.log("工具 B 完成");
});
// 触发事件: 并发跑, 返回值拿不到, 只保证都跑完
await ctx.parallel("collect.tools", {});
4. ctx.serial() 串行认领执行
- 所有监听器使用同一个原始参数, 数据互不传递
- 严格按注册序执行,
await上一个完成才运行下一个 - 第一个返回非空值 (非
null/false/undefined) 的监听器即”认领”, 直接断链, 返回该值; 全部不认领 →undefined - 返回认领值, 不是数组 (监听器返回的
undefined会被视为”不认领, 继续”) - 适合: 路由/兜底链, “谁来处理这个请求” 这类只有一个赢家的场景
// 注册监听
ctx.on("file.open", async (path) => {
if (!path.startsWith("/tmp")) return null; // 不认领, 继续下一个
return `本地模式打开 ${path}` ;
});
ctx.on("file.open", async (path) => {
return `网络模式打开 ${path}` ; // 兜底, 必然认领
});
// 触发事件: 第一个认领者胜出, 后面不跑
const winner = await ctx.serial("file.open", "/data/a.txt");
console.log(winner); // 网络模式打开 /data/a.txt
5. ctx.bail() 同步短路认领
- 所有监听器使用同一个原始参数, 数据互不传递
- 同步执行, 严格按注册序跑完; 认领语义与
serial相同, 但**无需await** - 第一个返回非空值 (非
null/false/undefined) 的监听器即”认领”, 直接断链, 返回该值; 全部不认领 →undefined - 返回认领值, 不是数组 (监听器返回的
undefined会被视为”不认领, 继续”) - 适合: 同步权限校验/策略决策, “谁来拍板” 这类要立即拿结果的场景
// 注册监听
ctx.on("access.check", (user) => {
if (user.banned) return "封禁用户, 拒绝访问"; // 认领, 断链
return null; // 不认领, 继续下一个
});
ctx.on("access.check", (user) => {
return "匿名用户, 默认放行"; // 兜底, 必然认领
});
// 触发事件: 同步跑, 第一个认领者胜出
const decision = ctx.bail("access.check", { banned: true });
console.log(decision); // 封禁用户, 拒绝访问
**补充:
bail已转正, 进入公开契约. ** 早期调研时bail只存在于运行时DispatchMode内部 (监听器注册本身就走 bail 分发), 官方 primer 不对外承诺; 0.2.0-RC 起bail正式成为第五种公开分发模式, 插件作者可以放心使用. 它与serial认领语义相同, 区别仅在同步/异步.
⚠️ 重点注意事项
waterfallvsserial极易混淆:- waterfall:
next()委托从外到内, 返回值从内往外套壳, 输出最外层单个值; 输出 = 注册序逆序 - serial: 按注册序排队, 所有处理器共用原始参数, 第一个认领值即断链, 返回认领值而非数组
ctx.on的第三参{ prepend: true }可反转注册序 (unshift 进钩子数组), 用于把 waterfall 输出掰正序
- waterfall:
bailvsserial: 认领语义相同 (第一个非空值断链), 但bail同步,serial异步 (await上一个完成才跑下一个). 纯同步判断用bail, 判断里带异步 IO 用serial;parallel并发执行, 结果丢弃, 仅聚合异常: 任一监听器抛异常, 整体抛AggregateError;ctx.on属于可逆副作用, 不要手动维护解绑逻辑, 框架自动清理;- 同一个事件名称可以注册多个监听器, 执行逻辑完全由触发函数决定;
emit不等待异步监听器全部完成;waterfall/parallel/serial均需await等待执行完毕;bail纯同步, 无需await.
简要对比速查表
| 方法 | 参数传递 | 返回类型 | 执行模式 |
|---|---|---|---|
| emit | 不传递 | 丢弃 (void) | 广播通知 |
| waterfall | next() 委托, 从内往外套壳 | 最外层单个值 | 洋葱委托链 |
| parallel | 共用原始参数 | void (结果丢弃) | 并发同时执行 |
| serial | 共用原始参数 | 第一个认领值 | 顺序认领执行 (异步) |
| bail | 共用原始参数 | 第一个非空值 | 同步短路认领 |
实战: 把 waterfall 的 hello321 改成正序 hello123
三个监听器按注册序各 +1 / +2 / +3, 内置行为返回 “hello”:
ctx.on("demo/transform", async (input, next) => {
const downstream = await next();
return downstream + 1;
});
ctx.on("demo/transform", async (input, next) => {
const downstream = await next();
return downstream + 2;
});
ctx.on("demo/transform", async (input, next) => {
const downstream = await next();
return downstream + 3;
});
ctx.waterfall("demo/transform", "hello", () => "hello") 输出 ** hello321 :
base → +3 → +2 → +1. 因为委托从外到内, 套壳从内到外**, 最后注册的监听器离内置行为最近, 所以先 +3, 最后才轮到最外层的 +1.
方案 A: 反注册序 ( prepend ), body 一个字不动
ctx.on 第三参 { prepend: true } 让监听器 unshift 进钩子数组, 注册序反成 +3, +2, +1 → 套壳变 +1, +2, +3 → hello123 .
ctx.on("demo/transform", async (input, next) => {
const downstream = await next();
return downstream + 1;
}, { prepend: true }); // 三个都加 { prepend: true }
方案 B: 换 serial + 共享 holder, 不用 waterfall
waterfall 的委托天然从内往外; serial 的返回值又被”第一个非空值截断”占住. 想让值前向流动, 就自己拿一个共享对象当累加器, 每个监听器改它并返回 undefined 表示”不认领, 继续”:
const holder = { value: "hello" };
ctx.on("demo/accumulate", (holder) => { holder.value += "1" });
ctx.on("demo/accumulate", (holder) => { holder.value += "2" });
ctx.on("demo/accumulate", (holder) => { holder.value += "3" });
await ctx.serial("demo/accumulate", holder);
console.log(holder.value); // hello123
serial 的 for...of + await 保证按注册序一个个跑完; 共享 holder 让”上一个的修改”对下一个可见. 顺序由 serial 保证, 传值靠共享对象, 返回值通道只用来表达”是否认领”.
方案对比
| 方案 | 传值机制 | 顺序 | 侵入度 |
|---|---|---|---|
| waterfall + prepend | 返回值从内往外套壳 | 注册序的逆序 | 只加注册选项, body 不动 |
| serial + 共享 holder | 共享对象显式传递 | 注册序正序 | 事件改成累加器形状 |
关键认知: Cordis 五种分发模式里没有内置 reducer (前向管道)——waterfall 是”套壳”, serial 是”认领”. 想要”L1 的输出喂给 L2 的输入”那种前向累加, 要么借 waterfall 反注册序, 要么自己拿共享状态 + serial 保序. 这两种方案都已在 Deepseek Harness 本地无密钥环境实跑验证 (输出
hello123).