在上一章中,我们创建了一个 greet 工具,它能根据传入的名字返回一句问候语。但有一个小问题:问候语本身是硬编码的。

看看 execute 函数里的代码:

async execute(args) {
    return `Hello, ${args.name}!`  // "Hello" 是写死的
}

如果用户想把问候语改成 "Hi" 或者 "Greetings",该怎么办?目前唯一的办法是修改源代码,然后重新启动。这对于一个插件来说显然不够灵活——不同的人、不同的场景,可能希望用不同的问候风格。

这一章,我们将解决这个问题。插件配置让用户可以在不修改代码的情况下,通过 cordis.yml 文件来调整插件的行为。我们将把问候语从硬编码变成可配置的,让用户自由定制。

更重要的是,配置机制不仅适用于问候语这种简单的场景。在实际项目中,API 密钥、超时时间、重试次数、功能开关——几乎所有的“可调参数”都应该通过配置来管理。这是让插件从“玩具”走向“生产级”的关键一步。

从硬编码到可配置

先来看一个对比。硬编码的方式是这样的:

// 硬编码:想改就得改代码
const GREETING = 'Hello'

可配置的方式是这样的:

// 可配置:用户可以在 cordis.yml 中自由设定
export interface Config {
    greeting: string
}

export const Config = Schema.object({
    greeting: Schema.string().default('Hello'),
})

export function apply(ctx: Context, config: Config) {
    // config.greeting 来自用户配置,或使用默认值 'Hello'
}

区别一目了然:硬编码把决定权留给了开发者,而可配置把决定权交给了用户。Harness 的设计哲学是——凡是不同部署可能需要不同值的参数,都应该设计为配置字段。

第一步:定义 Config 类型

打开 scratch-plugin/src/my-plugin.ts,在文件顶部添加 Config 接口的定义:

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export interface Config {
    greeting: string
    maxRetries: number
    verbose?: boolean
}

Config 接口定义了插件接受哪些配置项:

字段

类型

说明

greeting

string

问候语,比如 "Hello" 或 "Hi"

maxRetries

number

最大重试次数

verbose

boolean?

是否输出详细日志(可选,? 表示非必填)

给初中级开发者的提示:接口中的 ? 表示这个字段是可选的。用户如果没提供,框架也不会报错。但 greeting 和 maxRetries 没有 ?,表示它们是必填的——不过别担心,我们接下来会用 default() 给它们设置默认值,所以用户即使不填也能正常工作。

第二步:编写 Config Schema

有了类型定义,接下来需要编写一个同名的 Schema——注意,它和接口名字一样,都叫 Config:

export const Config: Schema = Schema.object({
    greeting: Schema.string().default('Hello'),
    maxRetries: Schema.number().default(3),
    verbose: Schema.boolean().default(false),
})

这段代码做了三件事:

  1. Schema.object({ ... }):声明配置是一个对象,包含多个字段。

  2. Schema.string().default('Hello'):greeting 必须是字符串,如果用户没提供,默认用 'Hello'。

  3. Schema.number().default(3) 和 Schema.boolean().default(false):同理,为 maxRetries 和 verbose 提供默认值。

这里有一个非常重要的概念需要理解:Config 接口和 Config Schema 是成对出现的。接口负责在 TypeScript 层面提供类型检查,Schema 负责在运行时校验用户传入的值。两者名字必须相同——Cordis 框架会通过导出的同名 Schema 来校验配置。

重要提示:不要导出一个普通对象作为 Config,因为它不满足 Cordis 要求的 Standard Schema 接口。必须使用 Schemastery 的 Schema.object() 来创建 Schema。

Schemastery 是 Cordis 框架内置的校验库。它支持的校验方法非常丰富:

方法

用途

示例

.required()

标记为必填

Schema.string().required()

.default(value)

设置默认值

Schema.number().default(3)

.min(n) / .max(n)

数值或长度范围

Schema.number().min(1).max(10)

.regex(/pattern/)

正则匹配

Schema.string().regex(/^[A-Z]/)

Schema.union([...])

枚举值限定

Schema.union(['fast', 'accurate'])

