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

日记详情

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

LangChain.js工具调用实战:从理论到实践打造智能AI应用

LangChain.js工具调用实战:从理论到实践打造智能AI应用

1. 项目概述:为什么LangChain.js的工具调用是AI应用落地的关键一步

最近在折腾LangChain.js,发现很多朋友在把玩完基础的聊天机器人后,就卡在了“如何让AI真正帮我做事”这个坎上。比如,你费劲写了个Agent,它能跟你聊得天花乱坠,但当你让它查一下明天的天气、或者帮你往数据库里存条数据时,它往往就只会说“抱歉,我无法访问实时数据”或者“我没有执行这个操作的能力”。这感觉就像你雇了个知识渊博的管家,但他只会动嘴皮子,连帮你开个灯都不会。问题的核心,就在于“工具”(Tools)的使用。LangChain.js中的“工具”,本质上就是赋予大语言模型(LLM)调用外部函数、访问外部资源的能力,让AI从“思想家”变成“实干家”。这不仅仅是技术实现,更是决定一个AI应用能否从Demo走向实用的分水岭。无论是处理实时信息、操作数据库、调用第三方API,还是执行本地计算,都需要通过工具来实现。今天,我们就抛开理论,直接进入实战,手把手拆解在LangChain.js中如何定义、集成并使用工具,打造一个真正能“动手”的智能体。

2. 工具(Tools)的本质:连接LLM与外部世界的桥梁

在深入代码之前,我们必须先搞清楚“工具”在LangChain.js架构里到底扮演什么角色。这有助于我们在后面做出正确的设计和选型。

2.1 工具的核心概念与工作流程

你可以把工具理解为一个标准的、LLM能够理解和调用的“函数接口”。这个接口包含几个关键部分:

  1. 名称(name):一个清晰、简短的标识符,LLM会根据这个名称来决定在什么情况下调用它。比如get_weathersearch_database
  2. 描述(description):这是最重要的部分。你需要用自然语言清晰地描述这个工具是干什么的、输入什么、输出什么。LLM(尤其是那些不专门针对代码训练的模型)主要靠这段描述来理解工具的用途。描述的质量直接决定了工具被正确调用的概率。
  3. 执行函数(func):一个实际的JavaScript/TypeScript函数,包含了真正的业务逻辑。当LLM决定调用某个工具时,LangChain会执行这个函数,并将结果返回给LLM。

其工作流程是一个典型的“规划-执行-观察”循环:

  1. 规划:用户提出请求(如“上海明天天气怎么样?”)。LLM分析请求,结合当前对话上下文和所有可用工具的描述,判断是否需要调用工具、以及调用哪个工具。
  2. 调用:LLM生成一个结构化的调用指令,包含工具名和输入参数。
  3. 执行:LangChain框架解析该指令,找到对应的工具函数并执行。这个函数可能会去调用一个天气API、查询数据库,或者执行一段计算。
  4. 观察:工具执行的结果(成功的数据或错误信息)被返回给LLM。
  5. 整合与回复:LLM接收到工具返回的结果,将其整合到自己的思考中,生成最终面向用户的自然语言回复。

这个循环可能会迭代多次。例如,用户问“帮我找一下关于LangChain的最新文章,然后总结成三点”。LLM可能先调用一个搜索工具获取文章列表,再调用一个阅读或总结工具来处理具体内容。

2.2 内置工具 vs. 自定义工具:如何选择?

LangChain.js 提供了丰富的内置工具,同时也支持高度灵活的自定义工具。选择哪种方式,取决于你的具体场景。

内置工具:开箱即用,通常是封装了常见、稳定的第三方服务。

  • 优势:集成快速,无需关注底层API细节,通常有较好的错误处理和类型定义。例如,SerpAPI工具用于搜索引擎检索,Calculator工具用于数学计算。
  • 劣势:灵活性受限,可能无法满足特定业务需求;部分工具可能需要API密钥和付费。
  • 适用场景:原型验证、快速搭建具备通用能力(如搜索、计算)的Agent,或者作为你工具集的一个补充。

