做LangChain开发也有些年头了,工具调用(Tool Calling)这套玩法我前前后后折腾过不少次。说句实话,它跟我早期用各种Chain堆提示词、让模型输出JSON再拿代码去解析的那套做法,完全是两码事。后者看起来能跑,但需求稍微一改,提示词和解析逻辑全得跟着动;工具调用则是让模型自己决定"该去调哪个函数、传入什么参数",我再在代码里真正执行它,把结果喂回去——这才是Agent该有的样子。
这篇是AI原生开发实战系列第3.2节,我把它当成一次完整实操复盘来写:从为什么需要工具调用,到工具定义、模型绑定、Agent组装,再到跑通天气查询、计算器这类典型场景,最后把我踩过的坑一次性倒出来。不管你是刚接触LangChain,还是已经写过不少链式调用但对Agent还不够通透,这篇都值得读完。
1. 为什么要把工具交给大模型:工具调用的价值与运行机制
1.1 一个没有工具调用的大模型有多"笨"
想象一下,你让大模型帮忙订机票。没有工具调用的情况下,它只能给你编一个看似合理的航班信息,或者建议你去某个平台自己查。它既没有实时的航班数据,也不能替你完成任何实际操作。传统方案是让模型输出一个结构化JSON,我们再写一堆解析代码去提取字段、调用真实API。这种方案最大的问题在于:模型的输出格式稍微漂移一下,解析就崩了;需求一改,提示词和解析层都得跟着返工。
工具调用(Function Calling / Tool Calling)改变了这个局面。模型不再需要"假装自己会做事",它可以明确"声明"自己想调用哪个工具、传什么参数,然后由外围代码真正去执行,再把结果回传给它做进一步推理。这本质上是一次分工:模型负责理解和规划,代码负责执行和确认。
1.2 工具调用的本质:让模型输出"调用意图"而不是执行代码
很多刚上手的人有个误解,以为工具调用是模型直接运行我们的Python函数。这是错的。模型本身只是文本生成模型,它不会也不应该直接访问你的系统。它做的事情是在生成内容里附带上tool_calls这个特殊字段,结构化地写出"我要调用multiply,参数是a=123、b=456"。真正执行函数的,是你自己的代码或者LangChain的Agent执行器。
完整的调用链路可以分为四步:
- 用户输入进入模型,模型的上下文里被塞入了所有注册工具的JSON Schema描述。
- 模型判断当前任务是否需要工具。如果不需要,它直接输出普通文本作为最终回答。
- 如果需要,模型生成包含
tool_calls的输出,里面标明工具名和参数。 - 我们的代码或者AgentExecutor拿到
tool_calls后,执行对应工具函数,把返回值作为一条工具消息(ToolMessage)回传给模型,模型再根据这个结果决定是继续调用其他工具还是直接给出答案。
这里最关键的一个认知是:模型是"提议者",我们才是"执行者和审批者"。因为工具调用不等于模型能够直接操作系统,所有真实世界的副作用(发消息、下单、改数据库)都由外部代码执行,所以我们可以在执行前做权限校验、成本控制、参数校验。这一点对生产环境尤其重要,也是工具调用比"让模型直接写代码再执行"安全得多的原因。
2. 环境准备与工具定义:从零搭起第一个可调用工具
2.1 依赖安装与模型配置
搞AI原生开发,环境这步绕不开。先用pip把基础依赖装上:
pip install langchain langchain-openailangchain-openai这个包是LangChain针对OpenAI协议接口的适配器。国内很多模型服务都兼容OpenAI协议,所以这个包基本能通用。我这里以常见的OpenAI模型接口为例,实际项目换成你买的模型服务也成立:
import os os.environ["OPENAI_API_KEY"] = "你的密钥" from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="gpt-4o-mini", temperature=0 )temperature我建议直接设成0。原因很简单:工具调用要求模型尽量稳定、确定地输出结构化调用意图,温度高了容易让输出发散,甚至出现模型"灵感迸发"而不调用工具的情况。等你跑通了流程,再根据业务需要慢慢调。
2.2 用 @tool 装饰器定义工具
LangChain里最简单也最常用的方式是@tool装饰器。它可以直接把一个普通函数变成可被模型调用的工具:
from langchain_core.tools import tool @tool def multiply(a: int, b: int) -> int: """计算两个整数的乘积。""" return a * b @tool def add(a: int, b: int) -> int: """计算两个整数的和。""" return a + b就这么简单。但这里有几个细节值得展开说说:
- 函数名就是工具名。模型看到的工具名就是这个函数名,所以命名要语义清晰,别用
f1、foo这类无意义的名字。 - docstring就是工具描述。模型根据这段文字来判断"什么时候该用这个工具",描述写得越清楚,模型选错的概率越低。比如
add的描述如果只写"加法",模型可能拿不准它到底能不能处理浮点数。写"计算两个整数的和"就好很多。 - 参数类型注解最终会变成JSON Schema的一部分。LangChain会自动从
int、float、str这些注解生成参数结构描述。所以注解别偷懒写Any,能精确就精确。 - 返回值类型建议也标注,虽然不是强制,但后面做调试和日志追踪时能少掉很多坑。
我把这个工具列表打印出来看看它的schema长什么样:
print(multiply.args_schema.schema())大概就是这种结构:
{ "properties": { "a": {"type": "integer"}, "b": {"type": "integer"} }, "required": ["a", "b"], "title": "multiplyArgsSchema", "type": "object" }模型看到的就是这样一份"工具说明书"。说明书越准确,模型越不会瞎调用。
2.3 更复杂的工具:StructuredTool 与 BaseTool
实际项目的工具往往比"两个整数相加"复杂得多。比如参数可能有缺省值、需要枚举、需要额外校验。这时候我会用StructuredTool配合Pydantic模型来定义:
from pydantic import BaseModel, Field from langchain_core.tools import StructuredTool class WeatherInput(BaseModel): city: str = Field(description="城市名,例如北京、上海") date: str = Field(default="今天", description="日期,格式为YYYY-MM-DD,缺省为今天") def _get_weather_by_city(city: str, date: str = "今天") -> str: # 这里替换成真实天气API或数据库查询 return f"{city}在{date}的天气:晴,气温25℃左右" weather_tool = StructuredTool.from_function( func=_get_weather_by_city, name="get_weather", description="查询指定城市在指定日期的天气情况,当用户询问天气时使用", args_schema=WeatherInput )用StructuredTool的好处是,你的函数可以保持普通函数的样子,参数的校验、描述、缺省值全交给Pydantic管理。团队里如果已经有一套现成的函数库,包一层StructuredTool就能直接用,不需要改造内部实现。
至于BaseTool,那是在需要把工具做成一个类、携带状态或生命周期管理的场景才会用到,比如工具内部要维护数据库连接池。平时的业务开发,有@tool和StructuredTool基本够了。
三种定义方式的适用场景,我整理了一个表:
| 定义方式 | 适用场景 | 特点 |
|---|---|---|
@tool | 简单函数、参数少、临时验证 | 最快、最简洁,schema自动生成 |
StructuredTool | 参数复杂、需要校验、复用现有函数 | 显式定义schema,可注入依赖 |
BaseTool | 工具类化、有状态管理、框架级封装 | 最灵活,但代码量也最大 |
3. 模型绑定与Agent组装:让大模型学会"拿起工具"
3.1 bind_tools:工具如何被"塞进"模型
工具定义好之后,下一步是把它绑定到模型上。LangChain的做法很直接:
tools = [multiply, add] llm_with_tools = llm.bind_tools(tools)bind_tools做的事情,其实就是把你刚才看到的JSON Schema列表传给模型的接口。以前手写Function Calling的时候,这个列表是要自己组装的,一不小心字段名写错模型就报错。LangChain把这步自动化了,它读取工具对象的schema,自动拼成模型要求的格式。
绑定完之后,你可以直接拿llm_with_tools做一次原始调用试试:
resp = llm_with_tools.invoke("请计算123乘以456") print(resp.tool_calls)输出会是类似这样的结构:
[{'name': 'multiply', 'args': {'a': 123, 'b': 456}, 'id': 'call_abc123'}]看到这个输出,就说明模型已经做出了调用决策,只是还没有人去真正执行。这一步跑通,整个工具调用机制的底子就打好了。
3.2 组装一个完整的Agent执行器
光有bind_tools还不够,模型只是口头说要调用,实际没人去执行。工具真正跑起来,需要一个围绕模型的执行循环。LangChain里的做法是create_tool_calling_agent配合AgentExecutor:
from langchain.agents import AgentExecutor, create_tool_calling_agent from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个乐于助人的助手,可以调用工具来帮助用户完成计算和查询。"), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_tool_calling_agent(llm, tools, prompt) agent_executor = AgentExecutor( agent=agent, tools=tools, verbose=True, )然后就可以调用它了:
result = agent_executor.invoke({"input": "23乘以17等于多少?然后加10"}) print(result["output"])这里有个特别容易踩的坑:ChatPromptTemplate里的占位符一个都不能少。chat_history用来承载多轮对话历史,input是用户输入,agent_scratchpad是Agent存放中间思考和工具执行结果的地方。尤其是agent_scratchpad,很多人第一次组装时会漏掉,然后报错或者模型根本不知道"刚才到底发生了什么"。
AgentExecutor做的事情就是一个循环:把用户输入发给模型 → 模型返回文本或tool_calls→ 如果是tool_calls,就执行工具函数 → 把结果作为工具消息回传给模型 → 模型再次判断 → 直到模型输出最终回答。打开verbose=True之后,这个过程会在控制台逐行打印出来,是调试时最好的助手。
4. 实测:用天气查询Agent跑通完整调用链路
4.1 有限数据源下的工具设计
纸上谈兵没意思,我直接用一个"天气查询+计算器"组合来做实测。因为演示没办法接真实天气API,我在工具函数内部用字典模拟了天气数据,但整个调用链路的逻辑和真实项目是完全一致的。你之后接真实服务,只需要把函数内部的模拟逻辑替换成HTTP调用或数据库查询就行。
先把模拟天气工具定义出来:
@tool def get_weather(city: str, date: str = "今天") -> str: """查询指定城市在指定日期的天气情况,city是城市名,date是日期,格式YYYY-MM-DD,缺省为今天。""" mock_weather = { "北京": "晴,气温24-32℃", "上海": "多云转阴,气温22-29℃", "广州": "雷阵雨,气温23-28℃", } return mock_weather.get(city, f"暂未收录{city}的天气数据")加上计算器工具:
@tool def calculate(expression: str) -> str: """计算一个数学表达式的值,expression是表达式字符串,例如'(12+8)*3'。""" import ast try: tree = ast.parse(expression, mode="eval") result = eval(compile(tree, "<string>", "eval"), {"__builtins__": {}}, {}) return str(result) except Exception as e: return f"表达式错误: {e}"这里我用了ast解析表达式,而不是直接eval,稍微安全一点。真实生产环境建议不要直接处理用户输入的表达式,而是把数值提取出来再用专门的数学库计算,更稳妥。
4.2 三种典型调用场景实测
场景一:单工具调用
agent_executor.invoke({"input": "北京今天天气怎么样?"})模型应该会调用get_weather(city="北京"),然后把返回的天气文本整理成自然语言回答。这种场景是基础,能跑通说明整条链路是通的。
场景二:多工具并行调用
agent_executor.invoke({"input": "帮我看看北京和上海的天气,再算一下(12+8)×3"})这里我比较关注的是模型能不能"同时"发起多个工具调用。实测中,当调用之间没有依赖关系时,模型经常会在一次输出里带上多个tool_calls,AgentExecutor会并行执行这些工具,然后把所有结果一次性回传。这种并行能力和传统"一步一问"的链式调用模型有本质区别,也是工具调用体验好的重要原因。
场景三:连续调用
agent_executor.invoke({"input": "先计算(12+8)×3的结果,再把它和5相乘"})连续调用要求模型分多步思考:先调用calculate得到中间结果,再把结果代入下一个calculate调用。这类场景更考验模型的推理能力,不同模型表现差异很大。如果遇到模型不会分步调用的情况,可以在系统提示词里明确写"遇到需要多步计算的问题时,先逐步调用工具,不要试图一次性得出答案"。
4.3 观察模型"思考"过程
把verbose=True打开后,控制台会打印整个执行过程,类似这样:
> Entering new AgentExecutor chain... Invoking: `get_weather` with `{'city': '北京', 'date': '今天'}` 北京在今天的天气:晴,气温24-32℃ > Finished chain.这部分信息很值钱。它能明确告诉你:模型到底调了哪个工具、传了什么参数、工具返回了什么、模型最后怎么加工结果。我在排查线上问题时,第一步永远是打开verbose日志看调用记录,比猜模型"为什么不干活"高效得多。
5. 工具调用中的常见坑与排查经验
5.1 模型不支持工具调用怎么办
如果用了一个不支持原生function calling能力的模型,运行时会报错或者模型根本不输出tool_calls。判断模型支不支持,最直接的方法就是看报错信息,好些模型服务会在接口层直接返回"function calling not supported"之类的提示。
那怎么办?我的经验是:
- 尽量选支持原生工具调用的模型,这是体验最好的路线。
- 如果一定用不支持的模型,就别硬套
create_tool_calling_agent,可以改成create_react_agent,它走的是"让模型输出Thought/Action文本,再靠解析器提取动作"的模式。效果会打折,但至少能跑。 - 或者自己写一个轻量解析层:让模型输出约定好的JSON,再用代码解析并执行工具。注意这已经退化到老路子了,能不选就不选。
5.2 工具定义与参数Schema问题
工具调用最让人头疼的问题,不是模型不调用,而是模型调了但参数传得乱七八糟。我遇到过几次典型的:
一是参数类型没写清楚。比如某个字段在代码里是浮点数,但函数注解写的是int,模型就可能把12.5传成12或者直接在参数里塞一个字符串,导致工具执行报错。这种情况下在Field(description=...)里写明取值范围和单位,比单纯改类型更有效。
二是工具描述有歧义。我有一回定义了两个工具,一个查订单状态,一个查询物流轨迹,描述里都写了"查询、订单、状态"这些词,结果模型一会儿调这个一会儿调那个。后来我把描述改成"专门查询订单进入售后流程后的处理进度,不包含普通物流轨迹",立刻准确多了。
三是工具数量太多导致模型"挑花眼"。我实测下来,工具超过10个之后,误调用概率明显上升。解决思路有两个:一是合并同类工具,把高度相关的功能收拢成一个工具;二是精心设计每个工具的一个"总描述",把边界条件写清楚。
可以按这个表快速自查:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 模型一直不调用工具 | temperature太高、提示词没写清 | 调低temperature、系统提示词中说明何时调用 |
| 调了工具但参数错误 | 参数注解不精确、缺少description | 精确注解、在Field里写格式示例 |
| 误调用相似工具 | 工具描述歧义 | 重写描述,明确边界 |
| 工具调用后报错 | 工具内部异常没兜底 | 让工具捕获异常并返回错误字符串 |
5.3 调用行为的控制与优化
最后分享几个跟Agent干活质量直接相关的习惯:
第一,工具内部错误要"软处理"而不是"硬崩溃"。工具函数在执行过程中抛异常的话,可以直接把异常信息作为错误字符串返回,这样模型收到工具消息后还能自行判断是换个参数重试还是放弃操作。如果在工具内部直接抛出异常,Agent的流程很容易卡死。
第二,一定要设置max_iterations。Agent的循环在设计上是带终止条件的,但模型有可能陷入"不断调用工具-看到结果-再调用工具"的循环,白白消耗token。给AgentExecutor加一个max_iterations=4之类的限制有助于控制成本,配合early_stopping_method="force",超了次数就直接返回当前结果。
第三,工具返回的内容要"给模型省脑力"。如果某个工具返回一个超大的字典或长文本,模型读起来很吃力,也容易丢失重点。我习惯在工具内部先做一次摘要,只把模型最需要的关键信息拼成一段简洁字符串返回。比如查天气,就直接返回"北京今天晴,24到32度",而不是返回一堆JSON字段。
第四,跑完功能路径后,一定手动测"模型不该调用工具"的情况。比如只跟Agent打声招呼,确认它不会乱发出一堆空的工具调用;再给一个模棱两可的输入,确认它不会误选工具。边界case比正常路径更值得花时间,因为线上翻车的往往就是这些边缘场景。
工具调用这套机制上手之后,你会发现自己对"大模型应用"的理解会发生一个不小的转变:模型不再是那个只能聊天的玩具,而是一个真正能接进工作流、替你执行任务的角色。下一步你可以试着把文件读写、数据库查询、HTTP请求这些真实业务工具接进来,做一个能帮你干活的小助理。那个过程里还会有新的坑,但工具调用的底子打扎实了,后面基本都是重复劳动。