DeepSeek Harness · 源码级解析

插件机制深度解析
「一切皆插件」不只是一句口号

从 Cordis 微内核到 Fiber 状态机,一路讲到能跑起来的工具插件

上一次聊 DeepSeek Harness(简称 dsh)时,我们讲到它是 24 万 Star 的开源 Agent 框架,核心设计理念是一句话——Everything is a plugin(一切皆插件)。

但"一切皆插件"到底是怎么落地的?插件怎么被加载、怎么互相依赖、怎么在运行时被热替换?这背后藏着一套名为 Cordis 的插件微内核。今天这篇文章,我们把它的插件机制掰开揉碎,一直讲到底层源码。

为什么是插件

一、Agent 框架为什么要长成「插件」的形状

市面上多数 Agent 框架是"Agent Loop 为中心"的:有一个主干循环(读输入 → 调模型 → 执行工具 → 循环),其他功能都是挂在主干上的附属模块。想改循环行为?改核心代码。

DeepSeek Harness 的思路完全相反。它把自己做成了一个 Agent Runtime 微内核,产品里的每一部分——模型适配器、工具注册表、会话日志、沙箱与审批策略,乃至 agent loop 本身——统统都是插件:

组件在 Harness 里的身份
模型适配器ctx.llm 上注册的一个插件
工具注册表ctx.tools 上注册的一个插件
会话日志ctx.sessions 上注册的一个插件
Agent 循环ctx.agentLoop 上注册的一个插件
Shell / 沙箱 / 存储各自独立的可替换插件

不存在需要打补丁的特权内核:扩展 dsh 的方式,是把插件挂载到其他插件旁边。

这意味着:换一个模型厂商?换插件。加一个工具?写插件。拦截每一次工具调用做审计?监听一个事件。改任何能力都不需要动框架源码,甚至 AI 自己都能写插件改造自己。

Cordis 微内核

二、Cordis:藏在底下的「插件微内核」

让这套架构成立的,是一个以 vendor 方式内嵌进仓库的插件框架 Cordis(@deepseek-ai/cordis,v4.0.4,TypeScript 编写,源码位于 vendor/cordis/src/)。

Cordis 是个"乐高底座"式的微内核:它只负责三件事——把插件插进去、把插件拔出来、管好插件之间的依赖。它本身不承载任何业务功能,业务全部由插件贡献。这套心智模型已经在 Koishi 机器人生态里跑了四年。

Cordis 的全部设计浓缩成五个核心概念:

  1. 插件(Plugin):实现一个 Service 的对象;
  2. 上下文(Context):服务的容器;
  3. inject:声明服务依赖;
  4. 类型化事件:插件之间的通信;
  5. 可逆的副作用(effect):所有注册都能被撤销。

把这五个概念吃透,就等于吃透了整个 Harness 的插件机制。下面逐一拆解。

三、五个核心概念逐一拆解

3.1 插件:三种形态

Cordis 接受三种插件形态,本质上归一化为同一种东西:

typescript
import { Service, type Context } from '@deepseek-ai/cordis'

// 1. 函数形态:最常见,导出一个 apply 函数
export function apply(ctx: Context) {}

// 2. 对象形态:带 apply 方法的对象
export const objectPlugin = {
  name: 'object-plugin',
  apply(ctx: Context) {},
}

// 3. 类形态:Service 子类(需要公开服务时使用)
export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myTutorialService')
  }
}

插件启动时,Cordis 会用一个上下文对象 ctx 调用它,插件通过 ctx 注册自己贡献的一切——工具、事件监听、资源。文件里没有框架启动代码:插件只描述自己的贡献,cordis.yml 负责组合应用。

3.2 Context:一个 Proxy 撑起的服务容器

ctx 是 Cordis 的核心对象,但它不是一个普通对象,而是一个 Proxy:

context.ts
// vendor/cordis/src/context.ts
constructor() {
  this[symbols.isolate] = Object.create(null)
  this[symbols.intercept] = Object.create(null)
  const self = new Proxy<this>(this, ReflectService.handler)
  this.root = self
  return self
}

读 ctx.tools、ctx.llm 这样的属性时,Proxy 会走 ReflectService.handler 的 get 陷阱,沿 fiber 链去查找对应服务的实现——这是"服务容器"的底层机制,稍后在第五节细讲。

上下文还能派生三种作用域能力:ctx.extend(meta) 创建带元数据的子上下文;ctx.isolate(name, label) 给某个服务创建独立的隔离作用域;ctx.intercept(name, config) 给服务注入拦截配置。