自定义工具:完全由你定义函数逻辑。

  • 优势:无限灵活,可以连接任何内部系统、数据库、API或执行任何复杂逻辑。这是将AI能力嵌入到你现有业务系统的唯一途径。
  • 劣势:需要自行实现所有逻辑,包括错误处理、参数验证、安全控制等,开发成本较高。
  • 适用场景:绝大多数企业级应用、需要操作内部数据或业务流程的场景。

对于严肃的项目,我的经验是:以自定义工具为主,内置工具为辅。核心业务逻辑必须掌握在自己手里,用自定义工具封装;而对于信息检索等辅助性功能,可以酌情使用成熟的内置工具提升开发效率。

3. 实战:从零开始创建并使用自定义工具

理论说再多不如一行代码。我们以一个实际场景为例:构建一个“智能待办事项助手”。这个助手能帮用户添加任务、查询任务,甚至根据内容自动分类。

3.1 环境准备与基础结构搭建

首先,确保你的项目环境已经就绪。我们使用TypeScript来获得更好的类型提示。

# 初始化项目并安装核心依赖 npm init -y npm install langchain @langchain/core npm install -D typescript ts-node @types/node # 初始化tsconfig.json npx tsc --init

我们用一个简单的内存数组来模拟数据库,实际项目中你会连接真实的数据库。

// 模拟一个简单的内存数据库 interface TodoItem { id: number; title: string; description?: string; category?: string; completed: boolean; createdAt: Date; } class TodoStore { private todos: TodoItem[] = []; private idCounter = 1; addTodo(title: string, description?: string): TodoItem { const newTodo: TodoItem = { id: this.idCounter++, title, description, completed: false, createdAt: new Date(), }; this.todos.push(newTodo); return newTodo; } getTodos(filter?: { category?: string; completed?: boolean }): TodoItem[] { let result = this.todos; if (filter?.category) { result = result.filter(todo => todo.category === filter.category); } if (filter?.completed !== undefined) { result = result.filter(todo => todo.completed === filter.completed); } return result; } // 其他方法如updateTodo, deleteTodo等... } export const todoStore = new TodoStore();

3.2 定义第一个自定义工具:添加待办事项

现在,我们来创建第一个,也是最核心的工具——add_todo

import { DynamicStructuredTool } from "@langchain/core/tools"; import { z } from "zod"; // 用于参数验证 import { todoStore } from "./todoStore"; // 使用 DynamicStructuredTool,它支持基于 Zod Schema 的强类型参数定义 const addTodoTool = new DynamicStructuredTool({ name: "add_todo", description: "添加一个新的待办事项。输入需要标题,描述是可选的。", schema: z.object({ title: z.string().describe("待办事项的标题,必须清晰简短。"), description: z.string().optional().describe("待办事项的详细描述。"), }), func: async ({ title, description }) => { // 这里是真正的业务逻辑 try { const newTodo = todoStore.addTodo(title, description); return `成功添加待办事项!ID: ${newTodo.id}, 标题: "${newTodo.title}"。`; } catch (error) { return `添加待办事项失败:${error instanceof Error ? error.message : '未知错误'}`; } }, });

关键点解析与避坑经验:

  1. 为什么用DynamicStructuredTool而不是基础的ToolDynamicStructuredTool集成了参数验证(通过Zod),能向LLM提供更精确的参数类型和描述,极大提高了工具调用的准确率。这是官方推荐的方式。
  2. 描述(description)是灵魂:注意看描述,它明确说明了工具的功能和输入要求。LLM就是靠这个来理解的。写得模糊,调用就会出错。
  3. 错误处理必不可少:工具函数必须包含健壮的错误处理(try-catch)。因为工具可能被以意想不到的方式调用,或者底层服务可能失败。永远不要让一个未处理的异常直接抛给LLM,这会导致整个Agent崩溃。应该返回一个描述性的错误信息字符串。
  4. 返回字符串:工具函数必须返回一个字符串(或Promise )。这个字符串会被直接塞回给LLM作为观察结果。因此,返回的信息应该是对LLM“友好”的自然语言描述,同时包含关键数据。

3.3 定义第二个工具:查询待办事项

一个只能添加不能查看的待办助手是没用的。我们再创建一个查询工具。

