大模型 Function Calling 实战:让 AI 真正调用你的 Python 函数

Function Calling 到底解决什么问题

很多团队在接入大模型时都会遇到一个共同的瓶颈:模型本身的能力很强,但它被关在一个”语言世界”里。你问它今天的天气,它不会去查实时数据;你让它算一个复杂的财务模型,它可能在脑子里”猜”出一个看起来合理但完全错误的数字。模型的训练数据有截止日期,它也没有权限访问你的数据库、调用你的 API 或者执行任何系统操作。

大模型 Function Calling 实战:让 AI 真正调用你的 Python 函数

Function Calling(函数调用)就是为了打破这个边界。它的核心思路并不复杂:你告诉模型有哪些函数可以用,模型在对话中判断”这个问题需要调函数才能回答”,然后输出函数名和参数,你在代码里执行这个函数,再把结果喂回给模型。模型拿到真实数据后,生成最终的自然语言回答。

听起来简单,但真正在项目里落地的时候,踩坑的地方远比想象中多。模型”决定调用什么函数”这一步本身就充满了不确定性,参数提取可能出错,多轮对话中函数调用的状态管理也很容易混乱。这篇文章不讲 API 基础用法,而是聚焦在工程实践中真正容易出问题的环节。

模型是怎么”决定”调用函数的

很多人把 Function Calling 理解成”模型学会了写代码”,这其实是个误区。模型并没有在你的机器上执行任何东西。整个机制本质上是两次 API 调用之间的协作:第一次调用,模型根据用户输入和工具描述,决定是否需要调用函数以及调用哪个;第二次调用,你把函数执行结果以特定格式追加到对话历史中,模型再据此生成最终回答。

模型做出”要不要调函数”这个判断,依赖的是你在 tools 参数里提供的函数描述。这段描述用的是 JSON Schema 格式,但真正影响模型决策的不是 Schema 本身的结构,而是 description 字段写得够不够清楚。很多团队把 description 写成函数名一样的简短标签,比如 “查询订单”,模型在遇到模糊问题时就很容易选错工具或者漏调。

一个更有效的写法是把函数的用途、适用场景、不适用场景都交代清楚。比如同一个订单查询,写成”根据订单号查询订单状态和物流信息,适用于用户询问订单进度、发货情况时调用。注意:如果用户只是想下单或修改订单,不应该使用此函数”,模型在边界场景下的准确率会明显高很多。

从零搭建一个可用的 Function Calling 流程

下面用一个相对完整的例子来展示整个流程。场景很常见:一个内部助手,用户可以用自然语言查询订单状态和库存信息。我们用 OpenAI 兼容接口来演示,但同样的结构在通义千问、文心一言等国产大模型上也基本通用。

定义工具函数和 JSON Schema

import json
import inspect

# 先定义两个真实的 Python 函数
def get_order_status(order_id: str) -> dict:
    """根据订单号查询订单状态和物流信息。
    适用场景:用户询问某个订单的发货状态、物流进度、预计到达时间。
    不适用场景:用户想创建新订单、修改收货地址或取消订单。
    """
    # 实际项目中这里调用订单服务 API
    mock_orders = {
        "A1001": {"status": "已发货", "courier": "顺丰", "eta": "2026-08-12"},
        "A1002": {"status": "待发货", "courier": None, "eta": "2026-08-11"},
    }
    return mock_orders.get(order_id, {"error": f"未找到订单 {order_id}"})

def check_inventory(sku: str, warehouse: str = "default") -> dict:
    """查询指定商品 SKU 在指定仓库的库存数量。
    适用场景:用户询问某个商品是否有货、库存余量。
    """
    mock_stock = {"SKU001": 128, "SKU002": 0, "SKU003": 56}
    qty = mock_stock.get(sku, 0)
    return {"sku": sku, "warehouse": warehouse, "quantity": qty}

# 把函数注册到一个字典里,方便后续动态分发
AVAILABLE_FUNCTIONS = {
    "get_order_status": get_order_status,
    "check_inventory": check_inventory,
}

# 定义工具描述(JSON Schema 格式)
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_order_status",
            "description": (
                "根据订单号查询订单状态和物流信息。"
                "适用于用户询问订单进度、发货情况、预计到达时间。"
                "如果用户想创建订单或修改地址,不要使用此函数。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "订单编号,例如 A1001、A1002"
                    }
                },
                "required": ["order_id"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "check_inventory",
            "description": "查询指定商品在仓库中的库存数量,适用于用户询问商品是否有货。",
            "parameters": {
                "type": "object",
                "properties": {
                    "sku": {
                        "type": "string",
                        "description": "商品SKU编号,例如 SKU001"
                    },
                    "warehouse": {
                        "type": "string",
                        "description": "仓库名称,默认为 default"
                    }
                },
                "required": ["sku"]
            }
        }
    }
]

这段代码的关键不在 Python 函数本身——那只是普通的业务逻辑。真正需要认真对待的是 tools 列表里的描述信息。description 字段决定了模型什么时候选择这个工具,parameters 里的 description 决定了模型能不能正确提取参数。把这两块写好,比调任何 prompt 技巧都管用。

