ELL3N HSU

Available in Chinese only.

← 文章

DeepSeek Harness 与 Cordis:热插拔、服务共享和并发问答

工具可以随时换,正在使用工具的插件怎么办?从一个视觉服务的例子,讲清 Cordis 的依赖、清理、共享和并发,以及 Python、Go 能否实现同样的机制。

假设你有一个一直在线的 Agent,能看图片、查数据库,也能调用外部工具。现在想换掉它的视觉模型。

改一个配置,再创建一个新对象,看起来就完成了。但旧模型还有一个请求没返回,两个插件还在用它,它注册的定时器也没停。新模型接进来了,这些旧东西怎么办?

如果每个插件都要自己处理,你很快就会在各处写上“服务在不在”“要不要重连”“这个监听器是谁注册的”。能加载一个插件,和能把它完整地卸下来,是两件难度不同的事。

Cordis 处理的就是这类问题。下面沿着三个问题展开:它在 DeepSeek Harness 里负责什么,热插拔和共享怎么实现,以及换成 Python、Go 后,这套机制还能不能成立。视觉服务只是贯穿全文的教学例子,代码接口以 2026-11-06 核对的 DSH 文档为准。

问一:DeepSeek / TypeScript 技术相关的 Cordis 是什么?

**答:**可以先把 Cordis 理解成一个管理插件关系和生命周期的小型运行时。

一个插件提供视觉能力,另一个插件需要视觉能力。Cordis 要知道谁提供了服务、谁依赖它,以及它退出时应该清理什么。插件作者把这些关系交代清楚,框架就能在服务出现、消失或替换时,安排相关插件启动和停止。

这也是它在 DeepSeek Harness(DSH)里的位置。模型适配器、工具、文件访问、会话相关能力,甚至 Agent 的主循环,都可以作为插件组合起来。DSH 用这套组合方式组织 Agent,Cordis 负责下面的依赖和生命周期。官方教程从这个角度介绍了两者的关系。

Cordis 由 Koishi 作者 Shigma 开发。介绍它时,经常会看到“元框架”和“时空可组合性”这两个词。名字有些抽象,但回到开头那个换模型的例子,就容易理解了。

一、核心理论:什么是“时空可组合性”?

很多后端应用是在启动时把组件装好的:读取配置,创建数据库连接,初始化模型客户端,再把这些对象传给业务模块。改了配置,重启一次应用,重新装一遍。

如果组件很少、重启成本也不高,这样做完全够用。Express、NestJS 之类的框架也可以配合自定义机制实现动态变化。问题在于,当你希望应用长期运行,还经常更换其中一部分能力时,启动时装一次的思路就不够了。

拿视觉服务来说,要解决两件事。

第一件事:有哪些插件现在能工作?

图片摘要插件需要视觉服务。服务没加载时,它就先等着;服务准备好了,它再启动;服务被撤掉了,它也要跟着停下来。你不应该在每个调用点都手动检查一遍,更不应该让它拿着已经失效的旧服务继续工作。

这叫空间可组合性(Spatial Composability)。这里的“空间”,可以理解成同一时刻组件之间的关系:谁提供能力,谁需要能力,当前条件能不能满足。相关论文把根据环境变化调整依赖组件的机制称为反应式协效应(Reactive Coeffects)。

第二件事:一个插件退出后,怎么处理它留下的东西?

图片摘要插件启动时,可能注册了一个事件监听器,还开了一个定时任务。现在把插件卸载了,监听器和定时任务也应该一起撤销。否则,新插件已经接班,旧插件还在后台干活。

这叫时间可组合性(Temporal Composability)。它关注的是组件从启动到退出的过程。插件做出一项受管理的修改时,同时交代以后怎样撤销,运行时负责保管并执行这份清理逻辑。这就是可逆副作用(Revertible Effects)的思路。

要解决的问题在视觉服务例子里意味着什么
空间可组合性视觉服务是否可用,决定依赖它的插件能否运行。
时间可组合性插件退出时,撤销它注册的监听器、定时任务等资源。

两者需要一起工作。只有依赖检查,插件停了以后可能还残留定时器;只有清理能力,框架又不知道服务消失后应该停掉谁。

