← 文章

DeepSeek Harness

想给 Agent 换个零件,为什么总要拆整台机器?

从替换文件后端出发,拆解 DeepSeek Harness 如何用服务接口、依赖身份、作用域和资源清理,让 Agent 的零件真正可以组合。

假设你已经有一个能读项目、改文件、运行检查的 Agent。最初所有东西都在本机,事情很简单。后来你想把文件操作搬到远端,模型接入另一个提供方,再给某个会话留一套独立的工具。

需求听起来只是“换几个零件”。真正动手时,却发现文件工具直接引用本地实现,模型请求藏在循环内部,卸载插件以后还残留监听器。替换一处,牵动一片。

上一篇 OpenClaw 收束篇讲的是插件怎样进入运行时。DeepSeek Harness 把这个问题往前推了一步:如果模型、工具注册表、会话日志,甚至 Agent 循环自己也是插件,系统该靠什么把它们接起来?

这一篇先不追模型效果,从一次假设的文件后端替换出发,看看 Cordis 怎样用服务、依赖和可清理的副作用,支撑这套组合方式。

本文使用 8 月 13 日的源码快照 9fcdb2c。官方 npm 的 @deepseek-ai/dsh@0.1.0-rc.6 同日发布,但没有 gitHead,本文不宣称二者完整构建一一对应。重点分析的七个 Cordis 核心文件,与同日发布的 @deepseek-ai/cordis@4.0.1 包内源码一致;实验运行这个固定包。历史源码 · 官方包元数据

先把“读文件”和“在本机读文件”分开

一个文件工具通常同时夹着几件事:告诉模型参数怎么填,检查路径和输入,调用操作系统,再把结果整理成模型能读的文本。如果这些代码紧紧长在一起,换后端就容易变成重写工具。

Harness 的文件能力分成三个角色。Service Definition 定义文件系统接口,Provider 提供实现,Consumer 使用这个接口。它们是源码中的职责划分,不是给同一层代码换三个名字。能力设计

具体到文件操作,FileSystem 继承 Cordis 的 Service,通过 super(ctx, 'fs') 把能力登记到 ctx.fs。LocalFileSystem 实现本地行为,而模型面对的 tool-fs 负责工具参数、读窗口、格式化以及相关策略协作。它依赖 ctx.fs,不直接把本地提供者写死在工具里。文件系统定义 · 本地实现 · 工具消费者

于是,替换的目标变清楚了:保持消费者需要的接口,换掉提供者。工具不必为了远程地址增加一串 if,也不必知道底下到底走本地系统调用还是远程协议。

不过,接口相同并不代表行为随便凑合。文件系统定义中的目标解析就是异步的,注释明确考虑了远程后端可能需要一次往返才能得到稳定目标身份。路径归一化、写入语义、取消与权限仍然要满足接口要求。仅仅让新类拥有一个 read 方法,远远不够。

这一层设计带来的收益,是把变化集中到一个可审查的位置。代价则是接口需要认真定义;否则,表面上后端可替换,真正执行时却到处依赖“它一定是本地文件”的暗中假设。

文件工具通过ctx.fs依赖文件系统定义,本地或其他提供者实现该能力
替换实现之前先稳定职责:工具管模型交互,接口管操作语义,提供者管实际执行。

不再喊“你先启动”,而是说“我需要什么”

接口解决了怎样调用,启动顺序怎么办?文件工具先加载,而文件系统还没来,是立刻报错,还是让每个插件自己写重试?

tool-fs 的依赖声明很具体:tools、fs、systemPrompt。Cordis 根据这些服务是否可用,决定这个插件何时运行。工具不必自己等待一个全局启动事件,更不必猜测某个提供者是不是已经构造完成。

同一个插件内部还可以开一个更小的依赖范围。文件工具用 ctx.inject(['attachments'], ...) 单独登记图像读取能力。基础依赖就绪时可以建立基础文件工具;附件存储出现后,再建立需要它的那部分能力。缺少可持久化图像的服务时,不先摆出一个只能报错的 read_image。条件能力装配

这里的核心对象叫 Fiber,表示一个插件运行实例。它记录依赖、配置、生命周期状态和清理函数。这个词在本文里不表示操作系统线程,也不表示一个独立进程。插件实例

我们用真实 Cordis 包做了一个很小的实验:先挂载依赖 storage 的消费者,再挂载模拟提供者。提供者到来之前,消费者没有执行;服务出现之后,它开始工作并安装监听器。这里的 storage 是测试用名字,不是 Harness 内置服务,也没有真的访问远程文件。

这比手写“先加载 A,再加载 B”多了一层表达力:依赖关系成为运行时的数据,系统可以在服务变化之后重新判断,而不只是启动时按名单走一遍。

服务换人了,旧的消费者不能装作没看见

让依赖晚一点到来,还只是半个问题。更难的是:消费者正在使用 A,A 被卸载了,B 随后接替。如何避免旧监听器还拿着 A 的引用,新逻辑又开始使用 B?

Cordis 的反射层在服务变化时通知相关插件实例。实现会遍历已登记的运行时和它们的 Fiber,检查依赖里是否包含变化的服务,以及作用域是否匹配,然后重新检查提供者并刷新状态。服务变化通知

