LangChain.js 手写代码Agent:多工具并发调用完整实战
前言
很多刚上手AI Agent开发的同学,都会踩一个致命性能坑: 模型一次返回多个工具调用,却用循环串行逐个执行工具。 比如同时读3个文件,串行要等全部读取时长叠加,并发只需要最慢文件的耗时。
我之前做代码助手Agent时,串行调用多工具,单次任务耗时直接拉到5秒+,用户反馈卡顿严重。
改用
Promise.all并行执行工具后,响应速度直接提升60%,同时完善异常捕获、工具校验、多轮循环逻辑。
读完本文你能收获:
- 搞懂Agent底层ReAct执行流程,LLM为什么必须搭配工具才能干活
- LangChain.js 完整可运行代码Agent,兼容DeepSeek/OpenAI系列模型
Promise.all并行多工具原理,串行/并发性能对比- 生产级容错方案:工具不存在捕获、工具执行异常兜底
- 完整多轮循环逻辑,自动持续调用工具直到任务完成
一、先搞懂:普通LLM为什么做不了复杂任务?
原生大模型有个天生缺陷:无状态(stateless)
- 不会记忆上下文,每次对话清空历史,需要手动维护消息数组
- 无法操作本地文件、命令行、接口、数据库,只能输出文字
- 复杂任务不会拆分步骤,不会自主规划执行流程
Agent本质公式
Agent = LLM + Memory记忆 + Tool工具调用 + RAG知识库
工具(Tool)就是给大模型装上手脚,让AI能读写文件、执行命令、查询接口,自主完成完整任务。
工具调用完整流程(ReAct范式)
- 用户输入需求,存入
HumanMessage,搭配系统提示词SystemMessage - LLM推理判断:是否需要调用工具,生成
tool_calls数组(包含工具名、参数、唯一ID) - 后端并行执行所有工具,捕获执行结果,组装
ToolMessage并绑定对应tool_call_id - 把工具执行结果追加进消息列表,再次传给LLM
- 循环执行上述步骤,直到模型不再返回工具调用,输出最终答案
二、串行VS并行工具调用,性能差距肉眼可见
模拟两个异步任务示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>并行vs串行对比</title>
</head>
<body>
<script>
// 模拟两个耗时工具
function getWeather() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ temp: 38, conditions: 'Sunny with Clouds'})
}, 2000)
})
}
function getTweets() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['I like cake', 'BBQ is good too!'])
}, 500)
})
}
// 串行写法:总耗时=2000+500=2500ms
const runSerial = async () => {
console.time("串行执行");
const weatherData = await getWeather();
const tweetsData = await getTweets();
console.log(weatherData, tweetsData);
console.timeEnd("串行执行");
}
// 并行写法:总耗时=Max(2000,500)=2000ms
const runParallel = async () => {
console.time("并行执行");
const [weatherData, tweetsData] = await Promise.all([getWeather(), getTweets()]);
console.log(weatherData, tweetsData);
console.timeEnd("并行执行");
}
// runSerial();
runParallel();
</script>
</body>
</html>
核心结论
- 串行:工具排队执行,耗时累加,工具越多速度越慢
- 并行:所有工具同时启动,总耗时等于最慢工具耗时
开发生产级Agent,多工具场景必须用
Promise.all并发执行
三、完整实战:LangChain.js 代码读取Agent(可直接运行)
1. 项目依赖安装
npm install @langchain/openai @langchain/core dotenv zod fs-extra
2. .env 环境变量配置
DEEPSEEK_API_KEY=你的密钥
3. 完整可运行代码 tool.js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import {
HumanMessage,
SystemMessage,
ToolMessage,
AIMessage
} from '@langchain/core/messages';
import fs from 'node:fs/promises';
import { z } from 'zod';
// 1. 初始化兼容OpenAI接口的大模型(DeepSeek)
const model = new ChatOpenAI({
modelName:'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0, // 关闭随机性,工具调用更稳定
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
// 2. 定义文件读取工具,Zod约束参数格式
const readFileTool = tool(
async({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
console.log(`[工具执行成功] read_file(${filePath}),读取${content.length}字符`);
return content;
},
{
name: 'read_file',
description: `读取本地文件内容,用户要求查看代码、读取配置、分析文件时调用,支持相对/绝对路径`,
schema: z.object({
filePath: z.string().describe('待读取文件路径')
})
}
)
// 批量注册工具,可无限新增
const tools = [readFileTool];
// 给模型绑定工具,让模型知道可用函数
const modelWithTools = model.bindTools(tools);
// 初始化对话上下文消息数组
const messages = [
new SystemMessage(`
你是专业代码分析助手,拥有本地文件读取工具read_file。
执行规则:
1. 用户需要查看文件内容时,必须调用read_file工具
2. 拿到文件内容后,基于文件代码详细解读逻辑
3. 一次可同时调用多个read_file读取多个文件
`),
new HumanMessage('请读取 src/tool.js 文件并完整解释代码逻辑'),
];
// Agent多轮循环核心:持续检测工具调用,直到模型输出最终回答
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}个工具调用,开始并行执行 =====`);
// 核心:Promise.all 并发批量执行所有工具
const toolResults = await Promise.all(
response.tool_calls.map(async (toolCall) => {
// 1. 根据模型返回工具名匹配本地注册工具
const targetTool = tools.find(t => t.name === toolCall.name);
// 容错1:模型幻觉生成不存在的工具,直接返回错误文本
if (!targetTool) {
return `执行失败:未注册工具【${toolCall.name}】`;
}
console.log(`[待执行工具] ${toolCall.name} 参数:${JSON.stringify(toolCall.args)}`);
// 容错2:捕获工具内部报错(文件不存在、权限不足等)
try {
const result = await targetTool.invoke(toolCall.args);
return result;
} catch(err) {
return `工具执行异常:${err.message}`;
}
})
);
// 将工具执行结果绑定对应tool_id,存入消息上下文
response.tool_calls.forEach((toolCall, index) => {
messages.push(
new ToolMessage({
content: toolResults[index],
tool_call_id: toolCall.id
})
)
});
// 携带工具结果再次请求LLM,判断是否需要继续调用工具
response = await modelWithTools.invoke(messages);
messages.push(response);
console.log("\n===== 新一轮LLM推理完成 =====");
}
// 循环结束,输出最终回答
console.log("\n=== Agent任务完成,最终输出 ===");
console.log(response.content);
四、核心并行工具代码逐行拆解
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(err) {
return `错误:${err.message}`
}
})
);
逐段解析
response.tool_calls.map(async):把每一条工具调用转为异步Promise任务Promise.all:并发执行全部Promise,等待所有工具执行完毕再往下走tools.find:校验模型返回工具是否合法,防止模型幻觉try/catch:单个工具崩溃不会中断整个批量任务,统一返回错误字符串- 返回数组
toolResults顺序和tool_calls完全一一对应,方便绑定tool_call_id
五、生产环境踩坑提醒
坑1:Promise.all 一个工具卡死,整体阻塞
解决方案:换成Promise.allSettled,区分成功/失败状态,单独处理超时工具,增加工具调用超时封装。
坑2:忘记绑定tool_call_id,上下文错乱
每个工具返回结果必须用ToolMessage绑定对应tool_call_id,否则LLM无法匹配哪条结果对应哪个工具调用,会出现逻辑混乱。
坑3:串行执行多工具,性能极差
文件读取、接口请求等IO密集型工具,务必并行;只有工具存在依赖关系时才串行。
坑4:未做工具参数校验
使用Zod定义工具schema,LLM生成参数不合规时,tool.invoke会自动抛出错误,提前拦截非法参数。
坑5:无限循环调用工具
生产环境需要增加循环次数上限,比如最多执行10轮工具调用,防止模型死循环。
六、Agent完整工作流程总结
- 初始化模型、定义工具、组装System+Human消息
- 首次调用LLM,判断是否需要调用工具
- 存在
tool_calls则用Promise.all并行执行全部工具 - 捕获工具执行结果,封装
ToolMessage追加进消息列表 - 携带完整上下文再次请求LLM,循环往复
- LLM不再返回工具调用,输出最终答案,任务结束
七、扩展方向
- 新增CLI命令行工具,实现Vite项目自动创建、运行
- 接入RAG知识库,让Agent读取私有文档
- 使用LangGraph拆分多智能体,规划Agent、执行Agent分离
- 增加Memory记忆模块,支持多轮长期对话
- 接入MCP第三方工具协议,扩展联网、搜索能力