DeepSeek Harness(命令行叫 dsh)是 DeepSeek AI 开源的 agent harness,MIT,还在 developer preview 阶段,明说会有破坏性变更。它不是又一个「把 prompt 和工具拼起来跑一圈」的循环,而是把整个 agent 运行时——模型适配器、工具注册表、会话日志、连 agent loop 本身——全部做成插件,挂在一个叫 Cordis 的框架上。没有一个要打补丁的内核,任何一层都能从配置替换掉。

这篇不介绍怎么用它写 agent,而是拆它的架构:Cordis 的插件范式、启动时怎么把一棵插件树组合出来、会话日志为什么是唯一事实源、一个回合怎么执行、工具管线和能力接缝是怎么设计的。然后是我更想聊的那半边——它到底贡献了什么,跟 pi 那种「薄内核」路线差在哪一层,以及顺着这条线往远看,harness 自身的进化该由谁来做。

定位

装好 Node 一行就能跑:

sh
npx @deepseek-ai/dsh web

默认起一个 Web UI,http://127.0.0.1:3080。除了浏览器形态,还有一个无服务器的 headless 一次性执行形态。但这些都只是表层——dsh 真正的主张写在架构文档第一句:一切皆插件。模型适配器是插件,工具注册表是插件,会话日志是插件,驱动整个对话的 agent loop 也是插件。它们平等地挂在同一个上下文里,谁都不比谁特权。

这句话听起来像口号,但它有具体后果:你扩展 dsh 不是去改某个核心文件,而是在旁边再挂一个插件;你定制产品形态不是 fork 代码,而是换一份配置。要理解这一点,得先认识它的底座。

Cordis:底座的插件范式

Cordis 是 dsh 下面那层框架,dsh 把它 vendor 进了仓库。它的世界观可以压成五条:

插件就是一个实现了 Service 的对象——可以是带 injectapply(ctx) 的函数,也可以是 Service 子类,Cordis 负责把它的生命周期挂进当前上下文 上下文是服务的仓库——一个服务占一个稳定的 ctx.<key>,比如 ctx.toolsctx.llmctx.sessions;别的插件按 key 找服务,而不是 import 某个具体实现 依赖用 inject 声明——插件声明它需要哪些服务,就会一直等到那些服务就位才激活,加载顺序是「谁依赖谁」推出来的,不用手写启动序列 通信靠类型化事件——服务用 TypeScript 声明合并注册事件名,再按语义选 emit / waterfall / parallel / serial 派发 注册都是可逆 effect——prompt 段、工具 schema、适配器、监听器全走 ctx.effect()ctx.on() 注册,插件卸载或热重载时按相反顺序干净撤销

第四条的四种派发模式是事件契约的一部分:emit 只观察、不等待;waterfall 是环绕式中间件,监听器拿到 (...args, next),调 next() 把(可能改写过的)结果交给下一个,不调就短路;parallel 并行等所有监听器;serial 按序且有返回值。策略类事件天然用 waterfall 的短路——谁拥有这个决定,谁就不调 next() 直接返回;只做旁观或标注的监听器则必须委托下去。

这套东西的价值在下面每一节都会复现:它让「替换一个能力」这件事有了统一的机械动作,而不是每个子系统各搞一套。

Profile 与 Bundle:启动即组合

启动即组合出的插件树启动即组合出的一棵插件树:profile 叠 bundle 再叠 patch,产出 Cordis 上下文,核心 service 全是可替换插件

交互版,可缩放、可播放数据流轨迹。)

一个运行中的 dsh 是一棵插件树,这棵树是启动时按有序的几层组合出来的。

profile 是一份命名组合,存在 Harness 主目录里,列出它要叠哪些 bundle,也放用户自己的 cordis.patch.ymlwebheadless 是内置模板。bundle 则是一种分发格式,把一批 Cordis 配置行和它们挂载的代码打包在一起,好让它插入的东西仍然能被上面的层继续打补丁。

叠层顺序是固定的:先按 profile 列出的顺序叠每个 bundle,再叠 profile 自己的 patch,然后是主目录级的 patch,最后是命令行 --patch 覆盖。dsh-base 永远是第一层——模型适配器、工具、持久化、沙箱与审批策略、设置、凭证、遥测都在这里;dsh-web-app 再往上加浏览器应用,dsh-headless 则加一个无服务器的一次性执行器。一条 patch 按行 id 定位,替换那一行的整段配置,或者插入新行。

