DeepSeek Harness 二次开发实战:从「一切皆插件」到写出你自己的 Tool
模型适配器、工具注册表、会话日志、Agent 主循环——全部是插件,没有不可替换的核心。这句话,就是 DeepSeek Harness 给二次开发者的入场券。
前面几篇我们聊了 DeepSeek Harness 的定位、桌面版体验和场景实战。但真正让它在开发者圈子里封神的,是另一件事:它是目前少有的、把"插件架构"写进论文的开源 Agent 框架——Everything is a plugin,一切皆插件。
这意味着一个普通人也能做、且价值巨大的动作:给 Harness 写插件,扩展它、改造它,甚至替换它的心脏。
这篇文章从技术架构讲起,手把手带你完成一次完整的二次开发:写 Hello World、写自定义 Tool、加可配置项、管理外部资源、打包成可安装 Bundle,最后解决几个真实的工程问题。
一、技术架构:先看懂这台"乐高机器"
1.1 架构总纲:Agent = Model + Harness
官方对 Harness 的定义很克制:
DeepSeek Harness(dsh)是连接模型与真实环境、调度工具完成任务的中介层。模型是 Agent 的灵魂,Harness 给予 Agent 理解环境、使用工具的能力。
落到架构上,就是一句话:模型、工具、技能、会话、沙箱、存储、循环、调度、UI,全部由插件组合而成。
没有"不可替换的核心"。想换模型?装个插件。想加工具?装个插件。想改 Agent 主循环?还是装个插件。
1.2 心脏:Cordis 插件系统
Harness 的底座是 Cordis——一个只负责插件"插进去、拔出来、管依赖"的微内核。它最早在机器人生态 Koishi 里跑了四年,如今被 DeepSeek 选中成为 Agent 框架的心脏,配套论文《A Programming Paradigm for Spatiotemporal Composability》(时空可组合性的编程范式)以预印本发布,署名单位为北京大学与 DeepSeek-AI。
Cordis 的职责非常聚焦,只有三件事:
-
加载与卸载插件:按声明顺序装配,卸载时自动清理该插件注册的一切能力; -
依赖注入:插件通过 inject声明依赖,框架保证依赖就绪后才调用它; -
生命周期管理:服务(Service)随上下文创建、随上下文销毁,热替换时自动 dispose 再重建。
记住这三个职责,后面所有代码都能看懂。
1.3 插件的本质:一个 apply 函数
在 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 插件和"随手写个脚本"最本质的区别:生命周期有始有终,不会泄漏。
1.4 插件的三种形态
|
|
|
|
|---|---|---|
| 函数形式 |
|
export function apply(ctx) {...} |
| 对象形式 |
|
export default { name, inject, apply } |
| 类形式(Service) |
|
class MyService extends Service |
三种形态可以按需选择,类形式最强大——它能把你的能力暴露成 ctx.myService,让其他插件通过 inject: ['myService'] 来依赖和使用。
1.5 依赖注入:inject 与 ctx
插件顶部常见的 inject 字段,是 Cordis 依赖注入的入口:
export const inject = ['tools']
含义:我这个插件依赖 ctx.tools 这个服务。Cordis 会保证两件事:
-
服务就绪后才调用 apply; -
如果 ctx.tools的 provider 被热替换,插件会自动 dispose 并重新apply。
这就是"一切皆插件"能真正落地的机制保证——插件之间不是靠全局变量硬耦合,而是靠声明式依赖解耦。
1.6 事件系统:emit / serial / waterfall
Harness 内置三种事件语义,插件可以监听或拦截框架的各个环节:
|
|
|
|
|---|---|---|
emit |
|
|
serial |
|
|
waterfall |
next() 才能继续
|
|
1.7 Tool 执行管道
注册的 tool 会走一条完整管道,这是做权限控制和安全拦截的关键:
模型返回 tool_call → tools/pre-execute (waterfall)
→ tools/execute (waterfall)
→ 你的 execute() 函数
→ tools/post-execute (waterfall)
→ tool/result (session event)
看懂这条管道,你就能理解:权限策略可以在 tools/pre-execute 拦截工具,审批机制可以在执行前要求确认,结果自动记录到 session log。
1.8 配置层:按层叠加,后层胜出
Harness 的配置是分层的,加载顺序如下:
-
profile.bundles列表中的各 bundle(按顺序) -
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 验证。
三、实战一:Hello World 插件(5 分钟)
先跑通最小闭环,验证插件机制。
第一步:创建插件文件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! 即为成功。到这里,你已经是"Harness 插件开发者"了。
四、实战二:写一个自定义 Tool
Hello World 只是证明机制通了。真正有用的是给 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 |
[A-Za-z0-9_-]
|
description |
|
parameters |
args 类型并做运行时校验
|
output.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 type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
// 定义 Config interface
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),
})
export function apply(ctx: Context, config: Config) {
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) {
const suffix = config.emoji ? ' 👋' : ''
return `${config.greeting}, ${args.name}!${suffix}`
},
}))
}
配置写入 cordis.yml:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
config:
greeting: 'Hey'
emoji: false
设计原则:凡是不同部署可能需要不同值的参数,都应定义为 Config 字段;Schema 在插件加载时执行校验,配置错了会响亮地失败(加载即报错),而不是运行时静默异常。
六、实战四:管理外部资源(数据库连接池)
很多二次开发要对接外部系统。如果 tool 需要维持一个长连接,比如数据库连接池,用 ctx.effect 管理生命周期:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'db-query-tool'
export const inject = ['tools']
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, description: 'SQL query' },
},
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
开发完要交付。把插件打包成标准 Bundle:
hello-plugin/
├── package.json # 声明 dsh.bundle
├── cordis.patch.yml # 该 bundle 贡献的配置层
└── index.js # 插件入口
package.json:
{
"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" } }
}
cordis.patch.yml:
- insert:
- id: hello
name: dsh-hello-plugin
安装到 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() { /* ... */ }
}
之后别的插件就能:
export const inject = ['myService']
export function apply(ctx: Context) {
ctx.myService.doSomething()
}
这一步把"写工具"升级成了"写框架"——你提供的服务会成为整个 Harness 生态的一部分。
九、实战七:完整案例——代码仓库浏览插件
前面六个实战都是"零件级"演示。这一章把它们串成一个完整、可运行、解决真实问题的插件:repo-explorer(代码仓库浏览插件)。
要解决的问题:默认情况下,让 Harness 的 Agent 自主浏览一个本地代码仓库——列出目录结构、读取关键文件、理解项目技术栈。这在"让 AI 接手一个陌生项目"的场景里极其实用,也是社区"本地代码依赖分析"玩法的核心。
9.1 完整代码
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 })
},
}))
}
9.2 逐块拆解:为什么它是"完整的"
|
|
|
|
|---|---|---|
|
|
list_dir
read_file
|
|
|
|
maxFileBytes
ignore
|
node_modules 这类巨型目录
|
|
|
stat
info.size
|
安全边界
|
|
|
exec.signal
readdir/readFile
|
|
|
|
output.render |
|
9.3 注册配置
- 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]
9.4 启动与验证
pnpm dsh web --patch ./scratch-plugin/cordis.yml
在 Web UI 输入:
浏览 /home/user/my-project 目录,找到入口文件,
总结这个项目的技术栈和目录结构。
你会看到 Agent 自主完成一串动作:list_dir 看根目录 → 发现 package.json → read_file 读取 → 继续深入 src/ → 最终输出技术栈和结构总结。整个过程无需你手动贴代码——这正是二次开发赋予 Agent 的"动手能力"。
9.5 安全提示
read_file 能读任意路径,是高风险工具。生产环境务必叠加第九章的权限拦截:在 tools/pre-execute 里把路径白名单限定在工作区目录内,防止 Agent 越权读取敏感文件。
十、解决实际问题:拦截、审批、可观测
二次开发最常见的目标不是"加功能",而是"加管控"。结合 Tool 执行管道和事件系统,可以做三件实事:
1. 权限拦截——在 tools/pre-execute 用 waterfall 拦截危险工具:
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()
})
}
2. 审批确认——执行前要求人工确认。
3. 可观测性——监听事件打日志:
ctx.on('tools/post-execute', (toolName, result) => {
console.log(`Tool ${toolName} executed, result length: ${result.length}`)
})
再配合 --dump-config 和 HMR 热更新(改插件保存后自动卸载重载,无需重启进程),二次开发的调试体验相当顺滑。
十一、二次开发的典型场景清单
总结一下,哪些实际问题值得你动手二次开发:
|
|
|
|---|---|
|
|
ctx.effect 管理连接
|
|
|
|
|
|
tools/pre-execute
|
|
|
|
|
|
|
|
|
|
|
|
|
核心思路一句话:凡是 Harness 本身不会、但你的业务需要的,写成一个插件塞进去就行。
十二、别忘了:跑起来要 Token
二次开发写得再漂亮,最终要烧 Token。Harness 本身免费开源,但每次 Agent 推理、每次工具调用都要走模型计费。省钱的关键还是那句老话:
-
通过向量云获取火山方舟官方 API 和 Key:官方真实 Key( ark-****-****-****-****-****-88888格式),请求直连ark.cn-beijing.volces.com,不经中转,延迟、稳定性、计费标准与官方一致; -
低折扣 Token、按量计费、即用即扣,注册、充值 10 元、创建 Key 三步搞定; -
复杂开发调试用 DeepSeek V4 Pro( 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,再到用事件管道做权限拦截,这条路径走下来,你收获的不只是一个插件,而是理解了"一切皆插件"背后那套优雅的工程范式。
动手试试:给 Harness 写一个解决你自己问题的插件,然后回来告诉我,你用它干了什么。
向量云(南京)科技有限公司
火山方舟官方 API · 低折扣 Token · 即用即扣 · 充值开票服务
获取 API Key:**https://ark.tokenrize.cn/**

