Deepseek Harness 调研 - Cordis 框架核心

前几天发布了 Deepseek Harness, 我就琢磨预计这玩意可以对我们现有的 harness 项目有所帮助, 于是调研一番

Meta

文章分类:Tech

标签:Cordis框架插件架构TypeScript事件系统Deepseek

目录

前几天发布了 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 五种事件分发模式

基础规则:

  1. ctx.on(事件名, 处理函数) : 统一用于注册监听器
  2. emit / waterfall / parallel / serial / bail 用于触发事件, 行为差异仅由触发方法决定
  3. 通过 ctx.on 注册的监听器属于当前插件上下文, 插件销毁/重载时自动解绑
  4. 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-RCbail 正式成为第五种公开分发模式, 插件作者可以放心使用. 它与 serial 认领语义相同, 区别仅在同步/异步.

⚠️ 重点注意事项

  • waterfall vs serial 极易混淆:
    • waterfall: next() 委托从外到内, 返回值从内往外套壳, 输出最外层单个值; 输出 = 注册序逆序
    • serial: 按注册序排队, 所有处理器共用原始参数, 第一个认领值即断链, 返回认领值而非数组
    • ctx.on 的第三参 { prepend: true } 可反转注册序 (unshift 进钩子数组), 用于把 waterfall 输出掰正序
  • bail vs serial : 认领语义相同 (第一个非空值断链), 但 bail 同步, serial 异步 ( await 上一个完成才跑下一个). 纯同步判断用 bail , 判断里带异步 IO 用 serial ;
  • parallel 并发执行, 结果丢弃, 仅聚合异常: 任一监听器抛异常, 整体抛 AggregateError ;
  • ctx.on 属于可逆副作用, 不要手动维护解绑逻辑, 框架自动清理;
  • 同一个事件名称可以注册多个监听器, 执行逻辑完全由触发函数决定;
  • emit 不等待异步监听器全部完成; waterfall / parallel / serial 均需 await 等待执行完毕; bail 纯同步, 无需 await .

简要对比速查表

方法参数传递返回类型执行模式
emit不传递丢弃 (void)广播通知
waterfallnext() 委托, 从内往外套壳最外层单个值洋葱委托链
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

serialfor...of + await 保证按注册序一个个跑完; 共享 holder 让”上一个的修改”对下一个可见. 顺序由 serial 保证, 传值靠共享对象, 返回值通道只用来表达”是否认领”.

方案对比

方案传值机制顺序侵入度
waterfall + prepend返回值从内往外套壳注册序的逆序只加注册选项, body 不动
serial + 共享 holder共享对象显式传递注册序正序事件改成累加器形状

关键认知: Cordis 五种分发模式里没有内置 reducer (前向管道)——waterfall 是”套壳”, serial 是”认领”. 想要”L1 的输出喂给 L2 的输入”那种前向累加, 要么借 waterfall 反注册序, 要么自己拿共享状态 + serial 保序. 这两种方案都已在 Deepseek Harness 本地无密钥环境实跑验证 (输出 hello123 ).