# Step 11: Tool 系统

一句话导读:Phase 2 的地基。用三个类型(ToolDef / ToolResult / ToolInput)和一个注册表(ToolRegistry)定义「一个工具长什么样」,并靠 toAPI() 把内部工具翻译成 Anthropic API 认得的格式——从此所有工具(Bash、读、写……)都长一个样,可以像插卡一样注册进来。


# 一、这一步做了什么(What)

step11 不实现任何具体工具,只搭「工具该怎么被定义和管理」的抽象层:

  1. 三个核心类型(types.ts):ToolInput(输入)、ToolResult(统一输出)、ToolDef(一个工具的形状);
  2. ToolRegistry 注册表:register / get / list / toAPI 四个方法,集中管理所有工具;
  3. toAPI() 格式转换:把内部 ToolDef 映射成 API 的 { name, description, input_schema };
  4. 一个 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>;  // 干活的方法
}
1
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
    }));
  }
}
1
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
1
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 命令,这是能力最强、也最危险的一个工具。