当你第一次在 DeepSeek Harness 里写下插件代码时,真正卡住你的往往不是 TypeScript 语法,而是三个具体的疑问:apply 函数到底接收什么ctx 上下文对象能做什么写好的插件凭什么被框架加载。这三个疑问分别对应本教程的三个核心关键词——applyctxcordis.yml。很多人把插件开发想象成一件复杂的事,实际上在 Harness 中,一个合法的插件不过是一个导出了 apply 函数和 name 常量的 TypeScript 模块;框架加载它时会调用 apply,并把一个 Context 对象塞到你手里,你通过这个对象注册能力,剩下的交回框架。本段作为全文第 1/2 部分,会先把「插件是什么」讲透:从 apply 的函数签名拆解开始,讲清 name 的双重身份与 ctx 的登记簿语义,然后带你从源码安装 Harness、搭建 scratch-plugin 实验目录、写出零遗漏的最小插件,最后把加载链路的两件套——patch 覆盖层 cordis.yml 与 --patch 启动参数——完整串起来。读完之后,你应该能独立写出一个能被 Web UI 真正加载运行的本地插件,而不是停留在复制粘贴的层面。

apply 函数签名解析:从 @deepseek-ai/cordis 导入 Context 类型

在 Harness 的世界观里,插件并不是某种需要继承基类、实现接口、注册到全局单例的庞然大物,它就是一个普通的 ES 模块文件。框架判断一个模块是不是插件,只看两件事:有没有导出 apply,以及 apply 有没有按约定接收上下文参数。这种设计的好处是插件天然可树摇(tree-shakable)、易测试、易按需加载,同时也把「插件初始化」这件事压缩到了一个函数的执行瞬间。

先看 apply 的签名。官方给出的写法是:插件模块从 @deepseek-ai/cordis 导入 Context 类型,然后导出一个接收 ctx: Contextapply 函数。请注意这里的类型来源——不是从 deepseek-harness 主包导入,而是从 cordis 这个底层容器库导入。这一点非常关键,因为 Harness 的插件系统建立在 Cordis 这个依赖注入与生命周期容器之上,Context 是 Cordis 的核心抽象,Harness 只是把它的能力扩展成了「Agent 运行时」的语义。

为什么强调「从 @deepseek-ai/cordis 导入 Context 类型」?因为 TypeScript 的类型导入在编译后会消失,它不会给运行时增加任何依赖负担,但它给开发者带来的收益极大:ctx 上可用的方法、属性、泛型约束都会在编辑器里被自动补全,你写错一个方法名或者传错参数类型,IDE 会立刻标红。把类型导入做对,等于拿到了一把通往整个 ctx API 的钥匙,后续注册事件监听、注册工具、注册 LLM 适配器时,你都能依赖类型提示而不是反复翻文档。

还需要注意 apply 的返回值。在最小插件里,apply 通常不返回任何东西,只是一个执行副作用的同步函数。框架调用它,它完成注册,然后生命周期继续向前。这种「一次性初始化」语义决定了 apply 里不适合放长耗时的阻塞逻辑,如果你需要拉取远程配置、建立连接,应该考虑用异步注册或后续的钩子来处理,而不是在 apply 里同步等待。素材明确提到框架加载插件时调用 apply,但并没有要求 apply 是 async,因此在初学阶段保持同步、把异步工作交给注册后的回调,是最稳妥的做法。

另外提醒一个常见的初学者误区:不少人会想当然地给 apply 加上第二个、第三个参数(比如「config」或「options」),期望框架把配置注入进来。按当前素材的描述,apply 的入参就是 ctx: Context,插件所需的依赖在 apply 执行前已经就绪,也就是说上下文本身就是依赖与配置的统一入口,你不需要、也不应该去扩展函数签名。想读取配置,应该走 ctx 提供的机制,而不是自己发明参数。

name 导出字段的双重身份:日志标识与配置引用键

最小插件里除了 apply,另一个必须导出的就是 name 常量。素材对它的定义非常克制:name 是插件名,用于在日志与配置中标识这个插件。短短一句话,其实点出了 name 的双重身份,理解这一点能帮你避免后面一系列「加载了却不生效」的排查地狱。

第一重身份是日志标识。Harness 是围绕 Agent 运行时构建的框架,运行时会产生大量事件、调用、注册、错误信息。当框架决定打印一行日志时,它需要知道「这条日志是谁说的」。如果你的插件导出了 name = 'hello-plugin',那么与这个插件相关的一切输出就能带上 [hello-plugin] 这样的前缀,你在终端里一眼就能把它的日志从成百上千行里挑出来。反过来,如果你没导出 name,日志就只能退化成无从追溯的匿名输出,多个插件混在一起时几乎没法调试。所以给插件起一个语义清晰、全局不冲突的 name,是工程规范而不是可选项

第二重身份是配置引用键。插件系统通常允许在配置文件里对插件进行参数化、启用/禁用、覆盖设置。框架要在配置里定位「我要给哪个插件传参」,靠的就是 name。也就是说,name 是你写插件时对外公布的「身份证号」,配置侧拿这个名字来找你。name 一旦发布就不建议随意更改,否则引用它的配置会全部失配,插件会静默地不生效或者报出让人摸不着头脑的错。

这里有一个很容易踩的坑:很多人会把文件名 my-plugin.ts 和插件名 hello-plugin 混为一谈,以为两者必须一致。实际上它们是两套东西——文件名是文件系统层面的路径,加载时用的是绝对路径;name 是框架层面的逻辑标识,用于日志和配置。文件叫 my-plugin.ts、插件叫 hello-plugin 完全合法,素材里的示例正是这么写的。但反过来,保持文件名与 name 语义接近(例如文件名 my-plugin.ts、name 为 hello-plugin 这种同族命名),能显著降低团队协作时的认知负担。

为了把 name 与加载路径的职责分清楚,可以对照下表:

