没有特权内核的Agent——DeepSeek Harness的Everything is a Plugin与微内核思想

前两天 DeepSeek 开源了 DeepSeek Harness(下面简称 dsh),在网上刷到以后,我第一时间把它的文档和架构决策记录(ADR)翻了一遍。

我们自己的 Agent 底座做了大半年,一个内核支撑着海内外多款AI产品,多人格、主动关心、健康数据解读这些业务能力,都是以插件的形式挂在生命周期事件上的。所以读 dsh 的时候,我一直在拿它和我们的底座对照。

最让我意外的是它的第一条原则:Everything is a Plugin,连 agent loop 本身都是一个插件。

接下来会写一个系列,把 dsh 的设计一块块拆开看,顺便记下哪些可以搬进我们的项目。这是第一篇,先看总体架构。

一、没有特权内核

我们平时说"插件化",一般是这样的结构:有一个内核,内核预留了一些扩展点,插件挂在扩展点上。内核是特权的,插件只能在内核允许的地方做事。我们的底座也是这样,Agent Loop 是内核,记忆、技能、观测这些挂在它的事件上。

dsh 走得更远。它没有一个"可以打补丁的特权内核":

包负责什么注册到
core/session只追加的会话事件日志ctx.sessions
core/system-prompt系统提示词和工具 schema 的组装ctx.systemPrompt
core/tools工具注册表和执行管线ctx.tools
core/agentAgent 接口和 agent/* 事件ctx.agents
core/agent-loop实现 Agent 接口的默认驱动器ctx.agentLoop
llm/llm消息和流的词汇表,模型适配器的接入点ctx.llm

可以看到,会话日志、工具注册表、模型适配器,甚至 agent loop,全都是插件,注册在同一个共享的 context 上。agent loop 只是 Agent 接口的一个"默认实现",你完全可以换一个。

这样做有什么好处?我觉得最大的好处是一个倒逼:内核也能被替换,接口和事件的契约就必须足够完整。如果 loop 是特权的,很多东西可以"偷偷"在 loop 里直接调用;一旦 loop 也是插件,所有协作都必须走公开的接口和事件,没有后门可走。

二、Cordis 的五个概念

dsh 底层用的插件框架叫 Cordis。整个 harness 就靠五个概念撑起来。

1. 插件

插件就是一个实现了 apply(ctx) 的东西,可以是函数、对象,也可以是类。它只描述"我贡献什么",至于怎么组合,交给配置文件。没有任何启动脚本。

2. context 是 service 的仓库

每个服务占用 context 上一个稳定的 key,比如 ctx.tools、ctx.llm。用的人按 key 去找服务,而不是 import 某个具体的实现。

3. 用 inject 声明依赖

插件通过 inject 声明自己依赖哪些服务:

// 声明依赖 tools 服务
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(
    defineTool({
      name: 'greet',
      description: 'Greet the named person.',
      parameters: { name: { type: 'string', required: true, description: 'Who to greet' } },
      output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
      async execute(args) {
        return `Hello, ${args.name}!`
      },
    }),
  ) // 注册返回的 disposer 自动挂在本插件上,插件卸载时工具也随之注销
}

依赖没就绪,插件就停在 PENDING 状态,等依赖到了再加载。所以加载顺序由依赖表达,配置文件里谁写在前面并不重要。

而且这个依赖是持续追踪的:依赖的服务消失了,所有依赖它的插件都会被卸载;服务恢复了,再重新加载。这样就从根上杜绝了"持有一个已经失效的服务引用"这种问题。

写 Java 的看到这里应该很亲切,这就是 Spring 的依赖注入,只不过更动态一些,有点像 OSGi 的动态服务。还有一个排查小技巧:插件"什么反应都没有",十有八九是停在了 PENDING,缺了某个必需的服务。

4. 类型化事件

插件之间通过事件通信和拦截。dsh 有五种分发模式,每个事件用哪种模式,是它公共契约的一部分:

模式是否等待有无返回值语义
emit否无同步广播,纯通知
waterfall链式有洋葱模型的中间件,可以改写结果,也可以短路
parallel是无所有监听者并发执行
serial是有顺序执行,第一个有效返回值胜出
bail否有serial 的同步版本

其中最重要的是 waterfall,几乎所有的拦截点都用它:

ctx.on('demo/transform', async (input, next) => {
  const downstream = await next()
  return downstream.toUpperCase() // 包装下游的结果
})

ctx.on('demo/transform', async (input, next) => {
  if (input.includes('blocked')) return '** blocked **' // 短路:自己拥有这个决策
  return next() // 否则必须委托给下游
})

每个监听者拿到一个 next。调用它,就是把决策交给下游,还能在回程上改写下游的结果;不调用它,就是短路,自己拿下了这个决策。

这和 Java 里 Servlet 的 Filter 链一模一样。dsh 的文档里专门强调了一条纪律:只是观察、打日志的监听者,必须调用 next()。不调用 next() 必须是一个刻意的决策,而不能是一个意外。就跟写 Filter 忘了 chain.doFilter() 一样,后面所有人的默认行为都会被你静默地吞掉。

5. 注册即可逆的 effect

所有的注册,提示词段落、工具 schema、适配器、事件监听,都是通过 ctx.effect() 或 ctx.on() 安装的,安装的同时就拿到了撤销它的方法。插件卸载时,这些 effect 会按相反的顺序依次撤销。

这其实就是 RAII,或者说 Java 里的 try-with-resources:资源的获取和释放写在一起。有了它,热重载、热替换任何一个组件才成为常态,而不是特例。

三、产品就是有序的叠层

dsh 有 Web 版、无头运行版、SDK、ACP 自动化服务好几种产品形态,它们共享同一个内核。差异怎么表达?靠三个概念:

  1. Bundle:一组配置行加上它们挂载的代码,是分发的单位;
  2. Profile:一个具名的组合,列出要叠加哪些 bundle;
  3. Patch:按配置行的 id 定位,替换某一行的配置,或者插入新的一行。

启动的时候,从一个空列表开始,依次叠加:profile 里的 bundle → profile 自己的 patch → 用户目录下的 patch → 命令行传进来的 patch。

比如 Web 版就是 [base, web-app],无头版是 [base, headless]。CI 里想用更严格的审批策略,不用改代码,写一个 patch 文件,按 id 替换掉审批策略那一行的配置就行了。

最终启动了哪些插件,可以直接打印出来看:

dsh --profile web --dump-config

这个命令会打印出本机实际启动的整棵插件树,其中每一行都能被你自己的 patch 替换。

定制永远是加层,而不是 fork。组合本身就是数据,可以被检视,可以被覆盖,也可以被审计。

四、事件分三个域

dsh 的文档里有一句话:"选对事件域,是大多数改动的第一个决定。"它把事件分成了三个互不混用的域:

  1. Session 事件:持久的事实,必须经得起重载和回放,比如用户发了一条消息、模型回了一条消息;
  2. Agent 事件:agent/* 开头,携带着活的 Agent,用来观察或拦截正在进行的工作;
  3. Capability 事件:fs/*、tools/*、telemetry/* 这些,用来给某个能力挂策略。

简单的说,Session 事件是事实(过去时),Agent 事件是控制(现在时),Capability 事件是策略。

新加一个行为之前,先问自己:这件事是需要持久回放的事实,还是对进行中工作的实时干预,还是某个能力上的策略?问清楚了,该挂在哪里也就清楚了。dsh 还专门维护了一张"新行为挂到哪里"的对照表,加模型、加工具、加上下文、拦截请求,各有各的位置。

五、对照我们的底座

读完这部分,我对照着我们的底座想了想,有几点可以借鉴:

  1. 产品差异从代码分支变成配置叠层。我们两款产品共用一个内核,但有些差异还是写在代码里,靠判断"这是哪个产品"来分支。照 dsh 的思路,每款产品应该是一组有序的插件叠层,差异全部落在可审计的配置里;
  2. 事件按域拆开。我们现在的生命周期事件里,"已经发生的事实"和"可以拦截的控制点"是混在一起的。拆开之后,哪些事件要持久化、哪些能否决,一目了然;
  3. 拦截链上的观察者必须放行。我们的拦截链也有类似的约束,但只是写在团队规范里。dsh 把它当成仓库级的规则来强调,值得在代码评审里重点盯一下。

也有暂时不打算学的:把 loop 本身也做成可替换的插件。这对 dsh 是必要的,因为它要支撑好几种完全不同的产品形态,还要给社区留出替换的空间。而我们目前只有一种 loop,为了"可替换"付出的抽象成本,现阶段收益不大。我更倾向于先把事件契约做完整,等真的需要第二种 loop 的时候再动。

另外要提一句,dsh 目前还处于 developer preview,文档里也说了接口会有破坏性的变更。借鉴的是思路,具体的 API 以仓库最新的代码为准。

总结

  1. dsh 没有特权内核,会话日志、工具注册表、模型适配器乃至 agent loop 都是插件,这倒逼接口和事件契约必须完整;
  2. Cordis 用五个概念撑起整个 harness:插件、service 仓库、inject 声明依赖、类型化事件、可逆的 effect;
  3. waterfall 就是 Filter 链,只观察的监听者必须调用 next(),不调用必须是刻意的决策;
  4. 产品形态等于有序的插件叠层加上按 id 覆盖的 patch,定制永远是加层,而不是 fork;
  5. 新行为落地前先选事件域:Session 事件是事实,Agent 事件是控制,Capability 事件是策略。

相关资料