上一节我们让模型回答了第一个问题。但是如果问它「杭州今天天气怎么样」或者「2 的 20 次方是多少」,它要么编一个答案,要么老老实实说自己查不了。模型本身只会生成文字,不能联网,也不能执行代码。Function Calling 就是用来解决这个问题的:让模型学会在需要的时候,向我们的程序「借」一个工具用。

本节的代码是一个新文件 tool_chat.py,上一节的 main.py 保持不变。

1. Function Calling 是什么

1.1 模型并不会自己执行工具

因为模型只能输出文字,可以类比为人类的大脑,而Function Calling则是作为工具,供大脑去使用调用。

以获取天气为例,模型调用工具的整个流程是这样的:

text
用户提问
  → 模型判断:需要调用 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 的工具:

python
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 会从这个函数里提取四样东西,缺一个都会报错或者效果变差:

  1. 函数名 会成为工具名,模型靠这个名字来指定调用哪个工具。
  2. docstring 的第一段 会成为工具描述,模型靠它判断什么时候该用这个工具。不写 docstring 会直接抛异常。
  3. Args: 下面的每一行 会成为对应参数的描述,添加参数描述有助于提升模型传入参数的准确性。使用Args需要设定 parse_docstring=True,否则整个 docstring 会被当成一段工具描述,参数就没有任何说明。
  4. 参数类型注解 会被转换成参数类型(JSON Schema),模型按这个类型生成参数。

docstring 用的是 Google 风格:第一段写功能,空一行,Args: 下面一个参数一行,格式是 参数名: 说明。养成习惯把每个参数都写上,后面工具一多,模型填错参数的概率会明显下降。

2.2 名称、描述和参数结构如何影响模型选择

模型看不到我们的函数体,它能看到的只有三样东西:名称、描述、参数结构。可以直接打印出来看看:

python
print(calculator.name)
# calculator
print(calculator.description)
# 计算一个数学表达式并返回结果。
print(calculator.args)
# {'expression': {'description': 'Python 语法的数学表达式,例如 "3 * (4 + 5)" 或 "2 ** 10"。', 'title': 'Expression', 'type': 'string'}}

如果想看发给模型的最终形态,可以用 LangChain 自带的转换函数:

python
from langchain_core.utils.function_calling import convert_to_openai_tool
print(convert_to_openai_tool(calculator))
json
{
  "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 天气工具

第二个工具是查天气。

python
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 会把工具的名称、描述和参数结构一起塞进每一次请求里:

python
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 字段:

python
response = llm_with_tools.invoke([HumanMessage("杭州今天天气怎么样")])
print(response.content)
print(response.tool_calls)
text

[{'name': 'get_weather', 'args': {'city': '杭州'}, 'id': 'call_894a633aa31346abb03aef52', 'type': 'tool_call'}]

可以看到模型这次没有回答文字(content 为空),而是在 tool_calls 里给出了一个请求:调用 get_weather,参数 city="杭州"。每个请求都有一个 id,后面把结果交回去的时候要靠它对应。

到这一步模型的工作就结束了。它不知道杭州的天气,它只是「申请」了一次查询。

4. 执行工具并把结果交回模型

4.1 参数是怎么传进函数的

先看模型到底给了我们什么。接口层面,模型返回的是一段 JSON,其中 arguments 是一个字符串,里面再套一层 JSON:

json
{
  "id": "call_894a633aa31346abb03aef52",
  "type": "function",
  "function": {"name": "get_weather", "arguments": "{\"city\": \"杭州\"}"}
}

LangChain 帮我们把这段东西解析好,放进 tool_calls。到我们手上时,args 已经是一个普通的 Python 字典:

python
call = response.tool_calls[0]
print(call["args"])            # {'city': '杭州'}
print(type(call["args"]))      # <class 'dict'>

字典的 key 就是函数的形参名。这也是 2.1 里说「参数类型注解会变成参数结构」的另一面:函数怎么声明参数,模型就按什么名字和类型来填,最后原样作为关键字参数传回函数。 把这个字典变成一次函数调用,有三种写法:

python
get_weather.func(**call["args"])    # 最直白:相当于 get_weather(city="杭州"),返回字符串
get_weather.invoke(call["args"])    # 先按类型注解校验参数,再调用,返回字符串
get_weather.invoke(call)            # 传整个 tool_call:校验、调用,并包装成 ToolMessage

两点说明:

  1. 加了 @tool 之后,get_weather 已经不是普通函数而是一个工具对象,直接写 get_weather("杭州") 会报错。原来的函数挂在 .func 上,第一种写法就是在调用它。
  2. invoke 在进入函数之前会按类型注解做一次校验。比如模型把 city 填成了数字 123,函数不会被执行,而是直接抛出 ValidationError: Input should be a valid string。参数名对不上也一样。5.3 节要处理的异常,大部分就是从这里来的。

正文里用的是第三种写法,因为它顺手把下一步要用的 ToolMessage 也生成好了。

4.2 ToolMessage

工具执行的结果需要用第四种消息类型 ToolMessage 交回模型,它必须带上对应的 tool_call_id:

python
result = TOOLS_BY_NAME[call["name"]].invoke(call)
print(type(result).__name__, result.tool_call_id, result.content)
text
ToolMessage call_894a633aa31346abb03aef52 杭州今天阴,气温 22 摄氏度

TOOLS_BY_NAME[call["name"]] 根据模型给的名字找到对应的工具,invoke(call) 取出参数执行,再把返回的字符串和 call["id"] 一起包成 ToolMessage,不需要我们手动拼。

至此我们一共接触了五种消息,整段对话在消息列表里是这样的:

text
SystemMessage   你是一个高级的助手……
HumanMessage    杭州今天天气怎么样
AIMessage       (content 为空,tool_calls 要求调用 get_weather)
ToolMessage     杭州今天多云,气温 24 摄氏度
AIMessage       杭州今天多云,气温 24 摄氏度,挺舒服的。

要点是:模型要求调用工具的那条 AIMessage 也必须追加进列表,然后紧跟着 ToolMessage,模型才能把请求和结果对上。

4.3 完整的调用流程

把 3、4 两步串起来,一次提问需要调用两次模型:第一次让模型决定要不要用工具,执行完工具后第二次让它组织回答。

python
# 第一次调用:模型决定直接回答,还是要求调用工具
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 交回去:

python
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. 完整代码

python
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)

