很多开发者第一次读 DeepSeek Harness 的插件示例时,最容易产生两个疑问:为什么一个插件只要写一句 export const inject = ['tools'],就能笃定 ctx.tools 一定存在?以及为什么插件卸载时,文档里完全没有出现 removeListener、clearInterval 这类手动清理代码?这两个问题看似分属“依赖声明”和“资源回收”两个话题,实际上回答的是同一件事:Harness 把插件从「一个随处产生副作用的模块」重新建模成了「挂在一棵作用域树上、依赖关系被显式声明的节点」。本篇文章围绕 依赖注入(inject)Effects(ctx.effect)服务模型(Service) 三条主线展开,先讲清楚三大内置服务暴露了什么、inject 数组在 apply 之前做了什么时序保证、如何用 Service 基类把自己变成服务的提供方,再顺着 ctx.effect() 和 Fiber 作用域记账的机制,把“为什么可以不写清理代码”这个悬念一层层拆开,并给出可以直接粘贴运行的 TypeScript 示例与工程避坑清单。本文是整篇教程的前半部分,先把机制讲透,把注册与卸载的时序、服务查找与类型提示的来源、自动追踪清单的边界都落到具体字段与行为上。

ctx.tools / ctx.llm / ctx.agents:三个内置服务分别暴露什么能力

要理解依赖注入,得先理解被注入的那个东西——“服务”。在 Harness 的语境下,服务是一个插件向其他插件公开的命名能力。它不是一个被 import 进来的函数库,而是挂载在 ctx 对象上的一个稳定属性,例如 ctx.toolsctx.llmctx.agents。任何一个插件,只要拿到 ctx,就可以顺着这个 key 去查找能力,而完全不需要知道这个能力背后是谁实现的、实现在哪个文件、是不是会被另一个插件替换成 mock 版本。这正是依赖注入区别于“直接 import”的核心:调用方依赖的是契约,而不是实现

Harness 在启动时就把三个基础服务挂到 ctx 上,它们构成了绝大多数插件的最小生态底座。下面这张表把这三个内置服务的服务名、它是什么、以及典型用法做对照,便于在写插件时快速判断自己到底该 inject 哪一个:

内置服务它是什么典型用法
ctx.tools工具运行时(ToolRuntime)注册工具、调用工具,把模型可调用的能力挂到运行时上
ctx.llm大语言模型服务(LLM)注册模型适配器、发起模型请求,是模型调用的统一入口
ctx.agents智能体服务(Agent)管理子智能体,负责多智能体之间的编排与生命周期

从这张表能读出两个工程含义。第一,ctx.tools 面向“能力注册与调用”这一层,它关心的是工具的运行,而不是工具的具体业务逻辑;你的插件把工具对象交给它,它负责在模型需要调用时把请求路由过去。第二,ctx.llm 是模型访问的统一收口,注册适配器意味着你可以换掉底层供应商而不动上层业务代码,发起请求意味着你不需要自己 new 一个 HTTP 客户端。第三,ctx.agents 把子智能体的管理从插件里抽离出来,插件的职责退化成“声明子智能体、编排它”,生命周期交给服务本身。

这里有个容易被忽略但很关键的设计细节:插件通过 key 查找服务,而不是导入具体实现。也就是说,你在插件里写的是 ctx.tools.register(...),而不是 import { ToolRuntime } from '...' 然后自己去构造一个实例。这个看似只是“少写一个 import”的差别,实际上决定了三件事:

  • 可替换性:只要有另一个插件提供了同名 key 的服务,甚至提供了符合契约的测试替身,消费方代码一行都不用改。测试时你完全可以把 ctx.tools 换成一个假的实现,观察插件是否按契约调用。
  • 可组合性:多个插件可以各自往同一个服务里注册自己的能力,工具、模型适配器、子智能体都能被聚合到同一个命名空间下,而不需要插件之间互相 import 形成网状依赖。
  • 可卸载性:因为服务注册是通过 ctx 进入框架视野的,卸载时框架才知道该归还哪些东西。反过来说,如果你绕过 ctx 自己维护一份全局注册表,卸载时框架是看不到它的,这份资源就会泄漏。

工程上还有一个常见的坑:把“服务”和“工具”混为一谈。服务是命名能力,它挂在 ctx 上;工具是你在 ctx.tools 这个服务里注册的具体条目。两者是容器与内容的关系。一个插件既可以消费 ctx.tools 这个服务,也可以消费 ctx.llm 这个服务,还可以自己提供一个新服务,这三件事互不冲突。写 inject 时要问自己的问题是“我需要用到哪个 ctx.”,而不是“我想让别的插件用我的哪个函数”。

inject 数组:apply 执行前框架如何保证依赖全部就绪

理解了服务,接下来看声明依赖的语法。一个插件要表达“我依赖 tools 服务”,只需要在模块顶层导出一个名为 inject 的数组:

// 文件路径:scratch-plugin/src/my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'my-tool-plugin'

// 声明依赖:需要 tools 服务
export const inject = ['tools']

