03 - 设计工具注册系统

骨架阶段的 ToolService 是一张裸 Map:能注册、能执行,但没有校验、没有权限、没有超时。本章把它升级为生产级工具系统——defineTool 风格的 DSL、四阶段执行流水线、分级权限门禁与审批回调、错误处理约定、description 质量工程,以及工具测试策略。完成后的系统在能力上覆盖了 dsh defineTool 的核心子集,且每个设计决定都能对照 dsh 的实现找到出处。

前置阅读:用 Cordis 搭建 Agent 框架defineTool 工具开发
相关章节:事件系统详解设计安全审计机制


1. 从裸 Map 到 defineTool DSL

1.1 五字段接口定义

dsh 的 defineTool 有五个核心字段,各司其职:name/description/parameters 给模型看,output.render 给人看,execute 干活。我们把这个契约原样搬进自己的框架:

// src/tool/types.ts —— 工具描述符类型契约
import { z } from 'zod'
 
// 工具风险等级:权限系统的基石,详见第 3 节
export type RiskLevel = 'read-only' | 'write' | 'network' | 'destructive'
 
// 渲染结果:同一份数据,给模型的文本形态和给人的展示形态分离
export interface ToolOutput<T = unknown> {
  canonical: T                    // 结构化真值:程序消费、可测试、可入库
  render: string                  // 文本渲染:回填给模型(以及简单场景下给人看)
}
 
// 完整工具描述符:五个字段一个不能少
export interface ToolDefinition<S extends z.ZodTypeAny = z.ZodTypeAny> {
  name: string                                    // 调用标识,snake_case
  description: string                             // 模型的说明书,质量工程见第 6 节
  parameters: S                                   // zod schema,同时承担校验与 JSON Schema 导出
  risk?: RiskLevel                                // 缺省视为 read-only,从严原则
  execute: (args: z.infer<S>) => Promise<ToolOutput>
}
 
export type RegisteredTool = ToolDefinition & {
  schemaJson: Record<string, unknown>             // 预编译的 JSON Schema,注册时算好
}

选择 zod 做参数 schema 是务实之举:一份声明同时得到运行时校验和(借助 zod-to-json-schema)给模型看的 JSON Schema,避免 dsh 中 schema 与校验逻辑双写的问题。

1.2 defineTool 工厂函数

// src/tool/defineTool.ts —— DSL 入口:注册期就把错误拦住
import { zodToJsonSchema } from 'zod-to-json-schema'
import type { ToolDefinition, RegisteredTool } from './types'
 
export function defineTool<S extends z.ZodTypeAny>(def: ToolDefinition<S>): RegisteredTool {
  // 注册期校验:名字格式不对、schema 导出失败,都在挂载时炸出来,
  // 而不是等模型调用到这个工具时才在运行时暴露
  if (!/^[a-z][a-z0-9_]*$/.test(def.name)) {
    throw new Error(`工具名 ${def.name} 不合法:须为 snake_case`)
  }
  if (def.description.length < 10) {
    throw new Error(`工具 ${def.name} 的 description 过短,无法起到引导模型的作用`)
  }
 
  return {
    ...def,
    risk: def.risk ?? 'read-only',          // 未声明的风险一律按最低处理?不——按最常用的 read-only 处理,
                                            // 而 destructive 必须显式声明才生效(白名单思维)
    schemaJson: zodToJsonSchema(def.parameters, { target: 'openAi' }),
  }
}

注意”缺省 read-only”的方向性:默认值必须落在低风险一侧,destructive 永远需要显式声明——让升级风险是一个主动动作。


2. 执行流水线:四阶段架构

2.1 流水线全景

骨架阶段的 execute 是一步到位的;生产级实现拆成四个阶段,每阶段一个拦截点:

flowchart LR
    A[模型发起 tool_call] --> B[阶段一 参数校验]
    B -->|不通过| E1[结构化错误回填]
    B -->|通过| C[阶段二 权限门禁 bail]
    C -->|拒绝| E2[拒绝原因回填]
    C -->|批准| D[阶段三 沙箱执行<br/>超时 + 并发控制]
    D -->|异常| E3[错误文本回填]
    D -->|成功| F[阶段四 结果渲染]
    F --> G[(canonical + render<br/>回填模型)]
    E1 --> G
    E2 --> G
    E3 --> G

四阶段的关键性质:**任何一条路径的终点都是”一条回填给模型的 tool 消息”,永远不向 loop 抛异常。**这是上一章确立的错误处理约定的延续。

2.2 升级版 ToolService 完整实现

