Book 第七章:Read 为什么不是读文件函数
第三部分:本地执行面

第七章:Read 为什么不是读文件函数

把 Read 看成多模态读取协议,而不是简单的 read(path) -> string。

1. 为什么要单独拆 Read

如果只看名字,Read 很容易被误会成一个很普通的工具:

read(path) -> string

但源码不是这么设计的。

在 Claude Code 里,Read 实际上承担了四层责任:

  1. 统一文件读取协议
  2. 多模态输入入口
  3. 权限与安全边界
  4. transcript 优化与 token 预算控制

所以它不是“读文件 API”,而是:

Claude Code 把本地文件送进模型上下文时的标准入口。

这也是为什么它值得单独拆。后面做 C# 版时,如果你把它简化成 File.ReadAllText() 的包装,最后一定会少掉一大半关键设计。

2. 先说结论

我对这份实现的判断是:

FileReadTool 的本质不是文件 I/O,而是一个“多模态、带权限、带缓存、带 transcript 策略的文件访问协议”。

它至少做了这些额外事情:

  • 先把路径标准化,再进入权限链路
  • 先做字符串级安全校验,再决定要不要碰文件系统
  • 按文本 / 图片 / PDF / notebook 四类内容走不同分支
  • 对重复读取做去重,避免把同样的内容一遍遍塞回上下文
  • 把 UI 看到的结果和模型真正拿到的结果分开处理
  • 给文本读取统一补行号、空文件提醒、偏移越界提醒、风险提醒
  • 顺手触发 skill 发现、memory 附件、分析埋点、session 文件识别

这说明 Claude Code 对“读文件”这件事的理解非常工程化:

文件不是字节流,而是 agent 的输入载体。

3. 源码锚点

建议先对着这些位置看:

  • src/tools/FileReadTool/FileReadTool.ts:337
  • src/tools/FileReadTool/FileReadTool.ts:388
  • src/tools/FileReadTool/FileReadTool.ts:395
  • src/tools/FileReadTool/FileReadTool.ts:398
  • src/tools/FileReadTool/FileReadTool.ts:418
  • src/tools/FileReadTool/FileReadTool.ts:496
  • src/tools/FileReadTool/FileReadTool.ts:594
  • src/tools/FileReadTool/FileReadTool.ts:652
  • src/tools/FileReadTool/FileReadTool.ts:729
  • src/tools/FileReadTool/FileReadTool.ts:804
  • src/tools/FileReadTool/prompt.ts:27

4. 整体结构图

flowchart TD
    A["模型发起 Read(file_path, offset, limit, pages)"] --> B["backfillObservableInput()<br/>先把路径扩成绝对路径"]
    B --> C["preparePermissionMatcher() / checkPermissions()"]
    C --> D["validateInput()<br/>页码、deny rule、UNC、二进制扩展名、设备文件"]
    D --> E["call()"]

    E --> F["读取限制与 telemetry"]
    F --> G["重复读取去重(file_unchanged)"]
    G --> H["按路径发现 skill / 激活条件 skill"]
    H --> I{"callInner() 分支"}

    I --> J["Notebook 分支"]
    I --> K["Image 分支"]
    I --> L["PDF 分支"]
    I --> M["Text 分支"]

    J --> N["mapToolResultToToolResultBlockParam()"]
    K --> N
    L --> N
    M --> N

    N --> O["模型侧 tool_result"]
    K --> P["补充 meta message: 图片尺寸"]
    L --> Q["补充 meta message: PDF document / page image blocks"]
    M --> R["补充 line number / freshness / 风险提醒"]

这个图里最关键的一点是:

真正读文件之前,Claude Code 已经做了大量“不是文件 I/O 的事情”。

5. 对外协议其实比名字复杂得多

5.1 Prompt 已经把它定义成多模态入口

src/tools/FileReadTool/prompt.ts 明确告诉模型:

  • 路径必须是绝对路径
  • 默认最多读 2000
  • 大文件应该优先用 offset + limit
  • 可以直接读图片
  • 可以读 PDF
  • 可以读 .ipynb
  • 不能读目录,目录要交给 BashTool
  • 用户给了截图路径时,应该优先用这个工具

这说明 Read 从 prompt 层就不是“文本文件读取器”,而是:

Claude Code 默认的本地文件观察入口。

5.2 输出协议不是单一字符串

outputSchema 不是简单的 { content: string },而是一个判别联合:

  • text
  • image
  • notebook
  • pdf
  • parts
  • file_unchanged

