☰
LangChain Tool与Toolkit实战:从自定义工具到多工具智能体搭建
2026/10/22 9:34:25 网站建设 项目流程

搞懂LangChain Tool与Toolkit,到真正搭出一个能在对话里自己挑选工具、按顺序调用工具的多工具智能体,我前后折腾了两三天。回想起来,最大的卡点不是语法,而是没想清楚“工具”和“模型”之间到底是怎么配合的。这篇内容就是把我从零开始验证过的那套思路完整梳理一遍:从Tool与Toolkit的基本概念,到用装饰器自定义工具,再到用LangGraph把多个工具组合成一个可运行的智能体,最后附上我实际踩过的坑。无论你是刚学LangChain的入门者,还是已经写过Agent但总觉得工具调用不稳定的人,这篇内容应该都能帮你省下不少试错的时间。

1. 智能体的核心拼图:为什么Tool是绕不开的坎

1.1 不会调用工具的智能体,只是个聪明但没手的客服

很多人在学LangChain时,最早接触的是ConversationChain或者直接调大模型接口,体验是“问一句答一句”,感觉还挺智能。但一旦让模型去查实时天气、算一笔复杂的账、或者往数据库里写入一条记录,它就傻眼了,因为它只会生成文本,所有知识都来自训练数据截止到某个时刻的静态语料。

这时候你需要的是一个可以让模型“动手做”的东西。在LangChain里,这个单元就叫Tool。一个Tool本质上是一个函数,这个函数被包了一层标准协议:模型决定要不要调用它、传入什么参数、以及结果怎么返回。有了Tool,大模型才从“只会动嘴”升级成“能动手”。我习惯把这件事类比成一个顾问团队:模型是提供思路的专家,Tool是执行具体动作的操作员,专家永远不亲自干活,但会准确告诉操作员该干什么。

1.2 模型“调用工具”的执行流程拆开看

我自己真正开始写多工具智能体之前,一直误以为“模型会直接执行代码”。直到把LangChain内部流程跑起来,才理解所谓工具调用,其实是模型与程序之间的一次结构化协商。整个流程大概是这样的:

  1. 用户输入一段文本。
  2. 模型看到可用的工具列表(包括工具名、功能描述、参数定义),它不会自己去执行,而是输出一个结构化的“调用意图”,例如“我想使用get_time工具,参数没有”。
  3. 智能体框架把这个意图解析成真实的函数调用,执行对应代码。
  4. 函数执行后的结果会被包装成一条“工具消息”,再传回给模型。
  5. 模型看到工具返回的结果,再生成最终回答,或者决定继续调用下一个工具。

这个循环通常是多轮的,尤其是当一个任务需要拆解成多个步骤时。我第一次看到智能体为了回答一个问题连续调用三个工具时,确实有“啊,原来这就是Agent”的感觉。理解了这套循环,后续所有调试都会顺畅很多。

1.3 Tool与Toolkit之间的区别,一句话说清

刚接触时,我也曾被Tool和Toolkit这两个词搞晕过。其实非常简单:Tool是单点能力,比如“查询天气”“计算两数之和”“写入待办”;Toolkit是按业务场景打包的一组Tool集合,比如一个数据库Toolkit里面包含执行查询、获取表结构、检查表内容等多个相关工具。用工具箱类比,Tool是螺丝刀,Toolkit是整套工具箱。工具箱的好处是让智能体在处理某个领域问题时,不用手工把十几个工具一个个列出来,而是直接导入一个Toolkit就能获得一套互补能力。

2. 动手前的准备:环境搭建与Tool核心概念热身

2.1 搭环境要注意版本,别拿旧代码硬套

我给出的建议是直接创建一个干净的虚拟环境,然后安装下面这几个包:

pip install langchain pip install langchain-community pip install langgraph

