
大家好,我是若风。
8 月 13 号,DeepSeek 开源了一个叫 deepseek-harness(命令行简称 dsh)的项目,一天冲到三万多 star。点进去你会发现一件怪事,README 总共才一千七百来字,没截图,没功能列表,连「这到底是个啥」都没讲明白,只甩出一句话,Everything is a Plugin。

明星数是 DeepSeek 这块招牌自带的。但若风看开源项目有个习惯,star 数会骗人,架构不会。这个项目真正值得花时间的地方,不在它一天涨了多少 star,而在它押的那个赌注,把 agent 框架里所有写死的部分,连「跑模型的那层循环」自己,都做成可以随时替换的插件。
这篇就来拆一下,这个赌注到底押得值不值。
先说清楚,它想解决什么问题
dsh 是一个 agent harness,直白讲就是一个给编码 agent 当底座的运行时。你装上 Node,跑一行 npx @deepseek-ai/dsh web,它就起一个本地 Web 界面,里面是可以调工具、读写文件、跟你来回对话的 agent。
这类东西今年太多了。Claude Code 是一个,OpenAI 的 Codex CLI 是一个,Cline、Aider、Cursor 各占一块。它们有一个共同点,核心循环是写死的。模型怎么调、工具怎么注册、会话怎么存、提示词怎么拼,这些决策被焊死在产品里,你想换一个沙箱后端、想塞一个自定义的拦截器,往往得 fork 源码去改。
dsh 的反例就在它的架构文档里写得很直白,There is no privileged core to patch,没有需要你去 patch 的特权核心。模型适配器是插件,工具注册表是插件,会话日志是插件,连驱动整个对话的那层 agent 循环,本身也是一个插件。
你想想看,这个差别有多大。
我专门去翻了 packages/core 的代码结构。agent-loop 这个包在文档里被定义成「the one concrete implementation of the public Agent contract」,也就是公开 Agent 接口的「唯一具体实现」。注意这个措辞,它是「一个实现」,不是「那个实现」。文档紧接着强调,所有扩展插件依赖的是 agent(接口包),从不直接依赖 agent-loop,所以这个循环始终是可替换的。
这不是 PPT 话术,是源码层面的契约。
先看整体,五层长这样
在钻进细节之前,先给你一张全景图。我把 dsh 的源码结构归成了五层,从上往下一步步往下拆。

