如何用 defineTool 给 DeepSeek-Harness 添加自定义工具
一份逐字段拆解 defineTool 的指南——parameters、output.schema、output.render、execute,以及它需要的 inject 依赖,还有 Code Mode 为何不改变你写工具的方式。
一个 DeepSeek-Harness(dsh)工具,是在插件的 apply(ctx) 里调用 ctx.tools.register(defineTool({...})) 注册出来的,defineTool 来自 @deepseek-ai/dsh-tools。本文会逐一拆解 defineTool 接收的每一个字段——parameters、output.schema、output.render、execute——以及你要调用它就必须具备的那一个依赖(inject: ['tools'])。
最简单的工具
下面是 dsh 官方工具教程自己给出的例子,原样照抄:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
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 `Hello, ${args.name}!`
},
}))
}
这里发生了五件事:插件声明它需要 tools 服务(inject);服务就绪后拿到 ctx;调用 ctx.tools.register(),传入 defineTool 构造出的对象;而 defineTool 本身把名字、描述、输入形态、输出形态和具体实现组合到了一起。本文接下来会逐一展开这些部分。
inject: ['tools']——为什么它不是可选项
ctx.tools 在 tools 服务加载之前是不存在的。声明 inject: ['tools'] 会同时告诉 Cordis 框架两件事:在这个服务就绪之前不要调用 apply;如果这个服务后来消失了,就自动卸载这个插件。跳过这条声明,你的插件可能会在 ctx.tools 存在之前就被加载,具体取决于加载顺序——inject 把这种依赖关系明确写出来,而不是靠假设,从而消除了这种竞态。如果你想了解被注入的依赖是如何驱动插件生命周期状态机的,参见《DeepSeek-Harness 中的服务、依赖注入与插件生命周期》。
name 和 description
都是普通字符串。name 是模型看到的可调用函数名;description 决定模型什么时候会想到用它。两者除了"必须存在"之外没有额外校验——但它们都会直接进入 prompt 组装流程,所以要把 description 当成决定你的工具会不会被调用的那一句话来对待。
parameters——输入形态
parameters 里的每一个键都是一个参数,用 type、是否 required、以及一句人类可读的 description 来描述:
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
defineTool 用这个形状在你的 execute 函数真正运行之前先校验传入的参数——缺少必填参数或者类型不对的调用根本到不了你的代码里。再加一个可选参数,遵循完全一样的形状:
parameters: {
text: { type: 'string', required: true, description: 'The text to count words in' },
caseSensitive: { type: 'boolean', required: false, description: 'Whether casing affects the count' },
},
工具教程自己的示例只展示了一个必填字符串参数,所以对于超出这个基本 type / required / description 形状之外的东西——嵌套对象、数组、枚举——应该把它当成一个需要去查更深入参考资料的问题,而不是凭空猜测:工具教程自己指向的进阶 cookbook 是 docs/cookbook/adding-a-tool.md,涵盖更高级的参数与 schema 模式。
output.schema 与 output.render——两件不同的事
这是很多人第一次读容易理解错的地方,因为它看起来像一件事,实际上是两件:
output.schema描述你的execute函数返回的规范值——也就是原始数据形态,和它如何被展示无关。output.render是一个函数(args, value) => content[],把那个规范值转换成模型实际在上下文里看到的内容块。
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
在最简单的例子里两者都很trivial——规范值就是那个字符串,渲染它也只是把它包进一个 { type: 'text', text: value } 块里。当一个工具的真实输出是需要特定文本呈现方式的结构化数据(对象、列表)时,或者——按上面提到的同一份 cookbook 参考——需要 UI 卡片这类更丰富的内容时,这个拆分才真正体现出价值;工具教程把 schema/render 拆分确立为机制本身,但我们审阅的范围没有覆盖它支持的每一种内容块类型。
execute——真正干活的地方
async execute(args) {
return `Hello, ${args.name}!`
},
args 到达时已经是根据 parameters 校验过的——等你的代码运行时,必填字段已经存在且类型正确。execute 是 async 的,所以从简单的字符串变换到一次 HTTP 调用或读文件,都只是一个 await 的距离。返回值必须匹配 output.schema,因为这正是 output.render 接下来会收到的值。
一个完整的第二例子
把这四个字段组合起来,做一个稍微不那么trivial的工具——统计一段文本里的单词数:
import { defineTool } from '@deepseek-ai/dsh-tools'
ctx.tools.register(defineTool({
name: 'word-count',
description: 'Count the words in a piece of text.',
parameters: {
text: { type: 'string', required: true, description: 'The text to count' },
},
output: {
schema: { type: 'number' },
render: (_args, value) => [{ type: 'text', text: `${value} words` }],
},
async execute(args) {
return args.text.trim().split(/\s+/).filter(Boolean).length
},
}))
execute 返回一个匹配 output.schema 的纯数字;output.render 把这个数字变成一句模型能读回来的、作为工具输出的话。
命名——哪些有文档,哪些没有
工具教程并没有对 defineTool 的 name 给出一套通用命名规范,除了"取一个字符串"之外。dsh 明确写在文档里的那条命名规则,适用于一个相关但不同的场景:从 MCP server 桥接进来的工具会被命名为 mcp__<serverName>__<rawName>,规范化到 64 字符以内且只含 [A-Za-z0-9_-],两个名字冲突时会加一个哈希后缀——参见《如何在 DeepSeek-Harness 中使用 MCP Server》。这条相同的"字符集+长度"约束是否也适用于一个普通的 defineTool 名字,我们审阅的文档里没有明确写出来,所以如果你要取一个不寻常的名字,最好自己去核对 docs/user/develop/basic/tool.md,不要凭假设行事。
Code Mode 与你的工具
dsh 支持一个 DSH_TOOLS_MODE 环境变量,有三个取值:native(普通的 function calling)、code("Code Mode",模型写代码去调用你的工具,而不是发起原生的 function call)、以及 both。这个设置改变的是模型调用工具的方式,而不是你定义工具的方式——一个用 defineTool 注册的工具,不管部署跑在哪种模式下,工作方式都是一样的,因为"写代码调用这个工具"和"原生 function call 调用这个工具"之间的翻译发生在 defineTool 这一层之下。
FAQ
我需要自己为 parameters 手写 JSON Schema 吗?
不需要——defineTool 的 parameters 对象用的是它自己的简写形式(每个字段 type、required、description),而不是原始 JSON Schema。defineTool 会根据这个简写自动为你推导出参数校验逻辑。
缺少必填参数时会发生什么?
execute 根本不会运行。针对 parameters 的校验发生在你的函数被调用之前,所以你不需要自己手动检查 args 里的必填字段是否存在。
execute 可以抛出错误吗?
工具教程没有为 execute 记录具体的错误处理约定,本文也不会凭空发明一个——如果你需要重试、超时或者带审批门槛的行为,官方文档给出的那个指向是 cookbook(docs/cookbook/adding-a-tool.md)。
output.render 是必需的吗,能不能直接返回一个字符串跳过它?
有文档记录的形态里,schema 总是和 render 成对出现——官方示例里没有展示过省略其中一个的捷径。
下一步
如果你还没搭好周边的插件,先从《从零构建一个 DeepSeek-Harness 插件》开始。工具跑通之后,用《用 Schemastery 让 DeepSeek-Harness 插件可配置》让它的行为可配置,而不是把值硬编码进代码里。想看一个真实的、高 star 的工具插件长什么样,可以浏览 modlens,或者 FindHarness 上更完整的 Tools & Capabilities(工具与能力)分类。