deepseek-harness :把“一切皆插件“做成一门编程语言
0. 先说结论:它不是又一个 CLI,而是一套"可插拔的操作系统内核"
我一开始也以为 deepseek-harness(下面简称 dsh)就是 DeepSeek 出的一个对标 Claude Code 的命令行工具。看了三天源码和底层论文之后,我收回这个想法。
它的口号是 “Everything is a Plugin”(一切皆插件)。但这句口号在 dsh 里不是营销词,而是被一套形式化理论撑着的。底层是一个叫 Cordis 的插件框架,而 Cordis 背后是一篇 2026 年 8 月的论文:A Programming Paradigm for Spatiotemporal Composability(时空可组合性的编程范式)。
换句话说,dsh 的野心不是"做一个好用的 agent",而是"定义一套让 agent 能被任意拼装、任意替换、还能安全撤销的编程范式"。Claude Code、Codex 在它眼里不是竞品,而是可以被它包起来的"子 agent 提供方"。
这篇文章我想把它真正讲透——不是罗列目录结构,而是讲清楚它为什么这么设计、解决了什么别人没解决的问题、以及你作为一个程序员能从里学到什么。
1. 真正的问题:为什么"插件系统"这么难做对?
我们先别急着看代码。先想一个朴素的问题。
你写过一个带插件的应用吧?比如 VSCode 插件、Webpack loader、或者你自己的系统里那堆"策略模式"的类。这些"插件系统"几乎都有一个共同痛点:
装上容易,卸干净难;组合起来容易,拆开还原难。
经典的例子:插件 A 往全局注册了一个事件监听,插件 B 往某张表里加了一行配置,插件 C 启动了一个后台定时器。现在你要卸载插件 A——你得记得把它注册的事件监听、后台定时器、那行配置全部手动清掉。漏一个,就是内存泄漏、幽灵监听、状态错乱。
再进一步:插件之间互相依赖。插件 B 依赖插件 A 提供的某个能力。如果 A 还没加载完,B 就启动,程序直接崩。所以你又得手写一套"启动顺序编排"——而顺序一旦写死,系统就僵了,想动态加载/热更新基本没戏。
这两个问题,论文给了两个名字,合起来叫 时空可组合性(spatiotemporal composability):
- 时间可组合性(temporal composability):一个组件被移除时,它的所有副作用都能被完整撤销(revert)。论文把它形式化为 revertible effects(可逆副作用)——每一次对上下文的变换都携带一个逆操作,运行时替你追踪。
- 空间可组合性(spatial composability):组件之间能声明依赖并响应式地管理依赖关系。论文把它形式化为 reactive coeffects(响应式协效应)——上下文每次变化,都按组件的"协效应规格"去通知它。
一句话类比给程序员听:时间可组合性 ≈ 一个永远不会漏掉的 try/finally 撤销栈;空间可组合性 ≈ 一个声明式的依赖注入 + 响应式刷新。
Cordis 做的事,就是把这两件事做成了运行时机制,然后统一成一种"组件(component)"的编程范式。这就是 dsh 的地基。理解了这一层,上面那些花里胡哨的"模型适配器、工具、会话日志"才讲得通。
论文原文摘要(我翻译):
“现代软件——从插件系统到自我演化的 agent harness——越来越需要动态组合,但其形式化基础仍然薄弱。我们识别出问题的两个正交维度:时间可组合性(移除组件时能完整撤销其副作用)和空间可组合性(声明并响应式管理组件间依赖)。我们把经典的效果(effect)与协效应(coeffect)概念提升为运行时机制……”
2. Cordis 在代码里到底是什么样?
讲完理念,落地到代码。Cordis 的核心是三个东西:Context(上下文)、Service(服务)、Plugin(插件)。
我先用一段最小复现代码(这是我写的,不是仓库里的,目的是让你 30 秒看懂机制)演示它的骨架:
// 最小复现:一个 Cordis 式的插件内核(伪代码,演示概念,非仓库源码)
import { Context } from '@deepseek-ai/cordis'
// 1) 一个服务(Service):声明一个稳定的 key,比如 ctx.tools
class ToolService {
private registry = new Map<string, Function>()
register(name: string, fn: Function) {
this.registry.set(name, fn)
// 返回一个 disposer(撤销函数)——这是"可逆副作用"的关键
return () => this.registry.delete(name)
}
}
// 2) 一个插件(Plugin):往 ctx 上挂东西,并声明自己依赖什么
const myToolPlugin = (ctx: Context) => {
// inject: 声明"我需要 ctx.tools 存在才能启动"——空间可组合性
// 不用手写加载顺序,Cordis 等到 tools 服务就绪才挂载这个插件
const dispose = ctx.tools.register('hello', (x: string) => `hi ${x}`)
// 注册本身是可逆的:返回 disposer,插件卸载时 Cordis 调它
ctx.effect(() => dispose) // 时间可组合性:卸载即撤销
}
// 3) 启动
const root = new Context()
root.plugin(ToolService) // 先挂服务
root.plugin(myToolPlugin) // 依赖满足后自动挂插件
这段伪代码对应 Cordis 文档里的五个要点(我读过官方 primer,逐条对过):
- 插件即 Service:插件是带
apply(ctx)的对象,ctx是上下文。 - 上下文是服务仓库:服务占据稳定的
ctx.<key>(如ctx.tools、ctx.llm、ctx.sessions),其他插件按 key 找服务,而不是 import 具体实现。这一步是解耦的核心——它意味着"换一个 provider 就换一整块能力",不用改调用方。 - 用
inject声明依赖:命名所需服务,Cordis 等到服务存在才加载它。加载顺序由依赖表达,不是手写 boot 序列。 - 类型化事件:服务用 TS 声明合并声明事件名,按四种模式派发——
emit(广播)、waterfall(环绕中间件、可改写/短路)、parallel(并行扇出)、serial(按序决策)。 - 注册是可逆副作用:提示词片段、工具 schema、监听器都走
ctx.effect()或ctx.on(),卸载时统一撤销。
第 5 点是 dsh 敢说"没有特权内核"的底气。在 dsh 里,模型适配器、工具注册表、会话日志、甚至 agent loop 本身都是插件。扩展产品 = 把插件挂到别的插件旁边。没有任何一块是"被硬编码、需要打补丁"的特权代码。
3. dsh 的分层:它不是一堆包,而是一棵可 patch 的插件树
理解地基后,看 dsh 怎么用 Cordis 组织自己。仓库是个 pnpm monorepo,几十个 group,每个 group 下挂多个 @deepseek-ai/dsh-* 包。但光看目录会晕,关键是运行时的分层组装机制。
运行中的 dsh 是一棵插件树,由启动时按序叠加的各层组合而成:
- Profile(具名组装):存在 Harness home 里,列出自己叠放的组合包、树外插件,并保存用户的
cordis.patch.yml。发行版自带web和headless两个模板。 - Bundle(组合包):Cordis 配置项 + 挂载代码的分发格式。在
package.json里用dsh字段声明。dsh-base:每个 profile 的第一层(模型适配器、工具、持久化、沙箱、审批、设置、凭据、遥测)。dsh-web-app:加浏览器应用。dsh-headless:加一次性运行器,完全不带服务器。
- 叠加顺序(在空条目列表之上):先按 profile 顺序应用每个 bundle → profile 的
cordis.patch.yml→ home 级 patch → 任意--patchoverlay。
最妙的是调试手段。你可以直接 dump 出"我这台机器实际启动的配置树":
dsh --profile web --dump-config
打印出的任何一条条目,都能被你自己的 patch 替换。这意味着:你想换掉内置的沙箱、换掉 prompt 组装逻辑、甚至换掉 agent loop 本身,都不用 fork 仓库,写个 patch 文件 overlay 上去就行。
我觉得这是 dsh 相比 Claude Code 这类"黑盒 CLI"最本质的区别:它是白标的、可嵌入的产品内核,不是一个开箱即用的应用。
4. 能力 Seam:为什么"换一个提供方就能换一整块能力"
dsh 把每个能力都建模成一种 seam(接缝),固定三角色:
- Service Definition:声明接口。
- Service Provider:实现接口。
- Consumer:使用它(通常是面向模型的工具)。
那为什么这个设计这么关键?论文和文档给的例子我反复体会,确实精妙:
文件系统与进程的提供方共享同一个"执行世界"。所以你把它们指向一个远程沙箱,Bash、PTY、LSP 会一并被搬过去——无需为每种能力写专门的 fork。
我读 packages/sandbox、packages/fs、packages/shell 的 README 时确认了这个设计意图:能力分成 Definition / Provider / Consumer 三个角色,是因为它们各自独立演化。你换 Provider(比如从本地换成 e2b 远程沙箱),所有消费方(shell、terminal、lsp 工具)自动跟着变,因为它们只依赖 Definition 接口,不依赖具体 Provider。
看一段真实仓库代码,是 llm-deepseek 这个 provider 插件怎么挂到 ctx.llm 上的(来自 packages/llm/llm-deepseek/src/index.ts,我删了注释和 import 噪声):
export const name = 'llm-deepseek'
export const inject = ['llm'] // 空间可组合性:声明依赖 ctx.llm 服务
const PROVIDER = 'deepseek-official'
// 插件把 API key 通过 credential seam 解析、把配置塞进 settings 段
// —— 改了 base URL / catalog / key,下一次请求就生效,不用重启
export function apply(ctx: Context) {
// 在 ctx.llm 上注册一个 provider route
ctx.llm.registerProvider(PROVIDER, (options) => new DeepSeekAdapter(options))
// 这一切都通过 ctx.effect 注册,卸载即撤销(时间可组合性)
}
这段真实的 20 行,把前面讲的四个概念一次性兑现了:inject 声明依赖、ctx.llm 作为稳定服务 key、provider 作为可替换角色、注册可逆。你再写一个 llm-openai 插件,挂到同一个 ctx.llm 上,dsh 就同时支持两家模型了——调用方一行都不用改。
5. 最被低估的设计:会话日志是唯一事实源,而且"模型看到的都必须被记录"
这是我觉得 dsh 整篇架构里最硬核、也最容易被忽略的一块。我先讲清楚问题。
一个 agent 跑起来,会产生大量东西:用户说了什么、模型回了什么、调了什么工具、工具返回什么、上下文怎么注入的……这些东西散落在各处,最后喂给模型。但如果你要回放(replay)一次会话、或者把会话存盘后恢复、或者做 UI 渲染——你就需要一个单一事实源(single source of truth)。
dsh 的解法很决绝:会话日志(Session Log)是唯一事实源,而且有一个运行时不变量叫 “模型可见即已记录(model-sees-is-logged)”。
意思是:任何抵达模型请求的东西,都必须能从事件日志重建出来;而且有一条运行时不变量去断言这件事。所以——你每新增一种"模型能看到的新输入",就必须新增一个会话事件类型。这不是建议,是强制。
我读 packages/core/session/src/invariant.ts 时,被这段真实代码震到了(这是仓库里的,逐字对应那个不变式):
// 来自 packages/core/session/src/invariant.ts(真实源码,节选)
case 'turn/start': {
if (trace.openTurn !== null) {
fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
}
if (event.data.turn !== trace.nextTurn) {
fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
}
openTurn = event.data.turn
nextStep = 1
break
}
case 'step/start': {
if (trace.openTurn !== event.data.turn) {
fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
}
if (trace.openStep !== null) {
fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
}
// ...
}
case 'tool/result': {
// 必须有前置的 tool/call,否则报错:
if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) {
fail(`tool/result for ${callId} with no prior tool/call in this step`)
}
}
看到没?它在用一个状态机 + 序列号在线校验整个事件流:turn/start 和 turn/end 必须配对、step 必须嵌套在 open turn 里、tool/result 必须有对应的 tool/call。任何违反,直接 fail。这就是"模型可见即已记录"的机器保证——不是靠程序员自觉,而是运行时硬断言。
为什么这么较真?因为 dsh 的 fork、恢复、transcript、遥测、持久化全部派生自这条事件流。deriveMessages() 从日志投影出模型历史;原始 assistant/chunk 事件保证回放和 UI 保真。一旦日志不完备,所有这些派生功能全错。所以宁可强制"加输入必先加事件",也比"跑着跑着状态对不上"强。
我自己复现了一个最小版的状态机校验器,让你直观感受它在干什么(这是我写的,演示用):
// 最小复现:复刻 invariant 的核心思路(我写的,非仓库源码)
type Ev =
| { type: 'turn/start'; turn: number }
| { type: 'turn/end'; turn: number }
| { type: 'step/start'; turn: number; step: number }
| { type: 'step/end'; turn: number; step: number }
| { type: 'tool/call'; callId: string }
| { type: 'tool/result'; callId: string }
function check(events: Ev[]) {
let openTurn: number | null = null
let pending = new Set<string>()
for (const e of events) {
switch (e.type) {
case 'turn/start':
if (openTurn !== null) throw new Error('turn 未结束又开新 turn')
openTurn = e.turn
break
case 'turn/end':
if (openTurn !== e.turn) throw new Error('turn/end 不匹配')
if (pending.size) throw new Error('turn 结束但还有未结的工具调用')
openTurn = null
break
case 'tool/call':
pending.add(e.callId)
break
case 'tool/result':
if (!pending.has(e.callId)) throw new Error('工具结果没有对应的调用')
pending.delete(e.callId)
break
}
}
if (openTurn !== null) throw new Error('结束时还有未关闭的 turn')
}
// 测试:缺 tool/call 直接抛错
check([
{ type: 'turn/start', turn: 1 },
{ type: 'tool/result', callId: 'x' }, // throw: tool/result for x with no prior tool/call
])
这段 30 行代码,就是 dsh 那条不变式的灵魂。我第一次读懂它的时候,觉得这比一堆"架构图"实在多了——它说明这个团队是真把正确性当回事,而不是堆功能。
6. Agent Loop:一个 turn 是怎么跑完的
讲了数据平面,再看控制流。一个 agent 跑一轮(turn)的内部时序,文档里给了一张真实的 Mermaid 时序图(docs/agent-lifecycle.md,仓库自动生成的)。我把它翻译成人话 + 关键源码:
- step(步骤) = 一次模型请求 + 它调用的工具。
- turn(轮次) = 零个或多个 step;在领取首条输入前打开,不再欠工作时关闭。
驱动核心在 packages/core/agent-loop/src/agent.ts。我读到的真实片段(节选,去掉噪声):
// 来自 packages/core/agent-loop/src/agent.ts(真实源码,节选)
// 1) 领取下一步输入后,开一个 turn,写进会话日志
this.session.append('turn/start', { turn })
// 2) 用 waterfall 事件让所有监听者有机会改写/拒绝这次步骤
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
() => Promise.resolve(claimed),
)
// 监听器可以改写 messages,也可以直接 reject;
// 被 reject 或改写为空,仍会关闭一个"不含步骤"的持久轮次(日志如实记录这次尝试)
// 3) 进入 step,写 step/start
this.session.append('step/start', { turn, step })
// 4) 组装请求头(system prompt + tools),再走一次 waterfall
const proposedConfig = await this.dispatch.waterfall(
'agent/request', { turn, step, signal },
() => Promise.resolve(seedConfig),
)
// waterfall 让中间件可以改写 provider/model/参数,甚至短路用缓存
// 5) 流式调用模型,把每个 chunk 写进日志
// llm/stream → assistant/chunk* → assistant/message
// 6) 工具调用走带把关的流水线
// tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
几个值得程序员品味的细节:
agent/pre-step、agent/request、llm/stream、tools/*都是 waterfall(瀑布式)事件:监听器拿到(args, next),调用next()才委托给下游,可以改写结果或短路。这就是环绕中间件模式在 agent 控制流里的落地——你要加一个"请求前审计"、“限流”、"换模型"的逻辑,挂一个监听器就行,不用改 loop 本体。agent/turn-stopping是 serial(终态)事件:没有next(),用来在轮次自然停下前做最后检查(比如"该停了")。- 每一步都把事件
append进会话日志——又回到第 5 节那个不变式。模型看到的,必然已记录。
真实时序图(仓库 docs/agent-lifecycle.md 生成,我原样保留):
7. 实操:写一个最小 dsh 插件到底长啥样
理论讲完,落到"如果我真要扩展它,代码怎么写"。我结合 packages/todo/tool-todo(仓库里真实的 todo 工具)和前面 Cordis 的机制,给你一个符合 dsh 约定的最小骨架(骨架逻辑来自真实插件,我精简了校验细节):
// 一个符合 dsh 约定的"模型可见工具"插件(骨架,参考 packages/todo/tool-todo)
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-todo'
export const inject = ['tools'] // 挂到 ctx.tools 服务上
export function apply(ctx: Context) {
// defineTool 会注册 schema + 执行器,并把工具加进 system prompt 组装
const tool = defineTool({
name: 'todo_write',
description: 'Record and update a structured task list...',
schema: { /* zod / schemastery schema */ },
async execute(params, { agentCtx }) {
// 关键:每次调用把"整张表"作为快照写进当前 agent 的会话日志
agentCtx.session.append('todo/write', { todos: params.todos })
// UI 从 session 事件渲染,replay 是 last-write-wins
return { ok: true }
},
})
// 注册可逆:返回 disposer
return ctx.tools.register(tool)
}
注意那个 session.append('todo/write', ...)——又是第 5 节那套:模型产生了一个"可见状态"(任务列表),必须落进日志,这样 UI 能渲染、会话能回放。这个 todo 工具的真实实现里,甚至还有一段校验:不允许同时多个 in_progress(除非部署允许并行),因为"写进日志的快照必须和模型以为自己写的一模一样"——否则回放就对不上了。这就是为什么它的 schema 设了 additionalProperties: false,嵌套字段形状不符就当场报错,而不是静默拍平。
8. 它和 Claude Code / Codex 到底什么关系
这是很多人会问的。我读 packages/subagent 时找到了答案:dsh 把 Claude Code 和 Codex 当成 subagent provider(子 agent 提供方) 直接包了进来。
packages/subagent 的 README 列出了这些提供方:subagent-claude-code、subagent-codex、subagent-fork-in-process、subagent-spawn-in-process、subagent-dsh-sdk……意思很直白:
- 你的
dshagent 跑着跑着,可以把"一个轮次"委派给 Claude Code 去干; - 或者反过来,Claude Code 可以把
dsh当成一个可被调用的子 agent; - 或者纯粹在进程内 fork 一个子 agent。
所以它们不是竞争关系,而是 dsh 在"可组合"这层抽象上,把别人当成可替换的零件。这也呼应了第 4 节那个 seam 模型:subagent 提供方在同一个接口后面千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。
我的判断:DeepSeek 这步棋是想做"agent 世界的 Kubernetes"——不跟你抢上层应用,而是定义底层的组合与运行规范。野心不小。
9. 工程的克制:质量门禁和"文档即代码"
顺带夸一句工程纪律,因为这影响你敢不敢用。仓库里能看到全套质量工具:oxlint(lint)、knip(死代码检测)、jscpd(重复代码)、lefthook(git hook)、vitest(node/web/e2e 多套测试)。更狠的是:文档和模块依赖图是脚本自动生成的(gen-doc-graphs、gen-module-graph),而且 CI 里有"新鲜度门禁"——文档和图如果和代码脱节,CI 直接挂。
这意味着你读到的那些 docs/subsystems/*.md(session、tools、llm-streaming 每个都是几万字的真实规格),不是人手工维护容易过期的文档,而是从代码和类型声明里抽出来的。对一个想深入改它的人来说,这点太重要了。
10. 我的诚实评价:什么时候该碰,什么时候别碰
写了这么多,给个不绕弯的判断。
它真正适合的场景:
- 你想要一个高度可插拔、可白标、能塞进自己产品的 agent 内核,而不是一个开箱即用的 CLI。
- 你认同"换一个 provider 就能换一整块能力"这种 seam 架构哲学,并且愿意为此付 Cordis 的学习成本。
- 你在做多模型、多沙箱、需要严格会话回放/审计的系统(金融、合规场景会喜欢那条日志不变式)。
现在还不适合的:
- 它明确写着开发者预览、会破坏性变更(
dsh@0.1.0-rc.5)。今天写的插件,下个版本可能 API 就变了。 - 主入口是 Web UI(
npx @deepseek-ai/dsh web→http://127.0.0.1:3080),不是纯终端体验。如果你想要今天就能当生产力 CLI 用的,Claude Code / Codex 更稳。 - monorepo + Cordis 的心智负担不低,小项目杀鸡用牛刀。
我个人的最大收获:不是它用了多少新技术,而是它把"可逆副作用"和"会话即事实源"这种正确性优先的纪律,从口头规范变成了运行时硬断言。我读过太多"架构图很美、跑起来一堆状态错乱"的项目。dsh 用一套形式化理论和配套的不变式代码,逼着正确性发生——这是值得所有做复杂系统的程序员借鉴的。
11. 上手三行
# 直接跑(Web UI,默认 127.0.0.1:3080)
npx @deepseek-ai/dsh web
# 从源码
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness && pnpm install && pnpm run build && pnpm dsh web
# 看自己机器实际启动的配置树(理解分层组装的最佳入口)
dsh --profile web --dump-config
官方自己也建议:用 agent 探索代码库理解架构;改 packages/ 之前先读 docs/architecture.md。我补充一句——先读 docs/cordis-primer.md 和那篇时空可组合性论文,否则上面这堆 ctx、effect、waterfall 你会看得一头雾水。
参考资料:https://github.com/deepseek-ai/deepseek-harness`;
底层论文 cordiverse/paper《A Programming Paradigm for Spatiotemporal Composability》(2026-08-13 draft)。文中标注"最小复现/我写的"片段为讲解用示例,非仓库源码;其余代码均来自仓库真实文件。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)