第七课:DeepSeek Harness 服务与依赖注入

第五课的 number-stats 插件同时做了两件事:

  1. 向模型注册 lesson_number_stats 工具。
  2. 完成数字校验、求和与平均值计算。

本课要把它拆成两个插件:

复制代码

最终调用链是:

模型调用工具 → 工具插件调用统计服务 → 统计服务完成业务计算 → 结果原路返回模型。


一、本课必须掌握的五个概念

概念 本课中的实例 准确定义
Plugin service.mjstool.mjs Harness 管理的加载和生命周期单元
Service lesson.numberStats 插件之间按名称共享的运行时能力
Service Provider service.mjs 提供某项 Service 的插件
Service Consumer tool.mjs 声明并使用某项 Service 的插件
Tool lesson_number_stats 暴露给模型调用的接口

最容易混淆的是 Service 和 Tool:

  • Service 面向其他插件。
  • Tool 面向模型。
  • Service 默认不会出现在模型的工具列表里。
  • Tool 可以调用一个或多个 Service。

这里说的"服务提供者"也不是你前面学过的"模型 Provider"。两者只是英文都叫 provider:

  • 模型 Provider:提供模型访问能力。
  • Service Provider:向其他 Harness 插件提供运行时能力。

DeepSeek Harness 官方 Services 教程将 Service 定义为插件通过 ctx 提供和消费的命名能力;ctx.toolsctx.llmctx.agents 本身也都是 Service。官方 Services 教程


二、本课的文件结构

在现有目录中增加两个文件:

文件 本课作用
index.mjs 第五课代码,保留但不再加载
service.mjs 提供统计业务服务
tool.mjs 消费统计服务并注册模型工具
cordis.patch.yml 同时加载两个新插件

最终目录位于:

复制代码
D:\DeepSeek\plugins\number-stats

三、编写服务提供者插件

创建:

复制代码
D:\DeepSeek\plugins\number-stats\service.mjs

写入以下完整代码:

复制代码
export const name = 'number-stats-service';

const SERVICE_KEY = 'lesson.numberStats';

function readConfig(config) {
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
    throw new Error('配置错误:number-stats-service 的 config 必须是对象');
  }

  for (const key of Object.keys(config)) {
    if (key !== 'maxItems') {
      throw new Error('配置错误:未知配置项 ' + key);
    }
  }

  const maxItems = config.maxItems === undefined ? 1000 : config.maxItems;

  if (!Number.isSafeInteger(maxItems) || maxItems < 1) {
    throw new Error('配置错误:maxItems 必须是正的安全整数');
  }

  return { maxItems };
}

function calculateStats(values, maxItems) {
  if (!Array.isArray(values)) {
    throw new Error('业务输入错误:values 必须是数组');
  }

  if (values.length < 1 || values.length > maxItems) {
    throw new Error(
      '业务输入错误:values 数量应为 1 到 ' + maxItems +
      ',实际为 ' + values.length
    );
  }

  let sum = 0;

  for (const value of values) {
    if (!Number.isFinite(value)) {
      throw new Error('业务输入错误:每一项都必须是有限数字');
    }

    sum += value;
  }

  if (!Number.isFinite(sum)) {
    throw new Error('计算错误:数值总和超出本示例支持的范围');
  }

  return {
    count: values.length,
    mean: sum / values.length
  };
}

export function apply(ctx, config = {}) {
  const { maxItems } = readConfig(config);
  let attempts = 0;

  const service = Object.freeze({
    maxItems,

    calculate(values) {
      const attemptNumber = ++attempts;

      console.info(
        '[number-stats-service] calculate #' + attemptNumber + ' start'
      );

      try {
        const result = calculateStats(values, maxItems);

        console.info(
          '[number-stats-service] calculate #' + attemptNumber + ' success'
        );

        return result;
      } catch (error) {
        const message =
          error instanceof Error ? error.message : String(error);

        console.info(
          '[number-stats-service] calculate #' + attemptNumber +
          ' failed: ' + message
        );

        throw error;
      }
    }
  });

  ctx.effect(() => {
    return () => {
      console.info(
        '[number-stats-service] cleanup maxItems=' + maxItems +
        ', attempts=' + attempts
      );
    };
  });

  ctx.provide(SERVICE_KEY, service);

  console.info(
    '[number-stats-service] provided ' + SERVICE_KEY +
    ' maxItems=' + maxItems
  );
}

3.1 这个插件究竟提供了什么

它提供的服务契约可以写成:

复制代码
{
  maxItems: 正整数,
  calculate(values): {
    count: 整数,
    mean: 数字
  }
}

最关键的一句是:

复制代码
ctx.provide(SERVICE_KEY, service);

等价于:

复制代码
ctx.provide('lesson.numberStats', service);

意思是:

当前插件向 Harness 注册一个名为 lesson.numberStats 的服务,服务值就是这个 service 对象。

ctx.provide() 注册的服务归当前插件生命周期所有。插件卸载时,该服务会自动注销,不需要我们在 cleanup 中手动删除。ctx.provide 官方 API

3.2 lesson.numberStats 是什么

它只是一个服务名称:

复制代码
const SERVICE_KEY = 'lesson.numberStats';

需要注意:

  • 它不是文件名。
  • 它不是工具名。
  • 它不是插件配置 ID。
  • 点号不会创建嵌套对象。
  • 它是完整的、扁平的字符串名称。

