第 3 章 · Tool Calling 与工具系统

03 · 文件工具:读、写、Patch

三种文件操作:read_file 读取、write_file 创建覆写、patch_file 精确修改。patch 比整文件覆写更安全。

从"看"到"改"

前两节给 agent 装上了搜索和列文件的能力。但搜索和列文件都是"看"——agent 只能观察代码仓库,不能修改它。

这一节要做的是从"看"到"改"的跨越。三个工具对应三种文件操作:

  • read_file(第 1 章已实现):读取文件内容
  • write_file(新增):创建或覆写整个文件
  • patch_file(新增):对已有文件做精确的局部修改

三个工具形成递进关系:read 看、write 全量改、patch 精确改。

write_file:创建和覆写

write_file 做的事情很直接——把一段内容写入指定路径的文件。如果文件已存在就覆盖,如果不存在就创建(包括中间目录)。

// src/tools/write-file.ts

import { writeFile, mkdir } from "node:fs/promises";
import { resolve, dirname, relative } from "node:path";
import type { AgentState, Tool } from "../types";

/**
 * 写文件工具
 *
 * 安全措施:
 * 1. 不允许写入工作目录之外的文件(路径遍历防护)
 * 2. 如果目标目录不存在,自动创建(但不递归创建超过一层的目录)
 * 3. 不允许覆盖 .git 目录下的文件
 */
export const writeFileTool: Tool = {
  name: "write_file",
  description:
    "创建或覆盖文件。写入完整文件内容。" +
    "如果要修改文件的一小部分,优先使用 patch_file 工具。" +
    "写入前会自动创建不存在的目录。",
  parameters: {
    type: "object",
    properties: {
      path: {
        type: "string",
        description: "要写入的文件路径(相对于工作目录)",
      },
      content: {
        type: "string",
        description: "文件内容",
      },
    },
    required: ["path", "content"],
  },

  async execute(
    args: Record<string, unknown>,
    state: AgentState,
  ): Promise<string> {
    const filePath = resolve(state.workingDir, String(args.path));
    const content = String(args.content ?? "");

    /** 安全检查:不允许写入工作目录之外的文件 */
    if (!filePath.startsWith(state.workingDir)) {
      return "错误:不能写入工作目录之外的文件。";
    }

    /** 不允许覆盖 .git 目录 */
    if (filePath.includes("/.git/") || filePath.endsWith("/.git")) {
      return "错误:不允许修改 .git 目录下的文件。";
    }

    try {
      /** 自动创建目标目录(如果不存在) */
      await mkdir(dirname(filePath), { recursive: true });
      await writeFile(filePath, content, "utf-8");

      const relativePath = relative(state.workingDir, filePath);
      const lineCount = content.split("\n").length;
      return `已写入 ${relativePath}(${lineCount} 行)`;
    } catch (error: unknown) {
      return `写入文件出错:${String(error)}`;
    }
  },
};

这个工具看起来简单,但有几个重要的安全设计:

路径遍历防护。 如果模型传入 path: "../../etc/passwd",不做检查的话就会写到工作目录之外。filePath.startsWith(state.workingDir) 这一行确保所有写操作都在工作目录范围内。resolve 会把相对路径转成绝对路径,所以 ../../etc/passwd 会被解析成实际路径后再做比较。

.git 目录保护。 即使路径在工作目录内,也不允许修改 .git 目录下的文件。Git 仓库的内部数据(提交历史、配置、hooks)不应该被 agent 工具随意修改。

自动创建目录。 如果写入 src/tools/new-tool.tssrc/tools/ 目录不存在,mkdir 会自动创建。recursive: true 确保中间的每一层目录都会被创建。

description 里引导模型行为。 注意 description 里专门写了一句"如果要修改文件的一小部分,优先使用 patch_file 工具"。这句话直接告诉模型:创建新文件用 write_file,修改已有文件用 patch_file。这种引导比事后纠正有效得多。

patch_file:精确的局部修改

write_file 的问题在于:如果你只想改一个函数的某一行,也得把整个文件重写一遍。这不仅浪费 token,还有风险——模型生成的完整文件可能和原文件在无关的地方出现差异。

patch_file 解决这个问题。它的工作方式是字符串替换:找到文件中的 old_content,替换为 new_content。只改需要改的部分,不动其他内容。

// src/tools/patch-file.ts(核心逻辑)

