DeepSeek Harness 源码深度分析:像 VS Code 一样插件化的 Agent 框架

435 阅读19分钟

DeepSeek Harness 源码深度分析:像 VS Code 一样插件化的 Agent 框架

2026年8月13日,DeepSeek 以 MIT 协议开源了自研 Agent 框架 deepseek-harness(dsh)。它的架构哲学和 VS Code 一模一样——一切皆插件。但 dsh 做得更狠:别说工具和 UI 了,连 Agent 循环、模型适配器、沙箱策略、会话存储,全都能换。本文从源码层面拆解它的可扩展性设计、核心数据结构和工程实践,掰开揉碎了讲清楚它到底牛在哪、你能从中学到什么。


一、先给个画面:dsh 到底是个啥

如果你把 Agent 比作一个司机——

  • 模型是大脑,负责判断"下一步怎么走"
  • Harness 就是整辆车:方向盘、油门、刹车、导航仪、后视镜

模型只负责预测下一个 token,但 Harness 决定了模型能看到什么、能调用哪些工具、上下文怎么组织、出错怎么重试、什么时候判断任务完成。同一个模型放进不同的 Harness,表现能差出一个量级。

dsh 的思路是:这辆车的每个零件,从发动机到雨刮器,都能换。 而且你不需要拆车——写个配置文件就行。

# 一行命令,车就发动了
npx @deepseek-ai/dsh web

仓库在 github.com/deepseek-ai/deepseek-harness,MIT 协议,97% 是 TypeScript,代码质量极高。


二、和 VS Code 对着看:同样是插件化,谁更彻底?

用过 VS Code 的话,理解 dsh 只需要 30 秒。两者都是微内核架构,但有一个关键区别:

对比维度VS CodeDeepSeek Harness
微内核Electron + Extension HostCordis 插件框架
扩展点contributes(commands、languages、views...)ctx.on() 类型化事件 + ctx.effect() 服务注册
依赖声明extensionDependenciesinject 字段,依赖就绪才激活
配置方式package.json + contributes.configurationcordis.yml,支持 !!js 环境变量注入
可替换范围编辑器功能(主题、语言、调试器、视图)Agent 的全部组件(模型、工具、循环、沙箱、持久化、UI)

VS Code 的硬边界:你能换主题、换语言服务器、换调试器,但你换不掉编辑器的核心循环——文本缓冲区怎么管理、光标怎么渲染、选区怎么追踪,这些是写死在 VS Code 内核里的。

dsh 没有这个边界。Agent Loop 本身就是一个插件,叫 agent-loop。扩展插件依赖的是抽象的 Agent 接口(ctx.agents),永远不直接依赖具体的 agent-loop 实现。这意味着——你可以写一个新的 agent-loop 实现,把整个 Agent 循环逻辑换掉,其他插件完全不受影响。

这就像 VS Code 允许你换掉整个编辑器引擎,而不仅仅是换主题。这就是 Cordis 微内核的核心理念:没有特权核心,一切皆插件,只通过服务和事件通信。

2.1 眼见为实:连编辑器界面都能整个换掉

说"UI 也是插件"你可能觉得抽象,直接看效果——同一个 dsh,换个插件,编辑器就完全变了一张脸:

image.png

image.png 这就好比你的 VS Code 突然变成了 Vim 的界面,但插件、快捷键、语言服务器全部照常工作。因为编辑器本身只是一个 UI 插件,和模型适配器、工具注册表没有本质区别——都是 ctx 上挂载的一个服务。 想换?改一行 cordis.yml 就行。


三、可扩展性:你到底能换什么?

dsh 的扩展性不是"功能",是它的存在方式本身。我把它拆成三个维度:

3.1 维度一:12 个可替换的核心能力

就像你换电脑配件——显卡、内存、硬盘都有标准接口,dsh 的每个能力也有标准接口:

