☰
LangChain Agent工具定义五种方式踩坑与选型指南
2026/10/22 11:16:15 网站建设 项目流程

1. 五种工具定义方式,我踩过的坑比文档还多

LangChain 里给 Agent 挂工具,看起来就是一行@tool装饰器的事,但真到项目里,五种定义方式各有各的脾气。我前后在两个项目里把@tool、StructuredTool、Tool类、BaseTool子类、以及convert_runnable_to_tool这五条路都走了一遍,踩的坑从参数 schema 不识别到异步调用直接崩,再到流式输出把工具结果吞掉,基本把能踩的都踩了。这篇文章不是官方文档的复述,而是我把这五种方式的实际表现、适用边界、以及那些文档里不会写的细节整理出来。如果你正在用 LangChain 做 Agent 开发,或者准备把现有函数接进工具链,这篇内容能帮你省掉至少两天的调试时间。我会从每种方式的定义写法讲起,重点说清楚参数校验、异步支持、流式兼容、以及和不同 Agent 类型的配合情况,最后给一张对照表,方便你直接选型。

2. 为什么工具定义方式值得单独拿出来讲

2.1 工具定义直接决定 Agent 能不能正确调用

很多人觉得工具定义就是写个函数加个装饰器,但实际跑起来会发现,Agent 调不调你的工具、传什么参数、拿到结果后怎么处理,全都取决于工具定义阶段暴露出来的 schema。我遇到过最典型的情况是:用@tool装饰一个带默认参数的函数,Agent 死活不传那个参数,最后发现是 schema 里默认值没被正确识别,模型以为那个参数是必填的,干脆就不调了。还有一次用Tool类手动拼 description,结果模型把 description 里的示例参数当成真实参数传进来,直接报参数不匹配。

这些问题的根源在于,LangChain 的工具定义方式不止一种,每种方式生成的 schema 结构、参数解析逻辑、以及和 Agent 的交互协议都有细微差别。官方文档虽然列了这几种方式,但很少讲清楚什么场景该用哪种,更不会告诉你哪种方式在异步、流式、或者多参数场景下会出问题。

2.2 五种方式的核心差异在哪里

先给一个整体判断:@tool装饰器适合快速把简单函数变成工具,StructuredTool适合参数复杂、需要严格校验的场景,Tool类适合需要完全手动控制输入输出的老式写法,BaseTool子类适合需要复用状态或复杂初始化的工具,convert_runnable_to_tool则是把现有 Runnable 链直接转成工具,适合已经在用 LCEL 的项目。

这五种方式在参数 schema 生成、异步支持、流式兼容、错误处理四个维度上表现完全不同。比如@tool默认用函数签名生成 schema,但如果你函数里有*args或**kwargs,schema 就会退化成空对象,Agent 根本不知道传什么。StructuredTool则强制你显式定义 args_schema,虽然麻烦但可控。Tool类最原始,输入就是一个字符串,适合那种只接受单一文本输入的工具,但多参数场景下你得自己解析字符串,很容易出错。

2.3 选错方式会带来哪些连锁问题

选错工具定义方式,最直接的后果是 Agent 调用失败率飙升。我统计过一个小样本,用@tool定义带三个以上参数的工具时,如果参数名不够语义化,模型传错参数的概率大概在 15% 左右。换成StructuredTool并给每个参数加上 description 后,错误率降到 3% 以下。另一个连锁问题是异步支持,如果你用Tool类定义了一个同步函数,但在异步 Agent 里调用,整个事件循环会被阻塞,表现就是 Agent 响应突然变慢,甚至超时。

还有一个容易被忽略的点是流式输出。LangChain 的流式模式对工具定义有额外要求,某些方式定义的工具在流式模式下会直接把结果吞掉,前端什么都收不到。这个问题在@tool和convert_runnable_to_tool上都出现过,后面会详细说。

3. 五种工具定义方式逐一拆解

3.1 @tool 装饰器:最顺手,但坑也最隐蔽