export const patchFileTool: Tool = {
  name: "patch_file",
  description:
    "对已有文件做局部修改。找到文件中的 old_content,替换为 new_content。" +
    "比 write_file 更安全,因为只改一小部分,不会影响文件的其他内容。" +
    "如果 old_content 在文件中出现多次,会报错——请提供更长的上下文来精确定位。",
  parameters: {
    type: "object",
    properties: {
      path: {
        type: "string",
        description: "文件路径(相对于工作目录)",
      },
      old_content: {
        type: "string",
        description: "要被替换的原始文本(需要和文件中的内容完全一致)",
      },
      new_content: {
        type: "string",
        description: "替换后的新文本",
      },
    },
    required: ["path", "old_content", "new_content"],
  },
  // ...
};

参数设计很关键。old_content 要求和文件中的内容完全一致——包括空格、缩进、换行。这是有意为之的。模糊匹配看起来更宽容,但实际上更容易改错位置。精确匹配确保"改的就是你想改的那段代码"。

patch 的四重安全机制

patch_fileexecute 方法实现了四层安全检查,每一层解决一个具体问题:

async execute(
  args: Record<string, unknown>,
  state: AgentState,
): Promise<string> {
  const filePath = resolve(state.workingDir, String(args.path));
  const oldContent = String(args.old_content ?? "");
  const newContent = String(args.new_content ?? "");

  /** 第一层:路径安全检查(和 write_file 相同) */
  if (!filePath.startsWith(state.workingDir)) {
    return "错误:不能修改工作目录之外的文件。";
  }

  if (filePath.includes("/.git/") || filePath.endsWith("/.git")) {
    return "错误:不允许修改 .git 目录下的文件。";
  }

  /** 第二层:old_content 不能为空 */
  if (!oldContent) {
    return "错误:old_content 不能为空。";
  }

  try {
    const content = await readFile(filePath, "utf-8");

    /** 第三层:检查 old_content 是否存在于文件中 */
    const firstIndex = content.indexOf(oldContent);
    if (firstIndex === -1) {
      return formatNotFoundError(content, oldContent, filePath, state);
    }

    /** 第四层:如果出现多次,要求模型提供更精确的上下文 */
    const secondIndex = content.indexOf(oldContent, firstIndex + 1);
    if (secondIndex !== -1) {
      return (
        `错误:old_content 在文件中出现了多次,请提供更长的上下文来精确定位。\n` +
        `文件:${relative(state.workingDir, filePath)}`
      );
    }

    /** 执行替换 */
    const newFileContent = content.replace(oldContent, newContent);
    await writeFile(filePath, newFileContent, "utf-8");

    /** 计算变更的行号范围,帮助定位修改位置 */
    const beforeLines = content.slice(0, firstIndex).split("\n").length;
    const oldLines = oldContent.split("\n").length;
    const newLines = newContent.split("\n").length;

    const relativePath = relative(state.workingDir, filePath);
    return [
      `已修改 ${relativePath}`,
      `位置:第 ${beforeLines}-${beforeLines + oldLines - 1} 行`,
      `${oldLines} 行 -> ${newLines} 行`,
    ].join("\n");
  } catch (error: unknown) {
    if (isNodeError(error) && error.code === "ENOENT") {
      return `错误:文件不存在 ${args.path}`;
    }
    return `修改文件出错:${String(error)}`;
  }
}

逐层看每个检查解决什么问题:

第一层(路径安全):和 write_file 一样,防止路径遍历攻击。不管工具多安全,如果写到了工作目录之外,一切都是空谈。

第二层(非空检查):如果 old_content 为空字符串,content.replace("", newContent) 会在每个字符之间插入 newContent——这显然不是我们想要的。提前拦截。

第三层(存在性检查):模型生成的 old_content 可能和文件实际内容有细微差异(多了个空格、少了换行)。如果找不到匹配,不能静默失败——必须告诉模型"找不到",并尽可能给出有用的提示。

第四层(唯一性检查)old_content 在文件中可能出现多次(比如两个一模一样的 return null;)。如果允许替换,会把所有出现的地方都改掉,这通常不是模型的本意。报错并要求提供更长的上下文,强制模型精确定位。

模糊匹配提示:帮模型自我修正

第三层检查中,当 old_content 完全找不到时,patch_file 不是简单返回一个"找不到"的错误,而是尝试给出更有帮助的提示:

/**
 * 当 old_content 找不到时,给出有帮助的提示
 * 帮助模型调整搜索内容而不是盲目重试
 */
