# 第 4 章:工具抽象与执行引擎
> 工具是思维的延伸;Agent 的能力边界,取决于它能调用什么。
---
## 4.1 核心问题
前三章搭起了进程骨架(第1章)、渲染引擎(第2章)、命令路由(第3章)。但我们的 Agent 还什么"实事"都做不了——它只是一个能接受用户输入的空壳。
真正的 Agent 需要**行动能力**:读写文件、运行命令、搜索代码……这些能力统称**工具(Tools)**。
本章要解决的问题是:
> **如何设计一套统一的工具接口,让 LLM 能够"调用"任意工具,同时保证类型安全、权限可控、错误可追踪?**
这个问题并不简单。LLM 通过 JSON 告诉我们"我想调用哪个工具、传入什么参数",而我们需要:
1. **验证**:JSON 里的参数是否符合工具期望的类型?
2. **执行**:找到对应工具并调用它。
3. **封装**:执行结果(或错误)以统一的格式返回给 LLM。
Claude Code 的解法是:**用 Zod 作为输入 schema 的单一来源**——它既是运行时的类型守卫,又能通过反射生成 JSON Schema 发送给 LLM,实现了"告诉模型工具长什么样"与"运行时校验模型发回的参数"的双重复用。
---
## 4.2 原理讲解
### 4.2.1 Function Calling:LLM 的"手"
OpenAI 在 2023 年引入了 Function Calling(Anthropic 称为 Tool Use),其本质是一次 API 往返的扩展协议:
```
用户 LLM 我们的代码
│ │ │
│── 消息 ────────────>│ │
│ │── JSON工具调用 ────────>│
│ │ │── 执行工具
│ │<── 工具结果 ───────────│
│<── 最终回复 ─────── │ │
```
LLM 在第一次响应时不直接回答用户,而是输出一个 `tool_use` 块:
```json
{
"type": "tool_use",
"id": "toolu_01abc",
"name": "Bash",
"input": {
"command": "ls -la",
"description": "List files in current directory"
}
}
```
我们的代码解析这个块,执行 `ls -la`,再把结果用 `tool_result` 块回传给 LLM,LLM 才最终生成自然语言回复。
这套协议意味着:**工具的"输入规格"(即 JSON Schema)必须随系统提示一起发送给 LLM**,让模型知道每个工具接受哪些参数。
### 4.2.2 Zod:一份定义,双重用途
Zod 是 TypeScript 生态中最流行的运行时验证库。其核心思想:
```typescript
import { z } from 'zod'
// 定义 schema
const BashInputSchema = z.object({
command: z.string().describe('The command to execute'),
timeout: z.number().optional().describe('Timeout in milliseconds'),
})
// 用途一:运行时验证(类型守卫)
const result = BashInputSchema.safeParse(unknownInput)
if (!result.success) {
console.error(result.error.issues)
}
// 用途二:TypeScript 类型推断
type BashInput = z.infer<typeof BashInputSchema>
// 等价于:{ command: string; timeout?: number }
```
Claude Code 的精妙在于:**同一份 Zod schema 同时服务于三个目标**:
| 目标 | 机制 |
|------|------|
| 告诉 LLM 工具参数格式 | `z.toJSONSchema(schema)` 生成 JSON Schema 发给 API |
| 验证 LLM 返回的参数 | `schema.safeParse(input)` 运行时类型守卫 |
| TypeScript 类型推断 | `z.infer<typeof schema>` 编译期类型安全 |
### 4.2.3 Tool 接口三要素
去掉 Claude Code 生产级代码中的渲染、权限、UI 等横切关注点,工具的核心骨架只有三要素:
```
┌─────────────────────────────────────────┐
│ Tool 接口 │
│ │
│ name "Bash" / "Read" / ... │
│ inputSchema Zod schema(双重用途) │
│ call() 异步执行函数 │
└─────────────────────────────────────────┘
```
**名称(name)**:字符串标识符,LLM 在 `tool_use` 块的 `name` 字段中引用它。全局唯一。
**输入 schema(inputSchema)**:Zod schema 对象。负责:① 生成 JSON Schema 给 LLM;② 在 `call()` 前校验入参。
**执行函数(call())**:接受已验证的类型安全入参,返回 `Promise<ToolResult>`。
### 4.2.4 `lazySchema`:惰性初始化
Claude Code 源码中大量使用了 `lazySchema` 包装:
```typescript
const inputSchema = lazySchema(() => z.strictObject({
command: z.string().describe('The command to execute'),
// ...
}))
```
这是因为 Zod 的 `.describe()` 等链式调用会在模块加载时执行,而部分描述文字依赖运行时配置(如超时限制的动态数值)。`lazySchema` 将 schema 构造推迟到首次访问,避免启动时的副作用。
### 4.2.5 `buildTool`:填充默认值的工厂函数
真实工具除了三要素,还需要大量"样板"方法:`isEnabled()`、`isConcurrencySafe()`、`isReadOnly()`、`checkPermissions()` 等。
Claude Code 用 `buildTool()` 工厂函数为这些方法提供安全的默认值:
```typescript
// src/Tool.ts
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: () => false, // 默认不并发安全(保守)
isReadOnly: () => false, // 默认会写(保守)
isDestructive: () => false,
checkPermissions: (input, ctx) =>
Promise.resolve({ behavior: 'allow', updatedInput: input }),
toAutoClassifierInput: () => '',
userFacingName: () => '',
}
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name, // 用工具名作为默认用户可见名
...def, // 工具自定义实现优先级最高
} as BuiltTool<D>
}
```
**设计亮点**:所有默认值都是**保守的**(fail-closed):不假设并发安全、不假设只读——这确保了即使工具实现者忘记声明某个能力,系统也会选择更安全的行为。
### 4.2.6 错误封装:工具不抛异常
工具执行失败时,正确的做法是**返回结构化错误**,而不是 `throw`。原因:LLM 需要读取错误信息来调整后续行为;而异常会打断整个对话循环。
```typescript
// 错误模式(不推荐)
async call(input) {
if (!existsSync(input.file_path)) {
throw new Error('File not found') // ❌ 打断对话循环
}
}
// 正确模式
async call(input) {
if (!existsSync(input.file_path)) {
return {
type: 'text',
text: `File not found: ${input.file_path}`,
} // ✅ LLM 可以读取并重试
}
}
```
### 4.2.7 工具注册表:`tools.ts`
`src/tools.ts` 是所有工具的装配中心。它根据编译特性标志(`feature('...')`)和运行时环境变量(`process.env.USER_TYPE`)动态决定哪些工具参与注册:
```typescript
// src/tools.ts(精简)
import { BashTool } from './tools/BashTool/BashTool.js'
import { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
import { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
// 编译时特性门控
const SleepTool =
feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
// Ant 员工专属工具
const REPLTool =
process.env.USER_TYPE === 'ant'
? require('./tools/REPLTool/REPLTool.js').REPLTool
: null
```
这种"按需装配"的设计实现了**功能开关(Feature Flag)** 的工具级别控制,公开版和内部版可以共用同一套代码库。
---
## 4.3 Claude Code 源码细节
### 4.3.1 `Tool` 类型:完整接口(src/Tool.ts)
生产级 `Tool` 类型远比骨架复杂,核心字段如下:
```typescript
// src/Tool.ts(精简注解版)
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = {
readonly name: string
/** Zod schema,双重用途:JSON Schema 生成 + 运行时校验 */
readonly inputSchema: Input
/** 可选:直接提供 JSON Schema(用于 MCP 动态工具) */
readonly inputJSONSchema?: ToolInputJSONSchema
/** 核心执行函数 */
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>
/** 生成工具描述(异步,可依赖运行时状态) */
description(
input: z.infer<Input>,
options: { isNonInteractiveSession: boolean; toolPermissionContext: ToolPermissionContext; tools: Tools },
): Promise<string>
/** 权限检查:在 call() 前调用 */
checkPermissions(input: z.infer<Input>, context: ToolUseContext): Promise<PermissionResult>
/** 是否只读(影响权限提示措辞) */
isReadOnly(input: z.infer<Input>): boolean
/** 是否并发安全(影响并发调度) */
isConcurrencySafe(input: z.infer<Input>): boolean
/** 是否破坏性操作(删除、覆写、发送) */
isDestructive?(input: z.infer<Input>): boolean
/** 用户中断时的行为:取消 or 等待 */
interruptBehavior?(): 'cancel' | 'block'
/** 工具结果超过此字节数时持久化到磁盘 */
maxResultSizeChars: number
// … 以及大量 UI 渲染方法(renderToolUseMessage、renderToolResultMessage 等)
}
```
**关键观察**:`call()` 的签名比我们想象的复杂。除了 `args` 和 `context`,还有:
- `canUseTool`:运行时权限查询函数,用于嵌套工具调用(如 AgentTool 内部再调用其他工具)
- `parentMessage`:触发本次工具调用的 AssistantMessage,用于关联上下文
- `onProgress`:流式进度回调,BashTool 用它实时推送命令输出
### 4.3.2 BashTool 的 Schema 定义(src/tools/BashTool/BashTool.tsx)
```typescript
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string().describe('The command to execute'),
timeout: semanticNumber(z.number().optional())
.describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`),
description: z.string().optional()
.describe(`Clear, concise description of what this command does...`),
run_in_background: semanticBoolean(z.boolean().optional())
.describe('Set to true to run this command in the background.'),
// 内部字段,不暴露给 LLM(通过 .omit() 从对外 schema 中剔除)
_simulatedSedEdit: z.object({
filePath: z.string(),
newContent: z.string()
}).optional(),
}))
// 对外 schema 剔除内部字段
const inputSchema = lazySchema(() =>
fullInputSchema().omit({ _simulatedSedEdit: true })
)
```
**安全设计**:`_simulatedSedEdit` 是 sed 编辑预览通过的内部传递字段,绝不能暴露给 LLM——否则模型可以绕过权限检查直接写任意文件。`omit()` 确保这个字段从 JSON Schema 中消失。
### 4.3.3 `ToolUseContext`:工具的运行时上下文
每次工具调用都会收到一个 `ToolUseContext`,它是工具与外部世界交互的通道:
```typescript
// src/Tool.ts(精简)
export type ToolUseContext = {
options: {
commands: Command[]
debug: boolean
mainLoopModel: string
tools: Tools // 当前可用工具列表
verbose: boolean
mcpClients: MCPServerConnection[]
maxBudgetUsd?: number // 预算上限(用于成本控制)
// ...
}
abortController: AbortController // 取消信号
readFileState: FileStateCache // 文件读取缓存
getAppState(): AppState
setAppState(f: (prev: AppState) => AppState): void
}
```
`ToolUseContext` 是**依赖注入**的载体——工具不直接访问全局状态,而是通过 context 获取所需资源,这使得工具在测试中可以轻松 mock。
### 4.3.4 `ToolResult`:统一的返回格式
```typescript
// src/Tool.ts
export type ToolResult<T> = {
type: 'tool_result'
content: T
// 当 isError 为 true 时,content 是错误描述文本
isError?: boolean
}
```
`isError: true` 时,LLM 会收到错误信息并尝试修正;`isError: false`(默认)时,内容被视为成功结果。
---
## 4.4 最小化产出物
> 代码骨架位于 `../chapters/04/src/`,参考实现位于 `../chapters/04/solution/`。
### 本章要实现什么
在 `../chapters/04/src/tools.ts` 中完成工具引擎。
**接口规范**(已提供,不要修改):
```typescript
export type ToolResult =
| { success: true; output: string }
| { success: false; error: string }
export type ToolContext = { cwd: string; verbose: boolean }
export interface Tool<Input extends z.ZodTypeAny = z.ZodTypeAny> {
name: string
description: string
inputSchema: Input
call(input: z.infer<Input>, ctx: ToolContext): Promise<ToolResult>
}
export const TOOL_REGISTRY: Map<string, Tool<any>>
export function callTool(name: string, rawInput: unknown, ctx: ToolContext): Promise<ToolResult>
export function getToolSchemas(): Array<{ name: string; description: string; input_schema: {...} }>
```
**你需要实现**:
1. `BashTool.call()`:用 `execSync` 执行命令,捕获错误返回 `{ success: false, error }`(不 throw)
2. `ReadFileTool.call()`:读取文件,支持 `start_line`/`end_line` 行范围,每行加 `" N │ "` 前缀
3. `WriteFileTool.call()`:写入文件,失败时返回错误(不 throw)
4. `TOOL_REGISTRY`:用 `Map` 注册三个工具
5. `callTool()`:查找工具 → `safeParse` 校验 → 执行,全程不抛异常
6. `getToolSchemas()`:遍历注册表,从 Zod schema 提取 JSON Schema
**关键约束**:
- 工具执行失败必须返回 `{ success: false, error }` 而不是 `throw`
- `callTool()` 本身也不能抛异常
### 验收
```bash
cd docs/chapters/04
npm install
npm test
```
卡住时查看 `../chapters/04/solution/tools.ts`。
---
## 4.5 从骨架到生产:Claude Code 做了什么
本章产出物是最小可用的工具引擎。Claude Code 的生产代码在此基础上叠加了以下层次:
| 关注点 | 本章(最小版) | Claude Code(生产版) |
|--------|--------------|----------------------|
| 输入校验 | `schema.safeParse()` | 同上 + `validateInput()` 钩子(工具自定义额外校验) |
| 权限控制 | 无 | `checkPermissions()` → 权限对话框 → `alwaysAllow` 规则 |
| 执行取消 | 无 | `AbortController` 贯穿整个调用链 |
| 进度回调 | 无 | `onProgress()` 流式推送(BashTool 实时输出) |
| 结果存储 | 内存返回 | 超过 `maxResultSizeChars` 时持久化到磁盘 |
| 并发控制 | 无 | `isConcurrencySafe()` 决定是否可并行调用 |
| UI 渲染 | 无 | `renderToolUseMessage()` + `renderToolResultMessage()` |
| 工具搜索 | 全量注册 | `shouldDefer` + ToolSearchTool 按需加载 |
| MCP 集成 | 无 | `inputJSONSchema` 支持非 Zod 的外部工具 |
每一层都在第5-16章中逐步引入。
---
## 4.6 本章小结
本章构建了工具执行引擎的核心骨架:
1. **Zod schema 是工具的"合同"**:一份 schema 定义同时承担 JSON Schema 生成(告知 LLM)和运行时校验(验证 LLM 响应)两项职责。
2. **Tool 接口三要素**:`name`(标识符)、`inputSchema`(合同)、`call()`(实现)。Claude Code 的完整 `Tool` 类型在此基础上扩展了权限、UI、进度等切面。
3. **`buildTool()` 思想**:用工厂函数提供保守的安全默认值,工具实现者只需关注业务逻辑,不必关心基础设施样板。
4. **工具不抛异常**:所有失败以结构化数据(`ToolResult`)返回,确保对话循环的健壮性——这是让 LLM 能够从错误中自我恢复的关键设计。
5. **注册表 + 调度**:`callTool()` 通过注册表查找工具、验证输入、执行调用,实现了调度逻辑与工具实现的完全解耦。
6. **`tools.ts` 独立导出**:所有类型、工具实例、注册表、`callTool()`、`getToolSchemas()` 均从 `tools.ts` 导出,供第 5-16 章直接 import 复用,不需要重复实现。
**下一章**将给这个工具引擎插上"大脑":接入 Claude API,处理流式响应,让 LLM 真正开始调用这些工具。
---
## 下一章
→ [第 5 章:LLM API 客户端(流式)](05-llm-client)