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 的思路是:这辆车的每个零件,从发动机到雨刮器,都能换。 而且你不需要拆车------写个配置文件就行。
bash
# 一行命令,车就发动了
npx @deepseek-ai/dsh web
仓库在 github.com/deepseek-ai/deepseek-harness,MIT 协议,97% 是 TypeScript,代码质量极高。
二、和 VS Code 对着看:同样是插件化,谁更彻底?
用过 VS Code 的话,理解 dsh 只需要 30 秒。两者都是微内核架构,但有一个关键区别:
| 对比维度 | VS Code | DeepSeek Harness |
|---|---|---|
| 微内核 | Electron + Extension Host | Cordis 插件框架 |
| 扩展点 | contributes(commands、languages、views...) |
ctx.on() 类型化事件 + ctx.effect() 服务注册 |
| 依赖声明 | extensionDependencies |
inject 字段,依赖就绪才激活 |
| 配置方式 | package.json + contributes.configuration |
cordis.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,换个插件,编辑器就完全变了一张脸:

这就好比你的 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 |
换导航仪 | 搜索和网页抓取 |
| 子 Agent | ctx.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 分钟搞定)
js
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 接入一个新模型(核心是翻译工作)
js
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 行代码)
js
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:
bash
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 设了三道关卡:
sql
[模型输出 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,包含三个角色:
css
Service Definition → 声明接口("一个文件系统应该能读、写、删、列目录")
│
├── Service Provider A → 本地文件系统实现
├── Service Provider B → E2B 远程沙箱实现
└── Service Provider C → 内存文件系统实现(测试用)
│
└── Consumer → 模型可调用的 tool-fs,只依赖接口
这看起来像策略模式,但有一个策略模式没有的关键特性:传递性。
7.2 传递性:一次替换,全家迁移
文件系统(ctx.fs)和子进程(ctx.subprocess)共享同一个执行世界。所以当你把两者都指向远程沙箱时:
scss
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------你声明需要哪些服务,框架负责启动和管理:
yaml
# 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 一样有层叠优先级:
css
第 1 层:bundle 默认配置(官方预置的插件组合)
第 2 层:Profile 的 cordis.patch.yml(你的自定义覆盖)
第 3 层:Home 级别的 patch(全局偏好)
第 4 层:--patch 命令行参数(临时覆盖,优先级最高)
这意味着你可以在不修改源码的情况下,替换任何插件。 比如官方默认用 SQLite 做持久化,你一个 patch 就能换成 JSONL:
yaml
# 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 能检查、试验和修改自己的插件树。
markdown
传统工具:你能用的功能 = 开发者写死的
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 扩展,编译时就有类型检查,不是运行时字符串拼接:
typescript
// 核心定义(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:
typescript
type SessionId = string & { readonly __brand: 'SessionId' }
type CallId = string & { readonly __brand: 'CallId' }
type MessageId = string & { readonly __brand: 'MessageId' }
这意味着你不可能把 CallId 当成 SessionId 传给函数------编译器会直接拒绝。这是一种零成本的类型安全增强,运行时没有任何开销。
所有注册都是可逆的------就像 useEffect 的 cleanup
javascript
// dsh 的注册模式
const dispose = ctx.effect(() => {
// 注册副作用
return () => { /* 清理副作用 */ }
})
// 插件卸载时,dispose() 自动调用
这和 React 的 useEffect(() => { ... return cleanup }, []) 一模一样。天然支持 HMR(热模块替换) ------插件卸载时所有副作用自动清理,新版本加载后重新注册,整个过程不需要重启进程。
运行时不变量:代码里硬编码的断言
dsh 的代码里有一些"要么正确,要么崩溃"的断言:
- 模型可见内容必须能从日志重建------否则
throw - 瀑布流监听器必须调用
next()除非有意短路------否则throw - 未知事件类型必须标记
ignorable: true否则拒绝加载会话------否则throw - 配置缺失了必须字段------加载时
throw,不是静默跳过
这些不是文档里的约定,是运行时会崩溃的代码。 这种"fail loud"的设计哲学,让系统在问题发生的第一时间就暴露,而不是悄悄积累错误状态。
十一、你能从中学到什么
| 要点 | 具体做法 | 类比 | 推荐 |
|---|---|---|---|
| 微内核 + 声明式扩展点 | 核心只做调度,能力全是插件。扩展点用类型化事件而非字符串 key | VS Code 的架构哲学 | ★★★★★ |
| Event Sourcing 做日志 | 所有交互存为追加事件,模型历史从日志派生。天然支持回放、分叉、A/B 测试 | Git 的 commit log | ★★★★★ |
| Capability Seam 三件套 | 每个可替换能力都有 Definition + Provider + Consumer,一次替换全家迁移 | 电脑配件的标准接口 | ★★★★★ |
| Waterfall 中间件链 | 用 (args, next) 模式做拦截,每个监听器可以 allow/deny/rewrite |
Koa/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,卸载时自动清理,天然 HMR | React 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 里最值得拿走的三样东西:
- Event Sourcing 会话日志------Git 般的 Agent 交互记录,让回放、分叉、A/B 测试、崩溃恢复全部变成"免费"的能力
- Capability Seam 三件套------Definition + Provider + Consumer 的标准化接口模式,一次替换全家迁移,不像策略模式那样只管换算法
- Waterfall 中间件链做扩展点------用 Koa 中间件模式做 Agent 生命周期的拦截,比事件总线灵活,比 AOP 类型安全
如果只记住一句话 :dsh 证明了一件事------Agent 框架的架构,完全可以像 VS Code 的插件系统一样优雅。 区别在于,它把"可替换"的边界从"工具"推到了"一切"。
参考来源: