Skip to content

从 Tool 开始:让大模型自动调用工具读文件

和大模型聊天,你可以问它问题,它会告诉你怎么做。但大模型本身没法帮你去做。

比如你想创建一个 react + vite 的 todolist 项目,直接问大模型,它只能告诉你应该创建哪些文件、代码是什么,但不能帮你读写文件、执行命令。

但 Cursor 可以:你让它创建 todolist 项目,它会直接给你写入文件;你还可以让它安装依赖,把项目跑起来。

这是怎么实现的呢?

开发一些 Tool 交给 Agent 调用就可以了。比如读文件、写文件、读取目录、创建目录、执行命令。

这一节我们就来学 Tool,让大模型真正"动手"。

准备工作:申请大模型的 API Key

首先找个大模型来用。这里用阿里的千问,因为每个用户登录都有 100 万免费 token,够我们学习了。当然就算以后不免费,买也没多少钱,几十块能用很久。你用别的大模型也一样。

先在阿里云百炼控制台获取 API Key:

  • 控制台地址:https://bailian.console.aliyun.com/?tab=api#/api
  • 获取 API Key 后,就可以用 apikey 来调用模型了

然后选模型:搜 coder 相关的编码模型,这是用来生成代码的。每个模型训练的数据集不同,用途也不一样,我们用 qwen-coder-turbo 就行。

小提示:qwen-coder-turbo 这个模型比较老,如果发现它不支持工具调用(返回的 tool_calls 是空的),换 qwen-plus 即可。

创建项目,先跑通模型调用

bash
mkdir tool-test
cd tool-test
npm init -y

用编辑器打开,创建一个文件 src/hello-langchain.mjs

.mjs 就是 ES Module 格式的 JS 文件,可以使用 importexport 语法。

js
import { ChatOpenAI } from '@langchain/openai';

const model = new ChatOpenAI({
  modelName: "qwen-coder-turbo",
  apiKey: '你的 apiKey',
  configuration: {
    baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  },
});

const response = await model.invoke("介绍下自己");
console.log(response.content);

这里的 apiKey 换成你刚才复制的,baseURL 用上面这个(DashScope 的 OpenAI 兼容模式地址)。

安装依赖并运行:

bash
pnpm install @langchain/openai
node ./src/hello-langchain.mjs

可以看到模型调用成功了。

用 .env 管理 API Key

不过把 API Key 写死在代码里不好,我们通过 .env 文件来管理,然后用 dotenv 这个包读取:

bash
pnpm install dotenv

dotenv 的作用就是读取 .env 文件,把里面的变量设置到环境变量里:

js
import dotenv from 'dotenv';
import { ChatOpenAI } from '@langchain/openai';

dotenv.config();

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME || "qwen-coder-turbo",
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const response = await model.invoke("介绍下自己");
console.log(response.content);

.env 文件里配置这些变量,代码里动态读取:

env
# OpenAI API 配置
OPENAI_API_KEY=你的 api key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# 模型配置(可选,默认为 qwen-coder-turbo)
MODEL_NAME=qwen-coder-turbo

然后还要把 .env 添加到 .gitignore,因为这些私密信息不保存到 git,就像数据库密码一样,都是私下里传文件,不会提交 git。

准备工作结束,接下来开发 Tool。

第一个 Tool:读取文件

创建一个 src/tool-file-read.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import fs from 'node:fs/promises';
import { z } from 'zod';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME || "qwen-coder-turbo",
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const readFileTool = tool(
  async ({ filePath }) => {
    const content = await fs.readFile(filePath, 'utf-8');
    console.log(`[工具调用] read_file("${filePath}") - 成功读取 ${content.length} 字节`);
    return `文件内容:\n${content}`;
  },
  {
    name: 'read_file',
    description: '用此工具来读取文件内容。当用户要求读取文件、查看代码、分析文件内容时,调用此工具。',
    schema: z.object({
      filePath: z.string().describe('要读取的文件路径'),
    }),
  }
);

const tools = [readFileTool];
const modelWithTools = model.bindTools(tools);

const messages = [
  new SystemMessage(`你是一个代码助手,可以使用工具读取文件并解释代码。
工作流程:
1. 用户要求读取文件时,立即调用 read_file 工具
2. 等待工具返回文件内容
3. 基于文件内容进行分析和解释
可用工具:
- read_file: 读取文件内容(使用此工具来获取文件内容)
`),
  new HumanMessage('请读取 src/tool-file-read.mjs 文件内容并解释代码')
];

