上一话我们搞懂了两件事:模型只会补全文字;想让它干活,得让它把“想干什么”说成某种格式,再由外面的代码去执行。
但上一话那个办法很脆——用提示词逼模型输出 JSON,模型经常给你残缺的 JSON、在前后多写几句废话、或者干脆忘了格式。于是就有了 Function Calling:它把“让模型提出一次工具调用”这件事,从碰运气的提示词技巧,做成了一套规规矩矩的协议。
这一话就讲这套协议:它长什么样,谁先做出来的,以及为什么它能让模型“说清楚要干什么”。
一、协议要解决的,就是上一话那个“脆”
先看上一话的提示词 hack 是怎么翻车的。你在 system prompt 里写“想查天气就输出 {"tool":"get_weather","city":"..."}”,然后:
- 模型输出的不是纯 JSON,前后还夹着“好的,我帮你查一下”这种话;
- JSON 本身残缺,少个引号、少个逗号,
json.loads直接崩; - 换了个问法,模型就把格式忘得一干二净。
每次翻车,你都得靠更长的提示词、更严的正则去兜,兜到最后还是不可靠。
协议的意义就在这里:别再用文字去“暗示”模型输出格式,而是给一个结构化的位置,让模型把调用填进去。 这个位置是 API 里明确规定好的,模型要么填对,要么 API 直接拒绝,没有“碰运气”的中间地带。
二、协议长什么样:tools 进,tool_calls 出
整个协议,一进一出两件事:
- 请求里传
tools:你告诉模型“现在有哪些工具可以用”。这是一份菜单,写清楚每个工具叫什么、干什么、参数长什么样。 - 响应里收
tool_calls:模型告诉你“我想调哪个工具、带什么参数”。这是一张申请单。
下面是一份声明了本地天气工具的菜单:
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气演示数据",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如北京"}
},
"required": ["city"],
"additionalProperties": False,
},
},
}
]
注意,tools 不是把 Python 函数“上传”给模型。它只是一份描述:函数叫 get_weather、查天气的、需要一个 city 字符串参数。模型拿到的是这些文字,不是那个函数本身。Chat Completions API
把菜单连同问题一起发过去:
assistant_message = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "请查询北京现在的天气。"}],
tools=TOOLS,
tool_choice="required",
).choices[0].message
响应里我们关心的不是自然语言,而是这张申请单:
{
"id": "call_00_...",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
拆开看每个字段:
id:这条调用的唯一标识,回传结果时要靠它关联;type:固定是function,标记这是一次函数调用;function.name:想调哪个工具;function.arguments:一个 JSON 字符串——注意它还是字符串,不是对象,代码里得再json.loads一层才能用。
它表达的就是一句话:“请调用 get_weather,参数是北京。”到这里,模型既没有运行 Python,也没有拿到天气数据。它只是把“想干什么”用协议规定的格式写清楚了。
三、这套协议,是谁先做出来的
它不是我编的,也不是模型厂商某天灵机一动。时间线很清晰:
- 2022 年,ReAct(Yao et al., arXiv 2210.03629)提出“推理 + 行动”交替,用提示词让模型按
Thought / Action的格式输出,再让代码去执行; - 2023 年 2 月,Toolformer(Meta AI, arXiv 2302.04761)证明“模型自己决定何时调 API”这个能力是能被训练出来的;
- 2023 年 5 月,Gorilla(UC Berkeley, arXiv 2305.15334)开始微调模型学写 API 调用。
这几篇走的都是“提示词 + 解析”的路线——也就是上一话讲的那个脆办法。
真正的转折在 2023 年 6 月 13 日:OpenAI 首次发布原生 Function Calling,随 gpt-4-0613 和 gpt-3.5-turbo-0613 两个模型快照一起推出。从这一天起,“让模型提工具调用”不再靠提示词暗示,而是变成了 API 里的标准字段——就是我们第二节看到的 tools 进、tool_calls 出。
四、别看就一个 tool_calls,背后做了很多工作
有个问题自然冒出来:同样是“让模型输出调用”,为什么提示词 hack 天天翻车,而协议一出来,模型就能稳定地输出合法参数、不再给你残缺 JSON?
不是因为提示词写得好了。是厂商在背后做了工作——模型是被训练成“会正确提出调用”的,这个能力不是运气,是训练出来的。
这里只说结论,不展开原理(那是下一话的事):
- 微调:训练阶段喂了大量“工具调用对话”样本,让模型学会判断要不要调、选哪个工具、生成合法参数;
- 约束解码:生成的时候,在输出层卡住模型,让它只能吐出符合 schema 的内容,从根上杜绝“残缺 JSON”。
这两件事把“正确提出调用”从提示词的玄学,变成了模型本身的能力。至于模型内部到底是怎么学会的、这两件事的具体机制——那是第三话要专门讲的问题,这里先按下不表。
五、协议只负责“提出”,不负责“执行”
回到那张申请单。tool_calls 拿回来,协议的部分就结束了。接下来是你的代码接手:
def execute_tool(name: str, arguments_json: str) -> str:
if name != "get_weather":
raise ValueError(f"不允许执行未知工具:{name}")
arguments = json.loads(arguments_json)
if not isinstance(arguments, dict) or set(arguments) != {"city"}:
raise ValueError("get_weather 参数必须且只能包含 city")
city = arguments["city"]
if not isinstance(city, str) or not city.strip():
raise ValueError("city 必须是非空字符串")
return get_weather(city.strip())
模型输出在这里被当成外部输入处理:先校验工具名,再校验参数,然后才真正执行。25℃ 来自 get_weather() 的返回值,不是模型编的:
def get_weather(city: str) -> str:
weather = {
"北京": "多云,25℃,东北风3级",
"上海": "小雨,22℃,东南风2级",
}
return weather.get(city, f"暂无{city}的天气演示数据")
协议让模型“说清楚要干什么”,执行永远在模型外面——这是上一话那条结论的延续,在这里变成了可运行的代码。
六、把结果交回去,也是协议的一部分
函数执行完,Python 手里有了天气文本,但用户还没看到一句话。模型上一次只停在“我想调什么工具”,所以要把两样东西放回消息历史:
messages.append(assistant_message) # 模型提出的调用请求
messages.append(
{
"role": "tool", # 工具执行结果
"tool_call_id": tool_call.id, # 关联到上面那条 tool_calls
"content": result,
}
)
role=tool 这一步不是可选的,是协议强制要求的。这里有个真实的坑:有人把 role=tool 写成了 role=user,API 直接报 HTTP 400——
An assistant message with 'tool_calls' must be followed by
tool messages responding to each 'tool_call_id'.
意思很直白:你给模型看了一张 tool_calls 申请单,就必须逐条配上一份对应的 tool 结果,用 tool_call_id 对上号。少一条、错一条,协议层面就拒绝,根本轮不到模型来猜。这个报错本身,就是“协议不是约定俗成、而是硬约束”的最好证据。
补上结果后再问一次模型,它才能把“多云,25℃”组织成一句人话。事实来自函数,句子由模型生成,两边分得清清楚楚。
七、回到最开始的问题:到底谁调用了工具?
| 角色 | 输入 | 输出 | 不负责什么 |
|---|---|---|---|
| 模型 | 用户问题与 tools 菜单 | tool_calls 申请单 | 不执行本地函数 |
| 你的代码 | 工具名、参数与本地能力 | 校验后的工具结果 | 不应把模型请求原样放行 |
| 模型 | role=tool 结果 | 面向用户的回答 | 不产生工具返回的外部事实 |
Function Calling 没有把函数“装进模型”。它做的,是把上一话那个脆弱的提示词 hack,变成了一套 tools 进、tool_calls 出的标准协议——模型提请求,你的代码执行,模型再说话。
跑通一次,你会看到这三步按顺序发生:
模型请求:get_weather({"city":"北京"})
程序执行:多云,25℃,东北风3级
模型回答:北京现在多云,气温 25℃,东北风 3 级。
配套 Python 与 Java 实现在 GYA 仓库与 GYA-Java 仓库的 c02-function-calling 目录,按 README 的命令跑,不复制这篇文章里零散的代码块。
协议跑通了,但一个更根本的问题还悬着:模型凭什么知道,该从菜单里挑 get_weather、该填北京? 这已经不是协议能回答的了——得回到模型训练本身。下一话。
参考资料:
- DeepSeek, Tool Calls
- DeepSeek, Chat Completions API
- Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models
- Schick et al., Toolformer: Language Models Can Teach Themselves to Use Tools
- Patil et al., Gorilla: Large Language Model Connected with Massive APIs

留言
欢迎分享你的想法。评论提交后会在审核通过后显示。