export function apply(ctx: Context) {
  // 走到这里时,ctx.tools 一定已就绪
  ctx.tools.register(/* ... */)
}

这段代码虽短,但它承载了一个非常强的时序契约:只要 inject 里写了 'tools',那么当 apply 被调用时,ctx.tools 一定存在且已就绪。这里的“就绪”不是“属性存在但可能是 undefined”,而是“服务已经完成挂载,可以直接调用它的公开方法”。框架在插件加载流程里,会先读取 inject 数组,逐个检查这些服务是否已经在 ctx 上可用;只有当 inject 里的每一项都满足条件,才会真正进入你的 apply。如果某个服务尚未就绪,框架会把这个插件挂起,等服务出现后再触发加载。这就是为什么 apply 里可以放心地写 ctx.tools.register(...),而不需要写 if (ctx.tools) 这样的防御式判断。

把这段时序展开成步骤,大致是这样的调用链:

  1. 框架读取插件模块导出的 name 与 inject。
  2. 框架解析 inject 数组里的每个 key,在 ctx 上查找对应的服务是否已就绪。
  3. 如果全部就绪,调用 apply(ctx);如果存在未就绪的服务,插件进入等待,不执行 apply。
  4. apply 内对 ctx.tools 的调用发生在服务已经挂载之后,因此访问安全。

这里有三个工程要点值得单独强调。第一,inject 是模块级导出,不是 apply 里的局部变量。这意味着它必须在插件被解析的那一刻就能被框架读到,而不能在 apply 运行时才“决定”自己依赖谁。这一约束是有意为之:依赖关系是静态可分析的,框架能在加载前做拓扑排序,避免出现“插件 A 依赖插件 B、插件 B 又依赖插件 A”的循环加载,也避免出现“服务还没初始化就被使用”的竞态。第二,inject 声明的是服务 key,而不是具体的类或文件名。你写 'tools',对应的是 ctx.tools;如果某个自定义服务挂载在 ctx.sessions 上,你 inject 的就应该是 'sessions'。key 与 ctx 上的属性名一一对应,这是最直观也最不容易出错的心智模型。第三,apply 是执行时机,不是依赖声明时机。很多新手会把依赖判断写进 apply 里,例如先检查再注册,结果既冗长又掩盖了真实的依赖关系;正确的做法是把依赖全部前移到 inject 数组,让 apply 只关心业务逻辑。

再进一步,会有一个自然的追问:如果我 inject 了 'tools',但我又希望这个插件在 tools 服务被替换后依然能正常工作怎么办?答案是,你本来就应该只依赖契约。因为 ctx.tools 是一个服务,它的公开接口是稳定的,替换实现不会改变你调用的方法签名。这也是为什么 inject 数组只写字符串 key 是足够的——契约的稳定性已经由服务的接口来保证,而不需要你在 inject 里声明版本或来源。

从消费方到提供方:用 Service 基类挂载自定义 ctx.<key>

前面讲的都是“消费方”视角:我的插件需要别人提供的能力,所以写 inject。但从插件生态的角度看,一个健康的系统里一定有插件充当“提供方”。Harness 为此提供了 Service 基类,让任何插件都可以把自己的一项能力注册成命名服务,挂到 ctx 的某个 key 上,供其他插件消费。

先明确服务的定义:服务是挂载在 ctx 上的命名能力,任何插件都可以提供服务,供其他插件使用。它占据一个稳定的 ctx.,例如 ctx.tools、ctx.llm、ctx.sessions;其他插件通过这个 key 查找服务,而不是导入具体实现。这个定义里有两个词很重要:命名稳定。命名意味着服务必须有一个明确的 key,作为它在 ctx 上的唯一地址;稳定意味着这个 key 一旦对外公开,就不应该随意改名或搬位置,否则所有消费方都会被破坏。这正是为什么内置服务的服务名要由仓库统一生成、而不是散落在各处的文档里——命名空间是需要被治理的。

用 Service 基类做提供方的典型流程可以概括成三步:

  1. 定义一个服务类,继承 Service 基类,并在类上实现你要公开的方法。
  2. 在插件的 apply 中,把这个服务实例挂到约定的 ctx. 上,完成“提供服务”这一动作。
  3. 在其他插件的 inject 数组里写入该 key,框架就会保证在它们的 apply 执行时你的服务已经就绪。

这里的关键在于挂载点与 key 的对应关系。当你把服务挂到 ctx.sessions 时,消费方 inject 的就是 'sessions';当服务被卸载时,这个 key 也会随之从 ctx 上撤下,消费方的插件依赖就不再满足。这种“服务出现 → 消费方被唤醒;服务消失 → 消费方被挂起或卸载”的联动,是依赖注入系统最值得体会的部分。它让插件之间的协作不再依赖“谁先加载”的偶然顺序,而依赖“谁声明了依赖”的显式契约。

工程实践上,提供方要特别注意两件事。第一,公开方法要小而稳定。服务接口一旦被大量消费方使用,修改成本会指数级上升,因此宁可多提供几个细粒度方法,也不要提供一个参数极多的“万能方法”。第二,服务不要在构造函数里做重活。服务被挂载的时机受框架调度影响,重初始化会拖慢启动;更稳妥的做法是把重活放进 apply,或在消费方第一次调用时惰性初始化。

类型提示从哪来:Service 接口与自动生成的服务页面

依赖注入系统最容易让高级读者挑剔的一点是类型安全:如果消费方只是通过字符串 key 去 ctx 上取服务,TypeScript 怎么知道 ctx.tools 上有 register 方法?答案是,框架通过 Service 基类与类型声明把服务接口注入到 Context 的类型里。换句话说,当你正确声明了一个服务,ctx. 在类型层面就会拥有该服务的公开方法,消费方获得完整的类型提示与编译期检查。

对内置服务,情况更值得强调:内置服务的服务名、公开方法和源码位置由仓库自动生成到各服务的子系统页面。这句话包含三层信息:

  • 服务名:例如 tools、llm、agents,这些 key 不是手写维护的一份静态清单,而是从仓库中生成出来的,保证与代码真实状态一致。
  • 公开方法:每个服务对外暴露了哪些方法,同样由生成区块给出,而不是靠文档作者凭记忆罗列。
  • 源码位置:服务实现在哪个文件,生成页面里可查,方便你直接跳到实现去确认行为细节。

由此得出一条非常重要的开发纪律:开发插件时应以这些生成区块和服务的 TypeScript 接口为准,不要依赖任何手写的静态服务清单。手写清单的问题是它会漂移:服务加了一个方法,清单没更新;服务改了参数,清单还是旧的。而自动生成的区块与 TS 接口天然与源码同步。遇到不确定的地方,正确姿势是去看 TypeScript 接口的方法签名,而不是去搜一篇二手教程里的表格。

把类型链条完整串一遍,大概是这样的:Service 基类定义服务实例上可用的公开方法;插件在提供方把服务挂到 ctx.;框架的类型声明让 Context 接口包含这个 key;消费方 inject 这个 key 后,在 apply 里访问 ctx. 时就能拿到完整提示。这也解释了为什么 inject 数组里的字符串是有意义的:它是类型系统与运行时调度系统共享的锚点——一边用来做类型推导,一边用来做依赖就绪判断。一处声明,两处受益,这是依赖注入在工程上最划算的地方。

ctx.effect():网络连接这类非注册资源的清理入口

讲完服务与依赖,文章进入第二条主线:资源清理。真实插件不会只打印一行日志,它会注册监听、注册工具、起定时器,甚至建立网络连接。于是问题来了:插件卸载时,这些资源谁来清理?Harness 的答案是——注册交给 ctx,清理也交给 ctx

对一部分资源,框架可以自动追踪,因为它们是通过 ctx 的公开方法注册进去的。但对另一部分资源,例如一个网络连接、一个文件句柄、一个第三方 SDK 的实例,框架并不知道你的创建逻辑,也就无法自动推断该怎么销毁。这时就需要 ctx.effect()。它的用法很直接:ctx.effect 接收一个回调,回调里创建资源并返回一个清理函数(disposer),这个 disposer 会在插件卸载时执行。

// 文件路径:scratch-plugin/src/heartbeat.ts
import type { Context } from '@deepseek-ai/cordis'

export function apply(ctx: Context) {
  ctx.effect(() => {
    // 创建定时器:每 5 秒打印一次 heartbeat
    const timer = setInterval(() => {
      console.log('heartbeat')
    }, 5000)

    // 返回的清理函数在插件卸载时执行
    // 等价于:不需要你在卸载逻辑里手动 clearInterval
    return () => clearInterval(timer)
  })
}

这段代码里有两个角色需要分清。回调负责“创建资源”,它在 effect 被调用时立即执行,返回 disposer;disposer 是这个回调的返回值,它描述的是“如何销毁这次创建的资源”,由框架在插件卸载时调用。把注意力放在 disposer 的语义上:它不是“随便一个收尾函数”,而是与这次创建严格配对的销毁动作。你创建了 timer,disposer 就负责 clearInterval;你打开了连接,disposer 就负责断开连接并释放相关句柄。这种“创建与销毁成对出现”的写法,把资源管理从“记得在某处写清理”变成了“创建时就地声明如何清理”,大幅降低了遗漏概率。

为什么说 effect 弥补了自动追踪的缺口?因为自动追踪的前提是“注册动作被 ctx 看见”,而 effect 是那个显式的声明入口:你主动告诉框架“这是我创建的资源,这是它的销毁方式”。凡是无法被自动追踪列表覆盖的资源,都应该走 effect。工程上有个很实用的判断法则:如果你在 apply 里 new 了某个对象、open 了某个连接、起了一个不在 ctx 上的循环,那它大概率需要 effect 来兜底。反过来,如果你只是调用 ctx 的注册方法,就不需要再包一层 effect,避免重复声明。

示意图
注册与卸载的自动清理时序:通过 ctx 的注册进入 Fiber 作用域,卸载时按逆序撤销,非注册资源则由 ctx.effect 的 disposer 兜底。

Fiber 作用域记账:为什么卸载时不需要 removeListener

现在回答开头那个问题:为什么可以不写清理代码?核心机制在于 Fiber 作用域。文档给出的解释是:框架能自动清理,是因为所有通过 ctx 的注册都被记在插件的 Fiber 作用域里;卸载时,框架按注册顺序的逆序撤销它们。这句话信息量很大,我们逐段拆开。

第一,“所有通过 ctx 的注册”。注意限定词是“通过 ctx”。ctx.on 注册的事件监听、ctx.tools.register 注册的工具、ctx.llm.registerAdapter 注册的适配器,以及 ctx.effect 注册的资源,都属于这一类。它们有一个共同特征:调用发生在 ctx 上,因此框架能在调用发生的瞬间把这条注册记录下来。相反,如果你绕过 ctx 直接调用某个全局对象的 addListener,框架是看不到这条记录的,自然也就无从清理。

第二,“记在插件的 Fiber 作用域里”。Fiber 可以理解为一个插件实例的执行上下文,它自带一个记录簿,记录这个插件在生命周期内做过的所有可撤销动作。每条记录既包含“撤销时要调用什么”,也包含顺序信息。把记录绑定到 Fiber 而不是全局,带来一个很实用的性质:卸载粒度是插件级。当某个插件被卸载时,框架只需要处理这个 Fiber 里的记录,不会误伤其他插件注册的资源。即便两个插件注册了同名工具,卸载其中一个也只会撤销属于它的那条注册。

第三,“按注册顺序的逆序撤销”。这是一个非常经典的资源管理原则,类似栈的后进先出。后注册的资源可能依赖先注册的资源,因此撤销时必须反过来:先把后注册的拆掉,再拆先注册的,否则会出现“依赖已经没了、消费方还在”的悬空状态。举个具体的例子,如果你的插件先注册了一个工具、随后又通过 effect 创建了一个定时器去周期性调用这个工具,那么卸载时正确的顺序是:先停掉定时器,再撤销工具注册。逆序撤销天然满足这一点,而如果是正序,定时器可能会在未来某个 tick 里访问到一个已经被撤销的工具,产生难以定位的错误。

把这三条合起来,就能理解“不需要手动 removeListener 或 clearInterval”的底气从哪里来:注册动作被 ctx 记录 → 记录进入插件 Fiber 作用域 → 卸载时逆序撤销。你写 ctx.on(...),卸载清理由框架负责;你写 ctx.effect 并返回 disposer,卸载时框架调用你的 disposer。整条链路里,你唯一需要主动做的事,就是把资源创建交给 ctx 或 effect,而不是自己开一个不受管理的影子注册表。

自动追踪清单:ctx.on / ctx.tools.register / ctx.llm.registerAdapter / ctx.effect 的卸载行为

最后,把“哪些操作会被自动追踪和清理”落成一张逐条对照的清单。下面这张表是本文最需要背下来的部分之一,因为它直接决定你在插件卸载时要不要写额外代码:

注册操作卸载时的行为
ctx.on(event, handler)事件监听自动移除
ctx.tools.register(tool)工具注册自动撤销
ctx.llm.registerAdapter(names, adapter)LLM 适配器注册自动撤销
ctx.effect(() => cleanup)执行返回的 disposer 清理函数

逐条解读这张表的工程含义。第一行 ctx.on(event, handler):事件监听是最容易被遗忘的资源之一,传统写法里你必须成对写 on 与 removeListener,否则重复加载插件会让同一个事件被响应多次。Harness 里你只写 ctx.on,卸载时监听自动移除,从根本上消除了“监听泄漏导致 handler 被调用多次”的经典 bug。第二行 ctx.tools.register(tool):工具注册自动撤销,意味着插件卸载后模型不会再把请求路由到这个已经不存在的工具上,避免了“工具已死、路由还在”的幽灵调用。第三行 ctx.llm.registerAdapter(names, adapter):模型适配器注册自动撤销,尤其适合做实验性适配器的插件——挂上、试跑、卸下,不需要担心适配器残留在全局注册表里污染后续请求。第四行 ctx.effect(() => cleanup):框架不替你推断销毁逻辑,但因为你在回调里返回了 disposer,卸载时框架会调用它,等价于你手动写清理,只是位置从“卸载钩子”前移到了“创建现场”。

这张表同时划出了一条边界:能被自动清理的前提是“通过 ctx 注册”或“通过 effect 声明”。清单之外的资源——裸 setInterval、裸 setTimeout、自行建立的网络连接、第三方 SDK 实例、全局缓存——都需要你主动用 ctx.effect 兜底,或者干脆改成通过 ctx 提供的等价能力来做。这也是本文最想传达的一条工程纪律:不要问“卸载时要清什么”,而要问“我在创建时有没有把它交给 ctx 或 effect”。前者是事后补救,后者是事前治理;在插件会被反复加载卸载的系统里,事前治理才是可维护的解法。

顺着这张清单继续往下,还有一个更深入的问题:如果两个插件都依赖同一个服务,其中一个卸载了会怎样?如果服务的提供方插件先于消费方卸载,消费方会不会拿到一个已经失效的 ctx.?这些与依赖顺序、服务消失后的联动相关的问题,恰好是下一部分要展开的内容——我们会在后半段把 inject 的时序保证、服务的生命周期联动,以及 ctx.effect 在资源依赖链上的进阶用法继续讲透。

上一段我们把 inject 声明依赖与 Fiber 作用域的记账机制拆开讲透了,也交代了 ctx.effect 的注册语义。这一段落我们把镜头拉近到真实插件:当插件里开始出现监听、工具、定时器,卸载那一刻到底发生了什么,谁来埋单。

心跳定时器实战:setInterval 与 clearInterval 如何被 disposer 接管

先回想上一段末尾提到的最小插件——它只打印一行日志,加载完就结束,卸载时没有任何副作用需要善后。但只要插件开始有点实际功能,情况立刻发生变化。考虑一个最经典的场景:我们需要一个后台心跳,每 5 秒往控制台打一次 heartbeat,用来确认插件还活着、事件循环没有被阻塞。直觉写法是直接在 apply 里调用 setInterval

// 反面教材:不要这样写
export function apply(ctx) {
  const timer = setInterval(() => console.log('heartbeat'), 5000)
  // timer 被闭包捕获,但没有任何代码在卸载时清它
}

这段代码能跑,但存在一个隐蔽的资源泄漏:setInterval 的句柄被闭包捕获后,如果插件被卸载,框架手里没有任何指向这个 timer 的引用,自然也就无从清理。定时器会继续以每 5 秒一次的频率触发,回调里的 console.log 照打不误。更糟的是,如果回调里引用了插件内部的其他对象,这些对象会因为闭包引用链无法被 GC 回收,形成常驻内存。在热重载频繁的开发环境下,反复加载卸载同一个插件,就会累积出几十个僵尸定时器,控制台被 heartbeat 刷屏,排查问题时极度干扰。

正确做法是把资源创建动作交给 ctx.effect,让框架替我们记账。看下面这段可以直接粘贴运行的 TypeScript:

// 文件路径:scratch-plugin/src/heartbeat.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'heartbeat-plugin'

export function apply(ctx: Context) {
  ctx.effect(() => {
    // 创建定时器:每 5 秒打印一次 heartbeat
    const timer = setInterval(() => {
      console.log('heartbeat')
    }, 5000)

    // 返回的清理函数在插件卸载时执行
    // 等价于:不需要你在卸载逻辑里手动 clearInterval
    return () => clearInterval(timer)
  })
}

这段代码与反面教材的唯一区别,就是把创建与销毁的配对关系显式表达出来了:回调负责创建,返回值负责销毁。框架在插件加载时执行 ctx.effect 的回调,拿到返回值(一个函数),把它存进当前插件的 Fiber 作用域;插件卸载时,框架遍历这个作用域,按注册顺序的逆序把每个 disposer 调一遍,于是 clearInterval(timer) 就被执行了,timer 句柄被释放,回调不再触发,闭包引用链断开,内存可以被正常回收。

这里有一个容易踩的坑:不要在 ctx.effect 回调里做异步的创建动作。因为框架需要在回调同步返回时就拿到 disposer,如果你回调里写到一半才 await 拿到连接对象,返回值可能是 undefined,disposer 就丢了。正确姿势是同步先把句柄创建出来,异步的初始化放在 effect 之外或单独用 async 流程配合手动的注册清理入口。

另一个工程细节值得说明:ctx.effect 可以调用多次,也可以与 ctx.on、ctx.tools.register 混用。Fiber 作用域在卸载时是逆序撤销的,这意味着最后注册的 effect 最先被清理。这个顺序在资源间存在依赖关系时很重要——例如先建立的数据库连接、后注册的订阅者,逆序清理能保证订阅者在连接关闭前先退订,避免清理期间报错。

disposer 的语义:它描述的是如何销毁这次创建的资源

很多人第一次接触 ctx.effect 时会把它和普通的事件回调混淆,觉得“我传了个函数进去,这不就是回调吗”。这里要把概念掰开:普通回调描述的是“事情发生时做什么”,disposer 描述的是“如何销毁这次创建的资源”。两者在语义上属于完全不同的范畴。

disposer 的三个关键性质,值得逐条记牢:

  • 它必须是 ctx.effect 回调的返回值。不是任意函数,不是外面定义好传进来的函数,而是“这一次 effect 执行时创建的资源,对应的销毁逻辑”。哪怕你从工具函数里 import 一个清理器,也必须在回调内 return 出去,框架才认。
  • 它只在插件卸载时被框架调用,插件正常运行时框架不会主动执行它。也就是说 disposer 是一个纯粹的卸载钩子,可以放心在里面写停服、断连、刷盘之类只该在下线时做的事。
  • 它天然与创建动作在同一个闭包里,因此可以捕获创建时拿到的本地变量(如上例里的 timer)。你不需要把句柄挂到全局单例或 ctx 上的某个字段,闭包就是天然的关联通道,这也是为什么推荐把创建与销毁写成一对的形式。

如果不返回这个函数,或者返回了非函数值(比如返回 undefined、返回一个字符串),框架就无法为这次 effect 建立清理入口。经验法则是:ctx.effect 里创建了任何“需要关闭的句柄”,就必须配对返回一个 disposer,无论是 setInterval、WebSocket、文件描述符、子进程句柄,还是自己维护的一个订阅列表。

还有一个进阶用法值得提示:disposer 本身可以是 async 函数,也就是 return async () => { await conn.close() }。框架会执行它,但要注意卸载流程的时序——如果项目里其他地方对卸载完成有同步时序假设(例如测试里 await 卸载后立刻断言资源已被释放),异步 disposer 可能带来竞态,需要在测试里显式 await 卸载完成信号。

从打印日志到真实插件:注册监听、工具、定时器后清理由谁负责

把视角拉回到上一段开头提到的那个“打印一行日志就结束”的最小插件。它是教学意义上的起点,但真实插件几乎不会长这样:一个像样的插件通常会注册事件监听(ctx.on)、往工具注册表里塞可被 Agent 调用的工具(ctx.tools.register)、注册 LLM 适配器(ctx.llm.registerAdapter),再加上自己的定时任务或后台轮询(用 ctx.effect 接管)。这些动作每一个都会在框架侧留下一个可撤销的注册点。

问题的关键在于责任归属的变化。传统插件式架构里,注册与清理是一对由插件作者手工维护的对称操作:你在 onLoad 里 addEventListener,就必须在 onUnload 里 removeEventListener;你 setInterval,就必须 clearInterval。遗漏清理是这类架构里最常见的 bug 类别之一,而且极难在开发阶段暴露——因为你不卸载插件的时候一切正常,只有热重载、动态禁用、测试用例反复挂载卸载时才现形。

Harness 的做法是把这套对称责任整体收归框架:凡是通过 ctx 完成的注册,都被记进当前插件的 Fiber 作用域,卸载时由框架统一核销。素材里给出的追踪范围包括:

  • ctx.on(event, handler) 注册的事件监听,卸载时自动移除,不需要手动 removeListener。
  • ctx.tools.register(tool) 注册的工具,卸载时注册自动撤销。
  • ctx.llm.registerAdapter(names, adapter) 注册的 LLM 适配器,卸载时注册自动撤销。
  • ctx.effect(() => cleanup) 里创建的资源,卸载时执行返回的 disposer 清理函数。

这四类覆盖了绝大多数插件的资源形态。也就是说,只要插件作者养成一个习惯——要注册,就走 ctx 提供的口子;口子之外的资源创建,就用 ctx.effect 主动申报——那么清理代码就基本可以从插件里消失了。这个习惯的价值不在于省下几行 clearInterval,而在于把“资源生命周期正确性”从一个依赖人记忆和谨慎的因素,变成一个由框架结构保证的不变量。清理不再靠自觉,而是靠机制。

scratch-plugin 目录下的两个文件:my-tool-plugin.ts 与 heartbeat.ts 的依赖声明差异

素材里给出了两个代表性的示例文件,它们恰好代表了插件开发中两种不同的“外部需求”处理方式,值得放在一起对比。第一个是 scratch-plugin/src/my-tool-plugin.ts,它需要往工具注册表里塞工具,因此必须声明对 tools 服务的依赖;第二个是 scratch-plugin/src/heartbeat.ts,它只需要一个自己的定时器,不依赖任何其他插件提供的能力,因此不需要 inject,只需要 ctx.effect。

// 文件路径:scratch-plugin/src/my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'my-tool-plugin'

// 声明依赖:需要 tools 服务
export const inject = ['tools']

export function apply(ctx: Context) {
  // 走到这里时,ctx.tools 一定已就绪
  ctx.tools.register(/* ... */)
}

对比来看,my-tool-plugin.ts 里多了一行 export const inject = ['tools'],这一行是它与框架之间的契约:框架读到这个字段后,会推迟插件的 apply 执行,直到 tools 服务确认就绪为止。于是插件函数体内可以放心地直接调用 ctx.tools.register,不需要写任何判空、重试或 ready 检查。heartbeat.ts 则没有 inject,因为它用到的 setInterval 是运行时原生 API,不是其他插件提供的能力,框架没有可等待的依赖,apply 可以立即执行;它唯一需要向框架交代的是“这个 timer 怎么销毁”,由 ctx.effect 承担。

这个差异可以概括成一句话:inject 解决的是“等别人”(我要用的服务何时可用),ctx.effect 解决的是“管自己”(我创建的资源何时销毁)。两者正交,可以任意组合。一个既依赖 tools 服务、又开了定时轮询的插件,会同时写 inject 和 ctx.effect,两者互不冲突。

维度my-tool-plugin.tsheartbeat.ts
声明字段export const inject = ['tools']无 inject 声明
框架在 apply 前等待什么等待 tools 服务就绪无需等待,立即执行
创建的资源类型工具注册项(框架代管)定时器句柄(自定义资源)
清理手段ctx.tools.register 的逆操作由框架自动完成ctx.effect 返回 disposer 执行 clearInterval
是否需要手写清理代码不需要需要写 disposer,但不需要写卸载事件订阅
典型失败模式忘写 inject 导致 ctx.tools 为 undefined忘写 return 导致 timer 泄漏

值得注意的是,heartbeat.ts 里虽然要手写一行 disposer,但它的位置与创建逻辑紧邻,形成“创建—销毁”成对出现的可读结构;而 my-tool-plugin.ts 的清理根本不出现,因为注册动作本身就是框架可逆的记账。这两种形态共同构成了插件清理责任的完整图景。

服务查找路径:为什么其他插件不直接导入具体实现

再往深一层追问:既然 A 插件需要 tools 能力,为什么不直接 import { tools } from './some-impl',而非要绕一圈注入 inject: ['tools'] 然后从 ctx 上取?这个问题的答案,就是服务模型存在的全部理由。

Harness 把服务定义成“挂载在 ctx 上的命名能力”,它占据一个稳定的 key,比如 ctx.tools、ctx.llm、ctx.agents、ctx.sessions。其他插件通过 key 查找服务,而不是通过 import 拿到一个具体的类或对象。这个设计带来几个直接后果:

  1. 实现可替换。ctx.tools 背后具体是哪个实现、由哪个插件提供,消费方并不关心。只要接口契约稳定,框架可以把 tools 的实现换成另一个版本——比如测试时注入一个 mock 工具运行时、生产时换成带分布式追踪的实现——消费方代码一行不改。如果消费方直接 import 具体实现,这种替换就必须改代码或改构建配置,灵活性大打折扣。
  2. 加载顺序解耦。消费方不需要知道提供方在哪、何时加载。inject 声明是一个声明式契约,框架负责在依赖图上排好加载顺序,消费方只管等着 apply 被调用。传统手写加载顺序、手写 ready 轮询的方案,本质上是把依赖图管理责任推给每个插件作者,规模一大必然出错。
  3. 生命周期对齐。服务是有生命周期的——它属于提供方插件,提供方卸载时服务也随之失效。通过 ctx 查找服务,消费方天然与提供方的生命周期绑定:提供方没了,消费方要么被联动卸载,要么根本加载不起来,不会出现“消费方还活着但依赖的功能已经消失”的幽灵状态。
  4. 命名即接口。服务占据稳定 key,意味着插件间的协作有了明确的边界词汇。插件文档里写“本插件提供 ctx.foo 服务,公开方法 bar/baz”,其他插件就能据此编写消费代码。这种以 key 为中心的协作方式,比跨包 import 一个实现类更粗粒度、更稳定,也更适合插件生态这种由多方独立开发的场景。

素材特别强调了一点,应当作为工程纪律记住:内置服务的服务名、公开方法和源码位置,以仓库自动生成到各服务子系统页面的信息为准,不要依赖任何手写的静态服务清单。手写清单会过时,而生成的页面和 TypeScript 接口才是与当前代码同步的权威来源。这与后文要讲的 2026 年 9 月实践直接呼应。

排查清单:apply 里拿不到 ctx.tools 时该检查哪几项

依赖没满足时的症状往往很朴素:apply 跑起来,第一行访问 ctx.tools 就报 undefined,或者报了某个看不清来源的类型错误。这类问题在真实工程里出现频率不低,按下面的清单逐项排查,基本可以定位到根因。

  1. inject 声明是否写全。最常见的错误就是忘了写 export const inject = ['tools']。框架只看这个字段来决定是否等待服务,你没声明,它就不会等,apply 就可能在 tools 尚未就绪时被执行。注意是导出(export)到模块顶层的常量,不是写在 apply 内部的局部变量,写在函数里框架读不到。
  2. 服务名(key)是否拼写一致。inject 数组里写的是服务 key 的字符串,必须与实际注册的服务名完全一致。写成 'tool''Tools''toolRuntime' 都不会被识别。大小写敏感是排查时最容易忽略的一点,因为 TypeScript 在字符串字面量上不会替你纠正。
  3. 提供该服务的插件是否真的被加载了。inject 只是声明“我依赖它”,前提是这个服务确实有人提供。如果依赖链上游的提供方插件没被注册进 Harness、或者因为自身依赖不满足而被跳过,那么消费方的等待就永远等不到结果。检查一下插件清单里提供方在不在、提供方自己的 inject 是否满足。
  4. 服务是否处于就绪状态而非加载中。框架承诺的是“服务就绪后才执行 apply”。如果提供方插件正在异步初始化服务(例如正在连接远端),apply 会被推迟。这种情况通常表现为“延迟发生”而不是“undefined”,但如果提供方的初始化抛错,服务可能永远不进入就绪态,消费方一直挂起。查看加载日志里提供方的状态,确认它有没有卡在初始化。
  5. 是否在错误的作用域里访问了 ctx。ctx.tools 的可用性绑定在插件的 Fiber 作用域上。如果你把 ctx 传给了外部模块,在插件卸载后、或者在没有注入关系的上下文中访问它,得到的可能是已失效的作用域。正确做法是在 apply 作用域内完成注册,不要长期持有一个跨作用域的 ctx 引用。
  6. 类型层面是否引入了正确的 Context 类型。示例里用的是 import type { Context } from '@deepseek-ai/cordis'。如果类型来源不对,编译器可能在你确实写错 inject 时也不报错,失去本可以救你一命的静态检查。确保类型定义与实际运行时框架版本匹配。

