和大模型聊天,你可以问它问题,它会告诉你怎么做。但大模型本身没法帮你去做。
比如你想创建一个 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即可。
创建项目,先跑通模型调用
mkdir tool-test
cd tool-test
npm init -y用编辑器打开,创建一个文件 src/hello-langchain.mjs。
.mjs就是 ES Module 格式的 JS 文件,可以使用import、export语法。
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 兼容模式地址)。
安装依赖并运行:
pnpm install @langchain/openai
node ./src/hello-langchain.mjs可以看到模型调用成功了。
用 .env 管理 API Key
不过把 API Key 写死在代码里不好,我们通过 .env 文件来管理,然后用 dotenv 这个包读取:
pnpm install dotenvdotenv 的作用就是读取 .env 文件,把里面的变量设置到环境变量里:
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 文件里配置这些变量,代码里动态读取:
# 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:
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:
pnpm install @langchain/core zod逐段看下这段代码:
1. 创建模型
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
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,也就是这样:
{
"filePath": "xxx"
}3. 把 Tool 传给大模型
const modelWithTools = model.bindTools(tools);调用 bindTools,把工具列表绑定到模型上。然后运行:
node ./src/tool-file-read.mjs可以看到 AI 返回的消息是 AIMessage 实例,它解析出了我们给的路径,拼接好了调用工具的参数。接下来我们基于这个参数调用工具就行了。
四种消息类型
LangChain 中具体的消息有四种:
SystemMessage:设置 AI 是谁、可以干什么、有什么能力,以及一些回答和行为的规范HumanMessage:用户输入的信息AIMessage:AI 的回复信息ToolMessage:调用工具的结果返回
我们用 SystemMessage 告诉 AI:它是一个代码助手,可以读取文件并解释代码内容、给出建议。
Agent Loop:循环执行工具调用
关键在这段循环:
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;如果是有先后关系的逐个调用,就不能用,必须等上一个工具调用完成后再进行下一个。根据实际情况决定。- 为什么要反复
pushmessage? messages 数组就是对话历史,也就是 memory。大模型是无状态的,不把历史传给它是不知道之前做过什么的。AI 返回tool_calls告诉你要调工具,你调用后封装成ToolMessage放入 messages 表示工具调用结果,然后 AI 基于工具结果再做思考——message 就是整个对话过程。
跑一下试试:
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。模型只是在"提出请求"。
整个调用机制可以用这张图概括:
所以结论是:返回的数组是模型"生成"的,但"会有这个机制"是我们要求的。这也解释了为什么没有 LangChain 也能手动实现——用 prompt 告诉模型有哪些工具可用、参数格式是什么,需要时让它按规范格式输出 JSON,再自己解析、自己执行函数,效果一样。LangChain 只是把这几步封装好了而已。
总结
这一节我们入门了 LangChain,调用了大模型,并且实现了第一个 Tool:
- 用千问的模型,因为它有免费额度;获取 API Key 后用
.env管理,.env不提交 git - 用
tool()创建工具:写一下函数,加上名字、描述、参数格式(用 zod 声明)就可以了 - 用
model.bindTools()把工具传给大模型,在 SystemMessage 里告诉它工具的信息、规范它的回答流程 - Message 分为
SystemMessage、HumanMessage、AIMessage、ToolMessage四种 - 之后直接问大模型某个代码的信息,它就会调用工具读取文件,然后解答了
理解一个关键点:大模型本身并不调用工具,它做的事情永远是通过纯文本回答。它只是返回 tool_calls 信息,真正执行工具的是我们自己的代码(上面的循环)。LangChain 只是对这套流程做了一层封装,没有 LangChain 我们也可以手动用 prompt 告诉大模型有哪些工具可用、参数是什么,然后让它按规范格式返回要调用的工具及参数。
实现了第一个 Tool 之后,你可以想一下 Cursor 是怎么实现的——后面我们就来实现一个简易版 Cursor!