能力接口 Key类比替换一次影响
模型适配器ctx.llm换发动机所有 Agent 的模型调用
文件系统ctx.fs换硬盘全部文件读写
Shell 执行ctx.shell换变速箱所有命令执行
子进程ctx.subprocess换电路系统Bash、LSP、子 Agent 的进程管理
沙箱ctx.sandbox换安全气囊进程安全隔离
Web 访问ctx.web换导航仪搜索和网页抓取
子 Agentctx.subagents换车队管理系统多 Agent 编排
会话持久化ctx.sessionPersistence换行车记录仪所有会话的存储
上下文压缩ctx.compaction换内存管理长对话的上下文
代码执行ctx.codeRuntime换车载电脑Code Mode 的代码执行环境
凭证管理ctx.credentials换钥匙系统所有鉴权
用户设置ctx.settings换中控面板全部配置

3.2 维度二:8 个生命周期钩子

dsh 把 Agent 的一呼一吸都暴露为事件,你可以在任何时候插一脚:

钩子时机类比
agent/pre-step模型即将看到输入之前红绿灯——允许通行/禁止进入/改道
agent/request模型请求发出前安检——检查/修改请求内容
agent/request-error模型请求失败后拖车服务——重试或换路线
agent/turn-stopping一个 Turn 结束前终点检查站——决定是否继续跑下一圈
tools/pre-execute工具执行前门禁——允许/拒绝/需审批
tools/execute包裹工具执行计时器——超时/重试/打点
tools/post-execute工具执行后质检——结果改写/阻止/添加上下文
tools/result最终结果确定后监控摄像头——只看不改

这些钩子都是瀑布流(Waterfall) ,和 Koa/Express 中间件一模一样:每个监听器收到 (args, next),调用 next() 放行,不调用就短路。比如工具执行前,三个监听器可以依次检查 Hook 规则、权限策略、沙箱策略,任何一个说"不"就整个链条终止。

关键点:Code Mode 中模型生成的代码调用工具时,同样必须经过这条链。比如模型写 await tools.bash("rm -rf /"),pre-execute 链会先检查权限,拒绝的话代码直接抛异常。权限模型无法被代码绕过。

3.3 维度三:多 Agent 的"万能遥控器"

dsh 的子 Agent 系统只有一个接口 ctx.subagents,但背后可以挂完全不同的实现:

  • 进程内 Spawn:创建一个全新 Agent,独立上下文
  • 进程内 Fork:从现有会话分支创建子 Agent,继承历史
  • ACP 协议:通过 Agent Client Protocol 对接远程 Agent
  • Claude Code:把 Claude Code 当作子 Agent 调用
  • Codex:把 Codex 当作子 Agent 调用

这就像一个万能遥控器,能控制电视、空调、音响——上层不关心底层是什么牌子,只按同一个接口发指令。换一个 Provider,整个多 Agent 系统的工作方式就变了,但编排代码一行不改。


四、三种扩展方式,直接上代码

4.1 注册一个工具(最常用,5 分钟搞定)

import { defineTool } from '@deepseek-ai/dsh-tools'
​
export const name = 'my-tool'
export const inject = ['tools']  // 声明依赖,tools 就绪后才加载我
​
export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'read_file',
    description: 'Read a file from disk.',   // 模型看到的功能描述
    parameters: {
      path: { type: 'string', required: true },
      limit: { type: 'number' },
    },
    output: {
      schema: { type: 'string' },            // 返回值类型
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args, exec) {
      // args 是类型安全的,exec.signal 用于取消
      return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
    },
  }))
  // 插件卸载时,工具自动注销——就像 useEffect 的 cleanup
}

三个你不需要操心的事:参数校验自动完成、Code Mode 自动可用、卸载时自动清理。你只写业务逻辑。

4.2 接入一个新模型(核心是翻译工作)

class MyAdapter extends LlmAdapter {
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // 唯一的职责:把 Provider 的原始响应翻译成 StreamChunk 序列
    // 规矩:usage 在 finish 之前,finish 之后不产生任何输出
    // 错误两种路径:throw LlmError(传输失败)或 finish { kind: 'error' }(业务失败)
  }
}
​
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
  ctx.llm.registerAdapter(['my-provider'], new MyAdapter(config))
}