使用 lesson. 前缀是为了避免和其他插件发生服务名称冲突。Cordis 的服务名称处于同一个扁平命名空间,正式产品中应当使用自己的项目或组织前缀。

3.3 为什么使用 Object.freeze

复制代码
const service = Object.freeze({
  maxItems,
  calculate(values) {}
});

它可以防止消费者意外执行:

复制代码
service.maxItems = 999;
service.calculate = anotherFunction;

但它不会冻结闭包里的变量:

复制代码
let attempts = 0;

因此调用次数仍然可以正常增加。

四、编写服务消费者插件

创建:

D:\DeepSeek\plugins\number-stats\tool.mjs

写入:

复制代码
export const name = 'number-stats-tool';

const SERVICE_KEY = 'lesson.numberStats';

export const inject = ['tools', SERVICE_KEY];

export function apply(ctx) {
  const stats = ctx[SERVICE_KEY];

  if (
    stats === null ||
    typeof stats !== 'object' ||
    typeof stats.calculate !== 'function' ||
    !Number.isSafeInteger(stats.maxItems) ||
    stats.maxItems < 1
  ) {
    throw new Error(
      '服务契约错误:' + SERVICE_KEY + ' 不符合预期接口'
    );
  }

  let toolCalls = 0;

  console.info(
    '[number-stats-tool] apply service.maxItems=' + stats.maxItems
  );

  ctx.tools.register({
    name: 'lesson_number_stats',

    description:
      '计算数字的数量和算术平均值;每次接受 1 到 ' +
      stats.maxItems + ' 个数字。',

    parameters: {
      type: 'object',
      properties: {
        values: {
          type: 'array',
          items: {
            type: 'number'
          },
          description: '要统计的有限数字数组'
        }
      },
      required: ['values'],
      additionalProperties: false
    },

    output: {
      schema: {
        type: 'object',
        properties: {
          count: {
            type: 'integer'
          },
          mean: {
            type: 'number'
          }
        },
        required: ['count', 'mean'],
        additionalProperties: false
      },

      render(_args, value) {
        return [
          {
            type: 'text',
            text: JSON.stringify(value)
          }
        ];
      }
    },

    async execute(args, exec) {
      const callNumber = ++toolCalls;

      console.info(
        '[number-stats-tool] execute #' + callNumber + ' start'
      );

      try {
        exec.signal.throwIfAborted();

        if (
          args === null ||
          typeof args !== 'object' ||
          Array.isArray(args) ||
          !Object.hasOwn(args, 'values') ||
          Object.keys(args).length !== 1
        ) {
          throw new Error(
            '工具参数错误:参数必须是只包含 values 字段的对象'
          );
        }

        const result = stats.calculate(args.values);

        console.info(
          '[number-stats-tool] execute #' + callNumber + ' success'
        );

        return result;
      } catch (error) {
        const message =
          error instanceof Error ? error.message : String(error);

        console.info(
          '[number-stats-tool] execute #' + callNumber +
          ' failed: ' + message
        );

        throw error;
      }
    }
  });

  ctx.effect(() => {
    return () => {
      console.info(
        '[number-stats-tool] cleanup toolCalls=' + toolCalls
      );
    };
  });

  console.info(
    '[number-stats-tool] registered lesson_number_stats'
  );
}

4.1 inject 是本课的核心

复制代码
export const inject = ['tools', SERVICE_KEY];

实际等价于:

复制代码
export const inject = [
  'tools',
  'lesson.numberStats'
];

它表示当前插件有两个必需依赖:

  1. Harness 内置的 tools 服务。
  2. 我们自己定义的 lesson.numberStats 服务。

这句话不是在调用服务,也不是在导入服务,而是在声明:

只有这两个服务都可以使用时,Harness 才允许执行本插件的 apply()

如果 lesson.numberStats 不存在,工具插件不会带着空值勉强启动,而是停留在等待依赖的 PENDING 状态。官方文档还说明,如果运行过程中必需服务消失,依赖它的插件会被卸载;服务恢复后,消费者可以重新加载。依赖注入与状态行为

4.2 为什么没有导入 service.mjs

注意,tool.mjs 中绝对没有:

复制代码
import './service.mjs';

也没有:

复制代码
import { calculateStats } from './service.mjs';

消费者只知道:

复制代码
const SERVICE_KEY = 'lesson.numberStats';

然后从当前上下文取得已经注入的服务:

复制代码
const stats = ctx[SERVICE_KEY];

因为点号属于服务名称本身,所以不能写成:

复制代码
ctx.lesson.numberStats

必须写成:

复制代码
ctx['lesson.numberStats']

或者:

复制代码
ctx[SERVICE_KEY]

4.3 import 与依赖注入的本质区别

普通 JavaScript import Harness Service 注入
依赖具体文件或包 依赖能力名称
模块加载时解析 插件运行时解析
文件找不到通常直接加载失败 服务缺失时消费者保持等待
消费者知道实现来自哪里 消费者不需要知道由哪个插件实现
没有 Harness 生命周期语义 服务出现、消失与插件生命周期联动

所以依赖注入真正降低的不是"函数调用次数",而是消费者对具体实现的耦合。

以后可以把 service.mjs 替换成:

  • 本地高性能统计实现;
  • 数据库统计实现;
  • 远程 HTTP 统计服务;
  • Python 服务代理;
  • 带缓存的统计实现。

只要新插件仍提供符合约定的:

复制代码
lesson.numberStats

tool.mjs 就不需要知道具体实现发生了变化。

