核心结论:bindTools 只是把工具"注册"给模型,真正执行工具的循环要自己写。
我们要做什么
让 AI 做一件它本身做不到的事------读本地文件。整个流程设计起来很简单:
- 用户提问:"请读取 tool.mjs 并解释代码"
- 模型判断自己需要读文件 → 发起
内部工具工具调用请求 - 我们的代码真正执行读取,把文件内容回传给模型
- 模型拿到内容 → 输出代码解释
第 3 步是关键:模型只能"请求"调用工具,执行者始终是我们的程序。
定义工具:tool() + zod 三要素
先来看工具的定义代码:
javascript
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import fs from 'node:fs/promises';
const readFileTool = tool(
// ① 执行函数:真正干活的地方
async ({ filePath }) => {
try {
const content = await fs.readFile(filePath, 'utf-8');
console.log(`[工具调用] 内部工具(${filePath}) 成功读取 ${content.length} 字节`);
return content;
} catch (err) {
console.log(`[工具调用] 内部工具(${filePath}) 失败:${err.message}`);
return `读取文件失败:${err.message}`;
}
},
{
// ② 名称与描述:模型靠 description 判断"什么时候用、怎么用"
name: '内部工具',
description: '用此工具来读取文件内容,当用户要求读取文件。查看代码,分析文件内容时调用此工具。输入文件路径(可以是相对路径或者是绝对路径)',
// ③ 参数 schema:约束参数结构,同时给模型结构化的参数说明
schema: z.object({
filePath: z.string().describe('要读取的文件路径'),
}),
}
);
这里三个要素各司其职:
- 执行函数:真正的逻辑,返回值会成为回传给模型的内容
- description:模型决策依据,写得越明确,调用时机越准
- schema(zod) :参数类型约束 + 给模型的结构化提示,
.describe()会进入模型的参数说明
另外有个刻意设计:try/catch 返回错误字符串。工具失败时,模型能"看到"失败原因并自行调整(比如换一个路径重试),程序则不会崩溃。把错误也当作工具的一种返回值。
绑定模型
定义好工具后,把它绑到模型上:
php
const model = new ChatOpenAI({
model: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0,
configuration: { baseURL: 'https://api.deepseek.com/v1' },
});
const modelWithTools = model.bindTools([readFileTool]);
第一版代码的问题:工具从来没被执行
很自然的写法:
ini
let response = await modelWithTools.invoke(messages);
console.log(JSON.stringify(response.content));
运行输出:
arduino
"我来读取 tool.js 文件的内容。"
看似正常,实际文件根本没被读过。做一个诊断实验,打印完整的响应结构:
javascript
console.log('content:', JSON.stringify(r.content));
console.log('tool_calls:', JSON.stringify(r.tool_calls));
输出:
css
content: ""
tool_calls: [{"name":"内部工具","args":{"filePath":"tool.js"},"type":"tool_call","id":"call_00_xxx"}]
真相大白:模型返回的是一次工具调用请求 (tool_calls 数组),不是最终答案。第一版代码拿到请求后什么也没做,直接打印了 content 就结束。系统提示里写的"工作流程第 2、3 步(等待工具返回、基于内容分析)"在代码层面从未发生。
记住这个模型:
bindTools相当于把武器说明书递给模型,模型会告诉你"我要开火了",但扣扳机的必须是你。
补上 Agent 循环
完整的代码长这样:
ini
const messages = [
new SystemMessage(`你是一个专业的代码助手,可以使用工具读取文件并解释代码。
工作流程:
1.用户要求读取文件时立即调用内部工具 工具。
2.等待工具返回文件内容。
3.基于文件内容进行分析和解释。
可用工具:
- 内部工具 : 读取文件内容(使用此工具来获取文件内容)
`),
new HumanMessage(`请读取文件 tool.mjs 文件内容并解释代码`),
];
let response = await modelWithTools.invoke(messages);
// agent 循环:模型发起工具调用 → 真正执行工具 → 把结果回传给模型 → 直到模型给出最终回答
const maxRounds = 5; // 防止无限循环
for (let round = 0; round < maxRounds && response.tool_calls?.length; round++) {
messages.push(response); // 把模型的工具调用请求(AIMessage)加入对话
for (const call of response.tool_calls) {
const result = await readFileTool.invoke(call.args); // 真正执行工具
messages.push(new ToolMessage({
content: result,
tool_call_id: call.id, // 告诉模型这条结果对应哪次调用
}));
}
response = await modelWithTools.invoke(messages); // 带着工具结果再次询问模型
}
console.log(response.content);
循环里的四个关键动作:
messages.push(response)------ 把含tool_calls的 AIMessage 放回对话历史。OpenAI 兼容接口要求:有 tool_call 就必须有对应的 tool 结果,顺序不能乱。readFileTool.invoke(call.args)------ 真正执行工具,拿到文件内容。new ToolMessage({ content, tool_call_id })------ 用tool_call_id把结果和请求一一对应。多工具、多轮调用时全靠这个 id 对号入座。- 再次
invoke------ 模型看到工具结果后继续思考:可能给出最终回答,也可能再发起下一次工具调用。
循环退出条件有两个:模型不再返回 tool_calls(给出最终回答),或达到 maxRounds 上限。上限必须有------否则模型若陷入"反复要求调工具"的死循环,程序和账单都会失控。
运行结果
ini
[工具调用] 内部工具(tool.mjs) 成功读取 2397 字节
# tool.mjs 代码解释
这是一个基于 LangChain 构建的 AI Agent(智能体)示例......
这次 [工具调用] 日志出现了,文件真的被读了(2397 字节),模型也基于真实内容给出了逐段代码解析。
顺手修掉的两个小坑
- 提示词里的文件名写错 :让模型读
tool.js,实际文件是tool.mjs。就算循环写对了,工具执行也会 ENOENT。提示词里的每个细节都会成为模型的"事实"。 - 工具函数没有错误处理 :
fs.readFile失败会直接抛异常。改成 try/catch 返回错误字符串后,模型能感知失败并作出反应。
小结
一个最小的 Function Calling Agent = 工具定义(执行函数 + description + zod schema)+ bindTools + 手写执行循环。
理解的关键在于角色分工:模型负责"决策"(要不要调工具、传什么参数),你的代码负责"执行"(跑工具、回传结果)。所谓 Agent,本质就是这样一个"模型决策 + 代码执行"的循环,市面上的 Agent 框架(LangGraph、createReactAgent 等)封装的都是这个循环。