Function Call 的原理及实践

让大模型把「要办哪件事、参数填什么」写成一张结构化委托单,而真正执行的永远是你的代码。

30″30 秒看懂 Function Call

把大模型想成一位被锁在办公室里的博学顾问:他读过海量的书,但办公室里没有网线,门也锁着——所以他不知道今天的天气,也进不了你公司的数据库。

你问他“今天北京天气如何”,他不会硬编一个答案,而是写一张委托单递出来:“请帮我办这件事:get_current_weather,参数 location=北京。” 单子由门外的跑腿助理——也就是你写的代码——拿去真正执行:发 HTTP 请求、查数据库。办完把回执递回办公室,顾问看完回执,才写出那句人话答复。

图① 30 秒看懂:顾问填单、助理跑腿
图① 30 秒看懂:顾问填单、助理跑腿
比喻里的角色对应的技术概念它到底干了什么
博学顾问大模型(glm-4)只判断「要不要办事、办哪件、填什么参数」,从不亲自办事
可委托事项清单tools你提前告诉顾问:他能委托哪些事、每件事需要填哪些信息
委托单tool_calls顾问填好的单子:函数名 + 参数,是一段 JSON,不是执行结果
跑腿助理你的 Python 代码照着单子真正调用函数、请求接口、执行 SQL
回执roletool 的消息把函数返回值贴回对话记录,让顾问看得见
顾问看回执后写的答复第二次调用模型的输出把冷冰冰的数据翻译成自然语言
⛔ 整讲只有一条铁律 模型的 Function Call 不会调用函数,只返回函数的名字和参数。执行的人永远是开发者。顾问全程没离开过办公室——后面所有代码,都是在实现「怎么把单子拿出来、怎么把回执送回去」。

01概念:什么是 Function Call

定义、由来,以及它到底替大模型补上了什么短板

1.1 定义与由来

2023 年 6 月 13 日,OpenAI 公布了 Function Call(函数调用) 功能。它指的是在语言模型中集成外部功能或 API 的调用能力——这意味着模型可以在生成文本的过程中调用外部函数或服务,获取额外的数据或执行特定的任务。

注意这句定义里的措辞:「集成调用能力」。能力是集成进来了,但按下执行键的手,始终在开发者这边。这一点在下一节会反复强调。

Function Call 的基本流程(简化版)

开始用户输入 query
大语言模型判断:是否需要调用外部信息?
直接生成回复,结束
a. 匹配外部函数
b选择合适的外部 API
c根据 API 逻辑生成回复
输出结束

用比喻复述一遍:顾问先掂量这个问题靠自己的学问答不答得了;答得了就直接答(上面那条路),答不了就去翻「可委托事项清单」,挑一件、填好单子,等助理把回执送回来再答(下面那条路)。

1.2 Function Call 解决大模型的三类问题

01信息实时性

大模型训练的数据集无法包含最新的信息,如最新的新闻、实时股价等。通过 Function Call,模型可以实时获取最新数据,提供更加时效的服务。

02数据局限性

模型训练数据虽多但有限,无法覆盖所有领域,如医学、法律等领域的专业咨询。Function Call 允许模型调用外部数据库或 API,获取特定领域的详细信息。

03功能扩展性

大模型虽然功能强大,但不可能内置所有可能需要的功能。通过 Function Call,可以轻松扩展模型能力,如调用外部工具进行复杂计算、数据分析等。

本讲后面的三个案例,正好一一呼应这三类问题:天气对应信息实时性,数据库查询对应数据局限性,航班票价的两步串联对应功能扩展性。

1.3 和 RAG 的区别

这两者经常被混为一谈,但主动权归属完全不同:

维度RAGFunction Call
谁发起你的程序先检索,再把文档塞进提示词模型自己判断要不要调、调哪个、传什么参数
拿到的是一段相似文本一次函数执行的真实结果
擅长私有文档问答、长资料检索实时数据、写操作、精确计算、系统集成

02原理:两次调用之间发生了什么

调用模式的变化、一条铁律、tools 的字段含义、messages 的增长过程

2.1 没有 Function Call 时,调用模式非常简单

当没有函数调用(function-call)的时候,我们调用大模型构建 AI 应用的模式非常简单,主要步骤只有两步、并重复执行

  1. 用户(Client)发请求给我们的服务(Chat Server)
  2. 我们的服务(Chat Server)给模型提示词,拿到文本后返回

2.2 有 Function Call 时,模式复杂一些

当有函数调用(function-call)的时候,模式比之前要复杂一些,主要步骤是四步:

1带着工具清单提问

用户(Client)发请求 prompt 以及 functions 给我们的服务(Chat Server)。

2模型决定用哪种格式回应

模型根据用户的 prompt,判断是用普通文本还是函数调用的格式响应我们的服务(Chat Server)。

3服务端执行函数

如果是函数调用格式,那么 Chat Server 就会执行这个函数,并且将结果返回给模型。

4模型组织成人话

然后模型使用提供的数据,用连贯的文本响应并返回。

图② 无 Function Call 与有 Function Call 的流程对比
图② 无 Function Call 与有 Function Call 的流程对比

2.3 核心概念:模型不执行函数

⛔ 一条必须背下来的规则 大模型的 Function Call 不会调用函数,仅返回函数的参数。开发者利用模型输出的参数在应用中调用函数。

这条规则决定了后面所有代码的长相。把它拆成三句话记:

环节谁来做产出什么
判断要不要调函数、调哪个模型finish_reason='tool_calls'
给出函数名和参数模型tool_calls[0].function.name.arguments(字符串形式的 JSON)
真正执行函数开发者函数返回值,由你手动放回 messages

所以「模型调用了天气接口」这句话是错的。准确说法是:模型请求你去调天气接口,你调完把结果告诉它。

2.4 tools 的 JSON Schema:写给模型看的说明书

tools 是一个数组,数组里每个元素描述一个可被委托的函数。它不参与任何计算,唯一作用是让模型知道有什么可用、每个参数该填什么

图④ tools 的 JSON Schema 逐字段拆解
图④ tools 的 JSON Schema 逐字段拆解
tools 的完整结构(以天气函数为例)
[
  {
    "type": "function",
    "function": {
      "name": "get_current_weather",
      "description": "获取给定位置的当前天气",
      "parameters": {
        "type": "object",
        "properties": {
          "location": {
            "type": "string",
            "description": "城市或区,例如北京、海淀"
          }
        },
        "required": ["location"]
      }
    }
  }
]
字段取值作用与注意
type"function"工具类型,目前固定这一种,照写。
function.name函数名字符串必须与真实 Python 函数同名。它是后面派发表 available_functions 的键,写错直接 KeyError。
function.description一句话说明决定模型选不选这个函数。写给模型看,不是写给同事看的注释;含糊 → 该调时不调、或调错函数。
parameters.type"object"参数整体是一个对象,固定写法。
properties参数字典每个参数写清 typedescription参数名要与函数形参一致,模型据此从用户原话里抽值。
required字符串数组必填参数清单。列进来的参数缺失时,模型会反问用户而不是瞎编——这正是航班案例里 system 提示词强调「不要假设或猜测参数值」的配套设计。

2.5 两次调用之间,messages 是怎么长大的

Function Call 没有任何隐藏状态,全部上下文都堆在 messages 这一个列表里,而且只追加、不修改。下面这张时序图把两次调用之间发生的事按时间轴排开:

图③ 两次调用模型的时序与 messages 演变
图③ 两次调用模型的时序与 messages 演变

第一次调用前,messages 是 2 条:

第一次调用模型时的 messages(2 条)
[
  {
    "role": "system",
    "content": "你是一个天气播报小助手,你需要根据用户提供的地址来回答当地的天气情况,如果用户提供的问题具有不确定性,不要自己编造内容,提示用户明确输入"
  },
  {
    "role": "user",
    "content": "今天北京的天气如何"
  }
]

第一次调用返回的不是答案,而是一张委托单。把响应对象摊开看,关键的三处已标注:

第一次调用的响应对象(glm-4 真实返回)
Completion(
    model='glm-4',
    created=1765805092,
    choices=[CompletionChoice(
        index=0,
        finish_reason='tool_calls',          # ← 关键:模型因「要调工具」而结束,不是因为答完了
        message=CompletionMessage(
            content=None,                    # ← 没有文本内容,模型这一轮压根没打算回答
            role='assistant',
            reasoning_content=None,
            tool_calls=[                     # ← 这就是「委托单」
                CompletionMessageToolCall(
                    id='call_-8074878091637713008',   # 委托单编号,回执要靠它配对
                    function=Function(
                        arguments='{"location": "北京"}',   # 参数:字符串形式的 JSON
                        name='get_current_weather'         # 函数名:要调哪个函数
                    ),
                    type='function',
                    index=0
                )
            ]
        )
    )],
    request_id='2025121521245166e1ccfc510a4af9',
    id='2025121521245166e1ccfc510a4af9',
    usage=CompletionUsage(prompt_tokens=155, completion_tokens=11, total_tokens=166)
)

