everything is a plugin —— 这句话什么都没说。

Claude Code 有插件系统,Codex 有,Hermes 也有。所以当 DeepSeek 开源的 agent harness(下称 dsh)把这句话写在架构主张的第一行时,它其实还没有区分出任何东西。

真正把它和其他几家分开的,是两个更具体的承诺。这篇文章想讲清楚这两个承诺是什么、它们逼出了哪些反直觉的设计,以及有哪几处 dsh 自己也还没兑现。

时效声明:本文基于 deepseek-ai/deepseek-harness 的 47f9438 这一个 commit。上游处于 developer preview,README 明示 THERE WILL BE COMPATIBILITY-BREAKING CHANGES。读到本文时具体行号大概率已经漂移,请以仓库为准。

先给个体量概念:TypeScript pnpm monorepo,219 个插件分布在 49 个 package group,底下的插件框架 Cordis 以 vendor 形式钉在仓库里(约 2700 行)。


1. 口号区分不了什么

判断一个 harness 的插件系统有多认真,不要看它「能插什么」,要看它什么不能是插件——也就是特权核心的边界划在哪一层。

三台锁着核心的机器,与一块没有核心、全部模块可热插拔的网格

插件能覆盖到什么不能替换
Claude Codecommands · hooks · agents · skills循环 · 模型客户端 · 会话存储(闭源二进制,推断)
Codex工具 · MCP 路由 · 可发现的应用循环 · 压缩 · 模型客户端(编译进 Rust)
Hermesprovider profile · memory · context_engine · observability基础 adapter · tool_executor · context_compressor(Python 核心包)
dsh全部,包含 agent loop 本身无

几处细节值得单独点出来:

Codex 确实有 core-plugins/ crate,但读它的模块名就知道定位:loader、discoverable、installed_marketplaces、app_mcp_routing——这是一套插件分发与路由机制。循环本身、压缩逻辑、模型客户端仍然是 codex-rs/core/ 下编译进二进制的 Rust 模块。

Hermes 走得更深,plugins/ 下有 30 个 model provider、memory、context_engine。但拆开一个 provider 插件看,它是一份 plugin.yaml 加一个覆写基类方法的 profile,而 anthropic_adapter.py、bedrock_adapter.py 这些基础 adapter 仍在 agent/ 核心包里。插件在这里是核心之上的 profile 层,不是核心本身。

Claude Code 那一栏是推断,需要说明:它闭源,公开仓库只有插件与示例,所以关于核心的判断是从扩展面反推的。可观察的事实是:plugin.json 只有 name / version / description / author,没有能力或服务声明;官方插件的组件全是 commands、hooks、agents、skills 四类。它扩展的是「agent 做什么」,不是「agent 由什么构成」。

而在 dsh 里,dsh-llm、dsh-session、dsh-agent、dsh-tools、dsh-agent-loop 是同一个 YAML 列表里的平级条目,彼此没有层级差。

那么,dsh 靠什么撑住「零特权核心」?


2. 承诺一:结构的真相源是注册表

判据只有一句话:

看它有没有撤销不掉的注册。

任何「注册了就拿不下来」的东西——一个全局 handler、一个 map 里的条目、一个事件监听——都会成为事实上的内核。因为替换它的代价,从改一行配置变成了重启进程。只要有一个,插件系统就只是围绕内核的装饰。

ctx.effect() 是唯一原语

Cordis 的回答是把「注册」这件事本身统一成一个原语 ctx.effect():执行副作用、收集 disposer、随所属 fiber 逆序撤销。

关键在于没有特例。ctx.on() 不是绕开它的旁路,它的实现体就是一次 effect:

1
2
3
4
5
6
7
8
// vendor/cordis/src/events.ts:254-260
register(label, hooks, callback, options) {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
hooks[method]({ ctx: this.ctx, callback, ...options })
return () => this.unregister(hooks, callback)
}, label)
}

同理,Service 基类的构造函数直接调 ctx.reflect.provide(...),服务注册也随 fiber 自动注销。框架里不存在「注册了但没法撤销」的东西。

于是 agent loop 就是两行 YAML

这条约束一旦成立,那句口号才有了可验证的内容。agent loop 在 base bundle 里长这样:

1
2
3
# packages/bundle/base/cordis.patch.yml:436-437
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'

换掉整个 agent 循环,就是在用户层 patch 里针对 agent-loop 这个 id 写一行替换。和换掉一个工具是同一件事。

一个不那么显眼的推论