《A Programming Paradigm for Spatiotemporal Composability》讨论的就是这两种机制如何组合。作者为 Yifan Shi、Wei Zhang、Tianyi Cui,论文仓库将其标为仍在修订的预印本。读工程实现时,可以把论文当作解释这套设计的理论背景,再对照具体 API 看它管理了哪些操作。

这里有个很实际的边界:清理函数能撤销的,是你为它安排了撤销方法的操作。 移除监听器、停止定时器、关闭连接,都有明确的做法。已经发出的邮件、已经提交的数据库写入、已经产生的模型调用费用,不会随着插件卸载而消失。涉及这些操作时,需要业务自己设计补偿、幂等或恢复流程。

二、Cordis 的核心技术实体

理解了上面两件事,再看 Context、Service、Fiber 和 Disposer,就知道它们各自要干什么了。

Context:插件从这里找到所需的能力。

插件运行时会拿到一个 ctx。要调用视觉服务,就通过这个上下文访问;要注册事件或挂载子插件,也从这里进入。上下文之间可以有父子关系,这让不同插件既能共享部分服务,也能有自己的作用域。

所以 Context 可以理解成插件所处的工作环境。它提供服务入口,也把当前操作和插件的生命周期联系起来。至于聊天记录具体存在哪里、数据库怎样做事务,那是相关服务的职责。

Service:一个插件对其他插件提供的能力。

视觉服务可以有 analyze(image) 方法,数据库服务可以有查询方法,LLM 服务可以提供模型调用接口。使用者按服务名访问,不必直接导入某个具体实现。之后更换提供方,只要接口兼容,使用者通常不需要跟着改代码。

这里容易混淆的是 TypeScript 的声明合并。你给 Context 接口增加一个 vision 属性,只是告诉类型检查器这个属性应该是什么类型。真正运行时,还需要创建服务实例并注册它。类型声明和运行时注册,各管一件事,后面会用代码展开。

Fiber:一次插件加载对应的运行时句柄。

调用 ctx.plugin() 后,会得到一个 Fiber。通过它,运行时可以记录这个插件实例的状态和清理工作,也可以卸载它。它管理的是一次插件实例的生命周期,不是一个操作系统线程;叫它“纤程”也不意味着每加载一个插件就多了一条执行线程。

同样,Fiber 不提供安全沙箱。插件如果绕过管理 API 操作全局变量、启动子进程或保留外部引用,不能指望这个句柄自动把一切收拾干净。

Disposer:资源退出时要执行的清理函数。

比如注册了事件,就需要移除事件监听;开了定时器,就需要清掉定时器。框架可以替你记住什么时候执行,但具体怎么清理,总得有代码来完成。

ctx.on() 这样的内置注册已经接入生命周期。框架不了解的外部资源,要通过 ctx.effect() 登记清理逻辑。计时器还要看所用版本及 timer 插件提供的 API,不能因为某个例子用了 ctx.setTimeout(),就认为任何裸 Context 都自带这个方法。

放在一起看,四个概念的分工是这样的:

概念负责什么
Context按当前作用域解析服务,并提供插件操作的入口。
Service对外提供可以调用的能力和接口。
Fiber跟踪一个插件实例的状态、依赖和生命周期。
Disposer执行一项具体的释放或撤销操作。

详细接口可以对照官方服务教程、生命周期教程和 Context API。先记住分工,再看接口名称,会比一开始背定义容易得多。

三、为什么 DeepSeek Harness 选择 Cordis?

从 DSH 的结构来看,Cordis 适合的正是“一切皆插件”这种组织方式。模型、工具、存储和 Agent Loop 都能单独组合,那么它们的依赖和退出过程也需要有人统一管理。

这能解释架构上的适配性。至于团队当时比较过哪些方案、内部如何作出选择,这里没有相应的选型记录。

对一个长期运行的 Agent,这套机制有三个直接用途。

首先,更换能力时,不用让每个使用者自己猜发生了什么。 比如切换视觉模型,依赖这个服务的插件会随依赖变化重新加载。添加新工具、切换存储实现,也可以沿着类似的方式组织。代码文件变化需要 HMR 插件监测,配置变化需要 Loader 协调,生命周期机制为它们提供了卸载和重新加载的基础。