接着你把这条 assistant 消息原样回填,再把函数执行结果作为 roletool 的消息追加进去。第二次调用前,messages 变成 4 条:

第二次调用模型时的 messages(4 条)
[
  { "role": "system", "content": "你是一个天气播报小助手……" },

  { "role": "user", "content": "今天北京的天气如何" },

  {
    "role": "assistant",
    "content": null,
    "tool_calls": [
      {
        "id": "call_-8074878091637713008",
        "type": "function",
        "function": {
          "name": "get_current_weather",
          "arguments": "{\"location\": \"北京\"}"
        },
        "index": 0
      }
    ]
  },

  {
    "role": "tool",
    "tool_call_id": "call_-8074878091637713008",
    "name": "get_current_weather",
    "content": "{\"location\": \"北京\", \"high_temperature\": \"高温 33℃\", \"low_temperature\": \"低温 17℃\", \"week\": \"星期一\", \"type\": \"晴\"}"
  }
]
为什么一定要回填那条 assistant 消息? 因为 roletool 的消息靠 tool_call_id 认领「自己是哪张委托单的回执」。如果上一条 assistant(内含 tool_calls)没有回填,回执就成了孤儿,接口会直接报错。顺序也不能颠倒:先 assistant,后 tool。

03最小代码:跑通第一个 Function Call

先把最短的一条路走完,再去看三个完整案例

三个案例的代码看着不一样,剥掉业务之后剩下的只有 60 行——这是理解 Function Call 的最短路径。整个流程就五步,对着代码里的编号看:

① 定义函数一个普通的 Python 函数,返回字符串
② 写 tools告诉模型它叫什么、要什么参数
③ 第一次调模型拿回 tool_calls,不是答案
④ 本地执行json.loads 参数 → 调函数 → 拿返回值
⑤ 回填 + 第二次调模型assistant 与 tool 两条消息追加进 messages
输出自然语言答复
fc_skeleton_min.py —— 单函数最小骨架,复制即用可复用模板
# -*- coding: utf-8 -*-
"""
Function Call 最小骨架(单函数版)
==========================================
天气案例去掉「天气」之后,剩下的就是这 60 行。
只有一个工具、固定两轮对话,是理解 Function Call 的最短路径。
需要多个工具或函数间依赖,请用 fc_skeleton.py(带注册表 + while 多轮)。
"""

import json
import os

# TODO(拓展点 1):换 client / 换模型
from zhipuai import ZhipuAI

client = ZhipuAI(api_key=os.environ["ZHIPUAI_API_KEY"])
MODEL = "glm-4"
# openai 包写法(只差 import 和 base_url,下面代码一字不改):
# from openai import OpenAI
# client = OpenAI(api_key=os.environ["OPENAI_API_KEY"],
#                 base_url="https://open.bigmodel.cn/api/paas/v4/")


# TODO(拓展点 2):换成你的真实函数(模型不执行它,执行的是你)
def my_function(param1):
    """示例:查天气、查订单、算运费……换成你的业务逻辑。"""
    return {"param1": param1, "result": "这里换成真实结果"}


# TODO(拓展点 3):函数描述——name 必须和上面的函数名一致
tools = [{
    "type": "function",
    "function": {
        "name": "my_function",
        "description": "这个函数是干什么的、什么时候该调用它",
        "parameters": {
            "type": "object",
            "properties": {
                "param1": {"type": "string", "description": "参数含义,写清格式和示例"},
            },
            "required": ["param1"],
        },
    },
}]


def run(user_input):
    messages = [{"role": "user", "content": user_input}]

    # 第一次调用:模型决定要不要用工具
    first = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools, tool_choice="auto")
    msg = first.choices[0].message

    if not getattr(msg, "tool_calls", None):
        return msg.content                        # 模型觉得不用工具,直接回答

    tool_call = msg.tool_calls[0]
    args = json.loads(tool_call.function.arguments)   # arguments 是字符串 JSON
    result = my_function(**args)                      # 本地执行

    messages.append(msg.model_dump())             # 回填 assistant(带 tool_calls)
    messages.append({                             # 回填 tool(函数回执)
        "role": "tool",
        "tool_call_id": tool_call.id,
        "name": tool_call.function.name,
        "content": json.dumps(result, ensure_ascii=False),   # 必须是字符串
    })

    # 第二次调用:模型拿着真实结果,翻译成人话
    second = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools, tool_choice="auto")
    return second.choices[0].message.content


if __name__ == "__main__":
    # TODO(拓展点 4):换成你的提问
    print(run("这里换成用户的问题"))
两种 client 写法,只差 import 和 base_url 代码里两种初始化都写了:ZhipuAI(api_key=...)OpenAI(api_key=..., base_url=...)下面的业务代码一个字都不用改——因为 client.chat.completions.create(tools=..., tool_choice="auto") 和返回的 tool_calls 结构是同一套 OpenAI 风格协议。换成 DeepSeek、Qwen 或本地 vLLM,也只是换 base_url
API-KEY 一律走环境变量 在项目根目录建一个 .env 文件写 zhipu_api=你的KEY,代码里用 os.environ['zhipu_api'] 读取。不要把 KEY 硬编码进源码,更不要连同 .env 一起提交到 Git。环境要求:Python 3.10 以上,依赖 pip install zhipuai python-dotenv requests(注意包名是 zhipuai)。

04完整案例:天气 · 航班 · 数据库

单函数、多函数串联、参数本身是一条 SQL——三种典型形态

4.1 单一函数应用:实时天气查询

假设我们要创建一个具备查询实时天气的聊天机器人。基本流程分三步:准备工作 → 定义 Function Tools → 模型应用 Function Call。

第一步准备工作:申请 API-KEY,配置环境变量
第二步定义 Function Tools:定义外部函数,描述函数功能
第三步模型应用:输入 prompt,模型输出函数参数,调用本地函数得到结果,融合消息再次送入模型
项目说明
支持的模型国内外支持 Function Call 的模型,如 ChatGPT、百度文心一言、智谱 ChatGLM3、讯飞星火 3.0 等
本次选用基于智谱 AI 的 ChatGLM 来实现,注册申请 API-KEY:open.bigmodel.cn/dev/howuse/functioncall
Python 版本3.10 以上
依赖安装pip install zhipuai python-dotenv requests

tools.py —— 定义 Function Tools

这个文件承担三件事:① 定义查询天气的外部函数;② 描述函数功能;③ 解析模型参数并调用函数。文件路径 ./ChatGLM3_FunctionCall/weather/tools.py,一共包含 2 个自定义函数和 1 个函数功能描述。

tools.py —— 函数 + 描述 + 解析派发
"""
单一函数应用(天气)—— 工具层:函数定义 + 函数描述 tools + 调用结果解析
文件路径:./ChatGLM3_FunctionCall/weather/tools.py
"""
import json                      # 解析模型返回的 arguments(字符串形式的 JSON)
import requests                  # 请求第三方天气接口

# ============ 1. 函数描述 tools:写给「模型」看的说明书 ============
# 注意:tools 不是代码逻辑,它只是一份 JSON Schema 清单,
#      模型读了它才知道「我手上有哪些函数可以委托、每个函数要填哪些参数」。
tools = [
    {
        "type": "function",                       # 工具类型,目前固定为 function
        "function": {
            # 函数名:必须与下方真实的 Python 函数同名,否则派发时会 KeyError
            "name": "get_current_weather",
            # 函数描述:模型「选不选这个函数」完全依据这句话,要写清楚用途
            "description": "获取给定位置的当前天气",
            "parameters": {                       # 参数定义,遵循 JSON Schema
                "type": "object",                 # 参数整体是一个对象,固定写法
                "properties": {                   # 对象里的各个字段
                    "location": {                 # 参数名,要与函数形参一致
                        "type": "string",         # 参数类型
                        # 参数描述:模型据此从用户原话里抽取出「北京」这个值
                        "description": "城市或区,例如北京、海淀",
                    },
                },
                "required": ["location"],         # 必填参数;缺失时模型会反问用户而不是瞎编
            },
        }
    }
]