也就是说,Claude Code 很早就接受了一个事实:

“读文件”不会只返回文本。

6. 权限链路之前,先做输入补全

6.1 backfillObservableInput() 先改“观察侧输入”

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:388

这里最重要的逻辑是:

  • 如果 file_path 是字符串
  • 就先 expandPath()

源码注释说得很直接:

这么做是为了避免 hook allowlist 被 ~ 或相对路径绕过去。

这是一种非常典型的 Claude Code 风格:

先把输入归一化,再交给权限系统。

换句话说,它不信任“字面路径”,它只信任“规范化后的路径”。

6.2 权限匹配不是字符串相等,而是 wildcard matcher

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:395

preparePermissionMatcher() 返回的是:

pattern => matchWildcardPattern(pattern, file_path)

这意味着读权限不是简单的:

  • 允许某个具体文件

而是支持:

  • 路径模式
  • 通配匹配
  • 规则驱动

6.3 真正的权限判定交给统一文件系统权限层

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:398

checkPermissions() 最终调的是:

  • checkReadPermissionForTool(...)

这说明 Read 自己不发明一套权限系统,而是接到统一的 filesystem permission runtime 上。

这个分层很重要:

  • 工具自己只声明“我是 read”
  • 统一权限层决定“你到底能不能读这个路径”

7. validateInput() 不是装饰,而是第一道安全边界

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:418

这个方法非常值得认真看,因为它展示了 Claude Code 一贯的策略:

能在不碰文件系统的前提下先挡掉的风险,就先挡掉。

7.1 先校验 PDF 页码字符串

如果 pages 不合法,直接失败。

如果页数范围超过单次上限,也直接失败。

这一步完全是字符串解析,不做 I/O。

7.2 先做 deny rule 命中

代码会先 expandPath(file_path),然后拿规范化后的路径去跑 deny rule。

命中了就直接报:

  • 这个目录被权限设置拒绝了

这一步也还没碰文件。

7.3 UNC 路径做了特殊处理

如果路径长得像:

  • \\\\server\\share\\...
  • //server/share/...

校验阶段不会直接去做文件系统操作,而是先返回 result: true,把真正的处理推迟到后面的权限阶段。

源码注释写得很明确:

这是为了避免在用户授权之前触发 NTLM credential leak。

这一点非常关键,因为它说明 Claude Code 的设计者不是只在想“工具能不能用”,而是在想:

“路径本身会不会触发宿主系统级副作用。”

7.4 二进制扩展名先挡一层

普通二进制扩展名会被挡掉,但 PDF 和图片例外。

这说明它不是粗暴地禁止 binary,而是根据“这个工具是否原生支持该媒体类型”来决定。

7.5 特定设备文件直接拒绝

它显式挡掉了这些路径:

  • /dev/zero
  • /dev/random
  • /dev/urandom
  • /dev/full
  • /dev/stdin
  • /dev/tty
  • /dev/console
  • /dev/stdout
  • /dev/stderr
  • /dev/fd/0
  • /dev/fd/1
  • /dev/fd/2

同时还挡掉:

  • /proc/.../fd/0-2

理由也很现实:

  • 有的会无限输出
  • 有的会阻塞等待输入
  • 有的从语义上就不该拿来“读文件”

这类处理不是“模型体验优化”,而是很硬的 runtime 防护。

8. call() 真正在做的是“读取编排”

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:496

call() 不是直接读取,而是一个编排层。

8.1 先决出读取预算

它先从 context 拿:

  • readFileState
  • fileReadingLimits

然后再跟默认值合并,得到:

  • maxSizeBytes
  • maxTokens

如果调用方覆盖了默认限制,还会打 telemetry。

也就是说,Claude Code 明确把“读文件的大小/token预算”当成 runtime 配置项,而不是工具内部常量。

8.2 对完全重复的读取直接去重

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:520

这个分支很能体现 Claude Code 的上下文工程意识。

逻辑大意是:

  • 如果之前已经读过这个文件
  • 不是 partial view
  • 是由 Read 本身产生的缓存状态
  • 这次请求的 offset/limit 和上次完全一样
  • 并且文件 mtime 没变

那就不再把完整内容发一遍,而是返回:

  • file_unchanged

对应的提示文案也很直接:

  • 文件没变,请参考之前那条 Read tool_result

源码注释还写了它想解决的问题:

重复读取会浪费 cache creation token。

这不是普通 CLI 工具会考虑的事,这是 agent transcript 优化。

8.3 读文件时顺手做 skill 发现

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:579
  • src/tools/FileReadTool/FileReadTool.ts:590