模型适配器本质上是一个翻译器——把 OpenAI 格式、Anthropic 格式、自研格式的流式响应,统一翻译成 dsh 内部的 StreamChunk 序列。Tool-call 的 arguments 必须保持原始 JSON 字符串,不能解析——这是为了保持日志的 "lossless JSON" 特性。

4.3 写一个 Hook 插件(权限门禁,10 行代码)

export const name = 'permission-gate'
​
export function apply(ctx: Context) {
  ctx.on('tools/pre-execute', async (exec, next) => {
    if (!(await isAllowed(exec))) {
      return { kind: 'deny', reason: '这个操作被公司策略禁止了' }
    }
    return next()  // 放行给下一个监听器
  })
}

放在 tools/pre-execute 而非 tools/execute 是关键:pre-execute 的监听器可以按优先级排序,高优先级的先判断;execute 的监听器则用来包裹实际执行(比如加超时、加重试、打指标)。


五、会话日志:dsh 最精妙的设计,没有之一

5.1 把 Agent 的一生写成 Git 提交记录

dsh 最核心的数据结构是一个仅追加的会话事件日志。把它想象成 Git——Git 把每一次代码变更存成 commit,dsh 把 Agent 的每一次"呼吸"存成 event:

Session 日志(就像 git log):
  [seq 0]  turn/start     → 新一轮对话开始
  [seq 1]  step/start     → 开始一次模型调用
  [seq 2]  user/message   → 用户说了什么
  [seq 3]  assistant/chunk → 模型吐了一个 token "我"
  [seq 4]  assistant/chunk → 模型又吐了一个 token "来"
  [seq 5]  assistant/message → 模型完整回复 + token 消耗
  [seq 6]  tool/call      → 模型决定调用 bash 工具
  [seq 7]  tool/result    → bash 返回了结果
  [seq 8]  step/end       → 这次模型调用结束
  [seq 9]  turn/end       → 这轮对话结束

核心原则:Model-visible ⟺ Logged。模型能看到的一切,都必须能从日志中完整重建。这不是文档里的约定——代码里硬编码了断言,违反就崩溃。

5.2 为什么这个设计是神来之笔

第一,调试体验质变。 Agent 任务失败了,你不需要猜"模型当时看到了什么"——日志就是完整的行车记录仪,每一帧都有。Trajectory 视图可以按来源查看每一次上下文注入、每一个工具调用结果。

第二,"时光机"能力。 因为模型历史是从日志"派生"出来的(deriveMessages()),而不是另外存储的,你可以:

  • 回放:从任意时间点重放日志,看不同插件组合在相同轨迹上的表现——这等于给 Agent 做 A/B 测试
  • 分叉:从任意事件点分叉出新的会话,复用之前的历史,追加新操作——相当于 git checkout -b,不需要复制任何数据
  • 恢复:崩溃后从日志重建整个会话状态,精确到最后一个 token

第三,插件之间零耦合的共享数据层。 UI 渲染、Hook 桥接、遥测导出、审计——都是 ctx.on('session/event', ...) 的监听器,各自消费同一份日志,互不干扰。这就像 Linux 的 /dev 目录——"一切皆文件"让所有工具能协同工作。

第四,日志自带版本控制。 插件可以通过 TypeScript 声明合并向 SessionEventMap 添加新事件类型。比如 Compaction 插件添加 compaction/start、compaction/summary、compaction/end。SESSION_FORMAT_VERSION 机制确保:旧版本遇到不认识的事件类型会拒绝加载(除非事件标记了 ignorable: true),防止静默数据损坏。

5.3 一个具体例子:日志的"Surface"机制

日志中的消息事件(user/message、assistant/message、tool/result)携带 SurfaceOp 元数据,声明它如何进入模型的"可见消息列表":

  • 'append':正常追加到末尾——这是 99% 的情况
  • { op: 'replace', start, end }:替换一段消息——用于 Compaction 把旧消息替换成摘要

想象一下:当对话长了需要压缩,Compaction 插件不是修改原始日志,而是追加一个带 replace 标记的新事件。模型看到的是压缩后的历史,但原始数据完整保留。这就像 Photoshop 的图层——你不破坏原始图片,只是在上面叠加了新的图层。