其次,反复加载和卸载时,资源清理有统一的归属。 一个监听器是谁注册的,一个定时器应该跟谁一起结束,都能挂到具体插件上。这样可以减少热重载后重复监听、重复执行和资源残留的问题。效果取决于插件是否把资源交给框架管理,以及清理逻辑是否正确,不能据此承诺任意插件都零泄漏。

还有,TypeScript 能把接口错误提前暴露出来。 服务方法叫什么,参数是什么,返回值该怎样处理,IDE 和类型检查器可以帮忙检查。运行时到底有没有加载这个服务,则由注册与 inject 的配合来判断。两层检查互相补充,不能拿编译通过代替运行时依赖就绪。

再回到开头那个没返回的视觉请求。进程还活着,新插件也加载了,这个旧请求就一定能顺利完成吗?

这要看应用怎么安排。有的请求可以取消,有的需要等它完成再卸载,有的外部操作已经发生,只能记录结果并防止重试造成重复执行。插件自己的临时状态是否保留、Session 能否恢复,也要另行设计。热替换解决了组件切换的一部分工作,进行中的业务还需要明确的交接方式。

四、用一句话概括 Cordis

Cordis 把插件需要什么、提供什么、退出时清理什么放进同一套运行时约定,让组件可以随着环境变化重新组合。

把它比作乐高积木也可以:接口要接得上,拆的时候还得处理连接关系。这个类比能帮助理解组合,但不能替代接口兼容、资源清理和状态迁移这些具体工作。接下来就看这套约定怎样落实到内存里的对象和代码上。

问二:热插拔和数据共享,是不是内存中的“服务索引表”?

**问:**Cordis 是如何实现热插拔的呢?怎么做到数据统一和共享的呢?我理解的是 Cordis 其实就是将所有可用的资源的名字记录在运行内存中的某个地方,要用了就先去查这个记录了资源名字的容器有没有名字,有的话再顺着名字去仓库领取工具,相当于一个人体中枢神经,只记录一个索引,需要哪个再去 retrieve。对吗?

答:“按名字找到能力”这部分,可以这样理解。比如插件需要视觉能力,通过 ctx.vision 就能访问当前作用域中的视觉服务。

但如果只记录名字,还解决不了热插拔。

假设插件 A 已经拿到了旧视觉服务,现在你把表里的 vision 换成了新服务。A 手上那个旧引用不会因为改了表就自动消失,A 注册的定时任务也不会自己停下来。要让替换有序进行,还得知道 A 依赖谁、什么时候让它停止、停止后要清理哪些资源。

所以,可以把这套运行时想成“服务索引加依赖关系加清理记录”。这是理解机制的模型,并不是说源码里恰好只有三张表。

这里的 retrieve 也只是解析已经注册的能力。下载插件代码、从磁盘加载模块、按关键词搜索工具,都是另外的步骤。访问一次 ctx.vision,通常不需要去 Git 仓库领取代码。

一、怎么实现热插拔?不只是查表,而是发布订阅的级联网络

先看插件怎样交代依赖。

假设一个插件必须同时使用数据库和模型,它可以声明 inject = ['database', 'llm']。运行时会等这两项服务都准备好,再启动它。没有硬性依赖的插件不需要为了格式完整而写 inject;允许缺失的可选能力,可以在使用处通过 ctx.get() 等接口查询并处理缺失情况。

为了把这件事讲完整,下面只用一个 vision 服务。先写提供方,再写使用者。

// vision.ts:服务提供方
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    vision: VisionService
  }
}

export class VisionService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'vision')
  }

  async analyze(image: string): Promise<string> {
    return `demo result for ${image}`
  }
}

export function apply(ctx: Context) {
  ctx.plugin(VisionService)
}

这段代码里,声明合并把 vision 的类型加进 Context 接口。运行时加载这个插件后,ctx.plugin(VisionService) 挂载服务,构造函数里的 super(ctx, 'vision') 按名称注册实例。

想同时获得运行时能力和类型提示,两步都要做。只写声明,编辑器知道类型,运行时却没有对象;只注册对象而不提供对应声明,运行时可能有这个能力,类型检查器却不知道怎么使用它。这里的 analyze() 只返回教学占位值,没有连接真实模型。

使用者可以这样写:

// consumer.ts:服务消费方
import type { Context } from '@deepseek-ai/cordis'
import type {} from './vision.js'