在非 simple mode 下,它会根据路径:

  • discoverSkillDirsForPaths(...)
  • addSkillDirectories(...)
  • activateConditionalSkillsForPaths(...)

这说明 Claude Code 把“用户正在读哪个文件”也当成一种环境信号。

也就是说,Read 不只是读取动作,它还可能推动系统发现新的能力。

8.4 文件不存在时,它会尽量给出友好修复路径

如果报 ENOENT,它不是简单抛错,而是会多做几步:

  • 尝试处理 macOS 截图路径里的细空格 / 普通空格差异
  • 猜测相似文件名
  • 猜测是不是用户把路径写成了 cwd 之外的形式

最后再拼成更像“帮你找路”的错误信息。

这又是一层 Claude Code 的典型增强:

工具错误也要尽量服务于下一步行动。

9. callInner() 里其实藏着四个不同工具

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:804

这部分最值得单独看,因为 Read 表面上是一个工具,实际上内部有四条完全不同的执行路径。

9.1 Notebook 分支:把 .ipynb 当结构化内容,不当普通 JSON

如果扩展名是 ipynb,它会:

  • readNotebook(...)
  • 把 cells JSON 序列化用于 size/token 校验
  • 记录 readFileState
  • 触发 memory attachment
  • 把结果映射成 notebook

最关键的一点是:

它返回的不是原始 notebook 文件文本,而是 notebook cell 结构。

这说明 Claude Code 认为 notebook 的正确抽象不是“一个 JSON 文件”,而是“由 cell 组成的可读对象”。

9.2 Image 分支:单次读取,尽量压进 token 预算

如果是图片扩展名,它会:

  • 只读文件一次
  • 先尝试标准 resize / downsample
  • 如果估算 token 超预算,再从同一个 buffer 做激进压缩
  • 还不行就继续做更重的 fallback 压缩

这里很重要的一点是:

它强调从同一个 buffer 继续压缩,而不是反复重新读文件。

这看似是小细节,其实说明作者很在意:

  • I/O 次数
  • 大文件内存风险
  • 图像读取链路的确定性

而且图片分支还会额外补一条 meta message,把尺寸信息送进上下文。

9.3 PDF 分支:它根本不相信“所有 PDF 都适合整本塞给模型”

PDF 是这里最复杂的一条分支。

如果用户提供了 pages

  • 先做页范围解析
  • extractPDFPages(...)
  • 把目标页转成 JPG
  • 再把这些页作为 image blocks 塞进新的 meta message

如果没提供 pages

  • 先拿页数
  • 页数太多直接拒绝整本读取
  • 模型不支持 PDF 时直接报错
  • 文件太大或当前模型不适合时,会走 page extraction 作为兜底准备
  • 如果支持整本,就 readPDF(...),再把整份 PDF 作为 document block 放进 meta message

这里的设计思路非常清楚:

PDF 不是文本文件,也不是图片文件,而是要按模型能力和文件规模动态决定传输形式的文档对象。

9.4 Text 分支:真正的“读文件文本”反而只是最后一支

文本分支做的事情也不只是 readFile

  • 通过 readFileInRange(...) 按行偏移读取
  • 可以在整文件读取时受 maxSizeBytes 限制
  • 对内容做 token 校验
  • 写入 readFileState
  • 触发 file read listeners
  • 触发 memory attachment
  • 打 session file analytics

如果这个文件被识别成 auto-memory 文件,还会把 mtime 放进 WeakMap,后面在结果序列化阶段再补 freshness 提示。

这一段很能说明 Claude Code 的设计习惯:

真正的文件内容只是结果的一部分,围绕“这次读取”的各种状态副作用同样重要。

10. UI 看到的结果,和模型真正拿到的结果,不是一回事

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:411
  • src/tools/FileReadTool/FileReadTool.ts:652

这里是 Read 最容易被忽略、但对 C# 版最关键的一点。

源码注释写得很直白:

  • UI 只显示摘要 chrome
  • 模型侧序列化才会真正附带内容

也就是说,终端上看到的:

  • “Read 120 lines”
  • “Read image (42KB)”

并不等于模型真正收到的那份 tool_result

这意味着 Claude Code 在工具层已经区分了两个视图:

  1. 宿主展示视图
  2. 模型消费视图

这个分层如果 C# 版不保留,后面 UI、日志、缓存、搜索索引会全部缠在一起。

11. 结果序列化才是真正的“送模阶段”

11.1 文本不是原样返回,而是带行号、带提醒

