LangChain之Tools:自定义工具与API集成实战
从一次诡异的API调用失败说起
上周帮团队排查一个LangChain Agent的bug,现象很诡异:Agent明明调用了自定义的天气查询工具,返回了数据,但后续推理完全跑偏。翻日志发现工具返回的JSON被LangChain当成了普通字符串处理,模型压根没解析出温度字段。折腾半天才意识到——工具的输出格式定义和实际返回不匹配,LangChain的OutputParser没触发。
这种坑我踩过不止一次。今天这篇笔记,就把LangChain Tools的底层逻辑和实战要点掰开揉碎,重点讲清楚自定义工具怎么写、API怎么集成、以及那些文档里不会写的边界情况。
Tools在LangChain中的真实角色
很多人把Tools简单理解为“函数包装器”,这没错但太浅了。LangChain的Tool本质上是一个协议适配层——它把任意Python函数、API调用、甚至外部命令,包装成LLM能理解的“能力单元”。每个Tool包含三个核心要素:
- 名称与描述:LLM选择工具的依据,描述写不好模型根本不会调用
- 输入模式:定义参数结构,LangChain用Pydantic做校验
- 执行逻辑:真正的业务代码,返回结果给LLM继续推理
关键点在于:Tool的输出不是直接返回给用户,而是作为“观察”喂回给Agent的推理循环。这意味着输出格式必须能被LLM理解,而不是被下游代码消费。
自定义工具:从最简实现开始
先看一个最基础的例子,别被网上那些花哨的装饰器搞晕:
fromlangchain.toolsimportTooldefget_current_time(format:str="%Y-%m-%d %H:%M:%S")->str:"""返回当前时间,支持自定义格式"""fromdatetimeimportdatetimereturndatetime.now().strftime(format)time_tool=Tool(name="current_time",func=get_current_time,description="获取当前时间,参数format是时间格式字符串,默认'%Y-%m-%d %H:%M:%S'")这里有个容易翻车的地方:description必须包含参数说明。LLM看到这个描述才知道format参数怎么传。我见过有人只写“获取当前时间”,结果模型每次都传空参数,工具永远返回默认格式。
进阶:用@tool装饰器处理复杂参数
当工具需要多个参数或复杂类型时,用装饰器更清晰:
fromlangchain.toolsimporttoolfrompydanticimportBaseModel,FieldclassWeatherInput(BaseModel):city:str=Field(description="城市名称,中文如'北京'")date:str=Field(description="日期,格式YYYY-MM-DD,默认当天")@tool(args_schema=WeatherInput)defget_weather(city:str,date:str=None)->dict:"""查询指定城市某天的天气情况"""# 这里踩过坑:如果date为None,需要默认当天ifnotdate:fromdatetimeimportdatetime date=datetime.now().strftime("%Y-%m-%d")# 模拟API调用,实际项目替换为真实请求# 别这样写:直接返回字符串,LLM解析会出问题return{"city":city,"date":date,"temperature":"25°C","condition":"晴"}注意返回值是dict而不是字符串。LangChain会自动把dict转成JSON字符串传给LLM,但如果你返回的是复杂嵌套结构,建议手动json.dumps确保格式正确。我遇到过Pydantic模型序列化失败导致Agent卡死的案例。
API集成实战:封装REST接口
真实项目中工具往往要调用外部API。这里以聚合数据的天气API为例,展示完整封装:
importrequestsimportjsonfromlangchain.toolsimporttoolfrompydanticimportBaseModel,FieldclassStockPriceInput(BaseModel):code:str=Field(description="股票代码,如'600519'代表贵州茅台")@tool(args_schema=StockPriceInput)defget_stock_price(code:str)->str:""" 查询A股实时股价 注意:API有调用频率限制,别在循环里调 """url=f"https://api.example.com/stock/{code}"headers={"Authorization":"Bearer YOUR_API_KEY"}try:# 这里踩过坑:requests默认不设置超时,生产环境必须加resp=requests.get(url,headers=headers,timeout=5)resp.raise_for_status()data=resp.json()# 别这样写:直接返回原始JSON,字段名可能误导LLM# 应该提取关键信息并格式化price=data.get("current_price","N/A")change=data.get("change_percent","N/A")returnf"股票{code}当前价格{price}元,涨跌幅{change}%"exceptrequests.exceptions.RequestExceptionase:returnf"查询失败:{str(e)}"这里有个设计原则:工具返回的信息要“自解释”。LLM不是程序员,它看不懂原始JSON里的字段含义。把数据组装成自然语言句子,模型后续推理会顺畅很多。
动态工具注册:根据上下文创建工具
有些场景下工具需要根据用户输入动态生成。比如用户问“帮我查北京和上海的天气”,你需要创建两个不同参数的天气工具实例:
fromtypingimportListfromlangchain.toolsimportBaseToolclassDynamicWeatherTool(BaseTool):name="weather_query"description="查询指定城市天气"def__init__(self,cities:List[str]):super().__init__()self.cities=citiesdef_run(self,city:str)->str:ifcitynotinself.cities:returnf"只能查询以下城市:{', '.join(self.cities)}"# 实际查询逻辑returnf"{city}天气:晴,25°C"# 使用示例tools=[DynamicWeatherTool(["北京","上海","广州"])]这种模式在需要限制工具作用域时特别有用。比如多租户系统里,每个用户只能操作自己的数据,动态创建绑定了用户ID的工具实例。
工具链与错误处理
工具执行过程中可能抛出各种异常,LangChain默认会捕获并返回错误信息给LLM。但有些错误需要特殊处理:
fromlangchain.toolsimporttoolimporttime@tooldefrate_limited_api(query:str)->str:"""带限流的API查询工具"""# 这里踩过坑:直接调用API被限流,Agent会反复重试# 应该实现退避策略max_retries=3forattemptinrange(max_retries):try:# 模拟API调用ifattempt<2:# 前两次模拟失败raiseConnectionError("服务暂时不可用")returnf"查询结果:{query}"exceptExceptionase:ifattempt==max_retries-1:returnf"查询失败,已重试{max_retries}次:{str(e)}"time.sleep(2**attempt)# 指数退避注意:工具返回错误信息时,LLM可能会尝试修改参数重新调用。如果你不希望LLM重试,可以在错误信息里明确说“请勿重试”。
调试技巧:让工具行为可观测
工具出问题时最难排查的是“LLM到底传了什么参数”。我常用的调试手段:
fromlangchain.toolsimporttoolimportlogging logging.basicConfig(level=logging.DEBUG)@tooldefdebug_tool(param1:str,param2:int=0)->str:"""调试用工具,记录所有调用参数"""# 这里踩过坑:logging默认不输出到控制台,需要配置logging.debug(f"debug_tool被调用,参数:param1={param1}, param2={param2}")# 也可以写入文件,方便事后分析withopen("/tmp/tool_calls.log","a")asf:f.write(f"{param1}|{param2}\n")returnf"收到参数:{param1},{param2}"更高级的做法是给工具加一个“回放”模式:记录每次调用的输入输出,方便复现问题。
性能优化:缓存与并发
高频调用的工具应该加缓存,避免重复请求:
fromfunctoolsimportlru_cachefromlangchain.toolsimporttool@tool@lru_cache(maxsize=128)defcached_weather(city:str)->str:"""带缓存的天气查询,相同城市5分钟内不重复请求"""# 实际API调用returnf"{city}天气:晴"# 注意:lru_cache默认不清除,生产环境建议用ttl_cache对于需要并发调用的场景,可以用asyncio:
importasynciofromlangchain.toolsimporttool@toolasyncdefasync_search(query:str)->str:"""异步搜索工具,适合同时查询多个关键词"""awaitasyncio.sleep(1)# 模拟IO等待returnf"搜索结果:{query}"但要注意:LangChain的Agent默认是同步执行的,使用异步工具需要配合AsyncAgent。
个人经验总结
写工具这件事,技术实现只占30%,剩下70%是让LLM能正确理解和使用。几个血泪教训:
描述比代码重要。花时间打磨工具的描述文本,用LLM能理解的自然语言说明参数含义、返回值格式、边界情况。我见过太多工具因为描述含糊导致Agent反复调用失败。
输出格式要“扁平化”。LLM处理嵌套JSON的能力很差,尽量把复杂结构拍平成字符串。如果必须返回结构化数据,用Markdown表格或列表。
错误信息要“可消费”。不要返回技术栈追踪,LLM看不懂。返回“查询失败:城市名称不存在,请检查拼写”这种人类能理解的信息。
工具数量不是越多越好。Agent在多个工具间选择时,描述越相似越容易选错。如果工具超过10个,考虑用工具分组或路由策略。
测试要覆盖“LLM会怎么用”。写单元测试时,不仅要测工具本身逻辑,还要模拟LLM可能传的各种奇葩参数——空字符串、特殊字符、超长文本。
最后说个反直觉的发现:有时候工具“故意失败”反而能提升Agent表现。比如让工具在参数不合理时返回明确错误,LLM会学会自我修正。这比让工具默默返回空结果要好得多。