DeepSeek Harness 系列(03):工具系统——给 Agent 装上手

为什么工具是 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

使用它需要在插件中声明依赖:

typescript 复制代码
// 插件声明
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 辅助函数,它在编译期做类型推断,并在运行时自动校验参数和返回值。

一个最小示例:

typescript 复制代码
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 干了三件事:

  1. parameters 声明推断 args 的 TypeScript 类型------不需要手写类型标注
  2. 把参数 Schema DSL 编译成 JSON Schema------自动发送给模型
  3. 运行时校验:参数不符合 Schema 抛 ToolArgsError,返回值不符合 output.schemaToolOutputError

Schema DSL 类型系统

dsh 的 Schema DSL(ValueSchemaSpec)支持以下节点类型:

typescript 复制代码
// 字符串
{ 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 收到模型的工具调用请求,它会走如下流水线:

sql 复制代码
模型请求 "调用 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(执行前)

这里决定一个工具调用允不允许运行。监听器返回三种决策:

typescript 复制代码
type PreToolDecision =
  | { kind: 'allow' }               // 允许执行
  | { kind: 'deny'; reason: string } // 拒绝,并告知原因(传给模型)
  | { kind: 'ask'; reason?: string } // 暂停,请求用户审批

ask 是 dsh 权限系统的核心。当监听器返回 ask,dsh 会调用 ctx.get('approval') 服务,等用户确认后才继续。如果没有审批服务,ask 自动变为 deny

一个常见用例:给 shell 命令工具加权限拦截:

typescript 复制代码
// 在插件里监听 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(环绕分派)

这是工具函数体真正运行的地方。监听器套在工具执行外面,适合做:

typescript 复制代码
// 添加超时控制
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(执行后)

工具执行完成后,这里决定把什么结果给模型

typescript 复制代码
type PostToolDecision =
  | { kind: 'accept' }                              // 直接接受原始结果
  | { kind: 'accept'; content: ContentBlock[] }     // 替换展示内容(保留规范值)
  | { kind: 'accept'; value: JsonValue }            // 替换规范值(重新渲染内容)
  | { kind: 'block'; feedback: ContentBlock[] }     // 改为错误结果,附上纠正反馈

block 适合做输出校验------如果工具返回了不符合预期的内容,拦截并让模型重试:

typescript 复制代码
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 仍然可以最终否决它:

typescript 复制代码
// 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 作用域级别过滤工具:

typescript 复制代码
// 只允许文件读取工具
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()

作用域中,allowdeny 取交集:如果有多个 restrict 调用,只有所有规则都允许的工具才能见到模型。

作用域自身注册的工具不受限制影响------子 Agent 注册的私有工具始终对自己可见。


并行工具调用

模型有时会在一条消息中请求多个工具调用。dsh 支持并行执行,但需要工具明确声明自己是并发安全的:

typescript 复制代码
const safeReadTool = defineTool({
  name: 'read_file',
  // ...
  // 只读操作,可以和其他调用并行
  isConcurrencySafe: (args) => true,
  execute: async (args, exec) => {
    return fs.readFile(args.path, 'utf-8')
  },
})

没有声明 isConcurrencySafe 或返回非 true 的工具,默认以独占(exclusive)模式运行------一次只跑一个,前一个完成后才开始下一个。

这个设计是防御性的:宁可串行慢点,也不让状态竞争。声明并发安全是工具作者的承诺,不是框架帮你猜的。


实战:从零写一个天气查询工具

把上面所有概念串起来,写一个完整的自定义工具:

typescript 复制代码
// 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 配置:

yaml 复制代码
# dsh.config.yml(或你的 profile)
plugins:
  - id: tool-weather
    # 指向你的包路径

就这些。工具注册后:

  1. ctx.tools.schemas() 会自动包含 get_weather 的 JSON Schema,模型能看到它
  2. 模型调用时自动经过完整三阶段流水线
  3. 返回值自动校验、渲染、记入 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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页

相关推荐
AI 思录2 小时前
Prompt 事故档案(八):日常表达被标为“待校准”,AI 的爹味语法从哪里来
大数据·人工智能·算法·prompt·用户体验·ai合规
冬奇Lab2 小时前
一天一个开源项目(第214篇):AstronRPA —— 科大讯飞开源的企业级 RPA + AI Agent 自动化平台
人工智能·开源·资讯
月光船幽幽2 小时前
跨范式映射的稳定接口设计
人工智能·python·算法
今日热点2 小时前
2026 企业微信主体变更公证书办理方法与私域资产保全指南:活码存档权限全规范
人工智能·企业微信
Dawson Zhu2 小时前
AI 辅助调试陷入「反复修改」循环?用证据驱动的四步法破局
人工智能·语言模型·架构·aigc·agi
信誓旦旦的程序猿2 小时前
【量化系统从零构建 #04】存储设计:选型·建库·交易日历
java·人工智能·python·股票数据api·股票数据·股票数据api接口·股票api数据接口
、如果2 小时前
PDF图片文字提取零依赖方案:pymupdf+AI视觉实战
人工智能·数据分析·pdf·图片提取·文字提取·skills
米小虾2 小时前
不偷权重,只问问题:蒸馏攻击是怎么把前沿模型"问"走的,以及什么真能挡住
人工智能
2601_960554472 小时前
会议录音一键生成纪要:实测告别手工整理
人工智能