这里要特别提醒一下版本问题。LangChain的更新速度非常快,我见过很多旧教程里还在用initialize_agent或者llm.binding(functions=...),但在较新的版本里,官方主推的是LangGraph的create_react_agent,工具定义方式也推荐使用@tool装饰器。如果你把旧代码和新库混着跑,最常见的报错就是LangChainDeprecationWarning警告、参数名对不上、甚至直接ImportError。

我实际验证时使用的组合是langchain 0.2.x配langgraph 0.2.x,小版本往上浮动基本没问题。装完之后,建议先跑一句话验证环境通不通:

from langchain_core.tools import tool @tool def hello(name: str) -> str: """向指定的人打招呼""" return f"Hello, {name}" print(hello.name) print(hello.args_schema.schema())

如果能正常输出工具名和参数结构,说明Tool相关的依赖已经没问题了。

2.2 大模型接入的通用姿势

很多教程会直接教你用某个特定服务商的模型类,但我觉得更稳妥的做法是先把兼容接口这件事搞清楚。你现在手里的大模型服务,不管来自哪家,只要提供了OpenAI兼容的HTTP接口,通常都可以通过相似的方式接入。我在本地通常会在环境变量里配置好三个值:接口地址、密钥、模型名,代码里统一读取。示例:

import os from langchain_openai import ChatOpenAI model = ChatOpenAI( model=os.getenv("LLM_MODEL", "your-model-name"), api_key=os.getenv("LLM_API_KEY", "your-api-key"), base_url=os.getenv("LLM_BASE_URL", "https://your-llm-endpoint/v1"), )

这个ChatOpenAI类在LangChain里已经成了“通用兼容客户端”的代名词,不一定是某一家独有。实际使用的时候,只要你的模型服务支持工具调用(Function Calling),把它配置成上面这样就能正常跑。如果你用的是本地部署模型,也可以通过类似方式指向本地服务,但模型版本尽量选支持工具调用的,不然后面的所有内容都白搭。

2.3 认识@tool装饰器:定义工具的最高效方式

刚开始写Tool时,我的第一反应是继承BaseTool类,然后实现_run方法,写起来很规范但代码量大。直到看到@tool装饰器,才意识到大部分场景根本不需要用类。装饰器会把一个普通Python函数自动包装成Tool,并且通过函数签名自动生成模型需要的参数Schema。

这里的关键点有三个:

  • 函数名就是工具名,所以命名要清晰,动词开头,一眼能看懂功能。
  • 参数的类型注解非常重要,它决定了模型“该填什么类型的值”。比如city: str和days: int,模型就会去生成字符串和整数。
  • 函数的docstring就是给模型看的“工具说明书”,模型会根据这段文字决定什么时候该调用它。千万别写“这是一个工具函数”这种废话,要写清楚功能、适用场景、参数含义。

下面是最小示例:

from langchain_core.tools import tool @tool def add_todo(task: str, due_date: str = "") -> str: """将一条任务添加到个人待办清单。task是任务内容,due_date是可选截止日期。""" # 实际逻辑可以改成写数据库、调接口等 return f"已添加待办:{task},截止:{due_date or '无'}"

这一小段代码包含的信息量其实很大:模型读到“将任务添加到个人待办清单”就知道这是写操作,读到参数名task和due_date就知道需要从用户话里抽取出什么信息。之后工具调用是否精准,很大程度上取决于这段说明书写得好不好。

3. 从零实现第一个自定义工具:安全、可靠的实用设计

3.1 别用eval写计算器,一个跑得通的四则运算示例

很多人定义计算工具时,喜欢直接把表达式字符串扔给eval(),图省事。但eval的安全隐患非常大,而且会让模型误以为它可以执行任意代码,这是我强烈不建议的。我实际采用的做法是定义一个参数更可控的计算工具,让模型分别传入两个操作数和一个运算符。

from langchain_core.tools import tool @tool def basic_calculator(a: float, b: float, operator: str) -> float: """对两个数值a和b执行基础四则运算。operator取值必须为:add、sub、mul、div。""" if operator == "add": return a + b if operator == "sub": return a - b if operator == "mul": return a * b if operator == "div": if b == 0: raise ValueError("除数不能为0") return a / b raise ValueError(f"不支持的运算符:{operator},只能使用add、sub、mul、div")