对比项name 导出字段插件文件路径
所属层面框架逻辑层文件系统层
典型取值hello-plugin 这类短标识以 .ts 结尾的绝对路径
主要用途日志前缀、配置引用键告诉框架去哪里加载代码
是否必须唯一建议全局唯一,避免日志与配置歧义每个插件文件路径天然唯一
改名影响影响日志可读性与配置引用,需谨慎只影响加载配置中的那行路径
出现位置插件源码中的常量导出cordis.yml 的 name 字段

看完这张表,再回头理解素材里那句「name 是插件名,用于在日志与配置中标识这个插件」,你会发现它同时覆盖了运行时可观测性与配置可寻址性两个维度。一个负责任的插件作者,会在起名时兼顾「日志里好看」和「配置里好引用」

ctx 上下文对象:注册能力入口与资源登记簿

如果说 apply 是插件的「门」,name 是插件的「身份证」,那么 ctx 就是插件与框架之间的全部接口。素材对 ctx 的意义给了一句非常浓缩的定义:ctx(Context)是框架传给每个插件的上下文对象;它既是注册能力的入口,也记录了插件注册的一切资源。这两半句话,值得拆开慢讲。

第一半是「注册能力的入口」。在 Harness 里,插件想对框架施加影响,唯一正规的方式就是通过 ctx 注册。素材明确列举了三类典型注册对象:事件监听工具(tools)LLM 适配器。这三类对应 Agent 运行时的三种核心扩展点——监听意味着你能观察并响应运行时事件;工具意味着你能给 Agent 增加可调用的能力;LLM 适配器意味着你能接入或改造模型调用链路。它们全都挂在 ctx 上,而不是散落在各个全局变量或者静态类里。这种「一切经由上下文」的设计,让插件的副作用可被框架统一管理和回收。

第二半是「资源登记簿」。ctx 不只是给你一堆 register 方法然后拍拍屁股走人,它还会记录插件注册过的全部资源。这带来两个直接好处:其一,框架知道每个插件声明了哪些能力,便于做依赖排序、冲突检测和诊断;其二,当插件需要卸载或热重载时,框架可以有依据地清理这个插件注册过的东西,而不需要插件自己手写一堆反注册逻辑(尽管上下文通常也会提供配套的销毁回调机制)。把 ctx 理解成「登记簿」而非「工具箱」,能让你在写代码时更自觉地走正规注册通道,而不是绕过框架自己去挂全局钩子——后者会让资源脱离登记,卸载时变成幽灵。

从工程实践看,围绕 ctx 有几条原则值得记住:

  • 能力只从 ctx 来:需要监听、需要工具、需要适配器,都通过 ctx 注册,不要自建全局单例与框架抢管理权。
  • 注册即声明:每一次注册都是对框架的一次声明,注册得越清晰,后续调试与卸载越省心。
  • 按需注册:不要在 apply 里无脑注册一堆用不到的能力,登记簿越干净,运行时越轻。
  • 善用类型:因为 Context 类型来自 @deepseek-ai/cordis,注册 API 的类型提示会帮你提前发现误用。

很多从其他插件体系迁移过来的开发者,习惯把「插件」等同于「一个带生命周期的类」,于是到处寻找 onInit、onDestroy 之类的钩子。但在 Harness 的模型里,插件的生命周期高度集中:apply 被调用时完成注册,资源被记录在 ctx 上,后续的启停与回收由框架依据登记簿统一协调。理解了这一点,你写插件时的心态会从「管理自己的生命周期」转变为「向框架声明我的意图」,代码会明显更短、更稳。

源码安装四连击:git clone、pnpm install、pnpm run build、pnpm dsh web

要真正跑起来,第一步是把 Harness 从源码装起来。素材给出的是一条四步命令流,顺序不能乱,每一步都有它存在的理由。下面给出可直接粘贴执行的完整命令:

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web

逐步拆解这条链路:

  1. git clone:从官方仓库 deepseek-ai/deepseek-harness 拉取完整源码。之所以强调「源码安装」,是因为插件开发往往需要与仓库内的构建产物、类型声明、配置约定对齐,直接用预编译包会缺少这些上下文。
  2. cd deepseek-harness:进入仓库根目录。后续所有相对路径(包括我们后面要创建的 scratch-plugin)都以这个根目录为基准,务必确认自己站在正确的位置,可以用 pwd 自检。
  3. pnpm install:安装依赖。这里用的是 pnpm 而不是 npm 或 yarn,请务必使用 pnpm,因为仓库的依赖拓扑、workspace 结构是按 pnpm 组织的,用其他包管理器容易出现依赖解析不一致的问题。
  4. pnpm run build:执行构建。源码仓库通常包含多个包,需要先构建出可供运行时加载的产物,跳过这一步直接启动往往会在加载阶段报模块找不到。
  5. pnpm dsh web:启动 Web UI。dsh 是 Harness 的命令行入口,web 子命令把界面跑起来,你就能在浏览器里观察插件加载后的效果。

这里集中列一下工程坑与解决方案:

  • 坑:clone 很慢或失败。 解决方案:检查网络与代理配置,确认能访问 github.com 的 deepseek-ai/deepseek-harness 仓库。
  • 坑:install 报依赖冲突。 解决方案:确认使用 pnpm,并清理已有 lock 文件与 node_modules 后重装。
  • 坑:build 报类型错误。 解决方案:确认 Node 版本满足仓库要求,必要时重新 install 后再 build。
  • 坑:dsh web 起不来或端口被占。 解决方案:查看启动日志中的端口信息,释放端口或按日志提示调整。
  • 坑:改了插件代码但界面没变化。 解决方案:确认插件加载配置正确(见后文 cordis.yml 与 --patch),并注意文件路径必须是绝对路径。

把这条命令流记住的基础上,更要理解它的次序意义:clone 拿代码、install 补依赖、build 出产物、web 起运行时,前者是后者的前提。跳过 build 直接 web,是新手最常见的「命令少敲一步,报错排查半天」的翻车现场