六、工具执行管道:三层关卡,插翅难逃

把工具调用想象成过海关,dsh 设了三道关卡:

[模型输出 tool-call:"bash rm -rf /"]
  │
  ├─ 第一关:tools/pre-execute (waterfall)
  │   安检员 A(Hook 规则):"检查你的 hook 配置..."
  │   安检员 B(权限策略):"你没有 root 权限,拒绝!"
  │   └─ 单调守卫 (ctx.tools.guard):"最终裁决,不可上诉"
  │
  ├─ 第二关:tools/execute (waterfall)
  │   计时员:"给你 30 秒,超时就终止"
  │   重试员:"失败了?再试一次"
  │   └─ 实际 execute() 函数体
  │
  ├─ 第三关:tools/post-execute (waterfall)
  │   质检员:"结果太长,截断到 10000 字符"
  │   上下文注入:"顺便告诉模型,这个文件已经改了"
  │
  └─ 监控摄像头:tools/result (同步通知)
      "记录到审计日志,不可篡改"

选关卡的规则:

你要做什么进哪道门为什么
权限检查、审批、沙箱拦截tools/pre-execute可以在执行前阻止,监听器可排序
不可被覆盖的最终否决ctx.tools.guard()后面的监听器无法撤销
超时/重试/指标tools/execute包裹执行,能访问 exec.signal
改写结果或添加上下文tools/post-execute此时结果已产生但未最终确定
审计/日志(只观察不改写)tools/result此时结果是不可变的

Code Mode 也不例外:模型生成的 TypeScript 代码调用 await tools.bash(cmd) 时,每个子调用都会记录 tool/code-dispatch 事件,然后完整走一遍三道关卡。Denial 会变成代码中的异常——模型写的代码无法绕过权限模型。


七、Capability Seam:为什么换一个 Provider 能改变整个系统

7.1 不只是"策略模式"

dsh 把每个可替换的能力抽象为 Capability Seam,包含三个角色:

Service Definition  → 声明接口("一个文件系统应该能读、写、删、列目录")
       │
       ├── Service Provider A → 本地文件系统实现
       ├── Service Provider B → E2B 远程沙箱实现
       └── Service Provider C → 内存文件系统实现(测试用)
       │
       └── Consumer → 模型可调用的 tool-fs,只依赖接口

这看起来像策略模式,但有一个策略模式没有的关键特性:传递性。

7.2 传递性:一次替换,全家迁移

文件系统(ctx.fs)和子进程(ctx.subprocess)共享同一个执行世界。所以当你把两者都指向远程沙箱时:

ctx.fs → fs-e2b (远程)
ctx.subprocess → subprocess-e2b (远程)

Bash、PTY、LSP——所有这些依赖 ctx.subprocess 的组件——全部自动迁移到远程执行,不需要逐个修改。这就像你换了一个房子的地基,所有建在上面的房间自动跟着迁移。

这个设计在 dsh 源码中体现为:每个 Provider 都声明自己属于哪个"执行世界",Consumer 不关心 Provider 是谁,只关心它提供的接口。一次 Provider 替换,整个执行环境跟着变。

7.3 多 Agent 的 Seam 是最佳案例

ctx.subagents 是最能体现 Seam 价值的例子。这个接口定义了一个子 Agent 应该能做什么(接收任务、返回结果),但背后的 Provider 可以是:

  • 进程内全新 Agent(dsh-subagent-spawn-in-process)
  • 从当前会话 Fork(dsh-subagent-fork-in-process)
  • 通过 ACP 协议对接的远程 Agent(dsh-subagent-acp)
  • Claude Code 实例(dsh-subagent-claude-code)
  • Codex 实例(dsh-subagent-codex)

上层编排逻辑(比如 workflow 工具)只依赖 ctx.subagents 接口,完全不感知底层是谁在执行。你今天用 Claude Code 做子 Agent,明天换成 Codex,编排代码一行不改。 这就是 Seam 的威力。


八、Profile 与 Bundle:Agent 的 Docker Compose

8.1 配置即组装

