Skip to content

Function Calling:让模型提出可执行的工具请求 ​

用户说“帮我查一下订单,如果符合条件就退款”,模型不能直接访问订单库,也不应该直接扣款。它能做的是:从你提供的工具中选一个,填出一份结构化请求;你的程序再检查这份请求,决定是否真的执行。

可以把它想成小朋友请家长帮忙买东西:小朋友说“买两盒牛奶”,家长还要确认数量、预算和商店,最后由家长刷卡。Function Calling 做的只是把这句话整理成机器能读的请求,数据库权限和扣款责任仍在应用一侧。

本文用 OpenAI Chat Completions 风格的请求和响应说明字段。Responses API、其他模型服务商或兼容层的外层字段可能不同,但工程边界相同:模型提出意图,服务端校验并执行。如果工具来自 MCP Server,Host 通常会把 MCP 的 Tool 定义转换成这里介绍的 Function Calling 形式,再把模型返回的调用转成 tools/call;两者的分工见 MCP 和 Function Calling 到底差在哪里。如果还需要规定“这一类任务应该怎样完成、何时验收”,可以再配一份 Agent Skills:用 SKILL.md 封装可复用的任务能力。

1. 一次调用到底发生了什么 ​

以查订单为例,请求大致会这样走:

text
用户问题
  -> 应用把工具说明发给模型
  -> 模型选择工具并生成参数
  -> 应用解析 JSON,校验 schema、业务和权限
  -> 应用执行真实函数
  -> 应用把结果带着 tool_call_id 回传
  -> 模型根据结果回答或继续请求工具

模型负责根据工具说明提出请求,并在拿到结果后组织回答。订单查询、退款、写数据库等动作都在你的服务端完成。多轮调用的完整循环见 工具调用循环(Tool Loop),本文先把工具如何描述、模型会返回什么、代码如何接住讲清楚。

2. 工具定义:给模型一张能看懂的菜单 ​

一个函数工具至少包含名称、描述和参数 schema:

python
query_order_parameters = {
    "type": "object",
    "properties": {
        "order_id": {
            "type": "string",
            "minLength": 1,
            "description": "订单号,例如 ORD-2026-001"
        }
    },
    "required": ["order_id"],
    "additionalProperties": False
}

tools = [
    {
        "type": "function",
        "function": {
            "name": "query_order",
            "description": (
                "查询当前登录用户的订单状态、金额和商品摘要。"
                "当用户询问自己的订单、物流或退款资格时调用。"
                "只读,不会修改订单;必须提供订单号。"
            ),
            "parameters": query_order_parameters
        }
    }
]

这里的 description 是写给模型看的,parameters 是给模型和校验器共同看的。工具实现代码、数据库连接字符串和内部表名都不应该塞进描述里;模型不需要知道你用 PostgreSQL 还是 Redis,它只需要知道“什么时候调用、需要什么参数、会返回什么、不会做什么”。

名称要像动作 ​

query_order、create_refund_request 比 func1、do_it 更容易让模型选对工具。名称应表达一个清楚的动作,避免把查询、修改和发送通知塞进同一个“万能工具”。工具边界越大,模型越难判断,服务端也越难做权限和审计。

描述要写触发条件和边界 ​

天气工具如果只写成下面这样,模型很难判断什么时候该用它:

text
天气工具
text
查询指定城市的当前天气,返回气温、天气状况和降水信息。
用户询问某地现在的天气、气温或是否需要带伞时调用。
只支持中国大陆城市;不提供历史天气,也不负责根据天气替用户订票。

第二段同时交代了能力、触发时机和边界,模型才有机会区分“查天气”和“写一段旅行计划”。

“只要有问题就调用”这类话不要写进 description。用户只说“你好”时,应用通常应该让模型直接回答,不必为了调用工具硬猜一个城市。

3. 参数 schema:把可猜的东西变成可检查的东西 ​

JSON Schema 常用的几个字段如下:

字段作用例子
type限定数据类型string、integer、boolean
properties声明参数及含义order_id、amount
required指定必填字段order_id
enum限定有限取值pending、shipped
additionalProperties是否允许额外字段严格场景通常设为 false

例如退款申请可以这样定义:

python
refund_tool = {
    "type": "function",
    "function": {
        "name": "create_refund_request",
        "description": (
            "为当前登录用户创建退款申请。"
            "只有用户明确提出退款时调用;不会直接完成退款。"
            "金额必须来自订单查询结果,不能由模型自行决定。"
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "要申请退款的订单号"
                },
                "reason": {
                    "type": "string",
                    "description": "用户提供的退款原因"
                },
                "refund_scope": {
                    "type": "string",
                    "enum": ["full", "partial"],
                    "description": "全额退款或部分退款"
                },
                "amount": {
                    "type": "number",
                    "description": "部分退款金额;全额退款时仍由服务端从订单计算"
                }
            },
            "required": ["order_id", "reason", "refund_scope"],
            "additionalProperties": False
        }
    }
}

enum 能减少“待发货”“已寄出”这类后端不认识的自由文本,但它不是业务校验。即使模型传入了合法的 refund_scope,服务端仍要检查订单归属、订单状态、金额上限、退款次数和用户权限。

部分平台支持在函数定义上开启 strict。严格模式可以让模型输出更贴近 schema,但仍要遵守平台对严格 schema 的额外要求,例如补齐必填字段、关闭额外属性,或用可空类型表达“可选”。严格模式解决的是格式问题,解决不了“这个用户是否有权退款”这样的业务问题。

4. 模型返回的不是函数结果,而是一份请求 ​

假设 client 已经初始化,先把用户消息和第 2 节定义的 tools 一起发给模型:

python
messages = [
    {"role": "user", "content": "帮我查一下订单 ORD-2026-001"}
]

response = client.chat.completions.create(
    model="your-model",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

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

这里先把 assistant_message 原样放回 messages。不要只取出函数名和参数,否则下一轮缺少模型自己发出的工具调用消息,tool_call_id 也无法正确配对。

当模型决定查订单时,它通常返回一条带 tool_calls 的 assistant 消息:

json
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_order_7f2",
      "type": "function",
      "function": {
        "name": "query_order",
        "arguments": "{\"order_id\":\"ORD-2026-001\"}"
      }
    }
  ]
}

处理这条消息时,先注意三个字段之间的关系。arguments 通常是 JSON 字符串,要先 json.loads,再做 schema 和业务校验。id 是这次调用的唯一标识,结果回传必须使用同一个 tool_call_id,模型才能把结果配回请求。tool_calls 是数组,一轮可能有两个互不依赖的查询,也可能只有一个,代码不能假设每轮恰好一个调用。

执行结果应该作为 role: tool 消息追加:

json
{
  "role": "tool",
  "tool_call_id": "call_order_7f2",
  "content": "{\"order_id\":\"ORD-2026-001\",\"status\":\"delivered\",\"amount\":128.5}"
}

assistant 的工具调用消息和对应的 tool 消息都要保留在下一轮 messages 中。少了其中任何一条,模型都可能重复调用、无法匹配结果,甚至得到格式错误。

5. tool_choice:什么时候让模型自己选 ​

传入多个工具时,tool_choice 控制本轮的选择范围:

值行为适合的场景
auto模型自行决定是否调用及调用哪个普通问答,默认选择
none禁止调用工具只总结已有结果或只做改写
required至少调用一个工具必须先拿实时数据的固定流程
指定函数强制调用某个函数已由业务路由器确定下一步

例如,下面的请求会强制模型调用订单工具,即使用户只说“你好”:

python
response = client.chat.completions.create(
    model="your-model",
    messages=[{"role": "user", "content": "你好"}],
    tools=tools,
    tool_choice={
        "type": "function",
        "function": {"name": "query_order"}
    }
)

模型可能只能猜一个订单号填进去。这个实验很适合帮助新人理解:强制调用并不会凭空产生用户没有提供的信息,参数依然需要校验,必要时应该先追问用户。

required 也不是“保证答案正确”。它只是保证模型走工具路径,工具返回什么、用户有没有权限、下游是否成功,仍由应用负责。

并行调用不等于并行写入 ​

用户问“北京和上海今天哪个更热”,两个天气查询互不依赖,可以并行执行;用户问“查订单后申请退款”,第二步依赖第一步的订单状态,必须串行。对于发邮件、扣款、改地址等副作用操作,即使模型一次返回多个调用,也应由服务端按风险和依赖关系决定是否串行、是否需要确认。

6. 执行工具前,先把请求拦在服务端 ​

模型返回的参数不能直接传给数据库或外部 API。一个只读的订单查询,至少要经过格式、业务和安全这几层检查:

