转载自公众号:敢敢AUTOHUB
0. 简介
2026年8月13日,DeepSeek 正式发布 DeepSeek Harness(下文简称 DSH)开发者预览版 v0.1,并同步以 MIT 协议开放源码。它采用插件式开放架构:模型适配器、系统提示词、工具目录、会话、存储、沙箱、Agent loop、Web UI,以至标准、PTC、极简、创造四种模式,都由同一套 Cordis 插件机制组合而成。官方在 V4 Flash 更新日志里已经透露过它的存在,正式版模型的 benchmark 就是用 DSH 极简模式作为框架跑出来的;内测阶段社区在几天内产出约 300 个插件,其中包括直接改写主界面、接入桌面宠物、以及用纯插件实现跨会话长期记忆的实现。本文沿源码结构解析 DSH 的两条主线——Cordis 维护的运行时插件图,与 Session 维护的追加式事件流——并在后半部分用一个真实客户端插件 dsh-web-stage 作为落地案例,说明这套机制在插件作者手里具体长什么样。
1. Agent Harness 要解决的组合问题
1.1 最小 loop 与产品级 Harness 的差距
一个最小的 Agent 循环并不复杂:拼装系统提示词,把历史消息发给模型,解析工具调用,执行工具,把结果送回模型,直到模型不再请求工具。这段逻辑用两百行代码就能跑通,也是大多数教程里的 Agent 形态。真正把它做成产品之后,复杂度并不出现在这条循环上,而是出现在循环之外:模型与凭证要按会话路由,会话要能恢复、分叉和压缩,工具要有策略与审批,执行要能落到本地或远程沙箱,宿主要同时支撑 CLI、Web 与无界面 runner,还要允许子 Agent 与第三方插件参与,而界面又必须能观察到与模型完全一致的运行事实。
这些需求彼此之间没有共同抽象时,每个子系统都会自带一套注册表、一套初始化顺序、一套关闭钩子。系统扩大之后,启动器会逐渐承担本不属于它的工作:手工排列若干模块的启动次序,为每个注册项另写卸载逻辑,为两个并行 Agent 复制并隔离状态,替换某个实现之后追踪残留引用,再额外维护一份「当前到底加载了什么」的诊断表。DSH 的选择是把这一整类组合复杂度交给领域中立的元框架 Cordis,把历史连续性交给 Session,Agent loop 只负责在两者之间搬运数据。
一句话理解:DSH 的辨识度不在于「插件多」,而在于它把「系统现在由什么组成」和「系统刚才做过什么」拆成两套独立且都可检查的运行时对象。
1.2 两套系统与一条循环
按源码结构,DSH 可以看成两套同时运行的系统加一条连接它们的循环。Cordis 维护一张运行时插件图,描述当前有哪些能力、它们依赖谁、在哪个作用域生效、卸载时由谁清理;Session 维护一份仅追加的事件日志,记录一次 Agent 工作实际发生了什么,并从中投影出模型上下文、UI 轨迹以及恢复与分叉所需的数据。Agent loop 位于两者之间,它从插件图取得模型、工具、提示词与会话服务,再把执行过程写回事件流。
这两套系统的生命周期完全不同,这一点决定了很多设计取舍。插件可以随时卸载,Provider 可以被替换,Context 上的服务图会持续变化;而已经写入 Session 的用户消息、模型消息、工具调用与工具结果是历史事实,不会因为某个插件退出而失效。恢复、分叉、轨迹视图与用量统计全部从这份日志派生,因此日志格式的稳定性要求远高于插件接口的稳定性。
1.3 界面固定带来的具体损耗
传统 AI 聊天应用的核心矛盾在于界面固定与任务多样化之间的冲突。用户在同一个线性对话窗口中完成代码编写、文档查阅、网页浏览、工具调用等多种任务时,不得不频繁在标签页和窗口之间切换,上下文被割裂。这种设计在处理简单问答时足够高效,但当任务复杂度上升——比如需要边查看 API 文档边与 AI 讨论实现方案,或者边测试 Web 应用边记录问题——线性界面就会成为瓶颈。
DeepSeek Harness 的设计思路是把 Agent 能力从固定应用转变为可组合平台。它基于 Cordis 插件系统构建,Cordis 作为元框架只负责插件的加载、卸载和依赖关系管理,不定义具体的 Agent 能力。模型适配器、工具目录、系统提示词、会话管理、沙箱执行、存储后端、Agent loop、Web UI 等所有组件都是独立的 Cordis 插件。开发者无需修改 DeepSeek Harness 的核心代码,就能通过插件方式替换其中任何一项能力。这里的关键是将"什么能力可用"从编译期决策推迟到部署期和运行期决策。
1.4 两条正交的组合轴
官方宣传里的四种模式很容易被理解成四套互斥的启动方案,源码里的组合关系实际是两条正交的轴。Runtime Profile 决定整个进程怎么跑,内置模板是 web 与 headless,前者启动 Web 应用,后者是没有界面的一次性 runner。Agent Preset 决定某个会话里的 Agent 看到哪些工具、提示词与局部能力,内置四项分别是 standard、code、minimal 与 cordis。所谓四种模式属于 per-session 的 preset,一个 Web 进程完全可以同时承载使用不同 preset 的多个会话,不需要为每种模式各起一份服务。
标准模式加载完整工具组合,适合日常开发和复杂任务;PTC 模式(程序化工具调用)让模型生成一段 TypeScript 代码来组合多轮工具调用,中间数据留在执行环境中,只有最终结果进入模型上下文;极简模式仅保留 shell 工具和文件编辑器,DeepSeek V4 Flash 的 benchmark 测试就使用这个模式,避免额外插件影响评测结果;创造模式允许检查当前运行时、在内存中试验 Cordis 插件,并据此组合新的模式。这种模式分层让用户可以在"功能完整"和"环境纯净"之间灵活选择,而分层本身不需要复制 Agent loop——差异只体现为会话作用域里的若干配置行。
DSH 还区分了宿主组合与单个 Agent 的组合。宿主层持有进程共享的注册表、持久化、沙箱与审批、模型路由等能力;Agent Preset 挂在会话作用域中,主要贡献该会话使用的工具、人格、提示词片段和少量隔离 service。这个分工让同一进程里的写作 Agent、编码 Agent 与研究 Agent 可以看到完全不同的工具集与指令,却共用同一套会话存储与审批策略。Preset id 还会写入 Session header,恢复一段历史时必须重新使用同一组合,否则历史里已经出现过的工具与提示词可能与当前 Agent 能力不一致。
在装有 Node.js 工具链的机器上,DSH 的 Web UI 不需要预先全局安装,一条 npx 命令即可拉起完整进程,包括服务端插件图与浏览器端的界面插件树。这条命令背后做的事情比看起来多:它会下载并解析 Profile 声明的 Bundle,构建插件树,启动 HTTP 服务,再把客户端插件清单交给浏览器加载。对于只想先看看界面长什么样的读者,这是成本最低的入口:
npx @deepseek-ai/dsh web
想读源码或参与插件开发的话,直接克隆仓库按说明安装也可以。两条路径加载的默认插件集合一致,区别只在于代码来自 npm 缓存还是本地工作区;本地工作区的好处是可以直接改 vendored Cordis 并观察行为变化,这在排查生命周期问题时相当有用;缺点是需要自己处理依赖安装与构建产物,首次启动时间明显长于 npx 路径:
git clone <https://github.com/deepseek-ai/deepseek-harness>
cd deepseek-harness
npm install
npm run dev
这种快速启动能力背后是 Profile 和 Bundle 的配置体系。Profile 决定整个进程以 Web 应用还是 Headless 模式运行,Bundle 则打包一组插件配置和对应代码。用户可以通过命令行参数或配置文件叠加 Patch 来覆盖默认行为,而不需要 fork 整个项目。下一节展开这三层配置的叠加规则,它决定了「同一份源码在不同机器上到底跑起了什么」这个问题的答案。
2. 从配置到运行图:Profile、Bundle 与 Patch
2.1 三层配置概念与叠加顺序
DSH 启动时并没有直接构造一个固定的 Agent 应用,它先读取 Profile 与 Bundle,再叠加用户 Patch,由 Cordis Loader 把配置行逐条挂载成插件实例。Bundle 负责分发一组 Cordis 配置和对应的插件代码,是插件生态的分发单位;Profile 决定一个进程堆叠哪些 Bundle;Patch 用于替换或插入配置行,命令行覆盖与用户自定义配置都走这一层。
叠加顺序在源码里是明确的,不是含糊地「合并一下」。流程从空的 entry list 开始,依次应用 Profile 声明的 Bundle、Profile 自己的 cordis.patch.yml、Harness home 目录下的 patch,再应用命令行传入的 --patch;如果启动参数关闭了 telemetry,launcher 还会在最后追加对应的 override。后面的层按配置行 id 替换整段配置或插入新行,因此同一个 id 在不同层里出现时,最后一层生效。
难点提示:调试启动问题时,源码的 import 图只表示「可能加载什么」,
dsh --dump-config输出的最终树才表示这台机器实际会挂载什么。两者经常不一致,缺少最终配置树的问题报告往往连复现对象都没描述完整。
2.2 部署期装配而非仅开发期扩展
Loader 处理后的结果不是普通配置对象,而是一棵正在运行的插件树。配置行决定插件名称、父子位置、参数、启停条件以及 service 隔离域;配置变化之后,对应的插件实例可以被重新配置、卸载或挂载。这让 DSH 不只支持开发时扩展,也具备了部署时装配的能力。
这个区别值得多说一句。如果更换一项实现仍然需要修改启动代码,那么选择权实际留在程序内部,使用者要改行为就得 fork 核心仓库;当部署方可以直接通过配置替换某个 Provider 时,替换本身才成为系统提供的能力。Loader 把「替换实现」从开发行为推进成了配置行为,这是 DSH 插件生态门槛较低的结构性原因之一。
3. Cordis 运行时:Context、Service、Fiber 与 effect
3.1 一份 YAML 回答不了的四个问题
配置树能描述期望拓扑,但它没有回答运行时问题:依赖尚未就绪时插件该不该启动,同名能力在不同会话里怎样隔离,Provider 被替换之后谁需要重启,插件制造的副作用由谁回收。下面这段从 minimal preset 的文件系统组合缩写而来的例子,把这些问题一次性摊开。为便于阅读,工具源码里本来就有的 inject 被显式写进 Entry,并且故意把 Consumer 放在 Provider 前面:
# agent.cordis.yml
- id: filesystem
name: cordis:group
group: true
# 为这棵子树创建私有的 fs service realm
isolate:
fs: true
config:
# Consumer:tools 或当前 realm 的 fs 缺失时,Fiber 保持 PENDING
- id: editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
inject: [tools, fs]
config:
maxOutputChars: 16000
# Provider:在上面的私有 realm 中发布 ctx.fs
- id: fs-provider
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
isolate.fs: true 让这个 group 拥有 entry-local realm,另一个 group 可以发布自己的 fs 而互不冲突。inject 让 editor 在 tools 与 fs 都可解析之前保持等待,因此 YAML 行的书写顺序并不承担启动顺序,Provider 出现之后 Cordis 才激活 Consumer。在宿主已经提供沙箱策略的组合里,把 fs-provider 换成 @deepseek-ai/dsh-fs-sandbox,Provider 身份变化会刷新 Consumer 的依赖 epoch,旧的 editor Fiber 先卸载,再用新的 fs 重载。
3.2 Context 与 Service:能力的解析边界
DSH 的插件通过 ctx.tools、ctx.llm、ctx.sessions 这样的 service 名称协作,消费者依赖的是能力接口,不直接绑定具体实现。ctx 看起来像一个装满单例的对象,实际是经过 Proxy 包装的 service 解析边界:它不只保存「有什么」,还携带父作用域、隔离域、依赖声明与当前 Fiber。子 Context 默认继承父级 service,isolate() 可以为某项 service 建立独立 realm,因此两个会话即使都访问 ctx.tools,也可以得到各自的工具目录。
这里的关键是它属于依赖约束,不是权限控制。通过 ctx.foo 读取未在 inject 中声明的 service 会报错,ctx.get("foo") 则是不受该检查约束的底层入口;但原生 JavaScript 插件仍然在宿主进程中执行,没有注入 fs 并不意味着它在操作系统层面失去了文件访问能力。不受信任的插件依然需要进程、WebAssembly 或容器级别的沙箱。
在 DSH 中,一条可替换能力通常由三个角色构成:Service Definition 定义接口与公共语义,Service Provider 提供本地、远程、沙箱或测试实现,Consumer 通过当前 Context 使用该能力,最常见的 Consumer 就是模型可见的工具。三者合起来才构成一处 capability seam。文件系统与子进程共享同一套 execution world,把 Provider 指向远程沙箱可以连带移动 Bash、PTY 与 LSP,而不必逐个工具单独改造;isolate 又让这种替换能够只作用于某个 Agent。
3.3 Fiber:插件的一次运行实例
Plugin 是可复用的定义,一次实际挂载对应一个 Fiber。同一个 Plugin 用不同配置、挂在不同 Context 之下,会得到多个 Fiber。Fiber 是 Cordis 为这次运行创建的实例记录,它把依赖解析、生命周期与资源所有权收束到同一个对象里,保存父 Context 与当前 Context、插件配置与 inject 依赖、当前解析到的 service 实现、加载与活跃与卸载等生命周期状态,以及运行期间登记的清理函数。
Fiber 会根据依赖计算自己的激活状态:必需 Provider 尚未出现时它停在等待状态,Provider 就绪后加载;Provider 消失或更换实现时,Cordis 通知相关 Fiber 重新计算依赖并触发卸载或重载。换句话说,Cordis 维护的不是启动时解析一次的依赖注入,而是一张会随 Provider 变化持续刷新的运行图。所谓依赖顺序也不再只是 boot 脚本里的先后关系,而是运行时持续维护的状态。
3.4 effect:副作用的归属与回收
插件加载通常会产生注册项、事件监听器、定时器、文件句柄或后台进程。Cordis 要求插件在创建这些资源的同时登记对应的 disposer,与单独维护 activate() / deactivate() 相比,资源获取与释放写在同一个 effect 里,不容易随功能迭代而失配。ctx.on()、ctx.provide() 这些框架辅助方法本身也接入了 effect 所有权。DSH 的 JSON 存储插件可以把这套机制完整串起来:
export const inject = ["storage"]
export function apply (ctx, config) {
const backend = new JsonStorageBackend(config.root)
ctx.effect(() => {
const unregister = ctx.storage.backend.register("json", backend)
return async () => {
unregister()
await backend.close()
}
})
ctx.provide("storageBackend:json", backend)
}
inject 让插件等待存储中心就绪,Context 为它解析当前作用域里的 storage,effect 把注销与关闭 backend 归到本 Fiber,provide 再把 JSON backend 暴露给其他消费者。单次 ctx.effect() 内收集的 disposer 会按逆序串联,适合处理「后创建的资源依赖先创建的资源」这类关系;而 Fiber 整体卸载时,DSH 内置的 Cordis 会并发启动多个顶层 effect 的清理并等待它们全部结束,因此不能把所有顶层 disposer 理解成一条严格串行的 LIFO 栈。
工程价值:effect 的能力止于声明边界。插件没有登记的进程和句柄不会自动消失,已经提交到外部系统的事务也不会因为 Fiber 卸载而撤销。它提供的是结构化资源管理,不是事务级回滚。
3.5 Event:流程拦截的五种形态
Service 适合「明确调用一个能力」,Event 适合观察、决策与包裹一段过程。Cordis 的广播方式不止一种:emit 同步通知全部监听器并忽略返回值,适合状态变化通知;parallel 并发执行并等待全部监听器,适合彼此独立的异步观察者;serial 顺序执行并在第一个有效结果处停止,适合有优先级的异步决策;bail 是 serial 的同步版本;waterfall 允许监听器通过 next() 包裹余下链路,也可以改写或短路流程,适合请求、模型流与工具中间件。
监听器同样归注册它的 Fiber 所有,插件卸载会自动移除监听器。DSH 用 waterfall 处理 agent/pre-step、agent/request、llm/stream 以及工具执行前后的扩展点,agent/turn-stopping 则使用 serial。审批、提示词注入与 Provider 适配可以接进已有的 seam,而不必不断给默认 loop 增加条件分支。反过来说,如果一项新能力只能通过修改 loop 才能实现,说明现有的 service 与 event seam 还不足以表达它。
4. Cordis 论文给出的边界
4.1 三份材料的版本差异
Cordis 有一篇 88 页的论文《A Programming Paradigm for Spatiotemporal Composability》,为上述机制建立了形式模型,说明组合语义、成立条件与适用边界。讨论它时需要区分三份材料:论文描述目标模型、性质与成立前提;独立 Cordis 仓库是上游实现快照;DSH vendored Cordis 才是 DSH 真正运行的版本,其包版本为 4.0.1,vendor 清单记录的来源基线是 4.0.0-rc.7,并叠加了 DSH 的本地加固。
DSH 的本地版本已经修补了 effect 设置期间重入卸载、异步清理失联、Loader 更新失败回滚、配置监听串行化等问题,与独立仓库快照存在实质差异。这里的关键是包版本号无法代替实现核对:论文中的性质是否在某次运行里真正成立,取决于当前代码是否满足证明前提,而不是取决于 package.json 里写着哪个号段。引用论文结论解释 DSH 行为之前,需要先确认对应机制在 vendored 版本里的实现形态。
4.2 时间组合性与空间组合性
论文把动态组合拆成两个正交方向。时间组合性关心组件退出后它对环境造成的改变能否被完整撤回,源码上落在 ctx.effect()、disposer 与 Fiber 生命周期;空间组合性关心组件需要什么,以及 Provider 出现、消失或更换时怎样响应,源码上落在 inject、service realm、target、committed 与依赖通知。Effect 描述组件对环境做了什么,coeffect 描述组件要求环境提供什么,论文把两者递归地合并进同一种 Context。
论文里的组件比「一个插件函数」更严格,它被定义成一个三元组:组件声明自己读取哪些 coeffect、可能向环境提供哪些 key,以及激活时贡献的可逆 effect。Fiber 则是这份声明的一次运行实例,额外携带父节点、生命周期状态与已提交的依赖绑定。把两者并排写出来,可以看清「代码定义」与「活实例」的分界落在哪里,这也是后面所有性质证明的记号基础:
第一行概括论文的 component 三元组,第二行是面向源码的说明式摘要,把论文概念与实现里的 parent、lifecycle 和 committed dependency view 放在一起。同一个 component 可以实例化多次,每个实例独立记录退休标记、effect 累加器与 Provider 绑定;形式演算本身没有纳入 realm,因此同一 key 在一个 registry 中仍只允许一个 Provider,实际 Cordis 再用 realm 放宽这个限制。
统一 Context 之后,它同时保存当前可见环境、effect 的逆操作累加器与依赖表。组件与环境的每次受管交互都经过 Context,因此修改与依赖读取都能归因到具体组件,父 Context 还能聚合子 Context 的 effect,形成可整体退休的层级。论文里的 coeffect 覆盖范围比「注入一个 service」更广,可以表示任何由组件共享、并通过受控操作访问的状态位置,比如路由表、资源句柄与权限;但组合性证明只覆盖已经表达为 coeffect 的状态,游离在 Context 之外的全局状态不在证明边界内。
4.3 可恢复性的三条边界
论文用 observational equivalence 定义恢复:通过系统公开操作观察,两份状态无法区分即可,堆布局与生成式 ID 无需逐字还原。这个宽松定义是必要的,因为要求逐字节还原在真实运行时里几乎不可能达到,也没有实际意义。写成条件的话,一个组件在环境状态 上执行 effect 再执行其 inverse,应当回到与原状态观察等价的位置:
单个 effect 可逆之后,多个组件交错运行时还需要额外的独立性条件,否则局部性质无法推广到整个系统。两个组件的正向与逆向操作必须可以交换顺序,否则撤回其中一个就会顺带改变另一个的贡献;论文用 coeffect 集合是否相交来判定这一点,这也解释了为什么共享同一个全局单例的两个插件天然不满足条件:
当
这个定义同时划出三条边界。第一,inverse 由插件作者提供,ctx.effect() 可以跟踪、组合并调用 disposer,却不能证明 disposer 真正撤销了前面的操作,漏掉清理、清错对象或过早释放资源仍然是插件缺陷。第二,多个 Fiber 的效果交错之后,论文要求不同组件的效果彼此独立,两个插件若直接改写同一个全局数组、环境变量或单例对象,通常无法满足这项条件。第三,恢复只覆盖系统边界内的状态,打开文件并获得句柄可以用关闭句柄撤回,写入共享文件的字节、发到网络的消息和已提交的支付已经越过边界,需要延迟提交、幂等协议或补偿事务。
Provider 安全退出是这套模型里最实用的一条。Cordis 把 Provider Fiber 的身份纳入 target,Provider A 被 B 替换时即使两者返回相同对象,Consumer 也可能需要重新初始化,_refresh() 会把每个已解析实现的 Fiber UID 编进 epoch。Fiber 开始加载时还会保存一份 committed view,运行中始终读取这份已提交绑定;Provider 开始退出后,新的 Consumer 不再绑定它,已有 Consumer 仍能用原来的依赖完成 teardown。这里最重要的是「先停止提供,再真正释放」——如果 Provider 一消失就立刻关闭连接池,Consumer 的清理代码可能还需要把连接归还给一个已经关闭的池。
难点提示:论文伪代码在 Provider 的整个
fiber.dispose()之前设置了 dependent-drain barrier,当前实现比这更弱。service 注册项本身会等待 Consumer,但另一个顶层 effect 所拥有的连接、进程或 backend 可能同时开始关闭。若 Provider 资源必须活到所有 Consumer teardown 完成,插件需要把资源清理与 service disposer 收进同一个外层 effect,或者设置显式 barrier。
4.4 Loader 收敛的四项前提
论文把 Loader 看成一个 reconciler:配置树描述期望状态,Loader 通过插入、退休、更新与重新加载 Fiber,使运行图向该状态收敛,并讨论了四项性质。恢复精确性要求卸载一个 Fiber 后其他独立 Fiber 的贡献仍然保留,前提是 effect 两两独立;顺序与解析一致性要求 Provider 先于 Consumer 激活并晚于 Consumer 退出,一次加载使用同一组已提交依赖;进展性要求依赖变化后生命周期最终到达静止状态,前提是 Provider 图无环、effect iteration 有界且 Fiber 数量有限;合流性要求相同编排输入最终得到与「从头按依赖加载」一致的静止状态。
这些前提直接限定了结论的适用范围。循环依赖会让相关 Fiber 永久停在未激活状态,持续注册自身子 Fiber 的插件会破坏有限性,相互干扰的全局副作用会破坏恢复精确性。Cordis 可以检查并暴露部分状态,插件作者仍需自己满足独立性、有界性与依赖完整性。论文还把 HMR 描述成识别受影响模块、找出 stale entries、备份模块缓存后替换 Fiber 的三段式事务;按源码观察,DSH 确实加载了 Cordis HMR 并用它持续监听用户 patch 配置,但 Web 与 Headless bundle 都明确关闭了共享的模块级 HMR,启动器只在缺少 HMR service 时挂载 root: [] 的 watch-only 实例,原因是对应的 reload 生命周期尚未验证。
5. Agent loop 的执行链
5.1 五项依赖与一次请求的主干
插件图装好之后,Agent loop 才有运行条件。它不是一个自带能力的巨型函数,而是把若干 service 串起来的编排者,因此它的依赖声明本身就勾勒出一次请求的主干。默认 AgentLoop 明确依赖五项 service,任何一项缺失时这个 Fiber 都停在等待状态而不会带着半套能力启动:
// 默认 AgentLoop 的依赖声明
export const inject = ["agents", "sessions", "llm", "tools", "systemPrompt"]
sessions 保存规范事件日志,并把事件投影成模型消息;systemPrompt 汇总提示词片段、变量与工具 schema;llm 提供模型适配与流式输出;tools 管理工具目录、策略与执行管线;agents 管理 Agent 实例与运行中的协调。一次 step 通常承载一次成功的模型请求及其工具执行,请求错误触发内部重试时同一 step 可能发起多次 Provider 请求;一个 turn 可以包含零到多个 step,被拒绝或被改写为空的第一次输入仍会留下 turn 边界,但不会花掉一次模型 step。
5.2 主流程与扩展点
把一次完整的 turn 压缩成一条路径之后,扩展点的位置就变得清楚了。下面这条链路里出现的每个名字都是一个真实的事件或投影函数,插件不需要改写循环本身,只需要在合适的名字上挂监听器。换句话说,读懂这条链路等于读懂了 DSH 允许第三方介入的全部时机:
输入进入 inbox
→ turn/start
→ 领取 next-step input 与一条排队消息
→ 读取 prompt sections 与 tool schemas
→ agent/pre-step 接纳、拒绝或改写输入
→ 拒绝,或第一次输入被改写为空:turn/end,不产生 step
→ 接纳:
step/start
→ user/message 写入会话日志
→ deriveMessages() 投影模型历史
→ agent/request → llm/stream
→ assistant/chunk* → assistant/message
→ tool/call* → tools/pre-execute
→ tools/execute
→ tools/post-execute
→ tool/result*
→ step/end
→ 仍有工具后续或 next-step input:进入下一 step
→ agent/turn-stopping
→ turn/end
工具结果要求模型继续工作,或者有新输入进入下一 step 时,turn 暂不结束。这条路径上的每个箭头都是插件可以接入的位置:审批插件挂在 tools/pre-execute,提示词注入通过 prompt sections 参与,模型适配挂在 llm/stream,会话统计与轨迹渲染则消费 step/* 与 turn/*。默认 loop 因此不必内置每一种审批策略、每一种提示词布局或每一家 Provider 的特殊行为,它只保证边界事件被正确发出。
5.3 三类事件不能混为一谈
这条链路上出现的三类事件虽然都叫 event,语义完全不同,混在一起理解会得出错误结论。Session events 是持久事实,包括 turn/*、step/*、user/message、assistant/* 与 tool/*,它们必须能跨重启恢复;Agent events 携带活的 Agent 对象,用于 inbox、请求、验证、继续运行与状态协调,进程结束即消失;Capability events 附着在 fs/*、tools/*、telemetry/* 等能力 seam 上,只服务于策略与适配。
这个分层解决的是一个具体矛盾:如果把所有 middleware 的临时状态都写进日志,日志会迅速膨胀且充满与历史无关的调度细节;如果只保存聊天文本,恢复所需的因果边界又会丢失。DSH 的做法是让持久事件进入 Session log,agent/* 与 tools/* 中间件大多只存在于当次运行。
6. 追加式事件流与模型可见性
6.1 「模型可见即已记录」
DSH 把 Session 设计为 append-only log,turn 与 step 边界、原始流式 chunk、最终模型消息以及工具往返都有连续序号,不会为了压缩上下文而直接修改旧事件。架构文档把核心约束写成 “Model-visible means logged”:凡是进入模型请求的输入,都必须能从规范日志重建;新增一种模型可见输入时,应先扩展 SessionEventMap,再由日志投影它。
这里的关键是「可重建」,不是要求所有内部调度状态都变成模型消息,也不是把每次 prompt 的完整副本机械保存一遍。这条约束的实际作用是让 UI 轨迹、恢复、分叉与审计共享同一份真相源。普通聊天工具往往只保存最终消息,因此界面上看到的内容与模型实际收到的内容之间始终存在一段无法核对的差距;DSH 把这段差距压到零,代价是日志体积与隐私面同步扩大。
6.2 完整记录与完整发送是两件事
模型下一次看到的内容并不是整份日志。deriveMessages() 从日志维护的 surface 中投影消息,assistant/chunk 保留流式回放细节但不与最终 assistant/message 重复进入模型历史,turn 与 step 边界以及统计类事件不会变成模型消息,compaction 追加 replacement 节点在当前 surface 中遮蔽一段旧消息而原始事件仍留在日志里。
因此 token 消耗取决于当次消息投影、系统提示词与工具 schema 的大小,不由磁盘日志长度直接决定。这一点常被误解为「append-only 会让上下文越来越贵」,实际两者之间隔着一层投影:日志只增不减,surface 却会被 compaction 收窄。它也没有让外部副作用变得可回放——日志能证明模型请求过一次命令,不能保证重复执行这条命令仍然安全,恢复工具调用仍然需要幂等性、检查点与「结果未知」的处理路径。
6.3 审计面扩大带来的成本
Append-only 设计增加的是存储、索引、格式迁移与隐私成本。日志可能保留用户输入、工具参数、命令输出、文件内容,以及模型接口实际返回的 reasoning chunk;向外部遥测系统导出时,部署方需要另外设计脱敏与保留策略。「记录思维链」也要按接口边界理解:Provider 在流中返回 reasoning chunk 时 Harness 可以保存,模型没有返回的内部推理过程 Harness 无法读取。完整日志提高了可审计性,同时扩大了敏感数据保留面,这两件事必须一起评估。
7. 与其他 Harness 的横向比较
7.1 先对齐层级再比功能
Pi、OpenClaw、Hermes-Agent、Codex 与 DSH 并不处在同一层。Pi 更接近 DSH 里的 Agent 内环,内环短、合同直接,没有再引入一套领域无关的组合运行时;OpenClaw 的中心更接近 Gateway 与产品领域,工具、消息渠道、Provider、Hook 都有明确入口;Hermes-Agent 围绕 AIAgent 组织能力,并把「这项能力会占用多少永久模型上下文」当作设计约束;Codex 强调 Thread / Turn / Item 这组执行语义以及围绕它建立的类型化客户端协议。直接比较「谁的插件更多」,很容易把层级差异误写成功能差异。
比较的合适方式是沿三个共同问题横向展开:一项能力怎样接入系统,同一套核心怎样服务多个入口,连续请求又怎样维持稳定的模型前缀。Codex 把多端复用收束到一条协议边界,各端共享的不是终端 UI,而是 codex-core 的执行语义与由 Thread、Turn、Item 构成的类型化合同。DSH 的 Web 与 Headless Profile 同样复用 Cordis、Session 与 Agent 的基础合同,但二者加载的默认插件集合并不相同:Profile 决定进程级宿主组合,Preset 再决定每个会话使用哪些工具与局部服务,因此配置本身也参与决定 runtime 由什么组成。
7.2 动态运行时会浪费前缀缓存吗
Cordis 允许运行时装卸插件,容易让人以为 DSH 每轮都会生成一份全新的 prompt,既浪费 token 也无法利用 Provider 的前缀缓存。实际并非如此。DSH 虽然在每个 step 重新读取当前插件图,但只要模型可见的 system prompt、工具 schema、模型路由与历史前缀没有变化,重新组装仍然得到相同前缀;为此它会稳定 prompt section 与工具的顺序,使用 pi-ai adapter 时也会把缓存保留策略与 session id 传给底层 Provider。
真正导致失效的不是「运行时是动态的」,而是动态变化最终穿透到了模型请求:工具集合改变、提示词 section 改写、模型被切换,或 compaction 替换了历史。request/header 会记录这类请求面变化但它本身不是 cache key,deriveMessages() 的本地缓存也只减少重复投影,不减少模型实际接收的上下文。换句话说,前缀缓存复用的是相同前缀的计算,可能降低延迟与缓存输入计费,不会缩短模型的有效上下文。
7.3 DSH 的位置
把这些差异压缩一下:Pi 追求一条短而直接的 Agent 内环;OpenClaw 以 Gateway 和产品领域连接大量真实入口;Hermes-Agent 把模型 surface 的长期成本纳入扩展决策;Codex 用类型化协议维持多客户端的一致执行语义;DSH 则把 Harness 的组合关系本身做成运行时系统。这也解释了它的代价:实际行为不再只由 import 图与调用栈决定,还要还原 Context、realm、Fiber 与最终配置树。
因此这些架构之间没有统一的胜负关系。若复杂度主要在 Agent loop,Pi 的直接性更有吸引力;若复杂度主要在渠道与产品集成,OpenClaw 的领域边界更自然;若主要问题是多客户端一致性,Codex 的 typed app-server 更合适;当 Provider、工具、会话、沙箱、UI 与 loop 都需要按部署或会话重新组合时,Cordis 的价值才最明显。
8. 插件协议与落地案例:dsh-web-stage
8.1 package.json 的双轨声明
DeepSeek Harness 的插件不是简单的功能扩展点,而是一套完整的能力组合协议。每个插件都是独立的 npm 包,通过标准的 package.json 声明元信息和加载方式。核心问题在于如何让服务端能力(工具注册、模型路由、会话管理)和客户端能力(UI 改造、交互增强)共存于同一个插件中,同时保持各自的生命周期和依赖关系。
dsh-web-stage 是一个只改客户端布局的插件,它的 package.json 恰好把这套双轨声明展示得比较完整:服务端一侧通过 patch 参与 Cordis 插件图,客户端一侧声明自己是 Web 平台插件并给出浏览器端的入口文件。整份声明不到五十行,却已经覆盖了 DSH 插件协议的全部必需字段:
{
"name": "@larkspur-wang/dsh-web-stage",
"version": "0.2.1",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web"
}
}
}
dsh 字段是插件与 DeepSeek Harness 通信的关键协议。bundle.patch 指向服务端配置修补文件,可以在 Cordis 插件图中插入新节点或修改现有节点的参数。client.platform 声明这是一个 Web 平台的客户端插件,DeepSeek Harness 的 Web 前端会通过特殊的模块加载器协议加载 ./client 导出点。这种分离让插件可以同时扩展服务端(比如添加新的文件系统 Provider)和客户端(比如修改布局),而不需要强制每个插件都实现两侧。
exports 字段定义了多个导出点。主导出点 . 通常用于服务端 Cordis 插件,它会被 Node.js 环境加载;./client 导出点则专门给浏览器端使用。DeepSeek Harness 的 Web 前端在启动时会扫描所有已安装插件的 dsh.client 配置,并通过 window.__ModuleLoader__.load() 协议将客户端模块注入到页面中。这里的关键是客户端代码不经过传统的 Webpack 或 Vite 打包流程,而是在运行时动态加载,因此插件必须是自包含的 ES 模块。
插件的加载链路可以描述为:DeepSeek Harness 启动 → 读取 Profile 和 Bundle 配置 → 解析每个插件的 package.json → 服务端加载 cordis.patch.yml 并应用到插件图 → Web 前端扫描 dsh.client 配置 → 通过 ModuleLoader 加载客户端模块 → 调用插件的 exports.apply(ctx) 函数完成激活。Cordis 在这个过程中负责维护插件的依赖关系、生命周期状态和资源清理。一个插件可以声明 inject: ['tools', 'fs'],表示它依赖工具目录和文件系统服务;只有这些依赖就绪后,Cordis 才会激活该插件。
安装一个第三方插件因而不需要向核心仓库提交 Pull Request,也不需要等待官方审核,只要把插件配置写入对应 Profile 的清单并重启服务即可。下面两条命令就是 macOS 上的完整安装流程,第一条写配置,第二条让服务重新读取配置树;Linux 环境把第二条换成对应的服务管理命令即可,配置写入部分完全一致:
dsh plugin --profile web add github:Larkspur-Wang/dsh-web-stage#v0.2.1
launchctl kickstart -k gui/$(id -u)/com.deepseek.dsh
第一行命令将插件配置写入 Web Profile 的插件清单,第二行重启 DeepSeek Harness 服务使配置生效。插件开发者只需遵循 package.json 协议,不需要向 DeepSeek Harness 核心仓库提交 Pull Request。这种去中心化的扩展机制降低了生态门槛,也是内测期间短时间内出现 300 个插件的原因之一;代价是插件质量与兼容性完全由作者负责,社区标签聚合页并不代表官方审核或兼容性认证。
8.2 布局改造的设计意图
dsh-web-stage 插件的核心功能是将 DeepSeek Harness 的中间区域变成可以加载任意网页的 iframe 舞台,同时把对话区域移到右侧。这种布局改造的设计意图来自一个具体的工作场景痛点:开发者在查阅 API 文档时需要频繁向 AI 提问,或者在测试 Web 应用时需要记录问题和解决方案。传统的切换标签页方式会打断思维流,而把网页和对话放在同一个视野内可以显著降低上下文切换成本。
改造后的界面分为三个功能区域。左侧保留 DeepSeek Harness 原有的会话列表和工作区管理,用户可以在不同会话之间快速切换,每个会话独立维护自己的对话历史和 Agent 配置。中间是插件添加的 iframe 舞台,占据大部分横向空间,顶部有一个精简工具条,包含 URL 输入框、"打开"按钮和"外链"按钮。右侧是对话区域,宽度可以通过拖拽分隔条在 320px 到 720px 之间调整,默认 480px。这种三栏布局在大屏幕上提供了充足的信息密度,在窄屏时会自动调整对话区域的最小宽度以保持可用性。
插件还提供了"隐藏网页"模式。点击工具条上的折叠按钮后,iframe 舞台会收起成一条细工具条,对话区域恢复全宽显示,分隔条也会隐藏。这种设计让用户可以在"网页+对话"和"纯对话"两种模式之间快速切换。换句话说,插件不是强制用户接受某种固定布局,而是提供了一个可伸缩的能力层。用户在需要参考外部资料时展开舞台,在专注于与 AI 对话时收起舞台,整个过程只需一次点击。
客户端插件的所有逻辑都集中在 lib/client.js 这个单文件里,没有构建步骤,也没有外部依赖。文件开头先定义默认加载的网页地址、三个 localStorage 键名以及对话区的宽度区间,这几个常量决定了插件的默认行为与可调范围,后续所有函数都围绕它们展开:
// lib/client.js
/** Default page loaded in the stage. */
const DEFAULT_URL = '<https://mc.kurogames.com/cloud/#/>'
/** localStorage keys */
const URL_STORAGE_KEY = 'dsh-web-stage.url'
const CONVERSATION_WIDTH_KEY = 'dsh-web-stage.conversation-width'
const COLLAPSED_STORAGE_KEY = 'dsh-web-stage.collapsed'
/** Width of the conversation pane placed at the right of CenterColumn. */
const CONVERSATION_WIDTH = 480
const CONVERSATION_MIN_WIDTH = 320
const CONVERSATION_MAX_WIDTH = 720
DEFAULT_URL 是插件首次激活时显示的网页。这里使用了一个游戏官网地址,开发者可以根据自己的需求修改这个常量并重新打包插件。之后,用户通过工具条输入的 URL 会被保存到浏览器的 localStorage 中,下次启动时自动恢复。宽度和折叠状态也采用同样的持久化策略。这种设计让插件在保持代码简单的同时,也能记住用户的使用习惯。
对话宽度的约束范围体现了插件开发中的一个重要原则:提供合理的默认值和边界约束。最小宽度 320px 保证对话区域不会被压缩得无法使用,最大宽度 720px 防止对话占据过多空间导致舞台失去意义。这些数值不是随意设定的,而是在实际使用中权衡了文本可读性、输入框宽度和多列布局需求后确定的。
8.3 URL 归一化:把用户输入当作不可信数据
当用户在工具条输入网址时,插件不能简单地把输入内容直接传给 iframe。浏览器对 iframe 加载的内容有严格的安全限制,错误的 URL 格式、不安全的协议、或者指向 Harness 自身的地址,都可能导致安全问题或功能失效。normalizeStageUrl 函数实现了完整的输入清洗、协议校验和智能转换逻辑:
// lib/client.js
function normalizeStageUrl (value) {
const raw = typeof value === 'string' ? value.trim() : ''
if (raw === '') return DEFAULT_URL
let candidate = raw
if (!/^https?:///i.test(candidate)) {
const looksLikeHost = /^(?:localhost|d{1,3}(?:.d{1,3}){3}|[^s/]+.[^s/]+)(?::d+)?(?:/.*)?$/i.test(candidate)
candidate = looksLikeHost
? (/^(?:localhost|d{1,3}(?:.d{1,3}){3})(?::|/|$)/i.test(candidate) ? 'http://' : 'https://') + candidate
: '<https://www.bing.com/search?q=>' + encodeURIComponent(candidate)
}
try {
const url = new URL(candidate)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return DEFAULT_URL
if (url.origin === window.location.origin) return DEFAULT_URL
return url.href
} catch {
return DEFAULT_URL
}
}
这个函数实现了五层安全校验。首先,空输入会回退到默认网址,避免 iframe 显示空白页面带来的困惑。其次,对于缺少协议头的输入,函数会判断它是域名还是普通关键词:如果输入符合主机名模式(包含点号或冒号),对 localhost 和 IP 地址补充 http://,对其他域名补充 https://;如果是普通关键词,转换为 Bing 搜索 URL,让用户可以直接搜索而不是看到加载失败的错误页面。进一步看,协议过滤确保只有 http: 和 https: 可以通过,拒绝 javascript:、data:、file: 等可能带来安全风险的协议。同源检查防止插件加载 DeepSeek Harness 自身的页面,避免出现无限嵌套或状态混乱。所有无法解析的输入都会回退到默认地址,确保插件始终处于可控状态。
这里的关键是把用户输入视为不可信数据,并且把校验分成两层:正则负责「看起来像什么」的快速分类,URL 构造函数负责「是否真的合法」的权威判断。这个正则不是完美的域名验证器,它会接受 a.b 这种技术上合法但不常见的域名,也会接受 999.999.999.999 这种不合法的 IP,但它的目标是容错分流而非严格校验,边缘情况交给后续的 catch 块回退到默认地址即可。插件运行在宿主应用同一个进程空间中,一个插件的安全问题会影响整个应用,因此这类回退优先于抛错的写法在插件开发里比在普通业务代码里更值得坚持。
8.4 在 React 管理的树中找到锚点
DeepSeek Harness 的 Web 前端使用 React 框架构建,DOM 结构由 React 虚拟 DOM 管理,可能在任何时刻重建。插件不能假设某个特定 class 名称或 DOM 节点永远存在,也不能依赖具体的哈希值。同时,为了减小打包体积和提升加载速度,DeepSeek Harness 对 CSS class 名称进行了内容哈希处理,同一个组件在不同版本中的 class 名称可能完全不同。核心问题在于如何在这个动态变化的环境中稳定地找到中间列容器(CenterColumn)。
findCenterColumn 函数用两级查找策略应对这个问题:第一级尝试用 class 名称里的稳定片段匹配,第二级在第一级失败时改用 DOM 位置关系推断。两级都失败时函数返回 null,调用方会跳过本次挂载而不是抛出异常,等待下一次 DOM 变化再试。这种「找不到就安静退出」的写法比抛错更适合插件场景,因为宿主渲染尚未完成时找不到锚点是完全正常的中间状态:
// lib/client.js
function findCenterColumn () {
// 尝试通过内容哈希的 class 名称定位(如 pI_x6G_centerCol)
const styled = document.querySelector('[class*="centerCol"]')
if (styled !== null) return styled
// 回退策略:通过 DOM 位置关系推断
const sidebar = document.querySelector('[class*="sidebarCol"]')
if (sidebar !== null && sidebar.parentElement !== null) {
const kids = sidebar.parentElement.children
for (let i = 0; i < kids.length; i++) {
const c = kids[i]
if (c !== sidebar && c.tagName === 'DIV') return c
}
}
return null
}
第一级查找利用了 DeepSeek Harness 的 class 命名约定。虽然完整 class 名称包含哈希前缀(如 pI_x6G_centerCol),但所有中间列容器都以 centerCol 结尾。使用属性选择器 [class*="centerCol"] 可以模糊匹配这个后缀特征,而不依赖具体的哈希值。这种策略的成立前提是 DeepSeek Harness 维护了语义化的 class 后缀约定,插件只依赖这个约定而不是实现细节。
第二级回退策略基于布局结构推断。DeepSeek Harness 的主布局是一个网格容器,包含侧边栏列和中间列两个主要子元素。如果第一级查找失败,插件先通过 [class*="sidebarCol"] 找到侧边栏,然后在同一父容器中寻找另一个 div 元素,假定它就是中间列。这种推断有一定脆弱性——如果布局中增加了新的顶层 div,或者侧边栏和中间列被包裹在额外的容器中,推断就会失效——但它为第一级策略提供了一个降级方案。需要留意的是函数会返回找到的第一个非侧边栏 div,没有进一步验证该元素是否真的是中间列。
这个查找函数体现了插件开发的一个核心矛盾:插件需要深入宿主应用的内部结构才能实现复杂功能,但又不能强绑定特定版本的实现细节。两级查找策略在"紧跟宿主约定"和"保持独立性"之间找到了平衡点。当 DeepSeek Harness 升级导致 class 名称规则变化时,只要布局结构保持稳定,插件仍有机会通过回退策略继续工作。直观理解是插件像一个谨慎的访客,先按门牌号找房间(class 后缀),找不到时按房间相对位置推断(DOM 层级关系),而不是记住房间的完整地址(具体 class 名称)。
8.5 不移动 DOM 节点的布局重排
dsh-web-stage 面临的核心挑战是如何在不破坏 React 管理的 DOM 结构的前提下,把对话区域从中间列移到右侧。直接操作 React 组件的 DOM 节点非常危险,React 在下次渲染时可能覆盖插件的修改,导致状态不一致或功能失效。更严重的问题是 React 使用虚拟 DOM diff 算法优化渲染性能,手动移动节点会破坏 React 内部维护的引用关系,可能导致事件监听器失效或内存泄漏。
插件采用的方案是通过 CSS 布局重新排列元素的视觉位置,完全不移动 DOM 节点的实际位置。这样做的原因是 React 会按自己的虚拟 DOM 结果校正真实 DOM,任何手工移动节点的操作都可能在下一次渲染时被撤销,甚至触发 React 的一致性告警。ensureLayoutStyle 函数注入了一段全局样式规则:
// lib/client.js (简化版,展示核心规则)
function ensureLayoutStyle () {
let styleEl = document.getElementById('dsh-web-stage-layout')
if (styleEl === null) {
styleEl = document.createElement('style')
styleEl.id = 'dsh-web-stage-layout'
document.head.appendChild(styleEl)
}
styleEl.textContent = [
'[data-dsh-web-stage-layout="1"]{display:flex!important;flex-direction:row!important;}',
'[data-dsh-web-stage-layout="1"]>[data-dsh-web-stage="stage"]{order:1;flex:1 1 auto;}',
'[data-dsh-web-stage-layout="1"]>[data-dsh-web-stage="divider"]{order:2;flex:0 0 9px;}',
'[data-dsh-web-stage-layout="1"]>[data-slot="conversation"]{display:contents!important;}',
'[data-dsh-web-stage-layout="1"]>[data-slot="conversation"]>*{order:3;flex:0 0 var(--dsh-web-stage-chat-width);}'
].join('')
return styleEl
}
这段 CSS 的核心机制是 display:contents 和 order 属性的组合运用。CenterColumn 被设置为 flex 容器,其直接子元素按 order 值排列:舞台在第一位(order:1),分隔条在第二位(order:2),对话区域在第三位(order:3)。对话区域的容器使用 display:contents,这个 CSS 属性让容器变成"透明",其子元素直接参与父级的 flex 布局。换句话说,React 创建的对话容器仍然存在于 DOM 树中,但在视觉渲染时被跳过,对话内容直接与舞台、分隔条并列显示。
对话宽度通过 CSS 变量 --dsh-web-stage-chat-width 控制,插件在运行时根据用户拖拽分隔条的操作动态更新这个变量的值。把宽度做成变量而不是直接写死在样式表里,好处是拖拽过程中只需要改一个属性,浏览器可以走合成层快速重排,不必重新解析整份样式规则:
// lib/client.js
function setConversationWidth (center, value, persist) {
const max = Math.max(CONVERSATION_MIN_WIDTH, Math.min(CONVERSATION_MAX_WIDTH, center.clientWidth - 249))
const width = Math.min(max, Math.max(CONVERSATION_MIN_WIDTH, Math.round(value)))
center.style.setProperty('--dsh-web-stage-chat-width', width + 'px')
if (persist) localStorage.setItem(CONVERSATION_WIDTH_KEY, String(width))
return width
}
宽度计算涉及三个约束条件。最小宽度 320px 保证对话区域不会被压缩得无法使用,最大宽度 720px 防止对话占据过多空间,同时减去侧边栏宽度 249px 确保舞台至少有可见区域。Math.min(max, Math.max(CONVERSATION_MIN_WIDTH, Math.round(value))) 这个嵌套表达式是标准的区间截断逻辑:内层 Math.max 保证不低于下限,外层 Math.min 保证不超过上限。Math.round 确保宽度值是整数像素,避免亚像素渲染导致的模糊。
这种 CSS 布局方案的最大优势是风险可控。即使 DeepSeek Harness 更新了 React 组件的实现,只要 DOM 结构的层级关系保持稳定,插件就能继续工作。插件没有试图理解或模拟 React 的渲染逻辑,只是在已有 DOM 树上叠加了新的视觉布局规则。直观理解是插件像一个室内设计师,通过重新摆放家具(调整视觉位置)来改变房间布局,而不是拆墙改造房间结构(移动 DOM 节点)。前者可以随时恢复,后者可能破坏房屋承重结构。
8.6 iframe 沙箱与工具条集成
找到 CenterColumn 容器并注入布局样式之后,插件需要构建完整的舞台结构。buildStage 函数创建三个核心组件:顶部工具条、iframe 画布和失败提示浮层。三者协同工作,为用户提供一个功能完整且安全可控的网页浏览环境——工具条负责输入与控制,iframe 负责承载页面,浮层负责在浏览器拒绝嵌入时给出可执行的替代路径。
工具条包含了用户与舞台交互的所有控件。URL 输入框使用等宽字体,方便用户查看和编辑网址;输入框的焦点样式会改变边框颜色,提供清晰的视觉反馈;"打开"按钮和"外链"按钮使用统一的样式定义,并通过 CSS 变量引用 DeepSeek Harness 的主题颜色,确保插件界面与宿主应用保持一致:
// lib/client.js
const BUTTON_CSS = [
'height:28px',
'padding:0 12px',
'border-radius:6px',
'border:1px solid var(--dsw-alias-border-l1, #d0d0d0)',
'background:var(--dsw-alias-bg-hover, #efefef)',
'color:var(--dsh-alias-text-1, #333)',
'font:13px/1 system-ui, sans-serif',
'cursor:pointer',
'transition:background 0.15s, border-color 0.15s'
].join(';')
CSS 变量的使用体现了插件开发的一个最佳实践:尽可能复用宿主应用的设计系统。var(--dsw-alias-border-l1, #d0d0d0) 这个语法表示优先使用 DeepSeek Harness 定义的边框颜色变量,如果变量不存在则回退到 #d0d0d0。这种写法让插件在保持独立性的同时,也能自动适配宿主应用的主题切换(如深色模式)。如果插件硬编码所有颜色值,用户在深色模式下就会看到一个突兀的浅色工具条。
iframe 元素是舞台的核心。插件用 sandbox 属性限制 iframe 内网页的能力范围,同时用 allow 声明少量额外权限,用 referrerpolicy 阻止把 Harness 的地址泄露给目标站点。这三个属性合起来定义了嵌入页面能做什么、不能做什么,而它们的取值本身就是一次安全与兼容性的权衡记录——每多给一项权限,能正常显示的网站就多一批,同时恶意页面可用的手段也多一项:
// lib/client.js (buildStage 函数片段)
const iframe = document.createElement('iframe')
iframe.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups ' +
'allow-downloads allow-presentation allow-pointer-lock allow-orientation-lock')
iframe.setAttribute('allow', 'autoplay; gamepad')
iframe.setAttribute('referrerpolicy', 'no-referrer')
这个沙箱配置体现了安全性和可用性的权衡。allow-scripts 和 allow-same-origin 让大多数现代 Web 应用能够正常运行,allow-presentation 和 allow-pointer-lock 支持视频播放和游戏交互。但同时,插件拒绝了 allow-top-navigation(防止 iframe 内的页面替换整个 Harness 窗口)和 allow-modals(阻止 alert/confirm 等阻塞式对话框)。核心问题在于如何在"让网页正常工作"和"防止网页接管 Harness"之间划定边界。
这里的关键是沙箱限制属于浏览器级别的强制约束,不是插件自己实现的权限系统。即使恶意网页试图绕过限制,浏览器也会在底层阻止它。换句话说,插件只需要正确声明沙箱属性,安全执行由浏览器保证。这种"依赖平台安全机制而非自建安全层"是 Web 插件开发的一个重要原则。
失败提示浮层是一个常驻的辅助组件,位于舞台右下角。当目标网站设置了 X-Frame-Options: DENY 或 CSP frame-ancestors 限制时,浏览器会拒绝在 iframe 中加载该页面,用户会看到空白。提示浮层此时会显示"页面不能嵌入?"并提供"外部打开"链接,让用户可以在新窗口中访问该网站。这个设计避免了用户误以为是插件出错,同时提供了清晰的解决路径。
8.7 可拖拽宽度与交互完整性
对话宽度的拖拽调整看起来只是一个简单的交互功能,完整实现却需要同时考虑指针事件处理、边界约束、键盘可访问性和状态持久化。buildDivider 函数创建分隔条元素并绑定全部交互逻辑,它的代码量在整个插件里排第二,仅次于舞台构建函数——这个比例本身就说明了交互完整性的成本。
插件使用 Pointer Events API 而不是传统的鼠标事件,这让同一段代码在鼠标、触控板与触摸屏上都能工作,不需要为 touch 事件写第二套分支。setPointerCapture 调用确保即使指针移出分隔条区域甚至移出 iframe 边界,移动事件仍然会被这个元素捕获,拖拽不会因为鼠标划过 iframe 而意外中断:
// lib/client.js (buildDivider 函数核心片段)
divider.addEventListener('pointerdown', function (event) {
event.preventDefault()
startX = event.clientX
startWidth = readConversationWidth()
const conversation = center.querySelector(':scope > [data-slot="conversation"] > *')
if (conversation !== null) startWidth = conversation.getBoundingClientRect().width
dragging = true
divider.dataset.dragging = '1'
divider.setPointerCapture(event.pointerId)
})
divider.addEventListener('pointermove', function (event) {
if (!dragging) return
setConversationWidth(center, startWidth - (event.clientX - startX), false)
})
拖拽过程中,宽度调整不会立即保存到 localStorage,setConversationWidth 的第三个参数传 false 表示只更新 CSS 变量,不持久化。只有在拖拽结束(pointerup)或双击重置时才写入存储。这避免了拖拽过程中频繁写入存储带来的性能开销。核心问题在于如何平衡"及时响应用户操作"和"避免过度频繁的副作用"。
键盘可访问性是很容易被忽视的细节。插件为分隔条设置了 tabIndex 和 ARIA 属性,键盘用户可以 Tab 聚焦分隔条、用左右方向键以 16px 步长微调、用 Home 键回到 480px 默认值,双击则复用浏览器原生 dblclick 事件完成重置。键盘与双击这两条路径都立即持久化,因为它们通常是有意识的精确设置,而不是拖拽时的连续探索。这些交互细节的完整实现体现了插件开发的一个原则:小功能也要做到工程完整性——鼠标、触摸、键盘三种输入方式,加上拖拽中断、边界约束、状态持久化与无障碍访问,缺一项就会在实际使用中暴露边缘问题。
8.8 MutationObserver 与客户端生命周期
DeepSeek Harness 的 React 前端可能在用户操作过程中重新渲染中间列。切换侧边栏展开/收起状态、改变窗口宽度、或者 Harness 自身的状态更新,都可能触发 React 重建 CenterColumn 节点。如果插件只在激活时挂载一次,重渲染后插件界面就会消失。核心问题在于如何让插件在宿主应用的动态变化中保持存在。
dsh-web-stage 用 MutationObserver 监听 DOM 变化,并在必要时重新挂载插件界面。整段逻辑放在 exports.apply 里,返回值是清理函数,这与服务端 Cordis 插件的 effect 语义完全一致:谁创建资源,谁在同一处登记回收路径。区别只在于客户端这一侧回收的是 DOM 节点、样式表与 Observer,而服务端回收的是注册项、句柄与子进程。下面是这个函数的核心结构:
// lib/client.js (exports.apply 函数核心逻辑)
exports.apply = function (ctx) {
const styleEl = ensureLayoutStyle()
let mounted = null
let scheduled = false
const reconcile = function () {
scheduled = false
const nextCenter = findCenterColumn()
if (nextCenter === null) return
// 如果 CenterColumn 没有变化,清理重复节点
if (mounted?.center === nextCenter) {
for (const stale of document.querySelectorAll('[data-dsh-web-stage="stage"]')) {
if (stale !== mounted.stage) stale.remove()
}
return
}
// CenterColumn 变化了,卸载旧挂载并重新挂载
mounted?.dispose()
mounted = mount(nextCenter)
}
const scheduleReconcile = function () {
if (scheduled) return
scheduled = true
requestAnimationFrame(reconcile)
}
const frameObserver = new MutationObserver(scheduleReconcile)
frameObserver.observe(document.body, { childList: true, subtree: true })
reconcile()
return function () {
frameObserver.disconnect()
mounted?.dispose()
styleEl.remove()
}
}
MutationObserver 监听整个 document.body 的子树变化。每当 DOM 发生增删节点操作时,scheduleReconcile 被调用。这个函数不会立即执行重排逻辑,而是设置一个标志位,并通过 requestAnimationFrame 将实际的 reconcile 函数推迟到浏览器下一次重绘前执行。这种调度策略避免了在一次 React 渲染中多次触发重排,提升了性能。换句话说,即使一次 React 渲染触发了 100 次 DOM 变化,reconcile 也只会在渲染结束后执行一次。
reconcile 函数首先查找当前的 CenterColumn。如果找到的容器与上次挂载时使用的容器是同一个,说明 React 没有重建这个节点,插件不需要重新挂载。但即使容器没变,DOM 树中仍可能出现重复的舞台节点(比如开发环境下的热更新)。插件会清理所有不是当前挂载实例的重复节点,保持 DOM 树的整洁。
如果 CenterColumn 容器变化了,说明 React 重建了中间列。插件先调用上次挂载实例的 dispose 方法清理旧节点和事件监听器,然后在新容器中重新执行 mount 流程。这个重挂载过程会保留用户的状态(因为 URL 和宽度存储在 localStorage 中),但 DOM 节点和事件绑定是全新的。直观理解是插件像一个寄居蟹,当旧壳(CenterColumn)被拆掉后,会自动找到新壳并搬进去,同时保留自己的财产(用户配置)。
exports.apply 返回的清理函数是 Cordis 插件生命周期的一部分。当插件被禁用或 DeepSeek Harness 关闭时,这个函数会被调用,负责断开 MutationObserver、卸载界面和移除样式表。这种显式的资源清理确保插件退出后不会留下孤立的监听器或 DOM 节点。进一步看,如果插件没有正确清理 MutationObserver,即使插件已经卸载,Observer 回调仍然会在每次 DOM 变化时执行,造成内存泄漏和性能下降。
9. 「一切皆插件」的边界与代价
9.1 核心并没有消失
源码支持官方宣传,但「一切皆插件」并不是一个可以递归到底的字面事实。Cordis 根 Context 在构造时会直接创建根 Fiber、Reflect、Registry、Events 与 Logger service,Session 的具体运行对象、Boot 以及部分 UI 启动代码也不可能由一棵尚未建立的插件图先行解释。换句话说,这句口号适合描述 DSH 组织应用能力的方式,不适合理解成系统没有核心;它仍然有核心,只是核心从 Agent 业务逻辑下沉成了组合内核。
DSH 还选择 source-vendor Cordis 并自行维护生命周期与 Loader 加固,这让运行时更容易被固定和审计,同时把上游同步、语义差异与兼容成本一并带进了 Harness 自身。对于一个 v0.1 的项目,这个取舍是可以理解的:与其等待上游发布,不如先把已经踩到的重入卸载、异步清理失联等问题在本地补掉。
9.2 统一机制带来的三项收益
第一项是组合方式统一。增加工具、替换 Provider、注册 UI、构建 benchmark preset,不必各自发明一套注册、作用域与卸载协议;插件作者反复使用 Context、service、event、effect 与 Fiber,发行者再用同一种配置树装配它们。第二项是生命周期成为一等问题:配置、依赖与 disposer 都归一次 Fiber 挂载所有,开发期热更新、测试隔离、临时插件与会话级能力因此共享同一条退休路径,而不是依赖每个子系统各自约定 shutdown()。
第三项是依赖可以晚绑定并按位置替换。Consumer 依赖稳定的 service 名称,Provider 晚到、消失、换成远程实现或只在某个 realm 中替换时,Cordis 都有对应的等待、通知与重载语义,一个 Agent 使用本地能力、另一个使用沙箱能力不需要复制 Consumer。配合前面提到的 preset 机制,模式差异表现为插件图增量而不是主程序分叉,这降低了多种体验各自漂移的风险。运行时本身也可检查:配置 Entry、Fiber、service 与 effect 都是可观察实体,创造模式能查看当前组合并挂载内存插件,说明这里的插件生态不只是构建期扩展点,也可以充当 Harness 实验台。
9.3 相应的四项成本
静态代码不再等于实际系统。 import 图只能说明可能性,Profile、Bundle、Patch、Preset、条件表达式与 realm 共同决定真实拓扑。动态依赖会放大因果链:Provider 的一次变化可能让一组 Consumer Fiber 依次进入卸载与重载,异步 setup、event 通知与 disposer 又可能交错,排障时除了调用栈还要看 Provider 身份、Fiber epoch 与当前 committed binding。可逆不等于事务:effect 只能回收插件声明过的资源,无法自动补偿网络消息、共享文件写入或支付;顶层 effect 还会并发清理,存在严格资源顺序时必须由插件显式建立边界。
插件化不等于安全。inject 约束的是通过 Context 使用能力,不能阻止同进程代码直接导入 Node API;effect 解决所有权,worker thread 只解决部分执行隔离,二者都不是不受信任代码的权限边界,创造模式尤其应当按高权限运行时实验室来理解。最后,性能代价目前仍缺少量化:论文与仓库都没有给出 Cordis 运行时开销、配置重组延迟或大规模插件图的对照基准,因此既不能断言统一运行图几乎没有成本,也不能据此判定它会成为瓶颈。
9.4 客户端插件一侧的具体限制
回到 dsh-web-stage 这个案例,它的限制正好是上面几条抽象成本的具体形态。iframe 嵌入限制是最常见的问题。现代 Web 安全机制中,网站可以通过 HTTP 响应头 X-Frame-Options: DENY 或 Content Security Policy 的 frame-ancestors 指令禁止自己被放入 iframe 中。这是一种重要的防护措施,防止点击劫持攻击。当遇到这类网站时,iframe 会显示空白,插件无法绕过这个浏览器级别的限制。银行、支付平台、企业内部系统等安全敏感的网站通常会设置这类限制。插件提供的解决方案是在右下角显示"外部打开"链接,让用户在独立浏览器窗口中访问该网站。
iframe 沙箱的安全权衡体现在 sandbox 属性的配置选择上。插件设置的沙箱虽然允许了脚本执行和表单提交,但仍然限制了部分浏览器 API。禁止 allow-top-navigation 意味着 iframe 内的页面无法通过 top.location = '...' 替换整个窗口,某些依赖这种导航模式的老旧网站可能无法正常工作。禁止 allow-modals 会阻止 alert、confirm、prompt 等对话框,虽然现代应用很少使用这些 API,但某些管理后台可能依赖它们。沙箱配置的选择本质上是安全性和兼容性的权衡。过于宽松的沙箱可能让恶意网页获得更多能力,过于严格的沙箱则会导致合法网站功能失效。
DOM 结构依赖是插件最脆弱的一环。插件的布局改造依赖于 DeepSeek Harness 当前版本的 DOM 结构,具体来说是假设 CenterColumn 是一个独立的容器,对话区域被包裹在标记为 [data-slot="conversation"] 的元素中,侧边栏和中间列是同一父容器的子元素。如果 DeepSeek Harness 在未来版本中重构了 Web 前端的架构——比如改用不同的布局模式或调整 DOM 层级关系——插件可能无法正确定位关键节点。findCenterColumn 的两级查找策略提供了一定的容错能力,但这不是完整的版本隔离。插件作者需要在 DeepSeek Harness 发布大版本更新时,测试插件的兼容性并进行必要的适配。这是所有深度集成类插件都面临的通用问题:越是深入宿主应用的内部实现,就越容易受到宿主变化的影响。
localStorage 状态管理的局限体现在几个方面。首先,localStorage 是同源共享的,如果用户在同一台机器上以不同用户身份登录 DeepSeek Harness(虽然这种场景不常见),他们会共享同一套插件状态。其次,清除浏览器缓存时可能同时清除 localStorage,导致用户偏好丢失。最后,localStorage 不支持跨设备同步,用户在不同设备上使用 DeepSeek Harness 时需要重新配置插件。对于需要跨设备一致性的场景,可能需要将状态存储到服务端或使用浏览器的同步机制。
性能考虑主要涉及 MutationObserver 的监听范围。Observer 监听整个 document.body 的子树变化,在页面 DOM 操作频繁时可能产生大量回调。虽然插件使用了 requestAnimationFrame 节流,但在极端情况下(比如某个脚本快速创建和删除大量节点),仍然可能影响性能。好在 DeepSeek Harness 的 Web 前端结构相对稳定,正常使用中不太会触发这种极端情况。如果确实遇到性能问题,可以考虑使用更精确的 Observer 配置,只监听特定容器而非整个 body。
这些限制和风险不是为了否定插件的价值,而是为了建立合理的预期。插件系统的开放性必然带来一定的不稳定因素,这是灵活性的代价。DeepSeek Harness 通过清晰的插件 API 和生命周期管理,已经在可扩展性和稳定性之间找到了一个务实的平衡点。理解边界的插件开发者可以在这些约束内构建可靠的扩展,而不是期望插件能够无限制地改造宿主应用。
10. 结语
10.1 值得记住的三件事
DSH v0.1 真正抓住的是 Harness 产品化之后的另一种复杂度:组合关系可能比 loop 本身更难管理。Cordis 把能力的可见范围、激活条件、依赖重载与资源清理做成显式运行图,Session 把执行历史保存为可恢复、可分叉、可投影的事件流,Agent loop 则退到中间只负责搬运。三者合起来解释了为什么源码里几乎每样东西都长得像插件,也解释了为什么读懂配置树比读懂调用栈更重要。
这套设计是否划算取决于项目面对的组合压力。对于单一宿主、固定 loop 和少量稳定工具,显式插件图带来的概念可能多于收益;当多宿主、多 Provider、会话级隔离、运行时装卸与第三方生态同时出现时,组合关系本身就成了主要问题,Cordis 的投入才开始显出价值。
10.2 怎么继续读源码
阅读顺序可以沿三件事展开:先看 Loader 输出的配置树,确认这台机器实际挂载了什么;再追踪某项 service 的 provide / inject、Context realm 与 Fiber effect,看清一条能力的三个角色分别落在哪里;最后沿 Session event 走到 deriveMessages(),检查模型这一步实际看到了什么。需要持续关注的问题也在这三条线上:配置重组之后依赖图能否收敛,Fiber 退出能否可靠回收资源,Session 投影能否在压缩、恢复与分叉之后保持一致。
DeepSeek Harness 的真正价值不在于它提供了多少预置功能,而在于它建立了一套完整的能力组合机制。通过 Cordis 插件系统,开发者可以在不修改 Harness 核心代码的情况下,扩展模型、工具、提示词、界面、存储等任何方面的能力。这让 AI 应用从单一聊天框走向可编排工作台,从固定功能集走向按需组合的能力平台。
dsh-web-stage 作为一个布局改造插件,展示了这种扩展能力的实际形态。它在 DeepSeek Harness 的 Web 外壳中插入了一个 iframe 舞台,把对话移到右侧,创造了"网页+对话"并列的工作模式。从技术实现角度看,插件处理了 URL 安全、DOM 定位、CSS 布局、事件管理、状态持久化、运行时重排等一系列工程问题。这些细节说明插件化不是抽象的架构口号,而是需要落到协议设计、生命周期管理、安全边界和容错策略上的具体工程实践。
插件化架构也引入了新的复杂性。依赖宿主 DOM 结构的插件可能在宿主升级后失效,深度改造界面的插件可能与其他插件冲突,加载过多插件可能影响启动性能。DeepSeek Harness 还处于早期版本,这些工程权衡还在实践中不断调整。但方向是清晰的:AI 应用的未来不是一个更强的聊天机器人,而是一个可以根据具体工作场景自由装配能力的工作台。
从 V4 Flash 使用极简模式进行 benchmark 测试,到内测用户短时间内开发出约 300 个插件,再到 dsh-web-stage 这样的布局改造实践,都说明 DeepSeek Harness 的插件化架构已经在真实工程中验证了可行性。Cordis 插件系统提供的不仅是一个扩展点集合,更是一套完整的运行时能力组合语义。Context、Service、Fiber、effect、Loader 这些概念共同维护了插件的依赖关系、生命周期和资源清理,让插件可以在不破坏宿主应用的前提下深度集成。
作为开发者,我们可以基于 DeepSeek Harness 构建自己的工作流插件;作为用户,我们可以根据需要选择和组合不同的能力插件;作为架构观察者,我们可以从中看到 AI 应用从"产品"向"平台"演进的可能路径。这篇文章分析的 dsh-web-stage 只是一个起点,更多的插件和更丰富的工作台形态还在社区的创造中逐步成形。DeepSeek Harness 和 Cordis 插件系统迈出了这个方向上的重要一步,它证明了运行时能力组合、插件生命周期管理、客户端扩展这些传统软件工程中的成熟机制,同样可以应用到 AI Agent 的架构设计中。
参考资料
- • DeepSeek Harness插件一夜燃爆GitHub:长期记忆、电子宠物、4399小游戏全来了• 横评GLM-5.3、DeepSeek-v4-pro、K3,谁才是Coding新王?• 首发体验 | DeepSeek Harness 来了,它不想做下一个Codex• DeepSeek Harness 开发者预览版:一切皆插件• DSH:DeepSeek Harness 架构解析
148