export const inject = ['vision']

export async function apply(ctx: Context) {
  const result = await ctx.vision.analyze('sample.png')
  console.log(result)
}

inject 交代的是启动条件:我需要 vision。条件满足后,框架调用 apply,插件才开始工作。import type 让类型声明进入编译上下文,不负责创建服务;运行时仍要加载提供方和消费方。示例中的 .js 导入路径采用 TypeScript 输出路径约定,需要与项目实际使用的构建或 TS loader 配置一致。

官方教程还支持函数插件、带 apply 的对象,以及 Service 子类等形态。普通类里写一个实例方法 apply(ctx),并不自动等同于这些插件入口,照写时要先选清楚插件形态。

接入一个服务时,会发生什么?

视觉提供方加载并准备好之后,依赖它的消费方就具备启动条件。原来等待的图片摘要插件开始执行 apply,通过上下文调用视觉服务。声明依赖以后,应用不必仅靠配置文件中的排列顺序来碰运气。

撤掉这个服务时,又会发生什么?

框架发现依赖不再满足,会卸载依赖插件,执行它登记的清理工作。新的视觉提供方就绪后,消费方再重新加载,使用新的实现。要是消费方还对外提供了其他服务,这个变化又可能影响到它的下游。

这就是级联关系。可以把它想成发布订阅:环境发生变化,相关组件随之响应。但“有依赖图”这个概念,并不说明每次变化一定遍历所有插件;具体存储结构和算法复杂度需要继续看对应版本的实现。

热模块替换(Hot Module Replacement,HMR)建立在同样的过程上。官方 HMR 教程描述的是:卸载旧实例、撤销 effect、加载新代码,再执行新的 apply。文件变化由 HMR 插件监测,配置变化由 Loader 协调。

这也解释了为什么“把旧对象换成新对象”还不够。旧实例注册的监听器需要撤销,受影响的消费方需要重新启动;进行中的请求、保存在外部的旧引用和没有接入生命周期的任务,还要由应用妥善处理。

二、怎么做到数据统一和共享?Context 树与原型链

假设 Agent A 和 Agent B 都需要数据库,但它们使用的视觉模型不同。

你可能希望数据库连接池共享一份,视觉服务各有一份。这个需求包含两种关系:有些能力要共用,有些能力要隔开。

Context 的父子关系能帮助表达这些关系。子上下文可以继承父级环境,也可以带自己的元数据。Cordis 的 extend() 确实用到了 JavaScript 原型链,但服务解析还经过 Proxy 和作用域机制;需要独立服务实例时,要使用相应的隔离 API 或配置。

读到同一份对象,意味着什么?

先看普通 JavaScript。通过 Object.create(root) 建立子对象后,子对象上没有的属性可以沿原型链向上查找。

如果父对象上有一个配置对象,子对象读到它时,得到的仍然是那一份对象的引用。这个共享很方便,也容易让人误以为修改时会自动隔离。

const root = { config: { model: 'shared-model' } }
const child = Object.create(root)

child.config.model = 'changed-model'
console.log(root.config.model) // changed-model:改的是共享对象

child.config = { model: 'local-model' }
console.log(root.config.model) // changed-model:这次才创建了子级自己的属性

前一次修改,先通过 child.config 找到了父级的配置对象,再修改它的 model。对象只有一份,父级当然也看得见。

后一次修改,是直接给 child 新增一个自己的 config 属性。以后读取 child.config,先找到子级这一份,就不会继续向上找了。这叫属性遮蔽(Shadowing)。

两次操作只差一个层次,结果却不同。遮蔽不会自动复制共享对象的内部数据。 所以,不能把原型链理解成一套天然的深层数据隔离,更不能认为子 Agent 随便修改配置都不会影响其他人。

Cordis 怎样区分“共享这一份”和“各用各的”?

在 Cordis 里,服务名还要结合隔离作用域解析。Context 源码中的 isolate(name) 会为指定服务建立独立作用域;Loader 的插件组也可以通过 isolate 配置,把同名服务隔开。

可以按下面的关系组织前面的两个 Agent。这是设计示意,不是说每个 DSH Session 都自动按这张表创建上下文。