219 个插件,靠人排启动顺序不可行——任何一个插件因为依赖的服务消失而退回等待,手排的序列就立刻失效。所以 dsh 的加载顺序由 inject 声明的依赖关系表达,配置文件的行顺序明确不承载语义。这一点直接写在 base bundle 的注释里:

Row order carries no load semantics (activation is service-availability driven); the grouping is for readers.

启动顺序的唯一真相是依赖声明,不存在第二份人排的顺序。 记住这个句式,下一节会看到同一个思路的另一半。


3. 承诺二:行为的真相源是会话日志

这一条口号里没提,但同样关键。

不存在一个可变的 messages 数组

多数 harness 里,「UI 显示的历史」「发给模型的历史」「落盘的历史」是三份需要手工同步的数据,漏改一处就是一类难复现的 bug。

dsh 把它反过来:日志是账本,上下文是报表。

append-only 的账本只能在末行追加,旧行锁定;模型视图是从账本投影出来的报表

写入侧只有 session.append(type, data, opts) 一个入口,读出侧只有 session.deriveMessages() 一条投影路径——后者是对 append-only 日志的一次纯函数投影。「修改上下文」这个动作不再存在,它被统一成:追加一个事件,并在事件上声明它如何进入模型可见的 surface(追加到尾部,或遮蔽一段既有区间)。

44 种会话事件里,只有 3 种会进入 surface(user/message、assistant/message、tool/result),其余 41 种是 log-only——它们是「留档但不进模型」的记忆。

这条主张没有停在文档层面

真正值得注意的是下面这个动作。dsh 在模型调用的出口挂了一个前置监听器,在请求发出之前,把即将发出的 messages 与当场重新投影出的结果做比较:

1
2
3
4
// packages/core/agent-loop/src/invariant.ts:39-52(示意)
if (JSON.stringify(options.messages) !== JSON.stringify(session.deriveMessages())) {
fail(`llm request diverges from the dispatch-time durable derivation`)
}

不是抽样,不是结构相似,是 JSON 逐字全等。

它把「谁不小心绕过了日志」从一个 code review 问题,降级成了一次崩溃。这是整套设计里最值得学的一个动作:把架构原则变成一句运行期断言。

(这个断言实际有没有在线上开着,见第 6 节——答案会让你意外。)

顺带免费拿到的东西

因为模型视图是日志的函数,fork / resume / replay 就不需要三套机制了:

  • resume = 用整份日志做 seed
  • fork = 用一个前缀做 seed
  • replay = 同一条构造器路径

它们是同一件事的三种用法。想恢复或追查,就是把日志重新投影一遍。


4. 一条 query 的完整旅程

两条承诺讲完,把它们放回一条真实路径里看。下面是一次用户输入从进入到 turn 关闭的全程:

这张图上有四个点值得停一下:

① 所有输入只有一个队列。 用户消息、steering、工具追加的上下文、注入的运行时快照——全部走 inbox。因为只要有一种输入绕开它,「输入层面欠不欠」就无法判定。而三个入口(followup / steer / inject)的差别只有两个位:投哪个队列、唤不唤醒。注入的上下文只入队不唤醒,在队列里等下一次真实输入捎带上——一个布尔位的差别,省掉大量无谓的模型调用。

② 准入只有一个决策点。 agent/pre-step 是唯一的闸门,listener 要么 reject 整个 step,要么重写进入 step 的消息集合。代价是这个扩展点语义很重,回报是**「谁拦了这一步」永远有唯一答案**。

③ 被拒绝的 turn 仍然成对闭合。 一个没执行任何 step 的 turn,在日志里依然写完整的 turn/start + turn/end{blocked}。这让「尝试过但被拦下」成为可查询的事实,而不是日志里的一段空白。子 agent 的终止原因就直接从这里推导。

④ 循环只有一个终止判据。 nothing is owed:模型层面不欠(没有未完成的工具调用)且输入层面不欠(inbox 为空),两个条件同时成立才关 turn。常见做法是在循环里堆条件分支,每加一个需求就多一个 flag,几轮之后没人能回答「这个 turn 为什么停了」。dsh 把其余需求全部化归到这一条上。

工具管线那一段

图里被压成一个方框的工具管线,值得单独展开,因为权限模型的两个判断都在这里。

第一个:端到端不存在 allow-always,唯一的许可值是「本次允许」。

这不是保守,而是承认一个经验事实——只要「永久允许」这个选项存在,用户就会在第一次被打断时选它,此后整个权限系统等于不存在。所以 dsh 把安全性主要交给沙箱(默认就在里面跑),审批只保留给「模型撞到沙箱边界后显式升权」这一件事。

