文件编辑的哲学:为什么"拒绝执行"比"尽力完成"更安全
这是《写一个 Coding Agent Harness and TUI 》系列的第 4 篇。 Agent 现在能调工具了——但它只有 ReadFile 和 Bash。它能读代码,不能改代码。 这篇给它装上 6 个文件编辑工具。但不是"每个工具怎么实现"—— 是"每个工具的设计为什么选了这条路而不是那条路"。
有一个 bug,只有当你用 agent 而不是人来做代码编辑时才会出现。
你让 agent “把 calculate 重命名为 calculate_total"。agent 调了 search_replace。代码里有三处 calculate——一处是函数定义,一处是局部变量,一处是注释里的引用。
如果你自己在 IDE 里做这个重构,你的眼睛扫过去,零秒就判断出:改函数定义和局部变量,注释里的那个留着不动——因为那是文档说明,用的是泛称。这是直觉,是经验,是对代码含义的理解。
agent 没有直觉。它调 search_replace 传了一个 old_string = "calculate"。三处匹配。如果你写的 search_replace 是 best-effort 模式——它会猜。全改?改第一处?用 heuristic 选"最可能"的那一处?不管选哪个,有一件事是确定的:agent 不知道它猜错了。 它继续往下执行,基于一个可能错误的文件状态做后续编辑。十分钟后你发现函数签名对不上调用,往回追溯——最初的 search_replace 改了不该改的地方。
这个 bug 不是代码 bug。它是设计哲学 bug。 你让 search_replace 在信息不完整的情况下做了决策。而决策权属于 LLM——不是你的 tool。
这篇分析的 6 个设计决策,每个都在回答同一个问题:在信息不完整的情况下,谁来做决策?工具(你的代码)还是 LLM?
〇、元问题:工具和 LLM 之间的决策权分配
一条决策权的光谱
工具全权代劳 ──────────────────────────────── 工具完全拒绝
(best-effort) (fail-stop)
↑ ↑
"我帮你猜" "你自己想清楚再来"
你站在哪一端?直觉告诉你站中间——“能帮就帮,帮不了再说”。但中间是最差的位置。 因为 agent 不知道你什么时候帮了它、什么时候没帮。agent 看到操作成功了,它以为它的参数是正确的——但实际上是你替它补全了信息。agent 永远学不会给正确的参数,因为它永远不需要。
一个具体例子:为什么 line-based patch 对 agent 是灾难
// 方案 A:line-based patch
// "把第 10-12 行替换为这三行新代码"
// start_line = 10, end_line = 12, new_lines = [...]
// 方案 B:search_replace(内容匹配)
// old_string = "fn calculate() -> f64 {",
// new_string = "fn calculate_total() -> f64 {",
line-based patch 的问题不是它"不够好”——是它在 agent 场景下有一个人用时不存在的故障模式。
人在用 line-based patch 时:(a) 人是看着文件编辑的,眼睛实时验证行号正确,(b) 人在同一次编辑会话里不会做"先插入几行再基于旧行号 patch"这种自相矛盾的操作。
agent 在用一个典型的 Turn 里:第一步 write_file("src/config.rs", 新增 10 行) → 第二步 edit_file("src/main.rs", start_line=45)。但 src/main.rs 第 45 行是 agent 在第一步之前看的——第一步之后文件没变,所以没问题。然而如果第一步改的是同一个文件——行号全变了。
agent 不需要犯错就能让 line-based patch 打到错误位置。 它只需要在做 patch 之前做了任何对同一文件的写操作。
search_replace 解决了这个问题——它不依赖行号,它依赖"找到这段内容"。但它引入了新问题:如果这段内容出现了多次怎么办?
这就是本篇的核心冲突:search_replace 的精确性是有代价的——它要求 old_string 唯一。而"不唯一时怎么处理"这个决策,定义了你的 agent 和 LLM 之间信任关系的性质。
一、search_replace 的唯一性校验——为什么是"拒绝"而不是"猜"
场景:LLM 传了一个不唯一的 old_string
你:把 calculate 重命名为 calculate_total
LLM → search_replace(old_string="calculate", new_string="calculate_total")
代码中:
src/main.rs:10: fn calculate() -> f64 { ← 函数定义
src/main.rs:25: let calculate = total / count; ← 局部变量(也是calculate)
README.md:15: 调用 calculate 函数获取结果 ← 文档引用
三处匹配。三种处理方式:
方案 A:全改(best-effort)
把三处全部替换。结果:函数名改了 ✓。局部变量名也改了——但这个局部变量跟函数没关系,LLM 是在给局部变量起名叫 calculate,不是引用函数。改了可能没问题(如果恰好局部变量也只在这个上下文用),也可能引入 bug(如果 calculate 在另一个作用域里还有别的含义)。
关键不是"这次改对了吗"——是"agent 不知道这次改对了没有"。 agent 执行了 search_replace,看到 success,继续下一步。它没有意识到自己改了三处——它以为只改了函数定义。如果局部变量被错误改名引入了一个细微 bug——agent 在 20 轮之后才会发现,甚至永远不发现。
方案 B:改第一处(heuristic best-effort)
只改第一处匹配。结果:改了函数定义 ✓。但局部变量和文档引用没改——如果 LLM 的意图确实是"把所有 calculate 改成 calculate_total",那两处没改。
同样的问题:agent 不知道它只改了一处。 它以为成功了。如果 README 里的文档需要同步更新——agent 不知道没更新。
方案 C:返回错误(fail-safe)
let count = original.matches(&old_string).count();
if count == 0 {
return Err("未找到要替换的内容。请确认文件内容和搜索文本。");
}
if count > 1 {
return Err(format!(
"找到 {} 处匹配。请提供更多上下文使 old_string 唯一。\
例如包含前后行。当前搜索:\"{}\"",
count, old_string
));
}
let modified = original.replacen(&old_string, &new_string, 1);
fs::write(&path, &modified)?;
agent 看到的是错误消息,不是成功结果。 它被迫处理这个错误:
"找到 3 处匹配。请提供更多上下文使 old_string 唯一。"
agent 的下一步:LLM 读了这条错误 → 理解"我的 old_string 太短了" → 重新构造 old_string = "fn calculate() -> f64 {\n let sum: f64 = prices.iter().sum();\n sum / count\n}" → 这次只有一处匹配 → 替换成功。
为什么 C 是对的——不是"更安全",是"让 LLM 承担决策责任"
A 和 B 的共同问题:工具替 LLM 做了决策。 工具在信息不完整的情况下选了一个行动(全改或改第一处),然后告诉 LLM “成功了”。LLM 基于这个假成功继续行动——它不知道工具替它做了选择。
C 的核心洞察:LLM 才是应该做决策的实体。 工具的角色不是"当 LLM 给的信息不够时帮它猜"——是"告诉 LLM 信息不够,让它提供更精确的信息"。
这不是"严格模式 vs 宽容模式"。这是**“决策权归属"问题。** 工具没有理解代码含义的能力——它不应该做语义层面的决策。LLM 有——但它需要正确的反馈才能做出正确的决策。
这个设计在 grok-build 里怎么体现的
grok-build 的 search_replace 同时支持两种模式——精确匹配和可控模糊匹配。模糊匹配有一个 fuzzy_threshold 参数,允许一定百分比的字符差异。但这个参数是 LLM 可控的——不是工具自动启用。
如果 LLM 觉得"我给的 old_string 足够好了但就是担心空格差异”,它可以传 fuzzy_threshold: 0.1 来容忍 10% 的差异。决策权在 LLM 手里,不在工具代码里。
grok-build:
tools/src/implementations/grok_build/search_replace/
二、WriteFile 和 search_replace 的分工——不是按"新文件 vs 旧文件"
直觉的分工
新文件或小文件 → WriteFile。大文件中改少量代码 → search_replace。
这个直觉是对的,但不完整。 它只说"什么时候用什么"。没说"为什么这样分工"。
真正的分工依据:token 消耗模型
search_replace 优势在于不需要传整个文件内容。只需要 old_string + new_string。对于 500 行的文件改一行,search_replace 用 ~50 token。WriteFile 要重写整个文件——~500 token。节省 90% token。
但 search_replace 有一个隐藏成本:如果 LLM 给的 old_string 不唯一,它需要重试。 重试意味着额外的 LLM 调用 + 额外的 token 消耗。一次重试可能吃掉之前节省的所有 token。
WriteFile 没有重试成本。 LLM 输出完整文件内容,一次写入,不存在"匹配度"问题。
分工的边界线在哪
经验法则是:如果一次 search_replace 成功的概率 > 80%,用它。否则用 WriteFile。
影响成功率的主要因素是 old_string 的唯一性。一个包含 3-5 行上下文代码的 old_string,在 500 行文件中几乎肯定唯一。一个包含 1 行的 old_string,唯一性概率可能低于 50%。
但这不应该由工具代码来决定。应该由 LLM 来决定。 LLM 在看到文件内容后(通过 read_file),自己判断 old_string 的唯一性风险。如果风险高,它会选择 WriteFile。如果风险低,它会选择 search_replace。
工具的职责:在两个工具的 description 里清晰说明各自的适用场景和代价。不要替 LLM 做决策——给它足够的信息让它自己做决策。
grok-build 的实现
grok-build 在两个工具的 description 里明确了这个分工。WriteFile 的 description 提到"适用于新建文件或需要重写大部分内容的情况"。EditFile(search_replace)的 description 提到"适用于修改少量代码,old_string 必须唯一"。
三、grep 的输出格式——不是"省 token",是"说 LLM 的母语"
两种格式对比
// 结构化 JSON(50 条结果,每条 70 字符不含内容,重复字段名)
{"file":"src/main.rs","line":10,"column":0,"content":"fn calculate() -> f64 {"}
{"file":"src/main.rs","line":25,"column":0,"content":" let calculate = total / count;"}
// ... 50 条 ≈ 3500+ token
// Unix 格式(50 条结果,每条 ~40 字符)
src/main.rs:10: fn calculate() -> f64 {
src/main.rs:25: let calculate = total / count;
// ... 50 条 ≈ 2000 token
Unix 格式节省 ~40% token。但 token 节省不是主要原因。
真正的原因:LLM 的预训练数据里有无数这种格式
LLM 在训练时见过数百万行 grep 输出。file:line: content 对 LLM 来说不是"另一种格式"——是"原生格式"。它的 tokenizer 对这种格式的编码效率极高(常见模式如 src/main.rs: 可能被编码成一个 token)。它的注意力机制在这种格式上训练得更充分——提取"哪个文件、哪一行、什么内容"这三个信息的速度和准确度更高。
你给 LLM 看结构化 JSON,你逼它走一条它在训练中较少见的解析路径。 它也能解析——但准确度和速度比 Unix 格式低。
这引申出一个更重要的原则
给 LLM 的数据格式应该优先选择它在预训练数据中高频出现的格式。 这不是"喂它习惯的食物"——是"利用它已经训练好的神经通路,让它的注意力更有效地聚焦在信息内容上,而不是格式解析上。"
对 grep 输出是 file:line:content。对代码是 markdown 代码块。对错误消息是自然语言句子(不是 error code 列表)。对路径是 Unix 风格(不是 Windows 反斜杠)。
输入和输出为什么可以不同格式
LLM API 协议要求 tool_call 的参数是 JSON——这是协议约束,不能改。所以输入是 JSON:{"pattern": "fn calculate", "path": "src/"}。
输出不受协议约束——可以选任何格式。输入端受协议约束,输出端受 LLM 认知约束。两个约束独立,所以两种格式独立。 不要因为输入是 JSON 就把输出也做成 JSON。
四、git diff 为什么是独立工具而不是自动触发
自动触发的表面理由
“agent 每改一个文件都应该看看自己改了什么”——听起来很合理。每次 WriteFile 或 EditFile 后自动跑 git diff,结果自动注入对话历史。
自动触发在 10 轮之后的实际情况
agent 在一个 Turn 里改了 8 个文件。自动触发产生了 8 段 diff。每段 diff 平均 15 行。8 × 15 = 120 行 diff 被注入到上下文窗口。
这 120 行里,LLM 真正关心的可能就 2 段(14 行 = 30 行)。另外 6 段(90 行)是噪音。 但 LLM 的注意力机制不能"跳过"不关心的内容——transformer 的 self-attention 是全局的。90 行不关心的 diff 仍然消耗注意力,稀释了 LLM 对重要信息的关注。
更隐蔽的问题:自动注入的 diff 给了 LLM 一个假信号——“这些变更已经验证过了”。 但实际上 diff 只是展示了变更内容,没有验证变更是否正确。LLM 可能跳过语义验证(“这些变更是否符合用户意图”),因为它看到 diff 已经被自动提供——它以为系统在告诉它"这些东西值得你看"。
独立工具的优势:LLM 自己决定什么时候需要验证
LLM 改完 8 个文件后,它可以选择:统一调一次 git diff 看整体变更;也可以改一个文件调一次 diff。关键是谁在决定——LLM。
LLM 在改第 3 个文件时可能不确定改对了,调一次 git diff 确认——然后基于 diff 结果决定第 4 个文件怎么改。这种反馈循环是 agent 智能的来源。自动触发破坏了这种自主性——它替 LLM 决定了"你应该在每个文件后看 diff"。
和 search_replace 唯一性校验的比较
这两个设计是同一条原则在不同场景下的实例化:
- search_replace:工具不应该替 LLM 决定"改哪处" → 工具返回错误,让 LLM 提供更精确的信息
- git diff:工具不应该替 LLM 决定"什么时候验证" → 工具是 LLM 按需调用的,不是自动触发的
工具提供操作能力。LLM 控制操作时机。工具不替 LLM 做语义决策。
五、Glob 和 fs_safety——发现性与保护性的两端
Glob:让 agent 能"看到"项目结构
agent 需要了解项目有什么文件。read_file 需要知道路径——但 agent 不知道路径,它需要发现路径。
Glob 给 agent 模式匹配的文件发现能力。glob("src/**/*.rs") → 所有 Rust 源文件列表。
设计要点:
- 结果上限 200 条——防止大型项目返回几万条撑爆上下文
- 返回路径列表,不返回内容——这是"发现"工具,不是"读取"工具
- 排除常见忽略目录——
.git、target、node_modules——这些不是 agent 应该关心的
fs_safety:保护 agent 不越界
独立模块(不是 trait 方法,原因在第 2 篇分析过了)。三个校验:
路径穿越检测:
let canonical = target.canonicalize()?;
if !canonical.starts_with(&self.work_dir) {
return Err(format!(
"路径 '{}' 在工作目录之外。agent 只能访问 {} 内的文件。",
target.display(), self.work_dir.display()
));
}
二进制检测:前 512 字节含 null byte → 二进制文件 → 跳过。agent 读不了 target/debug/myapp 这个二进制。
文件大小上限:默认 1MB。一个 500MB 的日志文件不应该被读到上下文窗口里。
这三个校验的共性:每个校验失败时返回的错误消息都包含"为什么被拒绝"和"怎么解决"。 "路径在工作目录之外" 而不是 "access denied"。"二进制文件无法读取,请使用 bash 命令处理" 而不是 "error"。
错误消息格式 = agent 自我纠正的基础设施。 这一点在第 2 篇工具系统设计里讨论过,在文件安全层面同样适用。
六、代码
~200 行,tutorial-code/part-04-file-edit/。6 个工具的结构:
src/tools/
├── write_file.rs ← 创建/覆盖文件,自动建父目录
├── edit_file.rs ← search_replace,唯一性校验 + 详细错误消息
├── grep.rs ← walkdir + regex,file:line:content 输出
├── glob.rs ← glob 模式匹配,200 条上限
├── git_diff.rs ← git diff --no-color,缓存区 + 工作区
└── fs_safety.rs ← 路径穿越检测 + 二进制检测 + 大小上限
七、总结:工具 = 操作能力 + 信息反馈。不是操作能力 + 自动决策。
| 工具 | 如果工具替 LLM 决策 | 实际设计(工具提供信息,LLM 决策) |
|---|---|---|
| search_replace | 模糊匹配 → 猜改哪 | 唯一性校验 → “3 处匹配,请精确化” |
| Write/Edit 分工 | 工具按文件大小自动选 | LLM 根据 description 自己判断 risk/reward |
| grep 输出 | JSON → 机器友好 | file:line:content → LLM 友好 |
| git diff | 每次写后自动触发 | LLM 按需调用,自控信噪比 |
| fs_safety | 拒绝 + “access denied” | 拒绝 + “为什么 + 怎么解决” |
决策权在 LLM,信息提供在工具。 这个原则在第 7 篇的安全模型里会推到极致——不是"agent 能做什么" vs “agent 不能做什么”,是"在什么条件下 agent 的自主决策是安全的"。
完整代码在 tutorial-code/part-04-file-edit/。
🎓 6 个工具够用了吗? 本系列覆盖了文件编辑的核心架构。扫码关注公众号,回复"agent进阶"获取完整源码 + 视频讲解 + 1v1 代码走读。