# ============ 2. 真正干活的本地函数:查询天气 ============
def get_current_weather(location):
    """得到给定地址的当前天气信息(真正执行方是本函数,不是模型)"""
    # 2.1 读取「城市 -> 邮政编码」映射表,天气接口用编码而不是中文城市名
    with open('./cityCode_use.json', 'r', encoding='utf-8') as file:
        data = json.load(file)                    # json.load 直接把文件流解析成 Python 列表

    city_code = ""                                # 城市编码,默认空
    weather_info = {}                             # 查询结果,默认空字典

    # 2.2 遍历映射表,找到与 location 同名的城市,取出它的编码
    for loc in data:
        if location == loc["市名"]:
            city_code = loc["编码"]

    # 2.3 拿到编码才发起请求;没匹配到就直接返回空结果,避免拼出错误 URL
    if city_code:
        weather_url = "http://t.weather.itboy.net/api/weather/city/" + city_code
        response = requests.get(weather_url)      # 发起 HTTP GET 请求
        result1 = eval(response.text)             # 示例写法:把返回文本转成字典
        # 更稳的写法是 result1 = response.json(),eval 会执行任意表达式,生产环境不要用

        forecast = result1["data"]["forecast"][0]  # forecast[0] 就是「今天」的预报
        # 2.4 只挑模型需要的字段返回,喂给模型的内容越干净,最终回答越准
        weather_info = {
            "location": location,
            "high_temperature": forecast["high"],
            "low_temperature": forecast["low"],
            "week": forecast["week"],
            "type": forecast["type"],
        }

    # 2.5 必须返回字符串:messages 里 role 为 tool 的 content 只接受字符串
    #     ensure_ascii=False 保证中文不被转成 \uXXXX 乱码
    return json.dumps(weather_info, ensure_ascii=False)


# ============ 3. 解析模型回复:判断要不要调函数,并把函数调起来 ============
def parse_response(response):
    """根据模型回复决定是否调用工具函数;若调用,返回函数执行结果"""
    response_message = response.choices[0].message

    # 3.1 关键判断:模型回复里有 tool_calls,说明它填了「委托单」
    #     没有 tool_calls,说明模型认为直接用文本回答就够了
    if response_message.tool_calls:
        # 3.2 函数名 -> 函数对象 的派发表;多函数场景往这里继续加即可
        available_functions = {
            "get_current_weather": get_current_weather,
        }
        # 3.3 从模型回复中取出函数名(模型只告诉你「叫什么」)
        function_name = response_message.tool_calls[0].function.name
        fuction_to_call = available_functions[function_name]

        # 3.4 arguments 是「字符串形式的 JSON」,必须 json.loads 才能当参数用
        function_args = json.loads(response_message.tool_calls[0].function.arguments)

        # 3.5 真正执行函数 —— 这一行是整个 Function Call 里唯一「执行」的地方
        function_response = fuction_to_call(
            location=function_args.get("location"),
        )
        return function_response


if __name__ == '__main__':
    # 单独测试本地函数是否可用(不经过模型,先保证函数本身跑得通)
    function_name = get_current_weather
    response = function_name(location="北京")
    print(response)
三个细节值得停一下get_current_weather 返回的是 json.dumps(..., ensure_ascii=False) 字符串,因为 roletoolcontent 只接受字符串,ensure_ascii=False 保证中文不变成 \uXXXX
parse_response 里的 available_functions 是「函数名 → 函数对象」的派发表,多函数场景就是往这里加键值对。
③ 示例里用 eval(response.text) 把接口返回转成字典,学习阶段可以,生产环境请换成 response.json()——eval 会执行任意表达式。

天气接口需要城市编码而不是中文城市名,所以要准备一份映射表(节选):

cityCode_use.json —— 城市与编码映射(节选,可直接跑通示例)
[
  {"市名": "北京", "编码": "101010100"},
  {"市名": "海淀", "编码": "101010200"},
  {"市名": "朝阳", "编码": "101010300"},
  {"市名": "上海", "编码": "101020100"},
  {"市名": "天津", "编码": "101030100"},
  {"市名": "郑州", "编码": "101180101"},
  {"市名": "深圳", "编码": "101280601"},
  {"市名": "广州", "编码": "101280101"},
  {"市名": "杭州", "编码": "101210101"},
  {"市名": "成都", "编码": "101270101"}
]

weather_zhipu.py —— 主逻辑:两次调用模型

文件路径 ./ChatGLM3_FunctionCall/weather/weather_zhipu.py,包含 2 个函数:调用模型的函数和主逻辑函数。

weather_zhipu.py —— 主逻辑:两次调用模型
"""
单一函数应用(天气)—— 主逻辑:两次调用模型,中间夹一次本地函数执行
文件路径:./ChatGLM3_FunctionCall/weather/weather_zhipu.py

运行前准备:
    pip install zhipuai python-dotenv requests
    在同目录 .env 中写入: zhipu_api=你的APIKEY
"""
import os                                    # 读取环境变量,密钥不写死在代码里
from dotenv import load_dotenv, find_dotenv  # 从 .env 文件加载环境变量
from tools import *                          # 导入 tools 列表、get_current_weather、parse_response
from zhipuai import ZhipuAI                  # 智谱官方 SDK

_ = load_dotenv(find_dotenv())               # find_dotenv 自动向上查找 .env,load 进 os.environ
zhupu_ak = os.environ['zhipu_api']           # 从环境变量取 API-KEY(严禁硬编码进源码)
client = ZhipuAI(api_key=zhupu_ak)           # 创建客户端
ChatGLM = "glm-4"                            # 选用支持 Function Call 的模型


def chat_completion_request(messages, tools=None, tool_choice=None, model=ChatGLM):
    """
    统一的模型调用入口(两次调用复用同一个函数,只是传入的 messages 不同)

    参数:
        messages:    对话消息列表,Function Call 的全部状态都靠它承载
        tools:       函数描述清单;不传则模型无从得知有哪些函数可用
        tool_choice: "auto" 表示由模型自行决定用文本回答还是调用函数
        model:       模型名称
    返回:
        成功返回响应对象,失败返回异常对象
    """
    try:
        response = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=tools,
            tool_choice=tool_choice,
        )
        return response
    except Exception as e:
        print("Unable to generate ChatCompletion response")
        print(f"Exception: {e}")
        return e


def main():
    # ---------- 第 1 步:构造初始 messages(此时 2 条:system + user)----------
    messages = []
    messages.append({
        "role": "system",
        "content": "你是一个天气播报小助手,你需要根据用户提供的地址来回答当地的天气情况,"
                   "如果用户提供的问题具有不确定性,不要自己编造内容,提示用户明确输入"
    })
    messages.append({"role": "user", "content": "今天北京的天气如何"})

    # ---------- 第 2 步:第一次调用模型(带上 tools)----------
    # 模型此时不会给出天气,而是返回 tool_calls:函数名 + 参数
    response = chat_completion_request(messages, tools=tools, tool_choice="auto")

    # ---------- 第 3 步:本地真正执行函数,拿到真实数据 ----------
    function_response = parse_response(response)

    # ---------- 第 4 步:把模型那条 assistant 消息原样回填进 messages ----------
    # 必须回填!否则下一条 role 为 tool 的消息会找不到它所对应的 tool_calls
    assistant_message = response.choices[0].message
    messages.append(assistant_message.model_dump())   # model_dump 把对象转成字典

    # 取出函数名与本次工具调用的 id(id 用来把「回执」和「委托单」配对)
    function_name = response.choices[0].message.tool_calls[0].function.name
    function_id = response.choices[0].message.tool_calls[0].id

    # ---------- 第 5 步:把函数执行结果作为 role 为 tool 的消息加入 messages ----------
    messages.append({
        "role": "tool",                 # 固定角色名,表示这是工具执行结果
        "tool_call_id": function_id,    # 必须与上一条 assistant 中的 tool_calls[0].id 一致
        "name": function_name,          # 函数名
        "content": function_response,   # 函数返回值,字符串
    })
    # 此刻 messages 共 4 条:system / user / assistant(tool_calls) / tool

    # ---------- 第 6 步:第二次调用模型,得到最终自然语言答案 ----------
    last_response = chat_completion_request(messages, tools=tools, tool_choice="auto")
    print(f'last_response--》{last_response.choices[0].message}')


if __name__ == '__main__':
    main()

运行结果

三个案例的最终输出
# ① 天气案例(单一函数)最终输出
last_response--》CompletionMessage(
    content='根据您的查询,我获取到了北京市当前的天气情况。今天是星期一,北京的天气情况是晴天,
             最高气温为33℃,最低气温为17℃。请注意天气变化,做好防晒和保暖措施。',
    role='assistant',
    tool_calls=None)          # ← tool_calls 变成 None,说明这次是真答案,循环结束

# ② 航班案例(多个函数)最终输出
last_response--》CompletionMessage(
    content='2024年4月2日,郑州到北京的航班号为1123,票价为668元。',
    role='assistant',
    tool_calls=None)

# ③ 数据库案例最终输出
last_response--》content='根据您的查询,我已经为您找到了工资最高的员工。这位员工的姓名是KING,他的工资是5000元。'
                 role='assistant' tool_calls=None

注意最后一行的 tool_calls=None——它是「循环该结束了」的信号:模型这次给的是真答案,不再是委托单。

4.2 多个函数应用:航班号 → 票价

假设我们要创建一个具备查询航班功能的聊天机器人。流程和环境同上一节一致,但是由于涉及多个函数,因此在代码设计结构上有所改动,完整代码包含三个文件:

文件职责
muti_utils.py主要用来定义多个 Functions,以及函数的应用(解析分发)
airplane_function_tools.py主要用来实现函数的功能描述 tools
muti_function_zhipu.py主逻辑函数,实现模型 Function Call 的应用
这个案例真正的看点:两轮串联 用户只问了「票价」,但查票价的函数需要航班号,而航班号得先查出来。于是模型自己拆成了两轮:
第 1 轮委托 get_plane_number(郑州→北京,2024-04-02)拿到航班号 1123;第 2 轮拿着 1123 委托 get_ticket_price 拿到 668;第 3 轮才合成人话。
对应比喻:顾问发现第二件事缺个关键信息,于是先派助理跑第一趟,拿到回执后再派第二趟。串联的判断是模型做的,串联的执行仍然是你的代码做的。

muti_utils.py —— 两个业务函数 + 统一分发

目的:① 定义查询飞机航班的函数;② 定义查询飞机票价的函数;③ 解析模型参数调用函数。文件路径 ./ChatGLM3_FunctionCall/airplane/muti_utils.py,一共包含 3 个自定义函数。

muti_utils.py —— 两个业务函数 + 统一分发
"""
多函数应用(航班)—— 函数层:定义多个可被委托的函数 + 统一的调用分发
文件路径:./ChatGLM3_FunctionCall/airplane/muti_utils.py
说明:可直接运行的完整脚本。
"""
import json


# ============ 函数 1:根据「出发地 + 目的地 + 日期」查航班号 ============
def get_plane_number(date, start, end):
    """
    用写死的字典模拟航班库;真实项目里把这里换成航司 API 或数据库查询即可,
    对模型而言接口形态不变 —— 它只负责填参数,不关心你内部怎么查。
    """
    plane_number = {
        "北京": {
            "深圳": "126",
            "广州": "356",
        },
        "郑州": {
            "北京": "1123",
            "天津": "3661",
        }
    }
    # 返回结构里带上 date,方便模型在下一轮把日期继续传给票价函数
    return {"date": date, "number": plane_number[start][end]}


# ============ 函数 2:根据「航班号 + 日期」查票价 ============
def get_ticket_price(date: str, number: str):
    """用固定票价模拟;重点在于它依赖上一个函数的输出(航班号)"""
    print(date)
    print(number)
    return {"ticket_price": "668"}


# ============ 统一分发:按模型给出的函数名,派发到对应的本地函数 ============
def parse_function_call(model_response):
    """
    与单函数版最大的不同:这里要用 if 判断函数名,
    因为模型可能选 get_plane_number,也可能选 get_ticket_price。
    """
    function_result = ''
    if model_response.choices[0].message.tool_calls:
        tool_call = model_response.choices[0].message.tool_calls[0]
        args = tool_call.function.arguments        # 字符串形式的 JSON

        function_result = {}
        if tool_call.function.name == "get_plane_number":
            # ** 解包:把 {"start":"郑州","end":"北京","date":"2024-04-02"} 展开成关键字参数
            function_result = get_plane_number(**json.loads(args))
        if tool_call.function.name == "get_ticket_price":
            function_result = get_ticket_price(**json.loads(args))

    return function_result

与单函数版相比,唯一的结构变化在 parse_function_call:它要按函数名分支,因为模型这次可能挑两个函数中的任意一个。另外注意 get_plane_number 的返回里带上了 date——这样模型在第二轮才能把日期一并传给票价函数。

airplane_function_tools.py —— 两个函数的 tools 描述

目的:定义函数描述功能,相当于给大模型应用的工具描述 tools。文件路径 ./ChatGLM3_FunctionCall/airplane/airplane_function_tools.py

airplane_function_tools.py —— 两个函数的 tools 描述
"""
多函数应用(航班)—— 工具描述层:一次性把两个函数都描述给模型
文件路径:./ChatGLM3_FunctionCall/airplane/airplane_function_tools.py

关键点:tools 是一个数组,里面放几个元素,模型手上就有几张「可委托事项」。
模型每一轮只会挑一个去填委托单,挑哪个取决于 description 写得准不准。
"""

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_plane_number",
            # 描述里点明了三个输入和一个输出,模型才知道「先查航班号」这一步归它
            "description": "根据始发地、目的地和日期,查询对应日期的航班号",
            "parameters": {
                "type": "object",
                "properties": {
                    "start": {
                        "description": "出发地",
                        "type": "string"
                    },
                    "end": {
                        "description": "目的地",
                        "type": "string"
                    },
                    "date": {
                        "description": "日期",
                        "type": "string",
                    }
                },
                # 三个参数都必填:用户说不全时,模型会追问而不是编造
                "required": ["start", "end", "date"]
            },
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_ticket_price",
            # 这个函数需要「航班号」,而航班号只能由上一个函数产出 ——
            # 两个函数因此形成依赖链,模型会自动分成两轮来调
            "description": "查询某航班在某日的价格",
            "parameters": {
                "type": "object",
                "properties": {
                    "number": {
                        "description": "航班号",
                        "type": "string"
                    },
                    "date": {
                        "description": "日期",
                        "type": "string",
                    }
                },
                "required": ["number", "date"]
            },
        }
    },
]

tools 数组里放了两个元素,模型手上就有两张「可委托事项」。它靠 description 分辨:查航班号要「始发地、目的地、日期」,查票价要「航班号、日期」。

muti_function_zhipu.py —— 三次调用模型,两次本地执行

文件路径 ./ChatGLM3_FunctionCall/airplane/muti_function_zhipu.py。主逻辑是「调用—执行—回填」重复两遍,最后再调一次收尾

muti_function_zhipu.py —— 三次调用模型,两次本地执行
"""
多函数应用(航班)—— 主逻辑:三次调用模型,中间夹两次本地函数执行
文件路径:./ChatGLM3_FunctionCall/airplane/muti_function_zhipu.py
说明:可直接运行的完整脚本。

一句话看懂本例的串联:
    用户只问「票价」,但票价函数要航班号 ——
    模型第 1 轮先委托 get_plane_number 拿到航班号 1123,
    第 2 轮才委托 get_ticket_price 拿到票价 668,
    第 3 轮把两段回执合成一句人话。

运行前准备:
    pip install zhipuai python-dotenv
    在同目录 .env 中写入: zhipu_api=你的APIKEY
"""
import os
import json
from zhipuai import ZhipuAI
from dotenv import load_dotenv, find_dotenv
from muti_utils import *                      # 三个自定义函数
from airplane_function_tools import *         # tools 描述清单

_ = load_dotenv(find_dotenv())
zhupu_ak = os.environ['zhipu_api']            # 从环境变量读取 API-KEY
client = ZhipuAI(api_key=zhupu_ak)
ChatGLM = "glm-4"


def chat_completion_request(messages, tools=None, tool_choice=None, model=ChatGLM):
    """模型调用入口,与单函数版完全一致 —— 多函数的复杂度不在调用,而在 messages 的累积"""
    try:
        response = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=tools,
            tool_choice=tool_choice,
        )
        return response
    except Exception as e:
        print("Unable to generate ChatCompletion response")
        print(f"Exception: {e}")
        return e


def main():
    # ---------- 准备 messages ----------
    messages = []
    messages.append({
        "role": "system",
        "content": "现在你是一个航班查询助手,将根据用户问题提供答案,"
                   "但是不要假设或猜测传入函数的参数值。如果用户的描述不明确,请要求用户提供必要信息"
    })
    messages.append({
        "role": "user",
        "content": "帮我查询2024年4月2日,郑州到北京的航班的票价"
    })

    # ========== 第一轮:查航班号 ==========
    # 1. 第一次调用模型 —— 模型判断出要先拿航班号
    first_response = chat_completion_request(messages, tools=tools, tool_choice="auto")
    assistant_message1 = first_response.choices[0].message
    print(f'assistant_message1-->{assistant_message1}')

    # 2. 把模型这条 assistant 回复(内含 tool_calls)原样加入 messages
    messages.append(first_response.choices[0].message.model_dump())

    # 3. 按模型给的函数名和参数,在本地真正执行 get_plane_number
    first_function = parse_function_call(model_response=first_response)

    # 4. 把第一次的函数结果作为 role 为 tool 的消息加入 messages
    tool_call = first_response.choices[0].message.tool_calls[0]
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,                       # 与第一张委托单配对
        "content": str(json.dumps(first_function)),         # content 必须是字符串
    })

    # ========== 第二轮:用航班号查票价 ==========
    # 5. 第二次调用模型 —— 模型看到航班号 1123,于是委托第二个函数
    second_response = chat_completion_request(messages, tools=tools, tool_choice="auto")

    # 6. 第二条 assistant 回复同样要回填
    messages.append(second_response.choices[0].message.model_dump())

    # 7. 本地执行 get_ticket_price
    second_function = parse_function_call(model_response=second_response)
    tool2_call = second_response.choices[0].message.tool_calls[0]

    # 8. 第二次的函数结果继续追加为 role 为 tool 的消息
    messages.append({
        "role": "tool",
        "tool_call_id": tool2_call.id,                      # 与第二张委托单配对
        "content": str(json.dumps(second_function)),
    })
    # 此刻 messages 共 6 条:
    # system / user / assistant(委托1) / tool(回执1) / assistant(委托2) / tool(回执2)

    # ========== 第三轮:合成最终答案 ==========
    # 9. 最后一次调用,模型不再返回 tool_calls,而是给出自然语言结果
    last_response = chat_completion_request(messages, tools=tools, tool_choice="auto")
    print(f'last_response--》{last_response.choices[0].message}')