范围数据库视觉服务
共享环境提供共用的数据库服务可以提供一个默认实例。
Agent A 所在的插件组使用共享数据库在独立作用域提供 A 的视觉服务。
Agent B 所在的插件组使用共享数据库在另一个作用域提供 B 的视觉服务。

同样写 ctx.vision,A 和 B 可以拿到不同的底层服务实例。服务使用者依然按名字调用,选择哪一个实现由上下文作用域决定。官方服务隔离示例用两个配置不同的 shell 提供方展示了这种做法。

因此,给普通子对象赋值的直觉可以帮助理解继承,但不能直接替代 Cordis 的服务注册与隔离机制。服务属性有框架自己的解析规则,不是任意往 ctx 上赋值就能得到想要的生命周期行为。

共享一个服务,数据就自动统一了吗?

共享的是服务实例以及它提供的访问方式。

假设两个插件都拿到同一个数据库服务,它们可以共用连接池。但两个请求是不是在同一个事务里、更新会不会互相覆盖,仍然取决于数据库服务和业务代码。扩展到多个进程,每个进程的内存也各自独立,跨进程的数据同步更不能靠一个 Context 解决。

服务内部的状态变化同样需要分清楚。vision 服务被卸载,属于依赖环境变化,Cordis 会调整相关插件的生命周期。vision 对象内部某个普通字段变了,则不代表所有依赖插件都会自动收到通知。

比如服务更新了一个计数器,另一个插件下次读取共享对象时,可能看见新值。但要让它在变化发生时主动收到通知,需要事件、订阅接口或服务自己实现的观察机制。读取同一份状态,和收到状态变化通知,是两种不同的交互。

三、用“神经模型”再进化一下

现在再用人体中枢神经的类比,可以把过程讲得更具体一些。

插件 A 相当于一个需要视觉信号才能工作的器官。它接入系统时交代依赖,运行时记录这层关系。视觉服务尚未准备好,A 就先不启动;提供方接入后,A 开始工作。提供方退出,A 随之停止,自己登记的监听器和其他资源一起清理。等视觉服务重新可用,再启动 A。

这个类比最有用的地方,是把“需要什么”和“现在能不能工作”连在了一起。所谓休眠,表示依赖没有满足,插件没有执行,不是后台多了一个阻塞线程。所谓逆向清理,表示调用登记过的释放函数,也不是把外部世界发生的事情全部倒带。

Root Context 连接着服务解析、插件注册表、事件和生命周期机制。它比一份名字索引多做了这些协调工作,但不会因此自动承担数据库一致性、业务调度或高可用恢复的全部责任。

问三:其他语言能实现吗?索引在哪里?用完要归还吗?并发如何处理?

**问:**意思是能实现 Cordis 这样的效果完全是借助了 JavaScript 语言的原型链设计,这在其他编程语言中很难找到对应的是吗?比如 Golang、Python 要实现起来这样的效果难度是否会增加?

我再进一步问下,中枢性记录的这个“服务索引表”是存在内存中的吗?当一个对话开启、session 激活后,中枢神经就在 on hold 是吗?当插件 A 要工作去向中枢登记要视觉信号,使用完了需要再跟中枢神经说我用完了吗?假设同时有两个插件同时需要视觉信号,中枢神经是按请求顺序提供视觉信号,还是再 spawn 进程来同时支持呢?

**答:**可以把这几个问题拆成三层:语言怎么表达这套机制,运行时保存什么,以及真正调用服务时谁来安排执行。把它们分开,很多看起来神奇的地方就能对应到熟悉的代码了。

一、跨语言对比:非 JS 语言实现 Cordis 难度会增加吗?

先看要实现的功能:按名称或类型找到服务,沿父级环境查找,记录依赖,在依赖变化时启停插件,最后清理资源。这些机制可以在 Python 和 Go 中实现。

JavaScript 的原型链和 Proxy 让动态属性访问比较自然,TypeScript 的声明合并又补上了开放扩展的类型提示。它们很适合做出 ctx.vision.analyze() 这样的使用体验。

但依赖管理并不要求底层一定有原型链。你也可以在容器里放一个父指针:当前层找不到,就去父容器找。实现时多写一些代码,不代表这种组合方式在其他语言里就不成立。

更值得区分的是三种目标:做出一样的属性访问语法,做出一样的依赖生命周期行为,以及运行中加载从未编译过的新代码。它们的难度并不相同。