function formatNotFoundError(
  fileContent: string,
  oldContent: string,
  filePath: string,
  state: AgentState,
): string {
  const relativePath = relative(state.workingDir, filePath);

  /** 尝试模糊匹配:如果 old_content 的第一行在文件中存在 */
  const firstLine = oldContent.split("\n")[0];
  const fuzzyIndex = fileContent.indexOf(firstLine);

  if (fuzzyIndex !== -1 && firstLine.length > 3) {
    const lineNum = fileContent.slice(0, fuzzyIndex).split("\n").length;
    return (
      `错误:未在 ${relativePath} 中找到完全匹配的内容。\n` +
      `但第一行 "${firstLine}" 在第 ${lineNum} 行附近有部分匹配。\n` +
      `请使用 read_file 读取该位置附近的代码,确认准确内容后再重试。`
    );
  }

  return `错误:未在 ${relativePath} 中找到指定的 old_content。请先用 read_file 确认文件内容。`;
}

这段代码做的事情是:提取 old_content 的第一行,在文件中搜索是否有部分匹配。如果第一行找到了,说明模型记住了大致内容但细节有出入——比如缩进不同、某个变量名记错了。

这时候返回的提示包含三部分信息:

  1. "未找到完全匹配"——告诉模型直接重试不会成功
  2. "第一行 xxx 在第 N 行附近有部分匹配"——指出大致位置
  3. "请使用 read_file 读取"——给出明确的下一步操作

模型收到这个提示后,会真的去调用 read_file 读取那一行的精确内容,然后带着正确的 old_content 重新调用 patch_file。这个过程完全自动,不需要用户介入。

为什么 patch 比 write 更安全

做完 write_filepatch_file,比较一下它们的适用场景:

write_filepatch_file
适用场景创建新文件、大规模重写修改已有文件的一小部分
影响范围整个文件只有 old_content 到 new_content 的部分
出错风险高(任何位置的生成错误都会影响文件)低(只改指定的那一段)
可验证性需要对比整个文件只看替换的那一段是否正确
token 开销整个文件内容都要放在参数里只需要 old 和 new 两段文本

最关键的区别是可验证性。用 patch_file 修改后,你可以清楚地看到"改了什么":old_content 是什么、new_content 是什么、在第几行。用 write_file 覆写后,你需要对比整个旧文件和新文件才能发现差异。

在实际使用中,90% 的文件修改应该用 patch_filewrite_file 只在创建新文件或文件确实需要大面积重写时使用。

这也解释了为什么 write_file 的 description 里要写"如果要修改文件的一小部分,优先使用 patch_file"。模型读了这句话,大多数修改场景就会选择 patch_file,降低出错概率。

返回值中的行号信息

patch_file 的返回值包含变更的行号范围:

已修改 src/tools/search.ts
位置:第 42-45 行
3 行 -> 5 行

这个信息有两个用途:

给模型提供反馈。 模型知道自己改了哪里,可以在后续操作中引用这个位置。比如模型改完代码后可能想跑一下相关的测试,它知道测试文件也在同一目录下。

给用户审计依据。 用户看到"第 42-45 行被修改了",可以自己去查看对应的代码,验证修改是否正确。这是 agent 可信度的重要基础——用户需要能追溯 agent 做了什么。

注册文件工具

更新工具注册表:

// src/tools/index.ts

import { readFileTool } from "./read-file";
import { writeFileTool } from "./write-file";
import { patchFileTool } from "./patch-file";
// ... 其他工具

export const tools: Tool[] = [
  searchTool,
  globTool,
  readFileTool,
  writeFileTool,
  patchFileTool,
  // ... 其他工具
];

三个文件工具按读写顺序排列:先读(read_file),再全量写(write_file),最后精确改(patch_file)。它们和两个搜索工具一起,构成了 agent 操作代码仓库的基础能力。

文件工具的安全边界

做完三个文件工具,可以看到它们共享同一套安全模式:

路径限制。 所有工具都检查 filePath.startsWith(state.workingDir),确保操作范围不超出工作目录。

.git 保护。 写操作(write_file、patch_file)额外检查不修改 .git 目录。读操作(read_file)不限制——读 .git/config 通常不会造成问题。

错误信息可操作。 所有错误信息不只是说"出错了",而是指出具体问题并建议下一步操作。比如"文件不存在"告诉模型路径可能错了,"出现多次"告诉模型需要更精确的定位。

这些安全措施不是过度防御。agent 的工具会被模型自主调用,用户不一定在每一步都监督。安全边界越扎实,agent 自主运行时出问题的概率越低。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page