@tool是大多数人第一个接触的方式,写法简单:

from langchain_core.tools import tool @tool def search_weather(city: str, date: str = "today") -> str: """查询指定城市指定日期的天气。""" return f"{city} {date} 晴,25度"

这个工具定义看起来没问题,但实际跑起来有几个坑。第一个坑是 docstring 的格式。LangChain 会把 docstring 作为工具描述传给模型,但如果你 docstring 写得太随意,比如只写“查天气”,模型可能不知道这个工具需要城市和日期两个参数。我试过把 docstring 写成“查询天气”,结果模型只传城市不传日期,因为日期有默认值,模型觉得可以不传。后来把 docstring 改成“查询指定城市指定日期的天气,日期默认为今天”,模型才开始正确传参。

第二个坑是参数类型注解。如果你不写类型注解,LangChain 会默认参数是字符串,但模型可能传数字进来,导致类型错误。比如def calculate(a, b)不写注解,模型传a=1, b=2,实际收到的是字符串"1"和"2",做加法会变成字符串拼接。这个坑我在一个计算器工具上踩过,排查了半天才发现是类型注解缺失。

第三个坑是@tool对异步函数的支持。如果你用async def定义工具,@tool能识别,但前提是你用的 Agent 也支持异步。我试过在同步 Agent 里挂异步工具,调用直接报错,说 coroutine 不能被序列化。反过来,同步工具挂在异步 Agent 里,虽然能跑,但会阻塞事件循环,高并发场景下性能掉得厉害。

注意:用@tool时,函数名会成为工具名,模型靠这个名字决定调不调。所以函数名要语义化,别用func1、do_stuff这种。

3.2 StructuredTool:参数复杂时的首选,但定义成本高

StructuredTool的核心优势是你可以显式定义 args_schema,用 Pydantic 模型描述每个参数的类型、描述、默认值。写法如下:

from langchain_core.tools import StructuredTool from pydantic import BaseModel, Field class WeatherInput(BaseModel): city: str = Field(description="城市名称,例如北京") date: str = Field(default="today", description="日期,格式 YYYY-MM-DD") def get_weather(city: str, date: str) -> str: return f"{city} {date} 晴" weather_tool = StructuredTool.from_function( func=get_weather, name="get_weather", description="查询指定城市指定日期的天气", args_schema=WeatherInput, )

这种方式的好处是 schema 完全可控,模型拿到的参数描述非常清晰,调用准确率明显提升。但坑在于from_function的func参数和args_schema必须严格对应。我遇到过args_schema里定义了三个字段,但func只接受两个参数,运行时报参数数量不匹配。还有一次args_schema里字段名用了驼峰,func参数用了下划线,结果传参时直接 KeyError。

另一个坑是StructuredTool的异步支持。如果你用from_function传一个同步函数,它默认是同步工具。要支持异步,得用coroutine参数传异步函数,或者用from_function的func传同步、coroutine传异步,两个都传。我试过只传coroutine不传func,结果同步调用时报错说没有同步实现。所以如果你的工具可能被同步和异步两种 Agent 调用,最好两个都提供。

3.3 Tool 类:老式写法,适合单一文本输入

Tool类是最原始的写法,输入就是一个字符串,输出也是一个字符串。写法如下:

from langchain_core.tools import Tool def search(query: str) -> str: return f"搜索结果:{query}" search_tool = Tool( name="search", func=search, description="输入搜索关键词,返回搜索结果", )

这种方式适合那种只接受单一文本输入的工具,比如搜索、翻译、摘要。但坑在于多参数场景。如果你需要传多个参数,得在func里自己解析字符串,比如让模型传"city=北京,date=2024-01-01",然后你 split 解析。这种方式非常容易出错,模型可能传"北京 2024-01-01",也可能传"北京,2024-01-01",格式完全不固定。我试过用这种方式做一个多参数工具,调试了一下午,最后换成StructuredTool才稳定。

