DeepSeek Harness 从 0 开始:01 Cordis 核心概念入门

4 阅读4分钟

DeepSeek Harness 从 0 开始:01 Cordis 核心概念入门

本系列从 0 开始,基于 Cordis 框架一步步实现一个简略版本的 DeepSeek Harness(loop、session、tool、system prompt 等)。这是第一篇:在写任何 Agent 逻辑之前,先搞清楚 Cordis 的两个核心概念。

为什么是 Cordis?

我们要实现的 Agent 框架天然是模块化的:Session 管理、Tools 系统、System Prompt 组装、LLM 调用、Agent Loop……这些模块之间需要互相依赖、共享状态、响应事件,还要支持插件式扩展。

Cordis 就是为这种场景设计的框架(它也是 Koishi 的底层框架)。它的核心抽象只有两个:

概念一句话理解
Context一个容器,存放服务,管理插件生命周期
Plugin向容器里装东西的功能单元,有三种形态

本文通过一个可以实际运行的示例,把这两个概念讲透。

准备工作

# 初始化 package.json
pnpm init

# 安装 Cordis 框架核心包(运行时依赖)
pnpm add @cordisjs/core

# 安装开发依赖:tsx 运行 TS、typescript 编译器、node 类型定义
pnpm add -D tsx typescript @types/node

package.json 中加一个启动脚本:

{
  "type": "module",                  // ES Module 语法(支持 import / export)
  "scripts": {
    "dev": "tsx src/main.ts"         // 用 tsx 直接运行 TypeScript 入口
  },
  "dependencies": {
    "@cordisjs/core": "^4.0.0-beta.5" // Cordis 框架核心包
  }
}

Part 1:Hello World —— 认识 Context

先看一下项目目录结构:

blog-01-core-concepts/
├── package.json          # 项目配置:依赖、启动脚本
├── package-lock.json     # 依赖锁定文件(pnpm 自动生成)
├── tsconfig.json         # TypeScript 编译配置
├── README.md             # 项目说明
├── blog.md               # 本文档
└── src/
    └── main.ts           # 代码入口,pnpm dev 运行它

我们的所有代码都写在 src/main.ts 里,pnpm dev 会用 tsx 直接运行它。

先看最简单的 Cordis 程序:

import { Context } from '@cordisjs/core'

// 插件就是一个函数,接收一个 Context 参数
function helloPlugin(ctx: Context) {
  console.log('🎉 Hello from plugin!')

  // 向 Context 注册一个服务(使用 ctx.provide())
  // 这样其他插件就可以通过 ctx.hello 访问它
  ctx.provide('hello', {
    // 对外提供的方法:打招呼
    say(name: string) {
      return `Hello, ${name}!`
    }
  })
}

// 1️⃣ 创建 Context
const ctx = new Context()

// 2️⃣ 注册插件(注意:ctx.plugin() 是异步的,需要 await)
await ctx.plugin(helloPlugin)

// 3️⃣ 使用服务
console.log(ctx.hello.say('World'))  // Hello, World!

运行输出:

1️⃣  创建 Context...
2️⃣  注册插件...
🎉 Hello from plugin!
3️⃣  使用服务...
Hello, World!

关键点

Context 是一个容器。 它里面可以存放各种服务;插件可以向里面添加服务;其他插件可以从里面获取服务。这就是 Cordis 世界的基本交互模式:

插件 A ──ctx.provide('hello', ...)──▶ Context ◀──ctx.hello.say(...)── 插件 B

ctx.provide(name, value) 把一个叫 name 的服务挂到 Context 上,之后任何拿到这个 Context 的代码都能通过 ctx[name] 访问它。服务可以是普通对象,也可以是我们后面会讲到的类实例。

ctx.plugin() 是异步的。 注册插件时别忘了 await,否则插件可能还没激活你就开始用它了。

Part 2:插件的三种形态

插件的本质是"接收 Context 并做点什么"的东西,但 Cordis 允许三种写法,各有适用场景。

形态一:函数插件

// 形态1:函数插件(Part 1 的方式)
function loggerPlugin(ctx: Context) {
  console.log('✅ 函数插件 logger 已激活')

  // 插件的内部状态(私有数据,外部无法直接访问)
  const logs: string[] = []

  // 注册服务:'logger' 是服务名(自定义),会挂载到 ctx.logger
  // 函数插件没有 name 属性,所以服务名完全由 provide() 的第一个参数决定
  ctx.provide('logger', {
    // 对外提供的方法:记录日志
    log(message: string) {
      const entry = `[${new Date().toLocaleTimeString()}] ${message}`
      logs.push(entry)
      console.log(entry)
    },
    // 对外提供的方法:获取所有日志
    getLogs() {
      return [...logs]
    }
  })
}