这样一个工具,模型调用它的方式就非常明确:解析到“帮我算23*7”,它会生成{"a": 23, "b": 7, "operator": "mul"}。而如果你用expression字符串参数,模型把整个句子当参数传进去的概率很高,解析起来非常头疼。

我个人测试下来,这类工具的函数签名越严格、可选项越少,模型调用准确率越高。因为模型不需要去猜测格式,只要在给定选项里选一个就行。之后如果你的业务有更复杂的计算需求,建议在工具里做一层映射,而不是让模型直接理解各种运算语法。

3.2 工具描述怎么写,模型才愿意用

这部分属于真正影响Agent体验的“隐形工作量”。我见过很多人写工具描述时只写了半句话,或者写成了给程序员看的接口注释,但模型根本不知道这个工具适合解决什么问题。这里分享我总结的一个描述模板:

  1. 第一句话点明工具功能,动词开头,比如“查询某个城市当前的天气”。
  2. 第二句话说明适用场景,例如“当用户想了解今天是否需要带伞、穿什么衣服时”。
  3. 第三句话说明参数含义与常见取值,比如“city参数只接受国内主要城市的中文名称”。

拿天气工具来举例:

@tool def get_weather(city: str) -> str: """查询指定城市当前天气情况。当用户询问天气、温度、是否需要带伞时使用。 参数city只接受中文城市名称,如北京、上海、广州。""" weather_map = { "北京": "晴,22℃,空气优", "上海": "多云,25℃,空气良", "广州": "阵雨,28℃,空气优", } return weather_map.get(city, f"暂时没有{city}的天气数据")

写清楚之后,模型才知道“用户说外面冷不冷”这件事也要用get_weather。描述写得太泛,模型会犹豫要不要调用;写得太偏,模型又会乱调用。建议写完描述后自己读一遍,把自己想象成一台没有常识的机器,只看这段文字能不能准确判断使用时机。

3.3 什么时候用@tool,什么时候需要继承BaseTool

@tool装饰器覆盖了90%的场景,但它有个短板:如果你需要给工具增加复杂的生命周期逻辑,比如缓存结果、限制调用频率、或者读取配置后动态生成工具,用类继承会更合适。我一般遵循下面这个判断原则:

  • 工具逻辑简单、就是输入到输出的函数映射:用@tool。
  • 工具需要保存状态、依赖初始化资源、或者需要做统一的异常包装:继承BaseTool。
  • 工具数量特别多且来自同一个外部系统:优先用Toolkit封装。

继承BaseTool时,最核心的三个字段是name、description和args_schema。其中参数Schema可以用pydantic的BaseModel定义:

from pydantic import BaseModel, Field from langchain_core.tools import BaseTool class WeatherInput(BaseModel): city: str = Field(description="中文城市名称") class WeatherTool(BaseTool): name: str = "get_weather" description: str = "查询指定城市当前天气" args_schema: type[BaseModel] = WeatherInput def _run(self, city: str) -> str: return f"{city}今天晴,22℃"

相比@tool,这种写法能让你在_run之前或之后再插入各种逻辑,比如打日志、限流、记录调用次数。对于生产环境里的工具管理来说,这种可控性非常重要。

4. 多工具智能体实战:打造一个能算数、查时间、记待办的工作助理

4.1 不要一次给模型太多工具,3到5个最好用

在设计智能体工具集时,新手最容易犯的错误是“越多越好”。我自己第一版就塞了十几个工具,结果模型经常在多个相似工具之间摇摆,或者为了一个简单问题反复调用好几个工具,费钱又费时间。