把这份清单浓缩成一句诊断口诀:先看声明在不在,再看名字对不对,再看提供方活没活,最后看就绪没就绪。按这个顺序查,绝大多数“ctx.tools 拿不到”的问题都跑不出这几类。

2026 年 9 月最新实践:以生成的服务接口与 inject 声明为准的插件开发方式

时间拨到 2026 年 9 月,这套机制在实践中沉淀出了一条清晰的推荐路径,核心可以概括为“以生成的权威接口为唯一事实来源,用 inject 声明消费侧依赖,用 ctx.effect 申报自定义资源”。

所谓“生成的权威接口”,指的是各服务子系统页面由仓库自动生成,涵盖服务名、公开方法以及源码位置。素材明确指出,内置服务的服务名、公开方法和源码位置都由仓库自动生成到各服务的子系统页面,开发插件时应以这些生成区块和服务的 TypeScript 接口为准。这条纪律的重要性在于:插件生态会演进,服务会增删方法,手写一份“我知道的服务清单”几乎必然在某个版本后变成错误信息。而生成页面跟代码同源,接口定义由类型系统约束,两者一起构成不会说谎的依赖事实。

落到日常开发动作上,推荐的姿势是这样的:

  • 要写消费代码时,先打开对应服务的生成页面与 TypeScript 接口,确认服务 key 的准确拼写、公开方法的签名、参数与返回值类型,而不是凭记忆或百度来的旧文档开写。
  • 在插件顶层导出 inject 数组,把用到的服务 key 全部列进去。多个依赖就写多个 key。这一行是消费方与框架之间的全部契约,写对它,框架负责其余一切。
  • 在 apply 内直接使用 ctx.<key>,不做判空、不做延迟轮询、不做手写 ready 检查,因为框架已经保证了就绪。如果确实拿到 undefined,回到上一节的排查清单,而不是在代码里加防御式兜底掩盖问题。
  • 凡是通过 ctx 的注册(on、tools.register、llm.registerAdapter)一律不用写清理,交给框架逆序撤销。
  • 凡是 ctx 追踪范围之外的自建资源(定时器、连接、句柄),一律用 ctx.effect 包裹并同步返回 disposer,让框架接管销毁时机。养成“创建即申报”的习惯,代码里就不会出现孤立的手动清理逻辑。
  • 升级框架时优先看生成页面的 diff,服务接口的变化会反映在这里,据此调整 inject 与调用处,比对着 changelog 猜要可靠得多。

这套实践的价值,不只是让单个插件的代码更短,而是让整个插件生态具备可组合性:任意两个插件之间只通过稳定 key 和声明式契约耦合,加载顺序由框架排序,生命周期由框架对齐,清理由框架核销。插件作者于是可以把注意力集中在业务逻辑本身,而不是分散在依赖编排和资源善后的细节里。这也正是标题那句话的完整含义——之所以可以不写清理代码,是因为清理责任被结构性地转移给了 ctx,而不是因为大家偷懒省略了它。

总结与最佳实践

把全文要点压缩成一份可以直接贴在工位上的清单:

  • 依赖别人,用 inject 声明:插件顶层 export const inject = ['tools'],框架保证 apply 执行时服务已就绪;服务 key 必须与实际服务名逐字符一致。
  • 管好自己,用 ctx.effect:回调内创建资源,同步返回 disposer;setInterval 配 clearInterval、连接配 close、订阅配 unsubscribe。
  • disposer 是返回值,不是普通回调:它只在插件卸载时被框架调用,与创建动作共享闭包,天然能捕获句柄。
  • 框架可逆的注册不用手写清理:ctx.on、ctx.tools.register、ctx.llm.registerAdapter 卸载时自动撤销,按注册逆序执行。
  • 服务通过稳定 key 查找,不 import 具体实现:换取实现可替换、加载顺序解耦、生命周期对齐、命名即接口四重收益。
  • 拿不到 ctx.tools 的排查顺序:声明有没有 → key 对不对 → 提供方活没活 → 就绪没就绪 → 作用域对不对 → 类型来源对不对。
  • 2026 年 9 月的权威来源是生成页面与 TypeScript 接口,不要依赖手写静态服务清单,升级时优先看生成区块 diff。
  • 一条纪律收尾:创建任何需要关闭的句柄,要么走 ctx 的口子,要么用 ctx.effect 申报;两者之外的自建资源,就是泄漏的候选。