最上面是用户进来的入口和配置组合层,中间是被高亮的核心 agent 循环和它依赖的能力缝隙,最底下是托起这一切的 Cordis 插件脊柱。这张图里每个卡片标的文件名和函数名,都是我从源码里读出来的,不是凭目录名猜的。后面几节就按这五层往下说。
Cordis,从一个聊天机器人框架借来的脊柱
把所有东西都做成插件,这话听着轻巧,真做起来第一个要回答的问题是,插件之间怎么拼、怎么拆、拆了之后会不会留下一地鸡毛。dsh 没有自己从零造这套机制,它用了一个叫 Cordis 的框架。
有意思的是 Cordis 的来历。我翻了 vendor/README.md,dsh 把 Cordis 连同它的一堆基础库「vendored」进了自己的 monorepo,也就是把上游源码整个拷进来,改名成 @deepseek-ai 作用域,锁死版本,而不是走 npm 依赖。上游指向 cordiverse/cordis。
熟悉前端机器人的朋友可能已经反应过来了。Cordis 是 Koishi 那套跨平台聊天机器人框架的新一代内核。Koishi 在国内机器人圈子里流行了很多年,作者把多年处理「插件热插拔、运行时组合」的经验沉淀成了 Cordis,还配套写了一篇论文,《A Programming Paradigm for Spatiotemporal Composability》。
这个背景很关键。dsh 等于把一个在机器人生态里打磨了很久的插件框架,搬过来当 agent 的脊柱。它不是凭空冒出来的实验品,地基是踩过坑的。
更关键的是 DeepSeek 没有只搬运,它还动过手。vendor/README.md 的「Local modifications」清单第 6 条写得很细,他们给 cordis/src/fiber.ts 的生命周期加了硬化处理,堵上了三个「reentrant disposal gaps」,可重入的回收漏洞。具体来说,effect 的 owner-list 包装在 setup body 之前就注册好,这样从 setup 内部发起的卸载会等 setup 和所有清理都跑完;异步清理一直对 owner 可见直到静止;effect 创建在 owner 处于 UNLOADING 状态时会被拒绝,防止清理阶段的注册逃逸出卸载快照。
这段你看不太懂没关系,记住一个结论就行,可逆效果这套机制最难的就是卸载时的边界情况,DeepSeek 是真去啃了这块硬骨头,不是拿来主义。
可逆效果,整个设计的灵魂
Cordis 最核心的一个 idea,文档原话叫 Registrations are reversible effects,注册即效果,效果皆可逆。
什么意思呢。你在一个插件里注册了一个工具、一段提示词、一个事件监听,这些动作在 Cordis 里不是「执行完就完了」,而是通过 ctx.effect() 或 ctx.on() 包成带「反操作」的效果。当这个插件被卸载(不管是因为配置改动、热重载、还是它依赖的服务没了),这些注册会按相反的顺序干净地回滚,不留下孤儿监听器,不留下泄漏的定时器。
教程里有一个最直白的例子。注册一个心跳定时器要这么写:
ts
function heartbeat(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => {
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}
ctx.effect() 接收一个 setup 函数,返回一个 disposer。Cordis 保证,这个插件卸载的时候,disposer 一定会被调用。于是「装一个插件」和「卸一个插件」是对称的,注册什么,就回收什么。
这件事为什么重要。因为 agent 框架天生就是「高度动态」的。你想给某一次会话临时换一套工具,想在某个 agent 上挂一个一次性的拦截器,想在运行时热替换模型适配器,这些场景在写死核心的框架里都很难做干净,要么得手动管理一堆 removeListener,要么干脆不允许运行时改。可逆效果把这件事变成了框架的内置能力,插件作者只要按规矩注册,框架替你兜底回收。
说真的,这是我看完全文最服气的一点。它不是某个 clever trick,是一种贯彻到底的纪律。
四种事件分发,waterfall 才是拦截的关键
插件之间要通信,Cordis 用的是带类型的 event。但它不止一种 emit,文档里明确列了四种分发模式,而且分发模式是事件公开契约的一部分。
| 模式 | 是否 await | 顺序 | 有返回值 |
|---|---|---|---|
emit |
否 | 按注册顺序观察 | 否 |
waterfall |
否 | 按注册顺序 | 是 |
parallel |
是 | 所有监听器并行 | 否 |
serial |
是 | 按注册顺序 | 是 |
四种模式对应四种意图,观察、包裹、扇出、顺序执行。这个分类很清楚,我挺喜欢。
其中最关键的是 waterfall。它其实就是 around-middleware,也就是「环绕式中间件」。一个监听器收到 (...args, next),调用 next() 就把(可能被改写过的)结果交给下一个,直接 return 不调 next() 就短路掉整条链。
这个语义在 agent 框架里太好用了。文档里的 turn 流程图就大量用了 waterfall。一轮对话是这样跑的:
text
turn/start
领取下一步输入和排队消息
组装提示词段落 + 工具 schema
-> agent/pre-step reject | enter(messages) ← waterfall
step/start
agent/request -> llm/stream -> assistant/chunk* -> assistant/message
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
step/end
-> agent/turn-stopping ← serial,无 next
turn/end
你看那个 agent/pre-step,它是个 waterfall,监听器可以改写模型这一步看到的消息,甚至直接 reject 掉。这意味着「模型能看到什么」这件事,是可以被任意插件拦截和修改的,而且改完调 next() 就继续,不用动循环本身的代码。
tools/pre-execute、tools/execute、tools/post-execute 三个连起来是一套工具执行管线,前中后三个口子全留给你插策略。想加权限校验挂 pre,想改结果挂 post。这种「把拦截点设计成事件」的思路,和「写死核心」的框架完全是两个物种。
会话日志是唯一的真相
很多 agent 框架的会话存储是「存对话消息」,消息列表就是历史。dsh 不这么干,它的会话是一个 append-only(只追加)的事件日志,类型都声明在 SessionEventMap 里,消息历史是从这个日志「投影」出来的,从不单独存。
文档反复强调一条运行时不变量,Model-visible means logged,凡是对模型可见的东西,都必须能从日志里重建出来。而且这不是写给人看的约束,是代码里有运行时断言在检查的。deriveMessages() 这个函数专门负责从日志投影出模型历史,连原始的 assistant/chunk 流式分片都保留,所以重放、UI 回放、fork 分支,全都从这一条日志流推导。
这个设计有个很实在的好处。你想 fork 一个会话、想恢复到某个节点、想做上下文压缩(compaction),都不用发明新的存储格式,它们都是「对同一条日志的不同投影」。文档里也说了,扩展新的「模型可见输入」需要扩展 SessionEventMap 然后从日志渲染,不是随便塞个字段进 message 就行。
坦白讲,事件溯源(event sourcing)这个词在后端圈被讲烂了,但真把它用在 agent 会话上、并且用运行时断言保证「可见即已记录」的,dsh 是我见过最彻底的一个。
能力缝隙,换一个 provider 整个产品都变
「全插件」听起来像在堆抽象,但它有一个非常实际的落点,文档里叫 capability seam(能力缝隙)。
一个 seam 由三个角色组成,Service Definition(声明接口)、Service Provider(实现)、Consumer(消费,通常是模型可见的工具)。文档有句话特别到位,Seams are why one provider swap changes the whole product,缝隙就是为什么换一个 provider 能改变整个产品。
举个例子,文件系统和子进程这两个能力共享同一个「执行世界」。所以你把它们的 provider 指向一个远程沙箱,Bash、PTY、LSP 这些工具会跟着一起搬到远程,不需要为每个工具单独 fork 一套。子 agent 的 provider 同理,一个接口背后可以是从零起一个子 agent,也可以是把这一轮委托给另一个产品。
这个设计意图其实挺野的。它赌的是,未来 agent 的能力边界会不断变,今天用本地 shell,明天可能全跑在沙箱里、跑在浏览器里、跑在别的 agent 里。把这些「能力」抽象成可替换的缝隙,比把工具一个个焊死要活得久。
Profile 和 Bundle,配置即组合
最后说一下普通人怎么用上这套机制,不用写代码。
dsh 的启动本质是从一份 cordis.yml 组合出一棵插件树。它把组合单元分成两层概念。Profile(配置档案)是一组 bundle 的有序叠加,存在 Harness 的 home 目录里;Bundle(捆)是一个 Cordis 配置行加它挂载代码的分发格式。每个包在自己的 package.json 里用 dsh 字段声明自己,dsh.profile 列出一个 profile 叠了哪些 bundle,dsh.bundle 指向 bundle 的 patch 文件。
层叠顺序是固定的,profile 列的 bundle 顺序、profile 自己的 cordis.patch.yml、home 级的 patch、最后是 --patch 覆盖。任何一个配置行都能被上层 patch 替换。想看你机器上真实启动的插件树,直接:
sh
dsh --profile web --dump-config
它打印出来的每一行,你都能用自己的 patch 替换掉。
我翻了 apps/cli/src/plugin.ts,发现 dsh plugin 这个命令本质是个「thin pnpm forwarder」,一个薄薄的 pnpm 转发层。你跑 dsh plugin --profile xxx add some-pkg,它说到底就是在 profile 目录里跑 pnpm 装包,然后用 exportsPatch() 这个函数去读包的 manifest,检查它有没有声明 dsh.bundle.patch。声明了就自动加进 layer 栈,没声明就当普通依赖装着,还会贴心地警告一句。这套「插件管理 = 包管理」的设计,对前端开发者很友好,因为你用的就是熟悉的 pnpm。
该说点不好听的了
夸了这么多,按若风的习惯,必须把边界讲清楚,不然这文章就成了软文。
其实所有的问题,根子都在一件事上,这个仓库 8 月 13 号才公开,版本号是 0.1.0-rc.5。README 第一段就摆明态度,「developer preview」「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。三万多 star 几乎全是 DeepSeek 招牌自带的流量,跟项目成熟度没半点关系。我翻了 issue 区,开放 issue 是零,不是因为它没 bug,是第三方还没来得及踩坑。真要拿到生产里跑,先做好接口随时被改穿的心理准备。
这种「嫩」最直接的体现,是它对新人几乎不设防。我下载了 README 数了一下,1711 个字符,通篇假设你已经知道 agent harness 是什么、Cordis 是什么。没一句解释这产品能干嘛,没一张截图,没一个使用场景。一个三万星的项目,新人点进来大概率第一屏就看懵了。这种「写给懂的人看」的文档风格,对一个想建生态的开源项目来说,老实讲是减分的。
门槛也得提前讲明白。底座是 TypeScript + pnpm monorepo,根 package.json 钉死了 pnpm@11.7.0,Node 要 ^22.19.0 || >=24.0.0,插件管理全靠 pnpm。你团队里要是没几个熟前端的,上手成本会比用 Claude Code 这种开箱即用的产品高一个量级。
话说回来,比门槛更要命的是生态。dsh-plugin 这个 topic 下,到现在还没几个像样的第三方插件。一个「全插件」架构的价值,和它的插件规模强绑死,现在架构再优雅,没插件可换,可替换性就还停留在理论里。这是标准的先有鸡还是先有蛋,得看 DeepSeek 后续愿不愿意持续投人养社区。
这个赌注,谁该下
拆完之后,我的判断是这样。
dsh 真正贡献的不是又一个编码 agent,而是一种可以命名的方法论,我愿意叫它「Reversible Plugin Spine」,可逆插件脊柱。它的核心信念是,一个系统的可演化性,取决于它「最不可替换的那一层」有多薄。大多数 agent 框架把循环、会话、工具管线焊死成厚厚一层,dsh 把这一层的厚度压缩到「只剩一个接口契约」,剩下全是可逆的插件。
这种思路不是没有代价。它换来了极致的可替换性,代价是陡峭的学习曲线、抽象带来的认知负担,以及一个还在 rc 阶段、随时可能改接口的早期状态。
所以选型上我的建议很明确。如果你是做 agent 基建、想搭一个自己能完全掌控、随时替换模型和沙箱的内部平台,dsh 值得你认真读一遍它的架构文档和 Cordis tutorial,这套「可逆效果 + 能力缝隙」的设计是能直接搬进自己系统的。如果你只是想找个好用的编码助手写日常代码,现在这个阶段,老老实实用 Claude Code、Codex CLI 这类成熟产品更省心,别为了一个漂亮的架构赌上团队的效率。
说到底,DeepSeek 这次开源,明星数会回落,接口会变,第三方插件还没长出来。但它押的那个「连循环都是插件」的方向,我觉得是对的。agent 这个形态还在快速变形,今天觉得理所当然的核心循环,明天可能就是瓶颈。把脊柱做薄、把变化做成可逆效果,这是面向不确定性最老实的工程姿态。
Cordis 从机器人生态一路长到 agent 生态,这件事本身也挺好玩。它说明「插件热插拔」「运行时组合」这套问题,跨领域是相通的。谁说做 agent 就只能盯着 agent 圈子的解法呢。