text
格式检查
  JSON 能否解析?字段类型、必填项、enum 是否符合 schema?

业务检查
  订单是否存在?金额是否在范围内?状态是否允许这一步?

安全检查
  当前用户能否访问这个订单?是否需要人工确认?是否有幂等键和审计记录?

下面用 jsonschema 做一次真正的 schema 校验。运行示例前先安装依赖:

bash
python -m pip install jsonschema

校验器直接复用第 2 节发给模型的参数 schema,不再手写另一套字段规则:

python
import json

from jsonschema import Draft202012Validator


query_order_validator = Draft202012Validator(
    query_order_parameters
)


def execute_query_order(arguments: str, current_user_id: str) -> dict:
    try:
        params = json.loads(arguments)
    except json.JSONDecodeError:
        return {"ok": False, "error": "invalid_json", "retryable": False}

    schema_errors = list(query_order_validator.iter_errors(params))
    if schema_errors:
        return {
            "ok": False,
            "error": "invalid_arguments",
            "details": [error.message for error in schema_errors],
            "retryable": False,
        }

    order_id = params["order_id"].strip()
    order = order_repository.get(order_id)
    if order is None:
        return {"ok": False, "error": "order_not_found", "retryable": False}
    if order.user_id != current_user_id:
        return {"ok": False, "error": "forbidden", "retryable": False}

    return {
        "ok": True,
        "order_id": order.id,
        "status": order.status,
        "amount": order.amount,
}

schema 只负责检查数据形状。参数通过以后,代码仍要查询订单,并验证订单是否属于当前用户;这是业务和权限校验,不能写进 schema 后就省略。

接下来再用白名单把模型给出的工具名映射到代码。示例中只有一个工具:

python
TOOL_HANDLERS = {
    "query_order": execute_query_order,
}


def dispatch_tool_call(tool_call, current_user_id: str) -> dict:
    function_name = tool_call.function.name
    handler = TOOL_HANDLERS.get(function_name)
    if handler is None:
        return {
            "ok": False,
            "error": "unknown_tool",
            "message": f"不支持工具:{function_name}",
            "retryable": False,
        }

    return handler(tool_call.function.arguments, current_user_id)

不要把 function_name 传给 eval()、globals() 或任意反射调用。模型返回的是不可信输入,只有白名单中的名称才能进入实际处理函数。order_repository 需要接入应用的订单数据层,并处理数据库超时、权限上下文、日志脱敏和错误码映射。尤其是退款、发信、建工单这类非幂等动作,必须使用业务幂等键,不能因为模型或网络重试就执行两次。

7. 工具错误应该让模型看到什么 ​

工具失败时,不要把 Python 堆栈、数据库地址或访问令牌原样塞回模型。返回可行动、可分类的错误:

json
{
  "ok": false,
  "error": "order_service_timeout",
  "message": "订单服务暂时没有响应,请稍后重试。",
  "retryable": true
}

模型可以据此选择有限重试、换一个只读工具,或向用户说明暂时无法完成。以下情况要区分开:

  • 参数错误:模型可以修正,但服务端仍要限制重试次数。
  • 权限不足:不能让模型换一个订单号继续猜,应直接拒绝或转人工。
  • 下游超时:不代表动作没有执行。对退款和发信要先查询状态,再决定是否继续。
  • 业务拒绝:例如订单已退款,应该如实回传,不能把拒绝改写成成功。

工具结果最终会进入模型上下文,因此也要把外部文本当作不可信数据。网页、工单备注和用户填写的退款原因里可能出现“忽略前面的规则”之类的文字;它们是数据,不是新的系统指令。

8. 工具数量和粒度怎么取舍 ​

工具不是越多越好。每增加一个工具,模型就多一个选择,提示词 token、参数冲突和权限组合也会增加。可以按下面的原则拆分:

  • 查询和修改分开,例如 query_order 与 create_refund_request 不合并。
  • 低风险读操作和高风险写操作分开,确认点更清楚。
  • 输入输出稳定、能独立测试的能力适合做工具。
  • 只在本应用内部调用、没有跨边界复用价值的小函数,不必为了“像 Agent”强行包装成协议工具。

如果工具已经多到模型经常选错,可以先用规则或轻量路由器缩小候选集合,再把少量相关工具发给模型。路由器也要纳入评测,不能用“少给工具”掩盖漏调用。

持续学习,持续实践。