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. 一次调用到底发生了什么
以查订单为例,请求大致会这样走:
用户问题
-> 应用把工具说明发给模型
-> 模型选择工具并生成参数
-> 应用解析 JSON,校验 schema、业务和权限
-> 应用执行真实函数
-> 应用把结果带着 tool_call_id 回传
-> 模型根据结果回答或继续请求工具模型负责根据工具说明提出请求,并在拿到结果后组织回答。订单查询、退款、写数据库等动作都在你的服务端完成。多轮调用的完整循环见 工具调用循环(Tool Loop),本文先把工具如何描述、模型会返回什么、代码如何接住讲清楚。
2. 工具定义:给模型一张能看懂的菜单
一个函数工具至少包含名称、描述和参数 schema:
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 更容易让模型选对工具。名称应表达一个清楚的动作,避免把查询、修改和发送通知塞进同一个“万能工具”。工具边界越大,模型越难判断,服务端也越难做权限和审计。
描述要写触发条件和边界
天气工具如果只写成下面这样,模型很难判断什么时候该用它:
天气工具查询指定城市的当前天气,返回气温、天气状况和降水信息。
用户询问某地现在的天气、气温或是否需要带伞时调用。
只支持中国大陆城市;不提供历史天气,也不负责根据天气替用户订票。第二段同时交代了能力、触发时机和边界,模型才有机会区分“查天气”和“写一段旅行计划”。
“只要有问题就调用”这类话不要写进 description。用户只说“你好”时,应用通常应该让模型直接回答,不必为了调用工具硬猜一个城市。
3. 参数 schema:把可猜的东西变成可检查的东西
JSON Schema 常用的几个字段如下:
| 字段 | 作用 | 例子 |
|---|---|---|
type | 限定数据类型 | string、integer、boolean |
properties | 声明参数及含义 | order_id、amount |
required | 指定必填字段 | order_id |
enum | 限定有限取值 | pending、shipped |
additionalProperties | 是否允许额外字段 | 严格场景通常设为 false |
例如退款申请可以这样定义:
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 一起发给模型:
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 消息:
{
"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 消息追加:
{
"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 | 至少调用一个工具 | 必须先拿实时数据的固定流程 |
| 指定函数 | 强制调用某个函数 | 已由业务路由器确定下一步 |
例如,下面的请求会强制模型调用订单工具,即使用户只说“你好”:
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。一个只读的订单查询,至少要经过格式、业务和安全这几层检查:
格式检查
JSON 能否解析?字段类型、必填项、enum 是否符合 schema?
业务检查
订单是否存在?金额是否在范围内?状态是否允许这一步?
安全检查
当前用户能否访问这个订单?是否需要人工确认?是否有幂等键和审计记录?下面用 jsonschema 做一次真正的 schema 校验。运行示例前先安装依赖:
python -m pip install jsonschema校验器直接复用第 2 节发给模型的参数 schema,不再手写另一套字段规则:
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 后就省略。
接下来再用白名单把模型给出的工具名映射到代码。示例中只有一个工具:
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 堆栈、数据库地址或访问令牌原样塞回模型。返回可行动、可分类的错误:
{
"ok": false,
"error": "order_service_timeout",
"message": "订单服务暂时没有响应,请稍后重试。",
"retryable": true
}模型可以据此选择有限重试、换一个只读工具,或向用户说明暂时无法完成。以下情况要区分开:
- 参数错误:模型可以修正,但服务端仍要限制重试次数。
- 权限不足:不能让模型换一个订单号继续猜,应直接拒绝或转人工。
- 下游超时:不代表动作没有执行。对退款和发信要先查询状态,再决定是否继续。
- 业务拒绝:例如订单已退款,应该如实回传,不能把拒绝改写成成功。
工具结果最终会进入模型上下文,因此也要把外部文本当作不可信数据。网页、工单备注和用户填写的退款原因里可能出现“忽略前面的规则”之类的文字;它们是数据,不是新的系统指令。
8. 工具数量和粒度怎么取舍
工具不是越多越好。每增加一个工具,模型就多一个选择,提示词 token、参数冲突和权限组合也会增加。可以按下面的原则拆分:
- 查询和修改分开,例如
query_order与create_refund_request不合并。 - 低风险读操作和高风险写操作分开,确认点更清楚。
- 输入输出稳定、能独立测试的能力适合做工具。
- 只在本应用内部调用、没有跨边界复用价值的小函数,不必为了“像 Agent”强行包装成协议工具。
如果工具已经多到模型经常选错,可以先用规则或轻量路由器缩小候选集合,再把少量相关工具发给模型。路由器也要纳入评测,不能用“少给工具”掩盖漏调用。