// src/tool/ToolService.ts —— 生产级工具服务
import { Service } from 'cordis'
import type { RegisteredTool, RiskLevel } from './types'
 
// 审批请求:由权限门禁发出,由外部渠道(UI/CLI/webhook)裁决
export interface ApprovalRequest {
  tool: string
  args: Record<string, any>
  reason: string                  // 为什么需要审批(命中了哪条规则)
}
 
export interface ToolServiceConfig {
  timeoutMs?: number              // 单次工具执行上限,默认 30s
  maxConcurrent?: number          // 全局并发上限,默认 4
  // 审批回调:返回 true 放行。注入哪个实现,决定了审批走 UI 还是 CLI 还是 webhook
  requestApproval?: (req: ApprovalRequest) => Promise<boolean>
}
 
class ToolService extends Service {
  static inject = [],             // 无硬依赖;审批回调经 config 注入,保持解耦
 
  private registry = new Map<string, RegisteredTool>()
  private inflight = 0            // 当前并发计数
 
  constructor(ctx: Context, public config: ToolServiceConfig = {}) {
    super(ctx, 'tools')
  }
 
  register(tool: RegisteredTool) {
    this.registry.set(tool.name, tool)
    // 返回清理函数:插件 dispose 时直接调用,注册天然可逆
    return () => this.registry.delete(tool.name)
  }
 
  listSchemas() {
    return [...this.registry.values()].map(t => t.schemaJson)
  }
 
  /** 四阶段流水线主入口 */
  async execute(name: string, argsJson: string): Promise<string> {
    const tool = this.registry.get(name)
    if (!tool) return `错误:工具 ${name} 不存在。可用:${[...this.registry.keys()].join(', ')}`
 
    // ---------- 阶段一:参数校验 ----------
    let args: Record<string, any>
    try {
      args = JSON.parse(argsJson || '{}')
    } catch {
      return `错误:参数不是合法 JSON。请检查 arguments 格式后重试。`
    }
    const parsed = tool.parameters.safeParse(args)
    if (!parsed.success) {
      // 把 zod 的诊断信息原样给模型:它知道哪里错了才能改对
      return `错误:参数校验失败 - ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`
    }
 
    // ---------- 阶段二:权限门禁(bail 语义) ----------
    const allowed = await this.checkPermission(tool, parsed.data)
    if (!allowed.ok) {
      // bail:一旦有人否决,立即短路,后续阶段不再执行
      return `操作被拒绝:${allowed.reason}`
    }
 
    // ---------- 阶段三:沙箱执行(超时 + 并发控制) ----------
    if (this.inflight >= (this.config.maxConcurrent ?? 4)) {
      return `错误:工具并发已达上限,请稍后重试。`
    }
    this.inflight++
    try {
      const output = await this.withTimeout(
        tool.execute(parsed.data),
        this.config.timeoutMs ?? 30_000,
        name,
      )
      // ---------- 阶段四:结果渲染 ----------
      // 回填给模型的是 render 文本;canonical 留在事件流里供 UI 和审计消费
      await this.ctx.emit('tool-result', name, output.canonical)
      return output.render
    } catch (e: any) {
      if (e?.name === 'TimeoutError') return `错误:工具 ${name} 执行超时。`
      return `错误:工具 ${name} 执行失败:${e.message}`
    } finally {
      this.inflight--
    }
  }
 
  /** 权限检查:分级规则 + destructive 触发审批回调 */
  private async checkPermission(tool: RegisteredTool, args: any): Promise<{ ok: true } | { ok: false, reason: string }> {
    const level: RiskLevel = tool.risk ?? 'read-only'
 
    // 白名单机制:write 及以上级别可配置精确到参数的白名单(第 05 章展开)
    if (level === 'destructive') {
      const req: ApprovalRequest = {
        tool: tool.name,
        args,
        reason: `destructive 级工具,需人工确认`,
      }
      const approved = await this.config.requestApproval?.(req) ?? false
      // 审批决定本身也要进事件流:拒绝率和审批链是审计的核心素材
      await this.ctx.emit('approval', req, approved)
      if (!approved) return { ok: false, reason: `人工审批未通过` }
    }
    return { ok: true }
  }
 
  /** 超时包装:用 AbortSignal 而非仅 Promise.race,确保底层任务真的被取消 */
  private withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
    const ctrl = new AbortController()
    const timer = setTimeout(() => ctrl.abort(), ms)
    return Promise.race([
      p.finally(() => clearTimeout(timer)),
      new Promise<never>((_, rej) =>
        setTimeout(() => rej(Object.assign(new Error(`${name} 超时`), { name: 'TimeoutError' }), ms)),
      ),
    ])
  }
}