依赖就绪时序:为什么 apply 执行前所需依赖已全部到位

素材里有一句看似不起眼但分量极重的话——「需要的依赖在 apply 执行前就已就绪」。这句话解释了一个长期困扰插件作者的问题:我在 apply 里直接用别的插件提供的能力,安全吗?答案是按框架的约定,是安全的。

背后的机制可以这样理解:Cordis 这类容器在加载插件时,会先完成依赖解析与前置插件的初始化,确认当前插件声明所需的外部能力已经可用,然后才调用当前插件的 apply。换句话说,apply 被调用这个时刻,本身就是框架给出的「你的前置条件已满足」的信号。你不需要在插件里自己写轮询等待、重试加载、或者用 setTimeout 延迟注册来赌别的插件先初始化完成。

这个时序保证带来几个非常实际的收益:

  • 插件代码更简单:不必在 apply 里写「等到 X 就绪后再注册 Y」的补偿逻辑。
  • 避免竞态:不会出现因加载顺序不确定导致的偶发失败,这类 bug 往往在本地复现不了、线上却频繁出现。
  • 声明式依赖:你只需按框架要求声明依赖,加载顺序交给容器统一编排,团队协作时不必口口相传「记得先启动那个插件」。

但也要正确理解这句话的边界。它说的是「所需依赖」在 apply 前就绪,指的是按插件依赖声明解析出来的那些依赖。它并不意味着「宇宙万物都已就绪」——例如外部网络的可用性、某个运行时事件的首次触发,这些依然需要你在 apply 之后通过注册回调去响应。把「依赖解析就绪」与「运行时就绪」区分开,是进阶插件作者的必修课:前者是加载阶段的静态保证,后者是运行阶段的动态事实。

scratch-plugin 目录规划:mkdir -p 搭建 src 源码目录

安装完成后,我们不动仓库源码,而是单独建一个实验项目来放插件。这样做的价值在于:把「学习插件」和「修改框架」彻底隔离,既能自由实验,又不会污染仓库,出问题时也容易定位是你的插件写的还是框架本身的行为。素材要求先创建 src 源码目录,命令如下,请在仓库根目录执行:

mkdir -p scratch-plugin/src

mkdir -p 里的 -p 参数保证「父目录不存在时一并创建、已存在时不报错」,一次调用就把 scratch-plugin 和它下面的 src 都建好,适合在脚本或文档里无脑粘贴。执行后可以顺手验证一下目录结构:

ls -R scratch-plugin

此时看到的应该是只有 src 一层。等我们写完插件、再补上 cordis.yml 之后,scratch-plugin 的完整结构会演变成下面这样(这是整个实验项目的最终形态,本段后面会先把两边都填满):

scratch-plugin/
├── src/
│   └── my-plugin.ts   # 插件源码(hello-plugin)
└── cordis.yml         # patch 覆盖层:告诉框架插入哪个插件

关于目录规划,有几点工程建议:

  • 目录命名保持 scratch 语义:scratch 意味着「草稿、实验」,提醒后来者这里的代码不是正式模块,避免被误当作生产代码依赖。
  • 源码统一放 src:即使是单文件插件,也建议先建 src 再放文件,等插件长大了可以直接加子目录,不用改配置结构。
  • cordis.yml 与 src 平级:覆盖层描述的是「如何加载 src 里的插件」,放在项目根层级最直观。
  • 始终在仓库根目录执行相对路径命令:因为之后 cordis.yml 里那个「绝对路径」需要你用 pwd 拼出来,站在正确的目录上能少犯路径错误。

最小可运行插件 my-plugin.ts:零遗漏的完整配置

目录好了,接下来写插件本体。进入 src 目录:

cd scratch-plugin/src

然后在 scratch-plugin/src/my-plugin.ts 写入下面的内容。素材强调这是「完整可用的插件配置,不差任何东西」,我也把它整理成可直接粘贴的版本,并保留关键注释:

// 文件路径:scratch-plugin/src/my-plugin.ts

import type { Context } from '@deepseek-ai/cordis'

// name 是插件名,用于在日志与配置中标识这个插件
export const name = 'hello-plugin'

// apply 是插件的入口:框架加载插件时调用它
export function apply(ctx: Context) {
  // 需要的依赖在 apply 执行前就已就绪(见第 9 篇)
  console.log('[hello-plugin] plugin loaded!')
}

这个文件短到只有几行,却把前面几节讲的所有概念都落到了实处,值得逐行读一遍:

  • import type:只导入类型,编译后不产生运行时依赖,这是 TypeScript 的标准做法,也是素材指定的导入方式。
  • export const name:把插件身份公布给框架,日志与配置都靠它识别。
  • export function apply:插件入口,接收 ctx,在加载时被调用。
  • console.log:最小可验证的副作用。它的价值在于提供肉眼可见的加载证据——只要日志出现,就说明框架确实加载并执行了这个插件。

为什么说「零遗漏」?因为一个能被加载的最小插件,恰好只需要两样导出:name 与 apply。类型导入保证编辑器体验,注释保证可维护性,console.log 保证可观测性,除此之外没有任何必需项。很多初学者在第一次尝试时喜欢加一堆东西——注册假工具、注册空监听——结果反而掩盖了「插件到底有没有被加载」这个基本问题。最小插件的正确姿势是先让它「被看见」,再逐步加能力

这里提前说一个后面一定会踩的坑:这个插件只有在被框架加载时 apply 才会执行。你把文件写好了,如果没有配置加载入口,运行 Web UI 时控制台是不会有 [hello-plugin] plugin loaded! 这行的。所以下一步必须解决加载链路,这就引出了 cordis.yml 与 --patch。

插件加载链路:patch 覆盖层 cordis.yml 与 --patch 启动参数