但同一作用域内不能随意同时提供两个同名服务;重复提供通常会产生冲突,而不是由 Harness 随机选择一个。

五、配置两个插件

将当前 cordis.patch.yml 替换为:

复制代码
- insert:
    - id: lesson-number-stats-tool
      name: 'file:///D:/DeepSeek/plugins/number-stats/tool.mjs'

    - id: lesson-number-stats-service
      name: 'file:///D:/DeepSeek/plugins/number-stats/service.mjs'
      config:
        maxItems: 5

这里故意把消费者 tool.mjs 写在前面,把服务提供者 service.mjs 写在后面。

这是本课的一个实验:

YAML 中的书写顺序不应该被当作插件依赖顺序;消费者什么时候可以执行,由 inject 是否满足决定。

即使 Harness 先发现工具插件,它看到:

复制代码
inject = ['tools', 'lesson.numberStats']

lesson.numberStats 尚未就绪,就会先等待。服务插件进入可用状态以后,工具插件才具备执行 apply() 的条件。

六、四种名称不要混淆

名称 当前值 用途
Loader entry ID lesson-number-stats-service 配置树中识别这一条插件配置
Plugin name number-stats-service 插件自身名称和诊断信息
Service name lesson.numberStats 插件之间进行依赖注入
Tool name lesson_number_stats 模型发起工具调用时使用

消费者真正依赖的是:

复制代码
lesson.numberStats

它并不依赖:

复制代码
lesson-number-stats-service

这说明消费者依赖的是能力契约,不是某个具体插件 ID。

七、语法检查

在 PowerShell 中执行:

复制代码
node --check "D:\DeepSeek\plugins\number-stats\service.mjs"
node --check "D:\DeepSeek\plugins\number-stats\tool.mjs"

如果两条命令都没有输出,说明两个文件通过了 JavaScript 语法检查。

但要注意:

node --check 只能证明语法可以解析,不能证明 Service 注入、工具注册和 Harness 生命周期一定正常。

八、启动 Harness

执行:

复制代码
npx.cmd @deepseek-ai/dsh@0.1.1-rc.2 --profile web --patch "D:\DeepSeek\plugins\number-stats\cordis.patch.yml"

关注我们自己的三条日志。其他 Harness 日志可以暂时忽略:

复制代码
[number-stats-service] provided lesson.numberStats maxItems=5
[number-stats-tool] apply service.maxItems=5
[number-stats-tool] registered lesson_number_stats

这三条日志的相对顺序表达的是:

  1. 服务插件执行了 ctx.provide()
  2. 服务插件进入可供依赖者使用的状态。
  3. 工具插件的依赖得到满足。
  4. Harness 才执行工具插件的 apply()
  5. 工具随后注册成功。

它们不一定和其他 Harness 日志紧挨着,但服务日志应当先于消费者的 apply 日志。

九、第一次调用实验:证明工具真的调用了服务

在新会话中输入:

请调用 lesson_number_stats,参数严格使用:

{"values":10,12,14}

只调用一次,不要自行计算,不要重试。

预期工具结果:

复制代码
{
  "count": 3,
  "mean": 12
}

终端中预期看到:

number-stats-tool execute #1 start

number-stats-service calculate #1 start

number-stats-service calculate #1 success

number-stats-tool execute #1 success

这组日志非常重要。它证明了真实调用路径:

工具 execute

stats.calculate

统计服务 calculate

结果返回工具

如果只是模型自己回答"平均值是 12",但没有实际工具调用记录和以上日志,就不能证明插件协作成功。

十、第二次实验:观察错误属于哪一层

实验 A:工具接口层错误

让模型严格发出多余字段:

请调用 lesson_number_stats,参数必须严格为:

{"values":1,2,"maxItems":10}

不要修改参数,不要重试。

如果这个参数确实进入了我们的 execute(),预期日志是:

number-stats-tool execute #2 start

number-stats-tool execute #2 failed: 工具参数错误:参数必须是只包含 values 字段的对象

此时不应出现新的:

number-stats-service calculate

这说明调用在工具接口层就被拦截了,还没有进入业务服务。

不过模型可能遵守工具 Schema,主动删除 maxItems,也可能拒绝发起非法调用。此时这个实验没有真正进入我们的代码,不能把模型的自然语言回答当作测试结果。

实验 B:服务业务层错误

输入:

复制代码
请调用 lesson_number_stats,参数严格使用:
{"values":[1,2,3,4,5,6]}

只调用一次,不要修改参数,不要重试。

因为参数对象结构合法,所以会进入服务;但数组有六项,超过服务配置的 maxItems: 5

预期日志:

number-stats-tool execute #3 start

number-stats-service calculate #2 start

number-stats-service calculate #2 failed: 业务输入错误:values 数量应为 1 到 5,实际为 6

number-stats-tool execute #3 failed: 业务输入错误:values 数量应为 1 到 5,实际为 6

这次证明:

  1. 工具接口层接受了参数结构。
  2. 请求进入统计服务。
  3. 服务执行了业务规则校验。
  4. 服务错误沿调用链返回工具。

这就是正确的分层:

负责什么
Tool 插件 模型工具名称、Schema、调用取消、工具参数外壳
Service 插件 数量限制、数字合法性、统计计算等业务规则
Harness/Cordis 服务发现、依赖等待、加载和卸载

然后再执行一次合法请求:

请调用 lesson_number_stats,参数严格使用:

{"values":-2,0,8}

只调用一次。

预期:

复制代码
{
  "count": 3,
  "mean": 2
}

