Appearance
从 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 接收两部分:
- 执行函数:参数从对象里解构出来,返回工具结果
- 元信息:
name、description、schema
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);几个关键点:
- 遍历
tool_calls:一次回复可能有多个工具调用 Promise.all并行执行:多个工具调用之间没有依赖时,并行比串行快很多tools.find路由:根据toolCall.name找到对应工具实例try/catch兜底:工具抛错时不要让循环崩,而是把错误信息作为结果回传给模型,让模型自己判断要不要换方式ToolMessage必须带tool_call_id:模型靠它把结果和当初的某次调用对应起来- 循环条件:模型还在返回
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 之后,大模型不再只是"告诉你怎么读文件",而是可以:
- 自己判断需要读哪个文件
- 主动发起工具调用
- 等程序执行后拿到真实内容
- 基于真实内容给出回答
Tool 的核心价值就是把大模型和真实环境连接起来。
十三、总结
- Tool 是大模型执行外部动作的入口
- 一个工具 = 函数 +
name+description+schema bindTools把工具能力告知模型,但不执行- 模型通过
response.tool_calls表达调用意图 - 程序负责实际执行工具,并用
Promise.all并行 ToolMessage必须带tool_call_id,才能让模型对应起每次调用- 整个流程是个循环,直到模型不再请求工具为止
继续扩展 write_file、list_dir、mkdir、execute_command 等工具后,就能逐步做出一个简易版的 Cursor。