3.3 inject:用依赖关系代替启动顺序

插件通过 inject 声明自己需要哪些服务:

typescript
export const name = 'consumer'
export const inject = ['greeter']   // 声明依赖

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))  // apply 里保证 greeter 已就绪
}

这里有两个反直觉、但极其重要的设计:

这也是"配置里换服务"能成立的原因:卸载一个 shell 提供方、挂载另一个,所有 inject: ['shell'] 的插件都会自动重启、改用新实现。如果某项能力缺失时插件仍想运行,就别写进 inject,改用 ctx.get('greeter') 做可选探测。

3.4 事件:5 种分发模式

服务支持直接调用;事件让插件在不知道有谁在监听的情况下发出通知。Harness 用它处理工具结果、模型请求、审批决定等一切交互。事件的写法是把事件名声明进 Events 接口(TypeScript 声明合并),然后 ctx.emit / ctx.on 都获得完整类型:

typescript
declare module '@deepseek-ai/cordis' {
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

关键在于,每个事件有且只有一种分发模式,这是它的公开约定:

模式调用语义
emitctx.emit(...)同步广播,不等待、不收集返回值
parallelawait ctx.parallel(...)所有监听器并发运行,一起等待
serialawait ctx.serial(...)按序等待,第一个非空返回值胜出并短路
bailctx.bail(...)serial 的同步版本
waterfallctx.waterfall(...)环绕中间件,可转换或短路

其中 waterfall 是实现"拦截"的杀手锏。每个监听器收到参数和一个 next() continuation:可以包一层 next() 的返回值再返回,也可以不调用 next() 直接返回从而"否决"整条链。Harness 用 waterfall 处理协作插件能包装或回答的决策——比如 agent/request 允许插件替换模型调用配置,approval/request 允许策略代替用户作答。

3.5 effect:可逆的副作用

这是 Cordis 最优雅的一处设计。插件可能因为修改配置、热重载、显式释放、或依赖服务消失而被卸载。Cordis 用 effect 保证:你做的每一个注册,都能在卸载时被干净地撤销。

对于 Cordis 尚未管理的资源(定时器、连接、watcher),包进 ctx.effect() 并返回一个 disposer:

typescript
ctx.effect(() => {
  const timer = setInterval(() => console.log('tick'), 200)
  return () => {            // disposer:卸载时运行
    clearInterval(timer)
    console.log('cleaned up')
  }
})

而 ctx.on()、ctx.plugin()、ctx.tools.register() 这类内置 API 本身已经是 effect,会自动在卸载时撤销,你永远不需要手动 removeListener。disposer 按注册的逆序启动,但多个异步 disposer 会并发运行。

💡 至此五个概念讲完。下面进入真正的"实现"部分——钻到 vendor/cordis/src/ 源码里,看这套机制是怎么跑起来的。

钻进源码

四、Fiber 状态机是怎么转起来的

每个已加载的插件实例,都拥有一个 Fiber(纤维),它是插件的运行时句柄。Fiber 在六个状态之间迁移:

状态机
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED

状态枚举定义在 vendor/cordis/src/fiber.ts:

fiber.ts
export const enum FiberState {
  PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING,
}

最精妙的是 PENDING 到 ACTIVE 的唤醒机制。一个声明了 inject 的插件,会一直停在 PENDING,直到所有依赖就绪。Cordis 的做法是:把插件依赖的每一个服务的"提供方 fiber 的 uid"拼接成一个 epoch 字符串,只要 epoch 不完整(缺一个依赖)就记为 INACTIVE:

fiber.ts
_refresh() {
  let epoch: string | boolean = false
  epoch = ''
  for (const name of Object.keys(this.inject)) {
    const impl = this._store[name]
    if (!impl) { epoch = INACTIVE; break }
    epoch += ':' + impl.fiber.uid   // 依赖的提供方 uid 拼进 epoch
  }
  this._setEpoch(epoch)
}

当 epoch 从 INACTIVE 变成完整字符串时,状态机进入 _reload() 执行插件;当依赖消失、epoch 退回 INACTIVE 时,进入 _unload() 卸载插件。依赖就绪和依赖丢失,走的是同一个 _setEpoch 通道——这就是"动态跟踪依赖"的底层实现。

卸载时,_unload() 先 Promise.all 跑完所有 disposer(DisposableList.clear() 返回逆序数组,实现 LIFO 释放),而子插件的 dispose 本身也是作为父 fiber 的一个 effect 注册的——所以卸载父插件会递归卸载它挂载的所有子插件。

五、Context 的 Proxy 服务解析与隔离作用域

第三节说 ctx 是 Proxy,现在看它到底怎么工作。真正的 get 陷阱在 vendor/cordis/src/reflect.ts,核心逻辑是沿 fiber 链向上查找服务实现:

reflect.ts
// handler.get 中的服务解析(简化)
const key = target[symbols.isolate][prop]     // 用隔离 label 定位
let fiber = (ctx[symbols.shadow] ?? ctx).fiber
while (true) {
  const impl = fiber.store?.[prop]            // 该层 fiber 是否提供了这个服务
  if (impl) return getTraceable(ctx, impl.value)
  if (prop in fiber.inject) { throw error }   // 声明了依赖却没找到 → 报错
  if (!fiber.runtime) throw error
  if (fiber.parent[symbols.isolate][prop] !== key) throw error  // 隔离边界
  fiber = fiber.parent.fiber                   // 继续向上找
}

注意最后两行:隔离作用域(isolate)就是在这里生效的。ctx.isolate(name, label) 会在子上下文的 symbols.isolate 原型链上,把 name 映射到新的 symbol label;所有服务读写都以这个 label 为 key 去 store 查找。不同 label 之间天然隔离——所以"两个组各自看到不同的 shell 提供方"才成立。

服务注册则收拢到 reflect.provide:写入 store[key]、在 fiber.store[name] 上登记归属,然后 notify 唤醒依赖方。而 Service 基类的构造器,最终就一句话:

service.ts
constructor(protected ctx: Context, name: string) {
  ...
  self.ctx.reflect.provide(name, self, this[symbols.check])
  return self
}

也就是说,super(ctx, 'greeter') 的本质,就是把实例注册成 ctx.greeter 这项服务。

六、waterfall 的 next() 链是怎么串起来的

事件系统里,所有监听器都存在 _hooks: Record<name, Hook[]>,五种模式只是对"过滤后的 callback 数组"做不同的执行策略。其中 waterfall 的 next() 链构建只有几行,却最见功力:

events.ts
waterfall(...args: any[]) {
  const cbs = this.dispatch('waterfall', args)
  const inner = args.pop()          // 最内层默认逻辑
  const next = () => {
    const cb = cbs.shift() ?? inner // 逐个取出监听器,取完用默认逻辑兜底
    return cb(...args)
  }
  args.push(next)
  return next()                     // 从最外层开始执行
}

每一层监听器调用 next() 就往下走一层,返回值再逐层向外返回,因此外层可以"包装"内层结果;任何一层不调用 next() 直接返回,链就短路了。这也引出了架构文档里的一条铁律:只负责观察或标注的 waterfall 监听器,必须调用 next(),否则会悄悄吞掉所有下游默认行为。

动手实战

七、手写一个能被模型调用的工具插件

理论讲完,动手写一个真正能用的插件:向 Harness 的 tools 服务注册一个模型可调用的 greet 工具。

greet-tool.ts
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallId } from '@deepseek-ai/dsh-llm'