因为最终那棵树是层层叠加加补丁得来的,光看源码猜不准机器到底跑的是什么,所以它给了一条命令直接打印:

sh
dsh --profile web --dump-config

它印出来的任何一行,你都能用自己的 patch 替换掉。这就是「从配置替换任意一层」的字面意思——产品形态(浏览器版、一次性版、你自己的定制版)不是三份代码,而是三种叠法。

会话日志:唯一的事实源

ctx.sessions 拥有一份追加式的 SessionEvent 日志,它是模型能看到的那份上下文的来源。deriveMessages() 从日志里投影出模型历史;原始的 assistant/chunk 事件被保留下来,用于重放和 UI 还原。fork、resume、转写、遥测、持久化,全都从这一条流派生。

这里有一条被运行时不变量执法的铁律:模型可见即已记录。任何能进到一次模型请求里的东西,都必须能从日志重建出来。它的直接推论是——想让模型多看见一种新输入,你就得新增一种会话事件,然后从日志渲染它,而不能走某条绕过日志的旁路把内容塞进请求。

这条纪律听起来严苛,但它是上面「一切皆插件」能成立的前提:正因为所有模型可见状态都收敛到一条可重建的日志,fork 一个会话、重放一段历史、审计「模型到底看到过什么」才都是确定的,而不是散落在各个插件的内存里各说各话。

Turn 与 Step:一次对话的执行流

一个 turn 的执行流一个 turn 的执行流:认领输入后组装 prompt,模型往返与工具管线各走一趟,持久事件同步落日志,欠请求就再走一个 step

交互版

先分清两个词。step 是一次模型请求,加上这次请求触发的那些工具调用。turn 是零或多个 step:它在第一份输入被认领前开启,在无所亏欠时关闭。

一个回合的骨架是这样走的:认领 next-step 输入(外加一条排队消息)→ 组装 prompt 段和工具 schema → agent/pre-stepstep/start → 把认领的消息作为 user/message 追加 → 从日志推导模型历史 → agent/requestllm/streamassistant/chunk* 汇成 assistant/messagetool/call* 过工具管线 → tool/result*step/end。如果工具还欠一次请求,或者又有新输入到达,就再走一个 step;否则 agent/turn-stopping,然后 turn/end

