Skip to content

第 5 章 文件操作工具族 —— Read/Write/Edit/Glob/Grep

核心论点:coding agent 的文件操作不应通过通用 shell 完成,而应拆分为一组专用工具(specialized tool),每个工具对应一个原子操作,以获得可审计性、权限粒度控制和输出格式的确定性。

5.1 为什么不用 shell 做所有文件操作

直觉上,agent 已经有了 bash 工具,catsedfindgrep 全能用——为什么还要专门封装 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
# 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
# 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
# Python 实现
EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"

Token 级截断。除行数截断外,Read 还实施基于 token 估算的截断。按 4 字符/token 的保守估计,当内容超过 token 阈值时追加截断提示,建议 agent 使用 jq 等工具格式化后重读:

python
# 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_msg

5.2.2 Write —— 创建/覆盖与安全约定

Write 的 API 极简——只接受 file_pathcontent 两个参数。它会创建新文件或覆盖已有文件,自动创建中间目录。

关键安全约定:必须先 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
# 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 path

resolve() 消除了 ../ 逃逸的可能性。

5.2.3 Edit —— 基于文本匹配的精准编辑

Edit 是 agent 最常用的文件修改工具。其核心设计选择是 search-and-replace 模式,而非行号定位。

参数签名:

python
# Python 实现
def edit_file(
    file_path: str,
    old_string: str,   # 要被替换的精确文本
    new_string: str,    # 替换后的文本
    replace_all: bool = False  # 是否替换所有匹配
) -> str:

为什么用文本匹配而非行号?两个原因:

  1. 鲁棒性。LLM 对行号的记忆不可靠——它可能把第 42 行记成第 40 行。但对几行代码的精确文本,LLM 的复述准确率极高。
  2. 唯一性约束。默认模式下(replace_all=False),old_string 必须在文件中唯一出现。这既是安全机制(防止意外修改同名代码),也是错误检测——如果 old_string 出现 0 次或多于 1 次,工具直接报错,agent 必须修正。

最简实现:

python
# 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
# Python 实现
def glob(
    pattern: str,   # glob 模式,如 "**/*.py"
    path: str = "/"  # 搜索起始目录
) -> str:

支持标准 glob 语法:*(任意字符)、**(递归目录)、?(单字符)。

超时保护。Glob 在大型文件系统上可能非常慢(如 node_modules 下递归搜索)。典型实现设置 20 秒超时:

python
# 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
# 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 字符串作为参数:

bash
# 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 语言。其正式文法:

sql
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

一个完整示例:

python
*** 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 diffapply_patch
行号必须指定 @@ -a,b +c,d @@无行号,用上下文定位
上下文固定 3 行按需提供,可用 @@ 指定作用域
文件操作仅修改支持增/删/改/移
LLM 友好度行号计算容易出错不需要计算行号

不使用行号是核心设计决策。LLM 对行号的计算能力差,但对上下文文本的复述能力强。@@ 标记后的 header(如 class BaseClassdef method():)帮助定位时甚至可以嵌套:

python
@@ class BaseClass
@@   def method():
 [3 lines of pre-context]
-[old_code]
+[new_code]
 [3 lines of post-context]

5.3.3 模糊匹配机制

patch 应用的核心挑战是:在原始文件中定位 patch 中的旧代码片段。Codex 的实现采用逐级放宽的匹配策略:

text
精确匹配 → 忽略尾部空白 → 忽略首尾空白 → Unicode 标点归一化

Rust 实现的四级匹配:

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
// 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 变体:

bash
# 直接调用
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
# 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
# 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 执行 pytestnpm build 等命令。可以组合多个 shell 命令(&& 连接),但不涉及工具间的编排。

Level 3:工具组合脚本。agent 在一次响应中连续调用多个工具,形成一个复杂操作序列。例如:

text
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
# 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}
"""

摘要包含三部分:

  1. 文件路径引用——告诉 agent 去哪里读完整结果
  2. 使用提示——建议用 read_file 分页读取
  3. 头尾预览——显示前 5 行和后 5 行,中间标注省略行数

5.5.2 分层截断策略

不同工具有不同的截断策略,这是精心设计的结果:

工具大输出处理原因
Read自带分页截断,不做 eviction单行过长的情况下 eviction 后再 read 也无效
Glob/Grep自带结果截断,不做 eviction大量匹配 = 查询需要细化,全量结果是噪音
Write/Edit永远不会超限返回值只是确认信息
Execute做 eviction命令输出不可预测,可能很大
python
# 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_file to inspect the saved result in chunks, or use grep within /large_tool_results/ if you need to search across offloaded tool results."

这形成了一个工具间的协作链:Execute 产出大结果 → eviction 写入文件 → agent 用 Read 分页查看或用 Grep 搜索关键信息。文件系统本身成为了工具间的通信缓冲区。

mermaid
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]
mermaid
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