加载本地插件在 Harness 里需要「两件套」:一个是 patch 覆盖层 cordis.yml,一个是 --patch 启动参数。概念先行:覆盖层(overlay)是一层补丁式的配置,它不推翻原有配置,而是声明「在原配置基础上插入什么」。我们的目标很简单——把 scratch-plugin/src/my-plugin.ts 插入到运行时的插件列表里

先在仓库根目录运行 pwd 拿到绝对路径,这是关键前置动作:

pwd

然后把命令输出的路径记下来,创建 scratch-plugin/cordis.yml,内容如下(把 /absolute/path/to/ 换成你 pwd 拿到的真实路径):

# 文件路径:scratch-plugin/cordis.yml
# 这是一个 Web 覆盖层(overlay),只负责插入本地插件
- insert:
    - id: hello
      # name 是插件文件路径,必须是绝对路径!
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'

这个 YAML 里每个字段都有明确职责:

  • insert:覆盖层的动作类型,表示「插入」。它不改写已有配置,只添加新条目,安全且可叠加。
  • id: hello:被插入条目的标识,用于在这层覆盖层内部定位与管理这一项。
  • name: 绝对路径:这里最容易搞混——在 cordis.yml 的上下文中,name 指的是插件文件路径,而不是插件源码里导出的那个 hello-plugin 字符串。而且它必须是绝对路径,素材用感叹号特别强调了这点。

为什么必须是绝对路径?因为框架解析这个配置时,自己的工作目录未必是你以为的那个目录,相对路径会依赖「从哪儿启动」而漂移。用绝对路径消除了不确定性,代价是换机器、换目录后这行要跟着改——所以它适合 scratch 这种本地实验场景,正式分发时应该换成更稳定的路径策略。

为了把两件套的职责和易错点摆清楚,请看下表:

对比项cordis.yml 覆盖层--patch 启动参数
角色描述「插入什么」的配置载体告诉框架「启用哪个覆盖层」
必要字段insert、id、name(绝对路径)指向 cordis.yml 的路径
常见错误name 写成相对路径忘记加 --patch 导致覆盖层不生效
修改影响改文件即改插入内容改启动命令即改启用范围
适用场景本地插件实验、临时注入每次启动时显式声明覆盖层

把两件套串起来看:cordis.yml 是「写下来」的补丁,--patch 是「用起来」的开关。只写 cordis.yml 而不加 --patch,覆盖层不会参与运行时配置;只加 --patch 而没有可用的 cordis.yml,就无补可打。两者缺一,插件都不会被加载,控制台也就不会出现那行 [hello-plugin] plugin loaded!

至此,我们已经把第一段的核心链路走通了一半:apply 的签名与语义、name 的双重身份、ctx 的登记簿模型、源码安装命令流、依赖就绪时序、scratch-plugin 目录、最小插件实现,以及 cordis.yml + --patch 的加载机制。你现在手里已经有一份能解释「为什么这样写」的完整认知,以及一份能直接粘贴运行的插件与配置。下一步,也就是本文第 2/2 部分,我们会把 --patch 的具体启动命令补全,验证插件真的被加载,并在此基础上继续往 ctx 里注册第一个真实能力——事件监听与工具,让你的 hello-plugin 从一个「会打招呼的空壳」进化成一个「真正参与 Agent 运行时」的插件。

在上一段里,我们已经把 DeepSeek Harness 的源码拉下来、装好依赖、跑通了构建,并且写出了第一个最小插件 hello-plugin:它只做一件事——导出一个名为 apply 的函数,接收框架传入的 ctx(上下文对象),然后在 apply 执行时打印一行日志。写完之后问题立刻来了:这个 .ts 文件放在磁盘上,框架怎么会知道它的存在?答案是本篇要讲的核心——用 cordis.yml 这个 patch 覆盖层把本地插件「插」进运行时,再用 --patch 启动参数让它生效。下面我们从目录结构开始,一路走到加载验证与失败排查。

scratch-plugin 最终目录树:src 与 cordis.yml 的并存结构

上一篇我们只创建了 scratch-plugin/src 这一层,严格来说那还不是一个「可被框架识别的插件项目」,只是一个放着源码的普通文件夹。要让它变成框架能加载的单元,必须再补上一份 cordis.yml,并且这份 yml 必须和 src 目录处在同一个父目录下,也就是共同挂在 scratch-plugin/ 之下。

最终的项目结构是这样的:

scratch-plugin/
├── src/
│   └── my-plugin.ts      # 插件源码(上一篇写的 hello-plugin)
└── cordis.yml            # patch 覆盖层:告诉框架插入哪个插件

这个结构看起来简单到有点「寒酸」,但它的两个部分是职责完全分离的,理解这一点比记住目录本身重要得多:

  • src/my-plugin.ts 是能力本身。它导出 name(插件名,用于在日志与配置中标识这个插件)与 apply(插件入口,框架加载插件时调用它)。它是一段纯 TypeScript 模块,本身不关心自己会被谁加载、以什么参数加载。
  • cordis.yml 是接线图。它不包含任何业务逻辑,只负责声明「请把哪个文件插入到当前配置中」。它是一层 overlay(覆盖层),叠加在框架既有配置之上。

为什么要把两者放在同一个父目录 scratch-plugin/ 下?因为在实际工程里我们会同时维护多个实验插件,每个插件项目都应该是一个自包含的目录:源码在内、接线图在内、可以整体删除、整体拷贝、整体移动到别的机器上。如果你把 cordis.yml 丢到仓库根目录,或者丢到 src 里面,就会出现两种典型混乱:

  1. 放到仓库根目录——你写实验插件时反复覆盖主配置,很容易把主仓库的配置搅乱,回滚成本高;
  2. 放到 src 里面——目录语义变得暧昧,别人读到这个项目时无法一眼判断「哪一层是目录约定、哪一层是源码」。

素材中给出的目录树正是把 src/cordis.yml 并列为兄弟节点,这一点不是随手画的,而是这套工作流的约定:一个插件沙箱 = 一个源码目录 + 一份 patch 覆盖层。请注意,这不是多层嵌套的 monorepo 结构,而是刻意压到最浅的一层,任何新加入的人打开目录就能明白全貌。