我们会在稍后的“Schema 校验”部分看到这些方法的实际应用。

第三步:在 apply 中接收配置

修改 apply 函数,让它接收 config 参数:

export function apply(ctx: Context, config: Config) {
    console.log(`[greet-tool] greeting configured as: "${config.greeting}"`)
    console.log(`[greet-tool] maxRetries: ${config.maxRetries}`)
    console.log(`[greet-tool] verbose: ${config.verbose}`)

    ctx.tools.register(
        defineTool({
            name: 'greet',
            description: 'Greet someone by name.',
            parameters: {
                name: {
                    type: 'string',
                    required: true,
                    description: 'The name to greet',
                },
            },
            output: {
                schema: { type: 'string' },
                render: (_args, value) => [{ type: 'text', text: value }],
            },
            async execute(args) {
                // 现在问候语来自配置,而不是硬编码!
                return `${config.greeting}, ${args.name}!`
            },
        })
    )
}

两个关键变化:

  1. apply 函数多了第二个参数 config: Config:框架在调用 apply 时,会把经过校验的配置对象传进来。

  2. execute 中的问候语变成了 ${config.greeting}:工具的执行逻辑现在可以访问插件配置了。

这里有一个重要的闭包现象:execute 函数定义在 apply 内部,所以它可以“捕获”外层的 config 变量。当工具被调用时,config 的值已经被固定下来了——就是插件加载时用户配置的那个值。

给初中级开发者的提示:这种“函数内部访问外部变量”的机制叫做闭包。在这个场景中,它让工具的执行逻辑能够访问到插件配置,非常实用且优雅。

第四步:在 cordis.yml 中传入配置

现在插件已经准备好接收配置了,我们需要在 cordis.yml 中把配置传进去。

打开 scratch-plugin/cordis.yml,修改为:

- insert:
    - id: hello
      name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/my-plugin.ts'
      config:
        greeting:  'Hi there'
        maxRetries: 5

config 下面的内容就是用户传入的配置值。这里我们把 greeting 设成了 'Hi there',把 maxRetries 设成了 5。verbose 没有提供,所以会使用默认值 false。

运行并验证

启动服务,看看配置是否生效:

pnpm dsh web --patch ./scratch-plugin/cordis.yml

在启动日志中,你应该能看到类似这样的输出:

[greet-tool] greeting configured as: "Hi there"
[greet-tool] maxRetries: 5
[greet-tool] verbose: false

然后打开 Web UI,在对话框中输入:

Use the greet tool to greet Ada.

这次模型会调用工具并返回 Hi there, Ada!——问候语已经变成了你配置的样子。

试着修改 cordis.yml 中的 greeting 值,重新启动,你会发现问候语随之改变,而插件的源代码一行都没动。这就是配置的力量。

Schema 校验:让配置更安全

目前我们的配置还比较宽松——greeting 可以是任何字符串,maxRetries 可以是任何数字。但在实际项目中,你可能希望对配置做更严格的限制。比如:

  • greeting 不能为空字符串。

  • maxRetries 必须在 1 到 10 之间。

  • mode 只能是 'fast' 或 'accurate' 二选一。

Schemastery 提供了丰富的校验方法来满足这些需求:

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'validated-plugin'

export interface Config {
    apiKey: string
    timeout: number
    mode: 'fast' | 'accurate'
}

export const Config = Schema.object({
    // 必填:用户必须提供 apiKey
    apiKey: Schema.string().required(),
    // 有默认值:用户不填就用 30000
    timeout: Schema.number().default(30000),
    // 枚举值:只能是 'fast' 或 'accurate'
    mode: Schema.union(['fast', 'accurate']).default('fast'),
})

export function apply(ctx: Context, config: Config) {
    // config 已经通过校验,类型安全,可以放心使用
}

校验发生在插件加载时。如果用户传入的配置不合法——比如 apiKey 没传,或者 mode 传了 'slow'——插件会加载失败,并给出明确的错误信息,而不是在运行时悄悄出问题。

这种“启动时校验”的设计理念是:配置错误要响亮。与其在运行时因为配置问题而行为异常,不如在启动时就大声报错,让问题尽早暴露、尽快修复。

