为什么单独聊工具调用很多人学 LangChain第一个完整例子往往是 Agent给几个 Tool接上模型一句agent.invoke()就出结果。跑通很快但一旦工具报错、模型不调用工具、或者需要自己控制中间步骤就会卡住。原因在于 Agent 把工具调用的完整链路包起来了看不到里面发生了什么。工具调用本身是一条清晰的链路模型判断要不要调用工具、生成结构化参数、你的代码执行工具、把结果塞回对话、模型再生成最终回答。这条链路理解了Agent 就只是外面包了一层循环。我下面用一个具体场景把这条链路走一遍用户问“北京现在多少度换算成华氏度是多少”。这需要两个工具一个是查天气一个是温度换算。为什么用这个例子因为它天然需要两次工具调用而且第二次依赖第一次的结果能顺带看清多轮工具调用的处理方式。需要先说明的是LangChain 更新比较快包结构经历过拆分。本文代码基于langchain-core、langchain、langchain-openai这几个包在 2024 年下半年之后的常见结构模型用的是 OpenAI 兼容接口。不同小版本之间 API 可能有细微差异你按自己安装的版本对照即可不确定的地方我会标出来。工具调用的基本链路先不写代码把链路说清楚。你把工具的定义名字、描述、参数 schema和用户消息一起发给模型。模型返回的不是普通文本而是一个或多个tool_calls每个包含工具名和 JSON 参数。你的代码根据工具名找到对应函数用参数执行拿到结果。把结果作为一条ToolMessage追加到对话历史。再次调用模型模型基于工具结果生成自然语言回答。关键点在于第 2 步和第 3 步之间模型只负责“决定调什么、传什么参数”真正执行的是你的代码。模型不会、也没能力执行任何函数。这一点决定了安全边界和错误处理都落在你这边。定义 ToolLangChain 里定义工具最直接的方式是tool装饰器加类型注解和 docstring。# 依赖langchain-corefromlangchain_core.toolsimporttooltooldefget_weather(city:str)-str:查询指定城市的当前天气情况。当用户询问某地天气、温度时使用此工具。 Args: city: 城市名称例如 北京、上海。 # 真实项目里这里应调用天气 API这里用固定数据演示fake_db{北京:晴气温 25 摄氏度,上海:多云气温 28 摄氏度,}returnfake_db.get(city,f未找到{city}的天气数据)tooldefcelsius_to_fahrenheit(celsius:float)-float:把摄氏度转换为华氏度。当需要温度单位换算时使用此工具。 Args: celsius: 摄氏度数值。 returncelsius*9/532这里有几个细节值得说。docstring 不是装饰它会被转成工具描述发给模型模型靠这段文字判断什么时候调用这个工具。写得含糊模型就容易漏调或错调。我一般会写清楚“什么时候用”而不只是“这是什么”。参数名和类型注解决定了生成给模型的 JSON Schema。city: str会变成{type: string}。如果参数是可选或复杂结构用Optional、Literal、Pydantic 模型都可以LangChain 会帮你转换但复杂类型建议先跑一次看生成的 schema 对不对。工具函数最好保持“纯函数”风格输入明确输出明确不依赖外部可变状态。因为模型可能并行调用多个工具也可能重试有副作用的工具要特别小心。绑定模型并拿到 tool_calls定义好工具后用bind_tools把工具挂到模型上。# 依赖langchain-openaifromlangchain_openaiimportChatOpenAI llmChatOpenAI(modelgpt-4o-mini,temperature0)tools[get_weather,celsius_to_fahrenheit]llm_with_toolsllm.bind_tools(tools)bind_tools做的事是把工具的 schema 转成模型 API 认识的tools参数格式附在每次请求上。注意它是返回一个新的 runnable不是原地修改llm。接下来发一条消息看模型返回什么。fromlangchain_core.messagesimportHumanMessage messages[HumanMessage(content北京现在多少度换算成华氏度是多少)]ai_msgllm_with_tools.invoke(messages)print(ai_msg.tool_calls)如果模型决定调用工具ai_msg.content通常是空的ai_msg.tool_calls会是一个列表结构大致是这样[{name:get_weather,args:{city:北京},id:call_abc123,type:tool_call,}]id很重要。回填结果时ToolMessage必须带上对应的tool_call_id模型靠它把结果和请求对应起来。尤其在并行调用多个工具时少了这个 id 或者 id 对不上模型就分不清哪条结果属于哪个调用。【踩坑提醒】.content为空不代表出错。工具调用场景下模型把信息放在tool_calls字段里content为空是正常的。反过来如果tool_calls为空而content有内容说明模型选择直接回答这时候就不用执行工具了。手动执行工具并回填结果先看不借助 Agent 的手动链路这样每一步都在自己手里。fromlangchain_core.messagesimportToolMessage# 保存模型这轮的输出里面带着 tool_callsmessages.append(ai_msg)# 建立工具名到工具对象的映射tool_map{t.name:tfortintools}fortool_callinai_msg.tool_calls:selectedtool_map[tool_call[name]]# invoke 传入参数 dictlangchain 会按 schema 校验resultselected.invoke(tool_call[args])messages.append(ToolMessage(contentstr(result),tool_call_idtool_call[id]))# 带着工具结果再问一次模型final_msgllm_with_tools.invoke(messages)print(final_msg.content)这套流程跑通你就理解了 Agent 内部在做什么。create_tool_calling_agent之类的高层封装本质就是把这个 for 循环和再次调用包进一个循环里直到模型不再请求工具、直接给出文本为止。有个细节selected.invoke(tool_call[args])里LangChain 会用工具的参数 schema 校验args。如果模型给错了参数名或类型这一步可能抛异常而不是静默返回错误内容。这其实是好事能尽早暴露问题。但生产环境里我一般会用try/except包起来把异常转成一条说明性的ToolMessage回给模型让模型有机会自己修正参数重试而不是直接让整个请求崩掉。fortool_callinai_msg.tool_calls:selectedtool_map.get(tool_call[name])ifselectedisNone:messages.append(ToolMessage(contentf未找到名为{tool_call[name]}的工具,tool_call_idtool_call[id],))continuetry:resultselected.invoke(tool_call[args])contentstr(result)exceptExceptionase:contentf工具执行失败{e}messages.append(ToolMessage(contentcontent,tool_call_idtool_call[id]))把工具错误当成一条普通结果回给模型是我比较推荐的做法。模型看到“参数错误”这类信息往往能自己调整再调一次。换成 Agent 写法理解了手动链路再看 Agent 封装会轻松很多。下面用的是create_tool_calling_agent配AgentExecutor的常见组合。# 依赖langchain提供 agent 与 executorfromlangchain.agentsimportAgentExecutor,create_tool_calling_agentfromlangchain_core.promptsimportChatPromptTemplate promptChatPromptTemplate.from_messages([(system,你是一个助手可以调用工具回答问题。),(human,{input}),(placeholder,{agent_scratchpad}),])agentcreate_tool_calling_agent(llm,tools,prompt)executorAgentExecutor(agentagent,toolstools,verboseTrue)resultexecutor.invoke({input:北京现在多少度换算成华氏度是多少})print(result[output])agent_scratchpad这个占位符是关键它会被替换成中间的工具调用和结果记录。手动链路里我们往messages里 append 的那些内容在 Agent 里就是通过这个占位符注入的。verboseTrue在调试阶段很有用它会打印出每一步模型决定调什么工具、参数是什么、工具返回什么。我第一次接一个工具时基本都开着它确认模型确实按预期在调。【注意】create_tool_calling_agent依赖模型本身支持工具调用function calling。不是所有模型都支持比如一些本地小模型或者旧接口。如果模型不支持这套写法会失败。这种情况要么换成支持工具调用的模型要么用基于 ReAct 文本解析的老式 Agent但那种方式靠解析模型输出的文本稳定性差一些。两种写法怎么选方案优点缺点适用场景手动链路每步可控便于调试、审计、自定义重试代码多需要自己写循环和终止条件需要精细控制、做可观测性、排查问题create_tool_calling_agent AgentExecutor代码少开箱即用中间过程被封装定制需要看源码快速验证、标准场景、原型我的实际习惯是原型阶段先用 Agent 快速验证模型和工具描述是否匹配要上线或者要接监控就改回手动链路或者基于 Agent 的中间步骤回调做记录。两者不冲突手动链路本身就是理解封装的工具。几个容易出问题的地方工具描述写不好模型不调用。这是最常见的问题。如果模型该调工具却直接编了个答案先检查 docstring 是否清楚说明了使用场景。描述里最好出现“当用户……时使用”这类触发条件。参数类型对不上。模型生成的参数类型偶尔和 schema 不完全一致比如该传数字传了字符串。Pydantic 校验能拦住大部分但复杂嵌套结构建议单独测。这一点我没有对所有模型都验证过具体表现跟模型有关。并行调用的顺序。当模型一次返回多个tool_calls它们是并行执行的别假设有先后顺序。如果工具之间有依赖比如本例里换算依赖查天气的结果模型通常会分两轮调用而不是一轮里同时调。如果发现模型硬要并行调有依赖的工具多半是工具描述没把依赖关系说清楚。消息历史要保留完整。手动链路里ai_msg带 tool_calls 的那条必须 append 进 messages否则回填ToolMessage时模型会报找不到对应调用。这个错误我见过几次都是漏了 append。写在最后工具调用这条链路不复杂核心就是“模型出参数、代码执行、结果回填、再问模型”四步。Agent 把它包起来是为了少写循环但封装越厚出问题时越难定位。我的建议是至少手动走通一遍之后用不用 Agent 都心里有底。下一步可以研究的方向是工具的错误恢复和重试策略以及怎么在工具调用链路上加日志和耗时统计这些在真实项目里比“能不能调通”更影响可用性。
阅读完成 · 觉得有帮助?