dsh 的插件组合不是写死在代码里的,而是通过 YAML 配置文件声明。这非常像 Docker Compose——你声明需要哪些服务,框架负责启动和管理:

# cordis.yml —— 就像 docker-compose.yml
plugins:
  dsh-llm-deepseek:              # 模型服务
    config:
      apiKey: !!js process.env.DEEPSEEK_API_KEY
  dsh-tool-bash:                 # Shell 工具
  dsh-tool-fs:                   # 文件工具
  dsh-tool-web:                  # Web 搜索工具
  dsh-session-persistence-sqlite: # 持久化

8.2 分层覆盖:像 CSS 一样的层叠

配置不是扁平的一层,而是像 CSS 一样有层叠优先级:

第 1 层:bundle 默认配置(官方预置的插件组合)
第 2 层:Profile 的 cordis.patch.yml(你的自定义覆盖)
第 3 层:Home 级别的 patch(全局偏好)
第 4 层:--patch 命令行参数(临时覆盖,优先级最高)

这意味着你可以在不修改源码的情况下,替换任何插件。 比如官方默认用 SQLite 做持久化,你一个 patch 就能换成 JSONL:

# cordis.patch.yml
plugins:
  dsh-session-persistence-sqlite:
    disabled: true
  dsh-session-persistence-jsonl:
    config:
      path: ~/my-sessions/

运行 dsh --profile web --dump-config 可以查看当前机器实际启动的完整插件树,调试配置变得极其简单。


九、Creator 模式:Agent 能给自己装插件

四种运行模式中,Creator 模式是最有想象力的一个。它做的事情很简单:让 Agent 能检查、试验和修改自己的插件树。

传统工具:你能用的功能 = 开发者写死的
Creator 模式:Agent 可以——
  1. ctx.cordisInspect → 查看当前运行时有哪些插件
  2. ctx.dynamicCordisRunner → 动态挂载/卸载插件
  3. 试验不同插件组合的效果
  4. 把验证过的组合导出为新的 Preset

这就像你给一个修车工配了一整套工具,然后跟他说:"你不仅可以用这些工具,你还可以自己改装工具、发明新工具、甚至重建整个车间。"

Harness 的配置本身变成了 Agent 可以操作的对象。 这离"Agent 自己改造自己的 Harness Runtime"还有距离,但方向已经非常明确。技术上,这是通过 self-modification 包实现的——它把 ctx.cordisInspect 和 ctx.dynamicCordisRunner 作为模型可调用的工具暴露出来,Agent 可以像操作文件一样操作自己的插件系统。


十、工程实践:TypeScript 能写到什么程度

dsh 的源码本身就是 TypeScript 工程化的教科书。几个你可能会"哇"的点:

声明合并:不碰核心代码,扩展核心类型

事件类型和 Context 接口通过 TypeScript 的 declaration merging 扩展,编译时就有类型检查,不是运行时字符串拼接:

// 核心定义(dsh-session 包)
interface SessionEventMap {
  'turn/start': { turn: number }
  'turn/end': { turn: number; reason: TurnEndReason }
  // ...
}

// 插件扩展(dsh-compaction 包,不修改核心代码)
declare module '@deepseek-ai/dsh-session' {
  interface SessionEventMap {
    'compaction/start': { turn: number }
    'compaction/summary': { summary: string }
    'compaction/end': { turn: number }
  }
}

这比运行时注册安全得多——如果你拼错了事件名,编译器直接报错,不会到生产环境才发现。

Branded Types:string 不安全,但 SessionId 安全

所有跨边界的 ID 都用 branded type 而非裸 string:

type SessionId = string & { readonly __brand: 'SessionId' }
type CallId = string & { readonly __brand: 'CallId' }
type MessageId = string & { readonly __brand: 'MessageId' }

这意味着你不可能把 CallId 当成 SessionId 传给函数——编译器会直接拒绝。这是一种零成本的类型安全增强,运行时没有任何开销。

所有注册都是可逆的——就像 useEffect 的 cleanup