第二个:guard 只能拒绝,没有 allow 返回值。

waterfall 的语义是「谁先返回谁说了算」,这在策略叠加时很危险——一个 hook 的 allow 可能覆盖另一方的拒绝。把不可翻转的策略放进只能拒绝的 guard,安全性就不再依赖监听器的注册顺序。

代价也很实在:Claude Code hook 协议里的 permissionDecision: allow 在 dsh 上被解析、但不生效。为消灭一类漏洞,牺牲了与外部生态的一处语义兼容。


5. 一组「刻意不做」的决定

dsh 的特色有一半在「不做的事」上。而且每条守护的性质都能指名道姓,正好分成两组。

第一组:守真相源的唯一性——不留第二份会漂移的记录。

刻意不做换来什么
不做自动记忆:无 memory 工具、无后台总结器、无向量库会话行为不依赖真相源之外的隐藏累积输入
重试不藏进 HTTP 客户端每次重试都是日志里可审计的事件,UI 能如实显示
重试不开新 turn / step日志里的 step 数等于真实的模型轮次,记录不失真
压缩不删除原始事件任何摘要都能回答「遮蔽了什么」,可回退
动态上下文不写进 system prompt策略变化是新事件而非重写前缀,顺带保住 KV 缓存
不做命令 glob / 路径前缀这类权限规则语言「哪里可写」只有一个家,不维护第二套会漂移的规则
配置行序不承载加载语义启动顺序的唯一真相是依赖声明

第二组:守「宁可失败,不进入说不清的状态」——要么无法收回,要么建立在假前提上。

刻意不做换来什么
不提供 allow-always不存在一经授出就收不回的许可
guard 没有 allow 返回值拒绝不可被后注册的钩子翻转
沙箱不可用时不退化成裸跑加 warning不在「以为被限制了、其实没有」的假前提上继续执行
凭据声明了却解析不到时不回退到无认证不拿环境里碰巧存在的错误身份继续跑

几条展开说:

没有 memory 包,是个决定而不是遗漏。 全仓 219 个包里,没有任何以 memory / remember / recall 命名的目录、服务或工具。「agent 的记忆」这个词把三件性质完全不同的事混在了一起,dsh 拒绝合并,拆成三条正交的线:持久化(系统写,自动无语义)、按需加载(人写文件、模型显式取用)、显式状态(goal / todo / plan 作为 durable 事件)。三条线各自可以独立装卸。

需要说清楚的是:这个动机是从代码模式归纳的,不是仓库的声明。 仓库的设计决策日志里没有任何一条谈及记忆。更简单的解释——developer preview 阶段的范围克制——无法排除。

沙箱不可用即拒绝执行。 多数系统在沙箱不可用时会退化成无限制执行并打条 warning。dsh 的判断是:「以为被限制了、其实没有」比「跑不了」危险得多,因为后续所有安全判断都建立在假前提上。所以抽象类的契约直接写死「silent unconfined passthrough is forbidden」,无可用后端就抛错,错误文本还列出每个平台的修复动作。放弃限制必须是显式配置,不能是运行时退化。

两组合起来的代价也是明说的:dsh 宁可承担日志膨胀、API 复杂度和重复审批的打扰,也不引入第二真相源和收不回的状态。


6. 四处尚未兑现的落差

前面全是亮点。但这套分析里最有价值的部分,恰恰是下面这几条——它们决定了你该以什么姿态看待前面那些漂亮的设计。

① 第 3 节那条运行期断言,不在出厂组合里。

断言代码是真的,219 个包也都按仓库的强制要求注册了检查器。但提供 ctx.invariants 的那个服务,在 packages/、apps/、examples/ 的任何一份 .yml 里都没有挂载。

它是开发期护栏,不是线上强制。 设计价值仍在——把「日志失同步」变成可机械检出的类别——但线上的强制只到编译期和 append 内部校验为止。

崭新的断路器挂在墙上,电缆却整齐地盘在地板上,插头躺在空插座旁——建好了,没接线

② 「没有特权 core」在接口层成立,但非测试的 loop 实现只有一个。

dsh-agent 不依赖 dsh-agent-loop 这条依赖方向是真的,两行 YAML 也是真的。但**「设计上可替换」与「已经被替换过」是两件事**。同样的落差还有:SQLite 持久化 backend 完整实现了,却没有任何配置挂载它,实际角色是「证明这条 seam 可换」的第二实现,出厂跑的是 JSONL。