Go:接口替换和加载新代码,是两件事

Go 没有 JavaScript 那样的动态原型链,但可以用显式容器和接口表达服务关系。

比如定义一个视觉服务接口,容器保存当前实现,使用者依赖这个接口。服务查找可以使用类型化的方法,也可以直接使用显式字段。不一定要把所有东西塞进 map[string]any,再在每次调用时写类型断言。

标准库 context.Context 主要用来传递取消、截止时间和请求范围的值。虽然也能放值,但用它承载所有应用服务,会把不同职责混在一起。实现一个有明确接口的服务容器,往往更容易看清依赖。

再看“热插拔”。如果两个视觉服务实现都已经编译进程序,运行时切换使用哪一个,是对象和生命周期管理问题。如果要把刚写好的全新机器码加载进一个已经运行的 Go 进程,则要面对另一组限制。

Go 的原生 plugin 机制能加载动态插件,但官方文档说明插件不能关闭,还存在平台支持、工具链版本和依赖构建一致性等限制。因此,要完整复刻动态脚本语言的代码热重载体验,确实需要额外考虑。

另一条路是把插件放在单独进程里,通过 RPC 调用。更换时启动新进程、切换连接,再让旧进程退出。这样代码加载的边界更明确,代价是要维护通信协议、序列化和进程生命周期。

至于关闭连接、停止 goroutine、撤销注册,本来就需要显式的 Close、取消函数和退出协调。这些工作不需要“对抗 GC”。GC 回收的是不可达的内存对象,资源该在什么时候结束,还得由程序表达出来;JavaScript 和 Python 也一样。

Python:动态访问不难,旧引用和退出过程仍要管

Python 可以通过 __getattr__ 实现当前对象找不到属性时向父容器查找,也可以干脆使用显式的 get_service()。动态导入模块也有现成机制。

类型提示需要另作选择。如果允许任意第三方插件不断给一个核心对象追加属性,静态分析确实不容易完整掌握。可以用 Protocol、显式属性和类型化容器接口约束服务契约,不一定照搬 TypeScript 的声明合并方式。

清理资源则可以用上下文管理器、ExitStack 或 AsyncExitStack 组织。真正还要补的是依赖变化的处理:服务消失时停谁,怎么清理,恢复以后怎么重新启动。这部分是运行时的工作,一个 __getattr__ 不会替你完成。

同样,importlib.reload()会重新执行模块代码,但外部保存的旧引用不会全部自动更新,已有实例也不会自动变成新类的实例。所以,重新导入和完整热替换之间,仍然隔着依赖管理与生命周期。

层次JavaScript / TypeScriptPythonGo
服务查找与父级回退Proxy、原型链或显式查表__getattr__ 或显式查表显式容器、父指针、类型化接口
依赖变化与生命周期Cordis 提供相应运行时可以实现状态机与订阅机制可以实现状态机与订阅机制
资源清理disposer、取消信号、服务关闭方法上下文管理器、显式关闭、任务取消Close、取消函数、goroutine 协调
加载全新代码需要模块加载器和 HMR 协调动态导入可用,旧引用仍需处理原生 plugin 有限制,也可选择 RPC 子进程
静态类型体验声明合并适合开放扩展可用显式类型契约接口与类型化容器,不必全部使用 any

这样比较,就不用给某一种语言笼统打一个“容易”或“极难”的分数。先确定要复制哪一层能力,才知道额外成本在哪里。

二、中枢神经的内存状态与生命周期

服务索引存在哪里?

运行时的服务注册关系和对象引用保存在进程内存里。可以把它们理解成一组对象、映射以及插件之间的关联信息。

但应用还有别的东西需要保存。插件配置可以来自磁盘,聊天记录可以写入数据库,模型服务也可能访问外部系统。内存里的服务索引和业务数据的持久化,不是同一个问题。

进程重启以后,旧对象引用已经没有了,需要根据配置重建运行时。持久化的会话能否继续使用,要看相应服务怎样恢复状态。也不需要拿未经测量的“微秒级响应”来解释为什么使用内存:一个正在运行的程序,首先就需要在内存中持有它要调用的对象。

Session 激活后,是不是有个中枢一直 on hold?

