Deepseek Agent Harness教程(三) | DeepSeek Harness 注册方法
DeepSeek Harness 核心设计:为什么不用 import?
一篇关于依赖注入、服务定位与插件化架构的深度解读
写在前面
如果你写过 Node.js 插件系统,一定遇到过这样的场景:14 个插件都在调用同一个大模型接口,某天要换厂商,你发现自己需要改 14 个文件。
DeepSeek Harness 给出的答案是:改一处配置(YAML 文件)就能管住所有消费方。
但当我第一次看到这套设计时,脑子里冒出一个问题:你们在 YAML 里不也把名字和实现焊死了吗?跟 import 有什么区别?
这篇文章,就是我从困惑到理解的全过程记录。内容全部来自 DeepSeek Harness 官方文档和源码的梳理,我会尽量把概念讲清楚,不绕弯子。
一、核心问题:为什么不用 import?
一句话回答
import 把 “我要什么功能” 和 “谁来实现这个功能” 这两件事,焊死在同一行代码里了。
展开说说
import { deepseek } from './llm-deepseek.ts'
这句话同时说了两件事:
- 我需要一个能调用大模型的东西
- 这个东西就是
llm-deepseek.ts这个文件里导出的那个
这就导致,如果以后想换一个不同的大模型接口(比如从 DeepSeek 换成其他厂商),你就得去所有用到这个功能的文件里,把 import 的那一行改掉。
材料里的例子很直观:有 14 个插件都调用了模型,要换厂商就得改 14 个文件。而 DeepSeek Harness 的设计目标是:改一处配置(YAML 文件)就能管住所有消费方,插件作者不需要知道是谁提供了功能,部署系统的人也不需要去改 14 个文件。
文档里的原话是: “import 把这两句话焊在了同一行代码里……import 是模块加载时就结算完的,它没有『等一下再决定』这个档位。”
二、那 YAML 配置里的“解耦”是怎么回事?
你材料里提到的“三行独立配置”,对应仓库里真实的 cordis.patch.yml 配置片段:
# 这是三行独立的配置,不是一行 import
- id: llm # 定义「llm 这个能力长什么样」
name: '@deepseek-ai/dsh-llm'
- id: llm-deepseek # DeepSeek 原生适配器
name: '@deepseek-ai/dsh-llm-deepseek'
- id: llm-pi-ai # 多厂商适配器
name: '@deepseek-ai/dsh-llm-pi-ai'
想换厂商,改的是这份 YAML,不是那十四个插件。
三、等等,这 YAML 不也把名字和实现焊死了吗?
表面上看起来是的:
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai' # 这里写着具体用哪个 npm 包
这行配置确实指定了“llm-pi-ai 这个 id 对应的实现是 @deepseek-ai/dsh-llm-pi-ai 这个包”。看起来和 import { deepseek } from './llm-deepseek.ts' 一样,都是把“名字”和“实现”绑定了。
那区别到底在哪?
关键区别:谁来决定“绑”这件事,以及“绑”发生在什么时候
import 的世界
// 在插件代码里写死
import { deepseek } from './llm-deepseek.ts'
- 绑定的主体: 插件作者(写代码的人)
- 绑定的时机: 编译/打包时(代码写好就定了)
- 绑定的范围: 每个插件各自绑一次,14 个插件就绑 14 次
如果你想换一个实现,得去改这 14 个插件的源代码。
YAML 配置的世界
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
- 绑定的主体: 部署系统的人(运维/配置者)
- 绑定的时机: 启动时(配置文件是运行时读的)
- 绑定的范围: 全局绑一次,所有插件共用这一个名字
四、如果非要较真的话:YAML 确实也有“焊死”的部分
你指出这一点是对的。YAML 里这一行确实也焊死了一个绑定:llm-pi-ai 这个 ID 被绑定到了 @deepseek-ai/dsh-llm-pi-ai 这个 npm 包。
但这个“焊死”发生的位置是配置层,不是代码层:
- 配置层焊死:运维可以改,改完重启就生效
- 代码层焊死:要改源码、重新编译、重新构建
而且更重要的是:插件代码里没有任何一行写了 llm-pi-ai 或 dsh-llm-pi-ai。插件只知道 llm,不知道具体是谁在背后提供。所以当你要从 A 厂商切到 B 厂商时,5 个插件一行都不用改。
五、解决方案:上下文(Context)与服务(Service)
核心概念
- 服务(Service): 一个插件对外提供的、有名字的能力。比如
llm(模型能力)、tools(工具注册表) - 上下文(Context,
ctx): 就是一个装服务的“容器”或“篮子”。每个插件启动时,都会收到一个属于自己的ctx。提供方把服务挂到ctx上,消费方从ctx上取
提供服务方的写法(继承 Service 基类)
材料里的 GreeterService 示例,就是真实 @deepseek-ai/cordis 框架的用法:
import { Service, type Context } from '@deepseek-ai/cordis'
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // 从这一刻起,别人写 ctx.greeter 就能拿到它
}
greet(who: string) {
return 'Hello, ${who}!'
}
}
服务名登记到 ctx 上——super(ctx, 'greeter') 把实例挂到了上下文的 greeter 键上。
消费方的写法(声明 inject 依赖)
export const name = 'consumer'
export const inject = ['greeter'] // 声明我需要 greeter 服务
export function apply(ctx: Context) {
// apply 被调用时,ctx.greeter 一定已经就绪
console.log(ctx.greeter.greet('world'))
}
框架的保证是: apply 运行时,inject 里的服务都已就绪;否则插件停在 PENDING。
真实的 harness 插件长这样
packages/web/tool-web/src/index.ts 里就是:
/** Services required by the web tool suite. */
export const inject = ['tools', 'web', 'systemPrompt']
这就是“一行 inject”的真实写照。
一个关键规则(防误解)
- 不声明依赖就直接用,会报错。 比如你没写
inject: ['greeter']就直接在代码里写ctx.greeter,框架会抛出一个错误:cannot get property "greeter" without inject - 这样做的目的是强迫你把依赖写在明面上,这样框架才能帮你安排加载顺序
六、依赖与启动顺序:inject 是关键
核心结论
加载顺序由依赖决定,不由配置文件里的先后顺序决定。
- 材料里做了实验:一个提供
greeter的插件和一个消费greeter的插件,在配置文件里谁写在前面,运行结果都一样(都是Hello, world!) - 因为消费方会一直等着,直到提供方出现并登记了服务,它才会被唤醒和执行
- 所以,你无法通过调整配置文件的行序来控制谁先启动。想让 A 在 B 之后启动,唯一的办法是让 A 依赖 B 提供的服务
文档里的原文证据
仓库配置文件注释里写着:
“Row order carries no load semantics (activation is service-availability driven).”
(行的顺序不携带任何加载语义,激活由服务是否可用驱动。)
packages/bundle/base/cordis.patch.yml 开头就有这句原话。
七、PENDING 状态:不是错误,是等待
核心概念
当一个插件声明了 inject,但所需的服务还没出现时,它就处于 PENDING 状态。
PENDING 的几个关键特性
- 它是合法状态,不是错误码。 框架不会因为它而崩溃或报错
- 它很安静。 处于 PENDING 的插件不会执行,也不会输出任何东西。这就是“为什么我的插件一句话不输出也不报错”的头号可能原因
- PENDING 的插件不会让 Node.js 进程保持活跃。 如果你的程序里只有 PENDING 的插件,进程会正常退出(状态码 0),让你以为程序跑完了,其实什么都没做
怎么确认是不是 PENDING
材料提供了一段诊断代码,通过遍历 ctx.registry 来查看所有插件的状态:
import { FiberState, type Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log('${fiber.name} is PENDING — a required service is missing')
}
}
}
}, 500)
}
ctx.registry 是 Cordis 框架暴露的注册表,fiber.state 是每个插件的生命周期状态。用 setTimeout 延迟半秒检查,是为了等那些正在加载的插件先完成加载。
八、可选依赖:ctx.get()
如果你有一个功能“有更好,没有也能活”,就不要用 inject,而是用 ctx.get('服务名') 来探测式获取:
export function apply(ctx: Context) {
// 没有提供方时返回 undefined;插件照常运行
const metrics = ctx.get('metrics')
metrics?.record('plugin_loaded', 1)
}
特别注意的坑: 当提供方正在重新加载时,ctx.get() 也会返回 undefined,不是只在你启动时检查一次。所以你的代码要能承受偶发的 undefined。
ctx.get() 和 inject 的区别:get 不会让你的插件进 PENDING。
最终一句话收尾
不用
import是为了把“我要什么”和“谁给我”彻底分开,让系统能在运行时自由切换实现,代价是你必须通过inject明说依赖,并接受服务未就绪时插件会安静地停在 PENDING 状态。
更多推荐




所有评论(0)