Claude Code 的 Edit 工具为什么不会改错文件

Claude Code 的 Edit 工具接口极简: 给一个 old_string, 给一个 new_string, 在文件里找到前者替换成后者。听上去就是一个 str.replace() 的事。但在 LLM Agent 的语境下, 这个看似平凡的操作背后藏着一整套从字符串清洗到并发安全的工程。模型会把行号塞进替换字符串, 会凭空产生弯引号, 会在用户审批的间隙里被外部工具改了目标文件。Edit 工具要在这些情况下保持正确, 比 find-and-replace 复杂得多。

从行为上观察, Edit 工具的执行可以拆成三个阶段: API 层预处理(在工具拿到输入之前), 输入校验(展示权限对话框之前), 和实际写入(用户同意之后)。每个阶段各自处理一类问题, 且刻意保持了特定的同步/异步边界。

反序列化: 模型看到的不是文件的真实内容

Read 工具返回文件内容时, 会加上类似 cat -n 格式的行号前缀。紧凑模式下格式是 42\tfunction foo(), 标准模式下则是六位右对齐加箭头符号。模型在构造 old_string 时, 经常会把这些行号前缀一起复制进去。这不是模型的 bug, 它看到的上下文里行号就是内容的一部分, 没有理由不复制。

行号问题通过正则匹配两种格式的前缀并剥离来处理。但行号之外还有一类更隐蔽的问题: 反序列化。

Claude 的 API 在将工具结果返回给模型之前, 会对特定标签做 sanitize, 把一些 XML 标签缩写成短形式, 甚至把特定的换行+关键词组合截断。这么做是为了防止模型输出中的这些 token 被误判为协议控制指令。当模型试图编辑一个恰好包含这些标签的文件时, 它只能输出缩写形式, 因为它根本看不到完整内容。

1
2
3
4
5
Sanitization (API -> Model):         Desanitization (Model -> Tool):

<function_results> --> <fnr> <fnr> --> <function_results>
<system> --> <s> <s> --> <system>
\n\nHuman: --> \n\nH: \n\nH: --> \n\nHuman:

Edit 工具的解决方案是在 API 预处理管线里, 对 old_string 和 new_string 做反向映射。这个处理发生在工具拿到输入之前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def normalize_file_edit_input(tool_input):
old_str = tool_input["old_string"]
new_str = tool_input["new_string"]

# Step 1: desanitize XML tags
for short, full in DESANITIZATION_MAP.items():
old_str = old_str.replace(short, full)
new_str = new_str.replace(short, full)

# Step 2: strip line number prefixes
old_str = strip_line_number_prefix(old_str)
new_str = strip_line_number_prefix(new_str)

return {**tool_input, "old_string": old_str, "new_string": new_str}

这套处理在工具收到输入之前就完成了, 工具层面完全无感知。从工具的视角看, 输入永远是干净的。

弯引号: 一种你没想过的字符差异

