第五课的 number-stats 插件同时做了两件事:
- 向模型注册
lesson_number_stats工具。 - 完成数字校验、求和与平均值计算。
本课要把它拆成两个插件:
最终调用链是:
模型调用工具 → 工具插件调用统计服务 → 统计服务完成业务计算 → 结果原路返回模型。
一、本课必须掌握的五个概念
| 概念 | 本课中的实例 | 准确定义 |
|---|---|---|
| Plugin | service.mjs、tool.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.tools、ctx.llm、ctx.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'
];
它表示当前插件有两个必需依赖:
- Harness 内置的
tools服务。 - 我们自己定义的
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
这三条日志的相对顺序表达的是:
- 服务插件执行了
ctx.provide()。 - 服务插件进入可供依赖者使用的状态。
- 工具插件的依赖得到满足。
- Harness 才执行工具插件的
apply()。 - 工具随后注册成功。
它们不一定和其他 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
这次证明:
- 工具接口层接受了参数结构。
- 请求进入统计服务。
- 服务执行了业务规则校验。
- 服务错误沿调用链返回工具。
这就是正确的分层:
| 层 | 负责什么 |
|---|---|
| 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
disabled 与 name、config 处于同一级。它会禁用这条 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 插件配置与生命周期
副标题:
配置注入、调用参数、状态保持与资源清理
核心内容包括 config 与 args 的区别、apply 与 execute 的执行时机、闭包状态、配置生效机制,以及 ctx.effect 的卸载清理。
上边第五课学完之后应该学什么?
第五课学完后,下一课应该是:
第六课:DeepSeek Harness 服务与依赖注入
副标题:
从单个插件走向多个插件协作
第五课研究的是"一个插件内部如何接收配置、注册工具、保存状态和完成清理"。第六课则要把现有的 number-stats 拆成两个插件:
number-stats-service:只负责统计计算,对外提供numberStats服务。number-stats-tool:依赖numberStats和 Harness 的tools服务,把业务能力注册成模型可调用的工具。
由此形成:
模型调用工具 → 工具插件调用服务 → 服务插件完成业务计算 → 工具返回结果
这一课要真正学会:
- Service、Provider、Consumer、Tool、Plugin 分别是什么。
ctx.provide()如何提供服务。inject如何声明必需依赖。- 为什么 YAML 中插件的先后顺序不等于加载顺序。
- 服务不存在时,消费者为什么停留在
PENDING。 - 服务卸载后,依赖它的工具插件为什么也会自动卸载。
- 为什么业务逻辑适合放在 Service 中,而模型接口适合放在 Tool 插件中。
虽然前面已经概念性讲过 Service 和 inject,但第六课会第一次把它们拆成两个真实插件进行完整实验。官方也把 Service 定义为"一个插件提供、其他插件通过 ctx 消费的具名能力",并由依赖关系决定插件何时加载。官方 Services 教程
第六课完成后,你就能真正理解 DeepSeek Harness 的模块化基础:
插件是部署和生命周期单元,Service 是插件之间的能力接口,Tool 是暴露给模型的调用接口。
第七课再继续学习插件事件系统,理解插件之间不通过直接服务调用时,怎样使用事件进行解耦通信。
按照你上边说的开始带我学习,开讲
第六课:DeepSeek Harness 服务与依赖注入
副标题:从"一个插件完成所有工作"走向"多个插件按能力协作"。
第五课的 number-stats 插件同时做了两件事:
- 向模型注册
lesson_number_stats工具。 - 完成数字校验、求和与平均值计算。
本课要把它拆成两个插件:
最终调用链是:
模型调用工具 → 工具插件调用统计服务 → 统计服务完成业务计算 → 结果原路返回模型。
一、本课必须掌握的五个概念
| 概念 | 本课中的实例 | 准确定义 |
|---|---|---|
| Plugin | service.mjs、tool.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.tools、ctx.llm、ctx.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'
];
它表示当前插件有两个必需依赖:
- Harness 内置的
tools服务。 - 我们自己定义的
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
这三条日志的相对顺序表达的是:
- 服务插件执行了
ctx.provide()。 - 服务插件进入可供依赖者使用的状态。
- 工具插件的依赖得到满足。
- Harness 才执行工具插件的
apply()。 - 工具随后注册成功。
它们不一定和其他 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
这次证明:
- 工具接口层接受了参数结构。
- 请求进入统计服务。
- 服务执行了业务规则校验。
- 服务错误沿调用链返回工具。
这就是正确的分层:
| 层 | 负责什么 |
|---|---|
| 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
disabled 与 name、config 处于同一级。它会禁用这条 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 界面、模型调用以及运行时卸载,仍应以你电脑上的上述实验结果为准。