if __name__ == '__main__':
    main()

messages 在这个案例里长到 6 条

序号role内容
system航班查询助手人设,强调「不要假设或猜测传入函数的参数值」
user帮我查询 2024 年 4 月 2 日,郑州到北京的航班的票价
assistant第一张委托单:get_plane_number,参数 start/end/date
tool第一份回执:{"date": "2024-04-02", "number": "1123"}
assistant第二张委托单:get_ticket_price,参数 number=1123、date
tool第二份回执:{"ticket_price": "668"}

最终输出:content='2024年4月2日,郑州到北京的航班号为1123,票价为668元。' role='assistant' tool_calls=None

4.3 数据库查询应用

假设我们要创建一个可以连接数据库查询的聊天机器人。流程和环境同上一节一致,但是在代码设计结构上有所改动,完整代码包含两个部分:

文件职责
sql_function_tools.py主要用来定义查询数据库函数、函数功能描述 tools,以及函数的应用(解析分发)
sql_zhipu.py主逻辑函数,实现模型 Function Call 的应用
本案例的思路转折:参数本身就是一条 SQL 前两个案例里,模型填的参数是「北京」「1123」这种值。这里只有一个函数 ask_database,它的唯一参数 query一整条 SQL 语句——也就是说,SQL 由模型写,执行由 pymysql 做
模型凭什么写得出正确的 SQL?因为我们把建表语句连同中文字段注释一起塞进了参数的 description。对应比喻:顾问手上多了一份「库房清单」,他照着清单开取货单,取货的仍然是助理。

sql_function_tools.py —— 表结构 + 执行 SQL + tools + 解析

目的:① 定义查询数据库的函数;② 描述函数功能 tools;③ 解析模型参数调用函数。文件路径 ./ChatGLM3_FunctionCall/sql/sql_function_tools.py

sql_function_tools.py —— 表结构 + 执行 SQL + tools + 解析
"""
数据库查询应用 —— 工具层:表结构描述 + 执行 SQL 的函数 + tools 描述 + 解析分发
文件路径:./ChatGLM3_FunctionCall/sql/sql_function_tools.py
说明:可直接运行的完整脚本。

本例的精髓:函数只有一个(ask_database),但它的参数是一条 SQL 语句。
把「建表语句」塞进参数的 description 里,模型就知道有哪些表、哪些字段,
从而自己写出正确的 SQL —— 模型负责写 SQL,pymysql 负责执行 SQL。
"""
import os
import json
import pymysql

# ============ 1. 把数据库表结构写成字符串(单表版本)============
database_schema_string = """
CREATE TABLE `emp` (
  `empno` int DEFAULT NULL, --员工编号, 默认为空
  `ename` varchar(50) DEFAULT NULL, --员工姓名, 默认为空
  `job` varchar(50) DEFAULT NULL,--员工工作, 默认为空
  `mgr` int DEFAULT NULL,--员工领导, 默认为空
  `hiredate` date DEFAULT NULL,--员工入职日期, 默认为空
  `sal` int DEFAULT NULL,--员工的月薪, 默认为空
  `comm` int DEFAULT NULL,--员工年终奖, 默认为空
  `deptno` int DEFAULT NULL,--员工部分编号, 默认为空
)"""

# ============ 2. 多表版本:表之间的关系也一并交代清楚 ============
# 注意每个字段后面的中文注释 —— 它们同样是给模型看的,
# 模型靠这些注释把「工资」对应到 sal、把「部门」对应到 deptno。
database_schema_string1 = """
CREATE TABLE `emp` (
  `empno` int DEFAULT NULL, --员工编号, 默认为空
  `ename` varchar(50) DEFAULT NULL, --员工姓名, 默认为空
  `job` varchar(50) DEFAULT NULL,--员工工作, 默认为空
  `mgr` int DEFAULT NULL,--员工领导, 默认为空
  `hiredate` date DEFAULT NULL,--员工入职日期, 默认为空
  `sal` int DEFAULT NULL,--员工的月薪, 默认为空
  `comm` int DEFAULT NULL,--员工年终奖, 默认为空
  `deptno` int DEFAULT NULL,--员工部分编号, 默认为空
);
CREATE TABLE `DEPT` (
  `DEPTNO` int NOT NULL, -- 部门编码, 默认为空
  `DNAME` varchar(14) DEFAULT NULL,--部门名称, 默认为空
  `LOC` varchar(13) DEFAULT NULL,--地点, 默认为空
  PRIMARY KEY (`DEPTNO`)
);
"""


# ============ 3. 真正执行 SQL 的本地函数 ============
def ask_database(query):
    """连接数据库,执行模型生成的 SQL 并返回结果"""
    print("进入函数内部")
    # 3.1 连接 MySQL(账号口令一律走环境变量,不要硬编码在源码里)
    conn = pymysql.connect(
        host=os.environ.get("MYSQL_HOST", "localhost"),
        port=int(os.environ.get("MYSQL_PORT", 3306)),
        user=os.environ.get("MYSQL_USER", "root"),
        password=os.environ["MYSQL_PASSWORD"],          # 从环境变量读取,缺失即报错
        database=os.environ.get("MYSQL_DATABASE", "it_heima"),
        charset='utf8mb4',
    )
    cursor = conn.cursor()      # 3.2 创建游标
    print('开始测试')
    cursor.execute(query)       # 3.3 执行模型写好的 SQL
    result = cursor.fetchall()  # 3.4 取回全部查询结果
    cursor.close()              # 3.5 关闭游标
    conn.close()                # 3.6 关闭连接
    return result


# ============ 4. tools:把表结构塞进参数描述里 ============
tools = [
    {
        "type": "function",
        "function": {
            "name": "ask_database",
            "description": "使用此函数回答业务问题,要求输出是一个SQL查询语句",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        # 这段 f-string 就是本例的核心:
                        # 把建表语句拼进 description,模型才能写出字段正确的 SQL
                        "description": f"SQL查询提取信息以回答用户的问题。"
                                       f"SQL应该使用以下数据库模式编写:{database_schema_string1}"
                                       f"查询应该以纯文本返回,而不是JSON。"
                                       f"查询应该只包含MySQL支持的语法。",
                    }
                },
                "required": ["query"],
            },
        }
    }
]


# ============ 5. 解析模型回复并执行函数 ============
def parse_response(response):
    response_message = response.choices[0].message
    if response_message.tool_calls:
        available_functions = {
            "ask_database": ask_database
        }
        function_name = response_message.tool_calls[0].function.name
        fuction_to_call = available_functions[function_name]
        function_args = json.loads(response_message.tool_calls[0].function.arguments)
        # 这里的 query 参数,内容就是模型自己写出来的那条 SQL 语句
        function_response = fuction_to_call(
            query=function_args.get("query"),
        )
        return function_response
两处工程加固,已在代码里标注 ① 数据库账号口令不写进 pymysql.connect(),改为从环境变量读取,避免口令进版本库;
tools 里引用的是多表版 database_schema_string1,单表版 database_schema_string 保留作对比——表越多,描述越要写清表间关系,否则模型不会写 JOIN

sql_zhipu.py —— 主逻辑:两次调用模型

文件路径 ./ChatGLM3_FunctionCall/sql/sql_zhipu.py。主逻辑与天气案例完全同构:两次调用模型,中间夹一次本地执行。

sql_zhipu.py —— 主逻辑:两次调用模型
"""
数据库查询应用 —— 主逻辑:两次调用模型,中间夹一次 SQL 执行
文件路径:./ChatGLM3_FunctionCall/sql/sql_zhipu.py
说明:可直接运行的完整脚本。

运行前准备:
    pip install zhipuai python-dotenv pymysql
    在同目录 .env 中写入:
        zhipu_api=你的APIKEY
        MYSQL_PASSWORD=你的数据库密码
"""
import os
import json
from zhipuai import ZhipuAI
from dotenv import load_dotenv, find_dotenv
from sql_function_tools import *      # tools、ask_database、parse_response

_ = load_dotenv(find_dotenv())
zhupu_ak = os.environ['zhipu_api']    # 从环境变量读取 API-KEY
client = ZhipuAI(api_key=zhupu_ak)
ChatGLM = "glm-4"


def chat_completion_request(messages, tools=None, tool_choice=None, model=ChatGLM):
    """模型调用入口:三个案例共用同一套写法"""
    try:
        response = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=tools,
            tool_choice=tool_choice,
        )
        return response
    except Exception as e:
        print("Unable to generate ChatCompletion response")
        print(f"Exception: {e}")
        return e


