Cordis 插件系统 · 自定义 Tool · 火山方舟官方 API
前面几篇聊了 DeepSeek Harness 的定位、桌面版体验和场景实战。但真正让它在开发者圈子里封神的,是另一件事:它是目前少有的、把"插件架构"写进论文的开源 Agent 框架——Everything is a plugin,一切皆插件。
这篇文章从技术架构讲起,手把手带你完成一次完整的二次开发:写 Hello World、写自定义 Tool、加可配置项、管理外部资源、打包成可安装 Bundle,最后解决几个真实的工程问题。
DeepSeek Harness(dsh)是连接模型与真实环境、调度工具完成任务的中介层。模型是 Agent 的灵魂,Harness 给予 Agent 理解环境、使用工具的能力。
落到架构上,就是一句话:模型、工具、技能、会话、沙箱、存储、循环、调度、UI,全部由插件组合而成。没有"不可替换的核心"。
Harness 的底座是 Cordis——一个只负责插件"插进去、拔出来、管依赖"的微内核。它最早在机器人生态 Koishi 里跑了四年,如今被 DeepSeek 选中成为 Agent 框架的心脏,配套论文《A Programming Paradigm for Spatiotemporal Composability》以预印本发布,署名单位为北京大学与 DeepSeek-AI。
Cordis 的职责非常聚焦,只有三件事:
inject 声明依赖,框架保证依赖就绪后才调用它;在 DeepSeek Harness 里,一个插件就是一个导出 apply 函数的 TypeScript 模块:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export function apply(ctx: Context) {
// 在这里注册能力
}
框架加载插件时调用 apply,传入 ctx(上下文,即服务容器)。你在 ctx 上注册的一切能力,插件卸载时会被自动清理——这是 Harness 插件和"随手写个脚本"最本质的区别:生命周期有始有终,不会泄漏。
| 形态 | 适用场景 | 写法 |
|---|---|---|
| 函数形式 | 绝大多数场景,注册能力 | export function apply(ctx) {...} |
| 对象形式 | 需要携带额外元信息 | export default { name, inject, apply } |
| 类形式(Service) | 要"提供服务"给别的插件依赖 | class MyService extends Service |
类形式最强大——它能把你的能力暴露成 ctx.myService,让其他插件通过 inject: ['myService'] 来依赖和使用。
export const inject = ['tools']
含义:我这个插件依赖 ctx.tools 这个服务。Cordis 会保证:服务就绪后才调用 apply;如果 provider 被热替换,插件会自动 dispose 并重新 apply。
| 类型 | 行为 | 用途 |
|---|---|---|
emit | 广播通知,监听器不影响流程 | 打日志、埋点 |
serial | 按注册顺序依次执行 | 有序的副作用 |
waterfall | 链式传递,必须调 next() 才能继续 | 拦截、改写、审批 |
权限策略可以在 tools/pre-execute 拦截工具,审批机制可以在执行前要求确认,结果自动记录到 session log。
加载顺序:profile.bundles → profile 的 cordis.patch.yml → $DSH_HOME/cordis.patch.yml(机器级)→ --patch 命令行 overlay。后应用的层按行胜出,且整体替换 config,不是深度合并。
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
corepack enable
pnpm install
前置要求:Node.js 22 或 24、pnpm。装好后用 pnpm dsh --version 验证。
创建插件文件 scratch-plugin/src/my-plugin.ts:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
console.log('[hello-plugin] plugin loaded!')
}
注册到 cordis.yml:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
插件路径必须是绝对路径。patch 文件只贡献配置,不改变 loader 的模块解析基目录。
启动:
pnpm dsh web --patch ./scratch-plugin/cordis.yml
终端打印 [hello-plugin] plugin loaded! 即为成功。
真正有用的是给 Agent 加一个它不会的能力——自定义 Tool。替换 my-plugin.ts:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
defineTool 的关键字段,每一个都是"模型接口契约":
| 字段 | 作用 |
|---|---|
name | 模型可见的工具名,64 字符内,只允许 [A-Za-z0-9_-] |
description | 模型用来判断是否调用该工具的描述 |
parameters | JSON Schema,自动推导 args 类型并做运行时校验 |
output.schema | 声明工具输出的 schema |
output.render | 把规范输出转成模型可见的 ContentBlock |
execute | 实际执行逻辑,接收校验后的 args |
ctx.tools.register() 会返回一个 disposer,插件卸载时自动把 tool 从注册表移除。验证:在 Web UI 输入 Use the greet tool to greet Ada.,模型会调用 greet 返回 Hello, Ada!。
用 Schemastery 给插件加配置项:
import Schema from '@deepseek-ai/schemastery'
export interface Config {
greeting: string
emoji: boolean
}
// 导出同名 Schema(Cordis 用它校验 + 填充默认值)
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
emoji: Schema.boolean().default(true),
})
// apply 的第二个参数就是 config
export function apply(ctx: Context, config: Config) {
// ... 用 config.greeting / config.emoji
}
配置写入 cordis.yml:
- insert:
- id: hello
name: '/absolute/path/.../my-plugin.ts'
config:
greeting: 'Hey'
emoji: false
设计原则:凡是不同部署可能需要不同值的参数,都应定义为 Config 字段;Schema 在插件加载时执行校验,配置错了会响亮地失败(加载即报错),而不是运行时静默异常。
如果 tool 需要维持一个长连接,用 ctx.effect 管理生命周期:
export function apply(ctx: Context) {
let pool: ConnectionPool | undefined
ctx.effect(() => {
pool = createConnectionPool({ host: 'localhost', port: 5432 })
return () => { // 清理函数,自动执行
pool?.close()
pool = undefined
}
})
ctx.tools.register(defineTool({
name: 'db_query',
description: 'Run a read-only SQL query.',
parameters: { sql: { type: 'string', required: true } },
output: {
schema: { type: 'array', items: { type: 'object' } },
render: (_args, rows) => [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
},
async execute(args) {
if (!pool) throw new Error('Database pool not available')
return await pool.query(args.sql)
},
}))
}
ctx.effect 返回的清理函数会在这些场景自动执行:插件被手动 dispose、依赖服务消失(热替换)、HMR 触发配置变更、整个应用关闭。连接池绝不会泄漏。
开发完要交付,打包成标准 Bundle:
hello-plugin/
├── package.json # 声明 dsh.bundle
├── cordis.patch.yml # 该 bundle 贡献的配置层
└── index.js # 插件入口
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
安装到 profile 并验证:
dsh plugin --profile demo add ./hello-plugin
dsh --profile demo --dump-config
dsh --profile demo
--dump-config 是二次开发的黄金命令——随时打印最终配置树,排查"配置到底生效没有"的疑难杂症。
想让自己的插件成为基础设施,被其他插件依赖,用类形式:
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
myService: MyService
}
}
export default class MyService extends Service {
static inject = ['tools']
constructor(ctx: Context) {
super(ctx, 'myService')
}
doSomething() { /* ... */ }
}
之后别的插件就能 inject: ['myService'],调用 ctx.myService.doSomething()。这一步把"写工具"升级成了"写框架"。
前面六个实战都是"零件级"演示。这一章把它们串成一个完整、可运行、解决真实问题的插件:repo-explorer(代码仓库浏览插件)。
要解决的问题:让 Harness 的 Agent 自主浏览一个本地代码仓库——列出目录结构、读取关键文件、理解项目技术栈。这在"让 AI 接手一个陌生项目"的场景里极其实用,也是社区"本地代码依赖分析"玩法的核心。
import { readdir, readFile, stat } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'repo-explorer'
export const inject = ['tools']
// 可配置项:文件大小上限 + 忽略目录
export interface Config {
maxFileBytes: number
ignore: string[]
}
export const Config: Schema<Config> = Schema.object({
maxFileBytes: Schema.number().default(128 * 1024),
ignore: Schema.array(Schema.string()).default(['node_modules', '.git', 'dist', '.next']),
})
export function apply(ctx: Context, config: Config) {
// 工具一:列出目录内容
ctx.tools.register(defineTool({
name: 'list_dir',
description: 'List files and subdirectories in a directory.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path to the directory' },
},
output: {
schema: { type: 'array', items: { type: 'string' } },
render: (_args, value) => [{ type: 'text', text: value.join('\n') }],
},
async execute(args, exec) {
const entries = await readdir(args.path, { signal: exec.signal })
return entries
.filter((entry) => !config.ignore.includes(entry))
.sort()
},
}))
// 工具二:读取文件内容(带大小上限与取消信号)
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a text file from disk. Returns its content as text.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path to the file' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
const info = await stat(args.path)
if (info.size > config.maxFileBytes) {
throw new Error(`File too large (${info.size} bytes, limit ${config.maxFileBytes})`)
}
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}
| 设计点 | 实现 | 价值 |
|---|---|---|
| 两个 Tool 组合 | list_dir + read_file | Agent 先"看目录"再"读文件",形成完整浏览闭环 |
| 可配置项 | maxFileBytes、ignore | 不同项目可调,防误读 node_modules 这类巨型目录 |
| 大小上限 | stat 后判断 info.size | 安全边界:避免一次性读入几百 MB 文件撑爆上下文 |
| 取消信号 | exec.signal 传给 readdir/readFile | 用户打断时,底层 I/O 能真正中断,不浪费资源 |
| 输出渲染 | output.render | 数组类型结果转成模型可见的文本 |
- insert:
- id: repo-explorer
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/repo-explorer.ts'
config:
maxFileBytes: 65536
ignore: [node_modules, .git, dist, .next, build]
pnpm dsh web --patch ./scratch-plugin/cordis.yml
在 Web UI 输入:
浏览 /home/user/my-project 目录,找到入口文件,
总结这个项目的技术栈和目录结构。
你会看到 Agent 自主完成一串动作:list_dir 看根目录 → 发现 package.json → read_file 读取 → 继续深入 src/ → 最终输出技术栈和结构总结。整个过程无需你手动贴代码。
安全提示:read_file 能读任意路径,是高风险工具。生产环境务必叠加权限拦截,在 tools/pre-execute 里把路径白名单限定在工作区目录内,防止 Agent 越权读取敏感文件。
二次开发最常见的目标不是"加功能",而是"加管控"。结合 Tool 执行管道和事件系统,可以做三件实事:
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.waterfall('tools/pre-execute', async (toolName, args, next) => {
if (toolName === 'bash' && isDangerous(args)) {
throw new Error('Blocked by safety policy')
}
return next()
})
}
审批确认:执行前要求人工确认。可观测性:ctx.on('tools/post-execute', ...) 打日志。再配合 --dump-config 和 HMR 热更新,二次开发的调试体验相当顺滑。
| 场景 | 用什么实现 |
|---|---|
| 接入企业内部系统(ERP、CRM、工单) | 自定义 Tool + ctx.effect 管理连接 |
| 数据查询 / 报表生成 | Tool 对接数据库 / API |
| 安全管控与权限审批 | tools/pre-execute waterfall 拦截 |
| 自定义模型接入(私有化模型) | 模型适配器插件 |
| 定时任务 / 工作流编排 | 调度插件 |
| 审计与合规 | 事件监听 + session log 回放 |
| 团队内部共享能力 | 类形式 Service + Bundle 分发 |
核心思路一句话:凡是 Harness 本身不会、但你的业务需要的,写成一个插件塞进去就行。
二次开发写得再漂亮,最终要烧 Token。Harness 本身免费开源,但每次 Agent 推理、每次工具调用都要走模型计费。省钱的关键还是那句老话:
ark-****-****-****-****-****-88888 格式),请求直连 ark.cn-beijing.volces.com,不经中转,延迟、稳定性、计费标准与官方一致;deepseek-v4-pro-ga-260813),日常跑通用 DeepSeek V4 Flash(deepseek-v4-flash-ga-260731)。获取 API Key:打开 https://ark.tokenrize.cn/ 注册充值,创建 Key 后填入 Harness 的 设置 → 模型 即可。
Harness 走 OpenAI 兼容协议,接入地址 https://ark.cn-beijing.volces.com/api/v3。
DeepSeek Harness 真正打动二次开发者的,不是它有多少现成功能,而是它把"改框架"的权力还给了开发者——模型、工具、主循环、UI,没有一处是你不能碰的。
从 Hello World 到自定义 Tool,从可配置插件到提供 Service,再到用事件管道做权限拦截,这条路径走下来,你收获的不只是一个插件,而是理解了"一切皆插件"背后那套优雅的工程范式。
面向大模型应用落地的技术服务型企业,依托字节跳动火山方舟能力,为合作方提供低折扣模型 Token、可独立部署的运营支撑平台及全链路运营支持。
火山方舟官方 API 与官方 Key 直连 · 向量云 · 按量计费享 Token 折扣
A. 以 Cordis 插件系统为心脏,配合依赖注入、事件系统与 Tool 执行管道,整体仍然是 Agent = Model + Harness。
A. 本质是一个 apply 函数:通过 ctx 拿到服务、用 inject 声明依赖、用事件参与执行流程。
A. 文中从 Hello World 起步,依次覆盖自定义 Tool、可配置插件、资源管理、Bundle 打包、Service 服务与权限拦截。