// dsh 的注册模式
const dispose = ctx.effect(() => {
  // 注册副作用
  return () => { /* 清理副作用 */ }
})
// 插件卸载时,dispose() 自动调用

这和 React 的 useEffect(() => { ... return cleanup }, []) 一模一样。天然支持 HMR(热模块替换) ——插件卸载时所有副作用自动清理,新版本加载后重新注册,整个过程不需要重启进程。

运行时不变量:代码里硬编码的断言

dsh 的代码里有一些"要么正确,要么崩溃"的断言:

  • 模型可见内容必须能从日志重建——否则 throw
  • 瀑布流监听器必须调用 next() 除非有意短路——否则 throw
  • 未知事件类型必须标记 ignorable: true 否则拒绝加载会话——否则 throw
  • 配置缺失了必须字段——加载时 throw,不是静默跳过

这些不是文档里的约定,是运行时会崩溃的代码。 这种"fail loud"的设计哲学,让系统在问题发生的第一时间就暴露,而不是悄悄积累错误状态。


十一、你能从中学到什么

要点具体做法类比推荐
微内核 + 声明式扩展点核心只做调度,能力全是插件。扩展点用类型化事件而非字符串 keyVS Code 的架构哲学★★★★★
Event Sourcing 做日志所有交互存为追加事件,模型历史从日志派生。天然支持回放、分叉、A/B 测试Git 的 commit log★★★★★
Capability Seam 三件套每个可替换能力都有 Definition + Provider + Consumer,一次替换全家迁移电脑配件的标准接口★★★★★
Waterfall 中间件链用 (args, next) 模式做拦截,每个监听器可以 allow/deny/rewriteKoa/Express 中间件★★★★☆
声明合并扩类型TypeScript 项目中用 declaration merging 扩接口,编译时检查插件不碰核心代码就能扩展类型★★★★☆
三层工具管道pre-execute(权限)→ execute(超时/重试)→ post-execute(改写)海关三道关卡★★★★☆
Profile/Bundle 配置分层插件组合用 YAML 声明,支持多层 patch 覆盖,--dump-config 调试Docker Compose + CSS 层叠★★★★☆
Branded Types跨边界 ID 用 branded type 而非裸 string给 ID 贴上"防伪标签"★★★☆☆
注册即副作用所有注册返回 disposer,卸载时自动清理,天然 HMRReact useEffect★★★☆☆

十二、局限

  • v0.1 阶段,API 不稳定:官方明确声明会有破坏兼容性变更,现在接入需要承担迁移成本。适合学习架构,不适合生产使用。
  • Cordis 学习曲线:declaration merging、waterfall 语义、scope 管理——TypeScript 不够熟的话,刚上手会比较吃力。
  • "什么都能换"不等于更好的效果:插件系统提供的是差异化空间,但真正决定体验的还是默认插件的质量。就像 Linux 的"一切皆文件"很优雅,但没人用裸 Linux 内核。
  • 多 Agent 编排范式没有突破:Spawn/Fork/Pipeline/Ralph 都是已有模式,创新在架构层面(统一接口可替换 Provider),而非编排算法。

十三、总结

DeepSeek Harness 不是又一个 Coding Agent 客户端,而是一份 "如何用微内核架构做 Agent 运行时"的参考实现。它把 VS Code 的插件化哲学推到了极限——连 Agent 的核心循环都是可替换的插件。

如果你正在做 Agent 系统,从 dsh 里最值得拿走的三样东西:

  1. Event Sourcing 会话日志——Git 般的 Agent 交互记录,让回放、分叉、A/B 测试、崩溃恢复全部变成"免费"的能力
  2. Capability Seam 三件套——Definition + Provider + Consumer 的标准化接口模式,一次替换全家迁移,不像策略模式那样只管换算法
  3. Waterfall 中间件链做扩展点——用 Koa 中间件模式做 Agent 生命周期的拦截,比事件总线灵活,比 AOP 类型安全

如果只记住一句话:dsh 证明了一件事——Agent 框架的架构,完全可以像 VS Code 的插件系统一样优雅。 区别在于,它把"可替换"的边界从"工具"推到了"一切"。


参考来源: