ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain Tool Calling实战:从手动解析到LangGraph Agent落地

LangChain Tool Calling实战:从手动解析到LangGraph Agent落地

第一次在LangChain里跑通工具调用(Tool Calling)时,我的第一反应是:这不就是让模型学会"填一张调用单"吗?等真正把它接到业务里我才意识到,Tool Calling的本质是把"模型说了什么"和"系统做了什么"之间的模糊地带,彻底变成了一道结构化协议。在它出现之前,想让大模型查天气、查库存、算运费,典型做法是让模型在回答里夹带暗号,比如"请查询城市=上海",然后开发者在外面用正则或字符串匹配去猜。模型稍微换个说法,你的解析就跪了。更麻烦的是,模型根本不知道你有哪些工具可用,全凭它在训练数据里"蒙"。Tool Calling改变的是这个底层的交互方式:模型不再靠猜,而是在系统明确给出的工具清单里,输出一个格式固定、参数清晰的调用意向,然后由你自己的代码真正去执行。

这篇文章不打算推荐什么"终极Agent框架",而是要把我自己的完整路径拆开——从手动解析JSON、绑定工具、读取调用意图,到用LangGraph里的ToolNode跑起一个真正能落地的Agent,包括那些文档里不会写、线上最容易翻车的坑。适合正在做Agent、想给模型挂工具的开发者,也适合刚看完LangChain入门、想知道Tool Calling到底怎么落地的人。

1. Tool Calling的本质:模型只负责"填调用单",执行全靠你的代码

1.1 没有Tool Calling时,我们是怎么被逼疯的

先回忆一下没有Tool Calling的日子。你想让模型帮你查某个城市的天气,常规操作是把它当作一个文本生成任务:系统提示词里写"如果用户问天气,请输出'WEATHER:城市名'",然后你在外面用正则去抓。模型心情好的时候输出得很标准,心情不好的时候会来一句"请稍等,我正在查询上海天气",你的正则直接落空。再往后有人开始让模型输出JSON,可模型经常把JSON塞进Markdown代码块,或者把布尔值true写成True,解析时又是一地鸡毛。

这种做法的本质问题是:模型输出的是"给人看的话",而你想让系统执行的是"动作"。这两者之间没有稳定的协议。你越是想靠提示词约束,模型就越容易在边界情况里放飞。工具调用(Tool Calling)这个机制,就是来补上这个协议空白的——它让模型不再直接输出自然语言去描述"我想干什么",而是输出一个结构化的调用意图,例如调用哪个函数、传什么参数,格式由模型API本身保证,和文本内容分开传递。

1.2 工具调用的协议长什么样

不管底层实现怎么包装,Tool Calling的核心数据结构万变不离其宗,就是下面这种东西:

{ "name": "get_weather", "arguments": "{\"city\": \"上海\", \"date\": \"2025-06-01\"}" }

注意arguments通常是一个字符串,里面嵌套JSON,需要二次解析。有些框架已经把这一步封装好了,但理解这一点很重要,因为当你手写日志、排查参数抽取错误时,经常会看到原始API层返回的是这种嵌套字符串。

这个协议的意思是:模型通过分析用户请求,认为此时应该调用一个名为get_weather的工具,并传入参数。模型并不执行这个函数,它只是把"调用单"填好交给你。真正去查天气、查库存、请求数据库的是你自己的代码。等工具执行完,你再把执行结果作为一条消息返回给模型,模型看到结果后继续生成下一轮回答或再次发起调用。

1.3 模型如何知道"有哪些工具可用"

模型能填出正确的调用单,前提是它知道有哪些工具存在、每个工具是干什么的、参数长什么样。这在实现上就是把工具的元信息转换成一个JSON Schema列表,随请求一起发给模型。LangChain的做法是:你用装饰器或StructuredTool定义好工具,再通过bind_tools挂到模型上。模型侧收到的是类似这样的信息:

{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市指定日期的天气情况", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名"}, "date": {"type": "string", "description": "日期,格式YYYY-MM-DD"} }, "required": ["city"] } } }

也就是说,绑工具这件事本质上是在给模型写一份"工具菜单",菜单写得越清楚,模型点菜点得越准。后面聊到描述优化时,我会专门讲这个菜单怎么设计才不容易翻车。

2. 工具定义:从普通函数到"懂规矩"的工具对象

2.1 @tool装饰器:一行代码把函数变成工具

最简单的方式是直接用装饰器。你不需要去继承某个基类,也不需要手动拼JSON Schema,LangChain会根据函数的签名和docstring自动生成元信息:

from langchain_core.tools import tool @tool def get_weather(city: str, date: str = None) -> str: """查询指定城市在指定日期的天气情况。 参数说明: - city: 城市中文名,如"上海"、"杭州" - date: 可选,日期格式YYYY-MM-DD,不传则返回当天与未来3天 """ return f"模拟天气数据:{city},{date or '今天'},多云 25°C"

这里有三个细节值得注意。第一,函数的参数名要尽量直观,因为参数名会直接出现在给模型的Schema里,city比c好一百倍。第二,docstring一定要写清楚参数含义和边界情况,在工具调用里,docstring比普通函数里的注释重要得多,因为模型是用它来理解"什么时候该用这个工具"的。第三,返回类型建议统一用str,工具执行结果是要塞进消息上下文给模型读的,返回Python对象字符化之后往往又长又乱,不如在工具内部就整理成一行可读的文本。

2.2 用args_schema管住参数:别让模型乱传参

依赖函数签名没问题,但函数签名能表达的约束有限。比如你想限制date必须是合法的日期格式,或者city必须是枚举值之一,函数签名做不到,Pydantic模型可以。

from typing import Optional from langchain_core.tools import StructuredTool from pydantic import BaseModel, Field class WeatherParams(BaseModel): city: str = Field( description="城市中文名", examples=["北京", "上海", "广州"] ) date: Optional[str] = Field( default=None, description="日期,格式YYYY-MM-DD;不传返回当天及未来3天" ) def _get_weather(city: str, date: Optional[str] = None) -> str: ... get_weather = StructuredTool.from_function( func=_get_weather, name="get_weather", description="查询指定城市指定日期的天气情况", args_schema=WeatherParams, )

用StructuredTool.from_function的好处是可以精细控制工具名和描述,而且Pydantic字段里的description、examples会一并转成JSON Schema传给模型。实测下来,examples的作用非常大,模型在犹豫参数格式时,会优先模仿examples里的写法。换句话说,你给一个"上海"的例子,模型就不太可能把城市名传给date字段。

2.3 工具描述才是真正的"提示词"

很多初学者把重心放在函数实现上,却忽略了description。实际上在工具调用里,description就是给模型看的提示词,它决定了模型在什么场景下会选中这个工具。

对比一下两种写法:

写法实际效果
"天气工具"模型只知道有个天气工具,不知道参数格式,不知道返回值,容易和其他工具混淆
"查询指定城市指定日期的天气情况。入参city为城市中文名,date为可选参数,格式YYYY-MM-DD;不传date时返回今天及未来3天预报。适合用户询问天气、温度、降水、风力时使用。"模型清楚知道触发条件、参数格式、返回范围

第二个描述明显更"可决策"。模型每次调用工具前都会做一次匹配,描述越精确,误报率越低。注意不要在这里写太多花哨的话,工具描述会占用上下文,最长控制在200字左右,把触发场景、参数约束、边界条件写清楚就够了。

2.4 工具不是越多越好:合并与拆分策略

一个常见的误区是"工具越多,Agent能力越强"。实践下来恰恰相反,工具数量超过8个以后,模型的选择准确率会明显下降,尤其当存在多个描述相似的工具时。我的建议是遵循两条原则。

第一,同域操作合并成一个工具。比如查天气、查空气质量、查日出日落,完全可以合成一个weather_tool,内部用一个operation参数区分。模型只需要在调用单里填operation=weather/air/sun,执行分支由你代码去处理。这能显著降低模型在多个近似工具间的犹豫。

第二,工具语义边界要互相排他。如果两个工具都覆盖"查询"能力,请在其中一方的描述里明确写"本工具不适用于xxx场景,请使用另一个工具"。这听起来有点笨,但模型确实会读这种排他说明,而且很吃这一套。

3. bind_tools与模型返回解析:从"人类读"到"机器读"

3.1 bind_tools到底做了什么

理解了工具定义,接下来把工具挂到模型上。在LangChain里这一步通常长这样:

llm_with_tools = llm.bind_tools([get_weather])

每次看到这种封装得很干净的API,我都会习惯性问一句:它在背后做了什么?答案是:把工具列表转换成了模型API能识别的tools参数,然后在一次请求中把用户消息和工具Schema一起发给模型。它不改变模型的推理方式,也不包含"执行工具"的逻辑,只是建立了一个"模型能看到哪些工具"的契约。bind_tools返回的是一个新的、持有工具清单的模型对象,原模型仍然是干净的,这在高并发场景里很重要:你不必反复重建模型,而是可以预先绑定一份工具列表复用。

3.2 读取模型的调用意图:AIMessage.tool_calls

模型响应在LangChain里被封装成AIMessage,其中tool_calls属性就是模型填好的"调用单":

resp = llm_with_tools.invoke("上海明天天气怎么样?") if resp.tool_calls: print(resp.tool_calls[0]["name"]) # get_weather print(resp.tool_calls[0]["args"]) # {'city': '上海', 'date': '2025-06-02'} print(resp.tool_calls[0]["id"]) # call_xxx,平台生成的唯一ID

每个tool_call里都有三个核心字段:工具名、参数、ID。其中ID极其关键。执行完工具之后,你要把执行结果包装成ToolMessage,并把tool_call_id设置成这个ID,模型才能把"执行结果"和"之前发起的调用"对应起来。如果ID对不上或者丢失,轻则报错,重则模型会认为结果不是它想要的,又发起一次重复调用。

3.3 完整的手动工具执行循环

把上面的逻辑串起来,一个最朴素但完整的工具调用循环是这样的:

import json from langchain_core.messages import ToolMessage # 工具注册表 tools_by_name = {tool.name: tool for tool in [get_weather]} # 第一轮:模型决定调用什么 resp = llm_with_tools.invoke("上海明天天气怎么样?") if resp.tool_calls: tool_messages = [] for call in resp.tool_calls: tool = tools_by_name.get(call["name"]) if not tool: content = f"工具 {call['name']} 不存在" else: try: content = str(tool.invoke(call["args"])) except Exception as e: content = f"工具执行失败: {e}" tool_messages.append(ToolMessage( content=content, tool_call_id=call["id"], )) # 第二轮:把工具结果回填给模型 final_resp = llm_with_tools.invoke([resp, *tool_messages]) print(final_resp.content) else: print(resp.content)

这个循环看起来简单,但它包含了所有工具调用必须遵守的协议:先取调用单,再执行,再回填。你后续无论用多复杂的框架,底层都是这个循环。执行工具时注意要捕获异常并把错误信息返回给模型,不要因为工具抛异常就中断整个流程——有时候模型正是看了错误信息才能纠正参数继续往下走。

3.4 强制模型调用某个工具:tool_choice的玩法

默认情况下,模型根据自己的判断决定调用哪个工具,还是直接回答,对应参数tool_choice="auto"。但有些场景你需要强制模型先调用某个工具,比如做数据接口路由时,第一步必须调用router_tool判断请求去哪个下游系统。

在LangChain里可以这样绑定:

llm_with_forced_tool = llm.bind_tools( [router_tool, get_weather], tool_choice="router_tool" )

强制调用在流程编排里特别好用:先让模型做一个明确的分类调用,拿到结果后再决定是否调用其他工具。注意不同模型API对tool_choice的支持程度不一样,有的支持"required"表示必须调用任意工具,有的支持"any"表示只能调用工具不能直接回答,对接前先确认底层模型支持哪种语义,别绑定完才发现不生效。

4. 从手动循环到LangGraph:工具调用在Agent流程里站稳脚跟

4.1 为什么手动循环不够用

上面的手动循环能跑通最简单的单轮工具调用,但实际业务中的Agent往往是多步的:先确认城市名,再查天气,然后根据天气推荐穿搭,中间还可能调用另一个工具查限行。如果你全用手动循环写,每增加一步你都得自己维护消息列表、判断要不要继续调用工具、控制最大轮数,代码很快会膨胀成一坨条件分支。这个时候就需要一个能显式管理状态的编排框架,我选择的是LangGraph,它把"调用工具"和"判断下一步"拆成图里的节点和边,逻辑一眼就能看清。

4.2 ToolNode:把"遍历工具+执行+回填ToolMessage"打包

LangGraph的prebuilt里有个非常实用的组件叫ToolNode。它做了我在3.3节手写的那件事,并且处理得更完整:

from langgraph.prebuilt import ToolNode tool_node = ToolNode([get_weather])

ToolNode的输入是一个消息列表,它会自动扫描列表中所有AIMessage的tool_calls,逐个执行对应工具,再把结果封装成ToolMessage返回。你不用再手动拿call["id"]去配对,它全包了。如果你的工具是异步的,ToolNode也支持异步执行,不过要注意线程安全问题,后面我会专门提。

4.3 条件边:让Agent自己决定"继续还是回答"

光有ToolNode还不够,Agent需要决定什么时候调用工具、什么时候结束。在LangGraph里这个决策靠条件边实现。核心逻辑非常简单:看当前消息里是否还有tool_calls,有就继续走工具节点,没有就说明模型可以直接回答,走结束节点。

def should_continue(state): last_message = state["messages"][-1] return "tools" if last_message.tool_calls else "end"

这个机制的最大好处是无限环节点有了明确的退出条件:只要模型不再发起新的工具调用,流程就自然收敛到最终答案。你还可以给Agent加上recursion_limit之类的上限,防止极端情况下的死循环。

4.4 更省事的做法:create_react_agent

如果不想自己拼状态图,LangGraph的prebuilt直接提供了一个开箱即用的ReAct Agent:

from langgraph.prebuilt import create_react_agent agent = create_react_agent( model=llm_with_tools, tools=[get_weather], prompt="你是智能助手,可以使用工具解决问题。" ) result = agent.invoke({"messages": [("user", "上海明天天气怎么样?")]})

create_react_agent内部就是上面说的"模型节点+ToolNode+条件边"的组合。它会自动维护消息列表,工具执行结果回填给模型后,模型会继续推理直到完成回答。对大多数常规场景,直接用它就足够了;当你需要更细粒度控制时,再回到手写状态图。

4.5 并行工具调用与流式输出的注意事项

现代的大模型API普遍支持一次返回多个tool_calls,也就是模型同时在"调用单"上填了多行。LangChain对并行调用的支持很自然:ToolNode会依次执行每个调用,并把多个ToolMessage合并返回。但这里有几个坑。

一个是并行执行工具时如果工具内部共享了全局状态,比如同一个临时文件、同一个数据库连接,容易出竞争问题,建议工具内部做成无状态或者自带连接管理。另一个是流式输出场景:模型API在流式返回时,tool_calls的分段数据可能被拆成多个chunk先后到达,你需要把chunk按index和id拼起来,等所有partial_tool_calls都收齐后再去执行工具。如果用LangGraph的astream,注意在tools节点阶段不要过早终结事件流,否则会丢结果。

5. 工具调用"事故高发区":我踩过的坑与排查思路

5.1 报错一:tool_call_id不匹配

这个是最常见的低级错误,但排起来很恶心。典型报错是"Tool call not found"或直接API层返回400。原因通常有三种:回填ToolMessage时忘了传tool_call_id;执行工具时擅自生成新的ID而不是原样使用模型返回的ID;以及在手动拼接消息时不小心丢掉了原始AIMessage。

排查方法很朴素:把进入工具执行前和回填后的消息列表完整打印出来,人工核对ID是否一致。我建议在开发阶段给每个工具调用环节打一条结构日志,把工具名、调用的ID、参数、返回结果前200字全部记录下来。这看着笨,但能省掉大量线上排查时间。

5.2 工具抛错后Agent原地打转

另一个高频问题是:工具执行抛了异常,Agent会反复调用同一个工具,在同一个错误上不断打转,直到撞上最大迭代次数。根因往往是错误信息没有正确回传给模型,或者回传的信息太模糊,模型看不懂该怎么纠正。

我的做法是在工具内部捕获所有异常,并把它转换成一句"对模型友好"的错误说明,比如:

except Exception as e: return f"参数校验失败:date应为YYYY-MM-DD格式,当前收到值:{call['args'].get('date')}。请修正后重试。"

这样模型看到的是"哪里错了、你该改什么",而不是一串看不懂的堆栈。同时我会在Agent上设置不高于10的迭代上限,并让超限后的消息明确告诉用户"连续失败,建议更换问题描述"。

5.3 描述冲突导致模型选错工具

假设你同时挂了get_weather和get_climate两个工具,描述都写"查询天气",模型大概率会选错。这不是模型笨,而是你给的信息本身没有区分度。解决思路有三个:合并同类工具,一个工具用operation区分模式;在描述里明确排他边界,比如写"只查短期天气,中长期气候预测请用另一个工具";给参数加examples,让模型从参数猜测更省力。

5.4 参数类型和枚举值对不上

模型生成的参数有时会突破你的预期:把int传成字符串、把枚举传成不在列表里的值、把必选参数传成None。Pydantic的args_schema能解决一部分强转问题,但真正稳妥的做法是工具内部做二次校验,非法参数直接返回纠正提示,而不是抛出异常让流程中断。

5.5 工具返回内容过大撑爆上下文

工具返回一整张报表或几万条JSON,模型读不完,上下文先爆了。解决思路是"摘要化+引用化":在工具内部就对结果做裁剪,只返回前20条或聚合统计,同时把完整数据写入缓存返回一个引用ID。比如"共查询到283条记录,已返回前20条,完整数据集ID为cache://xxx"。模型需要更多数据时,它会去调用get_cache工具继续取。这既能控制token,又不会丢失信息。

5.6 工具命名的隐藏规则

给工具起名不是纯看心情,很多模型API对工具名有约束:一般只允许字母、数字、下划线和短横线,不能有空格、中文或特殊符号。建议一律用小写字母加下划线,比如get_weather_detail,不要用getWeatherDetail或"获取天气"这样的命名,一个是不兼容,另一个是模型对驼峰分词的识别不如小写+下划线稳定。

6. 进阶组合:结构化输出、强制路由与多轮兜底

6.1 with_structured_output与Tool Calling的分工

LangChain里还有一个容易和Tool Calling混淆的功能叫with_structured_output,它让模型输出一个严格符合Pydantic结构的对象。两者的区别在于:structured output要的是"结果长成什么样",工具调用要的是"执行什么动作"。实际项目里它们经常搭配使用,比如先用structured output抽取出用户查询中的城市和日期,再拿抽取结果去构造工具调用参数。这种"先结构化抽取、再精准调用"的组合模式,比直接把原始用户问题丢给Agent要稳定得多,尤其是面对不规范的输入时。

6.2 串行链、分支路由与并行扇出

工具调用不只是一次调用,它可以是链式的。串行链最简单:工具A的结果作为输入去找工具B,比如先查城市坐标,再用坐标查天气。分支路由通常配合3.4节的tool_choice强制手段,第一步调用router_tool决定走哪个分支,避免后续工具选择漂移。并行扇出则适合需要多源信息综合的场景,比如同时查天气、查路况、查限行,三个工具并行执行后汇总结果,LangGraph对工具节点的一组调用会自然并行处理,注意给工具加上合理的超时时间,避免单个工具拖垮整个流程。

6.3 异常自修复:让Agent把报错变成下一步决策

我强烈建议你在Agent里设计一层"自修复"机制。当工具返回错误信息后,模型可以把错误当作决策依据,修正参数后重新发起调用。这个机制不需要额外代码,只要你在工具内部把错误描述写得足够可操作。实测下来,这个自修复对模型是有效的,绝大多数的参数错误它都能自己改对,通常一轮到两轮内就会成功。如果连续两轮还是同一个错,不要硬撑,捕获异常走兜底逻辑。兜底可以是切换一个更强的模型重新跑一轮,也可以是直接返回人工处理入口。原则是:错误要让模型看得懂,兜底要让流程停得住。

6.4 落地的几点个人建议

最后说几个实际项目的经验结论。工具数量控制在8个以内,单个工具描述控制在200字左右,工具返回内容尽量精简成10行以内的文本,超长内容用摘要加引用替代。每次模型返回的tool_calls都要落日志,包括工具名、参数、执行耗时和结果摘要,这样线上出问题十分钟内就能定位到是哪一步调错了。给关键工具加上幂等键,防止网络重试时产生重复的系统改动。改动工具描述后一定要做一轮回归测试,用固定的问题集跑一遍,看工具选择率和最终答案是否符合预期。

我个人习惯是把"工具清单"当成一份会持续维护的接口文档来对待。每新增一个工具,我都会先用几个固定问题测一轮,把模型历史返回的tool_calls和工具执行结果原样存下来。这个习惯看着笨,但排查问题时特别管用——它能让你快速看到模型在什么输入下选了哪个工具、为什么选错。工具调用这件事,说穿了就是一层把"模型意图"翻译成"系统动作"的中间层,中间层越透明,你的Agent就越可控。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进