DeepSeek Harness 系统架构与运行原理深度解析

DeepSeek Harness 系统架构与运行原理深度解析

如果你只把 dsh 当成一个"能跑 Agent 的命令行工具",那你只看到了它的壳。DeepSeek Harness 真正有意思的地方,在于它把"一个智能体能跑起来"这件复杂的事,拆成了一棵可以被任意插拔、叠加、替换的插件树。本文不堆术语,而是顺着"它从哪一层开始,一层层怎么拼起来,一条消息最终怎么变成一个 turn"这条线,把它的系统架构和运行原理讲透。

如果你读过它的使用教程,应该对 dsh webdsh --profile headless "任务" 这些命令有印象。那些命令只是冰山水面上的一角------你敲的命令越简单,底下替你兜住复杂度的架构就越厚。为什么配置要分层 patch?为什么换模型不用改源码?为什么 headless 任务结束时能给你一个干净的退出码?答案全在这套架构里。所以这篇文章不只是"讲原理",更是给你一张以后排查问题、写插件时随手能翻的地图。


一、先建立一张四层心智模型

理解 DeepSeek Harness(下文简称 dsh)最关键的一步,是别把它当成一个单体程序。它实际上是一组分层叠加的东西,从外到内大致可以分成四层:

  1. 用户入口层 :你敲的 dsh 命令、Web 界面(默认 http://127.0.0.1:3080),以及无界面的 headless 模式。这一层只负责"把人/脚本和运行时连起来",本身几乎不含业务逻辑。
  2. 组合层(Profile + Bundle) :决定"这次启动到底加载哪些能力、按什么顺序、用什么配置"。webheadless 就是这一层给出的两个预置组合模板。
  3. 运行时内核层(Cordis) :一个被 DeepSeek fork 并独立发版为 @deepseek-ai/cordis 的元框架。它负责插件挂载、依赖编排、副作用回收------换句话说,它才是"让插件系统成立"的那块地基。
  4. 能力层(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.loadCacherequire.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-steptools/* 的"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 自己的组合层。这里有两个核心概念:ProfileBundle

4.1 Profile:一份命名好的"能力组合"

一个跑起来的 dsh,本质是一棵在启动时按有序层次组合出来的插件树

Profile 就是这份组合方案的名字,存放在 Harness 的 home 目录里。它干三件事:

  • 列出它要堆叠的 Bundle
  • 持有它安装的"树外插件"(out-of-tree plugins);
  • 保存用户自己的 cordis.patch.yml 补丁文件。

webheadless 就是官方随包提供的两个 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 启动时,它对着一个空的插件入口列表,按以下顺序叠加各层:

  1. Profile 里按顺序列出的每个 Bundle
  2. 该 Profile 自己的 cordis.patch.yml
  3. Harness home 级别的 cordis.patch.yml
  4. 命令行传入的任意 --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/agentcore/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/messageassistant/*tool/*持久化的会话事件(durable session events) ,会被写进日志、跨重载存活;其余的 agent/pre-stepagent/requestllm/streamtools/*活着的扩展点,只在本次运行里有效。

第二,事件有不同的分派模式。 agent/pre-stepagent/requestllm/stream 以及三个 tools/*waterfall(瀑布流) ------监听器必须调用 next() 才能把控制权往下传;而 agent/turn-stoppingserial(串行) 的,没有 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 里已经注册了 fsshell 等工具)。
  • step 1agent/pre-step 放行 → agent/requestllm/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 由三个角色组成:

  1. Service Definition(服务定义):声明接口;
  2. Service Provider(服务提供者):实现接口;
  3. 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.jobsjob_* 工具负责收集或停止
加文件系统访问或策略 注册 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 "运行这个项目的测试套件" 到底发生了什么:

  1. 入口层dsh 解析参数,识别出 --profile headless,加载 headless 这个 Profile 模板。
  2. 组合层 :按"Bundle 顺序 → profile patch → home patch → --patch"叠加出一棵插件树,其中 dsh-base 先就位(模型适配器、工具、持久化、沙箱策略、凭证等),dsh-headless 再叠上"无服务器的一次性运行器"。
  3. 内核层 :Cordis 按依赖声明编排所有插件的加载顺序,等 ctx.llmctx.toolsctx.agentLoop 等服务都就绪后,让 core/agent-loop 进入 ACTIVE 状态。
  4. 输入进入:任务描述字符串作为一条消息进入驱动器的 inbox,唤醒它,开启一个 turn。
  5. 装配agent/pre-step 决定模型看到什么;core/system-prompt 装配提示词分段,core/tools 提供工具 schema。
  6. 执行回路agent/request → llm/stream → assistant/chunk* → assistant/message,若模型决定调用工具,则走 tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*。如果工具"还欠一次请求",就认领下一个 step,继续循环。
  7. 落日志 :上述每一步的 user/messageassistant/*tool/* 都作为 SessionEvent 写进会话日志------这就是"模型所见即所记"。
  8. 结束 :不再有欠下的请求、也没有新输入到达时,触发 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/sessioncore/toolscore/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.mdcordis 官方仓库及社区源码解读(floatboat.aiiceyao.com 等)整理。dsh 处于快速迭代阶段,架构细节请以你安装版本的官方文档与 dsh --profile web --dump-config 的实际输出为准。

相关推荐
Java牛马15 小时前
AI Agent 技术栈梳理(Skill / 蒸馏 / MCP / Harness)
人工智能·ai agent·蒸馏·skill·mcp·harness
jeffer_liu17 小时前
OpenAI把Codex开源了?
openai·deepseek·harness·openai开源
安逸sgr18 小时前
AI 应用怎么评测?离线评测、人工评估和线上反馈如何结合?
人工智能·ai·大模型·agent·智能体
DogDaoDao19 小时前
Magma:微软如何用一个模型打通数字与物理世界的 AI Agent
人工智能·微软·机器人·大模型·机器人模型·智能体·magma
张忠琳2 天前
【deepseek-harness】DeepSeek Harness (dsh) 系统级架构分析之二
ai·agent·deepseek·harness
thesky1234562 天前
智能体面试准备(四十三):具身智能体与机器人实操——从 VLA 到 Sim2Real
机器人·导航·操作·具身智能·智能体·vla·视觉语言动作
ltqvibe2 天前
Agent OS:企业智能体的控制平面
人工智能·平面·agent·智能体·企业ai
新知图书2 天前
7.1 需求分析与规划 《AI Agent智能体开发实践》
人工智能·agent·ai agent·智能体
潘正翔3 天前
DeepSeek Harness从0到1部署
人工智能·开发·codex·deepseek·harness·deepseekharness·cludecode