还有一个小细节值得单独说:文件名 my-plugin.ts 与插件内部的 name = 'hello-plugin' 并不需要一致。文件名只是磁盘上的定位坐标,name 才是框架内部的身份标识。素材里的例子里两者故意不一致(文件叫 my-plugin.ts,插件名叫 hello-plugin),恰好说明这两套命名空间是解耦的——但在真实项目里,我强烈建议让二者保持可对应的关系,因为排查问题时你需要在「文件路径」和「日志里的插件名」之间来回跳转,命名混乱时会凭空多出很多心智负担。

insert 指令写法:cordis.yml 覆盖层只负责插入本地插件

搞清了目录,接下来写内容。scratch-plugin/cordis.yml 的完整内容如下:

# 文件路径:scratch-plugin/cordis.yml
# 这是一个 Web 覆盖层(overlay),只负责插入本地插件
- insert:
    - id: hello
      # name 是插件文件路径,必须是绝对路径!
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'

短短几行,但每一处都有讲究。我们逐层拆开看:

  • 顶层是一个数组(YAML 里的 - 开头)。这意味着 Cordis 的 patch 机制支持一次声明多条指令,按顺序叠加执行。我们的例子里只有一条,就是插入插件。
  • 指令对象只有一个键 insert。这体现了素材中对它的定位:这个覆盖层「只负责插入本地插件」。它不修改已有插件的参数、不覆盖别处的配置、不做条件分支,只做一件事——把本地文件插进运行时。
  • insert 的值本身又是一个数组。也就是说一次可以批量插入多个插件,每个条目都是一个带 idname 的对象。

我特别想强调「单一职责」这一点,因为这是新手最容易踩的隐形坑。很多人写配置时习惯「顺手」在同一份 yml 里再加点别的——比如改改端口、调调日志级别。这在一次性实验里看起来很高效,但会带来两个后果:一是这份覆盖层不再能干净地删除(里面混入了你不该动的东西),二是当加载失败时,你无法快速判断问题出在「插入插件」这一步还是「其他改动」这一步。把 patch 文件当成一次事务:一次只做一件事,做完就验证,验证通过再考虑要不要扩大职责。

另一个容易忽略的点是 YAML 的缩进。上面这份文件里,insert 下面两个短横线开头的条目必须缩进,idname 又必须相对 - 再缩进一级。YAML 对缩进敏感,用 Tab 代替空格、缩进层级不一致,都会直接导致解析失败——而且报错信息往往只说「解析错误」,不告诉你是第几行的缩进问题。建议统一用两个空格,并且不要在编辑器里开启「Tab 转空格」以外的任何自动格式化插件去重排这份文件。

下面这张表把「覆盖层里能做什么、我们选择做什么」列清楚,帮助建立边界感:

维度 cordis.yml 作为 Web 覆盖层 插件源码 itself(my-plugin.ts)
核心职责 声明式接线:把哪个文件插入当前配置 命令式逻辑:导出 apply 并注册能力
语言与形态 YAML 配置,纯数据 TypeScript 模块,可执行代码
关键字段 insert、id、name name、apply(ctx)
改动后的生效方式 需配合 --patch 启动参数重新加载 重新加载插件后由框架调用 apply
是否包含业务逻辑 否,只做插入 是,通过 ctx 注册事件、工具、LLM 适配器等
删除时的风险 低,整份移除即可回退 中,需确认没有其他插件依赖它注册的能力

表格右侧那一列是本篇暂时不展开的部分——通过在 apply 里调用 ctx 去注册事件监听、工具、LLM 适配器,那是插件真正开始产生价值的阶段。但请记住顺序:先能加载,再谈能力。一个注册了一堆能力却加载不进来的插件,等于零。

id 与 name 字段辨析:插件路径必须是绝对路径

insert 条目里两个字段,idname,名字都很朴素,但语义差别很大,混淆会直接导致故障。

id 是这条插入指令的标识符。在例子里它是 hello。它用于在配置体系内部引用这条条目——比如日志中区分是哪一条 insert 生效了,或者未来其他指令需要指向它。它是一个「名字」,一个逻辑坐标,写什么由你决定,只要在当前文件内唯一即可。素材里的例子用 hello,与插件文件里 name = 'hello-plugin' 只是形似,并不是同一个东西。

name 是插件文件路径,而且必须是绝对路径。这是本篇最硬性、最容易出错的一条约束。素材里用一句带感叹号的注释强调它:# name 是插件文件路径,必须是绝对路径!。框架要据此去磁盘上定位模块并加载,相对路径会因为「相对于谁」这个问题而不确定——你的当前工作目录是仓库根目录?还是 Web UI 启动的位置?还是某个构建产物目录?只要不确定,加载就会随机成功或失败。绝对路径消灭了这种不确定性。

把这对字段的差异做成对照表:

字段 含义 取值示例 是否影响文件定位 常见错误
id insert 条目的逻辑标识,供配置内部引用与日志区分 hello 与其他条目重名,导致引用歧义
name 插件文件路径,框架据此加载模块 /绝对/路径/.../src/my-plugin.ts 写成相对路径,加载定位失败

还有一个衍生问题:这个路径指向的是 .ts 源文件,而不是编译产物。素材的例子里 name 明确写到 scratch-plugin/src/my-plugin.ts,说明这套工作流期望框架(或开发环境)能处理 TypeScript。这意味着你之前的 pnpm installpnpm run build 步骤不只是为了跑 Web UI,也是在为这种「直接加载 TS 源文件」的开发体验铺路。如果你跳过了构建步骤,即使路径写对了,也可能在加载阶段遇到模块解析问题。