def main():
    # ---------- 第 1 步:准备 messages ----------
    messages = []
    messages.append({
        "role": "system",
        "content": "通过针对业务数据库生成 SQL 查询来回答用户的问题"
    })
    messages.append({
        "role": "user",
        "content": "查询一下最高工资的员工姓名及对应的工资"
    })

    # ---------- 第 2 步:第一次调用模型 ----------
    # 模型不会去查库,它返回的 arguments 里是一条它写好的 SQL,例如:
    # SELECT ename, sal FROM emp ORDER BY sal DESC LIMIT 1;
    response = chat_completion_request(messages, tools=tools, tool_choice="auto")

    # ---------- 第 3 步:本地用 pymysql 执行这条 SQL ----------
    function_response = parse_response(response)

    # ---------- 第 4 步:回填 assistant 消息 ----------
    assistant_message = response.choices[0].message
    print(f'assistant_message-->{assistant_message}')
    messages.append(assistant_message.model_dump())

    function_name = response.choices[0].message.tool_calls[0].function.name
    function_id = response.choices[0].message.tool_calls[0].id

    # ---------- 第 5 步:把查询结果作为 role 为 tool 的消息加入 ----------
    # 注意:fetchall() 返回的是元组,必须转成字符串才能放进 content
    messages.append({
        "role": "tool",
        "tool_call_id": function_id,
        "name": function_name,
        "content": str(function_response),
    })

    # ---------- 第 6 步:第二次调用模型,把查询结果翻译成人话 ----------
    last_response = chat_completion_request(messages, tools=tools, tool_choice="auto")
    print(f'last_response--》{last_response.choices[0].message}')


if __name__ == '__main__':
    main()

这一轮模型实际写出来的 SQL

模型根据表结构描述自动生成的查询(arguments 里的 query 字段)
-- 用户原话:查询一下最高工资的员工姓名及对应的工资
-- 模型据此写出的 SQL(放在 tool_calls[0].function.arguments 的 query 字段里)
SELECT ename, sal
FROM emp
ORDER BY sal DESC
LIMIT 1;

-- pymysql 执行后 cursor.fetchall() 返回:(('KING', 5000),)
-- 这份结果转成字符串,作为 role 为 tool 的消息回填进 messages

执行后 fetchall() 返回 (('KING', 5000),),转成字符串放进 roletool 的消息,第二次调用模型得到:

content='根据您的查询,我已经为您找到了工资最高的员工。这位员工的姓名是KING,他的工资是5000元。' role='assistant' tool_calls=None

4.4 三个案例的结构对比

案例函数个数调用模型次数参数里装的是什么
天气12 次一个城市名 location
航班2(有依赖)3 次出发地/目的地/日期,第二轮是航班号
数据库12 次一整条 SQL 语句

三个案例的骨架完全一样:调模型 → 看有没有 tool_calls → 本地执行 → 回填 assistant 和 tool → 再调模型。变化的只是函数个数、参数形态和轮数。

05骨架模板:拿去改就能用

通用版(注册表 + 自动多轮)与数据库版(含只读安全校验)

5.1 通用骨架(推荐拿这份)

把天气、航班、数据库三个案例的共性抽出来,就是它;三个案例都只是它的特例。与前面逐个写死的实现相比,它做了三件事:

改进为什么
available_functions 注册表加新工具只动一个字典 + 一段 tools,不再写 if name == "xxx" 的硬编码分发
while 自动多轮前面的实现把「两轮/三轮」写死了;模板循环到模型不再返回 tool_calls 为止,函数依赖再深也能跑完
循环内批量执行模型一次返回多个 tool_calls 时(并行调用)逐个执行并逐条回填,不会漏掉
fc_skeleton.py —— 通用骨架:注册表 + 自动多轮,只改 TODO 处可复用模板
# -*- coding: utf-8 -*-
"""
Function Call 可复用骨架模板(通用版)
==========================================
把这个文件整个复制到你自己的项目里,只改 TODO(拓展点) 标注的地方就能跑。
三个案例(天气 / 航班 / 数据库)都是这个骨架的特例。

设计要点:
  1. available_functions 是「函数注册表」——加新工具只动这一个字典和 tools 列表;
  2. run_conversation 用 while 循环自动多轮,直到模型不再返回 tool_calls;
     (天气案例是 2 轮,航班案例是 3 轮,这里不写死轮数)
  3. 与 OpenAI 风格 SDK 完全兼容:client.chat.completions.create(tools=..., tool_choice="auto")。
"""

import json
import os

# ============================================================
# 一、client 初始化:两种写法二选一,只差 import 和 base_url
# ============================================================
# TODO(拓展点 1):换模型 / 换厂商,只改这一段

# 写法 A —— 智谱官方 SDK(本讲示例用的就是这个)
from zhipuai import ZhipuAI

client = ZhipuAI(api_key=os.environ["ZHIPUAI_API_KEY"])
MODEL = "glm-4"

# 写法 B —— openai 包(同一套代码可对接 OpenAI / DeepSeek / Qwen / 本地 vLLM)
# from openai import OpenAI
# client = OpenAI(
#     api_key=os.environ["OPENAI_API_KEY"],
#     base_url="https://open.bigmodel.cn/api/paas/v4/",   # 换成你的厂商地址即可
# )
# MODEL = "glm-4"
#
# 常见 base_url 参考:
#   OpenAI      https://api.openai.com/v1
#   智谱         https://open.bigmodel.cn/api/paas/v4/
#   DeepSeek    https://api.deepseek.com
#   本地 vLLM    http://127.0.0.1:8000/v1
# 两种写法下面的业务代码一个字都不用改——tool_calls 的字段结构是一样的。


# ============================================================
# 二、你的真实函数:模型永远不执行它们,执行的是这里
# ============================================================
# TODO(拓展点 2):把下面两个示例函数换成你自己的业务函数
#   规则:参数名要和 tools 描述里的 properties 完全一致;
#         返回值建议是 str / dict,方便统一 json.dumps。

def my_function_a(param1, param2="默认值"):
    """示例函数 A:换成你的——查数据库、调接口、读文件、发消息都行。"""
    # ... 你的业务逻辑 ...
    return {"param1": param1, "param2": param2, "result": "这里换成真实结果"}


def my_function_b(param1):
    """示例函数 B:用来演示「多函数」和「函数间依赖」。"""
    return {"input": param1, "result": "第二个函数的结果"}


# ============================================================
# 三、函数注册表:加新工具时,这里加一行
# ============================================================
# TODO(拓展点 3):注册你的函数(键必须与 tools 里的 name 一致)
available_functions = {
    "my_function_a": my_function_a,
    "my_function_b": my_function_b,
}


# ============================================================
# 四、tools 描述:告诉模型有什么可用、参数怎么填
# ============================================================
# TODO(拓展点 4):照着抄一份就是一个新工具
#   description 写清楚「什么时候该用它」,模型靠它决定用不用;
#   required 里的参数是必填,模型缺了会反问用户。
tools = [
    {
        "type": "function",
        "function": {
            "name": "my_function_a",                      # 必须与 available_functions 的键一致
            "description": "这个函数是干什么的、什么情况下调用它",
            "parameters": {
                "type": "object",
                "properties": {
                    "param1": {
                        "type": "string",
                        "description": "参数含义,写清楚格式和示例,例如:城市名,如 北京",
                    },
                    "param2": {
                        "type": "string",
                        "description": "可选参数的含义",
                    },
                },
                "required": ["param1"],                   # 必填参数
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "my_function_b",
            "description": "第二个函数的用途说明",
            "parameters": {
                "type": "object",
                "properties": {
                    "param1": {"type": "string", "description": "参数含义"},
                },
                "required": ["param1"],
            },
        },
    },
]


# ============================================================
# 五、解析分发:把模型给的「函数名 + 参数」真正执行掉
# ============================================================
def parse_response(tool_call):
    """执行一次 tool_call,返回字符串形式的结果。

    注意两个坑:
      1. arguments 是【字符串形式的 JSON】,必须 json.loads 才能取值;
      2. 回填给模型的 content 必须是【字符串】,元组/字典要先 dumps 或 str。
    """
    name = tool_call.function.name
    args = json.loads(tool_call.function.arguments or "{}")

    func = available_functions.get(name)
    if func is None:
        return f"错误:未注册的函数 {name}"

    try:
        result = func(**args)
    except Exception as exc:                      # 函数报错也要如实回给模型,别让它瞎猜
        return f"函数 {name} 执行失败:{exc}"

    if isinstance(result, str):
        return result
    return json.dumps(result, ensure_ascii=False)


# ============================================================
# 六、主循环:自动多轮,直到模型不再要求调用工具
# ============================================================
def run_conversation(user_input, system_prompt=None, max_rounds=5):
    """前面的实现是固定两轮/三轮,这里做成 while 循环,函数依赖再深也能跑完。"""
    messages = []
    if system_prompt:
        messages.append({"role": "system", "content": system_prompt})
    messages.append({"role": "user", "content": user_input})

    rounds = 0
    while True:                                   # 自动多轮,直到模型不再要求调用工具
        rounds += 1
        if rounds > max_rounds:                   # max_rounds 是保险丝,防止死循环
            return "已达到最大轮数上限,请检查函数是否陷入循环调用。"

        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=tools,                          # 每一轮都要继续传 tools
            tool_choice="auto",                   # auto = 由模型自己决定用不用工具
        )
        msg = response.choices[0].message

        # 没有 tool_calls,说明模型已经能直接回答,收工
        if not getattr(msg, "tool_calls", None):
            return msg.content

        # 1) 先把带 tool_calls 的 assistant 消息原样回填(漏了这步,下一条 tool 消息会成孤儿)
        messages.append(msg.model_dump())

        # 2) 逐个执行,每个 tool_call 回填一条 role="tool" 的消息
        for tool_call in msg.tool_calls:
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,     # 靠它和上面的调用配对,不能省
                "name": tool_call.function.name,
                "content": parse_response(tool_call),
            })
        # 3) 回到循环顶部,带着函数结果再问一次模型