export const name = 'greet-tool'
export const inject = ['tools']      // 等工具注册表就绪

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet the named person.',
    parameters: {
      name: { type: 'string', required: true, description: 'Who to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

再写一个独立的观察插件,通过 tools/result 事件监听每一次工具调用——两个插件互不知道对方存在,靠注册表和事件连接:

tool-logger.ts
export const name = 'tool-logger'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    const text = result.content
      .map(block => (block.type === 'text' ? block.text : ''))
      .join('')
    console.log(`[tool-logger] ${exec.name} -> ${text}`)
  })
}

组合起来,cordis.yml 长这样:

yaml
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'

运行后输出 [tool-logger] greet -> Hello, Cordis!。注意这里 ctx.tools.register(...) 会把注册 disposer 附着到插件上——卸载插件时,工具自动注销。而 defineTool 负责把参数规约转成 JSON Schema、推导参数类型、并在执行前校验模型提供的参数。

八、组合、HMR 与 Profile:插件如何组装成完整的 dsh

单个插件容易理解,完整的 dsh 是怎么来的?答案是:一棵插件树,由启动时按序叠加的各层组合而成。

组合还天然支持 HMR(热模块替换):因为"卸载会释放 effect、加载遵循依赖关系",HMR 插件只需要先卸载、再加载,就能替换正在运行的插件。Cordis 配置项还能带 id(稳定标识)、disabled: true(卸载但不删除条目)、嵌套 group 和 isolate。

