# Step 13: Read 工具
一句话导读:
readFileSync的一层薄封装,代码不到 30 行,却是 AI 编程助手最高频的工具——看代码才能改代码。这一步的看点不在「怎么读」,而在「一个再简单的操作,包成给 AI 用的工具,错误处理反而是主体」。
# 一、这一步做了什么(What)
step13 实现 tools/read.ts:
ReadTool:实现ToolDef,execute用readFileSync(path, 'utf-8')读文件;- 参数校验:
file_path缺失时提前返回isError; - 错误兜底:文件不存在/无权限时,catch 成友好的
ToolResult而非崩溃; - 注册进 registry:
register(ReadTool),此刻工具数变成 Bash + Read + Echo 三个。
它是三个真实工具里最温和、也最常被 AI 调用的一个。
# 二、面试官视角:为什么要做?(Why)
面试题:读文件不就是
readFileSync一行吗?为什么这一步的代码里,错误处理比「读」本身还长?
因为给 AI 用的工具,正常路径是一行,异常路径才是重点。人类手动读文件,读错了自己知道;但 AI 是自动调用的,工具必须替它把所有坏情况变成「它能读懂的反馈」。
| 失败场景 | 不处理 | 处理后 |
|---|---|---|
| 文件不存在 | readFileSync 抛异常 → 整个 execute 崩,工具挂掉 | 返回「读文件失败: ENOENT...」,AI 知道要换路径 |
| 没传 file_path | 拿 undefined 去读 → 崩 | 提前返回「未指定文件路径」 |
| 权限不足 | 抛 EACCES → 崩 | 返回错误文本,AI 可提示用户 |
核心洞察:工具的健壮性 = 把每一种失败翻译成 AI 能据此行动的信息。文件不存在不该让工具崩溃,而应变成一条「这条路走不通,换一条」的信号回给模型。所以「最简单的工具」在工程上一点不简单——简单的是逻辑,不简单的是「面向不可靠调用方的容错」。
# 三、原理:它是怎么工作的(How)
# 全貌:校验 → 读 → 兜底
import { readFileSync } from 'fs';
import { ToolInput, ToolResult } from '../types.js';
export const ReadTool = {
name: 'Read',
description: '读取文件内容',
inputSchema: {
type: 'object',
properties: {
file_path: { type: 'string', description: '文件路径' },
},
required: ['file_path'], // ← schema 层声明必填
},
async execute(input: ToolInput): Promise<ToolResult> {
const path = input.file_path as string;
if (!path) { // ← 运行时再校验一次,不信任输入
return { content: [{ type: 'text', text: '错误: 未指定文件路径' }], isError: true };
}
try {
const content = readFileSync(path, 'utf-8');
return { content: [{ type: 'text', text: content }] };
} catch (err: any) {
return {
content: [{ type: 'text', text: '读文件失败: ' + (err.message || String(err)) }],
isError: true,
};
}
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 两道校验:schema 层 + 运行时层
inputSchema 里 required: ['file_path'] 是给 API/模型看的约束,但代码里又判了一次 if (!path)。为什么重复?因为schema 是「契约」,运行时校验是「兜底」——不能假设调用方一定守约。模型可能传空串、传错字段,运行时那道 if 是最后防线。这是「不信任输入」的防御式编程。
# 成败走同一个返回类型
无论读成功、路径为空、还是抛异常,execute 永远返回 ToolResult,从不 throw。区别只在 isError 标记。这样上层引擎处理所有工具都是同一套逻辑:拿到 ToolResult,看 isError 决定怎么把结果回填给 AI,不用给每个工具单独包 try/catch。
# 数据流
AI 调用 Read({file_path}) 或 内部测试
↓
path 为空? ──是──> {text:'未指定文件路径', isError:true}
↓ 否
readFileSync(path, 'utf-8')
↓成功 ↓抛异常(ENOENT/EACCES...)
{text: content} catch → {text:'读文件失败: '+msg, isError:true}
↓
统一 ToolResult → 引擎看 isError 回填给 AI
2
3
4
5
6
7
8
9
和 step12 一样:Read 已注册进 registry,但此刻 AI 还不能自主调用它(对话循环尚未接入工具)。本步专注把工具本身做扎实。
# 四、深入追问(面试常见 follow-up)
Q:inputSchema 里已经 required: ['file_path'] 了,代码里的 if (!path) 是不是多余?
A:不多余。required 是声明式契约,约束的是「理想调用方」;if (!path) 是运行时兜底,防的是「真实调用方不守约」——模型可能传空串(能过 required 但语义上是空)、SDK 可能有 bug、手动测试可能漏参。契约和校验是两层防线,缺一不可。这是「不信任输入」原则。
Q:读文件失败为什么返回 isError 而不直接 throw?
A:因为工具不该让调用它的引擎崩溃。如果 execute throw,引擎每调一个工具都得包 try/catch,且一个工具挂掉可能拖垮整轮对话。返回 {isError:true} 让「失败」成为一个正常的数据分支,引擎统一处理,还能把错误文本原样回给 AI 让它纠错(换个路径重试)。
Q:这个 Read 工具有什么明显局限?读个几百 MB 的文件会怎样?
A:会一次性全读进内存(readFileSync 无分页),大文件直接撑爆内存或塞爆上下文窗口。它也不识别二进制/图片/PDF——对图片会读出一堆乱码。真实 FileReadTool(5 个文件)有分页读取、大文件截断、图片检测(本地图直接发给 Claude 看)、PDF 解析。我们这版只处理「小文本文件」这个最常见场景。
Q:为什么固定 'utf-8' 编码?
A:图简单,覆盖绝大多数源代码文件。代价是读非 UTF-8 文件(如 GBK、二进制)会乱码或出错。生产版需要编码探测或按文件类型分流处理,我们这里用「约定 UTF-8」换实现简洁。
# 五、踩坑 / 设计权衡
- 同步读
readFileSync阻塞:和 Bash 同理,学习期选可读性,代价是大文件时卡住主线程。 - 无大小上限:真实版必须防「一个文件塞爆上下文」,我们没做——这在把工具接进 AI 循环后会是隐患(token 爆炸),属于已知待补项。
- 错误文本直接暴露系统路径/errno:对学习无妨,生产中要考虑是否泄露文件系统结构等信息。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
read.ts 单文件 readFileSync | tools/FileReadTool/ 5 个文件 |
| 一次性全读 | 分页读取(大文件不整个加载) |
| 只处理 UTF-8 文本 | 图片检测(发给 Claude 看)+ PDF 解析 |
| 无大小限制 | 大文件截断,控制 token 占用 |
| 错误直接回文本 | 结构化错误 + 更细的失败分类 |
# 七、一句话总结
step13 = 最高频工具,容错才是主体:readFileSync 一行是正常路径,但两道校验(schema 契约 + 运行时兜底)和「失败也返回 ToolResult 而非 throw」才是把它变成「能安全交给 AI 自动调用」的关键——工具的健壮性,就是把每种失败翻译成模型能据此纠错的信息。
# 下一节预告
能读还得能写。step14 实现 Write 工具——和 Read 几乎对称,把 readFileSync 换成 writeFileSync,却跨过了一道分水岭:AI 从「只能看」变成「能改你的文件」。