第 5 章 文件操作工具族 —— Read/Write/Edit/Glob/Grep
核心论点:coding agent 的文件操作不应通过通用 shell 完成,而应拆分为一组专用工具(specialized tool),每个工具对应一个原子操作,以获得可审计性、权限粒度控制和输出格式的确定性。
5.1 为什么不用 shell 做所有文件操作
直觉上,agent 已经有了 bash 工具,cat、sed、find、grep 全能用——为什么还要专门封装 Read/Write/Edit/Glob/Grep?
三个工程理由:
5.1.1 可审计性(Auditability)
bash 是黑盒。一条 bash -c "cat foo.txt && sed -i 's/old/new/' bar.txt && rm baz.txt" 同时包含读、改、删三个操作,审计系统无法在执行前区分意图。专用工具将每个操作拆成独立 tool_call,权限审批系统可以对每一次调用单独决策:
read_file("foo.txt")→ 自动放行edit_file("bar.txt", old, new)→ 需审批bash("rm baz.txt")→ 拒绝
Claude Code 的权限模型正是基于这一点:文件读取默认放行,文件写入需用户确认或白名单,bash 执行按危险等级分层。
5.1.2 权限粒度(Permission Granularity)
通用 shell 的权限控制只能做到"允许/禁止执行命令"。专用工具可以在参数级别实施策略:
- Read 可以限制读取范围(offset/limit),防止一次读入 10 万行文件撑爆上下文
- Write 可以强制"先 Read 再 Write"的前置条件,防止盲写
- Edit 的
old_string参数天然要求 agent 先理解文件当前内容
5.1.3 输出控制(Output Control)
shell 命令的输出格式不可预测。cat 输出没有行号,grep 的输出格式因平台而异,find 的结果排序不稳定。专用工具保证:
- Read 始终返回
cat -n格式(带行号),agent 可以精确引用行号 - Grep 的结果结构化为
(文件路径, 行号, 匹配内容)三元组 - Glob 按修改时间排序,返回绝对路径列表
这种确定性输出使 LLM 更容易解析,减少因格式变异导致的错误推理。
5.2 各工具的实现细节
5.2.1 Read —— 带行号的分页读取
Read 是 agent 感知文件系统的主要入口。核心设计点:
行号标注。所有实现都使用 cat -n 风格的行号输出。行号从 1 开始,固定宽度(通常 6 字符),右对齐。这不是装饰——agent 在后续 Edit 操作中需要通过行号定位修改位置。
分页参数。Read 支持 offset(起始行)和 limit(最大行数)两个参数。典型实现的默认值:
# Python 实现
DEFAULT_READ_OFFSET = 0
DEFAULT_READ_LIMIT = 100分页是必要的安全措施。一个 100 万行的日志文件如果全量读入,会直接耗尽上下文窗口。工具层的截断比依赖 LLM 自行判断可靠得多。
超长行处理。当单行超过 5000 字符时(典型场景:minified JS、JSONL),Read 会将其拆分为多个子行,用续行标记(如 5.1, 5.2)编号。这些续行也计入 limit 配额。
多模态支持。Read 不限于文本。对于图片文件(.png/.jpg/.gif/.webp),工具检测 MIME 类型后返回 base64 编码的图像内容块,利用 LLM 的多模态能力直接"看"图片:
# Python 实现
if file_type != "text":
mime_type = mimetypes.guess_type("file" + Path(path).suffix)[0]
return ToolMessage(
content_blocks=[{
"type": file_type,
"base64": content,
"mime_type": mime_type
}],
name="read_file",
tool_call_id=tool_call_id,
)PDF 文件类似,可指定页码范围(如 pages="1-5"),每次请求最多 20 页。
空文件检测。文件存在但内容为空时,返回特定警告而非空字符串,避免 agent 误判为读取失败:
# Python 实现
EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"Token 级截断。除行数截断外,Read 还实施基于 token 估算的截断。按 4 字符/token 的保守估计,当内容超过 token 阈值时追加截断提示,建议 agent 使用 jq 等工具格式化后重读:
# Python 实现
NUM_CHARS_PER_TOKEN = 4
if len(content) >= NUM_CHARS_PER_TOKEN * token_limit:
max_length = NUM_CHARS_PER_TOKEN * token_limit - len(truncation_msg)
content = content[:max_length] + truncation_msg5.2.2 Write —— 创建/覆盖与安全约定
Write 的 API 极简——只接受 file_path 和 content 两个参数。它会创建新文件或覆盖已有文件,自动创建中间目录。
关键安全约定:必须先 Read 再 Write。多个实现在 system prompt 和工具描述中反复强调这一点:
"You must read the file before editing. This tool will error if you attempt an edit without reading the file first."
这不仅仅是建议——部分实现会追踪哪些文件已被读取,对未读文件的写入直接返回错误。原因是 LLM 的"幻觉"问题:如果不先读取,agent 可能基于过时或错误的记忆覆写文件,导致数据丢失。
Write 返回的是确认信息(如 "Updated file /path/to/file"),而非文件内容。这是刻意的——避免大文件写入后的响应回传吞噬上下文。
路径验证。所有路径都必须是绝对路径,并经过沙箱边界检查:
# Python 实现
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return pathresolve() 消除了 ../ 逃逸的可能性。
5.2.3 Edit —— 基于文本匹配的精准编辑
Edit 是 agent 最常用的文件修改工具。其核心设计选择是 search-and-replace 模式,而非行号定位。
参数签名:
# Python 实现
def edit_file(
file_path: str,
old_string: str, # 要被替换的精确文本
new_string: str, # 替换后的文本
replace_all: bool = False # 是否替换所有匹配
) -> str:为什么用文本匹配而非行号?两个原因:
- 鲁棒性。LLM 对行号的记忆不可靠——它可能把第 42 行记成第 40 行。但对几行代码的精确文本,LLM 的复述准确率极高。
- 唯一性约束。默认模式下(
replace_all=False),old_string必须在文件中唯一出现。这既是安全机制(防止意外修改同名代码),也是错误检测——如果old_string出现 0 次或多于 1 次,工具直接报错,agent 必须修正。
最简实现:
# Python 实现
def run_edit(path: str, old_text: str, new_text: str) -> str:
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"注意 replace(old_text, new_text, 1) 的第三个参数 1——只替换首次出现,即使文件中有多处匹配。
Edit vs Write 的分工。Edit 只传输变更的部分(old_string + new_string),而 Write 传输整个文件内容。对于大文件的小修改,Edit 的 token 消耗远低于 Write。这也是为什么工具描述反复强调"prefer editing existing files over creating new ones"。
5.2.4 Glob —— 模式匹配文件搜索
Glob 替代了 find 命令,提供文件路径搜索:
# Python 实现
def glob(
pattern: str, # glob 模式,如 "**/*.py"
path: str = "/" # 搜索起始目录
) -> str:支持标准 glob 语法:*(任意字符)、**(递归目录)、?(单字符)。
超时保护。Glob 在大型文件系统上可能非常慢(如 node_modules 下递归搜索)。典型实现设置 20 秒超时:
# Python 实现
GLOB_TIMEOUT = 20.0 # seconds
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(backend.glob, pattern, path=validated_path)
try:
result = future.result(timeout=GLOB_TIMEOUT)
except concurrent.futures.TimeoutError:
return f"Error: glob timed out after {GLOB_TIMEOUT}s. Try a more specific pattern."结果截断。当匹配结果过多时,工具自行截断而非返回完整列表。这是有意为之——大量匹配通常意味着查询需要细化,返回全部结果反而是噪音。
5.2.5 Grep —— 内容搜索
Grep 提供跨文件的文本搜索,替代 grep/rg。关键设计选择是 纯文本匹配而非正则:
# Python 实现
def grep(
pattern: str, # 文本模式(literal,非正则)
path: str | None = None, # 搜索目录
glob: str | None = None, # 文件过滤(如 "*.py")
output_mode: str = "files_with_matches" # 输出模式
) -> str:为什么不用正则?LLM 写正则的出错率很高——特别是需要转义特殊字符时(括号、管道、点号)。纯文本搜索意味着 grep(pattern="def __init__(self):") 直接工作,无需 def __init__\(self\):。
三种输出模式:
| 模式 | 说明 | 适用场景 |
|---|---|---|
files_with_matches | 只返回文件路径 | 定位文件,后续用 Read 查看 |
content | 返回匹配行及上下文 | 快速浏览匹配内容 |
count | 返回每个文件的匹配数 | 评估修改范围 |
这三种模式对应不同的 agent 工作流阶段:先用 files_with_matches 缩小范围,用 content 确认目标,用 count 估算工作量。
5.3 apply_patch:大规模编辑方案
Edit 工具的 search-and-replace 模式适合单点修改,但当 agent 需要同时修改一个文件的多处位置时,多次 Edit 调用效率低下——每次调用都是一个 tool_call,每次 LLM 都要等待返回结果。Codex 的解决方案是 apply_patch:一个基于自定义 patch 格式的批量编辑工具。
5.3.1 虚拟 CLI 模式
apply_patch 被设计为一个虚拟 CLI 命令——agent 通过 shell 工具调用它,传入一个 patch 字符串作为参数:
# Shell 调用方式
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}这个设计让 apply_patch 看起来像一个普通的 shell 命令,但实际上它被 agent 运行时拦截并在进程内执行,不经过真正的 shell。
5.3.2 Patch 格式的设计
Codex 没有使用标准的 unified diff 格式,而是设计了一种更简化的 patch 语言。其正式文法:
Patch := "*** Begin Patch" NEWLINE { FileOp } "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile:= "*** Delete File: " path NEWLINE
UpdateFile:= "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE一个完整示例:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch三种文件操作:
Add File—— 创建新文件,后续每行以+开头Delete File—— 删除文件,无后续内容Update File—— 修改已有文件,可选附带Move to实现重命名
与 unified diff 的区别:
| 特性 | unified diff | apply_patch |
|---|---|---|
| 行号 | 必须指定 @@ -a,b +c,d @@ | 无行号,用上下文定位 |
| 上下文 | 固定 3 行 | 按需提供,可用 @@ 指定作用域 |
| 文件操作 | 仅修改 | 支持增/删/改/移 |
| LLM 友好度 | 行号计算容易出错 | 不需要计算行号 |
不使用行号是核心设计决策。LLM 对行号的计算能力差,但对上下文文本的复述能力强。@@ 标记后的 header(如 class BaseClass 或 def method():)帮助定位时甚至可以嵌套:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
-[old_code]
+[new_code]
[3 lines of post-context]5.3.3 模糊匹配机制
patch 应用的核心挑战是:在原始文件中定位 patch 中的旧代码片段。Codex 的实现采用逐级放宽的匹配策略:
精确匹配 → 忽略尾部空白 → 忽略首尾空白 → Unicode 标点归一化Rust 实现的四级匹配:
// Rust 实现 — seek_sequence 函数的四级匹配策略
// 第 1 级:精确匹配
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
if lines[i..i + pattern.len()] == *pattern {
return Some(i);
}
}
// 第 2 级:忽略尾部空白
// lines[i + p_idx].trim_end() != pat.trim_end()
// 第 3 级:忽略首尾空白
// lines[i + p_idx].trim() != pat.trim()
// 第 4 级:Unicode 标点归一化
// 将 EN DASH、NON-BREAKING HYPHEN 等 Unicode 字符映射为 ASCII 等价物
fn normalise(s: &str) -> String {
s.trim()
.chars()
.map(|c| match c {
'\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
'\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
'\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
'\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
other => other,
})
.collect()
}第四级 Unicode 归一化解决的是一个真实痛点:很多代码注释中包含 typographic 引号或 em dash,而 LLM 生成的 patch 通常使用 ASCII 字符。如果不做归一化,patch 会因为"看起来一样但字节不同"而匹配失败。
5.3.4 替换的应用顺序
多个 chunk 的替换按照 逆序(descending index) 应用:
// Rust 实现
fn apply_replacements(mut lines: Vec<String>, replacements: &[(usize, usize, Vec<String>)]) -> Vec<String> {
// 逆序应用,避免前面的替换偏移后面的位置
for (start_idx, old_len, new_segment) in replacements.iter().rev() {
for _ in 0..old_len {
lines.remove(start_idx);
}
for (offset, new_line) in new_segment.iter().enumerate() {
lines.insert(start_idx + offset, new_line.clone());
}
}
lines
}逆序应用的原因:如果先应用前面的替换,插入或删除行会改变后面代码的行号,导致后续替换定位错误。逆序则不会影响前面的位置。
5.3.5 heredoc 解析与 shell 兼容
LLM 在调用 apply_patch 时,经常使用 shell heredoc 语法包裹 patch 内容。Codex 实现了 lenient 解析模式,能处理多种 heredoc 变体:
# 直接调用
apply_patch "*** Begin Patch\n..."
# heredoc(单引号)
bash -lc "apply_patch <<'EOF'
*** Begin Patch
...
*** End Patch
EOF"
# 带 cd 的 heredoc
bash -lc "cd subdir && apply_patch <<'EOF'
*** Begin Patch
...
*** End Patch
EOF"Codex 用 Tree-sitter 的 Bash 语法解析器提取 heredoc 体,而非正则匹配。这保证了对复杂 shell 语法的正确处理(如引号嵌套、路径中的空格等),同时通过严格的 AST 查询拒绝不安全的形式(如 echo foo; apply_patch <<...)。
5.4 REPL/脚本工具作为高阶工具
5.4.1 Execute 工具:受控的 shell 执行
尽管有了专用文件工具,agent 仍然需要 shell 来执行构建、测试、运行脚本等操作。Execute 工具(或 Bash 工具)提供这一能力,但加了多层控制:
危险命令过滤:
# Python 实现
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"超时控制。默认 120 秒超时,可按命令覆盖(如长时间构建),但有上限(通常 3600 秒):
# Python 实现
if timeout > self._max_execute_timeout:
return f"Error: timeout {timeout}s exceeds maximum allowed ({self._max_execute_timeout}s)."禁止用 shell 替代专用工具。Execute 的工具描述明确禁止用 cat/grep/find 替代 Read/Grep/Glob:
"You MUST avoid using search commands like find and grep. Instead use the grep, glob tools to search. You MUST avoid read tools like cat, head, tail, and use read_file to read files."
这是工具设计的 降级防护——即使 agent 技术上可以通过 shell 绕过专用工具,system prompt 也阻止这种行为,确保所有文件操作都经过审计路径。
5.4.2 Action Space 的三个层级
文件工具族与 Execute 工具的组合构成了一个分层的 action space:
Level 1:原子工具调用。单次 Read/Write/Edit/Glob/Grep,对应一个原子操作。
Level 2:shell 命令。通过 Execute 执行 pytest、npm build 等命令。可以组合多个 shell 命令(&& 连接),但不涉及工具间的编排。
Level 3:工具组合脚本。agent 在一次响应中连续调用多个工具,形成一个复杂操作序列。例如:
1. Glob("**/*.py") → 找到所有 Python 文件
2. Grep("deprecated_function", glob="*.py") → 定位使用了废弃函数的文件
3. Read("src/utils.py") → 读取目标文件
4. Edit("src/utils.py", old, new) → 修改代码
5. Execute("pytest tests/") → 运行测试验证这是 LLM agent 真正的力量——不是单个工具的能力,而是通过工具组合实现的 compound action。工具设计的目标是让每个原子操作足够简单、输出足够确定,使得 LLM 可以可靠地编排它们。
5.5 大输出处理哲学
当工具返回的结果超出 context window 可承受范围时,直接将结果塞入对话历史会导致灾难性后果——后续推理质量急剧下降,且浪费大量 token 预算。
5.5.1 自动 eviction 机制
成熟的实现会对工具输出实施 token 阈值检查。当输出超过阈值(如 20000 token),自动将完整结果写入文件系统,仅在对话中保留摘要:
# Python 实现 — 大输出 eviction 逻辑
TOO_LARGE_TOOL_MSG = """Tool result too large, the result of this tool call {tool_call_id}
was saved in the filesystem at: {file_path}
You can read the result using read_file with offset and limit parameters.
Here is a preview showing the head and tail of the result:
{content_sample}
"""摘要包含三部分:
- 文件路径引用——告诉 agent 去哪里读完整结果
- 使用提示——建议用
read_file分页读取 - 头尾预览——显示前 5 行和后 5 行,中间标注省略行数
5.5.2 分层截断策略
不同工具有不同的截断策略,这是精心设计的结果:
| 工具 | 大输出处理 | 原因 |
|---|---|---|
| Read | 自带分页截断,不做 eviction | 单行过长的情况下 eviction 后再 read 也无效 |
| Glob/Grep | 自带结果截断,不做 eviction | 大量匹配 = 查询需要细化,全量结果是噪音 |
| Write/Edit | 永远不会超限 | 返回值只是确认信息 |
| Execute | 做 eviction | 命令输出不可预测,可能很大 |
# Python 实现 — 排除自带截断的工具
TOOLS_EXCLUDED_FROM_EVICTION = (
"ls", "glob", "grep",
"read_file",
"edit_file", "write_file",
)这体现了一个设计原则:截断的责任应该放在最了解数据语义的层级。Read 知道自己在读文件,可以做基于行数的智能分页;Grep 知道自己在搜索,可以判断结果是否过多并建议细化查询。而 Execute 的输出完全不可预测,只能交给通用 eviction 机制兜底。
5.5.3 system prompt 中的工具协同指引
大输出处理不仅靠代码实现,还通过 system prompt 告知 agent 如何利用 eviction 后的结果:
"When a tool result is too large, it may be offloaded into the filesystem instead of being returned inline. In those cases, use
read_fileto inspect the saved result in chunks, or usegrepwithin/large_tool_results/if you need to search across offloaded tool results."
这形成了一个工具间的协作链:Execute 产出大结果 → eviction 写入文件 → agent 用 Read 分页查看或用 Grep 搜索关键信息。文件系统本身成为了工具间的通信缓冲区。
graph TD
A[Tool Call] --> B{Output size > threshold?}
B -->|No| C[Inline in conversation]
B -->|Yes| D[Write to /large_tool_results/]
D --> E[Return preview + file path]
E --> F[Agent uses Read/Grep to inspect]
F --> G[Paginated access to full result]graph LR
subgraph "File Tool Family"
direction TB
R[Read<br/>分页读取/多模态]
W[Write<br/>创建/覆盖]
E[Edit<br/>search-and-replace]
G[Glob<br/>文件搜索]
GR[Grep<br/>内容搜索]
end
subgraph "Batch Edit"
AP[apply_patch<br/>批量编辑/增删改移]
end
subgraph "Shell Execution"
EX[Execute/Bash<br/>受控 shell]
end
subgraph "Safety Layer"
PV[Path Validation<br/>沙箱边界检查]
PC[Permission Control<br/>读/写/执行分级]
OC[Output Control<br/>截断/eviction]
end
R & W & E & G & GR --> PV
AP --> PV
EX --> PC
R & EX --> OC