其中 turn/*step/*user/messageassistant/*tool/* 是持久会话事件,落进那条日志;其余是三个域上的实时扩展点。这三个域是大多数改动要做的第一个决定:

会话事件——持久事实,追加进日志并通过 session/event 广播;当这个事实必须扛过一次重载时用它 agent/* 事件——携带一个在飞的 Agent:inbox、step、status、request、validation、continuation;想观察或拦截进行中的工作时用它 能力事件——把策略和适配器挂到某个接缝(fs/*tools/*telemetry/*)上,而不必 import 那个循环

agent/pre-step 决定模型看见什么:监听器可以改写认领到的消息,或者干脆拒绝;一个被拒绝、或首次认领就被改写成空的回合,仍然会关闭一个没花任何 step 的持久 turn——于是日志里留下了「尝试过」这条记录。

工具管线:作用域注册与守卫式执行

ctx.tools 是工具注册表加一条守卫式执行管线。它有两个值得单独说的设计。

一是作用域注册会遮蔽全局。一个工具可以注册成全局的(每个 agent 都看得见),也可以注册在某个 agent 的作用域里;同名时,作用域内的那个替换掉全局的孪生兄弟,只在这个作用域生效。这就是「按 agent 定制人格」和「按 agent 换工具变体」的机制。反过来还有 restrict:它对一个作用域继承到的全局工具集做过滤(allow 只留、deny 移除,多个限制取交集),而作用域自己注册的工具不受影响——一个被过滤掉的全局工具,在 prompt 里消失、执行时也拒绝,和不存在完全一样。

二是执行是一条可扩展的瀑布加一段单调策略ctx.tools.execute() 把一次调用依次送过:

tools/pre-execute   可重排的 allow / deny / ask 瀑布
      ↓
守卫(guard)        pre-execute 之后、工具体之前的单调策略
      ↓
tools/execute       环绕式包装器(超时、重试、度量)
      ↓
tools/post-execute  检查、替换或拦截结果
      ↓
finalizeContent     工具自己的最后一手内容变换
      ↓
tools/result        冻结的、无损 JSON 的最终结果

守卫这一段的设计很见功力:它的返回类型故意没有「放行」这个结果。返回 undefined 保持瀑布已有的决定,返回一个理由则只能收紧权限。于是监听器的顺序无法把一个拒绝翻回允许——后来的监听器救不回前面守卫拒掉的调用。这让「谁都可以更严,但没人能偷偷放宽」成为结构性保证,而不是靠约定。

还有一个克制的选择:pre-execute 只能 allow / deny / ask,不能改写参数。因为历史、审计、UI 和执行这四方必须对同一份参数达成一致,任何一方偷改都会让四者打架。想隐藏某个结果值,就 block 或替换,而不是篡改入参。

能力接缝:一次替换搬动整片能力

前面反复出现的「换一个 provider 就换掉整片能力」,靠的是**能力接缝(capability seam)**这个抽象。一个接缝是一个可替换的能力,恰好由三个角色组成:

服务定义——声明接口、拥有那个 ctx.<key> 和词汇类型的 Cordis Service 服务提供方——一个或多个具体实现 消费方——注入这个服务来用它的人,通常是一个面向模型的工具

shell 是标准例子:dsh-shell 是服务定义,dsh-bash-localdsh-bash-sandbox 是两个提供方,dsh-tool-bash 是消费方。三个角色通常分居不同的包,因为它们各自独立演化;但当它们其实是同一件事时也可以合在一个包里(dsh-llm 就自己拥有服务定义和消费方)。关键在于:接缝是完整的那个能力,绝不是单个角色——加一个能力,意味着把三个角色一起设计出来。

这个抽象的回报是「一次替换搬动整片能力」。文件系统和子进程两个提供方共享同一个执行世界,所以把它们指向一个远程沙箱,Bash、PTY、LSP 会跟着一起搬过去,没有任何 provider 需要分叉。subagent 的提供方也一样,在同一个接口背后差异极大——从一个全新的子 agent,到把一个回合委托给另一个产品。

Code Mode 与进程沙箱

有两处能力值得单独点出,因为它们展示了接缝抽象能容纳多大的差异。

Code Mode 是代码执行接缝(ctx.codeRuntime)。它让模型不再一次发一个工具调用,而是写一段程序:程序体是一个 async 函数,可以用顶层 awaitreturn,宿主把一组异步绑定注入进去,跑完把它打印的和返回的报告回来。它是一个可选能力,不在 agent loop 的主干上。它的类型契约里藏着一个安全细节:在 mode: 'code' 下,只有带父 token 的调用(也就是程序内部通过 SDK 发起的子调用)才能执行原生工具名;一个模型直接发的调用(没有父)在进策略管线之前就被拒成 UNKNOWN_TOOL。换句话说,开了 Code Mode,模型就只能通过写程序来用工具,不能再绕过去直接点名调用。

进程沙箱ctx.sandbox)则把一个同世界子进程的 argv 包进一层文件效果策略。本地实现覆盖了 Linux 的 bwrap/Landlock、macOS 的 Seatbelt、Windows 的 ACL 受限令牌三套后端。它的模式只管文件效果:read-onlyworkspace-writedanger-full-access(网络和进程可见性不在这个词汇表里)。而且它诚实地报告执法完整度——full 表示后端管住了这个模式承诺的每一项文件效果,partial 表示只管住了一部分(老的 Landlock ABI、Windows ACL 的若干边界就是当前的 partial 情形)。需要绝对边界的消费方必须把 partial 当回事,不能拿它当 full 用。

贡献点

插件化不是新词,agent harness 也已经有一堆。把 dsh 和别人拉开距离的,是下面这几件它真正推进了的事——我认为其中至少三条值得别的项目直接抄走。

把「无特权内核」做到了字面意义。 大多数号称插件化的系统,都还留着一个不肯拆的核心:也许工具可插拔,但 agent loop 是写死的;也许模型可换,但会话怎么存是框架说了算。dsh 把这条线推到底——loop 本身也是一个插件,会话日志也是。这个差别不是程度上的,是性质上的:留一个核心,你的扩展就永远是「围绕它」;不留核心,扩展和核心平级。

把产品形态变成了配置的叠法。 profile 列 bundle、bundle 装配置行、patch 按 id 覆盖,三层叠出一棵树。于是「浏览器版」和「一次性执行版」不是两份代码分支,而是两种叠法。更关键的是 --dump-config 这个配套设计:既然最终形态是叠出来的,它就诚实地提供了一条命令让你看见叠的结果,而不是让你去源码里猜。承认自己不透明,并给出观察手段,这比假装透明要诚实得多。

把一条纪律升格成了运行时不变量。 「模型可见即已记录」在别处通常是一句写在贡献指南里的建议,靠 review 拦;dsh 把它做成运行时执法的不变量——你绕过日志往请求里塞东西,它当场报错。这是我在这个项目里最欣赏的一处:它把「大家应该遵守」变成了「你做不到违反」。 代价是每加一种模型可见输入就要新增一种会话事件,但换来的 replay、fork、审计是白拿的。

把权限的单调性写进了类型。 工具守卫的返回类型故意没有「放行」这个分支,于是「监听器顺序不能把拒绝翻回允许」不是靠约定,而是编译期就成立。安全策略最怕的就是「某个后注册的插件把前面的限制放宽了」,这个设计从根上掐掉了这类 bug。

把接缝规范成了三角色。 服务定义 / 提供方 / 消费方,缺一不算接缝。这条规矩看着教条,但它让「换一个 provider 搬动整片能力」变得可复制——文件系统和子进程共享执行世界,所以指向远程沙箱能一次带走 Bash、PTY、LSP。没有这套规范,同样的效果得靠每个能力各自的巧劲。

给 Code Mode 加了封闭性。 开启 mode: 'code' 后,模型直接点名调用原生工具会在进策略管线前就被拒成 UNKNOWN_TOOL——只有程序内部发起的子调用才算数。很多实现只是「多提供一个写代码的工具」,模型想绕就绕;dsh 让这个模式真正封闭。有封闭性,Code Mode 才谈得上是一种可以据以做策略的执行模型。

与 pi 的对比:薄内核与无内核

可替换面的两种深度可替换面的两种深度,以及扩展权归属:薄内核把扩展权留给开发者,无内核把它交给启动时的组合层,自进化是另一条路

交互版

聊插件化 agent,绕不开 pi。它和 dsh 表面上说的是同一句话——核心要小,功能推给外部——但两者的深度完全不在一层。

pi 的出发点是对「工具越做越重」的反动:内置工具只有 read write edit bash grep find ls 七个,没有内置 MCP、子代理、权限弹窗、计划模式、待办、后台 bash。这些统统推给 extension、skill、prompt template 和 package。它的 extension API 也相当能打,可以注册工具、斜杠命令、快捷键、provider,还能挂几十个生命周期事件——拦 tool_call、改系统提示、截输入。

但注意 pi 留下了什么:agent loop、会话模型、工具分发这些东西是固定的。 你的扩展再强,也是绕着一个不变的内核长出来的。这是一个非常合理的工程立场——保留一个稳定内核,才有稳定的扩展契约。

dsh 走的是另一步:连那个内核也拆了。 这就是我说的深度不同——pi 是「核心不变,外部可插拔」,dsh 是「没有不可变的东西」。

顺着往下想一层,会看到一个更有意思的差别:扩展权交给了谁。

pi 的扩展权在开发者手里——人写 TypeScript extension,社区打包分发,生态活不活取决于有多少人在写。dsh 把扩展权放在了启动时的组合层:换一份配置就是换一棵插件树,而配置是运行时自己就能读写的东西。

于是有了一个推断——这是我的推测,不是 dsh 文档里的承诺:它在结构上具备 Prime Agent 那种自进化的形状。agent 探到自己的能力边界,写一个插件、往组合里挂一行,下次启动那棵树就变了。pi 里做同样的事要人来写扩展并合进社区仓库,dsh 里理论上是一次配置写入。

要说清楚的是,dsh 目前并没有做这件事,也没有 Prime 那套配套机制。Prime 的自进化走的是完全不同的一条路:它不去加大可替换面,而是让 agent 复盘一次轨迹后,去改一层补充状态——提示、记忆、可复用技能、子 agent 规格;每次改动留快照可回滚,基础 system prompt 永远不动。也就是说,进化被严格关在一个可审查、可撤销的隔间里。

这个对比让我看清了一件事:可替换面越大,越需要 Prime 那种可回滚与可审查。 dsh 现在的可替换面比谁都大,但它的插件树是给人配置的——一旦真让 agent 自己往里写东西,缺的就不是能力,而是「改了什么、能不能撤、怎么验」这三样。能力和护栏得配套,谁先谁后不重要,缺一个就是隐患。

harness 自身的进化

再往远看一层。

pi 的插件现在靠社区维护,而社区里的插件——说实话——后面基本也都是 agent 生成的了。人在这条链上的位置正在退:从「写插件」退到「提需求、审 diff」,再往后可能只剩「出问题时来看一眼」。如果人为参与继续减少,最终形态大概是 agent 自己判断需要什么、自己实现、自己挂上去、自己用。扩展这件事重新回归 agent 自身,我觉得是个挺清楚的趋势。

插件化是当下最流行的答案,自进化则比它看得更远一点。但如果再往远推一步,会碰到一个更根本的东西:harness 本身的进化,现在仍然完全依赖人。

从 prompt 工程,到 MCP、工具调用,再到现在的插件化——每一代都是人总结出来的方法论,而每一种方法论或多或少都带着它那个时代的局限。今天我们认为「一切皆插件」是对的,就像三年前认为「把规则写进 prompt」是对的一样。真正的瓶颈不在某一代方法论好不好,而在于换代的速度受制于人的认知节奏

所以更远的那个问题是:harness 自身的演化,能不能也交给 AI?不是让它写一个插件,而是让它去评估、质疑、重构这套方法论本身——发现「插件」这个抽象在某个场景下就是不对的,然后提出下一个抽象。

这件事今天当然还做不到,而且它缺的东西相当具体:得先有可验证的评测。自动改插件还能靠 diff 和回滚兜住,自动改方法论则很难说清「改好了」是什么意思——没有靠得住的度量,自进化和随机漂移在外部看来是一样的。Prime 那套「小步、有据、留快照、基础底稿不动」的克制,可能正是这个方向上唯一站得住的起点。

从这个角度回看 dsh,它的价值就不只是「一个插件化做得很彻底的 harness」了。它把「什么可以被替换」这条线推到了尽头,等于替后来者先把地基打好——真到了 agent 自己改自己的那天,一个没有不可变内核的运行时,会比一个留着硬核的运行时好改得多。

代价与风险

好处讲清楚,代价也得讲清楚,不然不可信。

  • 它明说自己会破坏兼容。 developer preview、快速迭代、破坏性变更写在 README 最显眼处。现在就把生产依赖压上去,要有跟着版本改的准备。
  • 认知门槛实打实。 贡献之前你得先学会 Cordis:上下文、服务、inject、四种派发模式、瀑布的 next() 语义、作用域的遮蔽规则。这不是读几个函数就懂的,得先建立一套心智模型。
  • 「一切皆插件」的另一面是间接。 没有特权内核的代价是——想搞清楚某个行为从哪来,你追的不是一条调用栈,而是一棵组合出来的插件树。--dump-config 这条命令之所以存在,正是因为「机器到底跑的是什么」不看一眼就说不清。
  • 那条铁律是有摩擦的纪律。 「模型可见即已记录」意味着每加一种模型可见输入,就要新增一种会话事件、并从日志渲染它。它买来的是可重建性,付出的是每次扩展都要走这道手续。
  • 接缝的最小成本是三个角色。 加一个能力不是写一个类,而是设计服务定义、提供方、消费方三件事。对小改动,这套规矩显得重;它的回报要到「需要第二个 provider」时才兑现。

尾声

dsh 最值得学的地方,不是某个具体功能,而是它把一个决定推到了极致:没有内核,一切皆插件,组合发生在启动时。 大多数框架会保留一个「核心」,再在核心周围留扩展点;dsh 干脆连 agent loop 都做成可替换的插件,把「特权」这个概念从架构里删掉了。

这个选择不是免费的——它把认知成本前置,把「看懂系统」变成「看懂一棵组合出来的树」。但它换回的自由度也是真的:产品形态是一种叠法,定制能力是换一个 provider,观察行为是订阅一个事件。对一个明说自己会频繁破坏兼容、需要快速演化的项目来说,这种「任何一层都能替换」的结构,可能正是它敢于快速迭代的底气。

我自己的判断是:dsh 的这套设计,短期看是给人用的——给愿意学 Cordis、愿意自己叠一棵树的开发者用。但它真正的价值可能要更晚才兑现。当扩展这件事逐渐从人手里回到 agent 手里,「哪一层能被改」就会从一个工程口味问题,变成一个能力上限问题。到那时候,今天这份把可替换面推到尽头的执拗,会显得很有先见之明。

代码在 deepseek-ai/deepseek-harness,MIT。架构文档本身写得相当克制、准确,值得直接读一遍,比这篇更权威。