当你对一个 Agent 说:“帮我看看 README.md,这个项目是做什么的?”
看起来只是一句话,背后却不是“模型自己打开了文件”。大模型运行在服务端,它既看不到你的磁盘,也不会天然拥有文件权限。真正发生的是:模型提出工具请求,程序在本地执行,再把结果作为新上下文交回模型。
这一篇不急着堆代码。我们先沿着一次真实的 read_file 调用,把下面几个问题彻底讲清楚:
- 模型为什么看不到本地文件?
- Tool Schema、Tool Call、Tool Result 分别是什么?
- 模型、Harness、工具函数各自负责什么?
- 为什么必须校验路径和限制文件大小?
- 为什么当前实现只能读一次,还不算完整 Agent Loop?
先记住全文最重要的一句话:
限制模型的不是“智力”,而是当前程序还没有给它接入工具、权限和结果反馈机制。
01|模型能看到上下文,但看不到你的磁盘
模型每次推理时,真正能看到的只有这次请求里的上下文,例如:
- System Prompt;
- 用户问题;
- 历史消息;
- Harness 主动附带的工具说明;
- 已经返回的工具结果。
你电脑里的 README.md、package.json 和源码目录,并不会因为用户提到了它们,就自动进入模型上下文。
所以模型可以理解“请阅读 README”这句话,却不知道 README 里究竟写了什么。没有工具时,它只能根据已有知识猜;猜得再像,也不等于读取了真实文件。
这也是判断 Agent 是否“接触了真实世界”的第一条线:答案里的事实,究竟来自模型记忆,还是来自一次可验证的外部读取?
02|一次读取文件,实际有五个参与者
一次 read_file 看似简单,实际至少有五个参与者:
- 用户:提出目标,例如“这个项目如何启动?”
- 模型:判断仅靠现有上下文能否回答,以及下一步需要哪个文件。
- Harness:组织模型请求、提供工具、校验调用、执行函数、回传结果。
- 工具函数:把结构化参数变成一次真实操作,例如调用文件读取 API。
- 文件系统:保存真实的 README、代码和配置,是事实来源。
它们的完整关系是:
用户提问 → Harness 请求模型 → 模型返回 Tool Call → Harness 校验并执行 → 文件系统返回内容 → Harness 回传 Tool Result → 模型生成答案
这里最容易混淆的一点是:模型负责决定,Harness 负责落地。 模型没有直接执行本地代码,也没有绕过程序获得文件权限。
03|一个工具,其实由“说明书”和“执行器”两半组成
要让模型使用 read_file,程序里需要准备两样东西:
- Tool Schema:给模型看的说明书,描述工具叫什么、能做什么、要传哪些参数。
- Tool Executor:给 Harness 调用的执行函数,负责真的读取文件。
这两部分不能互相替代。
只有 Schema,没有执行器,模型虽然会提出“读取 README”的请求,但程序无法完成动作;只有执行器,没有 Schema,程序虽然具备读取能力,模型却不知道何时、以什么格式申请使用它。
很多框架会把这一步包装成“注册工具”。但在本篇对应的最小实战里,并没有额外的工具注册表:我们只是定义 READ_FILE_TOOL,再把它放进模型请求的 tools 数组;收到调用后,由 main.ts 明确分发给 readFileTool。
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]
})
此时模型拿到的是两类信息:
messages:用户想解决什么问题;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 需要传入文件路径。')
}
它们分别在确认:
- 这是我们支持的工具调用类型吗?
- 这是我们允许执行的工具吗?
- 参数能解析吗,
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?
把整条链路连起来,一次读取实际经历了两次模型请求:
- 第一次请求:模型判断是否需要工具,并返回 Tool Call。
- 本地执行:Harness 校验路径,读取文件,得到 Tool Result。
- 第二次请求:模型拿到真实内容,再组织最终答案。
这已经形成了最小的“决策 → 行动 → 反馈”闭环,但对应实战故意只处理一次工具调用:
- 只取
tool_calls?.[0]; - 只支持
read_file; - 第二次请求拿到结果后直接生成答案;
- 如果模型还想继续读
package.json,当前流程没有再次执行工具的循环。
所以,会调用一次工具,不等于已经具备 Agent Loop。 完整循环还需要反复检查模型返回:有 Tool Call 就继续执行并回传,没有 Tool Call 才结束。
对应到实战代码
如果你接着阅读配套实战,可以用下面这张地图定位:
| 概念 | 实战位置 | 负责什么 |
|---|---|---|
| Tool Schema | src/readFile.ts 的 READ_FILE_TOOL | 告诉模型工具名称、描述和参数 |
| 参数与路径校验 | getPath、readFileTool | 拒绝空路径和工作区外路径 |
| 工具提供 | src/chat.ts 的 tools: [READ_FILE_TOOL] | 把工具说明放进第一次模型请求 |
| Tool Call 分发 | src/main.ts | 识别工具并调用执行器 |
| Tool Result 回传 | answerAfterReadFile | 用 role: 'tool' 把文件内容交回模型 |
配套源码: powercode / demos / 02-read-file
最后再用一句话收住:
模型没有直接读取文件。它选择工具并生成请求;Harness 控制权限、执行动作,再把真实结果反馈给模型。
下一篇,我们继续把“一次工具调用”扩展成真正的 Agent Loop,看看它如何连续读取多个文件,并一步步把任务做完。