为什么工具是 Agent 的核心
LLM 本身只能输出文字。Agent 之所以能订机票、写代码、查数据库,靠的是工具——LLM 决定调什么、传什么参数,工具真正去执行。
没有工具系统,Agent 和聊天机器人没有本质区别。
dsh 的工具系统不是一个简单的函数注册表。它承担了从"模型决定调哪个函数"到"结果安全地回到模型"这整条链路上的所有工程问题:
- 模型需要 JSON Schema 才能知道工具参数格式——dsh 从你的 TypeScript 类型自动生成
- 工具执行可能有风险——dsh 提供三层流水线拦截
- 有些工具操作不可逆——dsh 内置权限审批机制,危险操作暂停等用户确认
- 不同 Agent 需要不同工具子集——dsh 支持细粒度的 Scope 隔离
这篇文章把这些机制逐一讲清楚。
ctx.tools 是什么
ctx.tools 是一个 Cordis Service,类型是 ToolRuntime。它是工具系统的入口,负责:
- 注册:管理全局工具表,支持作用域(Scope)级别的工具覆盖
- Schema 投影:把内部定义投影成模型能看懂的
ToolSchema[] - 执行:驱动三阶段流水线,返回类型化的
ToolExecutionResult
使用它需要在插件中声明依赖:
// 插件声明
export const inject = ['tools']
export function apply(ctx: Context): void {
// 注册工具
ctx.tools.register(myTool)
// 查询当前 Scope 可见的 Schema(传给模型)
const schemas = ctx.tools.schemas()
}
插件卸载时,所有通过 ctx.tools.register 注册的工具会自动撤销——这是 Cordis Effect 机制的应用(见第二篇)。
defineTool:类型安全的工具定义
直接实现 ToolDefinition 接口是可以的,但 dsh 提供了更便捷的 defineTool 辅助函数,它在编译期做类型推断,并在运行时自动校验参数和返回值。
一个最小示例:
import { defineTool } from '@deepseek-ai/dsh-tools'
const greetTool = defineTool({
// 工具名(模型用这个名字调用)
name: 'greet_user',
// 工具描述(模型根据这个决定什么时候用)
description: 'Send a greeting to a user by name.',
// 参数 Schema:使用 dsh 的 DSL,不是原始 JSON Schema
parameters: {
name: {
type: 'string',
required: true, // required: true 表示必填
description: 'The user name to greet.',
},
formal: {
type: 'boolean',
description: 'Use formal greeting if true.',
// 没有 required: true = 可选参数
},
},
// 输出定义:声明返回值的 Schema 和如何渲染给模型
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
// 执行函数:args 类型由 parameters 自动推断
async execute(args, exec) {
// TypeScript 知道 args.name: string,args.formal?: boolean
const prefix = args.formal ? 'Good day' : 'Hello'
return `${prefix}, ${args.name}!`
},
})
defineTool 干了三件事:
- 从
parameters声明推断args的 TypeScript 类型——不需要手写类型标注 - 把参数 Schema DSL 编译成 JSON Schema——自动发送给模型
- 运行时校验:参数不符合 Schema 抛
ToolArgsError,返回值不符合output.schema抛ToolOutputError
Schema DSL 类型系统
dsh 的 Schema DSL(ValueSchemaSpec)支持以下节点类型:
// 字符串
{ type: 'string', enum?: string[], const?: string }
// 数字
{ type: 'number' }
{ type: 'integer' }
// 布尔
{ type: 'boolean' }
// 数组
{ type: 'array', items?: ValueSchemaSpec }
// 对象(必须声明 additionalProperties)
{ type: 'object', properties?: ParameterSchemaSpec, additionalProperties: boolean }
// 无约束 JSON(任意 JSON 值)
{ type: 'json' }
// 精确匹配一个分支
{ oneOf: [spec1, spec2, ...] }
为什么不直接用原始 JSON Schema? 原始 JSON Schema 太宽泛,很多关键字 dsh 并不支持。用 dsh DSL,编译时能确保你声明的约束是 dsh 真正会执行的约束——没有"写了但不生效"的死角。
类型推断深度到 16 层容器,超过后回退到 JsonValue——这个设计避免 TypeScript 类型实例化栈溢出。
三阶段执行流水线
这是工具系统最重要的部分。当 Agent Loop 收到模型的工具调用请求,它会走如下流水线:
模型请求 "调用 shell_run(cmd='ls -la')"
│
▼
┌─────────────────┐
│ tools/pre-execute │ waterfall:allow / deny / ask
└─────────────────┘
│ allow(通过)
▼
┌─────────────────┐
│ Guards │ 单调拒绝检查(只能拒,不能放行)
└─────────────────┘
│ 通过
▼
┌─────────────────┐
│ tools/execute │ waterfall:around-dispatch(超时/重试/指标)
└─────────────────┘
│
▼ 工具 execute() 函数运行
│
┌─────────────────┐
│ tools/post-execute │ waterfall:accept / replace / block
└─────────────────┘
│
▼
┌─────────────────┐
│ finalizeContent │ 工具自有的最终内容调整(可选)
└─────────────────┘
│
▼
┌─────────────────┐
│ tools/result │ emit:只读观察,用于日志/监控
└─────────────────┘
│
▼
返回 ToolExecutionResult 给 Agent Loop
三个核心阶段:
阶段一:tools/pre-execute(执行前)
这里决定一个工具调用允不允许运行。监听器返回三种决策:
type PreToolDecision =
| { kind: 'allow' } // 允许执行
| { kind: 'deny'; reason: string } // 拒绝,并告知原因(传给模型)
| { kind: 'ask'; reason?: string } // 暂停,请求用户审批
ask 是 dsh 权限系统的核心。当监听器返回 ask,dsh 会调用 ctx.get('approval') 服务,等用户确认后才继续。如果没有审批服务,ask 自动变为 deny。
一个常见用例:给 shell 命令工具加权限拦截:
// 在插件里监听 tools/pre-execute
ctx.on('tools/pre-execute', async (exec, next) => {
// 只拦截 shell_run 工具
if (exec.name !== 'shell_run') return next()
const cmd = (exec.arguments as { command: string }).command
// 包含危险命令的操作需要用户确认
if (cmd.includes('rm') || cmd.includes('sudo')) {
return { kind: 'ask', reason: `Will run: ${cmd}` }
}
return next() // 其他命令直接允许
})
注意:tools/pre-execute 是 waterfall,和第二篇讲的一样,监听器必须要么调用 next() 要么返回一个决策。不调用 next() 会截断后续所有监听器。
阶段二:tools/execute(环绕分派)
这是工具函数体真正运行的地方。监听器套在工具执行外面,适合做:
// 添加超时控制
ctx.on('tools/execute', async (exec, next) => {
const timer = new Promise<ToolExecutionResult>((_, reject) =>
setTimeout(() => reject(new Error('Tool timeout')), 30_000)
)
return Promise.race([next(), timer])
})
// 添加执行时间指标
ctx.on('tools/execute', async (exec, next) => {
const start = performance.now()
const result = await next()
console.log(`${exec.name} took ${performance.now() - start}ms`)
return result
})
监听器只能修改 exec.signal(取消信号),不能修改工具参数——参数此时已经记录在日志里,改了会导致日志和实际执行不一致。
阶段三:tools/post-execute(执行后)
工具执行完成后,这里决定把什么结果给模型:
type PostToolDecision =
| { kind: 'accept' } // 直接接受原始结果
| { kind: 'accept'; content: ContentBlock[] } // 替换展示内容(保留规范值)
| { kind: 'accept'; value: JsonValue } // 替换规范值(重新渲染内容)
| { kind: 'block'; feedback: ContentBlock[] } // 改为错误结果,附上纠正反馈
block 适合做输出校验——如果工具返回了不符合预期的内容,拦截并让模型重试:
ctx.on('tools/post-execute', async (exec, result, next) => {
if (exec.name === 'read_file' && !result.isError) {
// 文件内容太长,截断后告知模型
const content = result.value as string
if (content.length > 50_000) {
return {
kind: 'accept',
content: [{ type: 'text', text: `[Truncated] ${content.slice(0, 50_000)}...` }],
}
}
}
return next()
})
Guards:单调拒绝
ctx.tools.guard() 注册的 Guard 在 tools/pre-execute 之后、工具函数体之前运行。它只能拒绝,不能放行——即使之前的 waterfall 允许了某个调用,Guard 仍然可以最终否决它:
// Guard 只返回 string(拒绝原因)或 undefined(不干预)
ctx.tools.guard((exec) => {
// 禁止在任何工具调用中包含注入模式
const args = JSON.stringify(exec.arguments)
if (args.includes('ignore previous instructions')) {
return 'Potential prompt injection detected'
}
// 返回 undefined = 不干预
})
Guard 的单调性保证:一旦某个 Guard 拒绝了调用,后续任何 Guard 都无法撤销这个拒绝。这防止了恶意插件通过注册 Guard 给自己开后门。
作用域隔离(ToolRestriction)
不同的 Agent 任务可能需要不同的工具子集。比如代码审查 Agent 只需要只读文件工具,不应该能执行 shell 命令。
ctx.tools.restrict() 允许在 Agent 作用域级别过滤工具:
// 只允许文件读取工具
const disposer = ctx.tools.restrict({
allow: ['read_file', 'list_files', 'search_files'],
})
// 或者:禁用危险工具
const disposer = ctx.tools.restrict({
deny: ['shell_run', 'write_file', 'delete_file'],
})
// 清理时撤销限制
disposer()
作用域中,allow 和 deny 取交集:如果有多个 restrict 调用,只有所有规则都允许的工具才能见到模型。
作用域自身注册的工具不受限制影响——子 Agent 注册的私有工具始终对自己可见。
并行工具调用
模型有时会在一条消息中请求多个工具调用。dsh 支持并行执行,但需要工具明确声明自己是并发安全的:
const safeReadTool = defineTool({
name: 'read_file',
// ...
// 只读操作,可以和其他调用并行
isConcurrencySafe: (args) => true,
execute: async (args, exec) => {
return fs.readFile(args.path, 'utf-8')
},
})
没有声明 isConcurrencySafe 或返回非 true 的工具,默认以独占(exclusive)模式运行——一次只跑一个,前一个完成后才开始下一个。
这个设计是防御性的:宁可串行慢点,也不让状态竞争。声明并发安全是工具作者的承诺,不是框架帮你猜的。
实战:从零写一个天气查询工具
把上面所有概念串起来,写一个完整的自定义工具:
// packages/my-tools/src/weather.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-weather'
export const inject = ['tools']
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'get_weather',
description: 'Get current weather for a city. Returns temperature and conditions.',
parameters: {
city: {
type: 'string',
required: true,
description: 'City name, e.g. "Beijing" or "Shanghai".',
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description: 'Temperature unit. Defaults to celsius.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
city: { type: 'string' },
temperature: { type: 'number' },
unit: { type: 'string' },
condition: { type: 'string' },
},
},
// render 把结构化值转成模型看到的文字
render: (_args, value) => {
const v = value as { city: string; temperature: number; unit: string; condition: string }
return [{
type: 'text',
text: `Weather in ${v.city}: ${v.temperature}°${v.unit === 'celsius' ? 'C' : 'F'}, ${v.condition}`,
}]
},
},
// 只读操作,允许并行
isConcurrencySafe: () => true,
async execute(args, exec) {
const unit = args.unit ?? 'celsius'
// 真实场景这里调用天气 API
// exec.signal 用于响应取消
const response = await fetch(
`https://weather-api.example.com/current?city=${encodeURIComponent(args.city)}&unit=${unit}`,
{ signal: exec.signal },
)
if (!response.ok) {
throw new Error(`Weather API returned ${response.status}`)
}
const data = await response.json()
return {
city: args.city,
temperature: data.temperature,
unit,
condition: data.condition,
}
},
// 自定义 UI 展示:调用中显示什么
presentCall: (args) => ({
card: 'generic',
title: `Checking weather in ${(args as { city: string }).city}`,
kind: 'fetch',
}),
// 自定义 UI 展示:完成后显示什么
presentResult: (args, result) => ({
card: 'generic',
title: result.isError
? `Weather check failed`
: `Weather in ${(args as { city: string }).city}`,
}),
}))
}
注册到 dsh 配置:
# dsh.config.yml(或你的 profile)
plugins:
- id: tool-weather
# 指向你的包路径
就这些。工具注册后:
ctx.tools.schemas()会自动包含get_weather的 JSON Schema,模型能看到它- 模型调用时自动经过完整三阶段流水线
- 返回值自动校验、渲染、记入 Session 日志
架构设计总结
回顾一下 dsh 工具系统的整体设计哲学:
| 设计决策 | 背后的原因 |
|---|---|
| 参数在 pre-execute 后不可修改 | 日志、审计、UI 展示必须和执行保持一致 |
| Guard 只能拒绝,不能放行 | 防止后挂插件撤销安全策略 |
| output.render 是纯函数 | Session 回放时需要重现展示效果 |
| isConcurrencySafe 默认独占 | 防御性设计,状态安全优先于性能 |
| Tools 是 Cordis Service | 注册/注销的生命周期跟随插件,HMR 不泄露工具 |
| 工具 Schema 不泄露 execute/present 等回调 | 模型只看 name/description/parameters |
dsh 的工具系统设计有一个贯穿始终的原则:在工具调用的每个阶段,都有且只有一个清晰的扩展点,并且扩展点的职责边界明确——不能越界。
这就是为什么你能在生产环境放心地挂上权限拦截器,而不用担心它和某个内部逻辑冲突。
系列下一篇
工具系统解决了"Agent 能做什么"的问题。下一篇 Agent Loop 会讲"一次对话是怎么跑起来的"——模型输出、工具调用、循环终止的完整流程,以及你可以在哪些地方插入控制逻辑。
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页