DeepSeek Harness 开发预览:运行、配置与踩坑记录
最近 DeepSeek 开源了 Agent Harness 框架 dsh,一个基于 Cordis 的插件化 AI 智能体框架。我花了两天时间把它跑起来,顺便翻了那篇关于 spatiotemporal composability 的论文,把理解和实际操作中遇到的问题整理了一下。
它解决什么问题
AI 智能体框架通常会逐渐长成一个大泥球:工具调用、记忆系统、子智能体编排、权限控制、会话管理——这些功能互相依赖,又都跑在同一个进程里。如果其中一个模块出了问题,或者你想在运行时换掉某个工具的实现,传统做法是重启整个进程。
dsh 的切入点很简单:每个功能都是一个插件,每个插件在加载时产生的副作用(注册路由、创建定时器、向上下文注入服务)都应该在插件被卸载时自动回滚。 换句话说,一个插件装上是什么样,卸掉之后系统就应该回到什么样。
这点和 VSCode 的扩展机制刚好形成对比。论文里专门拿 VSCode 举了例子:Top 100 的扩展里 87% 带可执行代码,禁用或卸载它们需要重启整个扩展宿主进程。dsh 的做法是把这个粒度降到单个插件级别。
安装与运行
官方推荐的方式是一行命令:
npx @deepseek-ai/dsh web
这会启动一个 Web UI,默认跑在 http://127.0.0.1:3080。
如果你是从源码跑,流程稍微长一点:
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
我用的是 Node.js 20.x,pnpm 版本 8.x。pnpm install 这一步如果卡住,大概率是网络问题,换一下镜像源或者设置代理就行。
# 如果 install 卡住,试试切换镜像源
pnpm config set registry https://registry.npmmirror.com
如果 pnpm dsh web 报错说找不到命令,那是因为 pnpm 没有把本地 bin 挂到 PATH 上。用 pnpm run dsh web 代替,或者全局安装 @deepseek-ai/dsh 之后再跑 dsh web。
第一次启动后,浏览器打开 http://127.0.0.1:3080,界面长这样。它本质上是一个 Cordis 应用,每个 UI 组件本身也是插件,通过 ctx 与后端交互。
一切皆插件:Cordis 的上下文模型
dsh 的架构完全建立在 Cordis 之上。理解 Cordis 的上下文模型,比背命令重要得多。
Cordis 的核心是一个叫做 ctx 的上下文对象。每个插件被加载时,都会拿到一个专属的 ctx。插件通过 ctx 做两件事:
-
声明它需要什么(依赖注入): ctx.inject列出它依赖的服务 key -
声明它提供什么(服务注册): ctx.provide把某个 key 绑定到一个实现
当一个插件 activate 的时候,Cordis 会检查它 inject 的所有依赖当前是否都被满足了。如果满足,就执行插件的 apply 函数;如果不满足,这个插件就保持未激活状态,直到所有依赖出现。
这个机制和 OSGi 的 Declarative Services 很像,但 Cordis 加了一个关键特性:当依赖消失时,依赖它的插件会被自动卸载,而它的卸载过程会先通知所有依赖它的插件,形成一个有序的依赖卸载链。
一个具体例子
假设你有一个数据库插件和一个日志插件。日志插件依赖数据库插件提供的 database 服务。配置是这样的:
// database 插件
export default {
name: 'database',
provide: {
database: true
},
apply(ctx) {
ctx.set('database', new DatabaseConnection())
// 卸载时会自动调用 ctx.get('database').close()
}
}
// logger 插件
export default {
name: 'logger',
inject: ['database'],
apply(ctx) {
const db = ctx.get('database')
// 用 db 干活
}
}
当数据库插件被禁用时,Cordis 会先通知日志插件:“你依赖的 database 没了”,于是日志插件先进入 UNLOADING 状态并执行自己的清理逻辑,然后数据库插件才真正卸载。
论文里把这种顺序保证叫做 Theorem 63 (Ordering):一个 provider 只有在所有依赖它的 consumer 都完成卸载之后,才会执行自己的卸载。
生命周期与状态机
Cordis 的每个插件实例叫做一个 fiber。它的生命周期在论文里用 Figure 2 画了出来:
INACTIVE → [激活] → LOADING → (迭代) → ACTIVE
↓
[依赖变化]
↓
UNLOADING → (等待依赖者退出) → INACTIVE
我实际碰到的一个问题是:插件激活到一半,它依赖的服务突然被卸载了。比如你在 apply 函数里做了三次 ctx.set,但第三次调用失败了,或者第二次迭代期间依赖消失了。
Cordis 的做法是记录每一步的逆操作。论文的 Algorithm 1 展示了一个 effect 函数:每次 ctx.set 都会返回一个 dispose 函数,这些 dispose 被按 LIFO 顺序组合成一个大的 accumulator。当插件需要被卸载时,这个 accumulator 被调用,之前做的所有修改被依次回滚。
用论文里的公式来说:如果插件在执行过程中产生了一个状态序列,那么在这个序列的任何一点上,调用当前累积的 accumulator 都能恢复到执行开始之前的状态。Theorem 7 管这个叫恢复精确性。
配置与热替换
配置通过 YAML 或 JSON 文件声明,每个插件条目至少包含:
- id: my-logger
url: ./plugins/logger.js
config:
level: info
disabled: false
disabled 字段用来控制插件是否启用。把它从 true 改成 false,加载器会执行一次热替换:卸载旧插件、加载新插件,过程中不重启进程。
@cordisjs/hmr 组件做热替换的思路是这样的:检测到文件变化后,先标记哪些模块是“被影响的”(accepted),哪些是“需要重新加载的”(stale),然后在一个事务里完成替换——如果任何一个模块加载失败,回滚所有操作,恢复到替换前的状态。
具体算法在论文的 Algorithm 8-10。一句话概括:热替换不是重启后重放,而是直接在当前状态上做差量更新。 如果替换失败,状态回滚到替换前,而不是丢在中间态。
// 这个事务逻辑在 @cordisjs/hmr 的 reload 函数里
try {
for (entry of stale_entries) {
entry.fiber.dispose() // 回滚旧插件
entry.fiber = ctx.use(newComponent) // 加载新插件
}
} catch (error) {
restore_caches(backup) // 全部回滚
throw error
}
常见问题与坑
1. 插件之间的循环依赖
Cordis 不会帮你解开循环依赖。如果 A 依赖 B、B 依赖 A,两个插件都会卡在 INACTIVE。这个问题在启动日志里看得到,target 永远算不出来,插件不会报错但也不会激活。解法是拆插件:把双向交互拆成两个单向依赖,再加一个整合插件把两边串起来。
2. 依赖 key 的命名冲突
Cordis 的依赖 key 是字符串,所以如果两个独立开发的插件用了同一个 key 名但代表不同的东西,就会发生奇怪的运行时行为。官方的建议是用命名空间前缀,比如 @myorg/database 而不是 database。
3. ctx.get 在插件未激活时调用
如果一个插件声明了 inject: ['service'],但在 apply 之外的地方(比如顶层作用域)调用了 ctx.get('service'),会抛一个 INACTIVE_ACCESS 错误。因为此时依赖还没被注入。所有依赖访问必须放在 apply 内部或由 apply 调用的函数里。
4. 异步清理
卸载插件时,dispose 函数如果是异步的,Cordis 会等待它完成。但论文的 Algorithm 5 里,unload 会调用 await all(notify(...).map(f => f.await())) 等待所有依赖者先卸载。如果你的 dispose 里有长时间的操作,整个卸载链都会被阻塞。所以插件清理逻辑应该尽量轻量,或者把耗时操作放到后台任务里。
5. 插件加载顺序
Cordis 的加载器根据 inject 关系决定激活顺序,而不是配置文件里的顺序。如果你想强制一个插件先于另一个加载,就声明依赖;如果不想有依赖关系,就别指望顺序。如果确实需要顺序,可以用一个“初始化完成”的 key 来做同步点。
总结
dsh 目前是开发者预览版,跑起来之后最大的感受是:插件的生命周期管理做得比较干净,卸掉一个插件之后没有残留的副作用。代价是你得适应 Cordis 这套依赖声明和上下文模型,以及对每个 set 操作给出对应的清理逻辑。
如果要快速判断它适不适合你,看两点:
-
你的系统需不需要在运行时增减组件? -
你愿不愿意为了这种灵活性,把代码组织成依赖注入的形式?
两个答案都是 yes,可以考虑花点时间玩玩 dsh。
操作清单
-
[ ] 安装 Node.js 20+ 和 pnpm -
[ ] 用 npx @deepseek-ai/dsh web快速体验,或从源码pnpm install && pnpm run build && pnpm dsh web -
[ ] 访问 http://127.0.0.1:3080确认 Web UI 正常启动 -
[ ] 写一个简单插件: inject依赖 +apply中ctx.set注册服务 -
[ ] 在配置文件中把 disabled: true改成false,观察热替换效果 -
[ ] 检查插件卸载后状态是否回滚( ctx.get不再能拿到已卸载的服务)
FAQ
Q: dsh 和直接写一个 Node.js 脚本有什么区别?
A: dsh 提供插件生命周期管理、依赖注入、热替换和状态回滚。如果你需要这些,不用自己实现一套。
Q: 插件之间必须用注入通信吗?
A: 推荐方式是通过 ctx.provide 和 ctx.get。你也可以用全局变量或模块导入,但这样会绕过生命周期管理,卸载时不会被追踪。
Q: 热替换会丢失内存状态吗?
A: 插件的内部状态会被回滚,但存在外部服务(比如数据库)里的数据不会丢。如果需要保留状态,把状态放在一个长期存活的依赖里。
Q: 报错 Module not found 怎么处理?
A: 检查 url 路径是否正确。Cordis 的加载器基于标准 Node.js import(),相对路径相对于配置文件所在目录解析。
Q: 插件日志在哪里看?
A: 默认输出到控制台。可以配置 logger 插件把日志写到文件。
Q: 循环依赖会怎样?
A: 两个插件都永远不会激活。需要在设计层面拆开双向依赖。
Q: 我可以同时运行多个 dsh 实例吗?
A: 可以,每个实例监听不同端口。但共享同一份配置目录时要注意文件锁问题。

