🚀 欢迎来到 不用框架,手搓 AI Agent 专栏第二站,给agent加上读文件的能力
就算你没看过上一篇,也完全可以直接从这里开始。
上一篇其实只做了一件很基础的事:搭好项目骨架,让我们能在终端里向大模型提问,跑通"一问一答"。
而这一篇,咱们要开始给这个只有"大脑"的 AI 装上"手和脚",让它可以读取项目里的文件。
因为上一版虽然能回答问题,但它有个很大的毛病:它根本看不到你的项目。
先来"放个毒",看看经过本系列的打磨,我们最终会亲手搞出一个怎样的"完全体"

在上一版 Agent中,假如你输入:
bash
npm run start -- -prompt "这个项目是做什么的?"
它只能结合你的问题,再靠自己已有的知识去猜。
它不知道你的项目里有什么文件,也不知道 README.md 里到底写了什么。
原因很简单:我们现在的 Agent 只有大模型这个"大脑",还没给它任何工具。
大模型很聪明,它能听懂你的问题、分析你的需求,甚至还能给出一套看起来很靠谱的方案。但它没有"手"和"脚",没法自己打开你项目里的文件,更没法把里面的内容读出来。
在真实开发里,我们希望 Agent 不只是"会回答",还应该能帮我们查看、读取、修改项目里的文件。
bash
你:这个项目是干嘛的?
AI:我先看一眼 README。
AI:看完了,这个项目是......
所以这篇咱们就来做这件事:给这个聪明的大脑配上第一双手。
先从最基础的一项能力开始:读取文件。
先准备一份给 AI 读取的 README
咱们这一篇要让 AI 读取 README.md,所以先在项目根目录新建这个文件:
text
powercode/
├── README.md
├── package.json
└── src/
在 README.md 里写入:
md
# powercode
这是一个从零实现 AI Agent 的 TypeScript 教学项目。
目前它已经可以在终端中向大模型提问。
接下来,我们会给它加入读取文件、修改文件和执行命令等能力。
后面咱们就让 AI 读取这份 README,看看它能不能理解项目是做什么的。
先看本节实现的效果
等会儿代码写完,你可以这样问:
bash
npm run start -- -prompt "帮我读一下 README.md,这个项目是做什么的?"
运行效果如下:

重点不是最后那段回答,而是是中间这句:
bash
AI 想读文件:README.md
这代表agent没有瞎编,而是真的先去看了项目里的文件。
先搞懂:AI 是怎么"读文件"的?
我们前面说了,AI 并不能直接打开你电脑里的文件。
它能做的事情是告诉我们:
bash
"我想读项目的README.md文件。"
然后,我们写程序去读我们项目中的文件。
可以把它理解成这样:
bash
AI:帮我读 README.md
↓
我们的程序:好,我去读
↓
我们的程序:README 内容在这里
↓
AI:好,那我根据内容给用户回答
这就是所谓的"工具调用"。
名字听起来挺高级,其实就是:
AI 提需求,程序负责干活。
这一篇咱们只给它一个工具:
bash
read_file:读取文件
先把一个工具玩明白,后面加写文件、改文件、跑命令就简单了。
第二步:新建 read_file 工具
在上一章的项目里,新建一个文件:
bash
src/readFile.ts
把下面代码复制进去:下面我们再来逐一解释
ts
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
const MAX_BYTES = 8_000; // 最大字节数
/**
* 读取文件工具配置
*/
export const READ_FILE_TOOL = {
type: 'function',
function: {
name: 'read_file',
description: '读取当前项目中的文本文件内容。',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: '文件路径,比如 README.md',
},
},
required: ['path'],
additionalProperties: false,
},
},
} as const;
/**
* 获取文件路径
* @param argumentsJson 参数JSON
* @returns 文件路径
*/
function getPath(argumentsJson: string): string {
const input = JSON.parse(argumentsJson) as { path?: unknown };
if (typeof input.path !== 'string' || input.path.trim() === '') {
throw new Error('read_file 需要传入文件路径。');
}
return input.path;
}
/**
* 读取文件
* @param argumentsJson 参数JSON
* @returns 文件内容
*/
export async function readFileTool(argumentsJson: string): Promise<string> {
const relativePath = getPath(argumentsJson);
const workDir = resolve(process.cwd());
const targetPath = resolve(workDir, relativePath);
const isOutsideWorkDir =
targetPath !== workDir && !targetPath.startsWith(`${workDir}${sep}`);
if (isOutsideWorkDir) {
throw new Error('只能读取当前项目里的文件。');
}
const content = await readFile(targetPath);
if (content.length > MAX_BYTES) {
return `${content.subarray(0, MAX_BYTES).toString('utf8')}
...[文件太长了,只返回前 ${MAX_BYTES} 个字节]...`;
}
return content.toString('utf8');
}
这份代码看着长,其实就两件事。
第一件:告诉 AI,它现在多了个技能
这段代码是给 AI 看的,它不是用来真正读取文件的。
你可以把它理解成一张"工具说明书":
text
工具名称:read_file
它能干什么:读取文件内容
使用它时要传什么:文件路径 path
AI 看完这张说明书后,就知道:如果它想读文件,可以申请调用 read_file,同时告诉我们文件路径。
这张"工具说明书"正式一点的名字叫 JSON Schema。
JSON Schema 说白了就是:用一段 JSON 格式的内容,把"这个工具叫什么、能做什么、需要哪些参数"讲清楚。
比如下面这段:
ts
function: {
name: 'read_file',
description: '读取当前项目中的文本文件内容。',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: '文件路径,比如 README.md',
},
},
required: ['path'],
},
}
就是在告诉 AI:
这里有一个叫
read_file的工具,它可以读取文本文件。调用它时,你必须传一个叫
path的字符串参数。
于是,当 AI 觉得"我得先看 README 才能回答"时,它就会发出这样的请求:
json
{
"path": "README.md"
}
第二件:真的去读文件
上面那段"工具说明书"只是告诉 AI:它可以申请读取文件。
真正干活的,是下面这行代码:
ts
const content = await readFile(targetPath);
readFile 是 Node.js 自带的文件读取方法。
AI 只负责提出需求:
bash
我想读 README.md。
而我们的程序会根据AI模型传来的文件路径,调用 Node.js 读取真实文件,最后把读取结果交回给 AI。
所以可以简单理解成:
bash
AI 负责说"我要读哪个文件"
↓
Node.js 负责真的把文件读出来
Node.js 才是真正去把文件内容读出来的那个。
为什么不能让 AI 随便读文件?
刚刚上面我们说了用readFile可以读取文件的内容,但是我们得限制ai读取的文件范围,比如我们平时在使用agent的时候,我们希望agent能读取到的文件只是我们agent启动的所在目录,其他的不能访问,所以我们在代码里加了这段限制:
ts
if (isOutsideWorkDir) {
throw new Error('只能读取当前项目里的文件。');
}
意思很简单:
text
当前项目里的文件:可以读
项目外面的文件:不可以读
另外,咱们还限制了文件大小:
text
const MAX_BYTES = 8_000;
原因也很朴素:有些文件特别大。
比如日志文件、打包后的代码、几十万行数据。全塞给 AI,不仅慢,还会多花 Token。
先限制在 8,000 字节,大家可以根据自己的需求进行调整。
把工具交给 AI
打开上一章的 src/chat.ts,替换成下面完整代码:
ts
import OpenAI from 'openai';
import type { ProviderConfig } from './config.js';
import { READ_FILE_TOOL } from './readFile.js';
export class ChatClient {
private readonly client: OpenAI;
constructor(private readonly config: ProviderConfig) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
});
}
async askWithTools(prompt: string) {
const response = await this.client.chat.completions.create({
model: this.config.model,
messages: [
{
role: 'system',
content:
'你是 power-code,一个研发助手。需要了解项目内容时,优先读取真实文件。请使用中文回答。',
},
{
role: 'user',
content: prompt,
},
],
tools: [READ_FILE_TOOL],
});
return response.choices[0]?.message;
}
async answerAfterReadFile(
prompt: string,
toolCall: any,
fileContent: string,
): Promise<string> {
const response = await this.client.chat.completions.create({
model: this.config.model,
messages: [
{
role: 'system',
content:
'你是 power-code,一个研发助手。请根据工具返回的真实文件内容,用中文回答用户。',
},
{
role: 'user',
content: prompt,
},
{
role: 'assistant',
content: null,
tool_calls: [toolCall],
},
{
role: 'tool',
tool_call_id: toolCall.id,
content: fileContent,
},
],
});
return response.choices[0]?.message.content ?? '模型没有返回内容。';
}
}
这里最关键的就一行:
ts
tools: [READ_FILE_TOOL],
上一篇中我们只告诉 AI模型:
text
用户问了什么
这一篇多告诉了它一句:
text
你现在有 read_file 这个工具,想读文件就可以申请调用。
注意,只是"可以申请"。
AI 不能直接读,它得先问咱们的程序。
接住 AI 的请求
再打开 src/main.ts,替换成下面完整代码:
ts
import { ChatClient } from './chat.js';
import { loadConfig } from './config.js';
import { readFileTool } from './readFile.js';
/**
* 从命令行参数中获取用户的问题
* @param args 命令行参数
* @returns 用户的问题
*/
function getPrompt(args: string[]): string {
const promptIndex = args.indexOf('-prompt');
if (promptIndex === -1) {
throw new Error('请通过 -prompt 传入问题,例如:-prompt "你好"');
}
const prompt = args[promptIndex + 1];
if (!prompt) {
throw new Error('-prompt 后面不能是空内容。');
}
return prompt;
}
/**
* 主函数,负责处理命令行参数、加载配置、与模型交互并打印结果。
* @returns
*/
async function main() {
// 从命令行参数中获取用户的问题
const prompt = getPrompt(process.argv.slice(2));
// 加载配置
const config = await loadConfig();
// 创建 ChatClient 实例
const client = new ChatClient(config);
console.log('AI 正在思考...\n');
// 调用模型,获取第一个消息
const firstMessage = await client.askWithTools(prompt);
// 获取工具调用
const toolCall = firstMessage?.tool_calls?.[0];
if (!toolCall) {
console.log(`AI:${firstMessage?.content ?? '模型没有返回内容。'}`);
return;
}
if (toolCall.type !== 'function') {
throw new Error(`暂时不支持工具类型:${toolCall.type}`);
}
if (toolCall.function.name !== 'read_file') {
throw new Error(`暂时不支持工具:${toolCall.function.name}`);
}
console.log(`AI 想读文件:${toolCall.function.arguments}\n`);
console.log(`正在读取文件:${toolCall.function.arguments}`);
const fileContent = await readFileTool(toolCall.function.arguments);
const answer = await client.answerAfterReadFile(
prompt,
toolCall,
fileContent,
);
console.log(`AI:${answer}`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`启动失败:${message}`);
process.exit(1);
});
别被代码长度吓到,主流程其实只有三步:
先问 AI:
用户的问题来了,你要直接回答,还是要用工具?
ts
const firstMessage = await client.askWithTools(prompt);
如果 AI 不需要读文件,就直接输出答案:
ts
if (!toolCall) {
console.log(`AI:${firstMessage?.content ?? '模型没有返回内容。'}`);
return;
}
如果它想读 README,就让我们的程序替它读:
ts
const fileContent = await readFileTool(toolCall.function.arguments);
最后,把 README 的真实内容再交给 AI:
ts
const answer = await client.answerAfterReadFile(
prompt,
toolCall,
fileContent,
);
这一步别漏。
因为 AI 第一次只是说:"我想读 README。"
读完之后,咱们得把内容发回去,它才能说:"我看完了,这项目是干嘛的。"
跑一下试试
先编译:
bash
npm run build
再运行:
bash
npm run start -- -prompt "帮我读一下 README.md,这个项目是做什么的?"
正常情况下,你会看到类似输出:
text
AI 正在思考...
AI 想读文件:{"path":"README.md"}
AI:这个项目是一个从零实现 AI Agent 的 TypeScript 教学项目......
这篇咱们到底做了什么?
一句话总结:
以前 AI 只能靠猜;现在它遇到不知道的内容,会先申请读文件。
完整流程是:
text
用户问问题
↓
AI 发现需要看 README
↓
AI 请求调用 read_file
↓
咱们的 Node.js 程序读取 README
↓
把 README 内容发回 AI
↓
AI 根据真实内容回答
到这里,它终于有一点 AI Agent 的样子了。
小提醒
现在只能读一次
咱们这篇故意只让它读一次文件。
因为重点不是堆功能,而是把这件事搞明白:
bash
AI 申请工具
→ 程序执行工具
→ 工具结果交回 AI
下一篇,咱们再解决一个更有意思的问题:
如果 AI 读完 README 后,还想再读
package.json呢?
到时候咱们就让它进入真正的 Agent 循环:
bash
想一想
→ 用工具
→ 看结果
→ 再想一想