三个实现细节值得单独说明:

  • register 返回清理函数:插件写法变成 const dispose = ctx.tools.register(myTool)ctx.on('dispose', dispose),比记名字再 unregister 更不易漏;
  • emit(‘tool-result’):结果渲染的同时广播事件,可观测子系统(06 章)无需侵入流水线即可旁路收集数据;
  • 审批决定也 emit:“谁批的、批没批”是审计素材,不是权限模块的私产。

3. 权限门禁设计:四级风险模型

3.1 分级标准

等级定义示例默认策略
read-only只读,无副作用read_file、search_runbook直接放行
write产生持久化变更但可撤销create_file、create_ticket放行 + 记录
network数据出边界,有外泄面http_fetch、send_message放行 + 目标域校验
destructive难以撤销或影响他人rm_rf、restart_service、drain_node强制人工审批

分级的意义不在标签本身,而在于策略可以挂在等级上:日志详略、是否审批、是否限流,都按等级批量配置,而不是逐工具打补丁。network 与 destructive 可以叠加(如”向生产集群发命令”),实现上允许工具声明多等级取最高。

3.2 bail 语义为什么是对的

Cordis 事件的四种语义中,bail 的特性是”任一监听器返回假值即中止”。权限门禁正是它的教科书场景:

// 多道独立审查并行监听同一个事件,任何一道说"不"就终止
ctx.before('tool-permission', async (tool, args) => policyEngine.check(tool, args))
ctx.before('tool-permission', async (tool, args) => complianceDb.isAllowed(tool, args))
ctx.before('tool-permission', async (tool) => !quarantineMode.active)

把权限做成事件而非方法调用的好处:新增一道合规审查不需要修改 ToolService 一行代码,注册一个监听器即可——这正是 事件系统详解所讲的开闭原则落地方式。


4. 错误处理约定

把约定显式化成三条铁律:

**铁律一:工具层永不抛异常出流水线。**所有异常在阶段三被捕获并转为文本。loop 里唯一允许的异常来自网络层断连这类”重试也没用”的基础设施故障。

**铁律二:错误文本要说人话,更要让模型能行动。**对比:

差:"Error: ENOENT"                        → 模型不知道该怎么办
好:"错误:文件 /app/config.yaml 不存在。
    目录下现有文件:config.example.yaml。"
                                          → 模型下一轮就会去读 example 文件

铁律三:区分可预期错误与缺陷。参数不合法、文件不存在是工作状态,返回文本让模型调整;空指针、类型崩溃是代码缺陷,除了返回文本还应 emit 错误事件留给开发者修——两条通道都要有,只留第一条会吞掉 bug。


5. description 质量工程

description 是 prompt 的一部分,它的措辞直接决定模型的调用质量。核心规范在 defineTool 工具开发已详细展开(动词开头、边界声明、分流指引三军规),这里补充自建视角下的增量工程手段:

5.1 描述回归测试

description 改一版,模型行为可能全变。像测代码一样测描述——准备一组固定用例,跑”给定任务,模型选了哪个工具”的断言:

// 描述回归:换描述后重跑,断言工具选择不变
const cases = [
  { task: '看看 config.yaml 里写了什么', expect: 'read_file' },
  { task: '帮我把这段日志搜一下 error', expect: 'grep_log', notExpect: 'read_file' },
]
for (const c of cases) {
  const choice = await mockModel.selectTool(c.task)
  assert.equal(choice, c.expect)
}

CI 里每次动 description 都跑一遍,防止”顺手改了下文案”引发静默退化。

5.2 工具注册的插件化形态

工具不该散落在主工程里,而应按域打包成 Cordis 插件——这是 dsh 的做法,也应该是你的:

// src/plugins/fs-tools.ts —— 文件系统工具集,一个插件一个域
import type { Context } from 'cordis'
 
export function applyFsTools(ctx: Context) {
  const disposers: Array<() => void> = []
 
  ctx.on('ready', () => {
    disposers.push(ctx.tools.register(readFileTool))
    disposers.push(ctx.tools.register(writeFileTool))     // risk: 'write'
    disposers.push(ctx.tools.register(deletePathTool))    // risk: 'destructive'
  })
 
  // 一键全清:插件卸载时整个工具域从模型视野消失,
  // 下一次 chat 请求的 tools 列表自动不再包含它们
  ctx.on('dispose', () => disposers.forEach(d => d()))
}

