图解 AI Agent ②:模型到底是怎么读取文件的?

0 阅读10分钟

模型、Harness 与文件系统组成的读取文件闭环

当你对一个 Agent 说:“帮我看看 README.md,这个项目是做什么的?”

看起来只是一句话,背后却不是“模型自己打开了文件”。大模型运行在服务端,它既看不到你的磁盘,也不会天然拥有文件权限。真正发生的是:模型提出工具请求,程序在本地执行,再把结果作为新上下文交回模型。

这一篇不急着堆代码。我们先沿着一次真实的 read_file 调用,把下面几个问题彻底讲清楚:

  • 模型为什么看不到本地文件?
  • Tool Schema、Tool Call、Tool Result 分别是什么?
  • 模型、Harness、工具函数各自负责什么?
  • 为什么必须校验路径和限制文件大小?
  • 为什么当前实现只能读一次,还不算完整 Agent Loop?

先记住全文最重要的一句话:

限制模型的不是“智力”,而是当前程序还没有给它接入工具、权限和结果反馈机制。

01|模型能看到上下文,但看不到你的磁盘

模型上下文与本地磁盘之间存在边界

模型每次推理时,真正能看到的只有这次请求里的上下文,例如:

  • System Prompt;
  • 用户问题;
  • 历史消息;
  • Harness 主动附带的工具说明;
  • 已经返回的工具结果。

你电脑里的 README.mdpackage.json 和源码目录,并不会因为用户提到了它们,就自动进入模型上下文。

所以模型可以理解“请阅读 README”这句话,却不知道 README 里究竟写了什么。没有工具时,它只能根据已有知识猜;猜得再像,也不等于读取了真实文件。

这也是判断 Agent 是否“接触了真实世界”的第一条线:答案里的事实,究竟来自模型记忆,还是来自一次可验证的外部读取?

02|一次读取文件,实际有五个参与者

用户、模型、Harness、工具与文件系统的分工

一次 read_file 看似简单,实际至少有五个参与者:

  1. 用户:提出目标,例如“这个项目如何启动?”
  2. 模型:判断仅靠现有上下文能否回答,以及下一步需要哪个文件。
  3. Harness:组织模型请求、提供工具、校验调用、执行函数、回传结果。
  4. 工具函数:把结构化参数变成一次真实操作,例如调用文件读取 API。
  5. 文件系统:保存真实的 README、代码和配置,是事实来源。

它们的完整关系是:

用户提问 → Harness 请求模型 → 模型返回 Tool Call → Harness 校验并执行 → 文件系统返回内容 → Harness 回传 Tool Result → 模型生成答案

这里最容易混淆的一点是:模型负责决定,Harness 负责落地。 模型没有直接执行本地代码,也没有绕过程序获得文件权限。

03|一个工具,其实由“说明书”和“执行器”两半组成

Tool Schema 与工具执行函数是工具的两半

要让模型使用 read_file,程序里需要准备两样东西:

  • Tool Schema:给模型看的说明书,描述工具叫什么、能做什么、要传哪些参数。
  • Tool Executor:给 Harness 调用的执行函数,负责真的读取文件。

这两部分不能互相替代。

只有 Schema,没有执行器,模型虽然会提出“读取 README”的请求,但程序无法完成动作;只有执行器,没有 Schema,程序虽然具备读取能力,模型却不知道何时、以什么格式申请使用它。

很多框架会把这一步包装成“注册工具”。但在本篇对应的最小实战里,并没有额外的工具注册表:我们只是定义 READ_FILE_TOOL,再把它放进模型请求的 tools 数组;收到调用后,由 main.ts 明确分发给 readFileTool

04|Tool Schema 是模型可读的函数契约

read_file Tool Schema 的四个关键字段

read_file 的核心说明可以简化成:

{
  type: 'function',
  function: {
    name: 'read_file',
    description: '读取当前项目中的文本文件内容。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string' }
      },
      required: ['path'],
      additionalProperties: false
    }
  }
}

这份契约至少回答四个问题:

  • name:模型要调用哪个工具?
  • description:这个工具适合解决什么问题?
  • properties:参数有哪些、类型是什么?
  • required:哪些参数不能省略?

additionalProperties: false 还在表达一个更严格的约束:不要随意附加未声明的字段。

不过要注意:Schema 只是调用格式,不是安全边界。 即使 Schema 要求 path 是字符串,模型仍可能传来空字符串、越界路径或不存在的文件。真正的安全校验必须由 Harness 和工具执行器完成。

05|Harness 把工具说明交给模型,不是把文件交给模型

模型请求同时携带消息与工具说明

用户提问后,Harness 发起第一次模型请求:

const response = await client.chat.completions.create({
  model,
  messages: [systemMessage, userMessage],
  tools: [READ_FILE_TOOL]
})

此时模型拿到的是两类信息:

  1. messages:用户想解决什么问题;
  2. tools:如果缺少信息,可以申请哪些能力。

模型仍然没有看到 README.md 的正文。它只知道存在一个名为 read_file 的工具,并知道调用时需要提供 path

这一步很像给模型一张菜单:菜单告诉它“可以点什么”,但菜还没有端上来。

06|Tool Call 是结构化的行动请求,不是执行结果

模型返回 read_file Tool Call 与参数

如果模型判断必须先读 README,它不会直接编造文件内容,而会返回一段 Tool Call。概念上类似:

{
  "id": "call_123",
  "type": "function",
  "function": {
    "name": "read_file",
    "arguments": "{\"path\":\"README.md\"}"
  }
}

这里有三个细节很重要:

  • name 表示模型想使用哪个工具;
  • arguments 在接口里通常是 JSON 字符串,程序仍要解析和校验;
  • id 用来让后面的 Tool Result 与这次调用准确对应。

所以 Tool Call 更像一张“申请单”:模型表达了下一步意图,但磁盘还没有被读取,执行权仍然在 Harness 手里。

07|Harness 必须先识别、校验,再决定是否执行

Harness 对工具类型、名称与参数进行三层校验

Harness 收到 Tool Call 后,不应该把模型给出的内容直接当成可信命令执行。最小实战里依次做了三层检查:

if (toolCall.type !== 'function') { /* 拒绝 */ }
if (toolCall.function.name !== 'read_file') { /* 拒绝 */ }

const input = JSON.parse(toolCall.function.arguments)
if (typeof input.path !== 'string' || input.path.trim() === '') {
  throw new Error('read_file 需要传入文件路径。')
}

它们分别在确认:

  1. 这是我们支持的工具调用类型吗?
  2. 这是我们允许执行的工具吗?
  3. 参数能解析吗,path 真的是非空字符串吗?

这体现了 Agent 工程里非常重要的一条原则:

模型可以提议动作,但程序必须保留最终执行权。

以后接入写文件、执行命令、访问网络等高风险工具时,这层校验还要继续增加权限、审批、超时和审计规则。

08|路径校验决定了工具能读到哪里

工作区路径归一化与越界拦截

只验证 path 是字符串还不够。假设模型传来:

../../secret.txt

如果直接交给文件 API,它可能逃出当前项目目录。实战代码先把工作目录和目标路径都转换成绝对路径:

const workDir = resolve(process.cwd())
const targetPath = resolve(workDir, relativePath)

const isOutsideWorkDir =
  targetPath !== workDir &&
  !targetPath.startsWith(`${workDir}${sep}`)

经过 resolve 归一化后,./src/../README.md 会被还原成真实目标,../../secret.txt 也会暴露出它已经跑到工作区外。只有目标等于工作区,或位于“工作区路径 + 系统分隔符”之下,才允许继续。

为什么要加 ${sep}?因为只判断字符串前缀会产生误判:/project-evil 虽然以 /project 开头,却不是 /project 的子目录。

这道边界说明:工具能力不等于无限权限。 read_file 可以读取文件,但只应该读取当前任务明确允许的范围。

09|真正读取文件的,是本地执行器

本地执行器读取文件并进行大小截断

通过校验后,Harness 才调用本地工具函数:

const content = await readFile(targetPath)

if (content.length > 8_000) {
  return content.subarray(0, 8_000).toString('utf8') +
    '\n\n...[文件太长了,只返回前 8000 个字节]...'
}

return content.toString('utf8')

这里真正接触磁盘的是 Node.js 文件 API,不是模型。

代码还把单次读取限制在 8,000 字节。原因不是“模型读不懂长文件”,而是工具输出会进入模型上下文:文件越大,请求越慢、Token 越多,也越容易挤掉真正重要的信息。

这份最小实现把文件当作 UTF-8 文本处理;在生产环境里,通常还要继续考虑二进制文件、编码、超时、敏感信息、日志大文件,以及按行或按区间读取等问题。

如果文件不存在或系统拒绝访问,底层读取会抛错;如果文件过大,本篇实现不会失败,而是返回前 8,000 字节并明确标记“已截断”。

10|Tool Result 必须和原来的 Tool Call 对上号

Tool Call 与 Tool Result 通过 tool_call_id 对应

文件读取完成后,内容还没有自动回到模型。Harness 需要发起第二次模型请求,并把完整对话关系带回去:

messages: [
  { role: 'user', content: prompt },
  {
    role: 'assistant',
    content: null,
    tool_calls: [toolCall]
  },
  {
    role: 'tool',
    tool_call_id: toolCall.id,
    content: fileContent
  }
]

这里不是简单地再发一段“README 内容”。assistant.tool_calls 记录模型刚才提出了什么请求,tool.tool_call_id 则说明这份结果是在回答哪一次调用。

如果同时存在多个工具调用,这个 ID 就更重要:它能避免模型把 A 工具的结果错配给 B 工具。

因此,Tool Result 的本质是:把外部世界的执行结果,转换成模型下一轮能够理解的上下文。

11|为什么当前能读文件,却还不是完整 Agent Loop?

一次工具调用与持续 Agent Loop 的区别

把整条链路连起来,一次读取实际经历了两次模型请求:

  1. 第一次请求:模型判断是否需要工具,并返回 Tool Call。
  2. 本地执行:Harness 校验路径,读取文件,得到 Tool Result。
  3. 第二次请求:模型拿到真实内容,再组织最终答案。

这已经形成了最小的“决策 → 行动 → 反馈”闭环,但对应实战故意只处理一次工具调用:

  • 只取 tool_calls?.[0]
  • 只支持 read_file
  • 第二次请求拿到结果后直接生成答案;
  • 如果模型还想继续读 package.json,当前流程没有再次执行工具的循环。

所以,会调用一次工具,不等于已经具备 Agent Loop。 完整循环还需要反复检查模型返回:有 Tool Call 就继续执行并回传,没有 Tool Call 才结束。

对应到实战代码

如果你接着阅读配套实战,可以用下面这张地图定位:

概念实战位置负责什么
Tool Schemasrc/readFile.tsREAD_FILE_TOOL告诉模型工具名称、描述和参数
参数与路径校验getPathreadFileTool拒绝空路径和工作区外路径
工具提供src/chat.tstools: [READ_FILE_TOOL]把工具说明放进第一次模型请求
Tool Call 分发src/main.ts识别工具并调用执行器
Tool Result 回传answerAfterReadFilerole: 'tool' 把文件内容交回模型

配套源码: powercode / demos / 02-read-file

最后再用一句话收住:

模型没有直接读取文件。它选择工具并生成请求;Harness 控制权限、执行动作,再把真实结果反馈给模型。

下一篇,我们继续把“一次工具调用”扩展成真正的 Agent Loop,看看它如何连续读取多个文件,并一步步把任务做完。