# Step 13: Read 工具

一句话导读:readFileSync 的一层薄封装,代码不到 30 行,却是 AI 编程助手最高频的工具——看代码才能改代码。这一步的看点不在「怎么读」,而在「一个再简单的操作,包成给 AI 用的工具,错误处理反而是主体」。


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

step13 实现 tools/read.ts:

  1. ReadTool:实现 ToolDef,execute 用 readFileSync(path, 'utf-8') 读文件;
  2. 参数校验:file_path 缺失时提前返回 isError;
  3. 错误兜底:文件不存在/无权限时,catch 成友好的 ToolResult 而非崩溃;
  4. 注册进 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,
      };
    }
  },
};
1
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
1
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 从「只能看」变成「能改你的文件」。