# ============================================================
# 七、跑起来
# ============================================================
if __name__ == "__main__":
    # TODO(拓展点 5):换成你自己的提问和 system 提示词
    answer = run_conversation(
        user_input="这里换成用户的问题",
        system_prompt="你是一个助手,需要时请调用提供的工具,不要编造数据。",
    )
    print(answer)
✅ 复制后你只需要改这五处 TODO 1 换 client / 模型(ZhipuAI 或 openai + base_url)· TODO 2 换成你自己的函数 · TODO 3 把函数登记进 available_functions · TODO 4 照着写一段 tools 描述 · TODO 5 换提问和 system 提示词。其余代码不用动。

5.2 数据库问答骨架(含只读安全校验)

emp/dept 两张表拿掉后的通用版。结构与 4.3 一致,但多了一层必须有的安全校验:只放行 SELECT、禁多语句、拦写操作关键字、没写 LIMIT 就替它补上。演示代码为了简洁没写这一段,拿去做真项目一定要加

fc_skeleton_db.py —— 数据库问答骨架,含只读安全校验可复用模板
# -*- coding: utf-8 -*-
"""
Function Call 数据库问答骨架(参数即 SQL 版)
==========================================
SQL 案例去掉 emp/dept 表之后的通用版本。
特点:只有一个工具 ask_database,它的唯一参数就是【一整条 SQL】——
      SQL 由模型写,执行仍然由你的代码做。

⚠️ 安全红线:模型写的 SQL 绝不能直接执行。
   本模板内置只读校验(只允许 SELECT、禁止多语句、强制 LIMIT),
   生产环境还必须配一个【只读数据库账号】兜底。
"""

import json
import os
import re

import pymysql

# TODO(拓展点 1):换 client / 换模型
from zhipuai import ZhipuAI

client = ZhipuAI(api_key=os.environ["ZHIPUAI_API_KEY"])
MODEL = "glm-4"
# openai 包写法:
# from openai import OpenAI
# client = OpenAI(api_key=os.environ["OPENAI_API_KEY"],
#                 base_url="https://open.bigmodel.cn/api/paas/v4/")


# ============================================================
# 一、表结构描述:模型全靠它写 SQL
# ============================================================
# TODO(拓展点 2):把这里换成你自己的建表语句
#   两个要点:① 字段一定要带中文注释,模型靠注释把「工资」对应到 sal;
#            ② 多表时必须写明表间关系,否则模型写不出正确的 JOIN。
database_schema_string = """
CREATE TABLE your_table (
    id      INT PRIMARY KEY,        -- 主键
    name    VARCHAR(50),            -- 名称,例如 员工姓名
    amount  DECIMAL(10,2),          -- 金额,例如 月薪
    dept_id INT                     -- 部门编号,关联 dept.id
);

CREATE TABLE dept (
    id    INT PRIMARY KEY,          -- 部门编号
    dname VARCHAR(50)               -- 部门名称
);
-- 表间关系:your_table.dept_id = dept.id
"""


# ============================================================
# 二、执行 SQL 的函数(带安全校验)
# ============================================================
# TODO(拓展点 3):换成你的数据库连接信息(一律走环境变量,别写死口令)
DB_CONF = dict(
    host=os.environ.get("DB_HOST", "127.0.0.1"),
    port=int(os.environ.get("DB_PORT", 3306)),
    user=os.environ["DB_USER"],            # 建议:只读账号
    password=os.environ["DB_PASSWORD"],
    database=os.environ.get("DB_NAME", "your_db"),
    charset="utf8mb4",
)

MAX_ROWS = 200


def _guard(sql):
    """把模型写的 SQL 关进笼子里。缺了这一步,一句 DELETE 就能删库。"""
    s = sql.strip().rstrip(";").strip()
    if ";" in s:
        raise ValueError("拒绝执行:不允许多语句")
    if not re.match(r"(?is)^\s*(select|with)\b", s):
        raise ValueError("拒绝执行:只允许 SELECT 查询")
    if re.search(r"(?i)\b(insert|update|delete|drop|alter|truncate|grant|create)\b", s):
        raise ValueError("拒绝执行:检测到写操作关键字")
    if not re.search(r"(?i)\blimit\b", s):
        s += f" LIMIT {MAX_ROWS}"             # 没写 LIMIT 就替它加上,防止拉全表
    return s


def ask_database(query):
    """执行模型生成的查询,返回字符串形式的结果。"""
    safe_sql = _guard(query)
    conn = pymysql.connect(**DB_CONF)
    try:
        with conn.cursor() as cursor:
            cursor.execute(safe_sql)
            rows = cursor.fetchall()
    finally:
        conn.close()                          # 异常路径也要关,别泄漏连接
    return str(rows)


available_functions = {"ask_database": ask_database}


# ============================================================
# 三、tools 描述:把表结构塞进参数说明里
# ============================================================
tools = [{
    "type": "function",
    "function": {
        "name": "ask_database",
        "description": "用 SQL 查询数据库来回答用户关于业务数据的问题。",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": (
                        "一条合法的 MySQL SELECT 语句,用于回答用户问题。"
                        "必须依据下面的表结构书写,只返回 SQL 本身,不要加解释:\n"
                        f"{database_schema_string}"
                    ),
                },
            },
            "required": ["query"],
        },
    },
}]


# ============================================================
# 四、主流程:两次调用
# ============================================================
def run(user_input):
    messages = [
        {"role": "system", "content": "你是数据分析助手,只依据数据库查询结果回答,不要编造数字。"},
        {"role": "user", "content": user_input},
    ]

    first = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools, tool_choice="auto")
    msg = first.choices[0].message

    if not getattr(msg, "tool_calls", None):
        return msg.content

    messages.append(msg.model_dump())
    for tool_call in msg.tool_calls:
        args = json.loads(tool_call.function.arguments)
        try:
            content = available_functions[tool_call.function.name](**args)
        except Exception as exc:
            content = f"查询失败:{exc}"       # 把拒绝原因如实告诉模型,它会换一种问法
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "name": tool_call.function.name,
            "content": content,
        })

    second = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools, tool_choice="auto")
    return second.choices[0].message.content


if __name__ == "__main__":
    # TODO(拓展点 4):换成你的提问
    print(run("这里换成用户的问题,例如:金额最高的是谁"))
代码校验不是全部,还得配只读账号 _guard() 拦的是文本层面的写操作,绕过的花样总比规则多。真正的兜底是数据库只读账号:即使校验被绕过,数据库这一层也执行不了 DELETE。两层都要有。

5.3 三份模板怎么选

模板适用特点
fc_skeleton_min.py一个函数、一轮就够60 行,用来理解流程,见第 03 节
fc_skeleton.py多个函数、可能有依赖注册表 + while 自动多轮,日常首选
fc_skeleton_db.py让模型写 SQL 查库在通用版基础上加只读校验与 LIMIT 兜底

06易错点汇总

按「概念 / 流程 / 单函数 / 多函数 / 数据库」五类归并,踩过一次就别再踩

⚠️ 一、概念层面

  • 把 Function Call 当成「模型会自己上网」。 它既不会上网,也不会执行代码;它只会写一张单子。联网的是你的 requests,查库的是你的 pymysql
  • 把它和 RAG 混为一谈。 RAG 是先检索再把文档塞进提示词;Function Call 是模型自己决定「要不要调、调哪个、传什么参数」,主动权在模型的判断上。
  • 记错时间点。2023 年 6 月 13 日 由 OpenAI 公布,不是 2022 年,也不是随 GPT-4 首发一起发布的。