路径里的另一个隐形杀手是空格与特殊字符。素材用的是 '/absolute/path/to/...' 这种带引号的写法,引号是必要的:一旦你的仓库路径里含有空格或需要转义的字符,不使用引号会让 YAML 解析器把路径切断。建议无论路径是否含空格,都统一加单引号包裹。同时注意路径中不要混入中文字符——某些工具链在处理非 ASCII 路径时会出问题,而这恰恰是中文开发者最容易忽略的点(比如把仓库放在「我的文档」目录下)。

pwd 取绝对路径:避免相对路径导致插件加载失败

既然 name 必须是绝对路径,那这个绝对路径从哪来最靠谱?答案是最朴素的办法:在仓库根目录执行 pwd,把输出原样拼进 name。素材给出的操作顺序就是先「在仓库根目录运行 pwd,拿到绝对路径」,然后「创建 scratch-plugin/cordis.yml」并填入。

为什么强调「在仓库根目录」?因为我们要拼的是 <仓库根>/scratch-plugin/src/my-plugin.ts,而 pwd 输出的是当前目录的绝对路径。只有站在仓库根目录,这个前缀才是正确的。如果你在别的子目录里执行 pwd,拼出来的路径就会多一层或少一层。

完整的手工流程可以写成这样,方便你复制执行:

# 1. 进入仓库根目录(按你自己的 clone 位置调整)
cd /absolute/path/to/deepseek-harness

# 2. 拿到仓库根的绝对路径
pwd
# 输出示例:/absolute/path/to/deepseek-harness

# 3. 创建插件目录与源码目录(若上一篇已做可跳过)
mkdir -p scratch-plugin/src

# 4. 确认插件源文件确实在预期位置
ls -l scratch-plugin/src/my-plugin.ts

# 5. 写入覆盖层配置,把 pwd 的输出与相对片段拼成绝对路径
cat > scratch-plugin/cordis.yml <<'YAML'
# 文件路径:scratch-plugin/cordis.yml
# 这是一个 Web 覆盖层(overlay),只负责插入本地插件
- insert:
    - id: hello
      # name 是插件文件路径,必须是绝对路径!
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
YAML

# 6. 校验拼出来的路径真实存在(关键一步,能提前拦住大量低级错误)
test -f /absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts && echo "PATH OK" || echo "PATH BROKEN"

第 6 步的 test -f ... && echo 是我强烈建议保留的自检动作。它做的事非常单纯:确认你写进 name 的那个绝对路径在磁盘上确实存在一个文件。很多「插件加载失败」的根因其实根本轮不到框架去报错,路径本身就是错的——也许是 clone 的位置和你想的不一样,也许是文件名拼错了(my-plugin 写成了 myplugin),也许是多写了一层目录。花两秒钟跑一次这个检查,能省掉半小时的日志翻找。

这里还要提醒一个与「相对路径」有关的思维陷阱。相对路径之所以诱人,是因为它看起来更「可移植」——换台机器不用改。但在插件加载这个场景里,可移植性由项目目录结构保证,而不是由相对路径保证:你把整个 scratch-plugin/ 拷到别的机器上,唯一需要改的就是 name 里那一段仓库根前缀,改一处即可。而如果你用相对路径,反而要面对「相对于谁」的永恒追问。素材选择绝对路径,是工程上更稳妥的取舍。

当你需要频繁切换开发环境时,可以把这个前缀抽出来做个小脚本,避免手改:

#!/usr/bin/env bash
# 文件路径:scratch-plugin/refresh-patch.sh
# 用途:把当前仓库根路径写入 cordis.yml 的 name 字段,避免手改相对/绝对路径出错
set -euo pipefail

REPO_ROOT="$(pwd)"
PLUGIN_TS="${REPO_ROOT}/scratch-plugin/src/my-plugin.ts"

if [ ! -f "${PLUGIN_TS}" ]; then
  echo "[refresh-patch] 插件源文件不存在:${PLUGIN_TS}" >&2
  exit 1
fi

cat > scratch-plugin/cordis.yml <<YAML
# 文件路径:scratch-plugin/cordis.yml
# 这是一个 Web 覆盖层(overlay),只负责插入本地插件
- insert:
    - id: hello
      # name 是插件文件路径,必须是绝对路径!
      name: '${PLUGIN_TS}'
YAML

echo "[refresh-patch] 已写入:${PLUGIN_TS}"

注意脚本里的 set -euo pipefail 与文件存在性检查:这两件事让路径错误在「写入配置之前」就暴露,而不是等到启动框架时才暴露。这种「把错误左移」的习惯,在插件开发这种频繁改配置的场景里收益极高。

hello-plugin 加载验证:从 console.log 看插件生命周期

配置写好了,怎么知道它真的生效了?靠日志。回看上一篇的插件源码:

// 文件路径:scratch-plugin/src/my-plugin.ts

import type { Context } from '@deepseek-ai/cordis'

// name 是插件名,用于在日志与配置中标识这个插件
export const name = 'hello-plugin'

// apply 是插件的入口:框架加载插件时调用它
export function apply(ctx: Context) {
  // 需要的依赖在 apply 执行前就已就绪(见第 9 篇)
  console.log('[hello-plugin] plugin loaded!')
}

这段代码里有两个导出值得反复咀嚼:

  • export const name:插件名,用于在日志与配置中标识这个插件。它决定了你在日志里看到的前缀是 [hello-plugin],也决定了其他配置项想引用这个插件时用什么名字。
  • export function apply(ctx):插件的入口。框架在加载插件时调用它,并把 ctx(上下文对象) 传进来。

于是验证方式就很直白了:当你在日志中看到 [hello-plugin] plugin loaded!,就说明框架确实加载了这个文件、确实调用了 apply、确实把控制权交到了你的插件手里。这一行 console.log 是整条链路上第一个可观测信号,也是你后续一切复杂能力的地基——如果这一行走不出来,后面注册事件、注册工具、注册 LLM 适配器都无从谈起。

