Function Calling,让模型学会调用工具
上一节我们让模型回答了第一个问题。但是如果问它「杭州今天天气怎么样」或者「2 的 20 次方是多少」,它要么编一个答案,要么老老实实说自己查不了。模型本身只会生成文字,不能联网,也不能执行代码。Function Calling 就是用来解决这个问题的:让模型学会在需要的时候,向我们的程序「借」一个工具用。
本节的代码是一个新文件 tool_chat.py,上一节的 main.py 保持不变。
1. Function Calling 是什么
1.1 模型并不会自己执行工具
因为模型只能输出文字,可以类比为人类的大脑,而Function Calling则是作为工具,供大脑去使用调用。
以获取天气为例,模型调用工具的整个流程是这样的:
用户提问
→ 模型判断:需要调用 get_weather,参数 city="杭州"
→ 程序执行 get_weather("杭州"),得到天气数据
→ 程序把数据交回模型
→ 模型根据数据组织回答
1.2 Function Calling 与 Tool Calling 的关系
这两个词经常混用。Function Calling 是 OpenAI 最早给这个能力起的名字,因为当时模型只能调用「函数」。后来各家都支持了这个能力,而且能调用的不只是函数,还有搜索、代码解释器等,所以 LangChain 统一叫它 Tool Calling,函数只是工具的一种。
在 LangChain 里,你会看到的 API 名称都是 tool 系列:@tool、bind_tools、tool_calls、ToolMessage。本文后面也统一用「工具」这个说法。千问和 DeepSeek 都通过 OpenAI 兼容接口支持了这个能力,所以上一节的 ChatOpenAI 可以继续用。
2. 定义工具
2.1 用 @tool 定义第一个工具
我们先做一个计算器。用 @tool 装饰器把一个普通的 Python 函数变成 LangChain 的工具:
from langchain_core.tools import tool
@tool(parse_docstring=True)
def calculator(expression: str) -> str:
"""计算一个数学表达式并返回结果。
Args:
expression: Python 语法的数学表达式,例如 "3 * (4 + 5)" 或 "2 ** 10"。
"""
# 使用 python 内置 eval 函数实现计算效果
return str(eval(expression))
@tool 会从这个函数里提取四样东西,缺一个都会报错或者效果变差:
- 函数名 会成为工具名,模型靠这个名字来指定调用哪个工具。
- docstring 的第一段 会成为工具描述,模型靠它判断什么时候该用这个工具。不写 docstring 会直接抛异常。
Args:下面的每一行 会成为对应参数的描述,添加参数描述有助于提升模型传入参数的准确性。使用Args需要设定parse_docstring=True,否则整个 docstring 会被当成一段工具描述,参数就没有任何说明。- 参数类型注解 会被转换成参数类型(JSON Schema),模型按这个类型生成参数。
docstring 用的是 Google 风格:第一段写功能,空一行,Args: 下面一个参数一行,格式是 参数名: 说明。养成习惯把每个参数都写上,后面工具一多,模型填错参数的概率会明显下降。
2.2 名称、描述和参数结构如何影响模型选择
模型看不到我们的函数体,它能看到的只有三样东西:名称、描述、参数结构。可以直接打印出来看看:
print(calculator.name)
# calculator
print(calculator.description)
# 计算一个数学表达式并返回结果。
print(calculator.args)
# {'expression': {'description': 'Python 语法的数学表达式,例如 "3 * (4 + 5)" 或 "2 ** 10"。', 'title': 'Expression', 'type': 'string'}}
如果想看发给模型的最终形态,可以用 LangChain 自带的转换函数:
from langchain_core.utils.function_calling import convert_to_openai_tool
print(convert_to_openai_tool(calculator))
{
"type": "function",
"function": {
"name": "calculator",
"description": "计算一个数学表达式并返回结果。",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Python 语法的数学表达式,例如 \"3 * (4 + 5)\" 或 \"2 ** 10\"。"
}
},
"required": ["expression"]
}
}
}
这里可以发现,其实内部使用的是json的形式来实现的。模型就是根据这几行文字来决定「要不要调用」以及「怎么填参数」的,所以写工具的时候需要注意:
| 要素 | 建议 |
|---|---|
| 名称 | 用动词或名词短语说明功能,如 get_weather,不要用 tool1、func |
| 描述 | 说清楚「做什么」和「什么时候用」,必要时给参数示例,模型会照着示例的格式填 |
| 参数 | 每个参数都要有类型注解,不要用 a, b等名称来命名参数,且最好在 Args: 里写一句说明:是什么、什么格式、取值范围;可选参数给默认值 |
描述写得含糊,模型就会该调用的时候不调用,或者把参数填错格式。比如 expression 的说明里写明了「Python 语法」并给了 "2 ** 10" 这个例子,模型就会用 Python 的幂运算写法,而不是传一个 2^10 进来。
2.3 天气工具
第二个工具是查天气。
import random
@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""查询某个城市今天的天气。
Args:
city: 中文城市名,例如 "杭州"。
"""
# 这里采用模拟的方式随机返回结果
weather = random.choice(["晴", "多云", "阴", "小雨"])
temperature = random.randint(15, 30)
return f"{city}今天{weather},气温 {temperature} 摄氏度"
工具返回的是一段普通文字,模型会原样拿到这段文字,再用它来组织回答。
3. 绑定工具并读取调用请求
3.1 用 bind_tools 绑定工具
定义好的工具需要告诉模型。bind_tools 会把工具的名称、描述和参数结构一起塞进每一次请求里:
TOOLS = [calculator, get_weather]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
llm = ChatOpenAI(model=MODEL, api_key=API_KEY, base_url=BASE_URL)
llm_with_tools = llm.bind_tools(TOOLS)
bind_tools 不会修改原来的 llm,而是返回一个新对象,之后所有调用都用 llm_with_tools。TOOLS_BY_NAME 是一个「工具名 → 工具」的字典,后面根据模型给的名字找工具时用。
3.2 读取模型返回的工具调用请求
绑定之后,invoke 返回的还是 AIMessage,但多了一个 tool_calls 字段:
response = llm_with_tools.invoke([HumanMessage("杭州今天天气怎么样")])
print(response.content)
print(response.tool_calls)
[{'name': 'get_weather', 'args': {'city': '杭州'}, 'id': 'call_894a633aa31346abb03aef52', 'type': 'tool_call'}]
可以看到模型这次没有回答文字(content 为空),而是在 tool_calls 里给出了一个请求:调用 get_weather,参数 city="杭州"。每个请求都有一个 id,后面把结果交回去的时候要靠它对应。
到这一步模型的工作就结束了。它不知道杭州的天气,它只是「申请」了一次查询。
4. 执行工具并把结果交回模型
4.1 参数是怎么传进函数的
先看模型到底给了我们什么。接口层面,模型返回的是一段 JSON,其中 arguments 是一个字符串,里面再套一层 JSON:
{
"id": "call_894a633aa31346abb03aef52",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"杭州\"}"}
}
LangChain 帮我们把这段东西解析好,放进 tool_calls。到我们手上时,args 已经是一个普通的 Python 字典:
call = response.tool_calls[0]
print(call["args"]) # {'city': '杭州'}
print(type(call["args"])) # <class 'dict'>
字典的 key 就是函数的形参名。这也是 2.1 里说「参数类型注解会变成参数结构」的另一面:函数怎么声明参数,模型就按什么名字和类型来填,最后原样作为关键字参数传回函数。 把这个字典变成一次函数调用,有三种写法:
get_weather.func(**call["args"]) # 最直白:相当于 get_weather(city="杭州"),返回字符串
get_weather.invoke(call["args"]) # 先按类型注解校验参数,再调用,返回字符串
get_weather.invoke(call) # 传整个 tool_call:校验、调用,并包装成 ToolMessage
两点说明:
- 加了
@tool之后,get_weather已经不是普通函数而是一个工具对象,直接写get_weather("杭州")会报错。原来的函数挂在.func上,第一种写法就是在调用它。 invoke在进入函数之前会按类型注解做一次校验。比如模型把city填成了数字123,函数不会被执行,而是直接抛出ValidationError: Input should be a valid string。参数名对不上也一样。5.3 节要处理的异常,大部分就是从这里来的。
正文里用的是第三种写法,因为它顺手把下一步要用的 ToolMessage 也生成好了。
4.2 ToolMessage
工具执行的结果需要用第四种消息类型 ToolMessage 交回模型,它必须带上对应的 tool_call_id:
result = TOOLS_BY_NAME[call["name"]].invoke(call)
print(type(result).__name__, result.tool_call_id, result.content)
ToolMessage call_894a633aa31346abb03aef52 杭州今天阴,气温 22 摄氏度
TOOLS_BY_NAME[call["name"]] 根据模型给的名字找到对应的工具,invoke(call) 取出参数执行,再把返回的字符串和 call["id"] 一起包成 ToolMessage,不需要我们手动拼。
至此我们一共接触了五种消息,整段对话在消息列表里是这样的:
SystemMessage 你是一个高级的助手……
HumanMessage 杭州今天天气怎么样
AIMessage (content 为空,tool_calls 要求调用 get_weather)
ToolMessage 杭州今天多云,气温 24 摄氏度
AIMessage 杭州今天多云,气温 24 摄氏度,挺舒服的。
要点是:模型要求调用工具的那条 AIMessage 也必须追加进列表,然后紧跟着 ToolMessage,模型才能把请求和结果对上。
4.3 完整的调用流程
把 3、4 两步串起来,一次提问需要调用两次模型:第一次让模型决定要不要用工具,执行完工具后第二次让它组织回答。
# 第一次调用:模型决定直接回答,还是要求调用工具
response = llm_with_tools.invoke(messages)
if response.tool_calls:
messages.append(response)
for call in response.tool_calls:
print(f"[调用工具] {call['name']}({call['args']})")
result = TOOLS_BY_NAME[call["name"]].invoke(call)
print(f"[工具结果] {result.content}")
messages.append(result)
# 第二次调用:模型拿着工具结果组织最终回答
response = llm_with_tools.invoke(messages)
print(response.content)
注意第二次调用用的仍然是 llm_with_tools。理论上模型可以多次要求调用工具(比如拿到天气后再要求算点什么),这一节先不处理,只取它的 content。让模型「调用 → 观察结果 → 再决定」地循环下去,就是 Agent,留到后续章节专门讲。
5. 三种需要处理的情况
5.1 模型不调用工具
不是每个问题都需要工具。问「你是谁」,模型会直接回答,tool_calls 为空列表,if 不成立,程序直接打印第一次的回答。这也是为什么要先判断 if response.tool_calls,而不是假设模型一定会调用。
反过来,如果模型该调用却没调用,比如问「2 的 20 次方」它直接心算给了个错的数,通常是系统提示词或工具描述没写清楚。上面的 SystemMessage 里加了一句「需要计算或查天气时请使用工具,不要自己猜」,就是为了这个。
5.2 一次提问多次调用
问「杭州和北京今天哪个更暖和」,模型会在同一条 AIMessage 里给出两个 tool_calls,所以执行工具时要用 for 遍历而不是只取第一个。每个调用的结果都要作为单独的 ToolMessage 交回,顺序和 tool_calls 保持一致。
还有一种是链式调用:先查天气,再根据天气里的温度算点什么。第二次调用需要第一次的结果,单轮流程做不到,但是 Agent 会解决。
5.3 参数错误和工具不存在
模型填参数偶尔会出错,比如把 expression 写成 expr,或者编造一个不存在的工具名,或者表达式本身算不出来。这些情况下 invoke 会抛异常(参数问题会在 4.1 说的校验那一步就被拦下来)。我们不能让程序直接崩,也不能跳过不管(跳过会导致 tool_call_id 对不上,第二次请求直接报错)。正确的做法是把错误信息也包成 ToolMessage 交回去:
try:
result = TOOLS_BY_NAME[call["name"]].invoke(call)
except Exception as exc:
# 工具名不存在、参数错误、计算出错都会到这里,错误也要交回给模型
result = ToolMessage(content=f"工具执行失败:{exc}", tool_call_id=call["id"], status="error")
status="error" 会告诉模型这次调用失败了。在单轮流程里,模型看到错误后只能在最终回答里说明「计算没有成功」;要让它自动用正确的参数重试一次,同样需要后续的循环。
6. 完整代码
import random
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
# 填入第一节获取的sk开头的API KEY
API_KEY = "sk-xxxxx•••••••••••••••••••"
# 如果是DeepSeek,切换为 https://api.deepseek.com
BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# qwen可选 qwen-plus, qwen3.7-plus 等;deepseek可选 deepseek-chat
MODEL = "qwen3.7-plus"
@tool(parse_docstring=True)
def calculator(expression: str) -> str:
"""计算一个数学表达式并返回结果。
Args:
expression: Python 语法的数学表达式,例如 "3 * (4 + 5)" 或 "2 ** 10"。
"""
# 使用 python 内置 eval 函数实现计算效果
return str(eval(expression))
@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""查询某个城市今天的天气。
Args:
city: 中文城市名,例如 "杭州"。
"""
# 这里采用模拟的方式随机返回结果
weather = random.choice(["晴", "多云", "阴", "小雨"])
temperature = random.randint(15, 30)
return f"{city}今天{weather},气温 {temperature} 摄氏度"
TOOLS = [calculator, get_weather]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
llm = ChatOpenAI(model=MODEL, api_key=API_KEY, base_url=BASE_URL)
# 把工具的名称、描述和参数结构告诉模型
llm_with_tools = llm.bind_tools(TOOLS)
messages = [
SystemMessage("你是一个高级的助手,需要友善地回答用户的问题。需要计算或查天气时请使用工具,不要自己猜。"),
HumanMessage(input("输入以获取回答:")),
]
# 第一次调用:模型决定直接回答,还是要求调用工具
response = llm_with_tools.invoke(messages)
if response.tool_calls:
# 要求调用工具的这条 AIMessage 也要放进对话,后面的 ToolMessage 才能和它对应
messages.append(response)
# 模型可能一次要求调用多个工具,逐个执行并把结果交回去
for call in response.tool_calls:
print(f"[调用工具] {call['name']}({call['args']})")
try:
result = TOOLS_BY_NAME[call["name"]].invoke(call)
except Exception as exc:
# 工具名不存在、参数错误、计算出错都会到这里,错误也要交回给模型
result = ToolMessage(content=f"工具执行失败:{exc}", tool_call_id=call["id"], status="error")
print(f"[工具结果] {result.content}")
messages.append(result)
# 第二次调用:模型拿着工具结果组织最终回答
response = llm_with_tools.invoke(messages)
print(response.content)
示例输出:
输入以获取回答:杭州和北京今天哪个更暖和?
[调用工具] get_weather({'city': '杭州'})
[工具结果] 杭州今天晴,气温 23 摄氏度
[调用工具] get_weather({'city': '北京'})
[工具结果] 北京今天小雨,气温 29 摄氏度
根据今天的天气情况:
- **杭州**:晴,气温 **23°C**
- **北京**:小雨,气温 **29°C**
所以今天**北京更暖和**,比杭州高了 6°C。不过北京在下小雨,出门记得带伞哦!🌂
可以看到模型在同一轮里要求了两次 get_weather,拿到两个结果后自己做了比较。再来一个计算:
输入以获取回答:2 的 20 次方是多少
[调用工具] calculator({'expression': '2 ** 20'})
[工具结果] 1048576
2 的 20 次方是 **1,048,576**(一百零四万八千五百七十六)。
最后试一个不需要工具的问题,可以看到模型直接回答,没有出现 [调用工具]:
输入以获取回答:你好,你能做什么?
你好!很高兴为你服务。我是一个高级智能助手,可以帮你处理各种各样的任务。具体来说,我可以为你做以下事情:
1. **查询天气**:你可以告诉我一个城市的名字,我能帮你查询该城市今天的天气情况。
2. **数学计算**:如果你有任何数学计算需求,只需提供算式,我就能帮你准确得出结果。
3. **日常问答与聊天**:无论是解答生活常识、科学知识,还是单纯想聊聊天,我都可以陪你。
4. **文本创作与处理**:我可以帮你写文章、邮件、总结报告,或者进行多语言翻译。
5. **代码与逻辑分析**:我可以帮你编写、解释或调试代码,也能协助进行逻辑推理和数据分析。
请问今天有什么我可以帮你的吗?比如想查一下某个城市的天气,或者算一道数学题?随时告诉我!
有意思的是,模型虽然没有调用工具,但在介绍自己时把「查询天气」和「数学计算」放在了前两条。这正是 bind_tools 的效果:工具列表随每次请求一起发给了模型,它知道自己手上有什么。
7. 小结
这一节的核心是「两次调用」:第一次模型给出工具调用请求,程序执行后用 ToolMessage 交回,第二次模型组织回答。记住三点:
- 模型只「申请」调用,不执行,执行的是我们的程序。
- 名称、描述、参数结构是模型唯一能看到的信息,决定了它会不会调用、怎么填参数。
- 每个
tool_call都必须有一条对应tool_call_id的ToolMessage交回去,失败了也要交。
现在系统提示词还是写死在代码里的字符串,工具返回的也是一段文字,程序没法直接拿来用。下一节介绍提示词模板和结构化输出,让模型的回答可以被程序读取。
无需登录,点一个表情告诉我。