处理模型的函数调用请求

from openai import OpenAI

client = OpenAI(api_key="your_api_key")

def chat_with_tools(user_message: str, messages: list = None) -> str:
    if messages is None:
        messages = []
    messages.append({"role": "user", "content": user_message})

    # 第一次调用:模型决定是否需要调用函数
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=tools,
        tool_choice="auto"
    )

    msg = response.choices[0].message
    messages.append(msg)

    # 如果模型决定调用函数
    if msg.tool_calls:
        for tool_call in msg.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)

            # 从注册表里拿到真实函数并执行
            func = AVAILABLE_FUNCTIONS.get(func_name)
            if func is None:
                result = {"error": f"未知函数: {func_name}"}
            else:
                try:
                    result = func(**func_args)
                except Exception as e:
                    result = {"error": f"函数执行失败: {str(e)}"}

            # 把执行结果追加到对话历史
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": json.dumps(result, ensure_ascii=False)
            })

        # 第二次调用:模型根据函数结果生成最终回答
        final_response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools
        )
        return final_response.choices[0].message.content

    # 如果模型没有调用函数,直接返回文本回答
    return msg.content

这个流程的结构是标准的:先让模型判断,再执行函数,最后让模型整合结果。但这里有几个工程细节值得注意:tool_call_id 必须和模型返回的 ID 严格对应,漏掉或者写错会导致 API 报错;func_argsjson.loads 解析时如果模型返回的 JSON 格式有问题(偶尔会发生),你需要做好异常处理。

生产环境中真正容易踩的坑

上面的代码能跑通,但放到生产环境里会暴露出一堆问题。下面这些坑我在实际项目里基本都遇到过。

坑一:模型”幻觉”出不存在的参数

模型在提取参数时,有时候会”发挥创造力”。你定义了一个 order_id 参数,类型是 string,但模型偶尔会返回带额外字段的 JSON,或者把枚举值搞错。比如你的仓库参数只有 “default” 和 “north”,模型可能返回 “south”。

解决办法是在执行函数之前加一层参数校验,不要无条件信任模型的输出。用 Pydantic 来做这件事是个不错的选择:

from pydantic import BaseModel, ValidationError, field_validator

class OrderQuery(BaseModel):
    order_id: str

    @field_validator("order_id")
    @classmethod
    def validate_order_id(cls, v: str) -> str:
        if not v.startswith("A"):
            raise ValueError("订单号格式不正确,应以 A 开头")
        return v

# 在执行函数前校验
try:
    validated = OrderQuery(**func_args)
    result = func(**validated.model_dump())
except ValidationError as e:
    result = {"error": f"参数校验失败: {e}"}

这层校验看起来多余,但在高频调用场景下能挡住大量异常情况。模型不是确定性的,同样的输入在不同时间可能返回略有不同的参数结构,校验层是你最后的防线。

坑二:多轮对话中函数调用历史的管理

在连续对话中,对话历史 messages 会越来越长。如果中间有函数调用的记录,你需要特别注意:模型在后续轮次中可能会”参考”之前函数调用的结果,即使当前问题已经不需要那个函数了。这在某些场景下是好事(模型记住了上下文),在另一些场景下会导致混乱。

一个比较实用的策略是对对话历史做滑动窗口管理:保留最近 N 轮的完整记录,更早的历史只保留文本摘要。但要小心,不要把 tool 调用记录从中间截断——如果你删掉了 tool_calls 消息但保留了对应的 tool 结果消息,API 会直接报错。必须成对删除或者全部保留。

坑三:函数超时和异常没有兜底

模型决定调函数,但函数本身可能因为网络问题、数据库连接超时等原因执行失败。如果你不处理异常直接抛出去,整个对话就断了。更好的做法是捕获异常后把错误信息以结构化格式返回给模型,让模型自己决定是告诉用户”出错了”还是换个方式重试。

手动管理 vs 框架托管:怎么选

上面的代码是手动管理 Function Calling 的完整流程,适合理解原理和简单场景。但在实际项目中,尤其是需要支持多轮、多工具、多步骤 Agent 的场景下,手动管理的复杂度会迅速膨胀。这时候用框架来托管是更合理的选择。

方案 适用场景 优点 缺点
手动管理(原生 API) 工具数量少(1-3 个),调用逻辑简单 完全可控,调试方便,无额外依赖 多轮状态管理、并发调用、重试逻辑需自己写
LangChain / LangGraph 多工具编排、多步推理、Agent 场景 生态成熟,工具链丰富,支持复杂流程编排 抽象层较厚,调试困难,版本迭代快导致 API 不稳定
OpenAI Assistants API 快速搭建对话型助手,不想自己维护对话状态 托管状态管理,内置 code interpreter 和文件检索 绑定 OpenAI 平台,灵活性受限,调试依赖日志
自研轻量框架 团队有特定需求,不想被框架约束 定制化强,可针对业务优化 开发成本高,需要团队持续投入维护