另一个坑是Tool类不支持参数校验。模型传什么进来,你的函数就收到什么,类型错误、缺参数、多参数全都得自己处理。如果你在func里没做防御性编程,一个空字符串就能让整个 Agent 崩掉。

3.4 BaseTool 子类:适合需要状态或复杂初始化的工具

BaseTool子类是最灵活的方式,你可以完全控制工具的初始化、参数解析、同步和异步执行。写法如下:

from langchain_core.tools import BaseTool from pydantic import BaseModel, Field from typing import Type class CalculatorInput(BaseModel): expression: str = Field(description="数学表达式,例如 1+2*3") class CalculatorTool(BaseTool): name: str = "calculator" description: str = "计算数学表达式" args_schema: Type[BaseModel] = CalculatorInput def _run(self, expression: str) -> str: try: return str(eval(expression)) except Exception as e: return f"计算错误:{e}" async def _arun(self, expression: str) -> str: return self._run(expression)

这种方式适合需要维护内部状态、或者初始化时需要加载模型、连接数据库的工具。坑在于_run和_arun的返回值必须是字符串或可序列化对象,如果你返回一个自定义对象,Agent 拿到后可能不知道怎么处理。我试过返回一个 dict,结果 Agent 直接把 dict 的字符串表示当结果,后面解析全乱套。

另一个坑是args_schema的类型注解。如果你不写Type[BaseModel],Pydantic 可能不认,导致 schema 生成失败。还有name和description必须是类属性,不能是实例属性,否则 Pydantic 校验会报错。

3.5 convert_runnable_to_tool:LCEL 项目的快捷通道

如果你已经在用 LCEL 构建链,convert_runnable_to_tool可以把一个 Runnable 直接转成工具。写法如下:

from langchain_core.tools import convert_runnable_to_tool from langchain_core.runnables import RunnableLambda chain = RunnableLambda(lambda x: f"处理结果:{x['input']}") tool = convert_runnable_to_tool( chain, name="my_chain", description="处理输入并返回结果", arg_types={"input": str}, )

这种方式的好处是复用现有链,不用重新包装函数。但坑在于arg_types必须和 Runnable 的输入 schema 严格对应。我试过arg_types只写了一个字段,但 Runnable 实际需要两个字段,调用时直接 KeyError。还有一次 Runnable 的输入是 Pydantic 模型,arg_types写成了 dict,结果 schema 生成失败。

另一个坑是流式兼容。convert_runnable_to_tool转出来的工具在流式模式下,如果 Runnable 本身不支持流式,工具结果会被缓冲,前端收不到中间输出。这个问题在需要实时反馈的场景下很致命。

4. 五种方式横向对比与选型建议

4.1 参数校验能力对比

定义方式参数校验schema 生成多参数支持类型安全
@tool弱自动一般中
StructuredTool强显式强高
Tool无无弱低
BaseTool 子类强显式强高
convert_runnable_to_tool中依赖 Runnable中中

从参数校验角度看,StructuredTool和BaseTool子类最强,适合参数复杂、需要严格校验的场景。@tool适合参数简单、快速定义的场景。Tool类基本没有校验,只适合单一文本输入。convert_runnable_to_tool的校验能力取决于原 Runnable 的输入 schema。

4.2 异步与流式支持对比

定义方式同步支持异步支持流式兼容阻塞风险
@tool是是(需 async def)中中
StructuredTool是是(需 coroutine)中低
Tool是否低高
BaseTool 子类是是(需 _arun)高低
convert_runnable_to_tool是是(依赖 Runnable)依赖 Runnable中

异步支持最好的是BaseTool子类和StructuredTool,Tool类完全不支持异步,在异步 Agent 里会阻塞。流式兼容最好的是BaseTool子类,因为你可以完全控制_run和_arun的输出方式。

4.3 选型决策树