示例输出:

text
输入以获取回答:杭州和北京今天哪个更暖和?
[调用工具] get_weather({'city': '杭州'})
[工具结果] 杭州今天晴,气温 23 摄氏度
[调用工具] get_weather({'city': '北京'})
[工具结果] 北京今天小雨,气温 29 摄氏度
根据今天的天气情况:

- **杭州**:晴,气温 **23°C**
- **北京**:小雨,气温 **29°C**

所以今天**北京更暖和**,比杭州高了 6°C。不过北京在下小雨,出门记得带伞哦!🌂

可以看到模型在同一轮里要求了两次 get_weather,拿到两个结果后自己做了比较。再来一个计算:

text
输入以获取回答:2 的 20 次方是多少
[调用工具] calculator({'expression': '2 ** 20'})
[工具结果] 1048576
2 的 20 次方是 **1,048,576**(一百零四万八千五百七十六)。

最后试一个不需要工具的问题,可以看到模型直接回答,没有出现 [调用工具]:

text
输入以获取回答:你好,你能做什么?
你好!很高兴为你服务。我是一个高级智能助手,可以帮你处理各种各样的任务。具体来说,我可以为你做以下事情:

1. **查询天气**:你可以告诉我一个城市的名字,我能帮你查询该城市今天的天气情况。
2. **数学计算**:如果你有任何数学计算需求,只需提供算式,我就能帮你准确得出结果。
3. **日常问答与聊天**:无论是解答生活常识、科学知识,还是单纯想聊聊天,我都可以陪你。
4. **文本创作与处理**:我可以帮你写文章、邮件、总结报告,或者进行多语言翻译。
5. **代码与逻辑分析**:我可以帮你编写、解释或调试代码,也能协助进行逻辑推理和数据分析。

请问今天有什么我可以帮你的吗?比如想查一下某个城市的天气,或者算一道数学题?随时告诉我!

有意思的是,模型虽然没有调用工具,但在介绍自己时把「查询天气」和「数学计算」放在了前两条。这正是 bind_tools 的效果:工具列表随每次请求一起发给了模型,它知道自己手上有什么。

7. 小结

这一节的核心是「两次调用」:第一次模型给出工具调用请求,程序执行后用 ToolMessage 交回,第二次模型组织回答。记住三点:

  1. 模型只「申请」调用,不执行,执行的是我们的程序。
  2. 名称、描述、参数结构是模型唯一能看到的信息,决定了它会不会调用、怎么填参数。
  3. 每个 tool_call 都必须有一条对应 tool_call_id 的 ToolMessage 交回去,失败了也要交。

现在系统提示词还是写死在代码里的字符串,工具返回的也是一段文字,程序没法直接拿来用。下一节介绍提示词模板和结构化输出,让模型的回答可以被程序读取。

这篇文章带给你什么?

无需登录,点一个表情告诉我。