import { DynamicStructuredTool } from "@langchain/core/tools"; import { z } from "zod"; import { todoStore } from "./todoStore"; const getTodosTool = new DynamicStructuredTool({ name: "get_todos", description: "查询待办事项列表。可以按分类筛选,也可以查看全部。", schema: z.object({ category: z.string().optional().describe("用于筛选的分类名称,例如 '工作'、'个人'。如果不提供,则返回所有事项。"), showCompleted: z.boolean().optional().default(false).describe("是否显示已完成的事项,默认为false(只显示未完成)。"), }), func: async ({ category, showCompleted }) => { try { const todos = todoStore.getTodos({ category, completed: showCompleted ? true : undefined, }); if (todos.length === 0) { const filterDesc = [category && `分类"${category}"`, showCompleted && `已完成`].filter(Boolean).join('且'); return `没有找到${filterDesc ? `符合${filterDesc}条件` : ''}的待办事项。`; } // 将数据格式化成易于LLM理解的文本 const todoList = todos.map(todo => `- ID:${todo.id} [${todo.completed ? '✓' : '○'}] "${todo.title}"${todo.description ? ` - ${todo.description}` : ''}${todo.category ? ` (#${todo.category})` : ''}` ).join('\n'); return `找到 ${todos.length} 个待办事项:\n${todoList}`; } catch (error) { return `查询待办事项失败:${error instanceof Error ? error.message : '未知错误'}`; } }, });

经验之谈:格式化返回信息工具返回给LLM的字符串,其格式非常重要。你应该返回结构清晰、信息完整的自然语言。避免返回原始的JSON或过于简短的语句(如“查询成功”)。好的返回格式能让LLM更容易提取关键信息并组织成给用户的回复。例如上面返回的列表格式,LLM可以轻松地将其转化为“我找到了您的X个待办事项,分别是...”这样的句子。

4. 组装智能体(Agent):让工具真正运转起来

有了工具,我们需要一个能调度它们的“大脑”——智能体(Agent)。这里我们使用最常用的ReAct代理框架,它模仿人类“思考-行动”的过程,效果非常稳定。

4.1 配置LLM与创建代理执行器

首先,你需要一个LLM。这里以OpenAI的模型为例,你也可以替换为Azure OpenAI、Anthropic等LangChain支持的其他模型。

import { ChatOpenAI } from "@langchain/openai"; import { AgentExecutor, createReactAgent } from "langchain/agents"; import { addTodoTool, getTodosTool } from "./tools"; // 假设我们把工具放在tools.ts里 // 1. 初始化LLM。请将你的API密钥放在环境变量中,不要硬编码在代码里! const llm = new ChatOpenAI({ modelName: "gpt-4o", // 对于工具调用,更强大的模型如gpt-4、gpt-4o、claude-3效果更好 temperature: 0, // 对于执行类任务,低温度(0-0.3)保证输出稳定性和可重复性 openAIApiKey: process.env.OPENAI_API_KEY, }); // 2. 定义工具数组 const tools = [addTodoTool, getTodosTool]; // 3. 创建ReAct代理 const agent = await createReactAgent({ llm, tools, // promptTemplate 可以自定义,这里使用默认的ReAct模板 }); // 4. 创建代理执行器,它是运行代理的主要接口 const agentExecutor = new AgentExecutor({ agent, tools, // 以下是一些重要配置 verbose: true, // 开发时设为true,可以看到LLM的思考链和工具调用详情,便于调试 maxIterations: 5, // 限制最大迭代次数,防止陷入死循环 handleParsingErrors: true, // 优雅处理LLM输出解析错误 });

4.2 运行你的第一个工具调用代理

现在,让我们来运行它,看看AI如何协调使用我们定义的工具。

async function runAgent() { const input1 = "帮我记一下:明天下午三点和团队开项目评审会。"; console.log(`用户: ${input1}`); const result1 = await agentExecutor.invoke({ input: input1 }); console.log(`助手: ${result1.output}\n`); // 等待一下,模拟另一个对话轮次 const input2 = "我刚才让你记了什么会议来着?"; console.log(`用户: ${input2}`); const result2 = await agentExecutor.invoke({ input: input2 }); console.log(`助手: ${result2.output}`); } runAgent().catch(console.error);

verbose: true时,你会在控制台看到类似以下的详细输出,这是理解Agent工作过程的绝佳材料:

用户: 帮我记一下:明天下午三点和团队开项目评审会。 [Agent Thought] 用户要求添加一个待办事项。我需要使用 add_todo 工具。工具需要的参数是 title 和可选的 description。从用户输入中,title 可以是“明天下午三点和团队开项目评审会”。description 可能不需要,或者可以用输入本身。我先调用工具。 [Agent Action] 调用工具 `add_todo`,参数:{"title": "明天下午三点和团队开项目评审会"} [Tool Output] 成功添加待办事项!ID: 1, 标题: "明天下午三点和团队开项目评审会"。 [Agent Thought] 工具调用成功,事项已添加。我可以把这个结果告诉用户。 助手: 好的,已经为您添加了待办事项:“明天下午三点和团队开项目评审会”。事项ID是1。 用户: 我刚才让你记了什么会议来着? [Agent Thought] 用户询问之前记录的会议。我需要查询待办事项列表。可以使用 get_todos 工具,不添加筛选条件来查看所有事项。 [Agent Action] 调用工具 `get_todos`,参数:{} [Tool Output] 找到 1 个待办事项: - ID:1 [○] "明天下午三点和团队开项目评审会" [Agent Thought] 查询结果显示有一个未完成的待办事项,正是之前添加的会议。我可以将这个信息回复给用户。 助手: 您之前让我记录的是“明天下午三点和团队开项目评审会”,目前该事项尚未完成。

这个过程清晰地展示了ReAct代理的“思考-行动-观察”循环。它自己分析问题、选择工具、解析参数、理解结果并生成回复。

5. 高级技巧与生产环境避坑指南

当你掌握了基础的工具调用后,下面这些进阶知识和踩坑经验能帮你构建更健壮、更强大的应用。

5.1 工具描述的优化艺术

工具的description是LLM选择工具的唯一依据。写得好坏天差地别。

  • 差描述“一个工具。”“处理数据。”
  • 好描述“根据用户提供的城市名称,查询该城市未来三天的天气预报,包括温度、天气状况和降水概率。输入应为单个城市名字符串。”

优化原则

  1. 明确功能:用动词开头,清晰说明做什么。(“查询...”、“计算...”、“存储...”)
  2. 定义输入:详细说明每个参数是什么、格式如何。(“城市名称,例如‘上海’”、“一个数学表达式字符串”)
  3. 说明输出:告诉LLM会得到什么。(“返回一个包含温度、湿度的字符串”、“返回操作成功或失败的消息”)
  4. 限定范围:说明在什么情况下使用。(“当用户询问天气时使用”、“当需要进行算术运算时使用”)

你可以为同一个工具准备多个不同详细程度的描述,在不同复杂度的Agent中切换使用。

5.2 处理复杂参数与多步骤操作

有时用户请求很复杂,比如“帮我查一下北京和上海的天气,然后对比一下”。一个工具可能搞不定,或者需要LLM进行多步规划。

方案一:设计组合工具创建一个高级工具,内部封装多个步骤。例如创建一个compare_weather工具,它在内部先调用两次天气查询API,再进行对比分析。这样对LLM来说,它只做了一次简单的工具调用。

方案二:依靠Agent的迭代能力这正是ReAct等框架的优势。LLM会先调用get_weather查北京,拿到结果后,再调用get_weather查上海,最后自己整合两个结果进行对比回复。你需要确保每个基础工具都设计良好,并且为Agent设置足够的maxIterations

关键点:对于复杂逻辑,我通常倾向于方案二。它更符合LLM的推理特性,也更具灵活性。方案一虽然将复杂性隐藏了起来,但工具描述会变得非常复杂,且不易维护。

5.3 错误处理与稳定性保障

在生产环境中,工具调用失败是常态。网络超时、API限流、无效输入等等。

  1. 工具层捕获:如前所述,每个工具函数内部必须有try-catch,返回错误信息字符串,而不是抛出异常。
  2. Agent执行器配置:利用handleParsingErrors配置。当LLM的输出无法被解析为有效的工具调用时,可以定义一个回调函数,向LLM返回一个定制化的错误提示,让它“重试”或“换一种方式思考”。
    const agentExecutor = new AgentExecutor({ agent, tools, verbose: true, maxIterations: 5, handleParsingErrors: (error) => { // 这里可以记录日志 console.error('解析Agent输出时出错:', error); // 返回一个指导性的信息给LLM return `我未能正确理解你的指令。请更清晰地说明你想让我做什么,或者直接告诉我你想使用哪个工具(如:添加任务、查询任务)。`; }, });
  3. 设置超时与重试:对于调用外部API的工具,应该在函数内部使用axios等库设置请求超时,并考虑实现简单的重试逻辑(注意幂等性)。
  4. 输入验证前置:在Zod Schema中尽可能定义严格的验证规则(如字符串格式、枚举值、数字范围),这能在工具执行前就过滤掉大量无效输入。

5.4 上下文管理(Memory)与工具调用

我们的例子中,两次invoke是独立的。但在真实对话中,我们需要Agent记住之前说过的话和做过的事。这就需要引入记忆(Memory)。

import { ChatMessageHistory } from "langchain/stores/message/in_memory"; import { MessagesPlaceholder } from "@langchain/core/prompts"; import { RunnableWithMessageHistory } from "@langchain/core/runnables"; // 1. 在创建Agent时,在Prompt中预留记忆变量的位置 const agent = await createReactAgent({ llm, tools, promptTemplate: ..., // 通常需要自定义Prompt,加入 `chat_history` 变量 }); // 2. 使用 RunnableWithMessageHistory 包装执行器 const messageHistory = new ChatMessageHistory(); const agentWithMemory = new RunnableWithMessageHistory({ runnable: agentExecutor, getMessageHistory: (_sessionId) => messageHistory, // 根据会话ID获取历史,这里简单处理 inputMessagesKey: "input", historyMessagesKey: "chat_history", }); // 3. 调用时传入配置 const result = await agentWithMemory.invoke( { input: "我刚才让你记了什么会议来着?" }, { configurable: { sessionId: "user-123" } } // 通过sessionId区分不同用户的对话历史 );

引入记忆后,Agent就能进行连贯的多轮对话,并基于历史上下文做出更准确的决策,比如知道“我刚才让你记的”指的是哪件事。

6. 调试与效能优化:让开发过程更顺畅

开发工具调用应用,大部分时间都在调试。以下是我总结的高效调试方法。

1. 开启Verbose模式:这是最重要的第一步。将AgentExecutorverbose设为true,所有思考过程、工具调用和结果都一览无余。2. 模拟工具(Mocking):在开发初期,或者当外部API不稳定时,可以先创建一个工具的“模拟版本”,返回固定的测试数据。这能让你快速验证Agent的逻辑流是否正确,而不用等待真实的API响应。3. 单元测试工具函数:将你的工具函数当作普通的异步函数进行单元测试。确保在各种边界输入下(空值、错误格式、超长字符串)都能正确处理并返回预期的字符串。4. 使用LLM调试输出:如果Agent的行为不符合预期,把verbose日志中LLM的“思考”(Thought)和准备调用的“动作”(Action)复制出来,单独扔给同一个LLM(比如通过OpenAI Playground),问它“基于这个工具描述和用户输入,你认为应该调用哪个工具?参数是什么?”这能帮你判断是工具描述的问题,还是LLM本身推理的问题。5. 迭代优化描述:调试往往是一个“修改工具描述 -> 运行测试 -> 观察结果”的循环。不要指望一次就能写出完美的描述。

在效能上,有两点需要注意:

  • 工具数量:一次性给Agent提供太多工具(比如超过10个)可能会降低其选择准确率,并增加Token消耗。可以考虑根据上下文动态加载工具集。
  • Token消耗:每个工具的描述、每次工具调用的输入输出,都会计入Token。优化描述的长度,并让工具返回简洁但信息丰富的结果,有助于控制成本。

工具调用是LangChain.js将大语言模型从“聊天玩具”变为“生产力应用”的核心枢纽。它要求开发者不仅要有LLM的知识,更要有扎实的软件工程思维:如何设计清晰的接口、如何处理异常、如何管理状态。当你熟练掌握了工具的创建、组合与调试,你就真正打开了构建复杂AI应用的大门。剩下的,就是将你的业务逻辑一个个封装成工具,然后看着AI智能体像一位熟练的员工一样,将它们串联起来,解决实际问题。这个过程充满挑战,但当你看到第一个能自动处理工作流的Agent跑通时,那种成就感是无与伦比的。

← 返回列表