1. 五种工具定义方式,我为什么全踩了一遍
LangChain 里给 Agent 挂工具,看起来就是一行@tool的事,但真到项目里,工具一多、参数一复杂、异步一混用,坑就全冒出来了。我最近在一个内部知识库问答项目里,前后用了五种不同的工具定义方式,从最朴素的Tool类到StructuredTool、@tool装饰器、BaseTool子类化,再到Toolkit打包,每一种都踩过至少一个坑。这篇文章就把这五种方式的适用场景、核心原理、实操细节和避坑经验一次性讲透。
先说清楚这个内容适合谁看。如果你刚开始接触 LangChain,还在用initialize_agent加几个简单函数当工具,那这篇文章能帮你提前避开后面会遇到的参数校验、异步调用、错误处理这些坑。如果你已经用过两三种方式,但说不清楚什么时候该用哪种,那这篇文章能帮你建立一套选型逻辑。如果你正在维护一个工具数量超过十个的 Agent 项目,那第五种 Toolkit 方式可能就是你现在最需要重构的方向。
核心关键词就几个:LangChain 工具定义、StructuredTool、tool 装饰器、BaseTool 子类化、Toolkit 打包。这五个词基本覆盖了从入门到进阶的全部工具定义路径。我下面会按“从简单到复杂、从能用 to 好用”的顺序,把每种方式的代码骨架、参数设计、踩坑记录和适用边界都拆开讲。
需要提前说明的是,LangChain 的版本迭代非常快,我用的版本是langchain 0.1.x配合langchain-core 0.1.x,部分 API 在更早或更晚的版本里可能有差异。但工具定义的核心逻辑——名称、描述、参数 schema、调用入口——这几个要素是稳定的,版本变化主要影响的是写法糖和导入路径。你如果用的是其他版本,重点关注逻辑,导入路径按你本地环境调整就行。
2. 五种工具定义方式逐个拆解
2.1 最朴素的 Tool 类:能用,但参数一多就崩
第一种方式是最原始的Tool类,直接传名称、函数和描述。写法大概是这样:
from langchain.agents import Tool def search_knowledge(query: str) -> str: """在内部知识库中搜索""" return f"搜索结果:{query}" tools = [ Tool( name="knowledge_search", func=search_knowledge, description="在内部知识库中搜索信息,输入应该是一个搜索关键词" ) ]这种方式最大的好处是直观,一眼就能看懂工具的三个核心要素:名称、执行函数、描述。对于只有一两个简单工具、参数全是字符串的场景,它完全够用。我一开始就是用这种方式快速搭了一个原型,十分钟就跑通了。
但问题很快就来了。第一个坑是参数类型无法约束。Agent 在决定调用工具时,会把参数拼成一个字符串传进来。如果你的函数签名是def search_knowledge(query: str, top_k: int = 5),Agent 可能会传"query='天气', top_k='五'"这种乱七八糟的东西,因为Tool类不会帮你做参数解析和类型校验。我实测下来,当工具参数超过一个时,Agent 传参的准确率会明显下降,尤其是涉及数字和布尔值时。
第二个坑是描述和参数说明混在一起。description字段既要告诉 Agent 这个工具是干什么的,又要暗示参数格式,写起来很别扭。比如上面那个例子,我不得不在描述里加一句“输入应该是一个搜索关键词”,但这其实应该是参数 schema 该干的事。当工具有多个参数时,这种混写方式会让描述变得又长又乱,Agent 反而更容易理解错。
第三个坑是不支持异步。Tool类的func只接受同步函数,如果你的工具内部要调异步的 HTTP 请求或数据库查询,就得自己包一层asyncio.run(),这在已经运行在事件循环里的环境中会直接报错。我踩这个坑的时候排查了半天,最后发现是asyncio.run()不能在已有事件循环中调用。
注意:
Tool类适合原型验证和单参数简单工具。一旦参数超过一个,或者需要异步支持,就应该考虑后面的方式。
2.2 StructuredTool:参数 schema 是核心武器
第二种方式是StructuredTool,它解决了Tool类最大的痛点——参数结构化。你可以用 Pydantic 的BaseModel来定义参数 schema,每个参数的类型、描述、默认值都能写得清清楚楚。
from langchain.tools import StructuredTool from pydantic import BaseModel, Field class SearchInput(BaseModel): query: str = Field(description="搜索关键词") top_k: int = Field(default=5, description="返回结果数量") category: str = Field(default="all", description="搜索分类") def search_knowledge(query: str, top_k: int = 5, category: str = "all") -> str: return f"在{category}分类下搜索{query},返回{top_k}条" search_tool = StructuredTool.from_function( func=search_knowledge, name="knowledge_search", description="在内部知识库中搜索信息", args_schema=SearchInput )这种方式的核心优势在于,Agent 看到的参数说明是从 schema 自动生成的,而不是从描述文本里猜的。LangChain 会把 Pydantic 模型转成 JSON Schema,再喂给 LLM,LLM 对结构化 schema 的理解准确率明显高于对自然语言描述的理解。我实测下来,参数传错的概率从Tool类的三成左右降到了不到一成。
但StructuredTool也有自己的坑。第一个坑是默认值和必填项的交互。如果你在Field里设了default,那这个参数就是可选的;如果没设,就是必填的。听起来很合理,但 Agent 有时候会“自作聪明”地省略它认为不重要的必填参数,导致调用失败。我的经验是,对于真正必填的参数,除了不设默认值,还要在description里用“必须提供”这样的字眼强调,双保险。
第二个坑是嵌套 schema 的支持有限。如果你的参数是一个列表或者嵌套对象,比如List[Dict[str, str]],Pydantic 能定义,但 LangChain 转成 JSON Schema 后,部分 LLM 对嵌套结构的理解会出问题。我试过用嵌套 schema 定义一个“批量搜索”工具,结果 Agent 经常把嵌套结构拍平成一个字符串传进来。后来我改成用 JSON 字符串作为参数,在函数内部自己解析,反而更稳定。
第三个坑是同步和异步的写法差异。StructuredTool.from_function默认创建的是同步工具,如果你要异步,需要用coroutine参数传一个异步函数:
async def async_search(query: str, top_k: int = 5) -> str: # 异步实现 return f"异步搜索{query}" async_tool = StructuredTool.from_function( coroutine=async_search, name="async_search", description="异步搜索工具", args_schema=SearchInput )这里有个细节:如果你同时传了func和coroutine,LangChain 会优先用异步的。但如果你只传了coroutine没传func,在某些 Agent 类型下会报错,因为 Agent 内部可能尝试同步调用。我的做法是,如果工具需要异步,就同时提供同步和异步两个实现,同步版本可以简单地用asyncio.run()包一层,虽然性能差一点,但兼容性最好。
实操心得:
StructuredTool是我在大多数场景下的首选。参数超过一个就用它,参数 schema 写详细一点,Agent 的调用准确率会有质的提升。
2.3 @tool 装饰器:最优雅,但暗坑最多
第三种方式是@tool装饰器,这是 LangChain 官方文档里最推荐的方式,写起来也最优雅:
from langchain.tools import tool @tool def search_knowledge(query: str, top_k: int = 5) -> str: """在内部知识库中搜索信息。 Args: query: 搜索关键词 top_k: 返回结果数量 """ return f"搜索{query},返回{top_k}条"装饰器会自动从函数签名和 docstring 里提取工具名称、描述和参数 schema。函数名变成工具名,docstring 变成工具描述,参数类型注解变成 schema。看起来完美,但我踩的坑也最多。
第一个坑是docstring 的格式要求很严格。@tool默认用 Google 风格的 docstring 解析参数说明。如果你写成:
@tool def search_knowledge(query: str, top_k: int = 5) -> str: """搜索知识库 query 是关键词 top_k 是数量 """那query和top_k的参数描述就提取不出来,Agent 看到的参数说明是空的。我一开始没注意这个格式问题,导致 Agent 经常把top_k传成字符串"五"而不是数字5。后来改成标准的 Google 风格Args:块,问题才解决。
第二个坑是工具名称的自动转换。函数名里的下划线会被保留,但如果你用中文函数名或者带特殊字符,可能会出问题。更隐蔽的是,如果你定义了两个同名函数(比如在不同模块里),后定义的会覆盖先定义的,而且不会有任何警告。我在一个项目里就因为这个原因,调试了半天才发现工具被覆盖了。
第三个坑是装饰器参数和函数参数的冲突。@tool可以接受参数,比如@tool("custom_name")来指定工具名,或者@tool(return_direct=True)来让工具直接返回结果。但如果你同时用了这些参数,又依赖函数签名自动提取 schema,有时候会出现 schema 和实际函数签名不一致的情况。我建议是,要么全自动(只用@tool不带参数),要么全手动(用StructuredTool),不要混用。
第四个坑是异步函数的装饰。@tool可以装饰异步函数,但你需要确保 Agent 是以异步方式运行的。如果你用同步的AgentExecutor去跑一个异步工具,会直接报错。而且异步工具的 docstring 解析和同步工具是一样的,不会因为异步就有什么特殊处理。
注意:
@tool装饰器适合快速定义简单工具,但 docstring 格式一定要规范。如果工具逻辑复杂或者参数多,我还是更倾向于StructuredTool,因为 schema 是显式定义的,不容易出意外。
2.4 BaseTool 子类化:最灵活,但代码量最大
第四种方式是直接继承BaseTool类,这是最灵活的方式,也是代码量最大的方式:
from langchain.tools import BaseTool from pydantic import BaseModel, Field from typing import Type, Optional from langchain.callbacks.manager import CallbackManagerForToolRun class SearchInput(BaseModel): query: str = Field(description="搜索关键词") top_k: int = Field(default=5, description="返回结果数量") class KnowledgeSearchTool(BaseTool): name: str = "knowledge_search" description: str = "在内部知识库中搜索信息" args_schema: Type[BaseModel] = SearchInput def _run( self, query: str, top_k: int = 5, run_manager: Optional[CallbackManagerForToolRun] = None ) -> str: return f"搜索{query},返回{top_k}条" async def _arun( self, query: str, top_k: int = 5, run_manager: Optional[CallbackManagerForToolRun] = None ) -> str: return f"异步搜索{query},返回{top_k}条"这种方式的核心优势是完全控制。你可以在_run和_arun里做任何事:初始化数据库连接、加载模型、维护状态、自定义错误处理。我有个工具需要在首次调用时加载一个本地模型,后续调用复用,就是用这种方式在__init__里加载,在_run里使用。
但坑也很明显。第一个坑是Pydantic 字段和类属性的冲突。BaseTool本身是 Pydantic 模型,你定义的name、description、args_schema都是 Pydantic 字段。如果你在__init__里想给这些字段赋值,必须用super().__init__(name=..., description=...)的方式,不能直接self.name = ...,否则会报字段未定义的错误。我踩这个坑的时候,错误信息很不直观,排查了好一会儿。
第二个坑是**_run和_arun的签名必须匹配args_schema**。如果你在args_schema里定义了query和top_k,但_run的签名里少了top_k,LangChain 在调用时会报参数不匹配的错误。而且这个错误是在运行时才暴露的,不是定义时。我的做法是,定义完args_schema后,立刻对照着写_run和_arun的签名,确保一一对应。
第三个坑是状态管理的线程安全问题。如果你在工具里维护了可变状态(比如一个计数器或者缓存字典),在多线程或异步并发调用时可能会出问题。BaseTool本身不提供任何并发保护,你需要自己加锁。我有个工具用了一个简单的字典做缓存,在并发测试时出现了数据不一致,后来加了threading.Lock才解决。
实操心得:
BaseTool子类化适合需要维护状态、需要复杂初始化、或者需要精细控制执行流程的工具。如果只是简单的无状态函数,用StructuredTool或@tool就够了,没必要上这个。
2.5 Toolkit 打包:工具一多就必须考虑的方式
第五种方式是Toolkit,它不是定义单个工具,而是把一组相关的工具打包在一起:
from langchain.agents.agent_toolkits.base import BaseToolkit from langchain.tools import BaseTool from typing import List class KnowledgeToolkit(BaseToolkit): tools: List[BaseTool] = [] def get_tools(self) -> List[BaseTool]: return self.tools # 使用 toolkit = KnowledgeToolkit(tools=[search_tool, summarize_tool, translate_tool]) agent_tools = toolkit.get_tools()Toolkit的核心价值在于批量管理和复用。当你有十几个工具,而且这些工具在多个 Agent 之间共享时,用 Toolkit 打包可以避免重复定义和遗漏。我那个知识库项目最后有十二个工具,分成搜索、摘要、翻译、格式化四类,每类一个 Toolkit,管理起来清晰很多。
但 Toolkit 的坑在于它本身不提供任何工具定义的能力,只是一个容器。你还是要先用前面四种方式定义好单个工具,再放进 Toolkit。而且BaseToolkit的get_tools方法必须返回List[BaseTool],如果你返回了其他类型,Agent 初始化时会报错。
另一个坑是工具名称冲突。如果你把两个 Toolkit 合并使用,而它们内部有同名工具,后加入的会覆盖先加入的,而且不会有警告。我的做法是,在 Toolkit 内部给每个工具名加一个前缀,比如search_、summary_、translate_,这样即使合并也不会冲突。
还有一个实际问题是Toolkit 的序列化。如果你需要把 Agent 配置保存下来再加载,Toolkit 里的工具必须支持序列化。StructuredTool和@tool装饰的函数工具序列化支持比较好,但BaseTool子类化的工具如果带了不可序列化的状态(比如数据库连接),序列化就会失败。我后来把这类工具的状态初始化改成了懒加载,序列化时只保存配置,加载后再初始化连接。
3. 五种方式的横向对比与选型逻辑
3.1 核心维度对比表
我把五种方式在几个关键维度上做了对比,这张表是我实际用下来后的总结:
| 维度 | Tool 类 | StructuredTool | @tool 装饰器 | BaseTool 子类 | Toolkit |
|---|---|---|---|---|---|
| 参数 schema 支持 | 无 | 完整 | 依赖 docstring | 完整 | 取决于内部工具 |
| 异步支持 | 不支持 | 支持 | 支持 | 支持 | 取决于内部工具 |
| 代码量 | 最少 | 中等 | 最少 | 最多 | 中等 |
| 灵活性 | 低 | 中 | 中 | 最高 | 低 |
| 状态管理 | 不支持 | 不支持 | 不支持 | 支持 | 取决于内部工具 |
| 适用场景 | 原型验证 | 大多数场景 | 简单工具 | 复杂工具 | 工具集管理 |
从这张表可以看出来,StructuredTool在大多数维度上都是均衡的,这也是我推荐它作为默认选择的原因。@tool装饰器在简单场景下更优雅,但 docstring 的隐式依赖是个隐患。BaseTool子类化是唯一支持状态管理的,但代码量最大。Tool类只适合快速验证,Toolkit是工具数量多了之后的组织方式,不是替代品。
3.2 选型决策树
我总结了一个简单的决策逻辑,你可以按这个顺序问自己:
第一个问题:工具需要维护状态吗?比如需要缓存、需要保持连接、需要记录调用次数。如果需要,直接上BaseTool子类化,其他方式都不支持状态管理。
第二个问题:工具需要异步吗?如果需要,排除Tool类,在StructuredTool、@tool、BaseTool里选。
第三个问题:参数超过一个吗?如果超过一个,排除Tool类,优先StructuredTool。
第四个问题:工具数量超过五个吗?如果超过,考虑用Toolkit组织,但单个工具的定义方式还是按前面的逻辑选。
第五个问题:只是快速验证想法吗?如果是,Tool类或@tool装饰器最快。
这个决策树不是绝对的,但能帮你快速缩小选择范围。我实际用下来,StructuredTool覆盖了大概七成的场景,BaseTool子类化覆盖了两成,剩下的一成是@tool和Tool类。
3.3 一个容易忽略的维度:错误处理
五种方式在错误处理上的表现差异很大,这一点官方文档基本没提,但实际项目里非常重要。
Tool类和@tool装饰器默认会把函数抛出的异常直接向上抛,Agent 收到异常后可能会中断整个执行链。StructuredTool可以通过handle_tool_error参数来定制错误处理:
search_tool = StructuredTool.from_function( func=search_knowledge, name="knowledge_search", description="搜索工具", args_schema=SearchInput, handle_tool_error="搜索失败,请稍后重试" )这样当函数抛异常时,Agent 收到的是这个字符串,而不是异常本身,执行链不会中断。BaseTool子类化则可以在_run里自己 try-except,完全控制错误处理逻辑。
我的经验是,对于生产环境的 Agent,错误处理必须显式配置。我一开始没配,结果一个工具的网络超时导致整个对话中断,用户体验很差。后来给所有工具都加了handle_tool_error,至少保证单个工具失败不会影响整体流程。
4. 实操中遇到的典型问题与排查记录
4.1 参数传递失败的三种典型表现
参数传递失败是我遇到最多的问题,具体有三种表现:
第一种是参数缺失。Agent 调用工具时只传了部分参数,导致函数报missing required argument。原因通常是参数 schema 里没标必填,或者描述不够明确。解决方法是检查Field的default设置,必填参数不设默认值,并在描述里强调“必须提供”。
第二种是参数类型错误。Agent 把数字传成了字符串,或者把列表传成了字符串。原因通常是 schema 定义不清晰,或者 LLM 对类型的理解有偏差。解决方法是在Field的description里明确类型,比如“返回结果数量,必须是整数”。
第三种是参数名不匹配。Agent 传的参数名和函数签名不一致,比如传了search_query但函数签名是query。原因通常是工具描述里用的词和参数名不一致。解决方法是确保描述里提到的参数名和 schema 里的字段名完全一致。
我整理了一个排查清单,遇到参数问题时按这个顺序检查:
- 检查
args_schema是否正确定义,字段名和函数签名是否一致 - 检查每个字段的
description是否清晰说明了类型和用途 - 检查必填字段是否没有默认值,可选字段是否有合理默认值
- 检查工具描述里是否混入了参数说明,导致 Agent 理解混乱
- 用
tool.args打印出实际的 schema,确认 Agent 看到的是什么
提示:
tool.args属性可以打印出工具的参数 schema,调试时非常有用。我经常用它来确认 Agent 实际看到的参数定义。
4.2 异步调用的三个隐蔽陷阱
异步工具的坑比同步工具多得多,我踩过三个比较隐蔽的:
第一个是事件循环嵌套。如果你在异步工具里调用了同步的阻塞函数,而这个函数内部又用了asyncio.run(),会直接报RuntimeError: This event loop is already running。解决方法是把阻塞函数放到run_in_executor里执行,或者改用异步版本的库。
第二个是回调管理器丢失。异步工具的_arun方法接收run_manager参数,如果你在异步实现里忘了传递它,工具调用的回调事件(比如on_tool_start、on_tool_end)就不会触发。这会导致日志和监控数据缺失,排查问题时很麻烦。我的做法是在_arun里显式传递run_manager,即使当前不用也要传。
第三个是超时控制缺失。异步工具如果没有超时控制,一个慢请求可能会挂起整个 Agent。我后来给所有异步工具都加了asyncio.wait_for超时:
import asyncio async def _arun(self, query: str, top_k: int = 5, run_manager=None) -> str: try: result = await asyncio.wait_for(self._async_search(query, top_k), timeout=10.0) return result except asyncio.TimeoutError: return "搜索超时,请稍后重试"这个超时时间需要根据实际业务调整,我设的是十秒,因为内部知识库的搜索一般不会超过这个时间。
4.3 工具描述写不好的连锁反应
工具描述(description)写不好,会引发一连串问题。我见过的最典型的情况是:描述太短,Agent 不知道什么时候该用这个工具;描述太长,Agent 抓不住重点;描述里有歧义,Agent 用错工具。
我的经验是,一个好的工具描述应该包含三个要素:做什么、什么时候用、输入是什么。比如:
description = """在内部知识库中搜索信息。当用户询问公司内部文档、流程、政策相关的问题时使用此工具。输入应该是一个自然语言搜索关键词,不要传入完整的句子。"""这个描述告诉 Agent:功能是搜索知识库,使用场景是内部文档相关问题,输入格式是关键词。我实测下来,这种三段式描述比简单的一句话描述,工具调用准确率能提升不少。
还有一个细节是避免在描述里写参数说明。参数说明应该放在args_schema的Field.description里,工具描述只讲功能和场景。我一开始把参数说明混在工具描述里,结果 Agent 经常把描述里的示例参数值当成实际参数传进来。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Agent 不调用工具 | 描述不清晰或工具名不直观 | 检查工具描述是否说明了使用场景 | 补充使用场景说明,工具名用动词开头 |
| 参数缺失 | 必填字段有默认值或描述不清 | 用tool.args打印 schema | 必填字段去掉默认值,描述里强调必填 |
| 参数类型错误 | schema 类型定义不明确 | 检查Field的 type 和 description | 在 description 里明确类型要求 |
| 异步工具报事件循环错误 | 同步阻塞函数嵌套异步 | 检查工具内部是否有asyncio.run() | 改用run_in_executor或异步库 |
| 工具调用无回调事件 | run_manager未传递 | 检查_arun签名和调用 | 显式传递run_manager |
| 工具名冲突 | 多个工具同名 | 打印所有工具名检查 | 加前缀区分,或用 Toolkit 管理 |
| 序列化失败 | 工具带不可序列化状态 | 检查工具属性 | 状态改懒加载,只序列化配置 |
这张表是我实际排查问题时整理的,基本上覆盖了八成以上的常见问题。遇到新问题时,我会先对照这张表排查,大部分情况都能快速定位。
5. 我最终采用的方案与一些个人体会
项目上线后,我最终的方案是:单个工具用StructuredTool定义,需要状态的用BaseTool子类化,所有工具用Toolkit按功能分类打包,异步工具统一加超时和错误处理。这个组合在我这个十二个工具的项目里运行了几个月,整体比较稳定。
如果让我给一个最简单的建议,那就是:别在工具定义上省事。我一开始为了快,全用Tool类,结果后面重构花了更多时间。工具定义是 Agent 的基础设施,基础设施偷懒,后面全是债。参数 schema 写详细一点,描述写清楚一点,错误处理加上,这些前期投入在后期调试时会加倍回报。
还有一个体会是,工具的数量不是越多越好。我一开始想把所有功能都做成工具,后来发现工具太多,Agent 的选择困难反而增加了,调用准确率下降。后来我合并了一些功能相近的工具,从十五个减到十二个,准确率反而提升了。工具设计应该以“Agent 能准确理解和使用”为标准,而不是以“功能覆盖全面”为标准。
最后分享一个小技巧:我习惯给每个工具写一个简单的测试用例,用tool.run()直接调用,验证参数解析和执行逻辑。这个习惯帮我提前发现了不少 schema 定义的问题,比在 Agent 里调试高效得多。工具定义完后,先单独测通,再挂到 Agent 上,能省很多排查时间。