Root Context 是一个运行时对象,不是一条独立线程。应用持有它,代码执行时访问它,相关事件发生时运行对应处理逻辑。

所以,不需要想象一个专门的中枢进程不断轮询:“有人要视觉服务吗?”对异步 I/O,运行时可以在等待结果时处理其他工作;等待中的 Promise 或插件状态,也不等于一直占着 CPU 空转。

Session 和 Context 的关系则由应用决定。你可以让一个会话拥有独立插件作用域,也可以让多个会话共用服务,再用 Session ID 区分业务数据。Cordis 本身没有规定每开启一次聊天,就一定创建一个子 Context。

官方服务教程里还有一个有助于理解的细节:只处于 PENDING 状态的 Fiber,不会单独让 Node 的事件循环保持活跃。如果应用里没有其他活跃工作,进程仍然可以退出。这个“等服务”的状态,本身不是一个必须占用线程等待的任务。

用完服务,要不要说“我用完了”?

要看你拿到的是什么。

如果只是调用 ctx.vision.analyze(image),通常不需要向 Context 归还视觉服务。调用方法、等待结果、继续处理即可。结果对象也不会在函数结束的那一刻必然回收;只要还有引用,相关对象就可能继续存在。

如果调用的 API 借给你一个连接、文件句柄、流、锁或摄像头租约,就要按它的约定释放。接口要求 acquire / release 或 open / close,使用者仍然要负责配对。依赖注入解决了如何得到服务,不会把所有资源都变成无需归还的普通对象。

还有一种情况:资源跟插件一样长寿。比如插件运行期间一直监听某个事件,卸载插件时才需要停止监听。这就适合交给插件生命周期管理。

下面把一个原生定时器交给 Cordis 管理:

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

export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => console.log('heartbeat'), 1000)
    return () => clearInterval(timer)
  })
}

加载时,effect 创建定时器。它返回的函数说明怎样清理;卸载时,Cordis 调用这个函数,定时器结束。

如果只写原生 setInterval(),不把清理逻辑交给框架,运行时不会凭空知道这个定时器属于哪个插件。ctx.on()、子插件挂载等框架管理的操作,已经替你接好了相应的生命周期;外部资源则需要你接上。

资源也不必都等到插件卸载时才释放。如果一个文件只在本次请求里使用,就应该在本次请求结束时关闭。让长期运行的插件替每个短请求一直保管资源,会把释放时间拖得过晚。需要提前结束的注册,也可以按对应 API 调用 disposer。

Session 销毁是否会触发清理,同样取决于应用有没有把插件生命周期绑定到这个 Session。会话结束和插件卸载是两个事件,只有应用建立了关系,它们才会一起发生。

最后还有清理顺序。官方生命周期教程说明,fiber.dispose() 会等待清理工作并递归卸载子插件;disposer 按登记的逆序启动,但多个异步 disposer 可以并发运行。

假设必须先停止消费任务,再关闭任务正在使用的数据库连接,就把这两步放进同一个异步 disposer,依次 await。不能只看登记顺序,就假定前一个异步清理已经全部结束。

三、高并发下的视觉信号:排队还是 spawn 进程?

两个插件都调用视觉服务,并不会让 Cordis 自动为它们创建两个进程,也不会自动安排一个按顺序执行的队列。它负责让插件找到合适的服务,服务内部怎样执行,是下一层的问题。

如果两个插件处在同一服务作用域里,它们通常使用同一个底层服务实例。若通过 isolate 隔开了,则可以各用一份。框架还可能包装服务访问,因此不宜拿一次对象引用相等检查,替代对服务作用域的理解。

先假设这个视觉服务调用的是一个支持异步请求的远端模型 API。

插件 A 发出请求以后,需要等网络和远端模型返回结果。在这段时间里,主 JS 线程可以继续处理其他工作,包括插件 B 发来的请求。于是,两个请求都在等待结果,先返回的结果先具备后续处理的条件。

这叫并发等待。应用不必为每个请求额外启动一个进程,就能让多个网络请求处于进行中。但并不意味着同一条 JS 线程同时执行两段 CPU 计算,也不意味着整台 CPU 在等网络时必然闲着。

