工具系统工程化

工具系统工程化

第 06 章我们手写了单轮 Tool Calling 的完整协议:定义 tools 数组、解析模型返回的 tool_calls、执行、再把结果回填成 tool 消息。那一章的代码是教科书式的:一个工具、几个分支、一把梭。可现实世界里的 Agent 不是这样的,Anthropic 的 Claude Code 内置几十个工具,Hermes Agent 内置 50+ 个工具还能再挂上外部 MCP 工具。当工具从 1 个长到 50 个,第 06 章那种写法会撞上两堵墙:分发逻辑不可维护schema 与实现互相漂移。这一章我们就把工具层工程化,拆掉这两堵墙。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:工具多了以后

假设你的 Agent 一开始只有两个工具,分发代码长这样(第 06 章风格的延续):

async function runTool(name: string, args: any) {
  if (name === 'get_time') return getTime();
  if (name === 'add') return add(args.a, args.b);
  // 每加一个工具,这里就多一个分支……
}

工具数量小的时候,这个函数是最好懂的代码,两个分支,一眼看穿。但它有一个致命问题:分发逻辑和工具列表耦合在同一段代码里。加一个 search,你要回到这个函数加一个 else if;再加一个 read_file,再回来加一个。今天改的是功能代码,明天就可能有人不小心改坏了别人的分支;等分支长到 30 个,这个函数本身就成了没人敢动的雷区。Hermes 的文档里有一句话点破了这个问题的本质:「加一个工具,意味着只加一个文件」,不碰循环、不碰分发、不碰任何中心配置。要做到这一点,第一件事就是把「工具在哪里」从「分发代码」里拆出来。

再看第二堵墙:发给模型的 tools 数组。第 06 章里我们是手写的:

const tools = [
  {
    type: 'function',
    function: {
      name: 'add',
      description: 'Add two numbers and return the sum.',
      parameters: {
        type: 'object',
        properties: {
          a: { type: 'number', description: 'First addend.' },
          b: { type: 'number', description: 'Second addend.' },
        },
        required: ['a', 'b'],
      },
    },
  },
];

麻烦在于:参数的真实定义add(a: number, b: number))写在实现里,参数的 schema 描述却手抄在 tools 数组里,同一个契约有两份拷贝。某天你为了支持浮点数把 a 的校验放宽,实现改了,tools 数组忘了改;或者更隐蔽的:给 add 加了个可选参数 round,schema 没加。于是模型看到的契约和真实实现不一致:模型按旧 schema 调 add({ a: 1, b: 2 }),一切正常;哪天模型按新逻辑猜了个参数传进来,运行时才炸。这种 bug 不在测试里出现(因为测试只覆盖你记得更新的路径),只会在线上偶尔出现——schema 与实现漂移是工具系统里最阴险的一类故障。

这两堵墙,就是本章要拆掉的:注册表解决「分发不可维护」,schema 生成解决「手抄漂移」。它们合起来,再加一个统一错误协议,就是工具系统工程化的最小内核。

核心一:注册表——name 到工具的映射

注册表的思想极简:一张 name → (spec + 实现) 的表。模型要调工具,不直接调函数,改为按名字查表、从表里取实现来调。工具本身不关心自己「被谁调用」,注册表不关心自己「装了哪些工具」。

// 工具的声明:模型看到的契约
interface ToolSpec {
  name: string;
  description: string;
  parameters: ParameterSpec[];   // 参数声明(见核心二)
}

// 工具的实现:真正干活的纯函数。参数校验是它自己的职责——
// 参数不对就 throw,错误协议会把它变成模型可见的字符串。
type ToolImpl = (args: Record<string, unknown>) => unknown;

interface ToolEntry {
  spec: ToolSpec;
  impl: ToolImpl;
  schema: Record<string, unknown>; // register 时由 spec 生成,见核心二
}

interface ToolRegistry {
  register(spec: ToolSpec, impl: ToolImpl): void; // 重名抛错,防静默覆盖
  list(): ToolEntry[];                             // 全部工具(生成 tools 数组用)
  get(name: string): ToolEntry | undefined;        // 分发时查表
}

实现只需要一张 Map 和一个注册动作:

function createToolRegistry(): ToolRegistry {
  const tools = new Map<string, ToolEntry>();
  return {
    register(spec, impl) {
      if (tools.has(spec.name)) {
        throw new Error(`tool already registered: ${spec.name}`);
      }
      // schema 在注册时就生成好、随 entry 存下,之后谁要用都取同一份。
      tools.set(spec.name, { spec, impl, schema: generateSchemaFromSpec(spec) });
    },
    list() {
      return [...tools.values()];
    },
    get(name) {
      return tools.get(name);
    },
  };
}

有了注册表,整个工具系统的运转分成注册期运行期两个阶段:

graph TD
  subgraph 注册期[注册期:加一个工具 = 调一次 register]
    A[写 spec 参数声明] --> B[写 impl 实现]
    B --> C[registry.register]
    C --> D[表项: name → spec + impl + 生成的 schema]
  end
  subgraph 运行期[运行期:循环只面向注册表]
    E[registry.list 生成 tools 数组] --> F[发给模型]
    F --> G[模型返回 tool_calls]
    G --> H[executeTool 按 name 查表分发]
    H --> I[调用 impl / 捕获错误为字符串]
    I --> J[tool_result 回填给模型]
    J --> F
  end

