
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 UseLangChain的简单Agent模式已经完全够用。Dify等平台则更偏向于低代码/可视化虽然上手快但不利于理解底层机制。因此为了掌握核心原理我们从LangChain.js开始是性价比最高的选择。注意框架选型没有绝对的对错只有是否适合当前阶段。如果你是零基础想快速理解概念LangChain是最佳起点。如果你已经熟悉概念需要构建生产级复杂应用可以再深入研究LangGraph的状态管理。3. 开发环境搭建与核心依赖解析3.1 从零开始Node.js环境与依赖安装工欲善其事必先利其器。我们的实战将在一个干净的Node.js项目中进行。首先确保你的系统已经安装了Node.js版本18或以上推荐LTS版本。可以通过node -v和npm -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_KEYsk-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中一个“工具”本质上是一个具有以下特征的函数明确的名称智能体通过名称来识别和选择工具。清晰的描述这段描述至关重要大模型完全依靠描述来决定是否以及如何使用这个工具。描述应准确说明工具的功能、输入参数和输出。结构化的输入工具通常接收一个字符串作为输入但这个字符串往往需要被解析成具体的参数。可靠的执行逻辑工具内部封装了具体的业务逻辑如调用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的方式将这个函数包装成一个Toolimport { 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\: \userexample.com\, \subject\: \会议提醒\, \body\: \您好会议将于明天下午2点开始。\}”糟糕描述示例“用来发邮件。”问题过于模糊。大模型不知道何时调用它也不知道输入格式。另一个糟糕示例“此工具功能强大可以处理用户关于邮件的一切请求包括发送、保存草稿、添加附件等。输入是用户的自然语言。”问题功能描述不单一违反了工具职责单一原则输入格式不明确“自然语言”太宽泛会导致大模型困惑和错误调用。编写要点明确触发条件以“当用户想要/询问...时使用此工具”开头。严格定义输入格式指定是纯文本、城市名、JSON字符串还是不需要输入。对于复杂输入给出具体示例。保持功能单一一个工具只做一件事。不要试图创建一个“万能”工具。使用大模型能理解的术语避免内部代码缩写。6.2 处理复杂参数结构化工具与Zod模式当工具需要多个参数时将输入定义为一个长字符串让模型去“猜”是非常不可靠的。LangChain支持使用StructuredTool和zod模式来定义结构化的输入。首先安装zodnpm 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.race或AbortController为工具调用设置超时。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 智能体不调用工具或调用错误工具这是新手最常见的问题。排查步骤如下检查工具描述这是首要原因。描述是否清晰、准确地说明了工具的用途和输入格式打开verbose日志看模型在“思考”时是如何理解你的工具描述的。检查LLM的temperature确保temperature设置得足够低如0或0.1。过高的温度会导致模型行为随机。简化指令先用一个极其简单的指令测试单个工具例如“北京天气”排除复杂指令解析带来的干扰。查看模型输出在verbose模式下你会看到模型决定调用工具前输出的原始“函数调用”JSON。检查这个JSON中的name和arguments是否正确。如果不正确说明模型没有理解你的工具或指令。尝试不同的agentType如果使用openai-functions不理想可以尝试切换到structured-chat-zero-shot-react-description有时提示词驱动的ReAct模式在某些场景下更灵活。7.2 工具调用结果未被正确利用有时工具被正确调用了也返回了结果但智能体的最终回答却忽略了结果或答非所问。检查工具返回格式工具必须返回一个字符串。如果你返回了一个对象LangChain会尝试将其转换为字符串可能产生意外的[object Object]。确保你的func始终返回明确的字符串信息。返回信息要“对人友好”工具返回的字符串是给大模型“看”的但最终是呈现给用户的。所以返回的信息应该完整、清晰。例如不要只返回一个数字420而是返回“计算结果为420”。观察verbose日志中的Observation在日志里工具返回后会有一行[agent] Observation: ...。确认这里显示的内容是否是你期望工具返回的结果。如果不是问题出在工具函数本身。7.3 性能优化与成本控制智能体应用可能产生较高的LLM API调用成本尤其是工具调用涉及多轮“思考-行动-观察”循环时。减少不必要的循环优化工具描述使其更精准减少模型“犹豫不决”反复思考的次数。对于确定性的任务可以考虑直接用Chain而不是Agent。设置maxIterations在创建AgentExecutor时可以设置maxIterations选项限制智能体最大的推理步数防止陷入死循环。const executor await initializeAgentExecutorWithOptions(tools, llm, { agentType: openai-functions, maxIterations: 5, // 最多执行5步包括思考、调用工具 verbose: true, });使用更经济的模型对于工具调用本身GPT-3.5-turbo在大多数情况下已经足够可靠成本远低于GPT-4。可以将modelName明确指定为“gpt-3.5-turbo”。缓存Caching对于重复的、结果不变的查询如“北京的人口是多少”可以考虑实现缓存层将(用户问题 工具参数)作为键将工具结果缓存一段时间避免重复调用外部API和LLM。7.4 安全性考量智能体能够调用外部工具这带来了新的安全风险。工具权限最小化每个工具只应拥有完成其功能所需的最小权限。例如一个“查询数据库”的工具应该只有只读权限并且最好限制在特定的数据表或视图上。输入验证与净化在工具函数内部必须对输入进行严格的验证和净化防止注入攻击。前面计算器工具的例子就是一个反面教材。对于执行系统命令、访问文件、操作数据库的工具要尤为小心。用户指令审查可选在将用户输入传递给智能体之前可以增加一个“守门员”LLM调用或规则引擎对明显恶意、危险或超出范围的指令进行过滤和拦截。监控与审计记录所有工具调用的日志包括用户指令、调用的工具、传入的参数和执行结果。这对于事后排查问题、分析使用模式和发现潜在攻击至关重要。构建一个真正可靠、安全、高效的智能体是一个迭代过程。从最简单的工具调用开始逐步增加复杂性并持续测试和优化。当你看到一段简单的自然语言指令被自动转化为一系列精准的操作并完成时那种成就感是无可替代的。这不仅仅是技术的实现更是对人机交互方式的一次重新想象。