Fiber 刷新时,不只是记一个“依赖都齐了”的布尔值。它按照依赖列表,把当前提供者所属 Fiber 的 uid 拼成一个 epoch;有任何必需服务缺失,就使用不活跃标记。这样,即使服务名称没有变,提供者实例变了,也会得到不同的依赖身份。依赖 epoch 与切换

epoch 变化会推动卸载或加载。生命周期正在进行时,新的变化先被记录;旧工作结束后再检查是否还对应当前状态。加载流程还会在异步检查点确认 epoch,避免已经过时的一次装配继续执行插件代码。

在实验里,移除模拟提供者后,消费者释放了监听器;提供一个新实例后,它再次启动,事件处理使用新提供者。显式销毁消费者则不同:之后再提供服务,它也不会自动复活。暂时缺依赖和主动销毁,是两种不同状态。

从算法成本看,当前 notify 路径会扫描运行时中的插件实例,并检查本次变化的服务名;最坏情况下可近似看成实例数乘变化名称数,再加上受影响实例刷新依赖的成本。它不是本文发现的一张按服务名直接索引所有消费者的常数时间表。这样的实现是否成为瓶颈,仍要用实际插件数量和变更频率测量,不能凭结构就给性能排名。

依赖缺失、提供者到来、消费者激活、提供者退出与清理重建的过程
依赖身份变化驱动重新装配;这不等于把旧任务的执行进度自动迁移给新后端。

同叫 fs,也可以属于不同的房间

如果只是把所有服务放到一个全局对象里,替换 A 还是可能影响整台 Agent 系统。一个会话想用模拟文件系统,另一个会话要继续访问正常项目,该怎么安排?

Cordis 的 Context 不只是普通字典。服务查找经过代理和反射层;isolate('fs') 创建带有新作用域标签的子 Context,让这个分支的 fs 查找与父级分开。没有另外隔离的名字仍沿用继承关系。两次隔离若显式传入同一个标签,则可以把那个服务的范围接到一起。Context 隔离

这给组合留下了空间:工具代码相同,所处 Context 不同,就能找到不同的能力提供者。我们的实验验证了子作用域的模拟服务不改变父级服务,也验证了相同标签可以共享服务。

但名字叫 isolate,不代表它是安全沙箱。这里隔开的是依赖解析的命名范围,不是内存、文件系统或网络权限。插件仍然运行在相应宿主环境里。把服务作用域误当作安全边界,会把上一系列刚讲清楚的执行隔离又混回去。

装上去的东西,要知道怎么拿下来

服务能重新装配,前提是旧能力能干净撤走。否则每换一次提供者,就多留一套监听器、工具条目或定时器。

Cordis 把这些登记看成 effect:建立某项效果,同时交还一个清理函数。ctx.on()、服务提供等框架能力也接入相应生命周期。插件被卸载时,宿主可以沿着已登记的所有权回收,而不用猜测插件到底改过什么。effect 实现 · 服务登记与释放

清理顺序也值得读代码。单个 effect 收集的清理函数会逆序执行,并在需要时等待异步清理;但 Fiber 卸载多个顶层效果时使用 Promise.all,不能把它概括成“所有资源在全局严格串行关闭”。如果连接 B 必须先于 A 关闭,就把这段依赖顺序收进同一个 effect,或者自己明确编排。

实验验证了一个 effect 内两个清理步骤按逆序等待完成,也验证了重复释放不会重复执行那组清理。这个结果不覆盖清理函数抛错时的所有路径,更不代表宿主能自动找回插件没有登记的资源。

取消一次模型请求、停止正在进行的文件写入、把旧任务交给新提供者,仍是业务和后端协议要处理的事。依赖重装配负责让能力结构重新一致,不能把已经发生的外部副作用倒回去。

如果要把这种方式用在自己的 Agent 平台,我会先选一个足够小的能力做替换实验:保持工具输入输出不变,准备两个模拟提供者,反复挂载、移除和恢复,再观察监听器与资源计数。这样能验证接口是否真的独立,也能发现卸载以后仍被旧闭包引用的状态。先证明一个零件可以完整换下来,比一开始把所有模块都改名叫插件更有价值。

把组合变成可解释的系统

Harness 的启动配置同样围绕组合展开。profile 堆叠 bundle,再应用用户与额外覆盖层,得到要加载的插件树。架构文档说明补丁按行 ID 定位并替换整份配置,而不是承诺任意深层字段都会递归合并。配置层也需要清楚的替换语义,才能与运行时的可替换能力对齐。配置装配

这一篇运行了 19 项真实 Cordis 运行时检查,使用模拟服务验证依赖等待、移除与恢复、作用域隔离、监听器回收和 effect 清理。没有启动完整 dsh CLI,没有调用模型,也没有把真实项目迁移到远端。

回到最初那个需求,“给 Agent 换个零件”能否轻松完成,取决于零件之间是否已经约定了接口、依赖和退出方式。DeepSeek Harness 的架构价值就在这里:让模型能力之外的装配关系,也成为可以阅读、验证和替换的对象。下一篇再顺着工具执行往里走,看这些能力怎样组成真正完成任务的控制流。

继续阅读