这可以证明一次业务错误不会破坏整个服务实例。

十一、最关键的依赖缺失实验

现在我们主动让服务提供者失效,观察消费者是否还能启动。

先结束 Harness:

将配置改为:

复制代码
- insert:
    - id: lesson-number-stats-tool
      name: 'file:///D:/DeepSeek/plugins/number-stats/tool.mjs'

    - id: lesson-number-stats-service
      name: 'file:///D:/DeepSeek/plugins/number-stats/service.mjs'
      disabled: true
      config:
        maxItems: 5

disablednameconfig 处于同一级。它会禁用这条 Loader entry,而不是删除文件。Cordis 配置教程

重新启动:

复制代码
npx.cmd @deepseek-ai/dsh@0.1.1-rc.2 --profile web --patch "D:\DeepSeek\plugins\number-stats\cordis.patch.yml"

预期现象:

  • Web 仍然可以启动。
  • 不会出现服务的 provided 日志。
  • 不会出现工具插件的 apply 日志。
  • 不会出现工具插件的 registered 日志。
  • lesson_number_stats 不应被注册到当前运行环境。

为什么工具插件没有直接报错?

因为它声明了:

复制代码
export const inject = [
  'tools',
  'lesson.numberStats'
];

必需服务不存在时,Cordis 不执行这个消费者的 apply(),而是让它等待依赖。这比让插件执行到下面这句才发生空指针错误安全得多:

复制代码
ctx[SERVICE_KEY].calculate(...)

测试时请创建新会话,避免把旧会话中曾经显示过的工具记录误认为当前工具仍然存在。

测试完成后,删除:

复制代码
disabled: true

重新启动,确认三条启动日志恢复。

这个实验验证的是:

应用启动时依赖缺失,消费者保持等待。

它还没有验证"同一运行进程中服务突然消失再恢复"的动态行为。动态卸载和重新加载将在后续学习热重载与配置树时专门实验,不能把重启实验说成动态卸载实验。

这个图我觉得你做的就不够清晰,一般人就看不明白

这是怎么回事

一直卡在这里

对,按照调整后的课程顺序,上边属于第五课。

正式课程名称建议定为:

第五课:DeepSeek Harness 插件配置与生命周期

副标题:

配置注入、调用参数、状态保持与资源清理

核心内容包括 configargs 的区别、applyexecute 的执行时机、闭包状态、配置生效机制,以及 ctx.effect 的卸载清理。

上边第五课学完之后应该学什么?

第五课学完后,下一课应该是:

第六课:DeepSeek Harness 服务与依赖注入

副标题:

从单个插件走向多个插件协作

第五课研究的是"一个插件内部如何接收配置、注册工具、保存状态和完成清理"。第六课则要把现有的 number-stats 拆成两个插件:

  • number-stats-service:只负责统计计算,对外提供 numberStats 服务。
  • number-stats-tool:依赖 numberStats 和 Harness 的 tools 服务,把业务能力注册成模型可调用的工具。

由此形成:

模型调用工具 → 工具插件调用服务 → 服务插件完成业务计算 → 工具返回结果

这一课要真正学会:

  1. Service、Provider、Consumer、Tool、Plugin 分别是什么。
  2. ctx.provide() 如何提供服务。
  3. inject 如何声明必需依赖。
  4. 为什么 YAML 中插件的先后顺序不等于加载顺序。
  5. 服务不存在时,消费者为什么停留在 PENDING
  6. 服务卸载后,依赖它的工具插件为什么也会自动卸载。
  7. 为什么业务逻辑适合放在 Service 中,而模型接口适合放在 Tool 插件中。

虽然前面已经概念性讲过 Service 和 inject,但第六课会第一次把它们拆成两个真实插件进行完整实验。官方也把 Service 定义为"一个插件提供、其他插件通过 ctx 消费的具名能力",并由依赖关系决定插件何时加载。官方 Services 教程

第六课完成后,你就能真正理解 DeepSeek Harness 的模块化基础:

插件是部署和生命周期单元,Service 是插件之间的能力接口,Tool 是暴露给模型的调用接口。

第七课再继续学习插件事件系统,理解插件之间不通过直接服务调用时,怎样使用事件进行解耦通信。

按照你上边说的开始带我学习,开讲

第六课:DeepSeek Harness 服务与依赖注入

副标题:从"一个插件完成所有工作"走向"多个插件按能力协作"。

第五课的 number-stats 插件同时做了两件事:

  1. 向模型注册 lesson_number_stats 工具。
  2. 完成数字校验、求和与平均值计算。

本课要把它拆成两个插件:

复制代码

最终调用链是:

模型调用工具 → 工具插件调用统计服务 → 统计服务完成业务计算 → 结果原路返回模型。


一、本课必须掌握的五个概念

概念 本课中的实例 准确定义
Plugin service.mjstool.mjs Harness 管理的加载和生命周期单元
Service lesson.numberStats 插件之间按名称共享的运行时能力
Service Provider service.mjs 提供某项 Service 的插件
Service Consumer tool.mjs 声明并使用某项 Service 的插件
Tool lesson_number_stats 暴露给模型调用的接口

最容易混淆的是 Service 和 Tool:

  • Service 面向其他插件。
  • Tool 面向模型。
  • Service 默认不会出现在模型的工具列表里。
  • Tool 可以调用一个或多个 Service。

这里说的"服务提供者"也不是你前面学过的"模型 Provider"。两者只是英文都叫 provider:

  • 模型 Provider:提供模型访问能力。
  • Service Provider:向其他 Harness 插件提供运行时能力。

