第七章:Read 为什么不是读文件函数
把 Read 看成多模态读取协议,而不是简单的 read(path) -> string。
1. 为什么要单独拆 Read
如果只看名字,Read 很容易被误会成一个很普通的工具:
read(path) -> string
但源码不是这么设计的。
在 Claude Code 里,Read 实际上承担了四层责任:
- 统一文件读取协议
- 多模态输入入口
- 权限与安全边界
- 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:337src/tools/FileReadTool/FileReadTool.ts:388src/tools/FileReadTool/FileReadTool.ts:395src/tools/FileReadTool/FileReadTool.ts:398src/tools/FileReadTool/FileReadTool.ts:418src/tools/FileReadTool/FileReadTool.ts:496src/tools/FileReadTool/FileReadTool.ts:594src/tools/FileReadTool/FileReadTool.ts:652src/tools/FileReadTool/FileReadTool.ts:729src/tools/FileReadTool/FileReadTool.ts:804src/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 },而是一个判别联合:
textimagenotebookpdfpartsfile_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 拿:
readFileStatefileReadingLimits
然后再跟默认值合并,得到:
maxSizeBytesmaxTokens
如果调用方覆盖了默认限制,还会打 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:579src/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:411src/tools/FileReadTool/FileReadTool.ts:652
这里是 Read 最容易被忽略、但对 C# 版最关键的一点。
源码注释写得很直白:
- UI 只显示摘要 chrome
- 模型侧序列化才会真正附带内容
也就是说,终端上看到的:
- “Read 120 lines”
- “Read image (42KB)”
并不等于模型真正收到的那份 tool_result。
这意味着 Claude Code 在工具层已经区分了两个视图:
- 宿主展示视图
- 模型消费视图
这个分层如果 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 协议层
ReadToolDefinitionReadToolInputReadToolOutputReadResultKind
13.2 权限与校验层
IPathNormalizerIReadPermissionServiceIReadInputValidatorIPathRuleMatcher
13.3 内容路由层
IFileContentRouterTextReadHandlerImageReadHandlerPdfReadHandlerNotebookReadHandler
13.4 transcript 序列化层
IReadResultSerializerIHostReadResultRendererIModelReadResultProjector
13.5 runtime 联动层
IReadStateCacheISkillDiscoveryServiceIMemoryAttachmentTriggerIReadTelemetry
我会特别建议你把“宿主显示”和“模型消费”拆成两个接口,不要合在一个 DTO 上硬扛。
因为 Claude Code 这里已经明确证明:
同一次 Read,UI 和模型拿到的本来就不是一个东西。
14. 我对这套设计的评价
Read 这份实现最值得学的,不是它支持了多少文件类型,而是它背后的判断:
在 agent 系统里,文件读取不是 I/O 问题,而是上下文治理问题。
作者把下面这些东西全都塞进了 Read:
- 安全
- token 预算
- 多模态输入
- transcript 优化
- 运行时联动
这么做的代价是工具本身会变重。
但好处也很明显:
- 模型不需要自己反复决定“该怎么读文件”
- 宿主可以统一管控读取安全
- transcript 不会因为重复读取迅速膨胀
- 图片 / PDF / notebook 都能进入统一的工具协议
如果你后面要做 C# 版,我建议把 Read 放在最优先的基础设施里,因为它和 Edit、Write、Bash 一起,几乎决定了整个 agent runtime 的本地工作方式。