后来我把原则改成:每个工具必须提供不可替代的独立能力,并且数量尽量控制在三到五个。下面这个案例,我选了四个工具组成一个“工作助理”:

  • get_current_time:获取当前时间,处理“现在几点”“两小时后是几点”这类跨时间的问题。
  • basic_calculator:执行四则运算。
  • add_todo:把任务写入待办清单。
  • get_weather:查询城市天气,这样助理也能回答生活类问题。

这四个工具之间几乎没有功能重叠,模型很容易判断该用哪个。更重要的是,它们联合在一起可以完成一些需要多步调用的综合性任务,正好用来观察智能体的完整执行循环。

4.2 用LangGraph的create_react_agent串起来

LangGraph提供了一个预置好的Agent入口:create_react_agent。它内部实现了一套ReAct风格的循环,模型思考、调用工具、看到结果、再思考,直到输出最终答案。使用起来比老的AgentExecutor简洁不少。

from datetime import datetime from langgraph.prebuilt import create_react_agent from langchain_core.tools import tool @tool def get_current_time() -> str: """获取当前日期与时间。当用户询问当前时间、日期,或者需要计算相对时间时使用。""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S") tools = [get_current_time, basic_calculator, add_todo, get_weather] agent = create_react_agent(model, tools)

这里需要注意,create_react_agent接收的model必须是一个支持工具调用的ChatModel实例,不能是原始的LLM接口。如果你用的是通用兼容接口,只要模型本身支持Function Calling,通常都能正常挂载。

4.3 看一次真实的多轮工具调用过程

拼好Agent之后,我们来跑一个稍微复杂一点的请求,看它内部是如何编排的。我输入的是:

“帮我记一下明天上午10点开项目评审会,然后算一下从现在起2小时之后是几点,顺便告诉我北京天气怎么样。”

如果我把Agent产生的消息流打印出来,会看到类似下面的过程:

result = agent.invoke({ "messages": [{"role": "user", "content": "帮我记一下明天上午10点开项目评审会,然后算一下从现在起2小时之后是几点,顺便告诉我北京天气怎么样。"}] }) for message in result["messages"]: print(message.type, "|", message.content)

第一轮,模型没有直接回答,而是生成了一条tool_calls,里面同时包含三个调用意图:add_todo、get_current_time、get_weather。框架依次执行这三个工具后,把工具返回结果作为消息传回给模型。模型看到三个结果之后,才汇总成一段自然语言回答。

这个小实验非常有价值,因为它证明了两件事:模型确实能并行发起多个工具调用;工具返回的内容是模型后续回答的唯一事实来源。如果一个工具多返回了一段无意义的缓存文本,模型会真的把它也写进回答里。所以,工具返回什么要非常克制,只返回必要的信息。

4.4 用Toolkit统一管理一组工具

当工具数量继续增长,比如要接入一套完整的数据库操作能力,你这时候不应该再靠手写工具列表,而是可以考虑用Toolkit。LangChain社区库里内置了不少Toolkit,比如数据库相关的SQLDatabaseToolkit、办公套件相关的工具集,它们把某类能力的所有细节封装好,暴露一个get_tools()方法。

下面这段代码演示了如何自己定义一个简单的Toolkit,把前面的工作助理工具统一管理起来:

from typing import List from langchain_core.tools import BaseTool class WorkAssistantToolkit: """工作助理工具箱,汇总常用个人效率工具。""" def __init__(self): self._tools = [ get_current_time, basic_calculator, add_todo, get_weather, ] def get_tools(self) -> List[BaseTool]: return self._tools

和直接写tools = [...]相比,Toolkit的优势体现在“可复用”和“可扩展”上。比如你有多个Agent,一个偏向办公,一个偏向生活,只需要给它们挂载不同的Toolkit实例即可。新增工具时,只需要改Toolkit内部,不需要去各个Agent创建的地方修改代码。

5. 常见问题与排查技巧实录

5.1 模型就是不调用工具,问题往往出在描述上

遇到过太多次“模型完全无视工具,直接瞎编答案”的情况。排查下来,原因基本集中在三件事:

  • 模型本身不支持工具调用,或者接入方式不对,模型根本不知道有工具。
  • 工具描述与用户提问的匹配度太低,模型认为现有工具解决不了这个问题。
  • 参数Schema写得太复杂,比如要求模型生成一长串JSON,模型反而放弃了。

我推荐的排查顺序是:先手动调用工具,确认工具本身能跑通;然后打印模型的tool_calls,看模型是否生成了调用意图;最后再逐字检查工具描述,把措辞调得更贴近用户的自然语言提问方式。

5.2 工具执行出错时,别让它直接中断对话

工具代码一报错,整个对话就崩,这是很多新手会遇到的体验。实际上在Agent循环中,工具抛出的异常信息可以作为一种“工具结果”返回给模型,让模型知道刚刚的调用失败,然后调整参数重试,或者向用户解释。

我习惯在工具函数里做一层异常处理,返回给模型一个人类可读的失败提示,而不是裸抛错误。比如:

@tool def divide(a: float, b: float) -> float: """计算a除以b的商。""" if b == 0: return "除数不能为0,请更换参数重试" return a / b

这样模型拿到“除数不能为0”这个提示之后,通常会重新组织参数,或者告诉用户输入有误,整个对话依然能继续。这是一个容易忽略但体验提升非常明显的细节。

5.3 工具返回结果太长,模型容易迷失重点

工具返回的结果是直接拼进上下文传给模型的,所以结果内容越精简,模型越容易提炼信息。我之前写过一个查询工具,把一堆调试日志也塞进了返回值,结果模型在回答时把日志里的机器信息也当成事实复述出来,非常尴尬。

正确的做法是:工具函数内部可以打印日志便于开发调试,但返回值只保留用户真正需要的信息。如果存在多条记录,也尽量用短句和结构化文本,比如:

北京:晴,22℃,空气质量优 上海:多云,25℃,空气质量良

而不是把这个工具背后完整的JSON响应原样返回。

5.4 实用排查速查表

我把日常开发中最常遇到的几类问题整理成了表格,对照排查效率会高很多:

现象可能原因解决方案
模型从不调用工具模型不支持Function Calling或接入方式不对换支持工具调用的模型,重新配置模型接入
模型调用了错误的工具工具间功能描述重叠,模型区分不了精简工具数量,改写区分性描述
工具参数总生成错误参数名不够直白,或没有类型注解给参数加类型注解,补充参数含义
工具报了异常中断对话没有在工具内捕获异常用返回错误信息替代抛出异常
Agent反复调用工具停不下来工具返回结果不明确,或缺少退出条件检查返回内容是否足以让模型作出最终回答
自定义Toolkit导入报错使用了旧版本API升级依赖并改用get_tools()规范

5.5 关于成本和稳定性的个人建议

多工具智能体的成本是很多人忽略的坑。每多一次工具调用,就多一轮模型交互,费用和时间都会增加。我个人的做法是:能在一个工具里同时返回多项结果的,就不要拆成多个工具;能在系统提示词里说明先做信息收集再调工具的,就提前写好。另外,如果工具返回的数据存在重复可能,可以加上简单缓存,用“内存字典+时间戳”即可,不必上重型缓存框架。

还有一点,Agent循环每次调用模型都会把历史消息全部带上,消息一多,延迟就会明显增加。建议在长对话场景中对历史做必要截断,只保留最近几轮,这样既能保证模型不被冗余信息干扰,也能控制调用成本。

写到这里,我其实最想分享的心得是:工具调用这个环节,决定了智能体上限的不是Tool数量,不是模型多聪明,而是工具说明书写得多清楚。你花半小时打磨一段工具描述,省掉的可能是模型无数次乱调用带来的时间和金钱损耗。这个系列如果你也在一步步跟着做,建议从三五个工具的小助理开始,先跑通工具定义、挂载、调用、结果回传这条链路,再逐步往里面加复杂逻辑,后面对Toolkit的理解也会更快,踩坑概率低很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询