如果是 text,序列化阶段会做这些事:

  • 如果有内容,就先补 freshness note
  • 然后按 cat -n 风格补行号
  • 再按模型类型决定要不要补 CYBER_RISK_MITIGATION_REMINDER

这个提醒本身也很值得注意:

  • 可以分析恶意代码
  • 但不能帮助改进或增强恶意代码

也就是说,Read 层已经开始承担模型行为约束,而不只是“把文件读出来”。

11.2 空文件和 offset 越界不会静默返回空串

如果内容为空,它不会直接给空字符串,而是给系统提醒:

  • 文件存在但内容为空
  • 或者文件比给定 offset 更短

这是很好的工程判断,因为对模型来说:

空串太暧昧,提醒语义更明确。

11.3 PDF 的真实内容经常不在主 tool_result

PDF 分支返回主结果时,经常只给:

  • 文件路径
  • 原始体积
  • 页数摘要

真正的 PDF document block 或页图片,反而是通过额外的 meta message 注进去的。

这意味着:

Claude Code 的工具结果不是总靠一个字符串字段表达,而是允许“主结果 + 补充消息”组合出真正的模型上下文。

11.4 file_unchanged 是 transcript 优化协议的一部分

这个类型不是业务语义,而是上下文优化语义。

它想表达的不是:

  • 文件对象有某种新状态

而是:

  • 你之前读过的那份内容现在仍然有效,别浪费 token 再传一遍

这类类型在普通应用 API 里不常见,但在 agent runtime 里非常合理。

12. Claude Code 在 Read 上额外做了哪些处理

把零散逻辑收一下,大概可以总结成这几层增强。

12.1 输入与安全增强

  • 路径标准化
  • wildcard 权限匹配
  • deny rule 提前短路
  • UNC 路径延迟处理
  • 二进制扩展名阻断
  • 特定设备文件阻断

12.2 内容与媒体增强

  • 图片单次读取 + 逐级压缩
  • PDF 整本 / 分页双协议
  • notebook 结构化读取
  • 文本按行区间读取

12.3 transcript 增强

  • file_unchanged 去重
  • 行号注入
  • 空文件提醒
  • offset 越界提醒
  • cyber risk mitigation reminder
  • UI 视图和模型视图分离

12.4 runtime 联动增强

  • skill 目录发现
  • 条件 skill 激活
  • nested memory attachment trigger
  • auto-memory freshness note
  • file read listeners
  • analytics / session file telemetry

这说明 Read 实际上是一个小型 runtime hub,而不是单点工具。

13. 对 C# 版的翻译建议

如果要转成 C#,我不建议做成一个大而全的 FileReadTool 类把所有逻辑糊进去。

更稳的拆法是:

13.1 协议层

  • ReadToolDefinition
  • ReadToolInput
  • ReadToolOutput
  • ReadResultKind

13.2 权限与校验层

  • IPathNormalizer
  • IReadPermissionService
  • IReadInputValidator
  • IPathRuleMatcher

13.3 内容路由层

  • IFileContentRouter
  • TextReadHandler
  • ImageReadHandler
  • PdfReadHandler
  • NotebookReadHandler

13.4 transcript 序列化层

  • IReadResultSerializer
  • IHostReadResultRenderer
  • IModelReadResultProjector

13.5 runtime 联动层

  • IReadStateCache
  • ISkillDiscoveryService
  • IMemoryAttachmentTrigger
  • IReadTelemetry

我会特别建议你把“宿主显示”和“模型消费”拆成两个接口,不要合在一个 DTO 上硬扛。

因为 Claude Code 这里已经明确证明:

同一次 Read,UI 和模型拿到的本来就不是一个东西。

14. 我对这套设计的评价

Read 这份实现最值得学的,不是它支持了多少文件类型,而是它背后的判断:

在 agent 系统里,文件读取不是 I/O 问题,而是上下文治理问题。

作者把下面这些东西全都塞进了 Read

  • 安全
  • token 预算
  • 多模态输入
  • transcript 优化
  • 运行时联动

这么做的代价是工具本身会变重。

但好处也很明显:

  • 模型不需要自己反复决定“该怎么读文件”
  • 宿主可以统一管控读取安全
  • transcript 不会因为重复读取迅速膨胀
  • 图片 / PDF / notebook 都能进入统一的工具协议

如果你后面要做 C# 版,我建议把 Read 放在最优先的基础设施里,因为它和 EditWriteBash 一起,几乎决定了整个 agent runtime 的本地工作方式。