# Step 11: Tool 系统
一句话导读:Phase 2 的地基。用三个类型(
ToolDef/ToolResult/ToolInput)和一个注册表(ToolRegistry)定义「一个工具长什么样」,并靠toAPI()把内部工具翻译成 Anthropic API 认得的格式——从此所有工具(Bash、读、写……)都长一个样,可以像插卡一样注册进来。
# 一、这一步做了什么(What)
step11 不实现任何具体工具,只搭「工具该怎么被定义和管理」的抽象层:
- 三个核心类型(
types.ts):ToolInput(输入)、ToolResult(统一输出)、ToolDef(一个工具的形状); ToolRegistry注册表:register/get/list/toAPI四个方法,集中管理所有工具;toAPI()格式转换:把内部ToolDef映射成 API 的{ name, description, input_schema };- 一个
Echo工具 +/tools命令:最简工具当「Hello World」,验证注册表机制通了。
这一步是接口先行——先把「插槽」定义好,后面 Bash/Read/Write 都往这个插槽里插。
# 二、面试官视角:为什么要做?(Why)
面试题:Bash 是执行命令、Read 是读文件、Write 是写文件,三者干的事天差地别,为什么非要逼它们实现同一个
ToolDef接口?各写各的不更自由吗?
因为调用方(对话引擎)不想关心工具的差异。统一接口的收益全在「上层代码可以对所有工具一视同仁」:
| 维度 | 各工具各写各的 | 统一 ToolDef 接口 |
|---|---|---|
| 引擎调用 | 每个工具写一套 if 分支 | registry.get(name).execute(input) 一行搞定所有 |
| 加新工具 | 改引擎、加分支 | 只 register() 一下,引擎零改动 |
| 发给 API | 每个工具手写 schema | toAPI() 批量转换 |
| 结果处理 | 各工具返回格式不一,上层要适配 | 统一 ToolResult,isError 统一判错 |
一句话:统一接口把「工具的多样性」封装在工具内部,对外暴露一个整齐的形状。这就是面向接口编程——引擎依赖的是 ToolDef 这个抽象,不是 Bash/Read/Write 这些具体。加第 54 个工具时,引擎一行代码都不用改。
# 三、原理:它是怎么工作的(How)
# 三个类型:定义「工具的形状」
export interface ToolInput { [key: string]: unknown; }
export interface ToolResult {
content: Array<{ type: string; text: string }>; // 统一输出:内容数组
isError?: boolean; // 统一错误标记
}
export interface ToolDef {
name: string;
description: string; // 给 AI 看的说明,决定它何时调用
inputSchema: Record<string, unknown>; // JSON Schema,约束参数
execute(input: ToolInput): Promise<ToolResult>; // 干活的方法
}
2
3
4
5
6
7
8
9
10
11
12
13
三个字段回答三个问题:叫什么(name)、AI 何时该用它(description)、怎么调(inputSchema),加上怎么干活(execute)。
注意 ToolResult 用「内容数组 + isError」而非直接返回字符串——这样能装多段内容(未来图片、多块文本),且成功/失败走同一个返回通道,靠 isError 区分,上层不用 try/catch 每个工具。
# ToolRegistry:工具的中央登记处
export class ToolRegistry {
private tools = new Map<string, ToolDef>();
register(t: ToolDef) { this.tools.set(t.name, t); }
get(name: string) { return this.tools.get(name); }
list() { return Array.from(this.tools.values()); }
toAPI() {
return this.list().map(t => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema, // ← 注意:驼峰 inputSchema → 蛇形 input_schema
}));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
用 Map 以 name 为键,天然去重、O(1) 查找。
# toAPI() 是题眼:内外两套命名的桥
我们内部用 inputSchema(TS 驼峰习惯),但 Anthropic API 要的是 input_schema(蛇形)。toAPI() 就是这层防腐转换:内部代码保持 TS 风格,只在出口处转成 API 契约。这样 API 契约变了只改一处,内部代码不受污染。
# 数据流
定义工具(实现 ToolDef 形状的对象)
↓
registry.register(tool) → Map<name, ToolDef>
↓
registry.toAPI() → [{ name, description, input_schema }] → 发给 Anthropic API
↓(AI 决定调某工具,未来 step)
registry.get(name).execute(input) → ToolResult
↓
看 isError 判成败 → 把 content 回填给 AI
2
3
4
5
6
7
8
9
# Echo 工具:验证插槽通了
step11 注册了一个最简 Echo 工具(原样返回输入),/tools 命令列出已注册工具。它不干实事,作用是证明「定义→注册→列出→转 API 格式」这条链路是通的,就像新框架的 Hello World。
# 四、深入追问(面试常见 follow-up)
Q:ToolResult 为什么是 content: Array<{type, text}> 而不直接 result: string?
A:三个原因。一是可扩展——数组能装多段内容,未来支持图片块、多个文本块无需改类型;二是对齐 API——Anthropic 的 tool_result 本就是内容块数组,内部同构省一次转换;三是成败同通道——成功和失败都返回 ToolResult,靠 isError 区分,上层不必给每个工具包 try/catch。
Q:注册表用 Map 而不是数组,为什么?
A:因为工具是按名字被 AI 点名调用的(registry.get('Bash'))。Map 以 name 为键,查找 O(1) 且天然防重名(同名后注册的覆盖前者)。用数组则每次调用都要 find 线性扫描,还得自己防重。
Q:toAPI() 为什么单独存在,不在定义工具时就用 input_schema 命名?
A:这是关注点隔离。内部代码遵循 TS 驼峰惯例(inputSchema),API 契约是蛇形(input_schema)。把转换收敛到 toAPI() 一个出口,好处是:万一 API 契约变化(改字段名、加字段),只改这一个函数,53 个工具的定义一个都不用动。这是「防腐层」思想。
Q:description 字段看着不起眼,它重要吗?
A:极重要。description 是给模型看的——AI 靠它判断「用户这个需求该不该调这个工具、什么时候调」。description 写得含糊,模型就会该调不调或乱调。它不是注释,是工具的「使用说明书」,是 prompt 工程的一部分。
# 五、踩坑 / 设计权衡
- inputSchema 类型是
Record<string, unknown>而非强类型 JSON Schema:图简单,牺牲了编译期校验。真实Tool.ts用 zod 等 schema 库做运行时校验 + 类型推导,我们这里靠约定。 - execute 返回 Promise:即便像 Echo 这种同步操作也声明成 async。统一异步签名,避免引擎调用时区分「同步工具/异步工具」——多数真实工具(执行命令、读文件、网络)本就是异步,统一成 async 让调用方一视同仁。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
types.ts 中 ToolDef ~6 字段 | Tool.ts ~754/793 行:权限上下文、进度回调、工具链、安全检查 |
ToolRegistry 四方法 | tools.ts ~390 行:注册 + 过滤 + 按上下文动态启用 |
toAPI() 手工映射 | 结合 zod schema 自动生成 input_schema |
Echo 一个演示工具 | tools/ 目录 53 个真实工具 |
ToolResult = content + isError | 同构,但 content 支持更多块类型(图片、diff 等) |
# 七、一句话总结
step11 = 定义「工具的插槽」:三个类型说清「工具长什么样」,ToolRegistry 用 Map 集中登记,toAPI() 当防腐层把内部驼峰翻成 API 蛇形——从此引擎面对的是统一的 ToolDef 抽象,加任何新工具都只需 register 一下、引擎零改动。这是「对话」迈向「动手干活」的地基。
# 下一节预告
插槽定好了,该往里插第一个「真」工具。step12 实现 Bash 工具——用 execSync 让 AI 能执行 shell 命令,这是能力最强、也最危险的一个工具。