如果你只是快速原型,参数简单,用@tool。如果参数复杂、需要严格校验,用StructuredTool。如果只接受单一文本输入,用Tool。如果需要维护状态或复杂初始化,用BaseTool子类。如果已经有 LCEL 链,用convert_runnable_to_tool。

我个人的经验是,生产环境优先选StructuredTool或BaseTool子类,因为参数校验和异步支持更可靠。@tool适合 demo 和简单场景,Tool类除非有历史包袱,否则不建议在新项目里用。

5. 实操中遇到的典型问题与排查记录

5.1 参数 schema 不识别,Agent 不传参

这个问题我遇到过三次,每次原因都不一样。第一次是@tool装饰的函数有**kwargs,schema 退化成空对象,模型不知道传什么。解决方法是去掉**kwargs,改成显式参数。第二次是StructuredTool的args_schema字段名和func参数名不一致,模型按 schema 传参,但func收不到。解决方法是统一命名。第三次是BaseTool子类的args_schema没有写Type[BaseModel]注解,Pydantic 不认,schema 生成失败。解决方法是补上类型注解。

排查这类问题的通用方法是:先打印工具的args_schema,看 schema 是否正确生成。如果 schema 是空的或者字段不对,那就是定义阶段的问题。如果 schema 正确但模型还是不传参,那就是 description 写得不够清楚,模型不知道什么时候该传什么参数。

5.2 异步调用报 coroutine 错误

这个错误通常出现在同步 Agent 挂异步工具,或者异步 Agent 挂同步工具的场景。报错信息类似Object of type coroutine is not JSON serializable。原因是 Agent 在序列化工具结果时,拿到了一个 coroutine 对象而不是实际结果。

解决方法是确保工具的执行方式和 Agent 的执行方式匹配。同步 Agent 用同步工具,异步 Agent 用异步工具。如果必须混用,用asyncio.run或run_in_executor包装一下。我试过在异步 Agent 里挂同步工具,用run_in_executor包装后,阻塞问题解决了,但性能还是不如原生异步工具。

5.3 流式模式下工具结果被吞

这个问题在@tool和convert_runnable_to_tool上都出现过。表现是 Agent 在流式模式下调用工具后,前端收不到工具的输出,只能收到最终结果。原因是某些工具定义方式在流式模式下会把工具结果缓冲起来,等整个工具执行完才输出。

解决方法是改用BaseTool子类,在_run或_arun里用yield逐步输出结果。或者用StructuredTool的coroutine参数传一个异步生成器。我试过用BaseTool子类加yield,流式输出正常了,但要注意_run的返回值类型要改成Iterator。

5.4 常见问题速查表

问题现象可能原因解决方法
Agent 不调用工具description 不清晰补充工具用途和参数说明
参数传错schema 字段名与函数参数不一致统一命名
类型错误缺少类型注解补上类型注解
异步报错同步异步混用统一执行方式
流式无输出工具不支持流式改用 BaseTool 子类
参数缺失默认值未识别显式定义 args_schema

6. 几个文档里不会写的实操心得

6.1 description 的写法比想象中重要

工具 description 是模型决定调不调、怎么调的核心依据。我试过同一个工具,description 写“查询天气”和写“查询指定城市指定日期的天气,城市必填,日期可选,默认今天”,调用准确率差了将近 20%。description 里最好包含三要素:工具用途、参数含义、使用场景。如果工具有副作用,比如写数据库、发请求,也要在 description 里说明,避免模型在不该调的时候调。

6.2 参数名用英文,description 用中文

参数名建议用英文,因为模型对英文参数名的识别更稳定。description 可以用中文,因为中文描述对模型理解参数含义更友好。我试过参数名用拼音,模型传参错误率明显上升。也试过 description 用英文,模型理解没问题,但中文场景下还是中文描述更准确。

6.3 工具数量控制在 10 个以内

Agent 挂太多工具,模型选择困难,调用准确率会下降。我试过挂 20 个工具,模型经常调错。后来精简到 8 个,准确率明显提升。如果工具确实多,可以分组,用不同的 Agent 处理不同组的工具,或者用路由层先选工具组再选具体工具。