let response = await modelWithTools.invoke(messages);
messages.push(response);

while (response.tool_calls && response.tool_calls.length > 0) {
  console.log(`\n[检测到 ${response.tool_calls.length} 个工具调用]`);
  // 执行所有工具调用
  const toolResults = await Promise.all(
    response.tool_calls.map(async (toolCall) => {
      const tool = tools.find(t => t.name === toolCall.name);
      if (!tool) {
        return `错误: 找不到工具 ${toolCall.name}`;
      }
      console.log(`[执行工具] ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
      try {
        const result = await tool.invoke(toolCall.args);
        return result;
      } catch (error) {
        return `错误: ${error.message}`;
      }
    })
  );

  // 将工具结果添加到消息历史
  response.tool_calls.forEach((toolCall, index) => {
    messages.push(
      new ToolMessage({
        content: toolResults[index],
        tool_call_id: toolCall.id,
      })
    );
  });

  // 再次调用模型,传入工具结果
  response = await modelWithTools.invoke(messages);
}

console.log('\n[最终回复]');
console.log(response.content);

需要用到 langchain 的核心包和 zod:

bash
pnpm install @langchain/core zod

逐段看下这段代码:

1. 创建模型

js
const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME || "qwen-coder-turbo",
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

temperature 是温度,也就是 AI 的创造性,设置为 0,让它严格按照指令做事,不要自己发挥。这里用 import 'dotenv/config' 引入模块就会自动执行配置,不需要手动调用。

2. 创建 Tool

js
const readFileTool = tool(
  async ({ filePath }) => {
    const content = await fs.readFile(filePath, 'utf-8');
    return `文件内容:\n${content}`;
  },
  {
    name: 'read_file',
    description: '用此工具来读取文件内容。当用户要求读取文件、查看代码、分析文件内容时,调用此工具。',
    schema: z.object({
      filePath: z.string().describe('要读取的文件路径'),
    }),
  }
);

这个很容易看懂:就是一个函数,加上它的名字、描述、参数格式。

因为要交给大模型用,你要描述下这个工具是干什么的,描述下参数的格式。这里用 zod 来描述:传入一个 object,里面的 filePath 是一个 string,也就是这样:

json
{
  "filePath": "xxx"
}

3. 把 Tool 传给大模型

js
const modelWithTools = model.bindTools(tools);

调用 bindTools,把工具列表绑定到模型上。然后运行:

bash
node ./src/tool-file-read.mjs

可以看到 AI 返回的消息是 AIMessage 实例,它解析出了我们给的路径,拼接好了调用工具的参数。接下来我们基于这个参数调用工具就行了。

四种消息类型

LangChain 中具体的消息有四种:

  • SystemMessage:设置 AI 是谁、可以干什么、有什么能力,以及一些回答和行为的规范
  • HumanMessage:用户输入的信息
  • AIMessage:AI 的回复信息
  • ToolMessage:调用工具的结果返回

我们用 SystemMessage 告诉 AI:它是一个代码助手,可以读取文件并解释代码内容、给出建议。

Agent Loop:循环执行工具调用

关键在这段循环:

js
let response = await modelWithTools.invoke(messages);
messages.push(response);

while (response.tool_calls && response.tool_calls.length > 0) {
  // 执行所有工具调用
  const toolResults = await Promise.all(
    response.tool_calls.map(async (toolCall) => {
      const tool = tools.find(t => t.name === toolCall.name);
      // ...
      const result = await tool.invoke(toolCall.args);
      return result;
    })
  );

  // 将工具结果添加到消息历史
  response.tool_calls.forEach((toolCall, index) => {
    messages.push(
      new ToolMessage({
        content: toolResults[index],
        tool_call_id: toolCall.id,
      })
    );
  });

  // 再次调用模型,传入工具结果
  response = await modelWithTools.invoke(messages);
}

根据 tool_calls 数组,分别从 tools 数组里找到对应的工具,取出来 invoke,传入大模型解析出的参数。

注意,这里要用 toolCall 对应的 id 来关联执行结果——也就是告诉大模型:你让我调用的是哪个工具,返回的结果是什么。

最后把工具调用结果作为 ToolMessage 传给大模型,让它继续回答。

几个容易困惑的点:

  • 为什么是 while 循环,不会死循环吗? 不会。是否继续调用工具由大模型决定:当它认为信息已经足够、不需要再执行工具时,tool_calls 会返回 undefined,循环就结束了。比如读取文件时第一次内容不全,它就可能会再次调用工具,直到认为读到了足够的信息。
  • Promise.all 是必须的吗? 不是。它是按逻辑决定的:如果这些工具调用互相没有依赖、可以批量并行,就适合用 Promise.all;如果是有先后关系的逐个调用,就不能用,必须等上一个工具调用完成后再进行下一个。根据实际情况决定。
  • 为什么要反复 push message? messages 数组就是对话历史,也就是 memory。大模型是无状态的,不把历史传给它是不知道之前做过什么的。AI 返回 tool_calls 告诉你要调工具,你调用后封装成 ToolMessage 放入 messages 表示工具调用结果,然后 AI 基于工具结果再做思考——message 就是整个对话过程。

跑一下试试:

bash
node ./src/tool-file-read.mjs

可以看到:检测到了 tool_calls 工具调用,用 read_file 这个工具读取了文件,然后让大模型分析了文件内容,给出了代码解释。

是不是现在大模型就能读文件了!这就是通过工具给大模型扩展了能力。

完整代码在课程仓库:https://github.com/QuarkGluonPlasma/ai-agent-course-code

常见疑问:tool_calls 是模型返回的还是我们要求的?

初学时会有一个疑问:我们调用大模型的 API,返回的工具数组是大模型自己生成的,还是我们要求的?

答案是两者都有——工具清单是我们提供的,但调不调、调哪个、参数是什么,是模型自己决定的

  • 工具清单是我们"塞"给它的。 model.bindTools(tools) 这一步,把工具的名字、描述、参数格式(zod schema)作为请求的一部分发给了模型 API。模型从这一刻起才知道"哦,有个叫 read_file 的工具可以用"。如果不 bindTools,模型永远不可能返回 tool_calls——它不是凭空知道你有这个工具的。
  • 但"要不要调、调哪个、参数填什么"是模型自己决定的。 模型根据你的问题自己判断:"用户让我读取文件,我需要调用 read_file,路径参数是 src/tool-file-read.mjs",然后把这些内容"生成"出来,也就是你看到的 tool_calls 数组。这一步我们没有任何干预,纯靠模型的理解和推理。
  • tool_calls 本质上还是文本。 模型只做一件事:生成文本。它并不"调用"任何工具。所谓返回工具数组,其实是模型输出了一段特定格式的 JSON({"name": "read_file", "arguments": {...}}),LangChain 把它解析成了结构化的 tool_calls 数组。可以想象成:你告诉模型"当你想用工具时,就用这个格式写出来",模型学会了在需要时"写"出这个格式。
  • 执行工具的从来不是模型。 文件真正被读取,是循环里 tool.invoke(toolCall.args) 这行代码干的——那是你的 Node.js 在跑 fs.readFile。模型只是在"提出请求"。

整个调用机制可以用这张图概括:

Tool Calling 调用机制

所以结论是:返回的数组是模型"生成"的,但"会有这个机制"是我们要求的。这也解释了为什么没有 LangChain 也能手动实现——用 prompt 告诉模型有哪些工具可用、参数格式是什么,需要时让它按规范格式输出 JSON,再自己解析、自己执行函数,效果一样。LangChain 只是把这几步封装好了而已。

总结

这一节我们入门了 LangChain,调用了大模型,并且实现了第一个 Tool:

  • 用千问的模型,因为它有免费额度;获取 API Key 后用 .env 管理,.env 不提交 git
  • tool() 创建工具:写一下函数,加上名字、描述、参数格式(用 zod 声明)就可以了
  • model.bindTools() 把工具传给大模型,在 SystemMessage 里告诉它工具的信息、规范它的回答流程
  • Message 分为 SystemMessageHumanMessageAIMessageToolMessage 四种
  • 之后直接问大模型某个代码的信息,它就会调用工具读取文件,然后解答了

理解一个关键点:大模型本身并不调用工具,它做的事情永远是通过纯文本回答。它只是返回 tool_calls 信息,真正执行工具的是我们自己的代码(上面的循环)。LangChain 只是对这套流程做了一层封装,没有 LangChain 我们也可以手动用 prompt 告诉大模型有哪些工具可用、参数是什么,然后让它按规范格式返回要调用的工具及参数。

实现了第一个 Tool 之后,你可以想一下 Cursor 是怎么实现的——后面我们就来实现一个简易版 Cursor!

基于 VitePress 构建 · 专注前端与 AI 实战