1. 从“只会聊天”到“动手干活”:Function Calling 到底解决了什么问题
大模型刚火起来那阵子,我跟很多人一样,觉得它就是个超级能聊的搜索引擎——你问它什么它都能接上话,但也就仅此而已了。你让它帮你查一下明天的天气,它只能回你一句“抱歉,我无法获取实时信息”;你让它帮你订张机票,它给你编出一段看似合理但完全虚构的航班号。这种“只会说不会做”的状态,就是 Function Calling 要解决的核心痛点。
Function Calling,中文一般叫“函数调用”或“工具调用”,是大模型与外部世界交互的一套标准化机制。说白了,它让大模型从一个“只会动嘴的顾问”变成了一个“能动手的执行者”。你告诉模型“帮我查一下北京现在的天气”,模型不再瞎编,而是输出一个结构化的调用请求,比如get_weather(city="北京"),你的程序拿到这个请求后去调真实的气象接口,把结果再喂回给模型,模型最后用自然语言告诉你“北京现在晴,气温 28 度”。
这套机制为什么重要?因为大模型本身有三个硬伤:知识有截止日期、无法执行副作用操作、不擅长精确计算。Function Calling 相当于给模型装了一双手——它负责理解和决策“该调用什么工具、传什么参数”,具体的执行交给外部程序。分工明确,各司其职。
这篇文章适合谁看?如果你是想把大模型接入自己业务系统的开发者,或者是对 Agent、智能助手底层原理好奇的技术爱好者,再或者你只是想知道“为什么 ChatGPT 能查天气而我本地部署的模型不能”,那这篇内容应该能帮你把整条链路捋清楚。我会从设计思路讲到代码实操,从参数细节讲到踩坑经验,尽量做到你看完就能自己动手搭一个最小可用的工具调用 Demo。
2. 核心设计思路拆解:模型不执行,只做“决策翻译官”
2.1 为什么不让模型直接执行工具
很多人第一次接触 Function Calling 时会有个误解,以为是大模型内部真的去调用了某个 API。其实不是。大模型从头到尾只做一件事:生成文本。它生成的是一段符合特定格式的文本,这段文本描述了“我想调用哪个函数、传什么参数”。真正执行函数的是你写的代码。
这个设计非常关键,理解它才能理解后面所有的实现细节。为什么这么设计?三个原因:
第一,安全隔离。如果模型能直接执行任意代码或网络请求,那风险不可控。让模型只输出“意图”,由开发者在中间层做校验、鉴权、限流,才能保证系统安全。
第二,模型无关性。函数的具体实现跟模型没关系,你用 Python 写、用 Java 写、用 Go 写都行,模型只负责输出结构化的调用意图。这样同一套工具可以接不同的模型,同一套模型也可以接不同的工具。
第三,可观测可调试。模型输出的调用请求是纯文本,你可以打日志、可以做审计、可以在中间加一层人工确认。如果模型直接执行了,你连它干了什么都很难追踪。
打个比方:模型就像一个餐厅里的点菜员,它负责听懂客人要什么,然后写一张点菜单交给后厨。点菜员不需要会炒菜,后厨也不需要懂客人说了什么方言。Function Calling 就是那张标准化的点菜单。
2.2 一次完整的工具调用经历了什么
把整个链路拆开看,一次典型的 Function Calling 包含四个阶段:
第一阶段:工具注册。你在调用模型 API 时,除了传用户的消息,还要额外传一个tools参数。这个参数是一个数组,里面每个元素描述了一个可用的工具——包括工具的名字、功能说明、以及参数的结构定义。这个描述用的是 JSON Schema 格式,后面会详细讲。
第二阶段:模型决策。模型收到用户消息和工具列表后,判断是否需要调用工具、调用哪个工具、传什么参数。如果需要,它不会返回普通的文本回复,而是返回一个特殊的结构,里面包含tool_calls字段,标明函数名和参数。
第三阶段:本地执行。你的程序解析模型返回的tool_calls,找到对应的本地函数,把参数传进去执行,拿到结果。这一步完全在你的掌控之中,你可以做任何校验和处理。
第四阶段:结果回传与最终回复。你把执行结果按照特定格式追加到对话历史里,再次调用模型。模型这次拿到真实数据后,生成最终的自然语言回复给用户。
这四个阶段里,模型被调用了两次。第一次是“决策”,第二次是“总结”。中间的执行环节完全在模型之外。理解这个两段式调用,是理解 Function Calling 的关键。
2.3 与传统 API 调用的本质区别
有人会问,这不就是让模型帮忙拼个 API 请求吗,有什么稀奇的?区别在于决策的灵活性。
传统 API 调用是你写死的:if 用户问天气 then 调天气接口。但用户可能说“我明天要出门,该穿什么”,这句话里没有“天气”两个字,但隐含了查天气的需求。传统规则引擎很难覆盖这种模糊表达,而大模型可以理解意图,自主决定“我需要先查天气,再根据温度给建议”。
更复杂的情况是多工具协作。用户说“帮我看看北京和上海明天哪个更适合跑步”,模型可能需要先调两次天气接口,拿到两个城市的温度、湿度、空气质量,然后综合判断。这种动态的、多步的决策能力,是 Function Calling 相比传统硬编码调用的核心优势。
当然,代价是模型可能判断错误——该调的时候不调,不该调的时候乱调,或者参数传错。所以实际工程中,工具描述的写法、参数的约束、以及兜底逻辑的设计,都有不少讲究。
3. 核心细节解析:工具描述、参数 Schema 与调用协议
3.1 工具描述怎么写才能让模型“看懂”
工具描述是 Function Calling 里最容易被忽视、但影响最大的部分。模型能不能正确调用工具,八成取决于你的描述写得好不好。
一个工具描述通常包含三个部分:name(函数名)、description(功能说明)、parameters(参数定义)。我见过太多人把description写成“查询天气”四个字就完事了,然后抱怨模型老是不调用或者调错。问题就出在这里。
好的description应该像写给一个新员工的操作手册,要说明:这个工具做什么、什么时候该用、什么时候不该用、有什么限制。比如:
{ "name": "get_weather", "description": "查询指定城市当前及未来三天的天气情况。当用户询问天气、温度、是否下雨、穿衣建议、出行建议等与气象相关的问题时使用。不适用于查询历史天气数据。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,使用中文,例如'北京'、'上海'。不要传省份名或区县名。" }, "days": { "type": "integer", "description": "查询天数,1 表示仅今天,3 表示未来三天。默认 1。", "enum": [1, 3] } }, "required": ["city"] } }注意几个细节:description里明确写了“什么时候用”和“什么时候不用”,这能大幅减少误调用。参数描述里写了格式要求和示例,能减少参数传错。enum限制了取值范围,模型就不会传个days=7进来。
实操心得:工具描述不是写给你自己看的文档,是写给模型看的“提示词”。你写提示词时会考虑措辞对模型理解的影响,工具描述也一样。我一般会把工具描述当成 prompt engineering 的一部分来打磨,改一版测一版。
3.2 参数 Schema 的类型系统与约束
parameters用的是 JSON Schema 标准,支持的类型包括string、integer、number、boolean、array、object。对于嵌套结构,可以用object套object,或者用array配合items定义元素类型。
几个实用的约束关键字:
enum:限定取值列表,适合状态、类型这种有限选项的参数。required:标记必填参数。模型有时会漏传,标了 required 能提高传全的概率,但不能保证。default:默认值。不过要注意,模型不一定理解 default 的含义,它可能还是会显式传值。minimum/maximum:数值范围限制。pattern:正则约束,适合格式固定的字符串,比如日期、编号。
我个人的经验是,参数越简单越好。如果一个工具需要传七八个参数,模型出错的概率会明显上升。更好的做法是拆成多个小工具,每个工具只做一件事,参数控制在三个以内。这跟函数式编程里“单一职责”的原则是一样的。
另外,参数类型尽量用基础类型。我试过用嵌套很深的 object 做参数,模型经常把结构搞乱。如果确实需要复杂结构,可以考虑让模型传 JSON 字符串,然后在本地解析,这样反而更稳。
3.3 模型返回的调用请求长什么样
不同厂商的 API 格式略有差异,但核心结构类似。以常见的对话补全接口为例,模型决定调用工具时,返回的消息里会有一个tool_calls数组:
{ "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\", \"days\": 3}" } } ] }注意arguments是一个JSON 字符串,不是对象。这是很多人第一次写代码时会踩的坑——直接当对象用会报错,得先JSON.parse或json.loads解析。为什么要设计成字符串?因为模型生成的是文本,字符串是最自然的输出形式,而且不同语言的 JSON 解析库都能处理。
id字段是这次调用的唯一标识,回传结果时需要带上,让模型知道哪个结果对应哪个调用。如果模型一次返回了多个tool_calls(并行调用),每个都有自己的 id,你需要分别执行、分别回传。
还有一种情况是模型返回了content和tool_calls同时存在。这通常意味着模型在调用工具前先说了一句话,比如“我来帮你查一下天气”。处理时两者都要保留,回传结果时把 assistant 消息原样带上。
3.4 结果回传的格式与角色定义
执行完工具后,你需要构造一条role为tool的消息,追加到对话历史里:
{ "role": "tool", "tool_call_id": "call_abc123", "content": "{\"city\": \"北京\", \"forecast\": [{\"date\": \"2024-06-01\", \"temp_high\": 32, \"temp_low\": 22, \"condition\": \"晴\"}]}" }content通常是字符串。如果你的工具返回的是结构化数据,建议序列化成 JSON 字符串再传。有些实现支持直接传对象,但为了兼容性,字符串更稳妥。
tool_call_id必须和之前模型返回的 id 对应。如果模型一次调了三个工具,你就要回传三条 tool 消息,每条对应一个 id。顺序不重要,但 id 不能错。
回传之后再次调用模型,模型就会基于这些真实数据生成最终回复。这时候模型的角色又变回了“总结者”,它会把工具返回的原始数据翻译成用户能看懂的自然语言。
注意:工具返回的内容也会占用上下文长度。如果工具返回了一大坨数据,比如一个几百行的表格,模型可能处理不过来,或者成本飙升。实际工程中,我通常会在工具层做一次“摘要”或“截断”,只把关键字段回传给模型。
4. 实操过程:从零搭一个可用的工具调用 Demo
4.1 环境准备与最小依赖
要跑通一个 Function Calling 的 Demo,你不需要什么重型框架。核心就是两样东西:一个能调用模型 API 的 HTTP 客户端,一个 JSON 解析库。Python 的话,requests加内置的json就够了。如果你用官方 SDK,那就更省事。
我这里的示例用 Python 写,因为生态最成熟、代码最直观。其他语言逻辑完全一样,只是语法差异。你需要准备一个模型 API 的访问凭证,具体怎么获取这里不展开,各家平台都有自己的流程。
先装依赖:
pip install requests如果你用的是某家平台的官方 SDK,按它的文档装对应的包就行。我建议先用最原始的 HTTP 请求跑一遍,把整个链路的每个字节都看清楚,再去用封装好的 SDK。这样出了问题你知道去哪找。
4.2 定义工具函数与 Schema
我们先定义一个真实的本地函数,比如查天气。为了 Demo 能跑,这里用模拟数据,实际项目中替换成真实的气象 API 调用:
import json import requests def get_weather(city: str, days: int = 1) -> dict: """模拟天气查询,实际项目中替换为真实 API 调用""" mock_data = { "北京": {"condition": "晴", "temp_high": 32, "temp_low": 22}, "上海": {"condition": "多云", "temp_high": 29, "temp_low": 24}, "广州": {"condition": "阵雨", "temp_high": 33, "temp_low": 26}, } result = mock_data.get(city, {"condition": "未知", "temp_high": None, "temp_low": None}) result["city"] = city result["days"] = days return result然后定义这个工具对应的 Schema:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的天气情况。当用户询问天气、温度、穿衣建议、出行建议时使用。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,中文,如'北京'、'上海'" }, "days": { "type": "integer", "description": "查询天数,默认 1", "enum": [1, 3] } }, "required": ["city"] } } } ]这个tools列表就是你要在 API 请求里传的东西。注意它是个数组,意味着你可以一次注册多个工具。模型会在所有工具里选最合适的那个。
4.3 发起第一次调用:让模型做决策
构造请求体,把用户消息和工具列表一起发出去:
def call_model(messages, tools=None): url = "https://api.example.com/v1/chat/completions" headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" } payload = { "model": "your-model-name", "messages": messages, } if tools: payload["tools"] = tools payload["tool_choice"] = "auto" resp = requests.post(url, headers=headers, json=payload) return resp.json()tool_choice参数控制模型是否必须调用工具。auto表示让模型自己决定,none表示禁止调用,也可以指定强制调用某个工具。大多数场景用auto就行。
第一次调用:
messages = [ {"role": "user", "content": "北京明天天气怎么样?适合跑步吗?"} ] response = call_model(messages, tools) assistant_msg = response["choices"][0]["message"] print(assistant_msg)如果模型判断需要调用工具,assistant_msg里就会有tool_calls。如果它觉得不需要,就直接返回文本。这里的关键是:不要假设模型一定会调用工具,你的代码要能处理两种情况。
4.4 解析调用请求并执行本地函数
拿到tool_calls后,解析参数、执行函数:
if assistant_msg.get("tool_calls"): messages.append(assistant_msg) for tool_call in assistant_msg["tool_calls"]: func_name = tool_call["function"]["name"] func_args = json.loads(tool_call["function"]["arguments"]) if func_name == "get_weather": result = get_weather(**func_args) else: result = {"error": f"未知函数: {func_name}"} messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps(result, ensure_ascii=False) })这里有几个细节值得说。json.loads解析参数时可能失败,如果模型生成了不合法的 JSON,你得有 try-except 兜底。func_args里的键可能跟函数签名不匹配,比如模型传了个city_name而不是city,这时候要么做映射,要么返回错误让模型重试。ensure_ascii=False是为了让中文正常显示,不然会变成\uXXXX转义。
4.5 第二次调用:生成最终回复
把工具结果追加到消息历史后,再次调用模型:
final_response = call_model(messages, tools) final_msg = final_response["choices"][0]["message"] print(final_msg["content"])这次模型拿到真实的天气数据,会生成类似“北京明天晴,气温 22 到 32 度,适合跑步,但注意防晒”这样的回复。注意第二次调用时tools参数可以传也可以不传。传的话模型理论上还能继续调用工具(多轮调用),不传的话它就只能基于已有信息回复。我一般会传,保留多轮调用的能力。
整个流程跑下来,你会发现核心代码不到 50 行。Function Calling 的机制本身并不复杂,复杂的是工程化——错误处理、多轮调用、并发控制、成本优化,这些才是实际项目中真正花时间的地方。
4.6 多轮调用与并行调用的处理
有些任务需要模型连续调用多个工具。比如用户问“北京和上海明天哪个更适合跑步”,模型可能先调get_weather(city="北京"),拿到结果后再调get_weather(city="上海")。这种多轮调用需要你在代码里加循环:每次模型返回tool_calls就执行、回传,直到模型不再调用工具、直接返回文本为止。
max_rounds = 5 for _ in range(max_rounds): response = call_model(messages, tools) msg = response["choices"][0]["message"] messages.append(msg) if not msg.get("tool_calls"): break for tool_call in msg["tool_calls"]: # 执行并回传结果 ...max_rounds是必须的,防止模型陷入无限调用循环。我见过模型因为工具返回错误、反复重试同一个调用的情况,没有上限的话会一直烧 token。
并行调用是指模型在一次返回里给出多个tool_calls,比如同时查北京和上海的天气。这时候你可以并发执行这些函数,提高效率。但要注意,如果工具之间有依赖关系,并行会出问题。模型一般能正确处理依赖,但保险起见,我建议对无依赖的工具才做并行。
5. 常见问题与排查技巧实录
5.1 模型不调用工具怎么办
这是最高频的问题。用户明明问了天气,模型却直接编了一段回复,根本没调工具。排查思路按优先级来:
第一,检查工具描述是否清晰。如果description写得太模糊,模型可能觉得“这个问题我自己能答,不需要工具”。把使用场景写具体,比如“当用户询问实时天气、温度、降水概率时使用”。
第二,检查tool_choice参数。如果设成了none,模型被禁止调用工具,当然不会调。确认是auto或指定了具体工具。
第三,检查用户消息是否真的需要工具。有时候用户问的是“天气一般怎么预测”,这是知识性问题,不需要实时数据,模型不调用工具是对的。别把正常行为当 bug。
第四,模型能力问题。一些小模型或旧模型对 Function Calling 的支持不好,可能压根不理解工具描述。换一个明确支持工具调用的模型试试。
5.2 参数传错或漏传的排查
模型传的参数跟你的函数签名对不上,常见的有:参数名拼错、类型不对、必填项缺失、多传了不存在的参数。
排查方法很直接:把模型返回的arguments打印出来看。我一般会在解析前先 log 一下原始字符串,确认模型到底生成了什么。如果是参数名问题,在工具描述的properties里把名字写得更明确,或者在description里加示例。如果是类型问题,检查type定义是否准确,比如该用integer的地方别写成string。
还有一种情况是模型传了null或空字符串。这通常是因为它不确定该传什么。把参数标成required能减少这种情况,但根治办法是让描述更明确。
5.3 工具返回结果太长导致上下文爆炸
工具返回的数据量不可控,比如查数据库返回了几百条记录。全塞给模型,轻则成本飙升,重则超出上下文限制直接报错。
解决办法是在工具层做处理。我通常会在函数返回前做三件事:截断(只取前 N 条)、摘要(只保留关键字段)、分页(告诉模型还有更多数据,需要的话再查)。比如查订单列表,不要返回完整订单对象,只返回订单号、金额、状态三个字段,模型足够用了。
实操心得:工具返回给模型的内容,目标是“让模型能做出正确判断”,而不是“完整还原数据”。想清楚模型需要哪些信息才能生成好的回复,只回传那些信息。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 模型不调用工具 | 描述模糊、tool_choice 设置错误 | 检查 description 和 tool_choice | 细化使用场景描述,确认 auto |
| 参数解析失败 | arguments 不是合法 JSON | 打印原始字符串 | 加 try-except,提示模型重试 |
| 参数名不匹配 | 模型理解偏差 | 对比 schema 和函数签名 | 在描述中加参数示例 |
| 无限调用循环 | 工具返回错误,模型反复重试 | 加调用轮次上限 | max_rounds 限制,错误时返回明确提示 |
| 上下文超限 | 工具返回数据过大 | 检查返回内容长度 | 截断、摘要、分页 |
| 调用结果被忽略 | 回传格式错误 | 检查 role 和 tool_call_id | 确保格式符合协议 |
5.5 几个容易踩的坑
坑一:忘记把 assistant 消息加入历史。模型返回tool_calls的那条 assistant 消息必须原样追加到 messages 里,否则回传 tool 结果时模型不知道对应哪个调用。我一开始就漏了这步,导致模型报错说找不到对应的 tool_call。
坑二:tool_call_id 对不上。并行调用时,多个 tool 结果的 id 必须和模型返回的 id 一一对应。顺序可以乱,但 id 不能错。建议用字典按 id 索引,别靠顺序。
坑三:工具函数抛异常没处理。如果本地函数执行时抛了异常,你没捕获,整个流程就断了。正确做法是捕获异常,把错误信息作为 tool 结果回传给模型,让模型决定是重试还是告诉用户出错了。
坑四:忽略模型的文本回复。有时候模型在tool_calls之外还带了content,比如“我来帮你查一下”。这条 content 也要保留,不然对话会显得突兀。
6. 工具选型与工程化建议
6.1 什么时候该用 Function Calling
不是所有场景都需要 Function Calling。如果你的需求是固定的、可枚举的,比如“用户点按钮触发某个操作”,那直接写代码调用就行,没必要绕一圈让模型决策。Function Calling 的价值在于处理模糊的、开放的、需要理解意图的输入。
适合的场景:智能客服需要根据用户描述查订单、改地址、退款;个人助手需要根据自然语言指令操作日历、发邮件、查资料;数据分析工具需要根据问题自动选择查询维度和聚合方式。
不适合的场景:流程完全固定、输入格式严格受控、对延迟极度敏感。这些场景用传统编程更可靠、更便宜、更快。
6.2 工具粒度怎么把握
工具设计有个核心原则:一个工具只做一件事,但要把这件事做完整。太粗的工具(比如一个do_everything函数)模型不知道怎么传参;太细的工具(比如把查天气拆成查温度、查湿度、查风速三个)模型要调好几次,容易漏。
我的经验是,按“用户意图”来划分工具。用户说“查天气”,对应一个get_weather;用户说“订机票”,对应一个book_flight。每个工具的参数控制在 2 到 4 个,超过就考虑拆分。
另外,工具之间尽量正交,不要有功能重叠。如果两个工具都能完成同一件事,模型会纠结选哪个,增加不确定性。
6.3 错误处理与降级策略
生产环境里,工具调用失败是常态——网络超时、接口限流、数据格式变化,什么都有可能。你的代码要能优雅处理这些情况。
基本策略是:捕获所有异常,把错误信息结构化后回传给模型。比如{"error": "timeout", "message": "天气服务暂时不可用"}。模型拿到这个,可以决定是重试、换工具、还是告诉用户稍后再试。
同时要有降级方案。如果模型调用工具失败,能不能用缓存数据?能不能给一个兜底回复?这些要在设计阶段就想好,别等线上出问题了才临时加。
6.4 成本与延迟的权衡
每次 Function Calling 至少调用模型两次(决策一次、总结一次),token 消耗是普通对话的两倍以上。如果涉及多轮调用,成本还会翻倍。延迟方面,两次模型调用加上工具执行时间,用户等待时间可能到好几秒。
优化方向有几个:工具描述精简,别写太长,减少输入 token;工具结果摘要,别回传大段数据;缓存,对相同参数的调用结果做缓存;并行,无依赖的工具调用并发执行;小模型做决策,用便宜的小模型判断该调什么工具,用大模型做最终总结。
这些优化不是一开始就要做,但心里要有数。等业务量上来了,这些细节决定成本能不能扛住。
7. 从 Demo 到生产:还需要补哪些课
跑通 Demo 只是第一步。真正上线还要考虑权限控制——不是所有用户都能调用所有工具,得在中间层做鉴权。还要考虑审计日志——谁在什么时候调了什么工具、传了什么参数、返回了什么结果,这些都要记录,方便排查问题和合规审查。
另外是工具版本管理。工具的参数和返回值可能会变,模型描述也要跟着更新。如果工具升级了但描述没改,模型可能传旧参数导致调用失败。建议把工具定义和实现放在一起管理,改的时候同步更新。
最后是测试。Function Calling 的测试比普通接口测试麻烦,因为模型的输出有随机性。我的做法是准备一批典型用户输入,跑多次看调用成功率,对失败的 case 逐个分析是描述问题还是模型问题。这个测试集要持续维护,每次改工具描述都跑一遍。
这个领域变化很快,各家平台的 API 格式、支持的能力都在迭代。但核心机制——模型做决策、本地做执行、结果回传总结——这套逻辑是稳定的。把这套逻辑吃透,换任何平台都能快速上手。我自己从最早的手写 HTTP 请求,到后来用各种框架,底层理解一直没变过,这也是我觉得值得花时间讲清楚的原因。