⚠️ 一个常见的坑:如果插件的 inject 指定了没人提供的服务,它会一直 PENDING、静默不输出——这不是错误,因为提供方可能稍后才挂载。排查时遍历 ctx.registry 里每个 fiber 的 state === FiberState.PENDING 就能定位。

九、能力 seam:替换一个插件,就改变整个产品

Harness 在插件之上还抽象出一层概念——seam(能力接缝)。一个 seam 是一份可替换能力,包含三种角色:

seam 的价值在于"替换一个提供方就能改变整个产品"。文件系统与进程提供方共享同一个执行世界,所以把它们指向远程沙箱,Bash、PTY、LSP 就一起搬了过去;subagent 提供方在同一个接口之后,可以是新建一个子 agent,也可以是把一个轮次委派给另一个产品。扩展 Harness 的正确姿势,就是找到对应的 seam,挂上你的插件。

十、总结:三个认知值得带走

回顾全文,这套插件机制最值得记住的三点:

  1. 没有特权内核。 连 agent loop 都是插件,"扩展"和"替换"是一回事——挂载插件即可,无需打补丁;
  2. 依赖驱动一切。 加载顺序、动态重启、热替换,全部由 inject 表达的依赖关系自动推导,而不是手动编排;
  3. 可逆是刻进骨子里的。 每个注册都能撤销,卸载是加载的镜像——这是 HMR 和"配置层随意替换"能够成立的根基。

理解到这一层,你再看 dsh,就不再是一个黑盒 Agent 工具,而是一张由插件织成的、处处可插拔的能力之网。这才是"一切皆插件"的真正分量。

省钱这件事

十一、跑 Harness 要烧 Token?向量云帮你把成本打下来

最后说个现实问题:Harness 开源免费,但骨架免费不等于零成本——真正干活要靠模型。Agent 任务来回迭代几十轮,每次读文件、写代码、跑脚本、调工具都要付费,Token 消耗比普通聊天高出好几个量级。

怎么把成本打下来?通过向量云获取字节跳动火山方舟官方 API 和 Key,给 Harness 接入模型,天然享受三样东西:

一把 Key 能调用多款模型:复杂推理用 DeepSeek V4 Pro(deepseek-v4-pro-ga-260813),日常快速任务用 DeepSeek V4 Flash(deepseek-v4-flash-ga-260731),正好匹配 Harness 不同模式下对模型强度的要求——开源框架搭配低成本官方 API,才是 Agent 玩家的省钱正道。

✅ 获取 API Key:打开 https://ark.tokenrize.cn/ 注册充值,创建 Key 后填入 Harness 的模型配置即可。

关于向量云
向量云(南京)科技有限公司

面向大模型应用落地的技术服务型企业,依托字节跳动火山方舟能力,为合作方提供低折扣模型 Token、可独立部署的运营支撑平台及全链路运营支持。

低折扣 Token 官方 API Key 运营支撑平台 充值开票服务 即用即扣 · 不设账期

写在最后

DeepSeek 的打法一直很清晰:把基础设施开源,把生态做大,把价格打下来。 Harness 用"一切皆插件"把 Agent 能力变成可插拔的乐高,再配合更便宜的模型,最终让这套框架成为千万人接入 AI 的第一入口。

对开发者来说,理解它的插件机制,不只是为了"会用",更是为了"能改"——当你第一次写下一个 apply(ctx),让 Harness 在合适的时机自动加载你的插件时,你会真正明白这句话:这个框架里,没有什么是你替换不了的。

至于 Token 成本?交给向量云。你有没有动手写过 dsh 插件?欢迎在评论区聊聊你的踩坑与心得。

常见问题(FAQ)

火山方舟官方 API 与官方 Key 直连 · 向量云 · 按量计费享 Token 折扣

Q1. 为什么 Agent 框架要长成「插件」的形状?

A. 因为模型、工具与执行循环都处在快速变化中,只有把一切做成可替换的插件,框架才能跟上演进节奏。

Q2. Cordis 微内核是什么?

A. 藏在底下的插件微内核:插件有三种形态,Context 是一个用 Proxy 撑起的服务容器。

Q3. inject 依赖注入解决什么问题?

A. 用依赖关系代替启动顺序:插件只声明自己需要什么,加载顺序由内核负责编排。

Q4. effect 是什么?

A. 可逆的副作用:插件注册的能力可以被干净卸载,避免热替换时残留状态。