模型有时会产生弯引号(curly quotes, 即 " " ' ')代替直引号(" ')。这个问题的根源可能在训练数据或 tokenizer 的映射里, 但对 Edit 工具来说, 问题很具体: 如果文件里写的是 const name = "hello", 模型给出的 old_string 却是 const name = \u201chello\u201d, 精确匹配就会失败。

Edit 工具的处理策略是两步:

1
2
3
4
5
6
7
8
9
10
11
12
def find_actual_string(file_content, old_string):
# Step 1: try exact match
if old_string in file_content:
return old_string

# Step 2: normalize curly quotes and retry
normalized_old = normalize_quotes(old_string)
normalized_content = normalize_quotes(file_content)
if normalized_old in normalized_content:
return find_original_span(file_content, normalized_content, normalized_old)

return None # truly not found

如果是通过引号归一化匹配成功的, 还需要对 new_string 做反向处理: 用 preserveQuoteStyle() 把 new_string 中的直引号转换回文件原来使用的弯引号风格, 保持文件的引号风格一致。这个细节决定了 Edit 工具不只是改对了内容, 还维护了代码风格。

竞态防御: 两道检查的不同目的

Edit 工具面临一个经典的 TOCTOU(Time of Check to Time of Use)问题: 模型在 turn 1 读了文件, 在 turn 2 发出 Edit 命令, 但中间可能过了几秒甚至几分钟。在这段时间里, linter 可能自动格式化了文件, 用户可能手动改了代码, 或者另一个并行的 Agent 可能也在编辑同一个文件。

Claude Code 用两道检查来应对, 每道检查的目的不同:

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
Timeline:
Model reads file
|
v
[Time passes: linter runs, user edits, ...]
|
v
Model sends Edit command
|
v
+---CHECK 1: validateInput() (async)-----+
| Compare mtime vs last read timestamp |
| Purpose: UX guard |
| -> Don't show stale permission dialog |
+----------------------------------------+
|
v
[Permission dialog shown to user]
[User reviews diff, clicks approve]
[More time passes...]
|
v
+---CHECK 2: call() (sync, atomic)-------+
| Compare mtime + content again |
| Purpose: Data integrity guard |
| -> No async ops between check & write |
+----------------------------------------+
|
v
Write to disk

Check 1 在展示权限对话框之前运行。它的目的是用户体验: 如果文件已经变了, 不要浪费用户时间去 review 一个注定失败的 diff。这个检查是异步的, 比较文件的 mtime 和上次读取的时间戳。

Check 2 在用户同意之后、实际写入之前运行。它的目的是数据完整性。代码注释明确警告: "Please avoid async operations between here and writing to disk to preserve atomicity." 在 Check 2 和 writeTextContent() 之间不允许任何 await, 确保检查和写入之间没有让出执行权的空隙。

两道检查之间的时间窗口可以很长: 用户可能离开几分钟再回来点确认。在这段时间里文件完全可能被改动, 所以第二道检查是必须的。

还有一个 Windows 特有的边界情况: 云同步服务(OneDrive, Dropbox)和杀毒软件会频繁触碰文件的 mtime, 即使文件内容没有变化。如果单纯比较 mtime, 这些场景会产生大量误报。所以两道检查都有一个 fallback 逻辑: 如果 mtime 变了但文件是完整读取的(没有 offset/limit), 就比较实际内容。内容一致则视为 false positive, 放行。

1
2
3
4
5
6
7
8
9
10
def check_staleness(file_path, read_state):
current_mtime = get_file_mtime(file_path)
if current_mtime > read_state.timestamp:
# mtime changed — but is content actually different?
if read_state.is_full_read:
current_content = read_file_sync(file_path)
if current_content == read_state.content:
return False # false positive (cloud sync, antivirus)
return True # genuinely stale
return False

读后才能改: 一个刻意的约束

Edit 工具有一条硬性前置条件: 必须先读过文件才能编辑。如果 readFileState 中没有目标文件的记录, 工具直接拒绝执行并返回错误信息, 提示模型先用 Read 工具读取文件。

这个约束看起来多余, 模型在实践中确实几乎总是先读后改。但它防住了一类微妙的问题: 模型有时会从对话历史的早期上下文中"记住"文件内容, 跳过 Read 直接尝试 Edit。如果文件在那之后被修改过, 模型记住的内容已经过时, edit 可能基于错误的假设。

更严格的是, 如果 Read 时使用了 offset/limit(只读了文件的一部分), Edit 工具也会拒绝。因为部分读取意味着模型没有看到完整的文件上下文, 它的 old_string 可能不是唯一的, 或者它不知道编辑位置的完整上下文。

这个约束和竞态检查配合形成一个闭环: Read 建立时间戳基线, 竞态检查验证基线是否仍然有效, 写入在验证通过后立即执行。三步之间的契约是严格的。

编码和换行符保持

文件的编码和换行符风格在编辑过程中会被透明地保持。读取时检测原始编码(UTF-8 或 UTF-16LE)和换行符(LF 或 CRLF), 内部统一转换为 LF 进行匹配, 写入时再转换回原始格式。

模型永远只看到 LF 换行的内容, 不需要知道目标文件是 Windows 风格还是 Unix 风格。这消除了一类常见的编辑错误: 模型在 CRLF 文件中插入 LF 行, 导致文件出现混合换行符。

还有一个小但体贴的细节: 当 new_string 为空(删除操作)且 old_string 不以换行符结尾时, 如果文件中 old_string 后面紧跟一个换行符, 系统会连同那个换行符一起删除。这防止了删除一行内容后留下一个空行的问题。

编辑工具的反直觉之处

回过头看, Edit 工具最有意思的地方不是它做了什么, 而是它选择在哪些层面做。

反序列化在 API 层, 不在工具层。这意味着工具永远不需要知道 sanitization 的存在, 关注点被严格分离。竞态检查在两个不同的阶段, 目的完全不同: 一个优化用户体验, 一个保证数据安全。弯引号处理先归一化再反归一化, 保证匹配语义正确的同时维护文件风格。

这些决策的共同点是: 每一个都在回答同一个问题, 模型在编辑意图上是对的, 但在字面表达上可能有偏差, 系统的职责是弥合这个偏差, 同时不破坏任何现有的文件状态。

对一个每天被调用几百万次的工具来说, 这些边界情况不是理论上的可能性, 而是每天都在发生的事。一个 str.replace() 搞不定的, 恰恰是那些让代码编辑从"能用"到"可靠"之间的距离。