这个形态带来两个直接收益:按场景裁剪(客服 Agent 只加载 kb-tools 插件,运维 Bot 只加载 ops-tools)与权限随域走(对整个插件统一设风险上限,而不是逐个工具审)。

5.3 描述质量的自检清单

每次新增工具后过一遍:

1. 只看 name + description(假装不知道实现),能猜出用途吗?
2. 与已有工具的边界写了吗?"什么时候不用我"说了吗?
3. 每个参数的 description 是填空题题干吗?(含格式示例更好)
4. 固定选项用 enum 了,还是留了自由文本让模型编?
5. 必填字段是真的必需,还是有合理默认值?
6. 描述回归测试跑过了吗?

6. 与 dsh 的 defineTool 对照表

诚实标注你实现了它的哪个子集:

能力dsh defineTool本章实现差距说明
五字段契约name/description/parameters/output/execute同构(zod 替代手写 schema)形状一致
注册期校验一致
参数自动校验schema 校验zod safeParse一致
权限分级内建分级四级 + 显式 risk 字段子集:dsh 另有会话级授权上下文
审批流内建 UI 审批回调注入,渠道自选你的更灵活,dsh 的更开箱
canonical/render 两层一致
输出截断与分页有(大结果保护)未实现补法见本表下方说明

大结果截断值得立刻补上:模型上下文有限,一次 read_file 读回 500KB 会挤爆窗口。在阶段四加一段”超过 N 字符则头部保留 + 尾部保留 + 中间替换为省略标记”的逻辑,十行代码,收益巨大:

// 阶段四的截断增强:头尾保留,中间省略
const MAX_RENDER = 20_000                       // 回填给模型的字符上限
function truncateRender(render: string): string {
  if (render.length <= MAX_RENDER) return render
  const head = render.slice(0, MAX_RENDER * 0.6)
  const tail = render.slice(-MAX_RENDER * 0.2)
  return `${head}\n...[中间 ${render.length - head.length - tail.length} 字符已省略]...\n${tail}`
}

注意省略提示本身也是信息:告诉模型”内容被截断了”,它才知道需要用更精确的工具或参数重新获取,而不是以为已经看到了全文。


7. 测试工具的策略

工具测试的最大障碍是”模型不可控”。解法是把模型从回路里拿掉:mock 模型按脚本回放固定的 tool_calls 序列,被测对象只有工具系统和流水线。

// 测试示例:不依赖真实 LLM,验证流水线各阶段
describe('ToolService 流水线', () => {
  it('参数不合法时返回校验错误而非抛异常', async () => {
    const svc = new ToolService(testCtx, {})
    svc.register(defineTool({
      name: 'echo',
      description: '原样返回输入文本,用于链路验证。',
      parameters: z.object({ text: z.string() }),
      execute: async ({ text }) => ({ canonical: text, render: `echo: ${text}` }),
    }))
    // 坏参数:缺少必填字段
    const result = await svc.execute('echo', '{}')
    assert.match(result, /参数校验失败/)
  })
 
  it('destructive 工具未获批准时不执行', async () => {
    let executed = false
    const svc = new ToolService(testCtx, {
      requestApproval: async () => false,           // 模拟用户点"拒绝"
    })
    svc.register(defineTool({
      name: 'rm_rf',
      description: '递归删除指定目录,危险操作,仅在明确指令下使用。',
      parameters: z.object({ path: z.string() }),
      risk: 'destructive',
      execute: async () => { executed = true; return { canonical: null, render: 'done' } },
    }))
    await svc.execute('rm_rf', '{"path":"/tmp/x"}')
    assert.equal(executed, false)                   // 关键断言:execute 根本没被碰过
  })
})

第二条例子里 executed === false 是整个权限设计的灵魂断言:拒绝发生在执行之前,而不是执行后回滚。配合 05 章的审计日志,审批请求与裁决也会留下完整痕迹。


8. 本章小结

  • defineTool 五字段 DSL 用 zod 实现,注册期校验把错误提前到挂载时刻;
  • 四阶段流水线(校验、门禁、执行、渲染)每阶段独立可拦截,所有路径终点都是回填消息;
  • 四级风险模型让策略挂等级而非逐工具配置,destructive 强制审批且默认拒绝;
  • 三条错误铁律保证 loop 永不被工具炸崩,模型始终保有自我纠正的机会;
  • description 是 prompt 的一部分,需要回归测试守护;
  • 对照 dsh:已覆盖其核心子集,缺口主要在大结果截断与会话级授权。

下一个子系统解决”模型只有一个、端点写死”的问题——设计多模型路由