用 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
在你的插件工程里新建入口文件,沿用上一节结构:导出 name 与 apply(ctx)。在 apply 里调用 defineTool 声明工具,然后按官方 Build a tool 指南的方式把它注册进工具注册表。
tsimport { 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
沿用上一节加载本地插件的方式,把 lesson-tools 挂进你的 profile 或 cordis.patch.yml,重启 dsh,确认启动输出里该插件被装载。
- 3
启动 Web UI:运行 npx @deepseek-ai/dsh web,默认地址是 http://127.0.0.1:3080。若模型还没配好,在 Settings → Models 里添加 DeepSeek API 凭据并选择一个模型。
shnpx @deepseek-ai/dsh web - 4
新建会话,选择已配置的模型,输入一句只有调用工具才能回答的话,例如“北京现在天气怎么样?” 发送后先不要看回答,想一步:模型应该先选工具、再作答。
- 5
打开 Trajectory 视图,按来源筛选记录,定位模型发起的工具调用:工具名、传入参数 city、run 的返回结果,并确认模型最终回答引用了该结果。
你应该看到
会话轨迹中出现一次由模型发起的工具调用 get_city_weather,参数含 city,并带有返回结果;模型后续回答引用了该结果。
把刚才的会话在 Trajectory 视图里连起来看:模型读到系统提示词装配出的工具描述 → 判断该用 get_city_weather → 按 input schema 生成参数 → harness 校验并执行 run → 结果写回会话 → 模型引用结果组织回答。整个过程被追加进只读的会话日志,按来源可查。
如果链路断掉,断点也在轨迹里:没有工具调用记录,说明模型没选工具;有调用但没有结果,说明执行失败。轨迹不证明正确性,它让复现与排查成为可能。
description 要写清触发条件与行为,例如“当用户询问某城市天气时使用”,模型才会在合适时机调用。input schema 里给每个参数写 description,并用 required 标出必填项。参数名用可读的 snake_case。优先写只读、无副作用的工具,这类工具更容易被安全地自动调用。工具执行受权限门禁约束,涉及副作用时保持最小权限,不要把凭据写进返回结果。
本课的 get_city_weather 只返回固定数据,没有任何副作用,这是刻意的最小示例。给真实环境写工具时,把副作用(写文件、执行命令、访问外部服务)留给权限门禁约束的流水线,工具自身只做参数校验与结果报告。凭据不写进返回结果,调用过程留在会话日志里,可审计。不要用本机环境对未授权的系统做任何测试。
遇到问题:会话里模型没有调用你的工具,而是直接给出了文字回答。
可能原因
- 工具没有加载:启动输出里找不到 lesson-tools 插件。
- description 太笼统,模型不知道什么时候该用它。
- input schema 缺参数说明或 required,模型不敢生成参数。
- 会话使用的模型或提供方不支持工具调用。
排查步骤
- 检查启动输出与插件加载方式,确认工具已注册进工具注册表。
- 把 description 改成明确的触发条件,例如“当用户询问某城市天气时使用”。
- 给每个参数补 description,并用 required 标出必填项。
- 在 Trajectory 视图里查看工具 schema 是否出现在系统提示词的装配区域;若无,说明工具没进会话。
- 换成支持工具调用的模型,或检查模型提供方配置后再试。
自检:模型决定“该调用哪个工具、传什么参数”时,依据的是工具定义里的哪些内容?你用什么证据确认这次调用真实发生?
依据是 description 与 input schema:description 让模型知道何时该用,input schema(参数类型、说明、required)让模型知道如何传参。确认证据在 Trajectory 视图:会话轨迹中出现由模型发起的工具调用记录、实际传入的参数以及返回结果,即达成预期。