我的建议是:如果你的场景是”用户问一个问题,调一个函数,返回结果”这种简单模式,手动管理就够了,别引入框架。一旦你需要支持多步骤任务(比如”帮我查一下今天的订单,如果有异常的帮我标记一下,再通知相关负责人”),就需要一个能管理任务状态的 Agent 框架,这时候 LangGraph 或者自研的状态机方案更合适。

让 description 真正发挥作用的几个技巧

前面反复提到 description 的重要性,这里展开讲一些实践技巧。这些不是理论推导,而是在大量调试中总结出来的经验。

  • 用否定语句标注边界:与其只说”这个函数做什么”,不如加上”什么时候不该用”。模型在模糊场景下的选择能力会明显改善。
  • 给参数写真实示例:在参数 description 里写上 “例如 A1001” 这样的示例值,模型提取参数的格式准确率会高很多。
  • 函数名要自解释:get_order_status 比 query_data 好,get 比 fetch 好,原因不是风格问题,而是模型在训练数据里见过的命名模式更倾向这种风格。
  • 避免功能重叠的函数:如果你有两个函数功能很像(比如”查订单”和”查订单详情”),模型选错的概率会显著上升。能合并就合并,不能合并就在 description 里写清楚区分条件。

一个容易被忽略的问题:函数执行结果怎么写

大部分教程只关注怎么让模型调函数,很少有人讲函数返回值应该长什么样。这个问题其实很重要——模型后续的回答质量直接取决于你喂给它的结果格式。

一个常见错误是把整个 ORM 对象或者嵌套很深的字典直接 json.dumps 扔回去。模型要在一堆无关字段里找关键信息,回答质量自然打折扣。更好的做法是在函数返回阶段就做一层筛选,只返回模型回答用户问题真正需要的字段。

比如查订单状态,你的内部 API 可能返回了几十个字段(创建时间、修改时间、操作人 ID、内部流水号等),但模型需要的可能只是状态、快递公司和预计到达时间。你在函数里做好裁剪,模型的回答会更精准,token 消耗也更少。

成本控制:别让 Function Calling 变成烧钱机器

Function Calling 的成本和普通对话不同,因为它涉及多轮 API 调用。一次用户提问可能触发两次 API 请求(模型判断 → 执行函数 → 模型整合),如果工具列表很长,每次请求的 tools 参数本身也会消耗大量 token。

实际项目中可以通过以下方式控制成本:

  1. 动态加载工具:不要把所有函数都塞给模型。根据对话上下文只加载相关的 2-3 个工具,减少 token 消耗和模型的选择压力。
  2. 设置 token 上限:给每次 API 调用设置 max_tokens,避免模型在函数调用失败后进入冗长的重试循环。
  3. 对话历史裁剪:前面提到的滑动窗口策略,除了防止状态混乱,还能有效控制每次请求的 token 数量。

Function Calling 和传统 Web 服务有什么根本区别

从软件架构的角度看,Function Calling 引入了一个很有意思的变化:传统 Web 服务里,调用哪个函数、传什么参数是由前端或者调用方显式决定的;而在 Function Calling 模式下,这个决策被交给了一个概率模型。

这意味着你的函数接口设计不再只是给程序员看的——它同时要给模型看。API 设计的关注点从”接口是否优雅”变成了”模型能不能正确理解和选择”。一个在传统意义上设计得很好的 REST API,如果 description 写得差,在 Function Calling 场景下表现可能很糟糕。

这种”模型即调用方”的模式对接口设计提出了新要求:参数越少越好,语义越明确越好,枚举值越可控越好。复杂的多参数嵌套结构虽然技术上能跑,但模型的参数提取准确率会随复杂度下降。

实践建议

如果你准备在项目里引入 Function Calling,下面这几条建议可能会帮你少走弯路:

  • 先用手动方式跑通一个最小场景,理解完整流程后再考虑框架。跳过理解阶段直接上框架,出问题时排查成本极高。
  • 把函数 description 当成代码的一部分来维护,纳入 code review 流程。description 的改动对模型行为的影响比代码改动更大。
  • 做好函数调用日志:记录模型选择了哪个函数、传了什么参数、执行结果是什么。这些日志在排查”模型为什么选错工具”时是唯一的线索。
  • 给每个函数调用加超时和重试机制,但重试次数要有限制——模型决定调函数不代表函数一定能成功执行,不要让重试变成死循环。
  • 如果是面向用户的产品,考虑在函数调用失败时给模型一个 fallback 话术,比如”抱歉,查询服务暂时不可用,请稍后再试”,而不是直接把 JSON 错误信息暴露给用户。

Function Calling 的价值不在于它能让模型”做更多事”,而在于它让模型和你的业务系统之间有了一个结构化的接口。把这个接口设计好、管好,模型才能真正在你的业务里发挥作用,而不是停留在 Demo 阶段。

原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/299/

(0)
上一篇 1天前
下一篇 1天前

相关推荐