在 DeepSeek Harness 里,"我装了插件"这句话至少盖住了四件事:配置里有这一行、代码能被解析到、它需要的服务已经就绪、实例真的挂上了。混成一句话讲,排查时就从一个错误现场变成四个。
这篇文章把这四层拆开,每层给出它实际管什么、不管什么,以及一个可以立刻执行的检查动作。
一、为什么插件不是"装上就完了"
DeepSeek Harness 的产品每一部分都是插件——模型适配器、工具注册表、Session 日志、Agent 循环——运行中的 dsh 不是一套写死的组件,而是启动时组合出来的插件树。
这个设计带来一个直接后果:配置里出现一个条目,和这个条目对应的工作真的在跑,是两个不同的检查点。
| 你以为的状态 | 实际可能停在 | 对应证据 |
|---|---|---|
| 装上了 | 配置行存在,但被后续层替换掉了 | --dump-config 的生效输出 |
| 装上了 | 配置生效,但依赖服务没就绪 | 条目 pending、缺失服务名 |
| 装上了 | 依赖就绪,但插件体本身抛错 | 启动诊断里的原始错误 |
| 装上了 | 挂载成功,但方法行为不对 | 一次代表性操作的返回值 |
下面四节对应这四个卡点。
二、第一层:profile 是叠出来的,不是写出来的
Profile 是保存在 Harness home 里的具名组合。它列出组合包、存放为该 profile 安装的树外插件,并保存自己的 cordis.patch.yml。Bundle 则通过 package.json 里的 dsh.profile 声明 profile 的组合包列表、通过 dsh.bundle 指定自己的 patch。
关键在于顺序:这些层不是简单相加,而是按顺序应用到同一个条目列表。
Bundle patch ← 组合包贡献的配置
Profile patch ← 当前 profile 的用户改动
Home patch ← 各 profile 共享的机器本地层
--patch 覆盖层 ← 命令行参数按顺序追加
──────────────────
运行时插件树 ← 被挂载的插件及其活动服务
最容易踩的坑:patch 按 id 定位条目,然后替换整行的 config,不做深合并。
所以下面这种事是真实会发生的:某个 bundle 给的配置里有 timeout: 30 和 retries: 3,你在 profile patch 里只想改 retries,写了个 retries: 5——结果 timeout 没了,因为整行 config 被换掉了。
编辑 patch 之前,先看完整的生效结果:
dsh --profile web --dump-config
顺带一个容易误解的点:dsh-base 是随附 web、headless、sdk、acp 四个 profile 的共享第一层,各应用 bundle 在上面加不同宿主。但 sdk-minimal 是文档写明的例外——它单个 bundle 自带显式 SDK 树,不套 dsh-base。不要因为同属一个框架,就假设各 profile 能力一致。
HMR 也是这样。YAML 控制配置监视:base 启用,Headless / SDK / ACP 禁用,sdk-minimal 不含这项;profile patch 可以覆盖默认值。所以"我改了配置它怎么没热重载",答案通常不在编辑器里,而在你实际启动的那个 profile 的组合里。
三、第二层:inject 表达的是等待,不是安装
服务通过稳定上下文键暴露,比如 ctx.tools、ctx.llm、ctx.sessions。消费者靠键拿能力,而不是 import 某个具体实现。
插件用 inject 声明它需要什么:
import type {
Context } from '@deepseek-ai/cordis'
export const inject = ['greeter']
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world'))
}
Cordis 会等 greeter 就绪后再激活这个插件。这解决了"启动顺序"问题——顺序由依赖关系表达,不用作者去预测所有插件的全局启动序列。
但这句话的反面必须一起记住:
inject不会安装提供方。 声明依赖不等于依赖会被满足。- 安装消费者不会自动安装提供方。 你得到的是一个一直 pending 的条目。
- 同一个键在每个作用域里不一定解析到同一个实现。 见下一节。
提供方那边要写三样东西,它们是三件事不是一件:
import {
Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter')
}
greet(who: string) {
return `Hello, ${
who}!`
}
}
export function apply(ctx: Context) {
ctx.plugin(GreeterService)
}
| 代码 | 负责什么 | 删掉它会怎样 |
|---|---|---|
super(ctx, 'greeter') |
运行时注册这个键 | 消费者永远等不到服务 |
declare module 类型扩展 |
让 TypeScript 认识 ctx.greeter |
编译报错,但运行时服务照样在跑 |
apply 里 ctx.plugin(...) |
真的把提供方挂上去 | 类型能过,运行时没有实例 |
拼写不一致是"消费者一直挂起"的头号原因。 super 的名字、Context 声明里的名字、消费者 inject 里的名字,三处写错一个,三个看起来都合理的部分就指向了三个键。类型检查通过只说明 TypeScript 认识这个属性,不说明 Cordis 注册了运行时实例。
还有一个生命周期上的现实:必需服务消失时,依赖它的插件会被释放;服务恢复后重新加载。 所以消费者不能长期缓存"服务一定在"的假设,重载时要确保监听器之类的注册被撤销、不会重复。
四、第三层:Context 作用域解决的是三个不同问题
ctx 是个代理,普通属性读取会走服务解析器。所以"这个键在这里解析到谁",取决于你所在的上下文。
Cordis 给了三种派生子上下文的方法,它们经常被混为一谈:
| 方法 | 解决的问题 | 不做什么 |
|---|---|---|
ctx.extend(meta) |
添加局部元数据,自有属性遮蔽继承属性 | 不安装独立服务实现 |
ctx.isolate(name, label?) |
让指定服务在子上下文里用另一个标签解析 | 不隔离其他服务,也不是进程沙箱 |
ctx.intercept(name, config) |
给下方启动的插件带上该服务的拦截配置 | 不等于用 profile patch 替换整行插件 |
两个细节值得单拎出来:
isolate 的标签是合并用的。 默认标签是新建的唯一 Symbol,即每次调用天然独立;但如果你两次调用传同一个标签,它们会加入同一个服务作用域。这既是有意共享的手段,也是"我以为隔离了其实没隔离"的来源。
ctx.root 在文档里标的是实验性,ctx.baseUrl 只在运行时提供时才用于解析相对模块标识。这两个属性本身都不保证插件能访问某个服务,也不证明外部模块可信。
五、第四层:清理是逆序的,而且只覆盖登记过的东西
插件实例的副作用靠 ctx.effect() 这类机制登记,它会立即执行主体,并收集主体产生的释放函数。触发时机是调用返回的释放函数,或 Fiber 卸载,以先发生者为准,释放函数按注册的逆序运行,重复调用是空操作。
逆序这个设计对资源依赖是对的:先拿资源、再拿依赖它的订阅,那么后注册的订阅会先清理。
但有三件事必须自己保证,框架替不了:
- 只覆盖登记过的。 在 effect 外面创建的资源,框架不知道它存在。
- 异步清理要等。 "注册已经不可见"和"资源已经静止"是两回事,判断资源泄漏前先等释放完成。
- 重复释放要无害。 成功路径不等于覆盖了失败路径和重复销毁路径。
服务注册那块也有明确的抛出条件:ctx.provide(name, value) 返回一个释放函数,调用它或 Fiber 卸载都会注销服务;如果同一作用域已经有这个名称的提供者,或者该名称已被声明为 accessor,注册会直接抛错。
再往上一层,Agent 维度的注册有它自己的作用域原语(ScopeKey / Scope.ctx / rawDispose / dispose()),和 ctx.isolate 是两个层面的东西——Agent 专属注册表的覆盖层,不会自动变成独立的服务实现。 读取时按插入顺序先出全局命名项、再加入作用域内的遮蔽项,所以一次精确作用域的贡献可以遮蔽全局同名项,但它并没有修改全局项本身。
六、反过来查:六个检查点
出问题时按这个顺序走,基本不会错认现场:
- 确认实际启动的是哪个 profile。 组合包列表和 patch 决定装配,别的 profile 有别的树。
- 导出实际装配。
dsh --profile web --dump-config,然后找到目标条目,以及到底哪一层贡献或改写了它。 - 先看生效值再改 patch。 记住整行 config 会被替换,不深合并;改之前把还要保留的键抄回去。
- 区分"等待依赖"和"插件体失败"。 条目 pending 只是现象,往上找提供方是否在同一作用域、有没有启动成功。
- 核对三处名字。
super的键、类型扩展的键、消费者inject的键。 - 验证一次代表性操作。 挂载成功只回答"它出现了",不回答"它做对了"。
第 5 步尤其容易跳过:类型检查通过会给人很强的安全感,但它证明的是编译期,不是运行时。
七、边界:这套机制不是安全机制
必须说清楚,因为插件体系读起来很像一套权限模型:
- 隔离是服务解析层面的。
ctx.isolate改变的是指定服务在子上下文里解析到哪个标签,文档没有把它描述为进程沙箱或安全保证。 - 清理不是回滚。 释放函数撤销的是登记过的注册;网络请求、已经写入磁盘的数据,可能比发起它们的注册活得更久。
- 挂载成功不等于安全。 插件会执行代码、注册副作用。加载前应该看准确版本的代码和依赖,用合适的数据和权限测试。
- 组合的灵活性不等于兼容性。 名字相似的插件不一定能互换,替换提供方前要读实现、配置和预期服务接口;替换会影响所有解析该键的消费者,只测一个调用方的成功路径是不够的。
相关阅读
- DeepSeek Harness 为什么用 Cordis 插件树组装?
- DeepSeek Harness 的插件、服务与依赖注入
- DeepSeek Harness 的 Context 能力与作用域
- DeepSeek Harness:插件作用域与资源清理的关系
术语速查
- Profile:组合包与 profile 专属变更构成的具名组装。
- Bundle:配置项及挂载代码的分发形式。
- Fiber:一次插件应用的运行时实例,承载生命周期与副作用。
- Effect:立即执行、并提供清理函数的注册。
- 服务键:从上下文解析能力使用的名称。
- 隔离标签:
isolate用于选择服务作用域的 Symbol。
本文由 DSH Plugin 社区(dsh-plugin.com)维护者撰写,首发于阿里云开发者社区。