工具系统工程化
工具系统工程化
第 06 章我们手写了单轮 Tool Calling 的完整协议:定义 tools 数组、解析模型返回的 tool_calls、执行、再把结果回填成 tool 消息。那一章的代码是教科书式的:一个工具、几个分支、一把梭。可现实世界里的 Agent 不是这样的,Anthropic 的 Claude Code 内置几十个工具,Hermes Agent 内置 50+ 个工具还能再挂上外部 MCP 工具。当工具从 1 个长到 50 个,第 06 章那种写法会撞上两堵墙:分发逻辑不可维护、schema 与实现互相漂移。这一章我们就把工具层工程化,拆掉这两堵墙。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说清工具多了以后,
if/elif分发与手写 JSON Schema 为什么不可维护(反例); - 实现一个**注册表**:
name →(spec + 实现)的映射,支持register / list / get,新增工具不改分发代码; - 用一份声明式 spec 生成 JSON Schema,不靠手抄,从根上消除 schema 与实现的漂移;
- 实现**统一错误协议(error-as-data)**:工具抛错被捕获成模型可见的错误字符串、回灌给模型让它自己纠正,不会中断整个对话;
- 了解超时、取消、幂等这些工程扩展点,以及内置工具集的设计思路。
开场反例:工具多了以后
假设你的 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 的类型(interface、type)在编译后被擦除,运行时根本拿不到,想从类型注解拿 schema,要么引入类型编译器(ts-json-schema-generator 这类工具在构建期扫描代码),要么引入运行时类型库(zod、typebox 这类,把类型和校验器捆在一起定义)。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 生成 + 错误协议)已经完整。再往工程化走,还有三个方向,本章只给思路,不做完整实现:
- 超时:工具可能挂起(网络请求没响应)。用
Promise.race包一层,超时就按错误协议返回{ ok: false, error: 'timeout after 10s' }——注意超时本身也是数据,不是崩溃。需要主动中断的话接上第 03 章的AbortController,把signal传给工具。 - 取消:用户按了停止,正在执行的工具要不要中断?方案是给执行链路传
AbortSignal,工具内部配合(比如fetch的第二个参数就能接收)。 - 幂等:
search读操作跑两次没影响,send_email、write_file这类有副作用的工具跑两次可能出事。工程化做法是给工具声明幂等性,必要时对幂等工具做去重。副作用工具还有更大的话题:权限,第 18 章展开。
内置工具集的设计思路
最后聊聊一个 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);
},
);
设计要点:
- 越纯越好:
get_time、add、search这类读操作,输出可预测、无副作用、天然幂等,是工具集的地基;文件读写、Shell 执行这类工具功能强,但每个都带副作用和安全隐患,属于危险工具,要配权限层(第 18 章)。 - 可用性过滤:不是每个工具都该出现在模型面前。Hermes 的做法是在注册时挂一个
check_fn,比如「没有配SEARCH_API_KEY就别把搜索工具发给模型」,模型根本看不到它就不会调它(见延伸阅读)。这个检查在list()里做,加工具的人不用关心。 - 结果要截断:一个搜索工具可能返回 50KB 结果,直接回填会烧穿上下文。截断、摘要都是第 09 章上下文工程的事,这里只需在工具层约定「结果过大时先截断再回填」。
常见坑
坑一:把错误当崩溃。 循环里执行工具不 try/catch,一个工具抛错整个对话中断,模型永远不知道发生了什么。错误协议的第一性原理:工具失败是数据不是崩溃,任何 throw 都要在 executeTool 这一层被拦住、转成字符串。
坑二:catch 了却又重新 throw,或者返回 undefined 把错误吞掉。 前者等于没 catch;后者更糟,{ ok: true, result: undefined } 会让循环以为工具成功了,模型拿到 undefined 还会继续用下去。错误要么以 error 字段返回,要么就明确抛给上层处理,不能半途而废。
坑三:错误字符串写得太模糊。 Error: failed、Error: 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。
小结
- 工具多了以后有两堵墙:
if/elif分发不可维护(加工具要改分发代码)、手写 schema 与实现互相漂移(同一个契约两份拷贝); - 注册表把「工具在哪里」从分发代码里拆出来:
name → (spec + impl + 生成的 schema),循环只面向list()/get(),加工具 = 调一次register,不碰任何分发逻辑; - schema 生成解决漂移:TS 的运行时没有类型,所以用声明式 spec 作为唯一契约来源,十行生成器把它算成 JSON Schema,需要复杂类型系统时再评估
zod/typebox; - **错误协议(error-as-data)**让失败顺着数据通道流回模型:
executeTool捕获一切throw,格式化成含「工具名 + 非法值 + 期望」的错误字符串,作为tool_result回填,模型读到后自己纠正,循环从不因一个工具而中断; - 扩展方向:超时/取消(
Promise.race+AbortController)、幂等(副作用工具)、可用性过滤(没配 key 的工具不发给模型)、结果截断(留给上下文工程)。
下一章(ch09)我们进入上下文工程:Agent 的上下文是有限预算,工具结果正是最大的消耗者之一,结果截断、token 估算、滑动窗口与摘要压缩,都会在那一章展开。工具系统到这里有了「注册、描述、执行、容错」的完整骨架,下一章给它接上「内存管理」这个器官。
延伸阅读
- Learn Hermes Agent — s02: Tool System:自注册注册表 + import 即注册 + 可用性过滤(
check_fn)的完整讲解,本章「加一个工具 = 加一个文件」与「可用性过滤」直接取自它的思路。 - build-your-own-agent — §02 Tool / function calling:工具调用 from-scratch 资源索引;「error-as-data:把工具失败变成模型能看到的字符串」是它收录教程的共同主线。
- Hugging Face smolagents — Tools in depth:
@tool装饰器从 Python 类型注解与 docstring 自动生成输入 schema,「从类型/注释生成 schema」的框架侧实现,与本章手写生成器对照阅读。 - Anthropic — Tool use best practices:官方对工具错误处理的建议,把错误信息放进 tool result 让模型继续,是 error-as-data 在真实 API 上的权威表述。