③ 前向兼容的分层设计完整,却没有第一个生产写入者。

日志格式的前向兼容靠信封上一个 ignorable 标志兜底:读到不认识的事件类型时,标了的可以跳过,没标的就拒绝重建整份日志。默认值刻意倒向「必需」——忘记打标志的后果是一次可诊断的过度拒绝,而不是静默恢复出一个被掏空的会话。

设计很漂亮。但全仓命中只有类型声明、JSDoc 和 SQLite 的往返重建,第一个使用者还没出现。

④ 仓库自带文档与源码有多处不一致,且集中在同一个点上。

不一致全部围绕 turn/step 边界:两处文档都声称「重试会开一个新的 turn」,而源码里 { kind: 'retry' } 只是让 while (true) 循环 continue,turn 号、step 号都不变。

最有力的反证来自 llm-retry 这个包自己:它的 README 说重试会开新 turn,而同一个包里的不变量检查器断言的恰好相反——重试事件必须在开着的 turn、开着的 step 内写入。

这说明两件事:该处重构快于文档;以及这个设计的表述比它的实现更难传达。


7. 不用 dsh,也能直接偷走的几条

坦白说,developer preview + 明示破坏性变更,大多数人不会真的把 dsh 用在生产里。但下面这几条不依赖它的架构,成本极低,今天就能抄进你自己的 harness。

① guard 只能拒绝,不能放行

给你的策略链加一档「只有拒绝、没有 allow 返回值」的检查器。

一次性消灭「后注册的 hook 把前面的拒绝覆盖掉」这整类权限漏洞。只要有一条按顺序执行的策略链就能加,几乎零依赖——这是全系列最容易单独搬走的一条。

② 重试写成 durable 会话事件,而不是 HTTP 客户端里的隐形循环

退避前后各写一条事件,带上策略指纹与计划延迟。

「模型被调了几次、为什么失败、等了多久」于是成为可审计的会话事实,UI 能如实显示「正在第 3 次重试,还有 4 秒」,而不是转一个没有信息量的圈。只需要事件日志加一个 loop 层的失败扩展点。

③ 凭据缺失 fail loud

区分两件事:「没打算认证」(没配置引用)和 「打算认证但没配好」(声明了引用却解析不到)。后者直接失败,绝不回退到无认证。

回退会拿环境里碰巧存在的无关 key 去认证——连错账户、错计费、错报错,而且错误信息完全误导。这是全系列最便宜的一条,零代价,该直接抄。

④ 动态上下文走 user 消息,而不是 system prompt

沙箱模式、审批策略这类会变的状态,如果写进 system prompt,状态一变整个缓存前缀就失效;放在历史尾部则只追加、不重写。

配套需要一句显式措辞:「本快照取代此前的运行时上下文快照」。而且清空也要发一次快照(”Current runtime context: none.”),否则模型无法区分「没有新快照」和「所有策略都已撤销」。

这是纯粹为 KV 缓存经济性做的结构决策,源码注释直说了这个理由。

⑤ 压缩不删除原始事件

写一条摘要事件,加一条遮蔽记录,并精确列出被遮蔽的是哪几条。

于是「这段摘要吃掉了什么」可以被精确回答,压缩可审计、可回退,甚至可以 fork 回压缩之前——这在快照式存储里做不到。


最后

dsh 让我印象最深的不是它的插件系统有多彻底,而是它在两个地方表现出的诚实:

一是明确承认中立抽象覆盖不了。provider 私有的签名推理块没法用中立词汇表表达,但丢掉对话就续不上。dsh 的答案是开一个类型为 unknown、带版本号、只有产出它的 adapter 能解释的口子,并把这个例外的边界画清楚——而不是假装它不存在。

二是做不满的承诺就不声称。沙箱每次包装都如实上报 full 还是 partial,Windows ACL 后端永远报 partial 并列出它承认的具体绕过面。需要绝对边界的调用方可以程序化地拒绝 partial。

至于要不要学它——Cordis 那篇给了一个我觉得很中肯的判断:

Claude Code 与 Codex 的边界选择更贴近产品,把不该被用户改坏的东西锁住;Hermes 居中;dsh 把边界推到零,换来的能力是真的,但它同时要求使用者理解整套插件语义才能安全改动。

这套设计的目标读者是要 fork 它的人,不是要用它的人。

如果你正在做 agent 产品,第 7 节那五条可以今天就抄;至于整套架构,等它过了 developer preview 再说。