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 做两件事:

  1. 声明它需要什么(依赖注入):ctx.inject 列出它依赖的服务 key
  2. 声明它提供什么(服务注册):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 依赖 + applyctx.set 注册服务
  • [ ] 在配置文件中把 disabled: true 改成 false,观察热替换效果
  • [ ] 检查插件卸载后状态是否回滚(ctx.get 不再能拿到已卸载的服务)

FAQ

Q: dsh 和直接写一个 Node.js 脚本有什么区别?
A: dsh 提供插件生命周期管理、依赖注入、热替换和状态回滚。如果你需要这些,不用自己实现一套。

Q: 插件之间必须用注入通信吗?
A: 推荐方式是通过 ctx.providectx.get。你也可以用全局变量或模块导入,但这样会绕过生命周期管理,卸载时不会被追踪。

Q: 热替换会丢失内存状态吗?
A: 插件的内部状态会被回滚,但存在外部服务(比如数据库)里的数据不会丢。如果需要保留状态,把状态放在一个长期存活的依赖里。

Q: 报错 Module not found 怎么处理?
A: 检查 url 路径是否正确。Cordis 的加载器基于标准 Node.js import(),相对路径相对于配置文件所在目录解析。

Q: 插件日志在哪里看?
A: 默认输出到控制台。可以配置 logger 插件把日志写到文件。

Q: 循环依赖会怎样?
A: 两个插件都永远不会激活。需要在设计层面拆开双向依赖。

Q: 我可以同时运行多个 dsh 实例吗?
A: 可以,每个实例监听不同端口。但共享同一份配置目录时要注意文件锁问题。