为什么说这行日志同时验证了三件事?我们把它拆解成一次「生命周期巡检」:

  1. 文件被定位并加载:如果 name 路径写错、目录结构不对、覆盖层没生效,这个模块根本不会被求值,console.log 自然不会执行。
  2. apply 被框架调用:即便文件被加载,如果框架没有调用 apply(例如导出的形状不对),日志也不会出现。日志出现 = 框架认出了这个模块并把它当作插件处理了。
  3. ctx 已传入:apply 的签名接受 ctx,能正常执行到 console.log,说明上下文对象已经就位。这是你在 apply 体内用 ctx 注册能力的前提,也呼应了源码注释里的那句「需要的依赖在 apply 执行前就已就绪」。

关于 ctx(Context),素材给的定义值得单独记下来:它是框架传给每个插件的上下文对象,既是注册能力的入口,也记录了插件注册的一切资源。这后半句特别重要——它意味着 ctx 不只是「工具箱」,还是一本账本:你通过它注册了什么,它就记着什么。这也是为什么「插件加载」这个动作与「资源登记」是同一件事的两面:加载成功后,ctx 就开始替你记账了。在 hello-plugin 这个最小例子里,我们还没有调用 ctx 的任何注册方法,所以账本是空的,但账本本身已经到手。

如果把加载过程画成时序,大概是这样:

  1. 你启动 Web UI,并带上 --patch 指向前面写的 cordis.yml;
  2. 框架读取这份覆盖层,遇到 insert 指令与 name 指向的绝对路径;
  3. 框架加载该路径下的模块,识别出 name 与 apply;
  4. 框架为这个插件准备 ctx,并调用 apply(ctx);
  5. apply 体内的 console.log 执行,你看到 [hello-plugin] plugin loaded!
  6. 此后,ctx 持续记录该插件注册的一切资源。

值得强调的是第 6 步的「持续」:ctx 不是一次性的函数参数,它伴随插件的整个生命周期。所以正确的心智模型不是「我在 apply 里用了一下 ctx」,而是「我把插件的一生托付给了 ctx」。理解了这一点,你在后面写更复杂的插件时,才会自然地思考:我通过 ctx 注册的东西,将来如何被注销、如何被其他插件发现、如何在日志里被追溯。

2026 年 9 月最新实践:scratch-plugin 作为插件开发沙箱的约定

把上面这些步骤串起来看,你会发现它们不是零散技巧,而是一套成型的工作流。截至当前版本,素材里这套「scratch-plugin 隔离实验插件 + patch 覆盖层加载」的组合,已经是社区里相当成熟的开发约定,值得作为默认做法固定下来。它的核心思想是:新插件一律先在一个独立目录里长出来,通过覆盖层接入,验证通过后再考虑是否并入正式结构。

为什么这套约定好用?因为它把「实验」和「生产」在物理层面隔开了:

  • 风险隔离:scratch-plugin 里的东西无论如何折腾,都不会改动框架主体配置。改坏了,删掉整个目录就能回到干净状态。
  • 加载方式统一:无论插件多小多大,接入方式永远是「写一份 cordis.yml + 带 --patch 启动」,心智负担恒定。
  • 可复现:目录 + 覆盖层构成一个自包含单元,拷给别人就能跑,非常适合写教程、写最小复现(minimal reproduction)、写 bug 报告附件。
  • 命名即文档:scratch 这个词本身就传达了「试验场、可丢弃」的语义。看到这个目录名,任何人都会预期里面的东西不是长期资产。

对应的标准操作节奏可以固化为五步循环:

  1. cd 到仓库根目录,pwd 拿到绝对路径;
  2. scratch-plugin/src/ 下新建或修改插件文件,导出 name 与 apply;
  3. 更新 scratch-plugin/cordis.yml,确保 name 指向刚改过的那个 .ts 文件(绝对路径);
  4. --patch 启动,观察日志里是否出现对应的 loaded 输出;
  5. 确认无误后再继续加能力(通过 ctx 注册事件、工具、LLM 适配器)。

有一个实践中容易被低估的点:一次只插入一个插件。素材的例子里 insert 只放了一条 id: hello。初学时很多人会想「反正 insert 支持数组,我一次性把五个实验插件都塞进去」。这在早期看起来省事,但会破坏上面第 4 步的可观测性:五条日志同时出现,你无法确定哪一条对应哪个文件、哪一次改动引入的问题。正确做法是让 insert 数组随着你的验证进度逐步增长——今天加一个,确认日志干净,明天再加一个。

另一个值得强调的约定是日志前缀统一使用方括号包裹插件名,如 [hello-plugin] plugin loaded!。这不是强制要求,但一旦你开始同时开发多个插件,这几乎是唯一的救命稻草:在几百行交错输出里,[hello-plugin] 这个前缀能让你瞬间过滤出属于自己插件的行。我建议把这条约定严格执行到每个日志点,包括注册成功、注册失败、收到事件等关键时刻。

最后,关于 --patch 参数本身,也有一条经验:把它写进你的启动脚本或 npm script,而不是每次手敲。手敲的问题是它容易被遗忘——尤其是在你改了 cordis.yml 之后重启框架时,如果忘了带 --patch,框架会正常启动、正常跑,但你的插件压根没被加载,然后你会对着「日志里怎么没有 [hello-plugin]」困惑很久。把启动命令固定下来,是防范这类「静默失败」最有效的办法。

插件加载失败排查清单:路径、参数与文件位置三查

即使完全照做,第一次加载也未必一次成功。好消息是,这个阶段的失败原因高度集中,可以用一张「三查清单」快速定位。所谓三查,就是查路径、查参数、查文件位置。下面按排查优先级从高到低展开。

第一查:name 是否为绝对路径。这是最高频的故障源,也是素材用感叹号强调过的那一条。检查方法很简单:打开 cordis.yml,看 name 的值是否以 / 开头(在类 Unix 系统上)。如果它以 ... 或目录名开头,那它就是相对路径,必须改。改的时候记得用引号包裹,并且改完立刻用前一节的 test -f 自检。

