Deepseek Harness 架构解析和应用

文章来源声明: 原文作者:古茗前端团队; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684759404727320627; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

dsh 的看点不在 agent 能力本身,而在插件化带来的组合自由:把 SDD、审查、发布等流程低成本织入工作流。适合想深度定制研发闭环的团队与二次开发者参考。

> 作者:李天生

前言

最近 Deepseek Harness 在 Github 很受追捧,短短几周就拥有了 200k+ 的 star,它跟其他的 agent 框架有何不同,为什么还能在这个拥挤的赛道出圈?这篇文章主要分析一下它的“Everything is a plugin”的设计和具体应用。

Deepseek Harness 是什么?

DeepSeek Harness(简称dsh)是由 DeepSeek AI 开发的开源 agent harness(智能体框架)。

它采用一切皆插件的架构,由 Cordis 驱动, 由于这种一切皆是插件化的设计,我们可以高度定制我们自己需要能力,十分灵活。

运行的话有两种方式

  1. 通过 npx @deepseek-ai/dsh web运行
  2. 源码启动:

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 就是我们这次启动的核心方法,简化后剩三个方法,分别对应:

  1. new Context 就是 Cordis 的初始化,管理上下文、插件等
  2. 然后就是把 loader 挂载到 Cordis 上下文中
  3. 之后就是使用 loader 处理我们 packages 下的各个插件,组成我们的插件树

核心源码位置:github.com/deepseek-ai…

这三个方法能力如下图所示:

在 Cordis 里会有个 fiber 的概念跟 react 的 fiber 很像: Cordis 的 fiber 是一个插件运行实例的生命周期管理单元,然后组成一棵可暂停、可更新、可销毁”的树 源码位置

它主要负责四件事:

  1. 保存插件自己的 Context 和配置
  2. 等待 inject 依赖满足
  3. 执行插件代码
  4. 卸载时清理插件资源

插件通过 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,
)

插件通信

现在插件之间主要有三种通信方式:

  1. 服务注入 / 服务调用:一个插件提供服务,另一个插件通过 inject 获取并调用。
  2. 事件总线:插件通过 ctx.emit() 广播事件,其他插件通过 ctx.on() 监听。
  3. Waterfall 扩展链:多个插件按顺序处理同一个事件,监听器必须调用 next() 才能继续传递。

暂时无法在飞书文档外展示此内容

  1. 服务注入:插件之间的直接通信

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 需要具备如下几种能力:

  1. 负责spec 、agents.md 文件的初始化、并且提供流程强制校验
  2. UI界面spec 流程进度跟踪、归档提醒
  3. 输出过程跟踪,对话提醒
  4. 强制执行 spec 流程,防止 spec 文档出现偏移
  5. 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 时,writeedit、修改型 bashrun_code 被拒绝;openspec ... 命令保留用于创建和推进 Change。skillask_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.tspackages/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.tsxpendingloadingcompletedfailed 映射为状态点和文本,显示初始化结果摘要。

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 出圈真正的原因是灵活的插件组合能力,赋予未来更多的想象。