用 defineTool 开发第一个可调用工具

约 30 分钟

用 defineTool 声明一个带输入 schema 的工具,并让模型在一次会话中实际调用该工具并返回结果。

上一节结束时,你卸载插件,看着 ctx.effect() 注册的清理逻辑随卸载自动 unwind、被一一调用。装配零件已经就位,但模型自己还只是输出文字,没有真正动过手。

模型只会输出文字。它不会查询天气、不会算时区,也不会写数据库。要让 Agent 真正做事,你得给它工具。

这一节只做一个目标:用 defineTool 声明一个带输入 schema 的工具,加载它,让模型在一次会话中真的调用它,并在 Trajectory 视图里看到这次调用和它的返回结果。

做完这一步,你手里就有一条最小链路:模型决定调用哪个工具,harness 负责执行并把结果回写进会话。插件、ctx.effect()、inject 这些装配零件在上一节已经装好,这一节只加一个新零件:tool。

核心概念

工具:模型可以点的菜

生活类比:把模型想成餐厅里的食客。食客不进后厨,只看菜单。defineTool 声明出的工具就是菜单上的一道菜:菜名告诉食客点什么,描述告诉他这道菜能做什么,点菜单(input schema)告诉他需要提供哪些配料。真正做菜的是后厨,也就是 harness;食客只负责“点”,不负责做。

技术定义:tool 是注册进工具注册表的一个具名能力,由 name、description、input schema 与 run 四部分组成。模型通过 API 读取当前会话可用的工具列表,根据 description 判断该调用哪个,按 input schema 生成参数;harness 校验参数后执行 run,把返回结果写回会话上下文,模型再基于结果继续推理。描述与 schema 决定模型能否选对、传对;结果回写决定后续回答是否连贯。工具注册表由 core/tools 提供,挂在 ctx.tools 上,官方 Build a tool 指南给出了完整示例。

工具不是写死在内核里的功能。官方架构文档说得很直接:没有需要打补丁的特权内核,你通过在别的插件旁边挂载一个新插件来扩展 dsh;所有注册都是效应,插件卸载时自动撤销。工具注册表挂在 ctx.tools 上,它带作用域,执行时走一条受守卫的流水线。

所以上一节的“可逆效应”在这里继续生效:插件卸载,工具从注册表消失,不留痕迹。这也是“一切皆插件”的直接体现——模型适配器、工具注册表、会话日志、Agent 循环本身都是插件,可以替换或重组,无需改动源码。

声明工具并让模型实际调用

  1. 1

    在你的插件工程里新建入口文件,沿用上一节结构:导出 name 与 apply(ctx)。在 apply 里调用 defineTool 声明工具,然后按官方 Build a tool 指南的方式把它注册进工具注册表。

    ts
    import { Context } from 'cordis';
    import { defineTool } from '@deepseek-ai/dsh';
    
    export const name = 'lesson-tools';
    
    export function apply(ctx: Context) {
      const getWeather = defineTool({
        name: 'get_city_weather',
        description: '查询指定城市的当前天气;当用户询问某城市天气时使用',
        input: {
          type: 'object',
          properties: {
            city: { type: 'string', description: '城市名' },
          },
          required: ['city'],
        },
        run: async ({ city }) => ({
          city,
          weather: 'sunny',
          temperature: 24,
        }),
      });
    
      ctx.tools.register(getWeather);
    }
  2. 2

    沿用上一节加载本地插件的方式,把 lesson-tools 挂进你的 profile 或 cordis.patch.yml,重启 dsh,确认启动输出里该插件被装载。

  3. 3

    启动 Web UI:运行 npx @deepseek-ai/dsh web,默认地址是 http://127.0.0.1:3080。若模型还没配好,在 Settings → Models 里添加 DeepSeek API 凭据并选择一个模型。

    sh
    npx @deepseek-ai/dsh web
  4. 4

    新建会话,选择已配置的模型,输入一句只有调用工具才能回答的话,例如“北京现在天气怎么样?” 发送后先不要看回答,想一步:模型应该先选工具、再作答。

  5. 5

    打开 Trajectory 视图,按来源筛选记录,定位模型发起的工具调用:工具名、传入参数 city、run 的返回结果,并确认模型最终回答引用了该结果。

你应该看到

会话轨迹中出现一次由模型发起的工具调用 get_city_weather,参数含 city,并带有返回结果;模型后续回答引用了该结果。

把刚才的会话在 Trajectory 视图里连起来看:模型读到系统提示词装配出的工具描述 → 判断该用 get_city_weather → 按 input schema 生成参数 → harness 校验并执行 run → 结果写回会话 → 模型引用结果组织回答。整个过程被追加进只读的会话日志,按来源可查。

如果链路断掉,断点也在轨迹里:没有工具调用记录,说明模型没选工具;有调用但没有结果,说明执行失败。轨迹不证明正确性,它让复现与排查成为可能。

遇到问题:会话里模型没有调用你的工具,而是直接给出了文字回答。

可能原因

  • 工具没有加载:启动输出里找不到 lesson-tools 插件。
  • description 太笼统,模型不知道什么时候该用它。
  • input schema 缺参数说明或 required,模型不敢生成参数。
  • 会话使用的模型或提供方不支持工具调用。

排查步骤

  1. 检查启动输出与插件加载方式,确认工具已注册进工具注册表。
  2. 把 description 改成明确的触发条件,例如“当用户询问某城市天气时使用”。
  3. 给每个参数补 description,并用 required 标出必填项。
  4. 在 Trajectory 视图里查看工具 schema 是否出现在系统提示词的装配区域;若无,说明工具没进会话。
  5. 换成支持工具调用的模型,或检查模型提供方配置后再试。
自检:模型决定“该调用哪个工具、传什么参数”时,依据的是工具定义里的哪些内容?你用什么证据确认这次调用真实发生?

依据是 description 与 input schema:description 让模型知道何时该用,input schema(参数类型、说明、required)让模型知道如何传参。确认证据在 Trajectory 视图:会话轨迹中出现由模型发起的工具调用记录、实际传入的参数以及返回结果,即达成预期。