第二查:启动时是否带了 --patch。如果路径完全正确但日志里就是没有输出,第二个要怀疑的就是覆盖层根本没被读进来。素材明确说这里会用到两个东西:patch 覆盖层 cordis.yml 和 --patch 启动参数。两者缺一不可——光写文件不传参数,框架不知道要去读它;光传参数没有文件,框架读不到东西。检查方法:回看你的启动命令,确认 --patch 及其指向的路径都在。

第三查:cordis.yml 是否置于 scratch-plugin 下。素材给出的目录结构里,cordis.yml 与 src 是兄弟节点,共同位于 scratch-plugin 之下。如果你的文件放错了层(比如放在仓库根、或者误放进 src 里面),那么即使 name 路径正确、参数也带了,你传递的 patch 路径与实际文件位置对不上,同样会失败。检查方法:ls -l scratch-plugin/,确认同时看到 srccordis.yml

把这三查整理成表格,方便对照:

排查项 检查对象 期望状态 典型症状
路径 cordis.yml 中 name 的值 以 / 开头的绝对路径,且文件真实存在 插件无任何日志输出
参数 启动命令是否带 --patch 及正确路径 --patch 指向 scratch-plugin/cordis.yml 覆盖层未生效,插件未插入
文件位置 cordis.yml 与 src 的相对层级 二者并列于 scratch-plugin 之下 patch 路径解析不到文件

除三查之外,还有几个「次级嫌疑点」,按出现频率排序:

  • YAML 缩进错乱:用 Tab 或缩进层级不一致,导致解析失败。症状通常是启动阶段就报配置解析错误,而不是插件静默不加载。
  • 导出形状不对:忘记导出 apply,或者把 apply 写成了箭头函数赋给变量但没 export。日志同样不会出现,因为框架找不到入口。
  • 文件路径含空格或特殊字符却没加引号:YAML 把路径切断,name 变成了残缺字符串。
  • 跳过构建步骤:如果只跑了 git clone 与 pnpm install 而没有 pnpm run build,环境本身可能不完整,加载 TS 源文件时会出现与路径无关的模块解析问题。
  • id 重名:同一份 cordis.yml 里两条 insert 用了同一个 id,导致引用歧义。多插件场景下尤其要注意。

排查时的推荐顺序是:先自检路径(秒级)→ 再看启动命令(秒级)→ 再确认文件位置(秒级)→ 最后才去翻完整日志。这个顺序的价值在于,前三步都是确定性的、不需要读长日志的,能覆盖绝大多数故障;把读日志放到最后,是在前三步都排除之后才做的事。很多人的排查习惯正好相反——一上来就 scroll 几百行日志,结果被无关信息淹没。

还有一条我个人的血泪经验:改动配置后,务必确认框架是重新启动过的。cordis.yml 是启动时被读取的,如果你只热重载了代码而没重启框架,覆盖层的改动不会生效。这一点在多窗口开发时特别容易犯——你在 A 窗口改了 yml,在 B 窗口对着旧进程的日志发呆。

总结与最佳实践

到这里,从目录结构、insert 语法、字段语义、绝对路径取法,到加载验证与故障排查,整条链路已经闭环。把全文压缩成一份可执行清单:

  1. 目录结构固定为「一个沙箱 = 一个 src + 一份 cordis.yml」:scratch-plugin/ 下让 src/cordis.yml 并列,不要把配置丢进 src,也不要污染仓库根目录。
  2. cordis.yml 只写 insert,保持单一职责:这份 Web 覆盖层只负责插入本地插件,不夹带任何其他改动,方便整体删除与回滚。
  3. insert 数组一次只加一个插件:随验证进度逐步增长,保证日志里每条 loaded 输出都能对应到明确的文件与改动。
  4. 严格区分 id 与 nameid(如 hello)是条目逻辑标识,供配置引用与日志区分;name 是插件文件路径,必须使用绝对路径
  5. 用 pwd 拿绝对路径,并立刻自检:在仓库根目录执行 pwd,拼出 <仓库根>/scratch-plugin/src/my-plugin.ts,再用 test -f 确认文件真实存在,把错误挡在配置写入之前。
  6. 路径统一加单引号包裹:避免空格与特殊字符把 YAML 路径切断,同时尽量避开含中文的目录路径。
  7. YAML 缩进统一两个空格:不用 Tab,不让格式化工具重排这份文件。
  8. 启动必带 --patch,并把它固化进启动脚本:忘了带是典型的「静默失败」,把命令写死可彻底规避。
  9. 用 [hello-plugin] plugin loaded! 作为加载成功的信号:这行日志同时证明文件被定位加载、apply 被框架调用、ctx 已传入;把它当作所有后续能力开发的地基检查点。
  10. 日志前缀统一用方括号包裹插件名:多插件并行开发时,这是快速过滤与定位的唯一可靠手段。
  11. 牢记 ctx 的双重身份:它既是注册能力的入口,也记录插件注册的一切资源;后续通过它注册事件监听、工具、LLM 适配器时,要按「伴随整个生命周期」来设计。
  12. 加载失败按三查顺序排查:一查 name 是否绝对路径,二查启动是否带 --patch,三查 cordis.yml 是否置于 scratch-plugin 下;三步都排除后,再依次怀疑 YAML 缩进、导出形状、路径引号、构建步骤与 id 重名。
  13. 把 scratch-plugin 当作可丢弃的试验场:实验插件先在这里长出来,通过覆盖层接入、日志验证后再考虑并入正式结构,让实验与生产在物理层面保持隔离。

最后再强调一次顺序:先让插件被加载进来,再谈它能为框架做什么。一个能打印 loaded 的空插件,胜过一个功能齐全但加载不进来的复杂插件。把今天这套「绝对路径 + patch 覆盖层 + 日志验证」练到肌肉记忆,后面你在 apply 里用 ctx 注册事件、工具与 LLM 适配器时,才能把注意力放在真正的业务逻辑上,而不是浪费在「为什么我的插件没生效」这类问题上。