DeepSeek Harness 核心设计:为什么不用 import

一篇关于依赖注入、服务定位与插件化架构的深度解读
在这里插入图片描述

写在前面

如果你写过 Node.js 插件系统,一定遇到过这样的场景:14 个插件都在调用同一个大模型接口,某天要换厂商,你发现自己需要改 14 个文件。

DeepSeek Harness 给出的答案是:改一处配置(YAML 文件)就能管住所有消费方

但当我第一次看到这套设计时,脑子里冒出一个问题:你们在 YAML 里不也把名字和实现焊死了吗?跟 import 有什么区别?

这篇文章,就是我从困惑到理解的全过程记录。内容全部来自 DeepSeek Harness 官方文档和源码的梳理,我会尽量把概念讲清楚,不绕弯子。


一、核心问题:为什么不用 import

一句话回答

import“我要什么功能”“谁来实现这个功能” 这两件事,焊死在同一行代码里了。

展开说说

import { deepseek } from './llm-deepseek.ts'

这句话同时说了两件事:

  1. 我需要一个能调用大模型的东西
  2. 这个东西就是 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-aidsh-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 的几个关键特性

  1. 它是合法状态,不是错误码。 框架不会因为它而崩溃或报错
  2. 它很安静。 处于 PENDING 的插件不会执行,也不会输出任何东西。这就是“为什么我的插件一句话不输出也不报错”的头号可能原因
  3. 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 状态。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