最简单直接,适合逻辑不复杂的插件。注意 logs 数组是闭包里的私有状态——外部只能通过 ctx.logger 暴露的方法间接访问,这就是天然的封装。

形态二:对象插件

// 形态2:对象插件(可以携带 name 等元信息)
const counterPlugin = {
  // 插件的名字,用于日志、调试、依赖声明等元信息
  name: 'counter',
  // 核心逻辑写在 apply 方法里
  apply(ctx: Context) {
    console.log('✅ 对象插件 counter 已激活')

    // 插件的内部状态
    let count = 0

    // 注册服务:'counter' 是服务名(自定义),会挂载到 ctx.counter
    ctx.provide('counter', {
      // 对外提供的方法:递增计数器并返回新值
      increment() {
        return ++count
      },
      // 对外提供的方法:获取当前计数
      getCount() {
        return count
      }
    })
  }
}

对象插件比函数插件多了 name 等元信息。核心逻辑写在 apply 方法里。适合需要在插件注册表里留名字(便于日志、调试、依赖声明)的场景。

形态三:类插件

// 形态3:类插件(适合有内部状态的插件)
class TokenBucket {
  private tokens: number

  // 构造函数接收 Context 和配置,配置来自 ctx.plugin() 的第二个参数
  constructor(ctx: Context, config: { capacity: number }) {
    console.log(`✅ 类插件 tokenBucket 已激活(容量: ${config.capacity})`)
    this.tokens = config.capacity

    // 注册服务:'tokenBucket' 是服务名(自定义),会挂载到 ctx.tokenBucket
    ctx.provide('tokenBucket', {
      // 对外提供的方法:尝试取一个令牌,成功返回 true,令牌不足返回 false
      take: () => this.take(),
      // 对外提供的方法:返回剩余令牌数
      remaining: () => this.tokens
    })
  }

  private take(): boolean {
    if (this.tokens <= 0) return false
    this.tokens--
    return true
  }
}

类插件有两个独门优势:

  1. 构造函数可以接收配置ctx.plugin(TokenBucket, { capacity: 3 }) 的第二个参数会传给构造函数;
  2. 适合有复杂内部状态的插件:令牌桶这种带状态的对象,用类来表达最自然。

组装运行

// 创建 Context
const ctx = new Context()

// 注册三种形态的插件
await ctx.plugin(loggerPlugin)
await ctx.plugin(counterPlugin)
// 第二个参数是插件的配置,会传给构造函数
await ctx.plugin(TokenBucket, { capacity: 3 })

// 使用服务:调用 logger 的 log 方法(服务名和方法都是自定义的)
ctx.logger.log('这是一条日志')

// 调用 counter 的 increment 方法(每次调用计数器 +1)
console.log('Counter:', ctx.counter.increment())  // 1
console.log('Counter:', ctx.counter.increment())  // 2

// 调用 tokenBucket 的 take 和 remaining 方法
console.log('取令牌:', ctx.tokenBucket.take())     // true
console.log('取令牌:', ctx.tokenBucket.take())     // true
console.log('取令牌:', ctx.tokenBucket.take())     // true
console.log('取令牌:', ctx.tokenBucket.take())     // false,已取完
console.log('剩余令牌:', ctx.tokenBucket.remaining())  // 0

运行输出:

📦 注册三种形态的插件...
✅ 函数插件 logger 已激活
✅ 对象插件 counter 已激活
✅ 类插件 tokenBucket 已激活(容量: 3)

🎯 使用服务...
[7:30:09 PM] 这是一条日志
Counter: 1
Counter: 2
取令牌: true
取令牌: true
取令牌: true
取令牌: false
剩余令牌: 0

三种形态怎么选?

形态元信息配置复杂状态适用场景
函数插件一般(闭包)简单逻辑、快速原型
对象插件一般(闭包)需要名字/元信息的插件
类插件擅长有状态、需配置的插件

三种形态殊途同归:都是通过 ctx.provide() 把服务挂到 Context 上。选哪种只是代码组织问题。

小结

这一篇我们建立了 Cordis 的心智模型:

  1. Context 是容器ctx.provide() 存服务,ctx.xxx 取服务,插件之间通过它协作;
  2. 插件有三种形态:函数、对象、类,按需选择,最终都是向 Context 提供服务。

这两块拼图看起来简单,但它们组合起来就是整个 Agent 框架的地基——后续我们要实现的 Session、Tools、Agent Loop,每一个都会是 Context 里的服务,每一个都会用到插件形态。