前言
最近 Deepseek Harness 在 Github 很受追捧,短短几周就拥有了 200k+ 的 star,它跟其他的 agent 框架有何不同,为什么还能在这个拥挤的赛道出圈?这篇文章主要分析一下它的“Everything is a plugin”的设计和具体应用。
Deepseek Harness 是什么?
DeepSeek Harness(简称dsh)是由 DeepSeek AI 开发的开源 agent harness(智能体框架)。
它采用一切皆插件的架构,由 Cordis 驱动, 由于这种一切皆是插件化的设计,我们可以高度定制我们自己需要能力,十分灵活。
运行的话有两种方式
- 通过
npx @deepseek-ai/dsh web运行 - 源码启动:
git <span>clone</span> https://github.com/deepseek-ai/deepseek-harness.git
<span>cd</span> deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
启动后配置完 key 之后就可以正常使用了,界面和功能和 codex 桌面版很像。那么 dsh 源码里面是怎么实现插件化能力的呢?接下来带着这个问题我们来分析一下他的源码。
源码分析
目录结构
deepseek-harness/
├── apps/ <span># 最终可运行的应用</span>
├── packages/ <span># dsh 的插件包</span>
├── vendor/ <span># Vendored Cordis 源码</span>
├── python/ <span># Python SDK 和运行时</span>
├── native/ <span># 原生扩展</span>
├── examples/ <span># 用户可运行的 cordis.yml 示例</span>
├── docs/ <span># 架构、子系统和开发文档</span>
├── website/ <span># VitePress 文档站</span>
├── scripts/ <span># 构建、检查、生成器</span>
└── .agents/ <span># Agent 工作流和 Agent Notes</span>
packages/ 是仓库的主体。每个子目录是一个独立 npm workspace package,命名为:@deepseek-ai/dsh-<name>
从 UI 界面到底层 agent loop 全部抽离成了一个个的插件,目录按能力分组:
packages/
├── core/ 核心agent能力相关
├── api/
├── llm/
├── shell/
├── client/
├── ....../
插件写法
插件是一个导出 apply 函数的 TypeScript 模块。框架在加载时调用 apply,传入一个 ctx(上下文对象),通过 ctx 注册能力。插件有三种写法:函数形式、对象形式、类形式。大多数情况下,函数形式足够了。当插件需要向其他插件提供服务时,可使用类形式(见 服务与依赖)。
函数形式
<span>import</span> type { <span>Context</span> } <span>from</span> <span>'@deepseek-ai/cordis'</span>
<span>export</span> <span>const</span> name = <span>'hello-plugin'</span>
<span>export</span> <span>function</span> <span>apply</span>(<span>ctx: Context</span>) {
<span>// Required dependencies are ready before apply runs.</span>
<span>console</span>.<span>log</span>(<span>'[hello-plugin] plugin loaded!'</span>)
}
对象形式
<span>import</span> <span>type</span> { <span>Context</span> } <span>from</span> <span>'@deepseek-ai/cordis'</span>
<span>export</span> <span>default</span> {
<span>name</span>: <span>'my-plugin'</span>,
<span>inject</span>: [<span>'tools'</span>],
<span>apply</span>(<span>ctx: Context</span>) {
<span>// ...</span>
},
}
类形式
<span>import</span> { <span>Service</span>, <span><span>type</span> <span>Context</span> } <span>from</span> '<span>@deepseek-ai/cordis</span>'</span>
<span>export</span> <span>default</span> <span><span>class</span> <span>MyService</span> <span>extends</span> <span>Service</span> </span>{
static inject = ['tools']
constructor(ctx: <span>Context</span>) {
<span>super</span>(ctx, 'myService')
<span>// Perform synchronous initialization in the constructor.</span>
}
}
知道了插件写法之后,那么这一个个的插件之间是怎么运行起来、互相通信的呢?接下来从源码里面分析一下具体实现。
Dsh 架构分析:
我们从服务的启动出发,看看都执行了什么:
首先从启动命令 pnpm dsh --profile web 这个命令开始找到入口文件:
package.json:
<span>"scripts"</span><span>:</span> <span>{</span>
<span>"dsh"</span><span>:</span> <span>"node --import tsx/esm apps/cli/src/bin.ts"</span><span>,</span>
<span>}</span>
可以看到是入口文件是 apps/cli/src/bin.ts,然后找到如下逻辑:
<span>switch</span> (invocation.mode) {
<span>case</span> <span>'profile'</span>: {
<span>const</span> { runProfile } = await <span>import</span>(<span>'./profile-boot.ts'</span>)
await <span>runProfile</span>({
<span>environment</span>: <span>loadLayeredEnv</span>(<span>'dsh'</span>),
<span>profile</span>: invocation.profile,
<span>patchFiles</span>: invocation.patches,
<span>args</span>: invocation.args,
})
<span>break</span>
}
}
入口文件的逻辑比较简单就是根据不同的 mode 走不同的方法,接下来我们重点去看 runProfile 的逻辑。
核心代码:
<span>// 简化中间过程文件 apps/cli/src/profile-boot.ts runProfile 中调用 boot 方法</span>
<span>import</span> {
boot,
} <span>from</span> <span>'@deepseek-ai/dsh-app-boot'</span>
<span>export</span> <span>async</span> <span>function</span> <span>runProfile</span>(<span></span>) {
<span>const</span> ctx = <span>await</span> <span>boot</span>(xxx)
}
<span>// /packages/boot/app-boot</span>
<span>export</span> <span>async</span> <span>function</span> <span>boot</span>(<span>...</span>) {
<span>const</span> ctx = <span>new</span> <span>Context</span>()
<span>await</span> ctx.<span>plugin</span>(<span>Loader</span>)
<span>await</span> <span>mountRootInclude</span>(ctx, absoluteConfigPath, patches)
<span>return</span> ctx
}
Boot 就是我们这次启动的核心方法,简化后剩三个方法,分别对应:
- new Context 就是 Cordis 的初始化,管理上下文、插件等
- 然后就是把 loader 挂载到 Cordis 上下文中
- 之后就是使用 loader 处理我们 packages 下的各个插件,组成我们的插件树
核心源码位置:github.com/deepseek-ai…
这三个方法能力如下图所示:
在 Cordis 里会有个 fiber 的概念跟 react 的 fiber 很像: Cordis 的 fiber 是一个插件运行实例的生命周期管理单元,然后组成一棵可暂停、可更新、可销毁”的树 源码位置
它主要负责四件事:
- 保存插件自己的 Context 和配置
- 等待 inject 依赖满足
- 执行插件代码
- 卸载时清理插件资源
插件通过 ctx.plugin() 创建 Fiber vendor/cordis/src/registry.ts:330
<span>const</span> fiber = <span>new</span> <span>Fiber</span>(
<span>this</span>.ctx,
config,
Inject.<span>resolve</span>(plugin.inject),
runtime,
getOuterStack,
)
插件通信
现在插件之间主要有三种通信方式:
- 服务注入 / 服务调用:一个插件提供服务,另一个插件通过
inject获取并调用。 - 事件总线:插件通过
ctx.emit()广播事件,其他插件通过ctx.on()监听。 - Waterfall 扩展链:多个插件按顺序处理同一个事件,监听器必须调用
next()才能继续传递。
暂时无法在飞书文档外展示此内容
-
服务注入:插件之间的直接通信
Provider 插件负责注册服务:
ctx.<span>provide</span>(<span>'llm'</span>, llmService)
Consumer 插件声明依赖:
<span>export</span> <span>default</span> {
<span>inject</span>: [<span>'llm'</span>],
<span>apply</span>(<span>ctx</span>) {
ctx.<span>llm</span>.<span>stream</span>(request)
},
}
2. #### 事件通信:插件之间的广播
发送方:
ctx.emit(<span>'session/event'</span>, session, <span>event</span>)
接收方:
ctx.<span>on</span>(<span>'session/event'</span>, (session, <span>event</span>) => {
<span>// 处理事件</span>
})
3. #### Waterfall:可修改的扩展链
Waterfall 事件和普通事件不同,它允许前一个插件把处理权交给下一个插件:
ctx.on(<span>'tools/execute'</span>, <span>async</span> (<span>exec</span>, <span>next</span>) => {
const result = <span>await</span> <span>next</span>()
<span>return</span> transform(result)
})
调用关系类似:
Plugin <span>A</span>
↓ next()
Plugin <span>B</span>
↓ next()
实际执行器
↓
Plugin <span>B</span> 后处理
↓
Plugin <span>A</span> 后处理
大致了解了 dsh 架构之后可能会有疑问,那到底我们能用 dsh 做哪些 codex 做不到的事情?
Dsh 应用
续接上文:《AI 乱改代码?试试这套 SDD 规范驱动工作流》
我们尝试了基于 openspec 的 SDD 的研发工作流。但是实操过上一篇文章的朋友应该能发现,这套流程是在现有的 code agent 工具中结合的,使用起来的融合度会差一些,尤其是在对话中我们可能会忘记使用,导致 spec 文档出现偏移,反而会污染我们的上下文。刚好我们可以利用 dsh 的插件组合的能力,把 SDD 融入到 dsh 中。
流程设计
根据之前的痛点总结出如下几点,我们 dsh 需要具备如下几种能力:
- 负责spec 、agents.md 文件的初始化、并且提供流程强制校验
- UI界面spec 流程进度跟踪、归档提醒
- 输出过程跟踪,对话提醒
- 强制执行 spec 流程,防止 spec 文档出现偏移
- Coding 完成后的 spec 自检和代码的自检
在导入项目时,会初始化 spec 、agents.md 文件,然后对话面板中 prompt 携带 sdd、 spec、openspec 等词汇时会自动触发 SDD 流程,并且会强制走着一套流程,如果没有走则会进行拦截,并且在右侧面板是实时显示 spec 进度,完成情况,在 agent 输出完成之后会自动对 spec 文档和代码进行验证。
插件设计
这组能力拆分为两个插件层:SDD 流程服务插件 和 SDD 界面插件。流程服务插件维护状态并执行约束,界面插件通过 Host 事件流读取状态并渲染。
SDD 流程服务插件
项目初始化
<span><span>private</span> <span>async</span> <span>initializeOnce</span>(<span>path: <span>string</span></span>): Promise<SddWorkflowResult></span> {
<span>if</span> (!<span>this</span>.config.enabled) <span>return</span> { path, openspec: <span>'disabled'</span>, agents: <span>'disabled'</span> }
<span>const</span> openspec = <span>await</span> <span>this</span>.initializeOpenSpec(path)
<span>const</span> agents = <span>await</span> <span>this</span>.initializeAgents(path)
<span>return</span> { path, openspec, agents }
}
缺少 openspec/ 时执行 openspec init --tools none --force;缺少根目录 AGENTS.md 时,根据有界项目快照请求模型生成。快照排除依赖、构建产物、.env、凭据、私钥和证书,并限制文件数量与字节数。已有文件不覆盖,同一路径的并发初始化共享一个 Promise。
Change 状态跟踪
插件执行 openspec list --json,结合 Change 工件推导阶段:
<span>const</span> phase = totalTasks > <span>0</span> && completedTasks === totalTasks
? <span>'verifying'</span>
: completedTasks > <span>0</span>
? <span>'implementing'</span>
: <span>await</span> has(<span>'design.md'</span>)
? <span>'approved'</span>
: <span>await</span> has(<span>'proposal.md'</span>)
? <span>'proposed'</span>
: <span>'draft'</span>
归档目录 openspec/changes/archive 中的项目标记为 archived。完整状态通过 Cordis 事件发布:
ctx.emit(<span>'sdd-workflow/status'</span>, <span>status</span>)
状态包含项目路径、初始化状态、Change 阶段、任务计数和验证结果。
写入前流程强制
该拦截器通过监听 tools/pre-execute 事件,在工具执行前进行校验和拦截。
<span>if</span> (!isMutatingTool(exec) || exec.agent === undefined) <span>return</span> next()
await <span>this</span>.refreshChanges(path)
<span>if</span> (hasActiveChange(<span>this</span>.latest.<span>get</span>(path)) || isOpenSpecCommand(exec)) <span>return</span> next()
<span>return</span> {
kind: <span>'deny'</span>,
reason: <span>'OpenSpec workflow required: create or select an active Change before modifying project files.'</span>,
}
没有活动 Change 时,write、edit、修改型 bash 和 run_code 被拒绝;openspec ... 命令保留用于创建和推进 Change。skill、ask_user_question 等非写入工具显式放行。
Web bundle 配置:
<span>-</span> <span>id:</span> <span>sdd-workflow</span>
<span>name:</span> <span>'@deepseek-ai/dsh-sdd-workflow'</span>
<span>config:</span>
<span>enforceWorkflow:</span> <span>true</span>
<span>validateOnStop:</span> <span>true</span>
<span>codeChecks:</span>
<span>-</span> <span>pnpm</span> <span>run</span> <span>typecheck</span>
<span>-</span> <span>pnpm</span> <span>run</span> <span>lint</span>
<span>-</span> <span>pnpm</span> <span>run</span> <span>test</span>
完成后的 Spec 和代码自检
Change 进入 verifying 后,Host 执行:
await this.runValidationCommand(<span>command</span>, path, <span>timeout</span>, [
<span>'validate'</span>, <span>'--all'</span>, <span>'--no-interactive'</span>, <span>'--strict'</span>,
])
OpenSpec 通过后执行 codeChecks。结果写入 ProjectVerificationStatus;失败时通过litiaagent.steer() 返回诊断,全部通过时返回归档提醒。
SDD 界面插件
ui-workspace:工作区树状态
实现位置:packages/client/ui-workspace/src/client/tree.ts、packages/client/ui-workspace/src/client/rows/Rows.tsx。
Host 事件消息在 WorkspaceManager 中按项目路径缓存:
} <span>else</span> <span>if</span> (envelope.payload.type === <span>'host/sdd-workflow-status'</span>) {
<span>const</span> { status } = envelope.payload
<span>this</span>.sddWorkflow = { ...<span>this</span>.sddWorkflow, [status.path]: status }
<span>this</span>.notifier.markDirty()
}
deriveGroups() 将状态挂到 Workspace 节点,Rows.tsx 把 pending、loading、completed、failed 映射为状态点和文本,显示初始化结果摘要。
ui-project-details:详情进度与归档提醒
组件先从 useSessions 读取当前会话,再从 useWorkspaces 找到会话所属的 Workspace;如果会话还没有归属 Workspace,则回退到会话自身的 cwd。这个路径是状态表的唯一索引,避免详情栏根据自己的文件扫描结果重新计算项目状态:
const <span>status</span> = workspaceState.sddWorkflow?.[workspace?.path ?? cwd ?? <span>''</span>]
const <span>changes</span> = status?.changes ?? []
没有匹配状态时,组件仍显示项目标题和路径,但不渲染初始化、Change 或验证区域。
匹配到 SddWorkflowStatus 后,详情栏显示 AGENTS.md 和 OpenSpec 的初始化状态,并列出每个 Change 的名称、阶段、已完成任务数和总任务数。
存在 Change 时,详情栏还会汇总任务进度,并显示所有 Change 中最新的 lastModified 值。
验证信息也来自同一份状态快照。组件显示验证总体状态,以及每项 codeChecks 的通过或失败结果;失败项附带服务端返回的错误文本。
只有当验证状态为 passed 且至少一个 Change 仍处于 verifying 阶段时,才显示归档提醒:
{<span>status.verification.state</span> === <span>'passed'</span> && changes.some(change => change.phase === <span>'verifying'</span>) &&
<div <span>className</span>={css.reminder}>Ready to archive this Change.</div>}
状态由插件 dsh-sdd-workflow 生成。它执行 openspec list --json,读取 Change 工件,发布 sdd-workflow/status。
host-apiproxy 将事件转换为 host/sdd-workflow-status 事件消息,并在新连接建立时发送 statuses() 返回的完整快照。 Client 的 WorkspaceManager 按项目路径更新 sddWorkflow,再把它提供给两个 UI 插件。ui-workspace 显示初始化状态,ui-project-details 显示 Change 和验证详情;两者都不执行 OpenSpec 命令,也不自行推导阶段,重连后会从同一份快照恢复状态。
如下图所示,左侧增加了项目初始化的展示,中间 AI 输出区增加了 sdd 流程拦截校验,右侧详情区域新增了SDD流程进度的展示:
扩展
除此之外我们还能利用 dsh 做些什么?现在有很多公司在做 AI 研发需求的闭环,需求提出后 AI 就能自动完成需求的开发、审查、发布。从 0 - 1 开发的话,成本无疑是巨大的,但是通过 dsh 我们可以低成本完成半自动化的流程,如下图所示:
把需求和 coding 建立在同一个工作流中,人工只需要维护关键的节点介入。
总结
本文其实并没有讲 dsh agent 本身的能力,因为在现在其实各家 agent 能力都大同小异,dsh 出圈真正的原因是灵活的插件组合能力,赋予未来更多的想象。
dsh 的看点不在 agent 能力本身,而在插件化带来的组合自由:把 SDD、审查、发布等流程低成本织入工作流。适合想深度定制研发闭环的团队与二次开发者参考。