DeepSeek Harness 官方 Services 教程将 Service 定义为插件通过 ctx 提供和消费的命名能力;ctx.toolsctx.llmctx.agents 本身也都是 Service。官方 Services 教程


二、本课的文件结构

在现有目录中增加两个文件:

文件 本课作用
index.mjs 第五课代码,保留但不再加载
service.mjs 提供统计业务服务
tool.mjs 消费统计服务并注册模型工具
cordis.patch.yml 同时加载两个新插件

最终目录位于:

复制代码
D:\DeepSeek\plugins\number-stats

开始前,先结束正在运行的 Harness:

复制代码
Ctrl+C

等待 PowerShell 重新出现:

复制代码
(base) PS C:\Users\ADMIN>

建议备份第五课配置:

复制代码
Copy-Item "D:\DeepSeek\plugins\number-stats\cordis.patch.yml" "D:\DeepSeek\plugins\number-stats\cordis.lesson5.patch.yml"

index.mjs不需要删除。只要新的 cordis.patch.yml 不引用它,它就不会被加载。


三、编写服务提供者插件

创建:

复制代码
D:\DeepSeek\plugins\number-stats\service.mjs

写入以下完整代码:

复制代码
export const name = 'number-stats-service';

const SERVICE_KEY = 'lesson.numberStats';

function readConfig(config) {
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
    throw new Error('配置错误:number-stats-service 的 config 必须是对象');
  }

  for (const key of Object.keys(config)) {
    if (key !== 'maxItems') {
      throw new Error('配置错误:未知配置项 ' + key);
    }
  }

  const maxItems = config.maxItems === undefined ? 1000 : config.maxItems;

  if (!Number.isSafeInteger(maxItems) || maxItems < 1) {
    throw new Error('配置错误:maxItems 必须是正的安全整数');
  }

  return { maxItems };
}

function calculateStats(values, maxItems) {
  if (!Array.isArray(values)) {
    throw new Error('业务输入错误:values 必须是数组');
  }

  if (values.length < 1 || values.length > maxItems) {
    throw new Error(
      '业务输入错误:values 数量应为 1 到 ' + maxItems +
      ',实际为 ' + values.length
    );
  }

  let sum = 0;

  for (const value of values) {
    if (!Number.isFinite(value)) {
      throw new Error('业务输入错误:每一项都必须是有限数字');
    }

    sum += value;
  }

  if (!Number.isFinite(sum)) {
    throw new Error('计算错误:数值总和超出本示例支持的范围');
  }

  return {
    count: values.length,
    mean: sum / values.length
  };
}

export function apply(ctx, config = {}) {
  const { maxItems } = readConfig(config);
  let attempts = 0;

  const service = Object.freeze({
    maxItems,

    calculate(values) {
      const attemptNumber = ++attempts;

      console.info(
        '[number-stats-service] calculate #' + attemptNumber + ' start'
      );

      try {
        const result = calculateStats(values, maxItems);

        console.info(
          '[number-stats-service] calculate #' + attemptNumber + ' success'
        );

        return result;
      } catch (error) {
        const message =
          error instanceof Error ? error.message : String(error);

        console.info(
          '[number-stats-service] calculate #' + attemptNumber +
          ' failed: ' + message
        );

        throw error;
      }
    }
  });

  ctx.effect(() => {
    return () => {
      console.info(
        '[number-stats-service] cleanup maxItems=' + maxItems +
        ', attempts=' + attempts
      );
    };
  });

  ctx.provide(SERVICE_KEY, service);

  console.info(
    '[number-stats-service] provided ' + SERVICE_KEY +
    ' maxItems=' + maxItems
  );
}

3.1 这个插件究竟提供了什么

它提供的服务契约可以写成:

复制代码
{
  maxItems: 正整数,
  calculate(values): {
    count: 整数,
    mean: 数字
  }
}

最关键的一句是:

复制代码
ctx.provide(SERVICE_KEY, service);

等价于:

复制代码
ctx.provide('lesson.numberStats', service);

意思是:

当前插件向 Harness 注册一个名为 lesson.numberStats 的服务,服务值就是这个 service 对象。

ctx.provide() 注册的服务归当前插件生命周期所有。插件卸载时,该服务会自动注销,不需要我们在 cleanup 中手动删除。ctx.provide 官方 API

3.2 lesson.numberStats 是什么

它只是一个服务名称:

复制代码
const SERVICE_KEY = 'lesson.numberStats';

需要注意:

  • 它不是文件名。
  • 它不是工具名。
  • 它不是插件配置 ID。
  • 点号不会创建嵌套对象。
  • 它是完整的、扁平的字符串名称。

使用 lesson. 前缀是为了避免和其他插件发生服务名称冲突。Cordis 的服务名称处于同一个扁平命名空间,正式产品中应当使用自己的项目或组织前缀。

3.3 为什么使用 Object.freeze

它可以防止消费者意外执行:

复制代码
service.maxItems = 999;
service.calculate = anotherFunction;

但它不会冻结闭包里的变量:

复制代码
let attempts = 0;

因此调用次数仍然可以正常增加。


四、编写服务消费者插件

创建:

复制代码
D:\DeepSeek\plugins\number-stats\tool.mjs

写入:

复制代码
export const name = 'number-stats-tool';

const SERVICE_KEY = 'lesson.numberStats';

export const inject = ['tools', SERVICE_KEY];

