不用 LangChain,手搓 AI Agent:给大模型装上“手”,让它自己读项目文件

🚀 欢迎来到 不用框架,手搓 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 复制代码
想一想
→ 用工具
→ 看结果
→ 再想一想
相关推荐
weixin_4223293118 小时前
什么是 Loop Engineering
人工智能
索西引擎18 小时前
【React】useState 函数式更新机制:闭包陷阱规避与状态一致性保障分析
前端·react.js·前端框架
梅雅达编程笔记18 小时前
编程启蒙|Scratch 转 Python 系列第10天:问答闯关游戏实战(AI题库管理+随机出题实战)
人工智能·python·游戏·青少年编程
云烟成雨TD18 小时前
Agent Scope Java 2.x 系列【42】2.0 GA 正式发布:从透明开发迈向智能体系统工程
java·人工智能·agent
anyup19 小时前
从文本到具身:我给 AI Agent 搭了套实时 3D 交互身体
前端·人工智能·aigc
lichenyang45319 小时前
给弱模型一本说明书:我把文档规范做成单文件 Skill,也终于分清了 Skill 和 MCP
前端
科技圈快迅19 小时前
新工科保研机构怎么选?垂直辅导与综合型服务模式差异解析
大数据·人工智能
中微极客19 小时前
视频Agent:从一次性生成到多轮编排架构
人工智能·架构·音视频
武子康19 小时前
FDE 到底是什么:为什么 AI 时代重新需要前线部署工程师(4 个标准 + 8 类风险 + 10 个问题)
人工智能·后端·openai