三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

LangChain实战:构建具备工具调用能力的Node.js智能体

LangChain实战:构建具备工具调用能力的Node.js智能体

1. 项目概述:从“聊天”到“做事”的智能体跃迁

最近和几个做产品的朋友聊天,大家都有一个共同的感受:单纯让大模型“说得好听”已经不够了。无论是内部提效还是对外服务,我们真正需要的是它能“把事情办了”。比如,用户说“帮我查一下明天上海的天气,然后订一张后天去北京的机票,再提醒我下午三点开会”,我们希望的不再是一段文字回复,而是一个能自动调用天气API、访问航司系统、操作日历应用的智能程序。这正是“智能体”概念爆火的核心——让大模型从“思考者”进化为“执行者”。

今天要聊的“Tool Use”,就是这个进化过程中的第一步,也是最关键的一步。你可以把它理解为给大模型装上“手”和“脚”。没有工具调用能力的大模型,就像一个知识渊博但四肢瘫痪的学者,它知道所有理论,却无法对现实世界产生任何直接影响。而一旦掌握了Tool Use,它就能阅读文档、查询数据库、发送邮件、控制智能家居,真正将“思考”转化为“行动”。我最初接触这个概念时,觉得它很酷,但真正在项目中落地,才发现从理论到实践有一堆坑要填。这篇文章,我就以一个Node.js + LangChain的实战项目为例,带你手把手拆解如何构建一个具备基础工具调用能力的智能体,并分享那些官方文档里不会写的“血泪教训”。

2. 智能体与工具调用的核心设计思路

2.1 为什么是“智能体+工具”的组合?

在深入代码之前,我们必须先理清背后的设计哲学。为什么我们不直接写一个脚本去调用这些API,而非要绕个弯子,让大模型来指挥呢?核心原因在于处理不确定性和复杂性

想象一个场景:用户输入“我感觉有点冷,把家里的温度调高一点,再放点舒缓的音乐”。一个传统程序需要精确解析“有点冷”(是多少度?)、“调高一点”(调高几度?)、“舒缓的音乐”(具体是哪首歌或哪个歌单?)。这需要极其复杂的自然语言处理和规则引擎。而大模型驱动的智能体,其优势在于能理解这种模糊的、带有上下文的人类指令,并将其“翻译”成一系列确定性的、可执行的操作。

这里的“智能体”就是一个决策中枢,它基于大模型的理解能力,决定在什么时机、以什么参数、调用哪个工具。而“工具”就是一个个封装好的、功能单一的函数,比如adjustThermostat(temperature: number)playMusic(genre: string)。这种架构实现了灵活性与确定性的完美结合:大模型负责处理灵活多变的自然语言,工具函数负责提供稳定可靠的执行结果。

2.2 主流框架选型:为什么选择LangChain?

目前市面上的智能体开发框架不少,比如LangChain、LangGraph、Dify、FastAPI等。对于入门和实战,我强烈推荐从LangChain开始,尤其是在Node.js环境下。理由有三:

第一,生态成熟,文档丰富。LangChain是目前最流行的LLM应用开发框架之一,社区活跃,遇到的绝大多数问题都能在GitHub或Stack Overflow上找到答案。其“链”(Chain)和“代理”(Agent)的概念抽象得非常好,能帮你快速搭建起智能体的骨架。

第二,对工具调用的支持最为直观。LangChain提供了Tool基类和一系列内置工具(如搜索引擎、计算器),同时自定义工具非常简单。它的AgentExecutor封装了复杂的循环推理逻辑,让你可以更专注于工具和提示词的设计。

第三,与Node.js集成无缝。虽然LangChain最初是Python生态的,但其JavaScript/TypeScript版本已经非常完善,API设计一致,能很好地融入现代Node.js或前端工程化项目。

至于LangGraph,它更侧重于构建有状态的、多智能体协作的复杂工作流,像是给LangChain加上了流程图引擎。对于初学Tool Use,LangChain的简单Agent模式已经完全够用。Dify等平台则更偏向于低代码/可视化,虽然上手快,但不利于理解底层机制。因此,为了掌握核心原理,我们从LangChain.js开始是性价比最高的选择。

