DeepSeek Harness(命令是 dsh)是 DeepSeek 开源的 agent harness,MIT 协议,目前处于开发者预览阶段。它值得看的地方不是功能列表,而是组织方式:整个产品没有一段"核心代码"可以改,所有东西都是插件,包括 agent 主循环本身。

先跑起来

最简单的启动方式不需要克隆仓库:

npx @deepseek-ai/dsh web

默认在 127.0.0.1:3080 起 Web UI 并打开浏览器。SSH 环境下只打印地址,因为转发端口由 SSH 客户端或编辑器持有。加 --no-open 可以只起服务不打开浏览器。

想改代码就从源码跑:

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web

pnpm run build 准备构建产物,pnpm dsh web 直接用产物启动,不会重新构建。

README 里有句话要当回事:这是开发者预览,会有兼容性破坏性变更,跑之前先看 SAFETY.md。

一切都是插件

dsh 底下是 Cordis 这个框架。Cordis 的模型是:插件往共享的 context 里贡献三样东西——服务、类型化事件、可回退的副作用。

dsh 把这条规则贯彻得比较彻底。按官方架构文档的说法,模型适配器、工具注册表、会话日志、以及 agent 主循环本身,全都是插件,每一个都能从配置层面替换。

这跟常见 agent 框架的差别在哪:多数框架有一个核心对象,插件往里注册钩子,核心本身不可替换。dsh 反过来,没有特权核心。你要扩展,就在旁边挂一个插件;注册行为是"副作用",插件卸载时会自动回退(unwind)。

这个设计带来的直接后果是运行时可控。换模型调用方式不用改框架内部,挂一个适配器插件;某个工具不想要了,卸载它,它注册的东西自动撤掉,不会留下悬挂状态。

profile 与 bundle

一个跑起来的 dsh 是一棵插件树,在启动时按层序拼出来。这里有两个容易混的概念。

profile 是一个命名的组合,存在 Harness home 目录里。它列出自己要叠哪些 bundle、装哪些树外插件、以及用户自己的 cordis.patch.yml。内置模板有五个:web、headless、sdk、sdk-minimal、acp。

bundle 是一种分发格式,把 Cordis 的配置行和它们挂载的代码打包在一起,好处是它插进去的东西,上面层还能继续 patch。

两者都在自己 package.json 的 dsh 字段里自我声明,一个 profile 长这样:

{
  "name": "my-profile",
  "dsh": {
    "profile": ["dsh-base", "dsh-web-app"]
  }
}

(结构示意,字段名取自文档,具体值按需填。)

dsh-base 是 web、headless、sdk、acp 四个 profile 共享的第一层,负责模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。在此之上,dsh-web-app 加浏览器应用,dsh-headless 加一个无服务器的一次性 runner,dsh-sdk-app 加 JSON-RPC 服务,dsh-acp-app 加只面向自动化的 ACP 服务。

sdk-minimal 是刻意的例外:它自己拥有完整的显式 SDK 树,不套 dsh-base。

启动时的层序

启动时,层按这个顺序叠到一个空条目列表上:

  1. profile 里排好序的 bundle
  2. profile 自己的 cordis.patch.yml
  3. home 目录里的 patch
  4. 命令行 --patch 叠加的覆盖

patch 的作用方式是按 id 命中某一行,整行替换它的配置,或者插入新行。preset 类型的 patch 把同样的操作限制在某个声明的 config.plugins 内部,层序优先级一致。

想知道自己机器上到底是什么树,直接打印:

dsh --profile web --dump-config

它打印出来的任何一行,理论上都可以被你自己的 patch 换掉。这一点是"没有特权核心"的验证方式——如果某一行你换不掉,说明架构承诺没有兑现。

HMR 的取舍

一个很小的细节能看出设计取向:base 会启用仅配置层面的 dsh-hmr;headless、sdk、acp 都把它关掉;sdk-minimal 干脆不带这个插件。

交互式的 Web 应用需要热更新,而给程序调用的一次性 runner 和 SDK 服务不需要——它们没有"开发时反复改配置"的场景。profile 的 patch 可以覆盖这些默认值。

会话日志

架构文档里有一句很硬的话:Model-visible means logged——模型能看到的东西,必须是日志里记着的。

具体到实现,每一次模型请求都要能从会话日志重建。deriveMessages() 从日志投影出模型历史。每个 assistant/message 里嵌着产生它的那段完整流;assistant/attempt 保留失败、重试、取消、流错误的尝试,但这些不进模型历史。

fork、resume、transcript、遥测、持久化,全都从这些"结算"记录派生。反过来,如果进程在结算之前硬崩,就没有持久化的 attempt 流留下。

这条规则对插件开发有约束:想加新的模型可见输入,就必须定义新的会话事件;想改已有消息内容,得注册纯消息投影。绕过日志直接往请求里塞东西,等于破坏可重建性。

核心包

从架构文档的包表里挑几个关键的,能看清职责切分:

包 负责 context 键
core/session 追加式 SessionEvent 日志与内存存储 ctx.sessions
core/system-prompt 提示词分段与工具 schema 组装 ctx.systemPrompt
core/tools 带作用域的工具注册表与受控执行管线 ctx.tools
core/agent Agent 接口、活注册表、agent/* 事件 ctx.agents
core/agent-loop 实现该接口的默认驱动器 ctx.agentLoop
llm/llm 消息与流的词汇表,以及适配器接缝 ctx.llm

注意 agent 和 agent-loop 是分开的两个包:接口和默认实现解耦,所以主循环可以换一个实现,而依赖 ctx.agents 的其他插件不用动。

值不值得用

官方明说处于开发者预览、会有破坏性变更。判断标准其实很简单:

适合拿来看的,是想搞清楚 agent 框架怎么组织的人,或者准备基于它做深度二次开发的人。

不适合的,是想找一个稳定生产基座的人——至少在它退出预览之前。

最后说一个取舍。把 agent 主循环做成可替换插件,代价是理解成本:你得同时理解 profile、bundle、patch 三层,才能定位"这个行为是在哪一层被决定的"。收益是任何一层都能被替换,不会被框架锁死。

这个交易划不划算,取决于你到底要不要深度定制。如果只是想调个 prompt、换个模型,那这套架构对你是负担;如果你要改的是主循环里的调度逻辑,那它给的自由度是实打实的。