注册期只发生一次:工具作者写下 spec 和 impl,register 一下完事。运行期(第 07 章的循环)永远只面向注册表:list() 拿全部 schema 发给模型,get(name) 查表执行。循环不认任何具体工具的名字,所以加工具永远不需要碰循环。这就是「加一个工具 = 加一个文件」的真正含义,Hermes 把这条 import 链画成 registry ← tools ← orchestration ← loop,方向永远从下往上,注册表在最底层、不依赖任何工具,整个设计才立得住(见延伸阅读)。

核心二:从声明式 spec 生成 JSON Schema

注册表解决了分发,但 tools 数组还是得有人写。回到反例里的问题:同一个契约存在两份拷贝,迟早漂移。怎么让 schema 和实现只保留一份来源?

先看「从 TS 类型直接生成」这条路为什么不选。TS 的类型(interfacetype)在编译后被擦除,运行时根本拿不到,想从类型注解拿 schema,要么引入类型编译器(ts-json-schema-generator 这类工具在构建期扫描代码),要么引入运行时类型库(zodtypebox 这类,把类型和校验器捆在一起定义)。Python 生态没这个问题:smolagents@tool 装饰器能直接读函数的类型注解和 docstring 来生成 schema(见延伸阅读),因为 Python 的类型是运行时对象。TS 的运行时没有类型,所以我们只能二选一:为它搬来一个编译器,或者自己定义一个轻量的、运行时可见的「类型描述」。

本章走第二条路,用一个手写的小生成器。做法是:把参数契约集中成一份声明式 spec,schema 从 spec 生成,实现也从 spec 出发,spec 是唯一的契约来源。

// 参数声明:name/type/description 三件套,外加可选的 required(默认必填)
interface ParameterSpec {
  name: string;
  type: 'string' | 'number' | 'boolean';
  description: string;
  required?: boolean; // 省略视为 true
}

// 小型生成器:一份参数声明 -> 一份 JSON Schema(模型要的格式)
function generateSchemaFromSpec(spec: ToolSpec): Record<string, unknown> {
  const properties: Record<string, unknown> = {};
  const required: string[] = [];
  for (const p of spec.parameters) {
    properties[p.name] = { type: p.type, description: p.description };
    if (p.required !== false) required.push(p.name);
  }
  return { type: 'object', properties, required };
}
graph LR
  A[spec 参数声明<br/>唯一契约来源] --> B[generateSchemaFromSpec]
  B --> C[JSON Schema<br/>properties + required]
  C --> D[list 拼进 tools 数组<br/>发给模型]

spec.parameters 写一次,register 时生成 schema 存进 entry(见核心一的代码),list()tools 数组时直接用。改动契约时你只改 spec 这一处,schema 是算出来的,不存在「忘改」这个操作,漂移问题从根上消失。这是「从类型生成 schema」的 TS 版最小实现:没有编译器、没有依赖、十行代码。等你的工具系统复杂到需要真正的运行时类型系统(嵌套对象参数、联合类型、跨工具复用类型),再评估 zod/typebox 也不迟,那时「手写生成器代价过高」的论证就成立了。

核心三:统一错误协议(error-as-data)

分发和 schema 都工程化了,还有最后一件事:工具执行失败时会发生什么

没有错误协议时,剧本是这样的:模型调 add({ a: 'abc', b: 2 })(模型把参数类型搞错太常见了),add 的实现一校验就 throw new TypeError(...)。异常一路往上抛,把第 07 章的循环打断,整个对话以报错结束——用户看到的是「Agent 挂了」,模型完全不知道发生了什么。你也许会想:「那就 try/catch 一下,打条日志呗。」但工具是模型在用的,错误信息真正的读者是模型自己

所以业界(Anthropic 的工具使用最佳实践、build-your-own-agent 的教程索引都把这条单列出来)的做法是 error-as-data:把工具失败当成数据,而不是崩溃。工具抛错 → 捕获 → 格式化成字符串 → 作为 tool_result 回填给模型 → 模型读到自己失败的原因,自己纠正重试。错误不跳出数据通道,而是顺着数据通道流回给发起点。

实现还是一个小函数:

type ToolResult =
  | { ok: true; result: unknown }
  | { ok: false; error: string }; // 错误是字符串,因为最终要进消息

function executeTool(registry: ToolRegistry, name: string, args: Record<string, unknown>): ToolResult {
  const entry = registry.get(name);
  if (!entry) return { ok: false, error: `unknown tool: ${name}` }; // 未知工具也是数据

  try {
    return { ok: true, result: entry.impl(args) };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return { ok: false, error: `Error: ${message}` };
  }
}

调用方(Agent 循环)拿到 { ok: false, error } 后,把 error 字符串原样塞进 tool 消息的 content 回填给模型:

// 循环里:工具结果 -> tool 消息
const outcome = executeTool(registry, call.function.name, parsedArgs);
const content = outcome.ok
  ? JSON.stringify(outcome.result)
  : outcome.error;              // 错误字符串,模型看得见
messages.push({ role: 'tool', tool_call_id: call.id, content });

于是对话变成这样,注意第三轮,模型是在读到错误字符串之后修正了自己的调用:

sequenceDiagram
  participant M as 模型
  participant R as 注册表
  participant T as add 工具
  M->>R: executeTool("add", {a:"abc", b:2})
  R->>T: impl({a:"abc", b:2})
  T-->>R: throw TypeError("add() expects numeric a, got \"abc\"")
  R-->>M: {ok:false, error:"Error: add() expects numeric a, got \"abc\""}
  Note over M,R: 错误字符串作为 tool 消息回填
  M->>R: executeTool("add", {a:2, b:3})
  R->>T: impl({a:2, b:3})
  T-->>R: 5
  R-->>M: {ok:true, result:5}

这里有一个容易被忽略的细节:错误字符串的写法决定了模型能不能纠正。上面的 Error: add() expects numeric a, got "abc" 之所以有效,是因为它包含了三样东西:工具名add)、非法值got "abc")、期望expects numeric a)。模型看到后能立刻知道「哦,我把字符串传进数字参数了」。如果你只写 Error: failed,模型就算想纠正也无从下手。反过来也别把整个堆栈丢进去:堆栈对模型没用、还烧 token,错误字符串是给模型看的,不是给调试器看的。

错误协议一旦建立,所有失败都走同一条路:工具抛错、工具不存在、甚至模型吐出的 arguments 是非法 JSON,统统捕获、格式化、回填。循环本身从不 throw,也就永远不会因为一个工具而中断。

可选扩展:超时、取消、幂等

到此,工具系统的最小内核(注册表 + schema 生成 + 错误协议)已经完整。再往工程化走,还有三个方向,本章只给思路,不做完整实现:

内置工具集的设计思路

最后聊聊一个 Agent 要随身携带哪些工具。原则一句话:纯函数工具优先,有副作用的工具慎入

// 内置工具集的典型成员:纯函数、确定性、无副作用
registry.register(
  {
    name: 'search',
    description: 'Search the knowledge base and return matching entries.',
    parameters: [
      { name: 'query', type: 'string', description: 'Search keywords.' },
      { name: 'limit', type: 'number', description: 'Max results.', required: false },
    ],
  },
  (args) => {
    const query = String(args.query ?? '').toLowerCase();
    if (!query) throw new Error('search() needs a non-empty query');
    const limit = typeof args.limit === 'number' ? args.limit : 10;
    return db.filter((row) => (row.title + row.body).includes(query)).slice(0, limit);
  },
);

设计要点:

常见坑

坑一:把错误当崩溃。 循环里执行工具不 try/catch,一个工具抛错整个对话中断,模型永远不知道发生了什么。错误协议的第一性原理:工具失败是数据不是崩溃,任何 throw 都要在 executeTool 这一层被拦住、转成字符串。

坑二:catch 了却又重新 throw,或者返回 undefined 把错误吞掉。 前者等于没 catch;后者更糟,{ ok: true, result: undefined } 会让循环以为工具成功了,模型拿到 undefined 还会继续用下去。错误要么以 error 字段返回,要么就明确抛给上层处理,不能半途而废。

坑三:错误字符串写得太模糊。 Error: failedError: invalid args,模型看到也不知道错在哪。错误消息必须带工具名 + 非法值 + 期望Error: add() expects numeric a, got "abc"),模型才有纠正的抓手。这是 error-as-data 能闭环的前提。

坑四:schema 手写,和实现脱节。 参数改了 spec 没改(或反过来),模型按旧契约调用,线上偶发炸。用本章的生成器:spec 是唯一契约来源,schema 是算出来的,不存在第二份拷贝。

坑五:注册重名工具不校验,静默覆盖。 两个模块各自 register('search', ...),后者把前者盖掉,线上行为莫名其妙。注册时重名直接抛错(如 tool already registered: search),让冲突早暴露。

坑六:模型吐出的 arguments 是非法 JSON。 模型有时会生成 {"a": 2, "b": } 这种残废 JSON,JSON.parse 会 throw。这同样要按错误协议处理,解析失败返回 { ok: false, error: 'Error: malformed JSON arguments: ...' } 回填给模型,让模型重新生成参数。凡是模型可能做错的地方,都要走错误协议

坑七:把堆栈或敏感信息写进错误字符串。 堆栈烧 token 且对模型没用;文件路径、环境变量这类内部信息不该暴露给模型(模型的话会原样进日志,甚至被 prompt injection 利用)。错误字符串是给模型看的「发生了什么 + 怎么改」,不是 debug dump。

小结

下一章(ch09)我们进入上下文工程:Agent 的上下文是有限预算,工具结果正是最大的消耗者之一,结果截断、token 估算、滑动窗口与摘要压缩,都会在那一章展开。工具系统到这里有了「注册、描述、执行、容错」的完整骨架,下一章给它接上「内存管理」这个器官。

延伸阅读

完成阅读,去做练习 →