注意:框架选型没有绝对的对错,只有是否适合当前阶段。如果你是零基础,想快速理解概念,LangChain是最佳起点。如果你已经熟悉概念,需要构建生产级复杂应用,可以再深入研究LangGraph的状态管理。

3. 开发环境搭建与核心依赖解析

3.1 从零开始:Node.js环境与依赖安装

工欲善其事,必先利其器。我们的实战将在一个干净的Node.js项目中进行。首先,确保你的系统已经安装了Node.js(版本18或以上,推荐LTS版本)。可以通过node -vnpm -v来检查。

接下来,我们创建一个新项目并安装核心依赖:

# 1. 创建项目目录并初始化 mkdir my-first-agent && cd my-first-agent npm init -y # 2. 安装LangChain核心包及OpenAI(或其他LLM)包 npm install langchain @langchain/core # 3. 安装OpenAI的集成包(这里以OpenAI为例,你也可以选择Anthropic、Google等) npm install @langchain/openai # 4. 安装开发依赖,如TypeScript和ts-node(可选,但推荐用于更好的类型提示) npm install -D typescript ts-node @types/node npx tsc --init

这里解释一下几个关键包的作用:

  • langchain: 这是主包,包含了链、代理、记忆等核心抽象。
  • @langchain/core: 包含了更底层的接口和类型定义,是langchain包的基础。
  • @langchain/openai: 这是为OpenAI大模型(如GPT-3.5, GPT-4)提供的专门集成包,封装了模型调用、token计算等功能。如果你用其他模型,需要安装对应的包,如@langchain/anthropic

3.2 大模型API密钥配置与管理

几乎所有的大模型服务都需要API密钥。绝对不要将密钥硬编码在代码中或上传到GitHub。正确的做法是使用环境变量。

首先,在项目根目录创建一个.env文件:

OPENAI_API_KEY=sk-your-actual-openai-api-key-here

然后,安装dotenv包来在运行时加载这些变量:

npm install dotenv

在你的入口文件(如index.ts)的最顶部,加载环境配置:

import { config } from 'dotenv'; config(); // 这会读取 .env 文件中的变量到 process.env

现在,你就可以通过process.env.OPENAI_API_KEY安全地访问密钥了。在生产环境中,你可能会使用类似AWS Secrets Manager或Kubernetes Secrets的服务来管理这些密钥。

实操心得:我建议在项目初期就建立严格的密钥管理规范。除了使用.env文件,还可以考虑使用dotenv-cli在运行命令时注入,或者在Docker构建阶段通过构建参数传入。一个常见的坑是:在Docker容器内,.env文件可能不存在或路径不对,务必在Dockerfile或启动脚本中明确处理。

4. 构建你的第一个工具:理论与实战

4.1 理解LangChain中的Tool抽象

在LangChain中,一个“工具”本质上是一个具有以下特征的函数:

  1. 明确的名称:智能体通过名称来识别和选择工具。
  2. 清晰的描述:这段描述至关重要!大模型完全依靠描述来决定是否以及如何使用这个工具。描述应准确说明工具的功能、输入参数和输出。
  3. 结构化的输入:工具通常接收一个字符串作为输入,但这个字符串往往需要被解析成具体的参数。
  4. 可靠的执行逻辑:工具内部封装了具体的业务逻辑,如调用API、查询数据库、执行计算等。

LangChain提供了Tool基类来帮助我们标准化这些工具。创建一个工具,就是定义一个符合这个接口的对象。

4.2 实战:创建一个获取天气信息的工具

让我们从一个最经典的例子开始——天气查询工具。假设我们有一个(模拟的)天气API。

首先,我们实现工具本身的业务逻辑函数:

/** * 获取指定城市的天气信息(模拟函数) * @param {string} city - 城市名称,例如 "上海" * @returns {string} 返回天气描述字符串 */ async function getWeather(city) { // 这里应该是真实的API调用,例如 fetch(`https://api.weather.com/v1?city=${city}`) // 为了演示,我们模拟一个延迟和返回 await new Promise(resolve => setTimeout(resolve, 100)); // 模拟网络延迟 const weatherMap = { '北京': '晴朗,气温25°C,微风', '上海': '多云,气温28°C,东南风3级', '深圳': '阵雨,气温30°C,湿度85%', }; return weatherMap[city] || `抱歉,未找到城市 ${city} 的天气信息。`; }

接下来,我们用LangChain的方式将这个函数包装成一个Tool

import { Tool } from "langchain/tools"; const weatherTool = new Tool({ name: "get_weather", description: `当用户询问某个城市的天气时使用此工具。输入应该是一个单独的城市名称字符串,例如“北京”。`, func: async (input) => { // 这里的 input 是智能体传递过来的字符串,我们直接作为城市名使用 // 在实际复杂场景中,你可能需要解析这个字符串 const result = await getWeather(input.trim()); return result; }, });

关键点解析

  • name: "get_weather": 这个名字是智能体在内部进行工具选择时的标识符,要简洁、唯一。
  • description: 这是给大模型看的“说明书”。务必清晰准确。“当用户询问...时使用”指明了触发条件。“输入应该是一个单独的城市名称字符串”规范了输入格式。模糊的描述会导致大模型错误地使用或忽略该工具。
  • func: 这是工具的执行体。它接收一个字符串input,并返回一个字符串result。这个结果将被反馈给大模型,作为其下一步推理的依据。

4.3 创建更多工具:计算器与时间查询

为了演示智能体如何在不同工具间做选择,我们再创建两个简单的工具:

// 计算器工具 const calculatorTool = new Tool({ name: "calculator", description: `当用户需要进行数学计算时使用此工具。输入应该是一个数学表达式字符串,例如“12的平方根”或“(3 + 5) * 2”。`, func: async (input) => { try { // 警告:这里使用eval仅用于演示,在生产环境中极其危险! // 真实场景应使用安全的数学表达式解析库,如 math.js const sanitizedInput = input.replace(/[^0-9+\-*/().\s]/g, ''); const result = eval(sanitizedInput); return `计算结果为:${result}`; } catch (error) { return `计算失败:输入的表达式“${input}”不合法或无法计算。`; } }, }); // 获取当前时间工具 const getCurrentTimeTool = new Tool({ name: "get_current_time", description: `当用户询问当前时间、现在几点钟或需要时间信息时使用此工具。此工具不需要任何输入参数。`, func: async () => { // 注意:这个工具不需要输入参数 const now = new Date(); return `当前时间是:${now.toLocaleString('zh-CN')}`; }, });

重要警告:上面的计算器工具为了演示简单,使用了eval函数。这在任何线上或生产环境都是严重的安全漏洞,因为它允许执行任意代码。在实际项目中,你必须使用像math.js这样的安全库来解析和计算数学表达式。这里只是为了直观展示工具的结构。

5. 组装智能体:让大模型学会使用工具

5.1 初始化大语言模型(LLM)

智能体的“大脑”是大语言模型。我们以OpenAI的GPT-3.5-turbo为例进行初始化。

import { ChatOpenAI } from "@langchain/openai"; // 初始化LLM,指定模型和温度等参数 const llm = new ChatOpenAI({ openAIApiKey: process.env.OPENAI_API_KEY, modelName: "gpt-3.5-turbo-0125", // 指定模型版本,推荐使用最新稳定版 temperature: 0, // 温度设为0,使输出更确定、更可控,适合工具调用场景 streaming: false, // 初次调试可关闭流式输出,更易观察 });

参数解读

  • modelName: 建议明确指定一个版本号(如gpt-3.5-turbo-0125),而不是简单的“gpt-3.5-turbo”,这能确保API行为的一致性,避免因模型默认版本更新带来的意外变化。
  • temperature: 在工具调用场景下,通常设置为0或一个很低的值(如0.1)。这是因为我们需要智能体严格地根据工具描述和用户指令做出理性的、可预测的决策,而不是发挥创造性。高温度可能导致它“胡思乱想”,调用错误的工具或生成错误的参数。
  • streaming: 设为false便于调试。当你需要构建实时交互的聊天界面时,可以开启流式输出。

5.2 创建智能体执行器(AgentExecutor)

这是LangChain中负责运行智能体的核心组件。它将LLM、工具列表以及一套“推理逻辑”打包在一起。

import { initializeAgentExecutorWithOptions } from "langchain/agents"; // 将我们创建的工具放入一个数组 const tools = [weatherTool, calculatorTool, getCurrentTimeTool]; // 创建智能体执行器 const executor = await initializeAgentExecutorWithOptions( tools, // 工具数组 llm, // 大语言模型 { agentType: "openai-functions", // 代理类型!这是关键选择。 verbose: true, // 开启详细日志,调试时极其有用 } );

核心选择:agentTypeagentType决定了智能体内部的推理机制。LangChain提供了几种类型:

  • "openai-functions"这是当前最推荐、最稳定的类型。它利用OpenAI模型原生的“函数调用”(Function Calling)能力。模型会输出一个结构化的JSON,指明要调用哪个函数(工具)以及参数是什么,非常精准可靠。这是我们本次实战的选择。
  • "structured-chat-zero-shot-react-description": 这是一种更通用的、不依赖于特定模型原生功能的ReAct模式。它通过提示词让模型以“Thought/Action/Observation”的格式进行推理。兼容性更好,但有时不如OpenAI Functions稳定。
  • "zero-shot-react-description": 更基础的ReAct代理,适合简单场景。

对于新手,无脑选"openai-functions"配合GPT系列模型,成功率最高。

5.3 运行你的第一个智能体对话

现在,让我们来测试这个智能体是否能正确理解指令并调用工具。

// 测试用例1:混合指令 const input1 = “现在北京天气怎么样?另外,帮我计算一下15乘以28等于多少?”; console.log(`用户: ${input1}`); const result1 = await executor.invoke({ input: input1 }); console.log(`智能体: ${result1.output}`); console.log('---'); // 测试用例2:需要澄清的指令 const input2 = “告诉我时间”; console.log(`用户: ${input2}`); const result2 = await executor.invoke({ input: input2 }); console.log(`智能体: ${result2.output}`);

当你运行这段代码(需要先配置好OPENAI_API_KEY),并设置verbose: true,你会在控制台看到类似以下的详细推理过程:

用户: 现在北京天气怎么样?另外,帮我计算一下15乘以28等于多少? [agent] 思考:用户问了两个问题,一个是北京天气,一个是数学计算。我需要分别使用get_weather工具和calculator工具。 [agent] 动作:调用工具 get_weather,参数:北京 [工具] get_weather 返回:晴朗,气温25°C,微风 [agent] 观察:北京天气是晴朗,25°C。 [agent] 动作:调用工具 calculator,参数:15 * 28 [工具] calculator 返回:计算结果为:420 [agent] 观察:计算结果是420。 [agent] 最终回答:北京的天气是晴朗,气温25°C,微风。另外,15乘以28等于420。 智能体: 北京的天气是晴朗,气温25°C,微风。另外,15乘以28等于420。 --- 用户: 告诉我时间 [agent] 思考:用户询问当前时间,我需要使用get_current_time工具。 [agent] 动作:调用工具 get_current_time,参数:{} [工具] get_current_time 返回:当前时间是:2024/5/27 下午3:45:20 [agent] 观察:现在的时间是2024/5/27 下午3:45:20。 [agent] 最终回答:当前时间是2024年5月27日下午3点45分20秒。

看到这个日志,你应该感到兴奋——你的智能体已经成功地理解了复杂指令,自动选择了正确的工具,并按顺序执行了它们!这就是Tool Use的魅力。

6. 高级技巧与实战避坑指南

6.1 如何编写高质量的工具描述

工具描述的质量直接决定了智能体的表现。以下是一些编写原则和反面教材:

优秀描述示例(针对一个“发送邮件”的工具):“当用户想要发送电子邮件时使用此工具。输入必须是一个JSON格式的字符串,包含‘recipient’(收件人邮箱)、‘subject’(邮件主题)和‘body’(邮件正文)三个字段。例如:{\"recipient\": \"user@example.com\", \"subject\": \"会议提醒\", \"body\": \"您好,会议将于明天下午2点开始。\"}”

糟糕描述示例:“用来发邮件。”问题:过于模糊。大模型不知道何时调用它,也不知道输入格式。

另一个糟糕示例:“此工具功能强大,可以处理用户关于邮件的一切请求,包括发送、保存草稿、添加附件等。输入是用户的自然语言。”问题:功能描述不单一(违反了工具职责单一原则),输入格式不明确(“自然语言”太宽泛),会导致大模型困惑和错误调用。

编写要点:

  1. 明确触发条件:以“当用户想要/询问...时使用此工具”开头。
  2. 严格定义输入格式:指定是纯文本、城市名、JSON字符串还是不需要输入。对于复杂输入,给出具体示例。
  3. 保持功能单一:一个工具只做一件事。不要试图创建一个“万能”工具。
  4. 使用大模型能理解的术语:避免内部代码缩写。

6.2 处理复杂参数:结构化工具与Zod模式

当工具需要多个参数时,将输入定义为一个长字符串让模型去“猜”是非常不可靠的。LangChain支持使用StructuredToolzod模式来定义结构化的输入。

首先安装zod

npm install zod

然后创建结构化工具:

import { StructuredTool } from "langchain/tools"; import { z } from "zod"; // 1. 使用Zod定义一个参数模式 const sendEmailParamsSchema = z.object({ recipient: z.string().email().describe("收件人的电子邮件地址"), subject: z.string().describe("邮件主题"), body: z.string().describe("邮件正文内容"), }); // 2. 创建工具函数,现在它接收一个对象而不是字符串 async function sendEmailFunc({ recipient, subject, body }) { // 模拟发送邮件逻辑 console.log(`模拟发送邮件至: ${recipient}`); console.log(`主题: ${subject}`); console.log(`正文: ${body}`); return `邮件已成功发送至 ${recipient}`; } // 3. 创建结构化工具 const sendEmailTool = new StructuredTool({ name: "send_email", description: “当用户请求发送电子邮件时使用此工具。”, schema: sendEmailParamsSchema, // 传入模式定义 func: sendEmailFunc, });

当智能体使用这个工具时,大模型(特别是支持Function Calling的模型)会理解它需要提供recipient,subject,body这三个字段,并尝试从用户指令中提取这些信息。这极大地提高了复杂工具调用的准确性和可靠性。

6.3 智能体的记忆与多轮对话

我们之前的例子都是单轮对话。一个实用的智能体需要记住之前的对话上下文。LangChain提供了多种记忆机制。

最简单的是BufferMemory,它保存最近的对话历史:

import { BufferMemory } from "langchain/memory"; const memory = new BufferMemory({ memoryKey: "chat_history", // 存储在记忆中的键名 returnMessages: true, // 以消息对象格式返回 }); // 在创建执行器时传入memory const executorWithMemory = await initializeAgentExecutorWithOptions( tools, llm, { agentType: "openai-functions", verbose: true, memory: memory, // 添加记忆 } ); // 使用方式:invoke时传入chat_history const result = await executorWithMemory.invoke({ input: “刚才我问的北京天气,具体是几点钟查询的?”, // 这个问题依赖于上下文 chat_history: [] // 首次对话为空,后续需要从memory中获取并传入 });

记忆的持久化:在实际应用中,你需要将会话记忆(chat_history)存储在数据库(如Redis、PostgreSQL)或前端状态中,并在每次对话时将其传递回给智能体执行器。

6.4 错误处理与工具调用超时

工具是外部服务,可能会失败(网络超时、API错误等)。一个健壮的智能体需要处理这些情况。

基础错误处理:在工具函数内部进行try-catch。

func: async (input) => { try { const response = await fetch(`https://api.example.com/data?q=${input}`); if (!response.ok) { throw new Error(`API请求失败: ${response.status}`); } const data = await response.json(); return `查询成功: ${JSON.stringify(data)}`; } catch (error) { // 返回一个对LLM友好的错误信息 return `调用工具时发生错误:${error.message}。请检查您的输入或稍后再试。`; } }

超时控制:可以使用Promise.raceAbortController为工具调用设置超时。

func: async (input) => { const timeout = 5000; // 5秒超时 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(`https://api.example.com/slow`, { signal: controller.signal, }); clearTimeout(timeoutId); // ... 处理响应 } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { return `工具调用超时(超过${timeout}ms),请重试或检查服务状态。`; } return `工具调用失败:${error.message}`; } }

7. 常见问题排查与性能优化

7.1 智能体不调用工具或调用错误工具

这是新手最常见的问题。排查步骤如下:

  1. 检查工具描述:这是首要原因。描述是否清晰、准确地说明了工具的用途和输入格式?打开verbose日志,看模型在“思考”时是如何理解你的工具描述的。
  2. 检查LLM的temperature:确保temperature设置得足够低(如0或0.1)。过高的温度会导致模型行为随机。
  3. 简化指令:先用一个极其简单的指令测试单个工具,例如“北京天气”,排除复杂指令解析带来的干扰。
  4. 查看模型输出:在verbose模式下,你会看到模型决定调用工具前输出的原始“函数调用”JSON。检查这个JSON中的namearguments是否正确。如果不正确,说明模型没有理解你的工具或指令。
  5. 尝试不同的agentType:如果使用openai-functions不理想,可以尝试切换到structured-chat-zero-shot-react-description,有时提示词驱动的ReAct模式在某些场景下更灵活。

7.2 工具调用结果未被正确利用

有时工具被正确调用了,也返回了结果,但智能体的最终回答却忽略了结果或答非所问。

  1. 检查工具返回格式:工具必须返回一个字符串。如果你返回了一个对象,LangChain会尝试将其转换为字符串,可能产生意外的[object Object]。确保你的func始终返回明确的字符串信息。
  2. 返回信息要“对人友好”:工具返回的字符串是给大模型“看”的,但最终是呈现给用户的。所以返回的信息应该完整、清晰。例如,不要只返回一个数字420,而是返回“计算结果为:420”
  3. 观察verbose日志中的Observation:在日志里,工具返回后会有一行[agent] Observation: ...。确认这里显示的内容是否是你期望工具返回的结果。如果不是,问题出在工具函数本身。

7.3 性能优化与成本控制

智能体应用可能产生较高的LLM API调用成本,尤其是工具调用涉及多轮“思考-行动-观察”循环时。

  1. 减少不必要的循环:优化工具描述,使其更精准,减少模型“犹豫不决”反复思考的次数。对于确定性的任务,可以考虑直接用Chain而不是Agent
  2. 设置maxIterations:在创建AgentExecutor时,可以设置maxIterations选项,限制智能体最大的推理步数,防止陷入死循环。
const executor = await initializeAgentExecutorWithOptions(tools, llm, { agentType: "openai-functions", maxIterations: 5, // 最多执行5步(包括思考、调用工具) verbose: true, });
  1. 使用更经济的模型:对于工具调用本身,GPT-3.5-turbo在大多数情况下已经足够可靠,成本远低于GPT-4。可以将modelName明确指定为“gpt-3.5-turbo”
  2. 缓存(Caching):对于重复的、结果不变的查询(如“北京的人口是多少?”),可以考虑实现缓存层,将(用户问题 + 工具参数)作为键,将工具结果缓存一段时间,避免重复调用外部API和LLM。

7.4 安全性考量

智能体能够调用外部工具,这带来了新的安全风险。

  1. 工具权限最小化:每个工具只应拥有完成其功能所需的最小权限。例如,一个“查询数据库”的工具应该只有只读权限,并且最好限制在特定的数据表或视图上。
  2. 输入验证与净化:在工具函数内部,必须对输入进行严格的验证和净化,防止注入攻击。前面计算器工具的例子就是一个反面教材。对于执行系统命令、访问文件、操作数据库的工具,要尤为小心。
  3. 用户指令审查(可选):在将用户输入传递给智能体之前,可以增加一个“守门员”LLM调用或规则引擎,对明显恶意、危险或超出范围的指令进行过滤和拦截。
  4. 监控与审计:记录所有工具调用的日志,包括用户指令、调用的工具、传入的参数和执行结果。这对于事后排查问题、分析使用模式和发现潜在攻击至关重要。

构建一个真正可靠、安全、高效的智能体是一个迭代过程。从最简单的工具调用开始,逐步增加复杂性,并持续测试和优化。当你看到一段简单的自然语言指令被自动转化为一系列精准的操作并完成时,那种成就感是无可替代的。这不仅仅是技术的实现,更是对人机交互方式的一次重新想象。

← 返回列表