DeepSeek 开源了一个 agent 框架,它把一切都做成了插件

6 分钟阅读

DeepSeek Harness 的特别之处,是它把模型适配器、工具、会话日志,连核心循环都做成了插件。这篇从「它是什么」讲起,再拆开它的组装方式、核心循环和事件日志,说清「一切皆插件」到底解决了什么问题。

跑起来只要一行命令。

npx @deepseek-ai/dsh web

它会在本机起一个网页界面,默认地址是 127.0.0.1:3080。你在设置里填一个 DeepSeek API 密钥,选一个项目目录,然后就能让它读代码、改文件、跑命令。用起来和别的 AI 编程助手差不多。

DeepSeek Harness,命令行叫 dsh,是 DeepSeek 开源的一个 agent 框架。它自己反复强调一句话,一切皆插件。模型适配器是插件,工具是插件,会话日志是插件,连那个决定下一步做什么的核心循环,也是插件。

它是什么

先说清楚 harness 这个词。大模型只会往外吐文字,它碰不到你的电脑。去读文件、改代码、执行命令的,是外面这层程序。这层程序叫 harness,opencode、Claude Code 都属于这一类,dsh 也是。

它和网页版聊天机器人最大的区别在这里,它能直接动你电脑上的项目。它由几块拼起来。

flowchart TD
    U["你<br/>在网页或终端里提要求"] --> UI["界面层<br/>网页 / 命令行 / SDK / 桌面"]
    UI --> Core["核心循环<br/>决定下一步做什么"]
    Core --> Model["模型<br/>只出主意"]
    Core --> Tools["工具<br/>读写文件、跑命令、搜网页"]
    Tools --> Work["你的项目"]
    Core --> Log["会话日志<br/>把每一步记下来"]

项目目前处于开发者预览阶段,官方明说会有破坏兼容性的变更。想试就按上面那行命令装来跑,别急着上生产。

它能干什么

入口不止网页一个。同一个 dsh,换一个 profile 就是另一种跑法。

  • dsh web 起网页界面,适合坐在电脑前用。
  • dsh --profile headless "总结这个仓库" 跑完一个任务就退出,输出结果,适合塞进脚本。
  • dsh --profile sdk 起一个 JSON-RPC 服务,让别的程序通过 TypeScript 或 Python SDK 调用它。
  • dsh --profile acp 用另一种协议和自动化工具对接。
  • 还有一个 Electron 桌面版。

模型手上有一批工具。读文件、写文件、改文件,按文件名或内容搜索,跑 bash(Windows 上是 pwsh)命令,把耗时的命令丢到后台,搜索和抓取网页。再往外,它能连 MCP 服务器把外部工具挂进来,开一个跨多次调用的持久终端,向语言服务器查定义和引用,加载技能手册,维护一份待办清单。

任务太重时,它可以把活分给子代理。子代理有自己独立的会话和日志,主代理能给它发消息,也能中途打断它。项目里还带了子代理提供方,可以把一个轮次直接交给 Codex 或者 Claude Code 去跑,默认没有打开。

权限是另一道闸。每个动作分三档沙箱,read-only、workspace-write、danger-full-access,再配一个审批策略 ask 或 never。默认是 workspace-write 加 ask,模型想写工作区之外的文件,或者跑一条危险命令,界面会先弹窗问你。

会话是持久化的。每一步都落盘,重启之后能恢复,也能从某条消息处 fork 出一支新会话。

怎么干的

插件框架 Cordis

dsh 底下是一套叫 Cordis 的插件框架,五个概念记住就够用。

  • 插件就是一段实现了某个服务的代码,可以是一个带 apply 函数的对象,也可以是一个 Service 子类。
  • 上下文 ctx 是装服务的容器。一个服务占一个固定的 key,比如 ctx.tools、ctx.llm、ctx.sessions,别的插件按 key 取用,不直接 import 具体实现。
  • 插件用 inject 声明自己要哪些服务,声明之后就等它们就绪再启动,加载顺序不用手写。
  • 插件之间用带类型的事件通信,分发方式分五种。emit 只通知,不管结果;waterfall 一层层包住下游并改写结果;parallel 并行;serial 按顺序等;bail 谁先给出结果就停在谁那里。
  • 注册是可撤销的副作用。提示词片段、工具定义、监听器都用 ctx.effect() 或 ctx.on() 装上,插件卸载时自动撤销。

按 key 找服务,注册能自动撤销,这两条是它敢把一切做成插件的前提。

启动是一层层叠出来的

dsh 启动时,在一份空列表上按顺序叠插件。叠的顺序是这样的。

flowchart TD
    E["空插件列表"] --> B1["bundle dsh-base<br/>模型适配器、工具、存储、权限"]
    B1 --> B2["bundle dsh-web-app<br/>补上浏览器界面"]
    B2 --> P1["profile 的 cordis.patch.yml"]
    P1 --> P2["用户 home 的 cordis.patch.yml"]
    P2 --> P3["命令行 --patch 覆盖"]
    P3 --> R["最终跑起来的插件树"]

profile 是一份启动方案,写明这次要叠哪些插件,放在你的 dsh 目录里。发行版自带几套现成的,对应不同用法,web 起网页界面,headless 不带界面跑一次,sdk 和 acp 给别的程序调用。bundle 是打包好的一组插件配置加上挂载代码,dsh-base 是这几套方案共享的第一层。

