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

首页 编程分享 JQUERY丨JS丨VUE 正文

不一样的少年_ 转载 编程分享 2026-08-27 14:05:15

简介 当你对一个 Agent 说:“帮我看看 README.md,这个项目是做什么的?” 看起来只是一句话,背后却不是“模型自己打开了文件”。大模型运行在服务端,它既看不到你的磁盘,也不会天然拥有文件权限。


当你对一个 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|一次读取文件,实际有五个参与者

一次 read_file,可以把它想成“大家分工查资料”:

  1. 用户:先说清楚目标——“帮我看看这个项目怎么启动。”
  2. 模型:像大脑,先看看手里的信息够不够。如果缺少项目里的真实内容,就提出:“我需要读取 README.md。”
  3. Harness:像总管。它先把 read_file 接入自己的工具列表(也就是注册好),再把“这个工具能做什么、需要哪些参数”告诉模型。模型提出调用后,Harness 负责检查参数、执行工具,并把结果交回去。
  4. 工具函数:像跑腿的人,拿着模型给出的 path,去执行真正的文件读取。
  5. 文件系统:像资料室,保存着真实的 README、代码和配置。工具读到的内容,最终都来自这里。

它们的完整关系是:

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

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

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

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

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

这两部分不能互相替代。

可以把它想成餐厅里的“菜单”和“后厨”:

  • Tool Schema 是菜单:告诉模型“有一个 read_file 可以用”,并写清楚要传什么,比如 path: "README.md"
  • Tool Executor 是后厨:模型提出调用 read_file 后,Harness 找到对应的执行函数;程序真正打开文件、读取内容,再把结果交回来。

所以:

  • 只有菜单,没有后厨:模型知道可以点“读取 README”,但没人真的去读;
  • 只有后厨,没有菜单:程序确实会读文件,但模型根本不知道有这个能力,也不知道请求要怎么写。

一个工具要真正跑起来,两个部分缺一不可:Schema 负责让模型知道怎么申请,Executor 负责让程序把申请变成动作。

很多框架会把这一步统一叫作“注册工具”。但这里的“注册”不一定意味着要新建一个复杂的注册表。对本篇的最小流程来说,只要完成两件事:

  1. 在 Harness 里准备好 read_file 的工具说明,并放进发给模型的 tools 工具列表;
  2. 模型返回调用请求后,Harness 能根据工具名找到对应的执行函数。

用脱离具体项目的示意代码表示,就是:

const toolsForModel = [readFileSchema]

if (toolCall.function.name === "read_file") {
  const args = JSON.parse(toolCall.function.arguments)
  return executeReadFile(args) // 程序执行读取文件
}

这里的 readFileSchemaexecuteReadFiletoolCall 都只是帮助理解的示意名称,不对应某个项目里的具体文件。重点是:Harness 把工具介绍给模型,再把模型的调用交给执行函数。

04|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 是结构化的行动请求,不是执行结果

如果模型判断必须先读 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 收到 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 对上号

文件读取完成后,内容还没有自动回到模型。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?

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

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

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

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

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

对应到实战代码

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

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

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

最后再用一句话收住:

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

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

转载链接:https://juejin.cn/post/7677762452274085914


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云