第1章:认识 DeepSeek Harness------一个插件化的 Agent Runtime 平台
系列:DeepSeek Harness 源码实战 | 进度 1/16
你以为 coding agent(编码智能体)是一个程序,其实它是一个可以拼装的运行时平台。
当你在终端里敲下一行命令,让 AI 帮你修 bug、写测试、重构代码的时候,背后跑的不是一个简单的「prompt 进、结果出」的脚本。它是一个完整的运行时:管理会话上下文、调度工具调用、执行沙箱策略、持久化历史记录、处理并发与中断。这个运行时就是 Harness(驾驭层)------介于 LLM(Large Language Model,大语言模型)和你的工程环境之间的那层软件。
我是怕浪猫,一个喜欢拆开源项目的技术博主。从这篇文章开始,我会用 8 章的篇幅,带你从源码层面完整拆解 DeepSeek 开源的 Harness 项目------dsh(DeepSeek Harness 的缩写)。这是第 1 章,先搞清楚它到底是什么、怎么跑起来、整体长什么样。
1.1 什么是 Harness:从 LLM 到可用 Agent 的最后一块拼图
先问一个问题:为什么不直接调 LLM 的 API(Application Programming Interface,应用程序编程接口)来干活?
答案很简单------裸模型只会输出文本。你让它「帮忙修一下 auth.ts 里的登录 bug」,它能给你一段修改建议,但它不会自己去读文件、跑测试、检查 git diff、写 commit message。这些「动手」的能力,需要一层软件来提供。
这层软件就是 Harness。它的职责可以用一句话概括:
把 LLM 的语言能力,变成可在真实工程环境中执行的操作能力。
具体来说,一个 Harness 要解决五件事:
| 职责 | 说明 |
|---|---|
| 上下文管理 | 维护对话历史,决定模型每次请求能看到什么 |
| 工具调度 | 把模型说的「我要读文件」翻译成实际的函数调用,再把结果喂回去 |
| 执行策略 | 权限控制、沙箱隔离、超时重试、审批流程 |
| 持久化 | 会话记录可保存、可回放、可 fork |
| 多形态 | 同一个 runtime(运行时),能以 Web GUI(Graphical User Interface,图形用户界面)、CLI(Command Line Interface,命令行界面)、headless(无头模式)等多种方式被使用 |
如果你做过 agent 项目,大概率自己拼过这些零件。dsh 的思路是:把这些全部做成插件,用一套框架组装起来,形成一个可商用的平台。
来看一个对比:
bash
裸 LLM 调用
┌──────────┐ ┌──────────┐
│ 用户 │────▶│ LLM API │────▶ 文本输出
└──────────┘ └──────────┘
(没有文件、没有终端、没有持久化)
Harness 化 Agent
┌──────────┐ ┌──────────────────────────────────┐ ┌──────────┐
│ 用户 │────▶│ Harness Runtime │────▶│ LLM API │
│ │ │ ┌─────────────────────────┐ │ └──────────┘
│ Web/CLI │ │ │ 会话管理 工具注册表 │ │
│ SDK/ACP │ │ │ 执行流水线 沙箱策略 │ │
│ │ │ │ 持久化 审批 子代理 │ │
└──────────┘ │ └─────────────────────────┘ │
└──────────────────────────────────┘
(文件、终端、LSP、shell 全部接入)
金句:LLM 是引擎,Harness 是底盘。没有底盘的引擎能转,但上不了路。
1.2 dsh 项目定位与现状
dsh 是由 DeepSeek AI 开发的开源项目,仓库地址在 GitHub(github.com/deepseek-ai...
- 版本:0.1.0-rc.5(Release Candidate,发布候选版本)
- 许可证:MIT(Massachusetts Institute of Technology,麻省理工学院)许可,商用友好
- 阶段:开发者预览(Developer Preview),官方明确说「未来将出现破坏兼容性的变更」
- 语言:TypeScript,Node.js 运行时
- 包管理:pnpm(Performant npm,高性能 Node 包管理器),monorepo(单体仓库)结构
项目采用 monorepo 布局,主要目录结构如下:
bash
deepseek-harness/
├── apps/ # 应用入口
│ ├── cli/ # dsh 命令行工具
│ ├── web/ # Web 前端应用(Vite 构建)
│ └── qurvis/ # 桌面应用(Electron 壳)
├── packages/ # 50+ 功能包(按家族分组)
│ ├── core/ # 核心包(session/tools/agent/agent-loop/scope)
│ ├── llm/ # LLM 适配器
│ ├── fs/ # 文件系统
│ ├── shell/ # Shell 执行
│ ├── boot/ # 启动装配
│ └── ... # 其他能力家族
├── docs/ # 架构文档 + 子系统文档
├── examples/ # 示例项目(acp-agent/headless-agent/jsonrpc-agent 等)
└── packages/bundle/ # 默认组合包(dsh-base/dsh-web-app/dsh-headless)
packages/ 下有 50 多个包,全部以 @deepseek-ai/dsh-* 为 npm scope(命名空间)发布。这不是一个小项目------它是一个完整的平台。
金句:50 多个包,不是一个应用,是一个生态的雏形。
1.3 与 Claude Code / Codex / OpenCode 的定位差异
你可能已经在用其他 coding agent 了。Claude Code、OpenAI Codex、OpenCode 都是优秀的工具。dsh 和它们的区别在哪?
| 维度 | dsh | Claude Code | Codex | OpenCode |
|---|---|---|---|---|
| 架构模式 | 全插件化,Cordis 驱动 | 单体应用 | 单体应用 | 插件化 |
| 开源程度 | MIT 开源,可自托管 | 闭源 | 部分开源 | 开源 |
| Provider 可替换 | 任意 LLM 适配器(ctx.llm 注册) | 仅 Claude | 仅 OpenAI | 多模型 |
| 程序化接入 | ACP + SDK + JSON-RPC | 不支持 | 不支持 | 不支持 |
| 多形态 | Web/CLI/Headless/SDK/ACP | CLI 为主 | IDE 集成 | CLI 为主 |
| 沙箱策略 | 可插拔(bwrap/Landlock/Seatbelt) | 内置 | 内置 | 无 |
核心差异在于「可组合性」。dsh 的设计哲学是:每一个功能都是插件,包括模型适配器、工具注册表、会话日志、agent 循环本身。这意味着你可以替换任何一个组件,而不需要 fork 整个项目。
举个例子:你想把文件系统操作指向远程沙箱(比如 E2B 沙箱),在 dsh 里只需要替换 ctx.fs(文件系统服务)的 Provider(提供方),Bash、PTY(Pseudo Terminal,伪终端)、LSP(Language Server Protocol,语言服务器协议)等工具会自动跟着搬过去,因为它们都依赖同一个文件系统接口。在其他单体应用里,这可能需要改几十处代码。
金句:单体应用改一个功能要动全身,插件化平台换一个 Provider 就够了。
1.4 快速上手:npx 运行、源码构建、第一个会话
方式一:npx 一键运行
如果你只是想体验,最快的方式是:
sh
npx @deepseek-ai/dsh web
前提是你装了 Node.js。这条命令会启动 Web UI,默认地址是 http://127.0.0.1:3080。打开浏览器就能看到界面,按引导设置模型 API Key(API 密钥)即可开始对话。
首次运行时,dsh 会在 $DSH_HOME(默认是 ~/.dsh)下创建 profile(装配档案)模板,包括 profiles/web/ 目录和默认配置文件。
方式二:从源码构建
如果你想深入源码,建议从源码运行:
sh
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
环境要求:
- Node.js 22.19+ 或 24+
- pnpm 11.7.0
pnpm install 会安装所有依赖(包括 workspace 里的 50+ 包),pnpm run build 会构建全部包,pnpm dsh web 启动 Web 应用。
入口源码:bin.ts 是怎么分发的
dsh 的 CLI 入口在 apps/cli/src/bin.ts,它做的事情很简单:解析参数,按模式动态 import 对应的执行器。来看真实代码:
typescript
// apps/cli/src/bin.ts(节选)
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
function readVersion(): string {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
) as { version?: unknown }
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
patchFiles: invocation.patches,
args: invocation.args,
})
break
}
case 'plugin': {
const { runPlugin } = await import('./plugin.ts')
process.exit(runPlugin(invocation.profile, invocation.args))
break
}
case 'dump-config': {
const { runDumpConfig } = await import('./dump-config.ts')
runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
break
}
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}
这段代码的设计要点有三个:
- 动态 import :每个模式只加载自己需要的代码,不相关的模块不会进入进程。
dsh web不会加载dump-config的逻辑,反之亦然。 - 三种模式 :
profile(启动一个装配档案)、plugin(管理插件依赖,转发给 pnpm)、dump-config(打印配置树并退出,不启动服务)。 satisfies never守卫 :default分支用了 TypeScript(TypeScript,带静态类型的 JavaScript 超集)的satisfies never操作符,如果将来加了新模式但忘了加 case,编译期就会报错。这是工程级别的防御编程。
参数解析:args.ts 的设计
parseDshArgs 函数在 apps/cli/src/args.ts 中定义。它的设计有一个巧思:启动器只解析自己拥有的标志(--profile、--patch、--dump-config),第一个不认识的 token 之后的参数全部原样传递给被启动的应用插件。
来看帮助示例:
bash
Examples:
dsh --profile web boot the web profile (same as: dsh web)
dsh --profile headless "run the tests" answer one task, print the result, and exit
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
dsh --profile tui --resume <session> arguments after the launcher flags reach the app
dsh --profile web --help the web app's own flags and help
dsh plugin --profile tui add <package> install a plugin into the tui profile
注意 dsh --profile tui --resume <session> 这行。--resume 不是启动器的标志,它会被传递给 tui profile 内部的应用插件去处理。这种「启动器只管装配,应用逻辑交给插件」的分层,让 dsh 的 CLI 保持了极简。
还有一个语法糖:dsh web 是 --profile web 的别名,定义在 args.ts 里的子命令中:
typescript
// apps/cli/src/args.ts(节选)
const web = program.command('web')
.description('boot the web profile (alias of --profile web)')
.helpOption(false)
.allowUnknownOption()
.passThroughOptions()
.enablePositionalOptions()
.argument('[args...]', 'arguments for the web app')
.option('--patch <path>', 'extra patch-list overlay', collect)
.option('--dump-config', 'print the composed web-profile tree and exit')
.action((args: string[], options: BootOptions) => {
rejectParentOptions('web')
resolved = resolveBoot(web, 'web', options, args)
})
第一个会话
启动 Web UI 后,流程是这样的:
- 打开
http://127.0.0.1:3080 - 首次使用会引导你设置模型(选 provider、填 API Key),不需要重启
- 选择工作区(workspace)目录
- 开始对话
对话过程中,任何超出权限策略的操作,dsh 会先问你。比如模型想执行一个 shell 命令,会弹审批确认。这是 dsh-base bundle 里内置的审批策略在工作,后续章节会详细拆解。
1.5 总体架构一览:从 CLI 到插件树
现在把视角拉高,看 dsh 启动后整体长什么样。
五层架构
bash
用户入口 装配层 运行时核心 能力层
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ dsh web │────▶│ │ │ │ │ fs (文件) │
│ dsh -- │ │ Profile │ │ Cordis │ │ shell (终端) │
│ profile │────▶│ Bundle │────▶│ 插件树 │────▶│ llm (模型) │
│ headless│ │ Patch │ │ │ │ web (搜索) │
│ SDK/ACP │ │ │ │ │ │ subagent │
└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘
三层叠加装配 ctx 服务总线 可替换 Provider
每一层的职责:
| 层 | 职责 | 关键概念 |
|---|---|---|
| 用户入口 | 接收命令和参数 | CLI / Web / Headless / SDK / ACP |
| 装配层 | 把配置和代码组装成插件树 | Profile / Bundle / Patch |
| 运行时核心 | 管理插件生命周期和事件分发 | Cordis 框架、ctx 服务总线 |
| 能力层 | 提供具体功能(文件读写、shell 执行等) | Seam(接缝):Service / Provider / Consumer |
装配层:三层叠加
启动一个 dsh 实例,实际发生的事情是:
- 读取 Profile :找到
$DSH_HOME/profiles/<name>/下的配置 - 叠加 Bundle:按 Profile 列出的顺序,把每个 Bundle 的 Cordis 配置项叠上去
- 应用 Patch:按层级顺序应用 patch(补丁),按 id 整行替换配置
叠加顺序从底到顶:
bash
空配置列表
│
▼
各 Bundle 的 patch(按 profile 列出的顺序)
│
▼
Profile 自己的 cordis.patch.yml
│
▼
Home 级 $DSH_HOME/cordis.patch.yml
│
▼
--patch 命令行临时 overlay(覆盖层)
│
▼
最终配置树 → 交给 Cordis Loader 挂载
你可以用 dsh --profile web --dump-config 查看最终合成的配置树,确认哪些条目被加载了、哪些被 patch 替换了。
运行时核心:Cordis 插件树
配置树合好后,交给 Cordis 框架的 Loader 挂载。每个配置条目对应一个插件,插件向共享上下文(Context,简称 ctx)贡献服务。最终形成一棵插件树,每个插件节点都可以注册服务、监听事件、产生副作用。
核心包及其在 ctx 上占据的键:
| 包 | 职责 | ctx 键 |
|---|---|---|
| core/session | 仅追加的会话事件日志 | ctx.sessions |
| core/system-prompt | 提示词片段与工具 schema 组装 | ctx.systemPrompt |
| core/tools | 工具注册表和执行流水线 | ctx.tools |
| core/agent | Agent 接口和注册表 | ctx.agents |
| core/agent-loop | 默认的 agent 驱动器 | ctx.agentLoop |
| core/scope | 按 agent 划分作用域 | 库,无 ctx 键 |
| llm/llm | 消息词汇表和适配器接口 | ctx.llm |
这些包构成了 dsh 的核心控制主干。它们之间的关系是:agent-loop 驱动 turn(轮次)和 step(步骤),每个 step 里向 llm 发请求,拿到模型回复后调度 tools 执行,所有过程记录到 session 日志。
能力层:Seam 架构
能力层是 dsh 最有意思的设计。每个能力(文件系统、shell、搜索等)都遵循三段式:
- Service Definition(服务定义):接口契约,声明有哪些方法
- Service Provider(服务提供方):具体实现,可以被替换
- Consumer(消费方):面向模型的工具,调用 Provider 的方法
比如文件系统:
- Service Definition 在
packages/fs/fs/里定义了读写文件、列目录等接口 - Provider 有
fs-local(本地文件系统)、fs-sandbox(沙箱受限)、fs-e2b(远程 E2B 沙箱) - Consumer 是
tool-fs,把文件操作暴露给模型作为工具调用
替换 Provider 就能改变整个产品的行为------把 fs-local 换成 fs-e2b,所有依赖文件系统的工具(Bash、终端、LSP)都会自动指向远程沙箱。不需要改工具代码。
金句:在 dsh 里,能力不是一个函数,是一根可以拨插的接缝。
六种服务方式
同一个 runtime,支持六种打开方式:
| 方式 | 命令 | 适用场景 |
|---|---|---|
| Web GUI | dsh web |
浏览器中交互式对话,默认 127.0.0.1:3080 |
| Headless | dsh --profile headless "task" |
单次任务,打印结果退出,适合 CI(Continuous Integration,持续集成) |
| Web GUI(双半包) | host + client | 浏览器端 + 宿主端分离部署 |
| ACP | packages/acp | Agent Client Protocol(代理客户端协议),程序化自动化 |
| SDK | packages/sdk | stdio JSON-RPC(JSON Lines 远程过程调用),外部进程驱动 |
| 人机协作 | interaction 家族 | 命令、审批、权限、提问 |
这六种方式共享同一个插件树和会话日志,只是入口不同。你在 Web GUI 里的会话,可以 fork 到 Headless 模式跑 CI,也可以通过 SDK 被另一个程序调用。
启动链:从 bin.ts 到插件树
把前面讲的串起来,dsh 的完整启动链是这样的:
bash
1. 用户执行 dsh --profile web
│
2. bin.ts 解析参数 → parseDshArgs() → mode='profile'
│
3. 动态 import profile-boot.ts → runProfile()
│
4. app-boot 加载 profile:读取 bundles 列表、叠加 patch 层
│
5. composeEntries() 合成最终配置树
│
6. Cordis Loader 挂载配置树 → 实例化每个插件 → 注册 ctx 服务
│
7. web-app bundle 的插件启动 Web Server(端口 3080)
│
8. 浏览器访问 → 加载 client 端插件 → 渲染 UI
其中第 4-6 步是装配的核心。来看 profile-boot.ts 里的关键逻辑:
typescript
// apps/cli/src/profile-boot.ts(节选)
export function prepareProfile(name: string, userLayer = true): Profile {
healProfilesModuleFallback(INSTALL_ANCHOR)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
// ... 总是重写空根配置,防止 Loader 的回写把组合行固化进去
return profile
}
注意注释里提到的设计细节:根配置文件 cordis.yml 每次启动都会被重写为空列表 []。这是因为 Cordis 的 Loader 有一个回写机制------插件自我卸载时会持久化当前配置树。如果不重置,上次组合的行会被固化进根文件,下次启动时每个 Bundle 的插入操作会重复一遍,导致配置膨胀。这种细节体现了工程上的谨慎。
金句:一个健壮的系统,连「回写会叠加」这种边角 case 都要兜住。
本章小结
| 要点 | 说明 |
|---|---|
| Harness 定义 | 介于 LLM 和工程环境之间的运行时层,负责上下文、工具、策略、持久化、多形态 |
| dsh 定位 | DeepSeek AI 开源的插件化 Agent Runtime,MIT 许可,开发者预览期 |
| 核心差异 | 全插件化架构(Cordis 驱动),任意 Provider 可替换,六种服务方式 |
| 入口设计 | bin.ts 三模式分发(profile/plugin/dump-config),动态 import 按需加载 |
| 架构五层 | 用户入口 → 装配层 → Cordis 插件树 → 核心包 → 能力 Seam |
| 装配三概念 | Profile(装配档案)、Bundle(组合包)、Patch(按 id 整行替换) |
| 能力 Seam | Service Definition + Provider + Consumer,换 Provider 即换产品形态 |
| 服务方式 | Web GUI / Headless / 双半包 / ACP / SDK / 人机协作 |
下章预告
这一章我们俯瞰了 dsh 的全貌。但你可能还有一个疑问:为什么 dsh 敢把所有功能都做成插件?它的插件引擎到底有多强?
下一章,我们来拆 Cordis------驱动 dsh 的插件引擎。你会看到它的四种事件分发模式、服务注入机制、可逆副作用设计,以及它在 dsh 中的具体落地。
我是怕浪猫,如果你觉得这篇有用,收藏一下整个系列。评论区聊聊你正在用的 coding agent 是单体还是插件化的?
下一章:Cordis 插件引擎