Skip to content

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

一、为什么需要 Tool

普通大模型只能"告诉你怎么做",没法"替你做"。

比如让大模型创建一个 React + Vite 的 TodoList:它能写出每个文件的内容、要执行哪些命令,但它不能自己读文件、写文件、跑命令。Cursor、Claude Code 之所以能直接动手,是因为给大模型挂了一组 Tool。

Tool 就是大模型的"手脚"——把它从一个聊天框,变成一个能影响真实环境的 Agent。

二、Tool 的本质

Tool = 一个开发者写的函数 + 一段给模型看的说明

大模型不会执行函数,它只判断:

  • 当前任务要不要用工具
  • 用哪个工具
  • 传什么参数

真正执行工具的是我们的程序。模型只是发"调用意图",我们的程序读到这个意图,再去跑真实代码,最后把结果回传给模型。

三、整体流程

本节目标:实现 read_file 工具,让大模型可以读取本地文件并基于内容回答问题。

text
用户提问

模型判断需要读文件

模型返回 tool_calls(工具名 + 参数 + 调用 id)

程序按 tool_calls 执行 read_file

结果包装为 ToolMessage 推回 messages

再次 invoke 模型

(如果模型继续要调工具,循环)

模型基于真实文件内容输出最终回答

这就是一个最基础的 Agent 工具调用循环。

四、模型准备

示例用阿里云百炼的通义千问代码模型:

text
qwen-coder-turbo

它兼容 OpenAI API 协议,所以可以直接用 @langchain/openai 调。

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

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: 0:让工具调用更稳定、可复现,减少模型乱发挥
  • baseURL:通过 configuration.baseURL 指向百炼的兼容端点

.env 配置:

ini
OPENAI_API_KEY=你的 api key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-coder-turbo

.env 记得加进 .gitignore,不要提交。

五、定义 read_file 工具

LangChain 的 tool(executor, meta) API 接收两部分:

  1. 执行函数:参数从对象里解构出来,返回工具结果
  2. 元信息namedescriptionschema
js
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import fs from 'node:fs/promises';

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('要读取的文件路径'),
    }),
  }
);

三个字段都很关键:

  • name:模型用它来发起调用,要和后续 tools.find 匹配
  • description模型决定要不要用这个工具的依据,写得越准确,模型判断越靠谱
  • schema:用 Zod 描述参数结构,LangChain 会转成 JSON Schema 喂给模型,同时也用来做参数校验

schema 的作用就是让模型知道,调用 read_file 时要生成形如下面的参数:

json
{ "filePath": "src/tool-file-read.mjs" }

六、把工具交给模型

工具定义好后,用 bindTools 挂到模型上:

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

注意:这一步只是告诉模型"你可以使用这些工具",并不会执行工具。模型在生成回复时,如果觉得需要工具,就会在 response.tool_calls 里写明要调谁、传什么参数。

七、四种 Message

工具调用流程里会用到四种 Message:

类型作用
SystemMessage设置 AI 身份、能力、规则和工作流程
HumanMessage用户输入的任务
AIMessage模型返回的消息,可能包含 tool_calls
ToolMessage工具执行结果,回传给模型继续推理

构造初始 messages:

js
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';

const messages = [
  new SystemMessage(`你是一个代码助手,可以使用工具读取文件并解释代码。

工作流程:
1. 用户要求读取文件时,立即调用 read_file 工具
2. 等待工具返回文件内容
3. 基于文件内容进行分析和解释

可用工具:
- read_file: 读取文件内容(使用此工具来获取文件内容)
`),
  new HumanMessage('请读取 ./src/tool-file-read.mjs 文件内容并解释代码'),
];

SystemMessage 等于是给模型立规矩,明确"看到读文件的需求 → 必须调用 read_file"。

八、tool_calls 结构

第一次 invoke 后,模型通常不会直接答,而是返回带 tool_calls 的 AIMessage。tool_calls 是数组,每一项形如:

js
{
  name: 'read_file',
  args: { filePath: './src/tool-file-read.mjs' },
  id: 'call_xxx',  // 这次调用的唯一 id
}

注意是数组——一次回复里模型可能同时请求调多个工具。

九、执行循环(核心)

整段逻辑就是:模型返回 tool_calls → 程序执行 → 包成 ToolMessage 推回 → 再 invoke → 直到模型不再请求工具

js
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} 个工具调用]`);

  // 并行执行所有 tool_calls
  const toolResults = await Promise.all(
    response.tool_calls.map(async (toolCall) => {
      const currentTool = tools.find((t) => t.name === toolCall.name);
      if (!currentTool) {
        return `错误: 找不到工具 ${toolCall.name}`;
      }

      console.log(`  [执行工具] ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
      try {
        return await currentTool.invoke(toolCall.args);
      } catch (error) {
        return `错误: ${error.message}`;
      }
    })
  );

  // 把每个结果包装成 ToolMessage 推回 messages
  response.tool_calls.forEach((toolCall, index) => {
    messages.push(
      new ToolMessage({
        content: toolResults[index],
        tool_call_id: toolCall.id,
      })
    );
  });

  // 再次 invoke,让模型基于工具结果继续
  response = await modelWithTools.invoke(messages);
}

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

几个关键点:

  1. 遍历 tool_calls:一次回复可能有多个工具调用
  2. Promise.all 并行执行:多个工具调用之间没有依赖时,并行比串行快很多
  3. tools.find 路由:根据 toolCall.name 找到对应工具实例
  4. try/catch 兜底:工具抛错时不要让循环崩,而是把错误信息作为结果回传给模型,让模型自己判断要不要换方式
  5. ToolMessage 必须带 tool_call_id:模型靠它把结果和当初的某次调用对应起来
  6. 循环条件:模型还在返回 tool_calls 就继续,否则跳出输出 response.content

十、为什么是循环

模型可能:

  • 读完一个文件,发现还要读另一个 → 再发起一次 tool_calls
  • 一次同时请求读多个文件 → 一轮里 tool_calls.length > 1
  • 读到内容后决定可以回答了 → tool_calls 为空,循环结束

这其实就是 ReAct(Reason + Act) 模式的最小形态:模型一边推理一边行动,行动结果反过来影响下一步推理。

十一、完整可运行示例

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 currentTool = tools.find((t) => t.name === toolCall.name);
      if (!currentTool) {
        return `错误: 找不到工具 ${toolCall.name}`;
      }

      console.log(`  [执行工具] ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
      try {
        return await currentTool.invoke(toolCall.args);
      } 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);

十二、关键收获

接上 read_file 之后,大模型不再只是"告诉你怎么读文件",而是可以:

  1. 自己判断需要读哪个文件
  2. 主动发起工具调用
  3. 等程序执行后拿到真实内容
  4. 基于真实内容给出回答

Tool 的核心价值就是把大模型和真实环境连接起来

十三、总结

  • Tool 是大模型执行外部动作的入口
  • 一个工具 = 函数 + name + description + schema
  • bindTools 把工具能力告知模型,但不执行
  • 模型通过 response.tool_calls 表达调用意图
  • 程序负责实际执行工具,并用 Promise.all 并行
  • ToolMessage 必须带 tool_call_id,才能让模型对应起每次调用
  • 整个流程是个循环,直到模型不再请求工具为止

继续扩展 write_filelist_dirmkdirexecute_command 等工具后,就能逐步做出一个简易版的 Cursor。