patch 是一份改动清单,用 id 定位某个已有的条目,把它的配置整段换掉,或者插一条新的。图里画了两层。profile 的 patch 只对这一个方案生效,路径在 ~/.dsh/profiles/<方案名>/cordis.patch.yml。用户 home 的 patch 对所有方案都生效,路径在 ~/.dsh/cordis.patch.yml,它排在后面,同一条被两边都改时以它为准。

跑一句 dsh --profile web --dump-config,它会把最终这棵树打印出来。打印出来的每一条,都能被你自己的 patch 换掉。

一次请求怎么走完

假设你说,给登录加上记住密码。

sequenceDiagram
    participant You as 你
    participant Core as 核心循环
    participant Log as 会话日志
    participant LLM as 模型
    participant Tool as 工具

    You->>Core: 给登录加上记住密码
    Core->>Log: 记下 turn/start 和你的消息
    Core->>Log: 从日志推导历史消息
    Core->>LLM: 系统提示 + 历史 + 工具定义
    LLM-->>Core: 我要改 auth.ts
    Core->>Log: 记下 tool/call
    Core->>Tool: 执行 edit
    Tool-->>Core: 改好了
    Core->>Log: 记下 tool/result
    Core->>LLM: 结果给你,继续
    LLM-->>Core: 做完了
    Core->>Log: 记下 turn/end
    Core-->>You: 把过程推给你看

拆开说。

  1. 你的消息先进收件箱,排在两列里,一列等自己的轮次,一列等下一个步骤边界。
  2. 循环醒来,认领这批输入,打开一个轮次。
  3. 它组装这次的请求,把系统提示词、相关工具的定义、从会话日志推导出来的历史消息拼在一起。
  4. 调用模型,流式接收回复。
  5. 模型说要用 edit 工具改某几行。循环先把这次调用记进日志,走一遍权限流水线,执行,再把结果喂回去。
  6. 循环拿着结果再问模型下一步。模型可以继续调工具,也可以说做完了。
  7. 没有欠下的工作,轮次结束。

这里的分工值得记一下。一次模型请求加上它调用的工具,叫一个步骤。一个轮次包含零个或多个步骤。日志里用 turn/start、step/start、tool/call、tool/result、turn/end 这些事件把整条路径串起来。

这一圈里有个词反复出现,日志。循环不把对话存进一个数组,它把每一步追加进一份只增不改的会话日志。模型看到的历史是从日志推导出来的,项目里把这条规矩写成一句硬话,模型能看到的东西,必须能从日志重建。

换一块,别的不用动

dsh 把一项能力拆成三个角色,项目里叫 capability seam。一个角色负责定义接口,叫 Service Definition。一个负责实现,叫 Service Provider。剩下一个是使用它的代码,叫 Consumer。

以跑命令为例。

flowchart LR
    Tool["bash 工具<br/>Consumer"] --> Def["ctx.shell<br/>Service Definition"]
    Def --> Local["dsh-bash-local<br/>本地执行"]
    Def --> Sandbox["dsh-bash-sandbox<br/>沙箱执行"]

dsh-shell 定义 ctx.shell,dsh-bash-local 和 dsh-bash-sandbox 是两个提供方,模型看到的 bash 工具是消费方。你要把命令执行从本地换成沙箱,只换提供方就行,工具的 schema 和提示词一个字都不用动。文件系统、进程、终端、语言服务器都按同样的方式拆开,所以把它们一起指向一台远程沙箱,Bash、终端和 LSP 会一起搬过去。

为什么这样干

没有一份必须改的核心

很多框架想加行为,得去改核心。dsh 反过来,新行为挂到已经有的扩展点上。更关键的一步在实现上,注册本身就是副作用,插件卸载的时候,它注册的东西会自动撤销。所以插件能热重载,不会因为加载顺序或者卸载残留把状态搞乱。

连核心循环都可替换。扩展插件依赖的是接口,不依赖那个具体的循环实现,想换一套循环,换掉循环插件就行。项目里甚至有一套实验性的能力,让模型去定义并运行新的 Cordis 插件,运行时给自己加功能。

这套插件结构还带来一个好用的副产品。dsh 里有两组插件专门去读 Claude Code 和 Codex 的 hooks 配置,用同一套引擎跑起来。别人现成的钩子,它直接借过来用。

日志即状态

会话日志只增不改,模型历史从它推导,这两件事合起来解决一个老问题。若对话存在一个可变数组里,另外再记一份日志,两者早晚会对不上。这里日志就是状态,错位在结构上不会发生。

回放、追踪、fork、遥测都从同一份日志长出来,不用另外接。日志会越变越长,所以它有压缩,把旧对话收成一段摘要,被折叠掉的原始记录仍然留在日志里,只是不再逐条发给模型。

存法上它和有的工具不同。opencode 把日志和算出来的历史一起放进一个本地 SQLite 库,一个文件直接能查。dsh 把每个会话的日志写成一个只追加的 JSONL 文件,历史每次从文件现算,SQLite 只留给会话搜索、凭据这些辅助用途。已提交的日志文件不重命名也不覆盖,格式升级时并排生成一个新版本文件,日志因此能独立迁移。代价是跨会话的查询得另建索引,它用 SQLite 的全文检索补上这一块。

代价也摆在那

抽象层多。想往 dsh 里加点东西,第一件事是搞清楚该挂在哪个扩展点,学习曲线比写一个小工具陡不少。插件之间靠服务名和事件通信,追一条调用路径要跳好几个包。项目标着开发者预览,接口还可能随时变。

这套设计把复杂度从框架作者手里挪到了插件作者手里。你只是装来用,这部分复杂度你碰不到。你要往里加东西,或者想让同一套东西跑在完全不同的环境里,它换来的自由度才值这个价。

评论