从 Cordis 微内核到 Fiber 状态机,一路讲到能跑起来的工具插件
上一次聊 DeepSeek Harness(简称 dsh)时,我们讲到它是 24 万 Star 的开源 Agent 框架,核心设计理念是一句话——Everything is a plugin(一切皆插件)。
但"一切皆插件"到底是怎么落地的?插件怎么被加载、怎么互相依赖、怎么在运行时被热替换?这背后藏着一套名为 Cordis 的插件微内核。今天这篇文章,我们把它的插件机制掰开揉碎,一直讲到底层源码。
市面上多数 Agent 框架是"Agent Loop 为中心"的:有一个主干循环(读输入 → 调模型 → 执行工具 → 循环),其他功能都是挂在主干上的附属模块。想改循环行为?改核心代码。
DeepSeek Harness 的思路完全相反。它把自己做成了一个 Agent Runtime 微内核,产品里的每一部分——模型适配器、工具注册表、会话日志、沙箱与审批策略,乃至 agent loop 本身——统统都是插件:
| 组件 | 在 Harness 里的身份 |
|---|---|
| 模型适配器 | ctx.llm 上注册的一个插件 |
| 工具注册表 | ctx.tools 上注册的一个插件 |
| 会话日志 | ctx.sessions 上注册的一个插件 |
| Agent 循环 | ctx.agentLoop 上注册的一个插件 |
| Shell / 沙箱 / 存储 | 各自独立的可替换插件 |
不存在需要打补丁的特权内核:扩展 dsh 的方式,是把插件挂载到其他插件旁边。
这意味着:换一个模型厂商?换插件。加一个工具?写插件。拦截每一次工具调用做审计?监听一个事件。改任何能力都不需要动框架源码,甚至 AI 自己都能写插件改造自己。
让这套架构成立的,是一个以 vendor 方式内嵌进仓库的插件框架 Cordis(@deepseek-ai/cordis,v4.0.4,TypeScript 编写,源码位于 vendor/cordis/src/)。
Cordis 是个"乐高底座"式的微内核:它只负责三件事——把插件插进去、把插件拔出来、管好插件之间的依赖。它本身不承载任何业务功能,业务全部由插件贡献。这套心智模型已经在 Koishi 机器人生态里跑了四年。
Cordis 的全部设计浓缩成五个核心概念:
把这五个概念吃透,就等于吃透了整个 Harness 的插件机制。下面逐一拆解。
Cordis 接受三种插件形态,本质上归一化为同一种东西:
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 负责组合应用。
ctx 是 Cordis 的核心对象,但它不是一个普通对象,而是一个 Proxy:
// 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) 给服务注入拦截配置。
插件通过 inject 声明自己需要哪些服务:
export const name = 'consumer'
export const inject = ['greeter'] // 声明依赖
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world')) // apply 里保证 greeter 已就绪
}
这里有两个反直觉、但极其重要的设计:
cordis.yml 里各项是并发启动的,谁先谁后由 inject 表达的依赖关系决定,而不是列表位置。这也是"配置里换服务"能成立的原因:卸载一个 shell 提供方、挂载另一个,所有 inject: ['shell'] 的插件都会自动重启、改用新实现。如果某项能力缺失时插件仍想运行,就别写进 inject,改用 ctx.get('greeter') 做可选探测。
服务支持直接调用;事件让插件在不知道有谁在监听的情况下发出通知。Harness 用它处理工具结果、模型请求、审批决定等一切交互。事件的写法是把事件名声明进 Events 接口(TypeScript 声明合并),然后 ctx.emit / ctx.on 都获得完整类型:
declare module '@deepseek-ai/cordis' {
interface Events {
'stats/report'(name: string, count: number): void
}
}
关键在于,每个事件有且只有一种分发模式,这是它的公开约定:
| 模式 | 调用 | 语义 |
|---|---|---|
| emit | ctx.emit(...) | 同步广播,不等待、不收集返回值 |
| parallel | await ctx.parallel(...) | 所有监听器并发运行,一起等待 |
| serial | await ctx.serial(...) | 按序等待,第一个非空返回值胜出并短路 |
| bail | ctx.bail(...) | serial 的同步版本 |
| waterfall | ctx.waterfall(...) | 环绕中间件,可转换或短路 |
其中 waterfall 是实现"拦截"的杀手锏。每个监听器收到参数和一个 next() continuation:可以包一层 next() 的返回值再返回,也可以不调用 next() 直接返回从而"否决"整条链。Harness 用 waterfall 处理协作插件能包装或回答的决策——比如 agent/request 允许插件替换模型调用配置,approval/request 允许策略代替用户作答。
这是 Cordis 最优雅的一处设计。插件可能因为修改配置、热重载、显式释放、或依赖服务消失而被卸载。Cordis 用 effect 保证:你做的每一个注册,都能在卸载时被干净地撤销。
对于 Cordis 尚未管理的资源(定时器、连接、watcher),包进 ctx.effect() 并返回一个 disposer:
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 在六个状态之间迁移:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
状态枚举定义在 vendor/cordis/src/fiber.ts:
export const enum FiberState {
PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING,
}
最精妙的是 PENDING 到 ACTIVE 的唤醒机制。一个声明了 inject 的插件,会一直停在 PENDING,直到所有依赖就绪。Cordis 的做法是:把插件依赖的每一个服务的"提供方 fiber 的 uid"拼接成一个 epoch 字符串,只要 epoch 不完整(缺一个依赖)就记为 INACTIVE:
_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 注册的——所以卸载父插件会递归卸载它挂载的所有子插件。
第三节说 ctx 是 Proxy,现在看它到底怎么工作。真正的 get 陷阱在 vendor/cordis/src/reflect.ts,核心逻辑是沿 fiber 链向上查找服务实现:
// 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 基类的构造器,最终就一句话:
constructor(protected ctx: Context, name: string) {
...
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}
也就是说,super(ctx, 'greeter') 的本质,就是把实例注册成 ctx.greeter 这项服务。
事件系统里,所有监听器都存在 _hooks: Record<name, Hook[]>,五种模式只是对"过滤后的 callback 数组"做不同的执行策略。其中 waterfall 的 next() 链构建只有几行,却最见功力:
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 工具。
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 事件监听每一次工具调用——两个插件互不知道对方存在,靠注册表和事件连接:
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 长这样:
- 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、推导参数类型、并在执行前校验模型提供的参数。
单个插件容易理解,完整的 dsh 是怎么来的?答案是:一棵插件树,由启动时按序叠加的各层组合而成。
dsh-base 是各 profile 共享的第一层,dsh-web-app、dsh-headless、dsh-sdk-app、dsh-acp-app 分别叠加浏览器应用、无服务器运行器、SDK 服务器等。cordis.patch.yml。dsh --profile web --dump-config 打印出的任何条目,都能被你的 patch 替换。组合还天然支持 HMR(热模块替换):因为"卸载会释放 effect、加载遵循依赖关系",HMR 插件只需要先卸载、再加载,就能替换正在运行的插件。Cordis 配置项还能带 id(稳定标识)、disabled: true(卸载但不删除条目)、嵌套 group 和 isolate。
⚠️ 一个常见的坑:如果插件的 inject 指定了没人提供的服务,它会一直 PENDING、静默不输出——这不是错误,因为提供方可能稍后才挂载。排查时遍历 ctx.registry 里每个 fiber 的 state === FiberState.PENDING 就能定位。
Harness 在插件之上还抽象出一层概念——seam(能力接缝)。一个 seam 是一份可替换能力,包含三种角色:
seam 的价值在于"替换一个提供方就能改变整个产品"。文件系统与进程提供方共享同一个执行世界,所以把它们指向远程沙箱,Bash、PTY、LSP 就一起搬了过去;subagent 提供方在同一个接口之后,可以是新建一个子 agent,也可以是把一个轮次委派给另一个产品。扩展 Harness 的正确姿势,就是找到对应的 seam,挂上你的插件。
回顾全文,这套插件机制最值得记住的三点:
inject 表达的依赖关系自动推导,而不是手动编排;理解到这一层,你再看 dsh,就不再是一个黑盒 Agent 工具,而是一张由插件织成的、处处可插拔的能力之网。这才是"一切皆插件"的真正分量。
最后说个现实问题:Harness 开源免费,但骨架免费不等于零成本——真正干活要靠模型。Agent 任务来回迭代几十轮,每次读文件、写代码、跑脚本、调工具都要付费,Token 消耗比普通聊天高出好几个量级。
怎么把成本打下来?通过向量云获取字节跳动火山方舟官方 API 和 Key,给 Harness 接入模型,天然享受三样东西:
ark-****-****-****-****-****-88888 格式),请求直连 ark.cn-beijing.volces.com 火山方舟官方接口,不经中转,延迟、稳定性、计费标准与官方完全一致;一把 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、可独立部署的运营支撑平台及全链路运营支持。
DeepSeek 的打法一直很清晰:把基础设施开源,把生态做大,把价格打下来。 Harness 用"一切皆插件"把 Agent 能力变成可插拔的乐高,再配合更便宜的模型,最终让这套框架成为千万人接入 AI 的第一入口。
对开发者来说,理解它的插件机制,不只是为了"会用",更是为了"能改"——当你第一次写下一个 apply(ctx),让 Harness 在合适的时机自动加载你的插件时,你会真正明白这句话:这个框架里,没有什么是你替换不了的。
至于 Token 成本?交给向量云。你有没有动手写过 dsh 插件?欢迎在评论区聊聊你的踩坑与心得。
火山方舟官方 API 与官方 Key 直连 · 向量云 · 按量计费享 Token 折扣
A. 因为模型、工具与执行循环都处在快速变化中,只有把一切做成可替换的插件,框架才能跟上演进节奏。
A. 藏在底下的插件微内核:插件有三种形态,Context 是一个用 Proxy 撑起的服务容器。
A. 用依赖关系代替启动顺序:插件只声明自己需要什么,加载顺序由内核负责编排。
A. 可逆的副作用:插件注册的能力可以被干净卸载,避免热替换时残留状态。