export function apply(ctx) {
  const stats = ctx[SERVICE_KEY];

  if (
    stats === null ||
    typeof stats !== 'object' ||
    typeof stats.calculate !== 'function' ||
    !Number.isSafeInteger(stats.maxItems) ||
    stats.maxItems < 1
  ) {
    throw new Error(
      '服务契约错误:' + SERVICE_KEY + ' 不符合预期接口'
    );
  }

  let toolCalls = 0;

  console.info(
    '[number-stats-tool] apply service.maxItems=' + stats.maxItems
  );

  ctx.tools.register({
    name: 'lesson_number_stats',

    description:
      '计算数字的数量和算术平均值;每次接受 1 到 ' +
      stats.maxItems + ' 个数字。',

    parameters: {
      type: 'object',
      properties: {
        values: {
          type: 'array',
          items: {
            type: 'number'
          },
          description: '要统计的有限数字数组'
        }
      },
      required: ['values'],
      additionalProperties: false
    },

    output: {
      schema: {
        type: 'object',
        properties: {
          count: {
            type: 'integer'
          },
          mean: {
            type: 'number'
          }
        },
        required: ['count', 'mean'],
        additionalProperties: false
      },

      render(_args, value) {
        return [
          {
            type: 'text',
            text: JSON.stringify(value)
          }
        ];
      }
    },

    async execute(args, exec) {
      const callNumber = ++toolCalls;

      console.info(
        '[number-stats-tool] execute #' + callNumber + ' start'
      );

      try {
        exec.signal.throwIfAborted();

        if (
          args === null ||
          typeof args !== 'object' ||
          Array.isArray(args) ||
          !Object.hasOwn(args, 'values') ||
          Object.keys(args).length !== 1
        ) {
          throw new Error(
            '工具参数错误:参数必须是只包含 values 字段的对象'
          );
        }

        const result = stats.calculate(args.values);

        console.info(
          '[number-stats-tool] execute #' + callNumber + ' success'
        );

        return result;
      } catch (error) {
        const message =
          error instanceof Error ? error.message : String(error);

        console.info(
          '[number-stats-tool] execute #' + callNumber +
          ' failed: ' + message
        );

        throw error;
      }
    }
  });

  ctx.effect(() => {
    return () => {
      console.info(
        '[number-stats-tool] cleanup toolCalls=' + toolCalls
      );
    };
  });

  console.info(
    '[number-stats-tool] registered lesson_number_stats'
  );
}

4.1 inject 是本课的核心

复制代码
export const inject = ['tools', SERVICE_KEY];

实际等价于:

复制代码
export const inject = [
  'tools',
  'lesson.numberStats'
];

它表示当前插件有两个必需依赖:

  1. Harness 内置的 tools 服务。
  2. 我们自己定义的 lesson.numberStats 服务。

这句话不是在调用服务,也不是在导入服务,而是在声明:

只有这两个服务都可以使用时,Harness 才允许执行本插件的 apply()

如果 lesson.numberStats 不存在,工具插件不会带着空值勉强启动,而是停留在等待依赖的 PENDING 状态。官方文档还说明,如果运行过程中必需服务消失,依赖它的插件会被卸载;服务恢复后,消费者可以重新加载。依赖注入与状态行为

4.2 为什么没有导入 service.mjs

注意,tool.mjs 中绝对没有:

复制代码
import './service.mjs';

也没有:

复制代码
import { calculateStats } from './service.mjs';

消费者只知道:

复制代码
const SERVICE_KEY = 'lesson.numberStats';

然后从当前上下文取得已经注入的服务:

复制代码
const stats = ctx[SERVICE_KEY];

因为点号属于服务名称本身,所以不能写成:

复制代码
ctx.lesson.numberStats

必须写成:

复制代码
ctx['lesson.numberStats']

或者:

复制代码
ctx[SERVICE_KEY]

4.3 import 与依赖注入的本质区别

普通 JavaScript import Harness Service 注入
依赖具体文件或包 依赖能力名称
模块加载时解析 插件运行时解析
文件找不到通常直接加载失败 服务缺失时消费者保持等待
消费者知道实现来自哪里 消费者不需要知道由哪个插件实现
没有 Harness 生命周期语义 服务出现、消失与插件生命周期联动

所以依赖注入真正降低的不是"函数调用次数",而是消费者对具体实现的耦合。

以后可以把 service.mjs 替换成:

  • 本地高性能统计实现;
  • 数据库统计实现;
  • 远程 HTTP 统计服务;
  • Python 服务代理;
  • 带缓存的统计实现。

只要新插件仍提供符合约定的:

复制代码
lesson.numberStats

tool.mjs 就不需要知道具体实现发生了变化。

但同一作用域内不能随意同时提供两个同名服务;重复提供通常会产生冲突,而不是由 Harness 随机选择一个。


五、配置两个插件

将当前 cordis.patch.yml 替换为:

复制代码
- insert:
    - id: lesson-number-stats-tool
      name: 'file:///D:/DeepSeek/plugins/number-stats/tool.mjs'

    - id: lesson-number-stats-service
      name: 'file:///D:/DeepSeek/plugins/number-stats/service.mjs'
      config:
        maxItems: 5

这里故意把消费者 tool.mjs 写在前面,把服务提供者 service.mjs 写在后面。

这是本课的一个实验:

YAML 中的书写顺序不应该被当作插件依赖顺序;消费者什么时候可以执行,由 inject 是否满足决定。

即使 Harness 先发现工具插件,它看到:

复制代码
inject = ['tools', 'lesson.numberStats']

lesson.numberStats 尚未就绪,就会先等待。服务插件进入可用状态以后,工具插件才具备执行 apply() 的条件。


六、四种名称不要混淆

名称 当前值 用途
Loader entry ID lesson-number-stats-service 配置树中识别这一条插件配置
Plugin name number-stats-service 插件自身名称和诊断信息
Service name lesson.numberStats 插件之间进行依赖注入
Tool name lesson_number_stats 模型发起工具调用时使用

消费者真正依赖的是:

复制代码
lesson.numberStats

它并不依赖:

复制代码
lesson-number-stats-service

这说明消费者依赖的是能力契约,不是某个具体插件 ID。


七、语法检查

在 PowerShell 中执行:

复制代码
node --check "D:\DeepSeek\plugins\number-stats\service.mjs"
node --check "D:\DeepSeek\plugins\number-stats\tool.mjs"

如果两条命令都没有输出,说明两个文件通过了 JavaScript 语法检查。

但要注意:

node --check 只能证明语法可以解析,不能证明 Service 注入、工具注册和 Harness 生命周期一定正常。


八、启动 Harness

执行:

复制代码
npx.cmd @deepseek-ai/dsh@0.1.1-rc.2 --profile web --patch "D:\DeepSeek\plugins\number-stats\cordis.patch.yml"

关注我们自己的三条日志。其他 Harness 日志可以暂时忽略:

复制代码
[number-stats-service] provided lesson.numberStats maxItems=5
[number-stats-tool] apply service.maxItems=5
[number-stats-tool] registered lesson_number_stats

这三条日志的相对顺序表达的是:

  1. 服务插件执行了 ctx.provide()
  2. 服务插件进入可供依赖者使用的状态。
  3. 工具插件的依赖得到满足。
  4. Harness 才执行工具插件的 apply()
  5. 工具随后注册成功。

它们不一定和其他 Harness 日志紧挨着,但服务日志应当先于消费者的 apply 日志。


九、第一次调用实验:证明工具真的调用了服务

在新会话中输入:

复制代码
请调用 lesson_number_stats,参数严格使用:
{"values":[10,12,14]}

只调用一次,不要自行计算,不要重试。

预期工具结果:

复制代码
{
  "count": 3,
  "mean": 12
}

终端中预期看到:

复制代码
[number-stats-tool] execute #1 start
[number-stats-service] calculate #1 start
[number-stats-service] calculate #1 success
[number-stats-tool] execute #1 success

这组日志非常重要。它证明了真实调用路径:

复制代码
工具 execute
    ↓
stats.calculate
    ↓
统计服务 calculate
    ↓
结果返回工具

如果只是模型自己回答"平均值是 12",但没有实际工具调用记录和以上日志,就不能证明插件协作成功。


十、第二次实验:观察错误属于哪一层

实验 A:工具接口层错误

让模型严格发出多余字段:

复制代码
请调用 lesson_number_stats,参数必须严格为:
{"values":[1,2],"maxItems":10}

不要修改参数,不要重试。

如果这个参数确实进入了我们的 execute(),预期日志是:

复制代码
[number-stats-tool] execute #2 start
[number-stats-tool] execute #2 failed: 工具参数错误:参数必须是只包含 values 字段的对象

此时不应出现新的:

复制代码
[number-stats-service] calculate

这说明调用在工具接口层就被拦截了,还没有进入业务服务。

不过模型可能遵守工具 Schema,主动删除 maxItems,也可能拒绝发起非法调用。此时这个实验没有真正进入我们的代码,不能把模型的自然语言回答当作测试结果。

实验 B:服务业务层错误

输入:

复制代码
请调用 lesson_number_stats,参数严格使用:
{"values":[1,2,3,4,5,6]}

只调用一次,不要修改参数,不要重试。

因为参数对象结构合法,所以会进入服务;但数组有六项,超过服务配置的 maxItems: 5

预期日志:

复制代码
[number-stats-tool] execute #3 start
[number-stats-service] calculate #2 start
[number-stats-service] calculate #2 failed: 业务输入错误:values 数量应为 1 到 5,实际为 6
[number-stats-tool] execute #3 failed: 业务输入错误:values 数量应为 1 到 5,实际为 6

这次证明:

  1. 工具接口层接受了参数结构。
  2. 请求进入统计服务。
  3. 服务执行了业务规则校验。
  4. 服务错误沿调用链返回工具。

这就是正确的分层:

负责什么
Tool 插件 模型工具名称、Schema、调用取消、工具参数外壳
Service 插件 数量限制、数字合法性、统计计算等业务规则
Harness/Cordis 服务发现、依赖等待、加载和卸载

然后再执行一次合法请求:

复制代码
请调用 lesson_number_stats,参数严格使用:
{"values":[-2,0,8]}

只调用一次。

预期:

复制代码
{
  "count": 3,
  "mean": 2
}

这可以证明一次业务错误不会破坏整个服务实例。


十一、最关键的依赖缺失实验

现在我们主动让服务提供者失效,观察消费者是否还能启动。

先结束 Harness:

复制代码
Ctrl+C

将配置改为:

复制代码
- insert:
    - id: lesson-number-stats-tool
      name: 'file:///D:/DeepSeek/plugins/number-stats/tool.mjs'

    - id: lesson-number-stats-service
      name: 'file:///D:/DeepSeek/plugins/number-stats/service.mjs'
      disabled: true
      config:
        maxItems: 5