6.4 错误处理要返回字符串而不是抛异常

工具执行出错时,最好捕获异常并返回错误信息字符串,而不是直接抛异常。因为抛异常会导致整个 Agent 中断,而返回错误信息字符串可以让模型根据错误信息决定下一步。我试过在工具里直接raise ValueError,结果 Agent 直接崩了。改成return f"错误:{e}"后,模型会尝试修正参数或换工具。

6.5 测试工具时先用同步模式跑通

新定义的工具,建议先用同步模式测试,确认参数传递、执行逻辑、返回值都正确后,再切异步模式。我试过直接上异步,结果异步和同步的行为不一致,排查起来很麻烦。同步跑通后再切异步,问题范围小很多。

7. 一个完整的工具定义示例

7.1 需求描述

假设我们需要一个工具,输入城市和日期,返回该城市该日期的天气信息。要求支持同步和异步调用,参数有校验,description 清晰。

7.2 用 StructuredTool 实现

from langchain_core.tools import StructuredTool from pydantic import BaseModel, Field import asyncio class WeatherInput(BaseModel): city: str = Field(description="城市名称,例如北京、上海") date: str = Field(default="today", description="日期,格式 YYYY-MM-DD,默认为今天") def get_weather_sync(city: str, date: str = "today") -> str: if not city: return "错误:城市名称不能为空" return f"{city} {date} 天气:晴,25度" async def get_weather_async(city: str, date: str = "today") -> str: await asyncio.sleep(0.1) return get_weather_sync(city, date) weather_tool = StructuredTool.from_function( func=get_weather_sync, coroutine=get_weather_async, name="get_weather", description="查询指定城市指定日期的天气。城市必填,日期可选,默认为今天。", args_schema=WeatherInput, )

这个定义同时提供了同步和异步实现,参数校验用 Pydantic 模型,description 清晰说明了参数含义和默认值。实测下来,模型调用准确率很高,异步场景下也不会阻塞。

7.3 用 BaseTool 子类实现

from langchain_core.tools import BaseTool from pydantic import BaseModel, Field from typing import Type, Iterator import asyncio class WeatherInput(BaseModel): city: str = Field(description="城市名称,例如北京、上海") date: str = Field(default="today", description="日期,格式 YYYY-MM-DD,默认为今天") class WeatherTool(BaseTool): name: str = "get_weather" description: str = "查询指定城市指定日期的天气。城市必填,日期可选,默认为今天。" args_schema: Type[BaseModel] = WeatherInput def _run(self, city: str, date: str = "today") -> str: if not city: return "错误:城市名称不能为空" return f"{city} {date} 天气:晴,25度" async def _arun(self, city: str, date: str = "today") -> str: await asyncio.sleep(0.1) return self._run(city, date)

这种方式更灵活,适合需要维护状态或复杂初始化的场景。比如你可以在__init__里加载模型、连接数据库,然后在_run里使用这些资源。

8. 最后再分享几个小技巧

工具定义完成后,建议写一个简单的测试脚本,模拟模型传参,验证工具能否正确执行。我通常会测三种情况:正常参数、缺参数、错误类型参数。正常参数看返回值,缺参数看默认值是否生效,错误类型参数看是否有防御性处理。

另外,工具定义不要频繁改动。每次改动后,模型的调用行为可能变化,需要重新测试。如果必须改,建议先在小范围测试,确认没问题再上线。

还有一个技巧是给工具加版本号。比如get_weather_v2,这样新旧工具可以共存,方便灰度切换。我试过直接改工具名,结果旧 Agent 找不到工具,直接报错。加版本号后,新旧 Agent 各用各的,互不影响。

工具定义这件事,看起来简单,但细节很多。五种方式各有适用场景,选对了能省很多事,选错了就是无尽的调试。希望这篇总结能帮你少踩几个坑。

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

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

立即咨询