DeepSeek Harness 系统架构与运行原理深度解析
如果你只把
dsh当成一个"能跑 Agent 的命令行工具",那你只看到了它的壳。DeepSeek Harness 真正有意思的地方,在于它把"一个智能体能跑起来"这件复杂的事,拆成了一棵可以被任意插拔、叠加、替换的插件树。本文不堆术语,而是顺着"它从哪一层开始,一层层怎么拼起来,一条消息最终怎么变成一个 turn"这条线,把它的系统架构和运行原理讲透。
如果你读过它的使用教程,应该对 dsh web、dsh --profile headless "任务" 这些命令有印象。那些命令只是冰山水面上的一角------你敲的命令越简单,底下替你兜住复杂度的架构就越厚。为什么配置要分层 patch?为什么换模型不用改源码?为什么 headless 任务结束时能给你一个干净的退出码?答案全在这套架构里。所以这篇文章不只是"讲原理",更是给你一张以后排查问题、写插件时随手能翻的地图。
一、先建立一张四层心智模型
理解 DeepSeek Harness(下文简称 dsh)最关键的一步,是别把它当成一个单体程序。它实际上是一组分层叠加的东西,从外到内大致可以分成四层:
- 用户入口层 :你敲的
dsh命令、Web 界面(默认http://127.0.0.1:3080),以及无界面的headless模式。这一层只负责"把人/脚本和运行时连起来",本身几乎不含业务逻辑。 - 组合层(Profile + Bundle) :决定"这次启动到底加载哪些能力、按什么顺序、用什么配置"。
web和headless就是这一层给出的两个预置组合模板。 - 运行时内核层(Cordis) :一个被 DeepSeek fork 并独立发版为
@deepseek-ai/cordis的元框架。它负责插件挂载、依赖编排、副作用回收------换句话说,它才是"让插件系统成立"的那块地基。 - 能力层(Plugins) :真正干活的部分。模型适配器、工具注册表、会话日志、Agent 循环......全都是插件,连"Agent 循环本身"都只是其中一个插件。
记住一句话:dsh 里没有需要被 patch 的特权核心(no privileged core)。你想扩展它,不是去改某个核心文件,而是在别的插件旁边再挂一个插件;插件卸载时,它注册的一切都会作为"可逆副作用"被自动撤回。这一点是后面所有设计的前提。
二、内核:Cordis 与"时空可组合性"
要理解 dsh 的架构,必须先理解它的底座 Cordis。Cordis 的作者 Shigma 同时也是开源聊天机器人框架 Koishi 的核心开发者------Cordis 就是把 Koishi v4 里跑了四年的插件管理代码,抽象成一个与业务无关的元框架(meta-framework)。所谓"元框架",意思是它自己不提供任何业务能力(没有 HTTP、没有数据库、没有调度器),只提供"怎么把业务能力组合起来的范式":插件化、依赖注入、生命周期管理。
它的设计哲学,集中在一篇配套论文《A Programming Paradigm for Spatiotemporal Composability》(时空可组合性的编程范式)里。这个名字听着吓人,其实拆成两个维度就很好懂:
2.1 时间可组合性:副作用必须能完整逆转
传统插件系统最大的坑,是"装上去容易、卸干净难"。一个插件可能注册了事件监听、开了文件句柄、改了全局状态,等它被移除时,这些副作用常常残留下来,慢慢累积成内存泄漏、状态污染,甚至把进程搞崩。
Cordis 的要求很硬:任何组件在撤出时,必须能完整逆转它产生的所有副作用 。机制是 ctx.effect()------插件注册一个副作用时,返回一个"逆操作"(清理函数),运行时把这个逆操作存起来,卸载时按注册的逆序自动调用。这和 C++ 的 RAII、Rust 的 Drop 是同一个思想,只是被提升到了"运行时"层面:哪怕不同团队写的插件,也能在卸载时干净利落地退场。
在 Cordis 内部,每个插件实例对应一个 Fiber (生命周期状态机 + 副作用回收器)。插件从 PENDING → LOADING → ACTIVE → DISPOSED 走确定性的状态转换,卸载时同步清空它的 _disposables 列表并反向执行 dispose。这也是为什么基于 Cordis 的 Koishi 服务能三年无数次更新插件而进程从不重启。
2.2 空间可组合性:依赖必须被显式声明
复杂系统里,组件之间有一堆隐式依赖。A 模块依赖 B 的某个功能,但如果没有显式声明,系统既不知道 B 必须在 A 之前加载,也不知道 B 不可用时该怎么优雅地处理 A。
Cordis 的要求是:任何组件必须显式声明它依赖的服务 ,框架据此自动编排加载顺序。插件通过 inject 声明所需服务(例如 inject: ['tools', 'llm']),Cordis 会等服务就绪后才启动该插件;如果服务一直不来,插件就安静地等着,而不是崩溃。这就是"声明式依赖"------插件声明"我需要什么",而不是"你塞给我什么",避免构造函数注入那种强耦合。
更进一步,Cordis 还提供 Service Isolation(服务隔离):可以为某个服务创建一个隔离上下文,使得上下文内外的插件互相不可感知。这对多租户、沙箱场景至关重要。
空间可组合性的底层实现很巧妙:Context 对象本身是一个 Proxy,你写 ctx.foo 看起来像普通属性访问,但底层路由到具体 Fiber 的 service 存储;而 service 的存储 key 是 Symbol (在 root 首次 provide 时分配),不是字符串------所以同一个名字 'db',在不同的 isolate 下对应不同的 Symbol,天然互不干扰。Context 提供三种嵌套操作:
extend(meta):创建子 context,原型链继承父级;isolate(name, label?):把 name 映射到新的 Symbol(共享 label 即共享 service),用于"会话/请求隔离";intercept(name, config):在原型链上累积 intercept 配置,用于"不改 service 实现就改配置"。
2.3 路径无关性:让热替换变安全
时间 + 空间两个维度合到一起,产生一个对 Agent 运行时极其关键的性质------路径无关性(path independence):一个 Cordis 应用的最终状态,只取决于"哪些插件被启用",而不取决于它们被加载/卸载的先后顺序。
这意味着你可以编辑一个插件的源码、热替换(HMR)它,系统状态不会乱。Cordis 的 HMR 用的是"模块缓存备份 + 全量重导入 + 插件重注册"的工程实现(基于 chokidar 监听 + ModuleLoader.loadCache 与 require.cache 双清 + 回滚),做到"不停机改代码"。对长时间运行的 Agent 服务来说,这个性质不是锦上添花,而是生死线------我们后面会解释为什么。
2.4 内核内部:五个服务与五种事件分派
如果你愿意往 Cordis 源码里多看一眼,会发现它的核心层出奇地克制:整个 packages/core 的运行时依赖只有两个------cosmokit(基础工具库)和 @standard-schema/spec(配置校验标准接口),核心代码不到三千行,却密度极高。它把全部能力收敛到挂在同一个 Context 上的五个服务:
- Fiber:生命周期状态机 + 副作用回收器,一个插件实例对应一个 Fiber;
- Registry :插件注册表,负责插件去重与
@Inject依赖声明; - Reflect :服务注册表,同时也是
Context这个Proxy的陷阱(trap)处理器; - Events:事件系统,提供五种分派模式;
- Logger:日志缓冲与导出机制。
这里最值得玩味的是 Events 的五种分派模式被合并到同一条 _resolve 路径 里:emit(发完就走)、parallel(并行)、serial(串行)、bail(遇错即停)、waterfall(链式委托)。dsh 上层那些 agent/pre-step、tools/* 的"waterfall vs serial"区别,根源就在这里------同一个事件系统,按你声明的方式决定监听器之间如何协作。理解这一点,你写插件时就不会搞混"我该调 next() 还是该独占返回"。
还有一处实现细节对理解 isolate 至关重要:Cordis 用 tracker + Proxy 双层 机制(utils.ts 里的 createTraceable),让一个 service 方法被调用时,this 会被替换成一个带着"当前调用方 ctx"的 shadow receiver。也就是说,service 内部写 this.ctx,拿到的永远是"当前调用方"的 ctx,而不是"创建这个 service 时"的 ctx------这正是 isolate 语义能成立的物理基础。没有这层,所谓的"会话/请求隔离"在方法内部就会串味。
三、为什么 Agent 运行时特别需要这套内核
你可能会问:IDE、浏览器都有插件系统,dsh 的"一切皆插件"到底特殊在哪?
答案在于,Agent 运行时把插件系统拉伸到了它从未被设计去承受的方向 。一个 Agent 循环不是一个"挂扩展的静态宿主"------它是一个跨多个 step 管理状态、持有上下文、调用工具、而且最要命的是可以在运行中要求修改自身行为的系统。当系统里某个组件被移除或替换时,所有依赖它的下游要么适配、要么优雅失败。
文本编辑器可以容忍一个插件留下悬空引用;但一个已经承诺了多步计划的 Agent 不能。Cordis 要填的就是这个坑:它不把插件管理当成一个便利特性,而是把"组合"本身当成一个要被形式化解决的问题。这也是 DeepSeek 选它做 Agent runtime 底座、而不是从零写一个传统插件管理器的根本原因。
举一个具体的反面例子,你就明白差别在哪。假设一个"朴素"的 Agent 宿主用传统插件管理器:插件 A 提供了"记忆检索"服务,插件 B 依赖它来做带上下文的回答。运行过程中,系统决定热卸载 A(比如要换一套检索后端)。在传统系统里,A 的代码被移除了,但它之前注册的定时刷新、文件句柄、全局事件监听可能没清干净;更糟的是,B 此时正处在一个多步计划的中间,下一轮它还要调 A 的接口------A 没了,B 要么拿到一个悬空引用直接崩,要么 silently 走错分支。而 Cordis 下,A 卸载会先按逆序跑完它所有的 ctx.effect() 逆操作,并且因为 B 声明了依赖 A 的服务,B 会在 A 不可用时被暂停/优雅降级,而不是在半路暴毙。对一个"一旦承诺就难以回滚"的 Agent 来说,这个区别是结构性的,不是工程细节。
四、组合层:Profile 与 Bundle 的叠加机制
理解了内核,再往上回到 dsh 自己的组合层。这里有两个核心概念:Profile 和 Bundle。
4.1 Profile:一份命名好的"能力组合"
一个跑起来的 dsh,本质是一棵在启动时按有序层次组合出来的插件树。
Profile 就是这份组合方案的名字,存放在 Harness 的 home 目录里。它干三件事:
- 列出它要堆叠的 Bundle;
- 持有它安装的"树外插件"(out-of-tree plugins);
- 保存用户自己的
cordis.patch.yml补丁文件。
web 和 headless 就是官方随包提供的两个 Profile 模板------一个带浏览器管理界面,一个是无服务器的"一次性运行器"。
4.2 Bundle:可分发的能力单元
Bundle 是 Cordis 配置行(config rows)和它们挂载的代码的分发格式 。换句话说,一个 Bundle 既能声明"我要往配置里插哪些行",又带着实现这些配置的代码。关键在于:它插入的任何东西,都能被它上面的层继续 patch------这正是 Cordis 分层组合的体现。
每个 Bundle 在自己的 package.json 里用一个 dsh 字段声明自己:
dsh.profile:列出这个 Profile 包含哪些 Bundle;dsh.bundle:指向这个 Bundle 的补丁文件。
dsh 里几乎所有东西都以 Bundle 形式存在,其中 dsh-base 是每一个 Profile 的第一层 ,它提供:模型适配器、工具、持久化、沙箱与审批策略、设置项、凭证、遥测。在此之上,dsh-web-app 加上浏览器应用,dsh-headless 加上一个完全没有服务器的单次运行器。
4.3 分层叠加的顺序
这是整篇文章里最该记住的一张"顺序表"。当 dsh 启动时,它对着一个空的插件入口列表,按以下顺序叠加各层:
- Profile 里按顺序列出的每个 Bundle;
- 该 Profile 自己的
cordis.patch.yml; - Harness home 级别的
cordis.patch.yml; - 命令行传入的任意
--patch覆盖层。
每一层对配置的修改,都是"按 id 定位某一行、整行替换其配置,或者插入新行"。想看清你本机实际启动的是一棵什么树,一条命令就够了:
sh
dsh --profile web --dump-config
它打印出来的每一行配置,你都可以用自己的 patch 去替换。这就是"没有特权核心"在操作上的含义:你不需要改源码,只要知道某行配置的 id,就能在任意层把它换掉。
4.4 一个具体的叠加例子
光说"分层叠加"有点抽象,给一个贴合文档描述的结构例子(以下字段结构源自官方对 dsh 字段与 patch 机制的说明,用于说明组合方式):
一个 Bundle 在自己的 package.json 里声明它指向的补丁文件:
json
{
"name": "my-dsh-bundle",
"dsh": {
"bundle": "./dsh-bundle.yml"
}
}
而那份 dsh-bundle.yml 就是一组"带 id 的配置行":
yaml
# 这个 bundle 往配置里插入一行模型适配
- id: model.deepseek
config:
provider: deepseek
baseURL: https://api.deepseek.com
model: deepseek-chat
当你想在不碰这个 Bundle 源码的情况下,把模型从 deepseek-chat 换成 deepseek-reasoner,你只需要在你的 Profile 或 home 级 cordis.patch.yml 里,用同一个 id 写一行覆盖:
yaml
# 你的 cordis.patch.yml
- id: model.deepseek
config:
model: deepseek-reasoner
因为叠加顺序是"Bundle 在前、用户 patch 在后",这一行会整行替换掉 Bundle 里那行的 config------注意是整行替换,不是深度合并里面的字段。这就是"一切皆插件、没有特权核心"在操作上的真实手感:你永远是"在某一层用 id 定位、整行换掉",而不是去改别人的代码。
五、核心包:长在 Cordis 树上的服务
dsh 把功能拆成了一组核心包,每个包向共享的 ctx 贡献一个服务(一个 ctx key)。官方架构文档列出了这些核心包:
| 包 | 负责什么 | ctx key |
|---|---|---|
core/session |
只追加的 SessionEvent 日志 + 内存存储 |
ctx.sessions |
core/system-prompt |
提示词分段与工具 schema 的装配 | ctx.systemPrompt |
core/tools |
作用域化的工具注册表 + 带护栏的执行流水线 | ctx.tools |
core/agent |
Agent 接口、活动注册表、agent/* 事件 |
ctx.agents |
core/agent-loop |
实现该接口的默认驱动器 | ctx.agentLoop |
core/scope |
每个 Agent 的作用域化注册原语(库,无 key) | --- |
llm/llm |
消息与流式词汇 + 适配器接缝 | ctx.llm |
注意一个共同点:它们都是插件,都是往 ctx 上挂一个服务 。模型适配器挂在 ctx.llm,工具挂在 ctx.tools,Agent 循环挂在 ctx.agentLoop。正因为如此,你才可以用"挂一个新插件"的方式,把模型换成另一个、把工具集换一套、把循环逻辑换一种------而核心代码一行都不用动。
逐个看这几个包,会更清楚"能力是怎么长出来的":
core/session:它维护的SessionEvent日志是整个系统的"记忆地基"。别的包都不自己存状态,而是往这条日志里追加事件,再从日志里派生自己需要的东西。core/system-prompt:负责把分散在各插件里的"提示词分段"和"工具 schema"装配成一次请求真正发给模型的那段内容。你加一个工具,它的 schema 会自动被这里收编。core/tools:不只是个注册表,它还有一个"带护栏的执行流水线"------工具调用不是想执行就执行,而是要过pre-execute/execute/post-execute三道关卡,这正是后面权限、沙箱能插手的地方。core/agent与core/agent-loop:前者定义Agent接口和"当前有哪些 Agent 活着"的注册表,后者是这套接口的默认实现(驱动器)。把循环逻辑也做成可替换的插件,意味着理论上你能换一套完全不同的调度策略。core/scope:这是一个很关键的"库"------它提供"把一次注册限定到单个 Agent"的原语。配合前面说的isolate,你就能做到"给会话 A 一套能力、给会话 B 另一套能力",而它们跑在同一个进程里互不串味。llm/llm:它定义的是"消息"和"流式"的词汇表,外加一个适配器接缝。模型厂商千差万别,但 dsh 只认这一套词汇,厂商差异被挡在适配器后面。
把这些合起来看,ctx 其实是一张"能力地图":每个包往上面钉一个 key,运行时按 key 取用。插件之间不直接 import 彼此,只通过 ctx 上声明好的服务协作------这正是 Cordis 依赖注入想达成的松耦合。
六、执行回路:从一条消息到一个 turn
这是运行原理里最硬核的一段。先定义两个词:
- step(步):一次模型请求 + 这次请求里调用的工具。一个 step = 一轮"模型说话 + 工具干活"。
- turn(轮):零个或多个 step。它在该 turn 的第一条输入被认领前打开,在"什么都不欠了"时关闭。
一条消息从进来到变成一个 turn,官方文档给出的时序大致是这样的(用文字版序列图表示):
text
turn/start
认领下一步的输入 + 一条排队消息
装配提示词分段 + 工具 schema
-> agent/pre-step reject | enter(消息)
若 reject,或首条 enter 被重写为空 -> 不消耗 step 直接关 turn
step/start
把 enter 的消息追加为 user/message
从日志推导出模型历史
agent/request -> llm/stream -> assistant/chunk* -> assistant/message
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
step/end
工具还欠一次请求,或下一步输入已到达 -> 认领 -> 下一个 step
-> agent/turn-stopping
turn/end
这里有几个必须点破的设计细节:
第一,事件分两类。 turn/*、step/*、user/message、assistant/*、tool/* 是持久化的会话事件(durable session events) ,会被写进日志、跨重载存活;其余的 agent/pre-step、agent/request、llm/stream、tools/* 是活着的扩展点,只在本次运行里有效。
第二,事件有不同的分派模式。 agent/pre-step、agent/request、llm/stream 以及三个 tools/* 是 waterfall(瀑布流) ------监听器必须调用 next() 才能把控制权往下传;而 agent/turn-stopping 是 serial(串行) 的,没有 next(),用来做"是否该停"的最终裁决。这种区分决定了你在写插件时,到底是该链式委托,还是该独占判断。
第三,输入走一个 inbox。 Agent 驱动只通过一个收件箱接收输入。有的消息会立刻唤醒它;有的注入上下文会在收件箱里等着,直到另一条消息把它"带"进一轮对话。
第四,agent/pre-step 决定模型"看到什么"。 监听器可以改写被认领的消息,也可以直接 reject 掉。即便首条认领被 reject 或重写成了空,系统仍然会关闭一个"没消耗 step"的持久化 turn------也就是说,日志会如实记录"这次尝试发生过",而不是悄悄吞掉。
6.1 一个具体场景:编码 Agent 跑测试
把上面的时序落到真实场景里会更好懂。假设你给 Agent 下了一条:"给 parser.ts 加单元测试,并跑通。"它大概会这样走:
- turn/start :驱动认领这条输入,装配提示词分段(来自
core/system-prompt)和工具 schema(来自core/tools,此刻ctx.tools里已经注册了fs、shell等工具)。 - step 1 :
agent/pre-step放行 →agent/request→llm/stream流出assistant/chunk*,最终合成一条assistant/message,内容可能是"我先读一下parser.ts"。 - step 1 的工具调用 :模型决定调
fs/read,触发tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*,把文件内容作为tool/result*写回日志。 - step 2 :因为工具"还欠一次请求"(模型看到结果后还要继续),驱动器认领下一个 step,模型这次决定写测试文件(
fs/write),再决定跑测试(shell/run)。 - step 3 :测试挂了,模型看到
tool/result*里的报错,进入"修复"step,改代码后重跑。 - 结束 :当模型不再调用工具、也没有新输入时,
agent/turn-stopping做最后裁决,turn 关闭,最终答案("已加测试并跑通,覆盖率 XX%")被打印。
注意整条链路里,每一次模型输出和每一次工具结果都是一条 SessionEvent------这正是下一节要说的"日志即真相"。也正是因为它全进了日志,你才可以在中途 fork 出一条分支("如果当时让它用另一种写法会怎样?"),而不用重头跑一遍。
七、会话日志:模型所见,即所记
dsh 有一条很强的运行时不变量,值得单独成节:Model-visible means logged(模型能看到的,必然已被记录)。
意思是:任何抵达一次模型请求的内容,都必须能从会话日志里重建出来;运行时甚至会用断言来强制这一点。这就是为什么"一条新的、模型可见的输入,必然对应一条新的会话事件"------如果它没进日志,它就不该进模型。
会话日志(core/session 维护的那条 SessionEvent 流)是模型所看到上下文的唯一真相来源 。deriveMessages() 从这条日志投影出模型历史;原始的 assistant/chunk 事件被保留下来,是为了支持回放和 UI 保真。
这套设计的红利是巨大的:会话的 fork(分叉)、resume(恢复)、转写(transcript)、遥测、持久化 ,全部都从这一条流派生出来。你不用为每个功能单独设计存储------只要它进了日志,上面那些能力自然就有了。这也是为什么官方文档会强调:要加一个"模型可见的新状态",正确做法是扩展 SessionEventMap 并从日志里渲染,而不是偷偷塞个变量给模型。
这条不变量还顺手给了你一个极省事的排查心法:当一个 Agent 行为反常,先别去翻代码,去看它的会话日志。因为模型看到的每一字节都在这条 SessionEvent 流里,你几乎总能从某一行事件里定位到"它那一刻到底看到了什么",进而反推是哪一层 patch、或哪个插件把不该出现的内容塞了进去。换句话说,会话日志在这里不是事后的审计附件,而是运行时的第一现场。
八、能力缝合:Capability Seams
dsh 把"可替换的能力"抽象成一个叫 seam(接缝) 的概念。一个 seam 由三个角色组成:
- Service Definition(服务定义):声明接口;
- Service Provider(服务提供者):实现接口;
- Consumer(消费者):使用它,通常是一个"面向模型的工具"。
一个包可以身兼多角,但只有单一角色的不能算一个 seam;加一项能力,意味着要把这三个角色都设计齐。
用一个最小例子体会这三角色怎么配合。假设你想要一个"文档摘要"能力:
- Service Definition(定义) :声明接口
Summarizer,签名是"输入长文本、输出摘要",不关心背后是谁算的; - Service Provider(实现):你可以写一个本地用 DeepSeek API 实现的 provider,也可以后面换成"调用一个独立的摘要微服务"的实现;
- Consumer(消费) :一个叫
summarize的模型可见工具,它只依赖Summarizer接口,调用时完全不知道底下是本地还是远程。
这样一来,"把摘要从本地 DeepSeek 换成远程服务"这件事,只需要换 Provider,Consumer(工具)和模型侧一行都不用改------这就是 seam 的价值:能力被接口化了,替换发生在接缝处,而不波及调用方。
seam 的意义在于:换一个服务提供者,就能改变整个产品的行为 。文档举了一个很能说明问题的例子------文件系统(fs)和子进程(subprocess)的 provider 共享同一个执行世界。所以当你把这套 provider 指向一个远程沙箱时,Bash、PTY、LSP 会一起被搬过去,而没有任何 provider 需要为这种迁移写分支。
子 Agent(subagent)的 provider 也在一套接口后面千变万化:从"一个全新的子 Agent"到"在另一个产品里委托的一轮对话"。甚至还有一个实验性的 Agent Teams :一个私有的、可选开启的协调 seam,挂在 ctx.agentTeams 上,提供持久化的花名册、任务板和信箱,叠在"可续跑的子 Agent"之上。
顺带说一个安全边界。dsh-base 这一层除了模型适配器,还负责沙箱与审批策略(sandbox and approval policy) ------也就是说,一个工具到底能不能真的去执行、执行前要不要先问人一眼,是由这一层把关的,而不是工具自己说了算。这正是"护栏"该放在接缝处、而不是散落在各插件里的体现。另外在 Cordis 的配置体系里,YAML 支持一种能直接写 JS 表达式的 !js tag,能力很强,但显然也是安全隐患;当你用 patch 往配置里塞东西时,要对这份"配置即代码"的权力保持清醒。
九、扩展点全景:新行为该往哪放
官方架构文档给了一张"新行为映射到机制"的表,非常实用,这里转述核心部分:
| 你想做的事 | 该用的机制 |
|---|---|
| 加一个模型 provider | 在 ctx.llm 上注册它的适配器 |
| 加一个面向模型的能力 | 在 ctx.tools 上注册;它的 schema 会自动并入提示词装配 |
| 给某个会话不同的能力集 | 组合一个 agent preset;对应 service 行需要 isolate 一个 realm |
| 加 shell 执行 | 注册一个 ctx.shell 后端;本地的通过 ctx.subprocess 拉起 |
| 加持久终端执行 | 注册 ctx.terminals 后端 + dsh-tool-terminal |
| 加一个人工命令 | 注册在 ctx.commands;它不走模型 turn,直接分派 |
| 加后台工作 | 注册在 ctx.jobs;job_* 工具负责收集或停止 |
| 加文件系统访问或策略 | 注册 ctx.fs provider,或监听 fs/* 事件 |
| 约束被拉起的进程 | 用 ctx.sandbox 后端;消费者在拉起前包裹 argv |
| 拦截一次请求/工具/turn | 用对应的 agent/* 或 tools/* 事件;agent/turn-stopping 能停掉一轮 |
| 加面向模型的上下文 | 调 agent.inject();它会落到下一次被接纳的请求里 |
| 加 UI 或编辑器集成 | 驱动 ctx.agents 并从 session/event 渲染 |
| 加一个 Web Client Chat 节点 | 注册 ConversationNodeDefinition + 带 key 的渲染器 |
| 加持久会话状态 | 扩展 SessionEventMap;从日志渲染与回放 |
| 生成会话标题 | 注册唯一的 ctx.sessionTitle provider |
| 在同一会话里管理目标 | 用 ctx.goals;通过 agent/* 续跑 |
| 分叉一个活跃会话 | ctx.sessions.fork(source, boundary?, childSessionId?) |
| 把注册限定到单个 Agent | 用那个 Agent 的 agent.ctx |
这张表几乎就是 dsh 的"能力地图"。你会发现,无论你想加什么,路径都是同一句话:找到一个文档化的扩展点,挂一个插件上去,而不是去改核心。
十、串一遍:一次 headless 任务的完整生命周期
把前面所有层串起来,一次 dsh --profile headless "运行这个项目的测试套件" 到底发生了什么:
- 入口层 :
dsh解析参数,识别出--profile headless,加载headless这个 Profile 模板。 - 组合层 :按"Bundle 顺序 → profile patch → home patch → --patch"叠加出一棵插件树,其中
dsh-base先就位(模型适配器、工具、持久化、沙箱策略、凭证等),dsh-headless再叠上"无服务器的一次性运行器"。 - 内核层 :Cordis 按依赖声明编排所有插件的加载顺序,等
ctx.llm、ctx.tools、ctx.agentLoop等服务都就绪后,让core/agent-loop进入 ACTIVE 状态。 - 输入进入:任务描述字符串作为一条消息进入驱动器的 inbox,唤醒它,开启一个 turn。
- 装配 :
agent/pre-step决定模型看到什么;core/system-prompt装配提示词分段,core/tools提供工具 schema。 - 执行回路 :
agent/request → llm/stream → assistant/chunk* → assistant/message,若模型决定调用工具,则走tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*。如果工具"还欠一次请求",就认领下一个 step,继续循环。 - 落日志 :上述每一步的
user/message、assistant/*、tool/*都作为SessionEvent写进会话日志------这就是"模型所见即所记"。 - 结束 :不再有欠下的请求、也没有新输入到达时,触发
agent/turn-stopping做最终裁决,turn 关闭,驱动器打印最终答案并以退出码0(成功)或非零(失败)退出。
整个过程中,没有任何一步是写死在核心里的"特权逻辑"------模型怎么连、工具怎么跑、循环怎么驱动,全都是树上挂着的插件,任何一个都可以被你自己的 patch 或插件替换掉。
这里也顺手解释了使用教程里提到的 headless 退出码 :turn/end 正常走完、最终答案成功产出,进程就以 0 退出;如果中途 agent/turn-stopping 裁决为停止、或在某个 step 的执行流水线里出错,退出码就是非 0。所以你才能在 CI 脚本里直接拿退出码判断任务成败------因为"一轮对话"在 dsh 里是一个有清晰起止边界、可被外部观测的对象,而不是一个糊在进程里的黑箱循环。
十一、与其它框架的架构对照
把 dsh 的底座 Cordis 放进更大的框架版图里看,会更清楚它"补的是哪块空白"。Node.js 生态里,其实没有一个框架同时做到"插件化 + 自动 effect 清理 + Context 嵌套隔离":
- NestJS :有依赖注入(DI),但没有 effect tracking------写 NestJS 插件的人仍然要手动清理 timer、listener,漏一个就是线上 bug;
- Pluggy (pytest 的插件系统):有 hook 系统,但没有 DI------插件之间不能声明依赖,加载顺序得靠人肉约定;
- Effect-TS :有 effect 模型,但它不是框架------生命周期要用户自己处理;
- Vue / React 的 plugin + context :只覆盖 UI 层,没有应用级的 lifecycle。
Cordis 的差异化卖点,恰恰是把"插件编排 + 依赖注入 + 资源自动清理"这三件事塞进同一个被形式化过的 Context 类型里。对 dsh 这种 Agent runtime 来说,这个组合不是可选项:它既要空间上的依赖编排(模型适配器没就绪,工具插件就别启动),又要时间上的副作用可逆(换个 provider 不能留下半个状态的残局),还要路径无关(运行时自我重配不能 corruption 自身)。这三点单独看都有人做,但只有被同一个统一 Context 从一开始串起来,它们才能可靠地组合------这正是那篇论文想论证的核心。
十二、结语:这套架构真正值钱的地方
回过头看,DeepSeek Harness 最值得关注的,不是"它能跑 Agent",而是它用 Cordis 的时空可组合性 当底座,把"一个能自我重配而不 corruption 自身状态的运行时"这件事,从一句口号变成了有论文背书、有四年 Koishi 实战沉淀的工程现实。
对想基于它做二次开发的人来说,这套架构最大的红利其实是"可控的不确定性":Agent 系统天生充满不确定,但 dsh 把不确定性关进了"插件树 + 会话日志"这两个确定性结构里------你能替换任何一部分,也能回放任何一段历史。理解了这两点,你就不会在它快速迭代时迷失,反而能借着它的可组合性,把自己的业务稳稳长在它上面。
对使用者来说,这带来两个很实在的好处:
- 可替换性:想换模型、换工具集、换执行环境,不需要 fork 改源码,挂个插件、写个 patch 就行;
- 可演进性:因为副作用可逆、组合路径无关,系统可以长期运行、热替换、反复实验,而不必动不动重启进程。
当然,必须诚实地说:dsh 目前处于 Developer Preview ,官方明确警告会有不兼容的破坏性更新,命令和配置都可能变。但无论表层怎么改,支撑它的那几个核心概念------Profile 组合、插件树、Cordis 运行时、会话日志即真相、能力 seam------大概率会长期存在。抓住这几个,你就能在它持续演进的过程里不迷路。
给想深入源码或写插件的开发者几点建议
如果你读到这里想动手,几点实在的经验:
- 读源码的入口 :别一上来翻
packages/下的所有东西,先读 Cordis 的 primer(docs/cordis-primer.md)和 tutorial,再去看core/session、core/tools、core/agent-loop这几个包------它们是理解"日志、工具、循环"三条主线的钥匙。 - 调试先看树 :任何"为什么我的配置没生效"类的问题,第一步永远是
dsh --profile web --dump-config,看实际启动的插件树里那一行到底长什么样,再决定用哪一层 patch 去改。 - 写插件时记两条铁律 :一是副作用一定要通过
ctx.effect()返回清理函数,别自己ctx.on()注册后不管;二是分清你要挂的事件是 waterfall 还是 serial,该调next()的地方不调,链路就断了。 - 尊重"模型可见必进日志"这条不变量 :任何你想让模型看到的新信息,老老实实发一条
SessionEvent,别图省事直接塞变量------否则 fork、resume、遥测全都会失真。
把这三篇文章连起来看会更完整:使用教程带你把 dsh 跑起来、把命令用熟;概念综述帮你建立"它是什么"的整体印象;而这篇架构文,则是把前两篇里那些"为什么配置要分层""为什么换模型不用改源码"的疑问,落到一套可验证的设计语言上。三篇合起来,从"会用"到"懂它为什么这么设计",算是把 DeepSeek Harness 这条线摸透了。
本文基于 deepseek-ai/deepseek-harness 官方 docs/architecture.md、cordis 官方仓库及社区源码解读(floatboat.ai、iceyao.com 等)整理。dsh 处于快速迭代阶段,架构细节请以你安装版本的官方文档与 dsh --profile web --dump-config 的实际输出为准。