disablednameconfig 处于同一级。它会禁用这条 Loader entry,而不是删除文件。Cordis 配置教程

重新启动:

复制代码
npx.cmd @deepseek-ai/dsh@0.1.1-rc.2 --profile web --patch "D:\DeepSeek\plugins\number-stats\cordis.patch.yml"

预期现象:

  • Web 仍然可以启动。
  • 不会出现服务的 provided 日志。
  • 不会出现工具插件的 apply 日志。
  • 不会出现工具插件的 registered 日志。
  • lesson_number_stats 不应被注册到当前运行环境。

为什么工具插件没有直接报错?

因为它声明了:

复制代码
export const inject = [
  'tools',
  'lesson.numberStats'
];

必需服务不存在时,Cordis 不执行这个消费者的 apply(),而是让它等待依赖。这比让插件执行到下面这句才发生空指针错误安全得多:

复制代码
ctx[SERVICE_KEY].calculate(...)

测试时请创建新会话,避免把旧会话中曾经显示过的工具记录误认为当前工具仍然存在。

测试完成后,删除:

复制代码
disabled: true

重新启动,确认三条启动日志恢复。

这个实验验证的是:

应用启动时依赖缺失,消费者保持等待。

它还没有验证"同一运行进程中服务突然消失再恢复"的动态行为。动态卸载和重新加载将在后续学习热重载与配置树时专门实验,不能把重启实验说成动态卸载实验。


十二、生命周期如何贯穿两个插件

当两个插件正常运行时:

  • service.mjs 拥有 lesson.numberStats 服务。
  • tool.mjs 依赖这个服务。
  • tool.mjs 还拥有 lesson_number_stats 工具注册。
  • 两个插件各自拥有自己的 ctx.effect() 清理函数。

按一次 Ctrl+C 后,会看到类似:

复制代码
[number-stats-tool] cleanup toolCalls=...
[number-stats-service] cleanup maxItems=5, attempts=...

但不要依赖这两条日志之间的绝对先后顺序。跨插件清理涉及各自 Fiber 和依赖关系,不应把当前观察到的打印顺序当成永久 API 契约。

真正可以依赖的是:

  • 通过 ctx.provide() 注册的服务归提供者生命周期所有。
  • 通过 ctx.tools.register() 注册的工具归消费者生命周期所有。
  • 通过 ctx.effect() 注册的清理函数会在对应作用域销毁时运行。
  • 必需服务不可用时,消费者不能保持正常 ACTIVE 状态。

这正是 Harness 插件生命周期机制的价值。官方插件生命周期文档

十三、为什么本课没有使用 Service

官方教程还提供了继承 Service 类的写法,适合:

  • TypeScript 项目;
  • 正式发布的 npm 插件包;
  • 需要声明完整类型接口;
  • 需要更规范的服务封装。

本课使用:

复制代码
ctx.provide(name, value);

是因为我们当前正在编写直接加载的本地 .mjs 文件。它能最直接地展现依赖注入的核心机制,又不需要先引入构建系统和包发布。

两种方式不是两套架构:

  • Service 类是更结构化的服务定义方式。
  • ctx.provide() 是直接提供命名服务的底层方式。

后面进入 TypeScript 插件工程时,我们再把这个服务升级为正式的类型化 Service。

十四、本课真正需要内化的结论

不要只记住代码,要记住这条架构原则:

Plugin 是部署和生命周期单元,Service 是插件之间的能力契约,Tool 是能力面向模型的适配接口。

再进一步:

消费者不应该依赖"由哪个文件实现",而应该依赖"运行时需要什么能力"。

这就是:

复制代码
import concreteImplementation

向:

复制代码
inject capabilityName

的转变。

本课通过标准

完成下面六项,才算真正学完:

  • 两个文件均通过 node --check
  • 启动时服务日志先于工具插件 apply 日志。
  • 合法调用得到 count: 3, mean: 12
  • 工具日志与服务日志能组成完整调用链。
  • 超过五个数字时,错误确实发生在服务层。
  • 禁用服务后,消费者不再执行 apply();恢复服务后重新注册工具。

我给出的两份模块已经完成了本地模块加载和模拟上下文逻辑验证;但真实 Cordis 调度、Windows Loader、Web 界面、模型调用以及运行时卸载,仍应以你电脑上的上述实验结果为准。

相关推荐
会周易的程序员1 小时前
5Draft使用说明书
服务器·c++·分布式·raft·共识
Shadow(⊙o⊙)1 小时前
Linux网络——IP协议 子网 路由 IP的分片字段
服务器·网络·tcp/ip
wangqiaowq1 小时前
langfuse linux下的安装部署
linux·运维·服务器
weixin199701080161 小时前
[特殊字符]《闲鱼 + 淘宝 + 1688 三平台库存同源:二手ERP主数据治理与超卖防御》(附Python源码)
开发语言·python
ltqshs1 小时前
C语言-链表例程
c语言·开发语言·链表
shmily麻瓜小菜鸡1 小时前
JavaScript / TypeScript 易踩坑知识点 —— 作用域与变量类
开发语言·javascript·typescript
YYYing.1 小时前
【C++进阶系列 (一)】关于线程堆栈的那些事 (上篇)
c语言·开发语言·c++·线程堆栈
zh73141 小时前
Go 1.23 → 1.26 升级检查文档
开发语言·chrome·golang
许彰午1 小时前
24-MyBatisHelper与autoCount
java·低代码·架构