设计原则:让配置成为“一等公民”

基于 Harness 的配置设计理念,这里总结两条重要的设计原则:

原则一:无硬编码可调参数

凡是不同部署可能需要采用不同值的参数,都必须定义为配置字段。

反例(硬编码):

// 错误:超时时间写死在代码里
const TIMEOUT = 30000

正例(可配置):

// 正确:超时时间由用户配置
export interface Config {
    timeoutMs: number  // 默认值 30000
}

检验标准很简单:这个值是否可以在 cordis.yml 中改变,而需要修改代码?如果答案是可以,那这个参数就应该被配置化。

原则二:配置错误要响亮

Schema 中应内建完备的约束,以便插件加载时,就能尽早暴露无效配置。而服务或已注册资源的引用,则统一走依赖注入。

反例(静默失败):

// 错误:配置不合法时悄悄使用默认值,用户毫不知情
const timeout = config.timeout || 30000

正例(响亮失败):

// 正确:Schema 校验不通过时,插件直接加载失败并报错
export const Config = Schema.object({
    timeout: Schema.number().min(1000).max(60000).default(30000)
})

配合 HMR

Harness 支持 HMR(Hot Module Replacement)——这意味着你修改 cordis.yml 配置后无需重启整个服务,框架会自动重新加载受影响的插件。

当你修改了某个插件的 config 后:

  1. 框架检测到配置变更。

  2. 卸载旧版本的插件实例(所有通过 ctx 注册的资源自动清理)。

  3. 用新配置加载新版本的插件实例。

整个过程对用户几乎是透明的。由于所有注册都属于 effect 并会自动清理,替换后不会残留旧实例的注册。你之前学到的“自动清理”机制在这里发挥了重要作用——它让 HMR 变得安全、可靠。

完整代码

以下是本章完成后的完整插件代码(scratch-plugin/src/my-plugin.ts):

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export interface Config {
    greeting: string
    maxRetries: number
    verbose?: boolean
}

export const Config = Schema.object({
    greeting: Schema.string().default('Hello'),
    maxRetries: Schema.number().default(3),
    verbose: Schema.boolean().default(false),
})

export function apply(ctx: Context, config: Config) {
    console.log(`[greet-tool] greeting configured as: "${config.greeting}"`)
    console.log(`[greet-tool] maxRetries: ${config.maxRetries}`)
    console.log(`[greet-tool] verbose: ${config.verbose}`)

    ctx.tools.register(
        defineTool({
            name: 'greet',
            description: 'Greet someone by name.',
            parameters: {
                name: {
                    type: 'string',
                    required: true,
                    description: 'The name to greet',
                },
            },
            output: {
                schema: { type: 'string' },
                render: (_args, value) => [{ type: 'text', text: value }],
            },
            async execute(args) {
                // 问候语来自配置,不再是硬编码
                return `${config.greeting}, ${args.name}!`
            },
        })
    )
}

对应的 cordis.yml:

- insert:
    - id: hello
      name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/my-plugin.ts'
      config:
        greeting:  'Hi there'
        maxRetries: 5

总结

本章我们完成了从“硬编码”到“可配置”的转变。核心收获如下:

概念

说明

Config 接口

TypeScript 类型定义,提供编译时的类型检查

Config Schema

Schemastery 校验规则,提供运行时的配置校验和默认值

apply(ctx, config)

插件入口函数接收配置对象作为第二个参数

cordis.yml 中的 config

用户传入配置值的地方

Schema.required()

标记字段为必填

Schema.default()

为字段设置默认值

Schema.union()

限制字段只能取枚举值

无硬编码原则

所有可调参数都应配置化

响亮失败原则

配置错误应在加载时暴露,而非运行时

HMR

配置变更自动触发插件重载,无需手动重启

你现在已经掌握了一个生产级插件必不可少的能力——配置管理。无论是 API 密钥、超时时间、功能开关还是业务参数,都可以通过配置来让用户自由定制。

下一章,我们将学习如何将你的插件打包成一个可分发的组合包(bundle),方便他人轻松安装和使用。