Node 默认在一个主 JS 线程上执行回调,整个运行时还包含线程池,也支持 worker threads 和子进程。网络 I/O、文件操作、DNS、原生计算,涉及的底层路径不完全相同;libuv 是运行时依赖的库,不是操作系统本身。Bun 的内部实现也不能直接照搬这套描述。

如果视觉服务调用本地 GPU,是否阻塞还取决于所用 API 和绑定。把函数标成 async,不能把其中的同步计算自动搬到其他线程或 GPU 上。

调用方怎样影响并发?

下面两种写法,发起第二个请求的时机不同:

// 顺序调用:第二次调用在第一次完成后才发起。
const first = await ctx.vision.analyze('a.png')
const second = await ctx.vision.analyze('b.png')

// 并发发起:允许两次异步操作同时处于等待中。
const [a, b] = await Promise.all([
  ctx.vision.analyze('a.png'),
  ctx.vision.analyze('b.png'),
])

前一种写法先等 A,再发 B。后一种写法先发起两次调用,再等两者的结果。Promise 表示未来完成的状态,后续代码仍然要被运行时调度;它本身不是一条计算线程。

还有一个容易混淆的地方:Promise.all 返回的数组按照传入顺序排列。即使 B 先完成,变量 a 对应的仍是第一项调用,b 对应第二项。完成顺序和结果的排列顺序要分开看。

这些代码只解释调用关系,不是性能测试。真实耗时还取决于服务内部是否限流、远端是否排队,以及每次调用做了多少同步工作。Node 事件循环文档可以帮助进一步区分回调执行和 I/O 等待。

什么情况下需要排队?

换一个例子:vision 服务接的是一个实体摄像头,同一时刻只允许一个使用者占用。那它就需要管理占用关系,让其他调用等待,或者直接返回忙碌状态。

摄像头只是最直观的情况。模型 API 有并发配额、数据库有连接池上限、GPU 显存装不下更多任务,也会出现同样的问题。服务可以在内部排队、限流或拒绝请求,Cordis 不会因为使用了异步调用就取消这些约束。

共享可变状态也需要协调。即便只有一个 JS 主线程,一个请求在 await 处让出执行机会后,另一个请求也可能修改同一份状态。等第一个请求恢复时,它之前读到的条件可能已经不成立。解决这种逻辑竞争,需要事务、版本检查、队列或其他符合业务的同步方式。

回头看最初的问题,职责就清楚了:Context 负责解析视觉服务,依赖关系决定插件能否运行,disposer 负责插件退出后的资源清理,而请求是否排队、在哪里计算,则由视觉服务和执行环境共同决定。

补充问答:怎样验证自己真的理解了热插拔?

**问:**如果不只停在概念层面,最小实验应该观察什么?

**答:**从一个提供方、一个消费方和一个定时器开始。先看插件什么时候启动,再看它什么时候停止,最后确认停止后没有继续发生本该结束的工作。

下面是可以动手执行的实验,表格写的是需要核对的行为,不是已经运行过的结果。

操作需要核对的行为
只加载依赖 vision 的消费方依赖不满足,消费方不执行 apply。
加载提供 vision 的插件依赖就绪后消费方激活;服务的方法调用能获得结果。
卸载提供方依赖消费方被卸载,其已登记监听器与定时器被清理。
换一个提供方重新加载消费方重新激活,调用新实现;确认旧监听器没有重复存留。
故意创建一个未登记清理的原生定时器检查它是否继续运行,以验证自动清理的边界;实验结束后主动清理。
在请求进行时卸载插件分别记录是否取消、是否排空、是否遗留外部效果,不把进程存活当成请求无损。
对两个隔离组配置不同服务实例验证各组解析结果,另外检查共享业务对象是否仍被不小心复用。

做完前四步,能看清依赖和生命周期怎样配合。再做后三步,就能分辨哪些事情框架已经接管,哪些仍然是业务代码的责任。只看“新插件加载成功”这一条日志,会漏掉后一半问题。

动手时可以沿着官方生命周期、服务和 HMR教程继续。

还有一个相邻的问题值得单独看:运行时里已经注册了工具,模型怎么知道这一轮有哪些工具可以调用?这涉及工具定义、发现和模型输入,可以接着读Agent 注册了很多工具,如何告诉模型?。本文讲的是工具及其依赖怎样在程序里运行,那篇接着讲这些能力怎样进入模型的视野。