文件工具
Claude Code 如何读取、创建和精确修改文件:读取前置约束、令牌上限、去重、引号规范化,以及写入时的保护机制。
Claude Code 只暴露三种文件操作原语:Read、Write 和 Edit。表面上看它们只是“读文件”“写文件”“改文件”,但真正的设计核心只有一句话:任何会改动已有文件的操作,都必须先读过那个文件。
这一条约束决定了几乎所有实现细节:为什么 Write / Edit 会拒绝部分读取、为什么要做 mtime 与内容校验、为什么 Read 会带去重状态缓存,也解释了 Claude Code 为什么能在文件编辑这件事上既保守又高效。
这一条约束决定了几乎所有实现细节:为什么 Write / Edit 会拒绝部分读取、为什么要做 mtime 与内容校验、为什么 Read 会带去重状态缓存,也解释了 Claude Code 为什么能在文件编辑这件事上既保守又高效。
这一条约束决定了几乎所有实现细节:为什么 Write / Edit 会拒绝部分读取、为什么要做 mtime 与内容校验、为什么 Read 会带去重状态缓存,也解释了 Claude Code 为什么能在文件编辑这件事上既保守又高效。
| Capability | Read | Write | Edit |
|---|---|---|---|
| 只读/并发安全 | Yes | No | No |
| 需要事先读取现有文件 | — | Yes | Yes |
| 本地处理图像 | Yes | No | No |
| 原生处理 PDF | 如果支持的话 | No | No |
| 处理 Jupyter 笔记本 | Yes | No | 否 — 使用 NotebookEdit |
| 报价标准化 | — | — | Yes |
| Dedup(跳过重新发送未更改的文件) | Yes | — | — |
| 强制执行令牌限制 | 默认 25 000 tok | — | — |
| 保存时的 LSP 通知 | No | Yes | Yes |
| 最大文件大小(编辑) | — | — | 1GiB |
分页:偏移+限制
Read 默认从第 1 行开始,最多返回 2000 行。遇到大文件时,模型需要显式传入 offset 和 limit 来做分页读取,这样既能控制上下文成本,也能避免在还没确定目标范围时一次性读入整份文件。
这里的分页不是前端层面的“滚动加载”,而是协议层面的硬约束。Claude Code 希望模型先缩小问题范围,再逐段读取,而不是把“大文件整段搬进上下文”当默认行为。
// Default call — reads from line 1 up to 2000 lines Read({ file_path: "/project/src/main.ts" }) // Paginated — start at line 500, read 100 lines Read({ file_path: "/project/src/main.ts", offset: 500, limit: 100 })
代币限制执行
Read 有两级输出预算控制。第一层先做便宜的粗估,如果粗估看起来已经接近上限,再走第二层的精确 token 计数。真正超出预算时,不会把半截内容塞给模型,而是直接抛出 MaxFileReadTokenExceededError。
这样设计的关键不在于“算得更准”,而在于“别浪费上下文”。对超大文件来说,明确报错并提示改用分页,远比发送一大段截断内容更划算。
图片支持
图片读取走的是本地多模态路径:Claude Code 根据扩展名识别图片,把文件读入内存,必要时用 sharp 或原生图像处理库缩放后,再作为 base64 图像块交给模型。
这意味着 Read 不只是“文本文件读取器”,它同时承担截图分析入口。尺寸信息也会跟着一起传递,所以模型后续如果要基于坐标做操作,仍然知道图像经过了怎样的缩放。
// Read a screenshot — Claude sees the image visually Read({ file_path: "~/Documents/screenshots/CleanShot 2026-03-31.png" })
PDF 支持
PDF 支持是按页读取的,而不是整本文件原样塞进上下文。调用方必须通过 pages 明确声明页码范围,并且单次调用最多只能取 20 页。
这个限制并不是保守过头,而是防止“无意中把整本 PDF 喂给模型”。大 PDF 还可能被转成图像页来处理,进一步说明这里的目标是稳定可控,而不是原样搬运文件。
// Read pages 1 through 5 of a PDF Read({ file_path: "/docs/spec.pdf", pages: "1-5" }) // Large PDF: exceeds 10 pages requires explicit range Read({ file_path: "/docs/report.pdf", pages: "6-10" })
Jupyter 笔记本
Notebook 读取不是把 .ipynb 原始 JSON 直接交给模型,而是会先把单元格、输出和可视化整理成更适合阅读的统一表示。
这背后体现的其实是 Claude Code 对工具输出的一贯思路:不要把底层数据格式原封不动暴露给模型,而要先转换成更贴近任务语义的结果。
Dedup:Read 如何避免重新发送未更改的文件
Read 会把最近一次读取的内容、时间戳和范围信息缓存到 readFileState。如果同一会话里再次读取同一文件、同一范围,且磁盘上的 mtime 没变,它就不会把完整内容再发一遍。
取而代之的是一条很短的提示消息,告诉模型“文件没有变化,继续引用上一次的读取结果即可”。这能显著减少重复 token 开销,尤其是在模型反复确认同一文件内容时。
被阻止的设备路径
某些路径根本不需要尝试读取,只要看字符串就知道会出事,例如 /dev/zero 会无穷输出,/dev/stdin / /dev/tty 可能会一直阻塞等待输入。
因此 Claude Code 直接在路径层面把这些设备文件拦掉,避免读取工具把整个进程拖挂起。这是一种非常便宜但很有效的防御。
限制优先级:env var > GrowthBook > default
Read 的大小和 token 上限并不是单点常量,而是一条优先级链:环境变量最高,其次是实验配置,最后才是硬编码默认值。
但这些值一旦在本次会话中被解析出来,就会被记忆住,不会随着实验开关热更新而变化。这保证了同一会话里的行为边界是稳定的。
写是一个 全部内容替换。 它要么创建一个新文件,要么完全覆盖现有文件。 对于对现有文件进行外科手术更改,提示明确表示首选编辑 - 它只发送差异。
先读后写门
Write 在已有文件上执行时,首先会检查 readFileState:这个文件有没有在当前会话里被完整读过、那次读取是不是完整视图、读取之后文件有没有被别人改动过。三种情况任意一种不满足,都会直接拒绝写入。
因此“我刚刚看过一小段就去覆盖整份文件”在这套模型里是不被允许的。Claude Code 强迫模型先建立完整上下文,再去做修改,这是它能减少误写、误覆盖和竞态错误的根本原因。
errorCode: 2“文件尚未读取。请先读取,然后再写入。”
errorCode: 2,同样的消息部分读取被标记 isPartialView: true — Write 拒绝它们以防止覆盖从未见过的内容。
errorCode: 3“文件自读取以来已被用户或 linter 修改。在尝试写入之前再次读取它。”
行结束策略
Write 对行结束符采取的是明确策略,而不是‘尽量猜原文件风格’。它统一写成 LF,目的不是格式洁癖,而是避免跨平台、跨仓库推断时出现静默损坏。
这里牺牲了一点‘跟随旧文件风格’的柔性,换来的是更强的确定性。对自动化写文件工具来说,这通常是值得的。
原子写入顺序
读取当前内容和写入磁盘之间的关键部分尽可能保持同步,以防止并发编辑交错:
// 1. mkdir (async, before critical section — safe) await fs.mkdir(dir) // 2. Backup for file history (async, keyed on content hash — idempotent) await fileHistoryTrackEdit(...) // 3. Sync read + staleness check (critical section starts) meta = readFileSyncWithMetadata(fullFilePath) if (lastWriteTime > lastRead.timestamp) throw FILE_UNEXPECTEDLY_MODIFIED_ERROR // 4. Write to disk (critical section ends) writeTextContent(fullFilePath, content, enc, 'LF')
写入成功后会发生什么
写入成功并不等于“把字节刷到磁盘就结束了”。Claude Code 还会同步更新读取缓存、发送 LSP 的 didChange/didSave、通知 VS Code 差异面板,并记录与 CLAUDE.md 相关的遥测事件。
也就是说,Write / Edit 不只是文件 I/O,它们还是整个编辑生态的协调点:状态缓存、编辑器集成、语言服务刷新都在这里串起来。
编辑执行 精确的字符串替换: 寻找 old_string 在文件中并将其替换为 new_string。 仅传输差异——对于小更改来说比写入便宜得多。
琴弦更换机制
搜索是直接子字符串匹配。 如果字符串出现多次,编辑会拒绝并出现错误 9 unless replace_all: true 已通过。 这迫使克劳德提供足够的周围环境以确保不含糊。
// Unambiguous — narrow match Edit({ file_path: "/src/config.ts", old_string: "const MAX_RETRIES = 3", new_string: "const MAX_RETRIES = 5", }) // Replace all occurrences of a variable name Edit({ file_path: "/src/utils.ts", old_string: "oldFunctionName", new_string: "newFunctionName", replace_all: true, })
报价标准化
Edit 并不要求 old_string 和磁盘字节级完全一致。因为模型常常只能稳定输出 ASCII 直引号,而真实文件里可能是弯引号、排版引号,直接做字面匹配很容易失败。
所以它会先把两边都规范化到统一引号风格上进行查找,找到真实片段后,再把原文件中的引号风格映射回 new_string。这让编辑既能命中正确位置,又不会悄悄改坏文档原本的排版。
const msg = "Hello, world" // "Hello" uses U+201C/U+201D
const msg = "Hello, world" // Claude can only type ASCII "
findActualString() 将文件和搜索字符串规范化为直引号,找到匹配项,然后返回 原始花引号版本 从文件中。 然后 preserveQuoteStyle() 将相同的大引号样式应用于 new_string,因此编辑后会保留文件的版式。
// normalizeQuotes() under the hood: str .replaceAll('\u2018', "'") // left single curly .replaceAll('\u2019', "'") // right single curly .replaceAll('\u201C', '"') // left double curly .replaceAll('\u201D', '"') // right double curly
preserveQuoteStyle() 不会盲目地转换每一个 ' 到一个弯引号。 当单引号两侧有两个 Unicode 字母时(例如, don't, it's),它被视为撇号并得到 right 单引号而不是左引号。 启发式用途 /\p{L}/u 正确检测 Unicode 字母。
通过编辑创建新文件
Edit 也能创建新文件,但方式非常显式:只有 old_string: "" 且目标不存在时才成立。这样做是为了避免模型把“创建新文件”和“改已有文件”这两类动作混在一起。
一旦目标文件已经存在且非空,Edit 就不会再把自己当成‘创建器’。这让工具意图边界更加清晰。
陈旧性检查(与写入相同)
Edit 共享 Write 的过时逻辑。 临界区同步读取文件,检查 mtime readFileState,并抛出 FILE_UNEXPECTEDLY_MODIFIED_ERROR 如果文件在上次读取后发生更改。 Windows 完整阅读的内容比较回退也适用于此处。
消毒台
Edit 里这层 sanitize 兼容,主要是在兜 API 对某些 XML 风格标签的净化行为。否则模型明明看到的是一串文本,回传时却因为服务端清洗过而找不到原始片段。
因此这里不是在做额外魔法,而是在补平‘模型看到的文本’与‘磁盘上的原始文本’之间可能出现的轻微偏差。
尾随空白剥离
在实际应用编辑前,Claude Code 会清掉 new_string 每一行末尾的多余空白,防止模型不小心带入看不见的尾空格。
Markdown 文件是例外,因为两个尾随空格在 Markdown 里本来就有语义。这里的策略体现的是“默认帮你修复无意噪声,但不要破坏本身有语义的格式”。
同样的改变——将常量从 3 更新为 5——以两种方式完成。 对于现有文件,编辑几乎总是正确的选择。
// Must include the ENTIRE file content Write({ file_path: "/src/config.ts", content: `import { z } from 'zod' export const config = { maxRetries: 5, // changed timeout: 3000, endpoint: "https://api.example.com", } ` }) // Sends the whole file every time. // 150+ lines → 150+ lines of tokens.
// Only the changed fragment needed Edit({ file_path: "/src/config.ts", old_string: "maxRetries: 3,", new_string: "maxRetries: 5,", }) // Sends ~20 characters. // Costs a fraction of Write. // //
- 创建一个 从头开始新文件
- 将文件内容替换为 结构不同的版本 (例如,完全重写配置)
- 这些变化如此广泛,以至于建立了一个独特的
old_string无论如何都需要大部分文件
写入和编辑都强制执行相同的三阶段合同。 了解它可以防止最常见的工具错误。
这家店 { content, timestamp, offset, limit } in readFileState。 部分阅读(isPartialView: true) 不满足要求 — 您必须阅读整个文件。
检查 readFileState。 如果没有先前读取、部分读取或更新时间比上次读取更新,则拒绝。 在 Windows 上,还在拒绝之前比较内容的完整读取。
陈旧性检查在内部第二次运行 call() 通过同步读取来关闭之间的竞争窗口 validateInput 和实际写的。 如果文件在中间发生更改, FILE_UNEXPECTEDLY_MODIFIED_ERROR 被抛出。
存储新的移动时间和内容,以便同一会话中的下一次写入不会失败。
要点
- Read 是唯一只读、并发安全的工具。 写入和编辑都需要事先完全读取任何现有文件。
- 当 mtime 与缓存的时间戳匹配时,Read 的重复数据删除系统(约 18% 的命中率)会返回约 100 字节的存根,从而避免重新发送未更改的文件。
- Read 的 25 000 个代币上限首先使用快速粗略估计; 仅当可疑时,它才会调用 API 来进行准确计数。
- 写入截断经过测试并恢复:向 256 KB 门抛出比发送模型无论如何都无法使用的 25K 内容令牌更便宜。
- Edit 的引号标准化处理 API 对大引号的清理 —
findActualString()使用直引号进行搜索,但写回弯引号以保留文件排版。 - 写入和编辑都运行两次陈旧性检查:一次
validateInput(预先许可)并且一旦进入call()(原子,同步)关闭竞争窗口。 - PDF 需要明确
pages文件超过 10 页的范围; 每次调用最多 20 页。 - Jupyter 笔记本的本机读取方式是
Read; 它们无法通过编辑工具进行编辑 - 使用NotebookEditinstead.
知识检查
Write 和 Edit 都要求先读取现有文件?Read(offset, limit) 读了一小段文件,随后直接调用 Write,结果通常会怎样?