⚠️ 二、调用流程与 messages

  • 只调一次模型就想拿到答案。 第一次调用的 contentNone,里面没有天气。必须有第二次调用,模型才会把回执翻译成人话。
  • 直接把 arguments 当字典用。 它是字符串形式的 JSON,必须 json.loads() 之后才能取值。
  • 忘了回填 assistant 消息,或颠倒了它和 tool 消息的顺序。 二者必须成对、按序出现:先 assistant,后 tool。
  • roletoolcontent 传了字典或元组。 只能传字符串,所以天气案例用 json.dumps(),数据库案例用 str()
  • 第二次调用忘了继续传 tools 两次都要传——因为模型可能还需要再发起一轮调用(航班案例就真的发生了)。
  • description 写成「天气」两个字。 模型据此决定调不调、调哪个;描述含糊会导致该调时不调,或多函数场景下选错函数。

⚠️ 三、单函数案例(天气)

  • cityCode_use.json 找不到。 代码里写的是相对路径 ./cityCode_use.json,必须在该文件所在目录下启动脚本,否则 FileNotFoundError
  • 读 JSON 没带 encoding='utf-8' Windows 默认 GBK,中文城市名会直接报解码错误。
  • 城市名对不上。 用户说「海淀」而映射表里只有「北京」,会导致 city_code 为空、返回空字典。模型拿到空回执就只能含糊其辞——空结果也要回传,别让程序静默失败
  • zhipu_api 写死在代码里,或者 .env 里的变量名与 os.environ['zhipu_api'] 不一致,会抛 KeyError
  • 误以为 parse_response 里就完成了对话。 它只负责「执行函数拿结果」,把结果送回模型是主逻辑的事。

⚠️ 四、多函数案例(航班)

  • 只写了一轮就想拿票价。 少一轮,模型手里就没有航班号,要么反问你,要么编一个号码。有几层函数依赖,就有几轮「调用—执行—回填」。
  • 两次的 tool_call_id 用混。 第二份回执必须配 second_response 里的 tool_calls[0].id,复制粘贴第一份的 id 会直接报错。
  • parse_function_call 用了两个独立 if 而不是字典派发。 函数名互斥时逻辑没问题,但多函数扩展时建议改成注册表,避免漏判。
  • plane_number[start][end] 直接下标取值。 用户问「郑州到深圳」时字典里没有这条线路,会抛 KeyError。真实项目要用 .get() 并返回「查无此航线」,让模型把这个结果如实转述给用户。
  • 忘了把 date 透传。 第一个函数若不返回 date,模型第二轮就得靠猜——这正是 system 提示词禁止的行为。

⚠️ 五、数据库案例

  • 把模型生成的 SQL 直接执行而不做任何限制。 模型可能写出 DELETEDROP,或拉取整表。生产环境务必:只读账号 + 白名单校验(只允许 SELECT)+ 强制 LIMIT + 超时。这是本案例最大的安全坑。
  • 表结构描述里漏了中文注释。 模型靠 --员工的月薪 这类注释把「工资」对应到 sal;漏了注释,字段选错概率显著上升。
  • 多表时不说明关系。 emp.deptnoDEPT.DEPTNO 的关联若没写明,模型写不出正确的 JOIN
  • content 传了元组。 cursor.fetchall() 返回元组,必须 str()json.dumps() 成字符串。
  • 连接没关。 cursor.close()conn.close() 不能省;异常路径建议用 try/finally 或上下文管理器,否则连接泄漏。
  • 把数据库口令写死在源码里。 与 API-KEY 同理,一律走环境变量。

07自测题

点击题目展开答案;能把这 15 题说清楚,这一讲就通了

一、概念
什么是 Function Call?是哪一天由谁公布的?

在语言模型中集成外部功能或 API 的调用能力,使模型可以在生成文本的过程中调用外部函数或服务,以获取额外数据或执行特定任务。由 OpenAI 于 2023 年 6 月 13 日 公布。

Function Call 主要解决大模型的哪三类问题?各举一例。

信息实时性(最新新闻、实时股价)、数据局限性(医学、法律等专业领域,或公司内部数据库)、功能扩展性(复杂计算、数据分析等模型没有内置的能力)。

用户问「你好」,模型也会触发 Function Call 吗?

不会。流程第一步就是模型判断「是否需要调用外部信息」,判断为「否」时直接生成文本回复并结束,不会产生 tool_calls。用比喻说:顾问自己就能答的问题,不必写委托单。

Function Call 和 RAG 的本质区别在哪?

发起方不同。RAG 由你的程序先检索、再把文档塞进提示词;Function Call 由模型自己判断要不要调、调哪个、传什么参数,拿回的是函数执行的真实结果而不是相似文本。

二、原理与 messages
大模型能够直接运行 function 吗?

不会调用函数,仅返回函数的参数。开发者利用模型输出的参数在应用中调用函数。

第一次调用模型后,怎么判断该不该执行本地函数?

检查 response.choices[0].message.tool_calls 是否存在。有值说明模型要求调用函数(此时 finish_reason'tool_calls'contentNone);为空则说明模型直接用文本回答了。

tool_call_id 有什么用?漏了会怎样?

它把「函数执行结果」与「模型发出的那次工具调用」配对,取自 tool_calls[0].id。漏了或对不上,模型无法确定这份回执属于哪次调用,接口会报错。

description 写成「天气」两个字,会有什么后果?

模型据此判断该不该用这个函数。描述过于含糊时,模型可能在该调用时不调用(直接瞎编天气),也可能在多函数场景下调错函数。描述要写清用途、输入、产出,例如「获取给定位置的当前天气」。

tool_choice="auto" 是什么意思?

把「要不要调用工具」的决定权交给模型:它可以选择直接用文本回答,也可以选择返回 tool_calls。这与流程图第一个判断框「是否需要调用外部信息」是同一件事。

三、三个案例
Function Call 单一函数应用的完整流程是怎样的?

定义函数:函数可以是真实的外部 API 或工具,也可以是模拟函数。
提供函数定义给模型:将定义好的函数作为参数(tools)提供给模型。
模型生成函数调用 JSON:包含要调用的函数名称和参数值。
后端系统执行函数:后端系统根据 JSON 中的内容,调用相应的函数得到结果。
将函数结果返回给模型:函数返回结果给模型,作为模型的额外上下文信息。
模型生成最终响应:模型综合原始查询、函数调用结果等信息,生成最终输出。

天气函数为什么要返回字符串而不是字典?

因为这个返回值要放进 messagesroletoolcontent 字段,该字段只接受字符串。所以用 json.dumps(weather_info, ensure_ascii=False) 序列化,并保留中文原文。

如果用户问「今天天气怎么样」却没说城市,会发生什么?

locationrequired 里,而 system 提示词又要求「问题具有不确定性时,不要自己编造内容,提示用户明确输入」。因此模型通常不会返回 tool_calls,而是直接用文本反问用户是哪个城市。

为什么航班案例调用了三次模型,而天气、数据库案例只调两次?

因为航班案例存在函数依赖:get_ticket_price 需要 get_plane_number 的输出。每解决一层依赖就要「调模型 → 执行函数 → 回填结果」一轮,两层依赖两轮,最后再调一次把结果合成自然语言,共三次。天气和数据库只有一个不依赖别人的函数,一轮就够。

如果把航班的两个函数合并成一个「查票价(含航班号查询)」,还需要多轮吗?

不需要,模型一轮就能委托完,内部两步由你的函数自己串。这是工程取舍:函数粒度细则模型编排灵活、可复用,但轮次多、token 成本高;函数粒度粗则调用次数少、更快,但灵活性差。

数据库案例里,SQL 是谁写的?又是谁执行的?用户说「把张三的工资改成 9999」该怎么防?

SQL 由模型写(出现在 tool_calls[0].function.argumentsquery 字段里),由开发者的 ask_database 函数用 pymysql 执行——铁律在这里依然成立。防护要做在你的函数里,不能指望提示词:使用只读数据库账号、校验语句必须以 SELECT 开头、拒绝多语句、追加 LIMIT 上限、设置查询超时。

术语表

术语含义
Function Call在语言模型中集成外部功能或 API 的调用能力;模型只产出调用意图,不执行
tools函数描述清单(JSON Schema 数组),告诉模型有哪些函数可用、参数是什么
tool_calls模型返回的调用意图,含 id、函数 name 和字符串形式的 arguments
tool_choice调用策略;"auto" 表示由模型自行决定是否调用工具
messages对话消息列表,Function Call 的全部状态都在这里,只追加不修改
role消息角色:system / user / assistant / tool
tool_call_id把函数执行结果与对应的那次工具调用配对的标识
JSON Schema描述 JSON 数据结构的规范,toolsparameters 就用它书写
finish_reason本次生成的结束原因;'tool_calls' 表示模型因要调用工具而停下
✅ 一句话收束本讲 Function Call 没有魔法:它只是让模型学会填一张结构化的委托单,而把「跑腿」这件事牢牢留在你的代码里。看懂 messages 怎么一条条长大,这一讲就通了。