你正在构建一个多工具智能体,它能在回答“A仓库还有多少库存,B仓库呢,明天要发50件够不够”这类问题时,不是凭空编造一个数字,而是真的去调用接口查数据、做计算,最后给你一个可验证的答案。这就是 LangChain 里 Tool 与 Toolkit 体系的典型价值:把模型的“想象”变成可执行的“操作”。这篇内容就是一套从零到一的完整实践指南,适合已经跑通 LangChain 基础流程、但还没系统理解工具体系的开发者,也适合刚接触智能体、想知道“工具到底怎么被模型调用”的初学者。
1. 工具不是“插件”,是给模型装的传感器和执行器
1.1 模型再聪明也有够不到的地方
先说句大实话:大模型本身的能力边界非常硬核。知识有截止时间,新发生的事情它不知道;没有实时数据权限,问它今天的天气、某个仓库的实时库存,它只能靠训练数据里的统计规律去“猜”;算个稍微复杂的数字,比如某仓库剩余库存减去明天要发的50件,它也可能一本正经地算错。更关键的是,它没有执行能力,没法替你更新一条数据库记录、创建一个文件、调用一个外部系统。
这些边界不是“再训练一个更强的模型就能解决的”,而是“模型与世界之间缺少连接层”的问题。工具,就是这一层连接。你给了模型一个查询函数,它就等于多了一只可以伸到数据库里的手;你给了它一个计算函数,它就等于多了一个不会算错的计算器。LangChain 的工具体系,干的就是这件事。
1.2 工具调用的本质:模型动嘴,代码动手
很多人第一次接触 LangChain 的 Tool 时会疑惑:工具到底是模型在执行,还是模型自己写代码?都不是。工具调用的本质是:模型在对话过程中,基于你的问题,主动“声明”想调用某个函数,并给出参数;真正去执行这个函数的是你的代码;执行完的结果再传回给模型,模型继续推理并给出最终回答。
整个过程很像餐厅里的点餐:顾客(用户)向服务员(模型)说要吃什么,服务员不会做菜,但他知道后厨(工具)有哪些菜、怎么下单,他把菜端回来后,再由他向你汇报。后厨永远是服务员在指挥,但做菜的从来不是服务员本人。这个类比能帮你绕开一个最常见的误区——不是模型变聪明了,而是工具让它“够得着了”。
1.3 多工具智能体的最小工作流
一个多工具智能体的运行循环其实很朴素,但每一步都不能少:
- 用户输入被送到大模型
- 模型根据系统提示和工具描述,判断“要不要调用工具、调用哪个、参数填什么”
- 你的执行器拿着模型给出的参数,真的去调用工具
- 工具的返回值被拼接成一段文本,重新送进模型
- 模型看到工具结果,继续思考,直到它认为信息足够,再给出最终回答
这个循环就是智能体的基本心跳。LangChain 里的 Agent 负责把第 2 到第 5 步串起来。理解这个循环之后,你就会明白:整个系统里最容易被忽略、也最值得花心思的地方,其实是“工具描述”——因为模型唯一能看到的不是你工具内部的代码,而是你写的那几行说明文字。
2. 动手前先吃透三件事:Tool、Toolkit、Agent 的分工
2.1 Tool:一个“会说话的 Python 函数”
在 LangChain 里,Tool 是对普通函数的标准化包装。它有三个字段决定了模型能不能正确使用它:
| 字段 | 作用 | 谁来看 |
|---|---|---|
| name | 工具的唯一标识,模型通过这个名称调用工具 | 模型 |
| description | 工具的能力说明书,描述这个工具能做什么、什么场景用 | 模型 |
| args_schema | 参数格式定义,告诉模型参数名、参数类型、是否必填 | 模型 + 你的代码 |
这三样东西,name 必须短而唯一,description 必须清楚到“一个没看过你代码的人能准确调用”,args_schema 则要精确到“参数填错就会报错”。从最朴素的角度理解:Tool 让一个函数变成了“会说话”的服务——它把自己的能力和约束都通过描述告诉模型,然后等着模型来调用。
2.2 Toolkit:把散装工具打包成一个工具箱
Toolkit 是比 Tool 高一级的抽象。它的本质是“一组语义相关的工具的集合”。我用一个比较贴切的比喻:Tool 是螺丝刀,Toolkit 是工具箱,Agent 是那个拿着工具箱干活的工人。工人不会每次手里攥着二十把螺丝刀来回跑,他要的是一个箱子,打开就知道里面有哪些工具、分别能干什么。
在代码层面,Toolkit 通常是一个类的实例,这个类提供一个get_tools()方法,返回一个工具列表。Agent 真正拿到的就是这个列表。Toolkit 并不神秘,它更多解决的是组织问题:当你的工具数量多到一定程度,或者你要在不同项目里复用同一组工具时,把它打包成 Toolkit,代码结构会更清晰,复用也会更容易。
2.3 Agent:调度大脑与循环控制器
Agent 是整个多工具智能体的调度核心。它的职责是:读取所有工具描述,理解用户问题,自主规划该调用哪个工具、调用顺序是什么、参数怎么填。然后它执行工具、观察结果、再规划,直到获得足够的信息。
LangChain 里两种常见的 Agent 模式,建议一开始就分清楚:
| 模式 | 工作机制 | 适用场景 |
|---|---|---|
| tool-calling 模式 | 模型原生支持工具调用,通过结构化的 tool_calls 输出直接决定调用哪个工具 | 模型支持 function calling 时优先选这种 |
| ReAct 模式 | 模型通过输出文本格式(Action / Action Input)表达调用意图,再由解析器读取 | 模型不支持函数调用,或者你想自己控制解析逻辑 |
如果模型支持工具调用,尽量别用 ReAct。ReAct 的文字格式解析链路更长,也更容易在解析环节出问题。我在实测中最明显的体感是:tool-calling 模式的调用成功率更高,出错也更少。
2.4 版本差异提醒:别被旧教程带偏
LangChain 更新快,API 变动也不小。很多旧教程还在用initialize_agent这种老写法,但新版本更推荐的做法是create_tool_calling_agent配合AgentExecutor。你如果在旧教程里看到一个函数,去查阅你实际安装版本的官方文档,确认它是否已经废弃,再决定要不要用。这个提醒不是废话,我见过太多的错误堆栈,最后查来查去就是版本 API 不匹配导致的。
3. 从零写第一个工具:把普通函数变成模型可用的“手”
3.1 环境准备
这一步很简单。安装langchain和langchain-core作为基础依赖,再用你自己习惯的方式创建模型客户端。在下面的代码示例里,我会用llm变量表示你已经创建好的模型实例,它需要支持 tool calling / function calling。怎样初始化模型客户端,每种模型都不一样,这里就不展开了;你只要保证手头有一个能正常调用的模型实例即可。
pip install langchain langchain-core如果你打算在本地调试模型调用效果,建议把 verbose 打开,这样可以看到模型到底有没有生成工具调用。这一步经常被忽略,但它是后面排查一切问题的基础工具。
3.2 最朴素的例子:写一个查询库存的工具
我先用一个模拟的仓库库存数据构造一个工具。这个工具接收warehouse_id和可选的item_id,从一份本地数据里查询库存信息,并返回一段格式化的文本。
import json from langchain_core.tools import tool # 模拟一份仓库库存数据 INVENTORY = { "A01": {"SKU001": 120, "SKU002": 30}, "B02": {"SKU001": 80, "SKU002": 45}, } @tool def query_inventory(warehouse_id: str, item_id: str | None = None) -> str: """查询指定仓库的库存数量。仓库编码如 A01、B02。如果提供商品编码,返回该商品的库存;否则返回仓库内所有商品库存。""" if warehouse_id not in INVENTORY: return f"错误:仓库 {warehouse_id} 不存在" data = INVENTORY[warehouse_id] if item_id: count = data.get(item_id, 0) return f"仓库 {warehouse_id} 中商品 {item_id} 的库存为 {count} 件" return f"仓库 {warehouse_id} 的库存信息:{json.dumps(data, ensure_ascii=False)} 件"@tool装饰器会读取这个函数的名称作为工具名,读取函数的 docstring 作为 description,读取类型注解生成参数 schema。注意warehouse_id: str和item_id: str | None = None这两个注解,少了类型注解,工具的参数 schema 往往就无法正确生成,模型也就不知道该怎么填参。
3.3 好描述与坏描述的差距
工具描述是直接拼接进提示词、被模型逐字读取的。它决定了模型在正确的场景会不会想起这个工具、会不会填出正确的参数。我见过太多人写完工具的 docstring 就再也不管它,等到线上出问题才发现模型根本不知道工具能干什么。
举个坏例子:
查询库存。模型看完没有任何线索。仓库编码是什么格式?item_id 参数要不要填?返回什么内容?全是黑盒。
改成一个稍微像话的版本:
查询指定仓库的库存数量。仓库编码如 A01、B02。如果提供商品编码,返回该商品的库存;否则返回仓库内所有商品库存。这个描述里给出了仓库编码的示例,也说明了参数空与不空时的行为差异,模型知道怎么填参了,也知道了返回结果的形态。如果你希望模型在某些情况下不要调用这个工具,可以直接写进 description,例如:“仅用于查询,不要用于补货建议。”这种“用途 + 参数格式 + 返回值格式 + 限制条件”的结构,是我目前用过最稳定的写法。
3.4 正式接进 Agent 之前,先单测
很多问题在集成前就能拦下来。工具写完,先单独调用一次:
print(query_inventory.invoke({"warehouse_id": "A01"})) # 输出:仓库 A01 的库存信息:{"SKU001": 120, "SKU002": 30} 件再检查一下自动生成的参数 schema:
from langchain_core.tools import tool # 通过工具的 args_schema 可以导出 JSON Schema print(query_inventory.args_schema.schema())看到输出里有warehouse_id、item_id的字段定义,你就知道这个工具已经可以被模型理解了。如果 schema 是一片空白,优先检查你的函数是不是漏了类型注解。这种单测习惯能帮你把“Agent 为什么乱调用”的问题,从集成阶段提前拆解到工具编写阶段。
4. 把工具打包成 Toolkit:批量注册、命名冲突与内置包速览
4.1 什么时候该从 Tool 升级到 Toolkit
如果你只有一两个工具,直接往 Agent 里塞列表就完了,完全不需要 Toolkit。但当你遇到下面几种情况,就可以考虑打包了:
- 一组工具在多个项目或多个 Agent 里重复使用
- 工具数量超过五个,Agent 初始化代码变得越来越长、越来越难维护
- 你想把一个领域的能力完整封装起来,比如“库存管理”“订单处理”“文档解析”
Toolkit 的本质还是返回工具列表,它不会给工具本身增加一个“额外的包装层”。Agent 最终拿到的仍然是list[BaseTool]。所以不用担心 Toolkit 会拖慢执行效率,它只是个组织工具。
4.2 手写一个自定义 Toolkit:以库存管理为例
我这次准备造一个InventoryToolkit,里面放两个工具:一个是查询库存的query_inventory,一个是计算两个日期之间天数的calculate_days_between。后者看起来跟库存没关系,但真实场景里,智能体经常需要“算出距交货日还有几天”,然后跟库存一起做判断。
from langchain_core.tools import BaseToolkit, BaseTool, tool @tool def calculate_days_between(start_date: str, end_date: str) -> int: """计算两个日期之间的间隔天数,日期格式为 YYYY-MM-DD。""" from datetime import datetime s = datetime.strptime(start_date, "%Y-%m-%d") e = datetime.strptime(end_date, "%Y-%m-%d") return abs((e - s).days) class InventoryToolkit(BaseToolkit): """库存管理与日期计算工具包""" def get_tools(self) -> list[BaseTool]: return [query_inventory, calculate_days_between]这个自定义 Toolkit 的核心就是get_tools()方法。你可以在里面按需生成工具,甚至根据不同的运行环境返回不同的工具列表。用的时候很简单:
toolkit = InventoryToolkit() tools = toolkit.get_tools() print([t.name for t in tools]) # ['query_inventory', 'calculate_days_between']4.3 内置 Toolkit:能用现成的就别自己造
LangChain 的社区和官方都沉淀了一些内置 Toolkit,例如数据库查询类、文件系统类、文档加载类等。这些内置工具包的优势是经过大量场景验证,代码质量通常比你自己临时写的要高,也更少踩坑。缺点是它们背后的服务不同,初始化时往往需要自己提供连接参数,不同版本的类名和导入路径也经常变化。我的建议是:先用官方文档搜一下,如果有正在维护的解决方案,直接用它;没有合适的,再按上一小节的方式自定义。
4.4 命名冲突:两个工具叫了同一个名字
工具列表中不能出现两个name相同的工具,否则 Agent 在调用时会出现歧义,轻则调用到错误实现,重则直接报错。这是一个很隐蔽的坑,发生在你从一个 Toolkit 里拿工具、又手动往 Agent 里塞另一个同名工具时。
定位方法很简单,初始化前先把所有工具名打出来,检查重复项:
tools = toolkit.get_tools() + [query_inventory] # 可能出问题的写法 names = [t.name for t in tools] duplicated = {n for n in names if names.count(n) > 1} if duplicated: print("发现重名工具:", duplicated)发现重复后,改掉其中一个工具的name即可。给工具命名时最好带上业务前缀,比如inventory_query、inventory_update,而不是笼统的query、update——这样既降低重名概率,也方便模型识别工具归属。
5. 串成第一个多工具智能体:一次真实任务的完整链路
5.1 组装 AgentExecutor
Agent 的组装比想象中简单。关键是在一个 Prompt 模板里,把用户的输入和模型内部的工具调用记录位置预留好。create_tool_calling_agent会帮我们完成大部分调度工作。
from langchain.agents import create_tool_calling_agent, AgentExecutor from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一名仓库助理,善于调用工具查询库存并做数量计算。"), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_tool_calling_agent(llm=llm, tools=tools, prompt=prompt) agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)agent_scratchpad这个占位符很关键,它承载的是模型在推理过程中已经生成的工具调用和工具返回结果。没有它,模型就没有“记忆”去继续推理。
5.2 实测:让 Agent 替你做一次跨仓库存比较
组装完之后,直接赋予一个真实的业务问题:
result = agent_executor.invoke({ "input": "A01 仓库和 B02 仓库的 SKU001 库存各是多少?两个仓库加起来够明天发 50 件吗?" })如果把verbose=True打开,你能在当前终端看到类似这样的中间过程:
> Entering new AgentExecutor chain... Invoking: `query_inventory` with `{'warehouse_id': 'A01', 'item_id': 'SKU001'}` ... Invoking: `query_inventory` with `{'warehouse_id': 'B02', 'item_id': 'SKU001'}` ...Agent 先是调了两次query_inventory,拿到两个数字,然后自己在最终回答里做加法并给出结论。整个过程完全由模型自己规划,没有人在代码里写“先查 A 再查 B 再比较”的硬逻辑。这就是多工具智能体帮我们解放双手的地方——它自动完成了任务编排。
5.3 中间日志怎么读:判断 Agent 选路是否正常
日志里最值得关注的是Invoking那一行。它告诉我们模型认为此刻应该调用哪个工具、传入什么参数。如果日志里出现了不合理的调用,比如模型把warehouse_id填成了“一号仓库”,说明你的描述或 schema 没写清楚,而不是模型“变笨了”。把 Agent 的推理链路暴露到 verbose 日志里,是最直接的排障手段。
5.4 工具返回值的姿势:结构化文本比裸字典更稳
工具有一个极其影响后续推理的细节:返回值格式。如果你的工具返回一个 Python 字典,有时候模型确实也能读,但它更容易在格式化字符串时搞出歧义。我踩过几次坑之后,强制自己在工具里json.dumps(ensure_ascii=False),或直接用“仓库 X 中商品 Y 的库存为 Z 件”这样的文本模板。
例如:
# 不建议 return {"warehouse": warehouse_id, "sku": item_id, "count": 100} # 建议 return f"仓库 {warehouse_id} 中商品 {item_id} 的库存为 {count} 件"当你看到模型对工具结果理解错位时,先别怪模型,回去改工具的返回值格式往往就有奇效。工具返回的信息对模型而言就是一个字符串,字符串越规整,模型越容易从里面对应出最终回答所需的要素。
6. 让 Agent 少犯错的调优手段,以及一次典型踩坑复盘
6.1 描述里写清“不要做什么”
工具描述不止要说它能做什么,更要说清边界。比如我们要做一个“补货建议工具”,但如果库存不够,直接把“缺货”这件事说清楚,不要顺手给出建议,因为建议应该由另一个“补货建议工具”来管。把边界写进 description,模型就不会把一个工具同时干了两件事,也不会在工具返回缺货结果后依然瞎猜一个补货数量。
好描述里可以加一句“如果库存充足,无需调用此工具”或者“此工具不负责生成补货建议”。这听起来像是在对模型说话,但工具描述本质上就是要说给模型听的。你越是把限制写清楚,模型就越不会自由发挥。
6.2 工具内部防御:让错误信息成为推理依据
工具调用过程中的异常,最好不要直接抛给 AgentExecutor。一旦抛出未捕获的异常,整个链路会变得很难看,模型也会完全失去“自己修正”的机会。更稳妥的做法是工具内部 try-except,把异常转成一句可读的错误信息返回:
@tool def query_inventory(warehouse_id: str, item_id: str | None = None) -> str: """(描述同前)""" try: if warehouse_id not in INVENTORY: return f"错误:仓库 {warehouse_id} 不存在" ... except Exception as e: return f"查询库存时发生错误:{e}"这样它不会中断 Agent 的循环,模型读到“错误:仓库 X 不存在”时,会自己尝试修正参数、换一个仓库编码,甚至直接向用户说明情况。错误信息从“崩溃”变成了“推理素材”。
6.3 token 与性能优化:别让描述撑爆上下文
工具描述最终都会被拼进提示词。如果你给每个工具写一篇两千字的长篇描述,模型每次调用都要把这些文字从头读一遍,token 消耗自然增大,模型注意力也会被稀释。我见过一个项目里的工具描述长达整页,结果 Agent 反而更频繁调用错了工具。好的描述应该压缩在几行内,把用途、参数格式、返回值、边界说明讲清楚就好。给工具额外加一个简单的入参缓存也能省掉大量重复调用,比如用functools.lru_cache装饰底层函数,业务场景里非常实用。
6.4 踩坑复盘:一个描述过于泛化的“查询工具”是怎么坑了整个 Agent 的
写这篇文章的时候,我真的复现过一个典型场景:把两个工具分别命名为query和calculate,描述都很简单。结果 Agent 在遇到“计算明天发货够不够”这种问题时,不去调用calculate,反而连续调用query,传入完全错误的仓库编码。从 verbose 日志看,它就像一个固执的客服,反复拨同一个错误号码。
定位过程是这样的:
- 打开 verbose 日志,发现模型连续调用
query,参数warehouse_id有时候是“A01号仓”,有时候是“上海仓” - 检查
query工具的 schema,发现只有warehouse_id: str,没有任何说明仓库编码格式 - 查看了两个工具 description,发现
calculate的描述只写了“计算”,没写“用于计算日期差值或数值增减” - 模型根本不知道第二个工具是解决“够不够”问题的关键
修复方案是重写两个工具的描述,把query明确为“仅查询库存,不接受中文名称;仓库编码格式为 A01、B02”,把calculate明确为“计算两个日期天数差或数值运算结果,用于判断库存是否满足发货需求”。改完之后,同一个任务日志变成了清晰的“先查库存、再调用计算工具比较”链路。
这次踩坑让我养成了一个习惯:每个新工具都先直接投喂给模型一个测试问题,观察它调用路径是否与预期一致;如果不一致,先看日志里模型是怎么理解工具描述的,再顺着描述去修改,而不是去调模型参数。排查的优先级永远是:工具描述和 schema 的问题先看,Agent 配置的问题后看。
6.5 沉淀一份工具上线前的自查清单
我在多工具智能体上踩过足够多的坑之后,现在每次写完一组工具,都会对着下面的清单检查一遍:
- 工具名是否唯一、是否带业务前缀
- description 是否包含用途、参数格式、返回内容、边界限制
- 参数类型注解是否完整,可选参数是否有默认值
- 工具内部是否做了异常兜底,错误信息是否可读
- 返回值是否为规整文本而不是裸字典
- 是否先用单测确认工具能填充正确的参数,再接入 Agent
这六条看起来琐碎,但每一条都在真实项目里用事故换过教训。工具一旦打磨到清单里每一项都通过,Agent 基本上就不会再犯那些低级的“调用错了”类错误。
我个人的体会是:多工具智能体最难的部分,从来不是怎么让模型“会调用工具”,而是你愿不愿意先把每个工具本身打磨到可以被放心调用。工具是模型的传感器和执行器,传感器的精度不达标,大脑再聪明也判断不了世界。如果你最近也在搭类似的智能体,遇到问题多往描述和参数 schema 那边看看,大概率能找到最直接的解法。