先说一个我自己的直观感受:大模型真正开始“值钱”,不是从它能聊天开始的,而是从它能调用工具、完成任务开始的。你把模型当成一个只会说话的顾问,它顶多帮你润色文案;但你教会它调接口、发请求、查数据库、操作业务系统,它才从“聊天窗口”里走出来,变成了一个能帮你干活的数字员工。这个转折点,靠的就是 Function Calling(函数调用)。
这篇实战指南,我不打算给你堆概念。我直接带你过一遍 Function Calling 从原理到落地的全过程,包括本地模型怎么跑、OpenAI 风格接口怎么调、JSON Schema 怎么定义参数、多轮调用怎么处理状态、以及我在真实项目中踩过的那些坑。无论你是刚接触大模型应用开发,还是已经做了几个 Demo 但觉得不够“扎实”,这篇文章都能给你一条可以照着走的路线。
1. 先搞清楚 Function Calling 到底在解决什么问题
1.1 没有 Function Calling 的时候,大模型有多“笨”
很多人第一次用大模型 API 做应用时,都会遇到一个尴尬场景:你问它“北京今天天气怎么样”,它回答得头头是道,但实际天气数据是它编的。你问它“帮我查一下订单号 20250101 的物流状态”,它要么说“我无法访问外部系统”,要么开始一本正经地编造物流轨迹。
这背后的原因很简单:大模型本身是一个“文本生成器”,它没有主动查询数据库、调用 API、操作文件的能力。它的知识截止到训练数据那一刻,之后发生的事情它一概不知。所以,你让它“查一下”“算一下”“提交一下”,它只能靠概率去编一个看起来合理的答案。
没有 Function Calling 之前,开发者想解决这个问题,只能用“提示词硬刚”的方式:在 Prompt 里告诉模型“如果用户想查天气,你就输出【查天气】北京”,然后自己在代码里解析这段文字,再调天气接口,最后把结果拼接回去。这个方案能跑,但极其脆弱。模型稍微换个措辞,解析就崩了;多几个工具,Prompt 就臃肿得没法维护;一旦模型在中间步骤输出一些“废话”,你的解析逻辑就要跟着崩。
1.2 Function Calling 把“意图识别”和“参数提取”从提示词里解放出来
Function Calling 的核心思路,不是让模型自己决定“要不要调用工具”,而是你提前把工具的描述、参数结构告诉模型,模型在回答时如果觉得需要调用工具,就输出一个结构化的“调用请求”。这个请求里包含工具名称和参数,你的代码拿到这个请求后自己去执行真实函数,再把结果返回给模型,模型最后组织成自然语言回答用户。
换句话说,Function Calling 做的事,是把“意图识别”和“参数提取”这两个关键步骤从“纯文本约定”变成了“结构化协议”。模型不再需要靠输出特定文字来暗示“我要调工具”,而是直接输出一个 JSON 结构的 tool_calls,开发者解析起来极其稳定。这也是为什么说 Function Calling 是“从聊天到干活”的分水岭——它是第一个让大模型能和外部系统进行确定性交互的官方机制。
2. 动手之前:本地模型也能跑函数调用
2.1 选工具:为什么我用 Ollama 而不是直接调云端 API
在热词里反复出现“本地部署大模型”“ollama 部署大模型”,说明现在很多人都在尝试把大模型拉到本地。我平时做项目验证时,也很喜欢先用本地模型跑通流程,再切换到云端大模型。原因有三:一是数据不出内网,适合业务敏感场景;二是没有 API 调用费,适合反复调试;三是可以完全掌握模型行为,不会被厂商偷偷改版本搞懵。
本地部署工具里,我个人最推荐 Ollama。它支持 macOS、Windows、Linux,一条命令就能把模型拉下来,而且它自带的 OpenAI 兼容接口(/v1/chat/completions)几乎可以无缝衔接主流 SDK。哪怕你最后生产环境用的是云端 API,本地先用 Ollama 做开发联调,体验也完全不会差。
2.2 MacBook Air M3 16G 实测:哪些模型能跑函数调用
很多朋友担心自己的笔记本跑不动大模型,我拿 MacBook Air M3 16G 实测过几种主流模型,结果如下:
| 模型 | 参数量 | 量化版本 | 函数调用表现 | 生成速度(实测) | 能不能用 |
|---|---|---|---|---|---|
| qwen2.5:7b | 70亿 | Q4_K_M | 能稳定输出 tool_calls,参数提取准确率高 | 15~20 token/s | 推荐日常开发 |
| qwen2.5:3b | 30亿 | Q4_K_M | 能输出 tool_calls,但复杂参数容易漏字段 | 25~30 token/s | 轻量场景可用 |
| llama3.1:8b | 80亿 | Q4_K_M | 需要提示词引导,原生稳定性一般 | 12~18 token/s | 不推荐做生产 |
| mistral:7b | 70亿 | Q4_K_M | 函数调用格式老旧,兼容性差 | 15~20 token/s | 不建议选它 |
实测下来,qwen2.5 系列是本地跑 Function Calling 的“性价比之王”。它在训练时就专门强化了函数调用能力,输出结构非常规范,配合 Ollama 自带的 OpenAI 兼容接口,20 分钟就能搭出一个本地函数调用测试环境。
2.3 Ollama 部署 Qwen 2.5 7B 的具体步骤
这里给出我在 macOS 上实测可行的一套流程。
第一步,安装 Ollama。直接去官网下载 macOS 安装包,或者用 Homebrew 一行命令:
brew install ollama第二步,启动 Ollama 服务并拉取模型。默认情况下,Ollama 安装后会自动监听 11434 端口。
ollama serve # 如果已经作为服务运行,这一步可跳过 ollama pull qwen2.5:7b第三步,验证 OpenAI 兼容接口是否可用。打开新终端,用 curl 测一下:
curl http://localhost:11434/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5:7b", "messages": [{"role": "user", "content": "你好"}] }'如果返回正常的 choices 内容,说明本地环境已经就绪。这里要注意一点:Ollama 的 OpenAI 兼容接口不需要 API Key,随便填一个(比如“ollama”)就能通过认证。
3. 核心实现:从 OpenAI 到本地模型的 Function Calling 调用全流程
3.1 基于 OpenAI 官方 SDK 的完整示例
我不喜欢只讲抽象概念,直接上一个完整的 Python 示例。这个示例实现了一个“查天气 + 算运费”的助手,你能看到 Function Calling 从“定义工具”到“执行工具”再到“二次生成回答”的完整闭环。
from openai import OpenAI client = OpenAI( base_url="http://localhost:11434/v1", # 换成你的 Ollama 地址 api_key="ollama" # 本地 Ollama 不校验 key,随意填 ) def get_weather(city: str) -> str: """模拟天气查询接口""" weather_map = { "北京": "晴,气温 25°C", "上海": "小雨,气温 22°C", "广州": "多云,气温 28°C", } return weather_map.get(city, f"{city} 天气数据暂时未收录") tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的当前天气情况", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海、广州" } }, "required": ["city"] } } } ] messages = [{"role": "user", "content": "北京今天天气怎么样?"}] response = client.chat.completions.create( model="qwen2.5:7b", messages=messages, tools=tools, tool_choice="auto", ) # 检查模型是否想调用函数 if response.choices[0].message.tool_calls: tool_call = response.choices[0].message.tool_calls[0] print("模型想调用:", tool_call.function.name) print("参数:", tool_call.function.arguments) # 执行真实函数 import json args = json.loads(tool_call.function.arguments) result = get_weather(**args) # 把函数调用记录和结果追加到消息列表 messages.append(response.choices[0].message) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) # 让模型基于工具结果生成最终回答 final_response = client.chat.completions.create( model="qwen2.5:7b", messages=messages, tools=tools, ) print("最终回答:", final_response.choices[0].message.content) else: # 模型没有调用工具,直接回答 print("模型直接回答:", response.choices[0].message.content)这段代码的核心逻辑不复杂,但有几个地方我要特别强调。
3.2 messages 列表的“三段式”追加
很多人第一次写 Function Calling 会在消息历史的管理上出问题。完整的一次函数调用,messages 列表里至少要有四段内容:
- 用户原始请求,比如“北京今天天气怎么样?”
- 模型的 tool_calls 响应,也就是模型说要调用 get_weather,参数是 {“city”: “北京”}。这条消息的 role 是 assistant,但里面没有普通 content,而是带一个 tool_calls 字段。
- 工具执行结果,role 是 tool,必须带上 tool_call_id 来和上一步的调用请求对应。
- 模型基于工具结果生成的最终回复。
漏了第 2 条或者第 3 条,模型在下一轮就不知道刚才发生了什么,可能会重复调用函数或者答非所问。尤其是 tool_call_id,必须严格一一对应,不然会直接报错。
3.3 tool_choice 参数:auto、none、required 怎么选
在 OpenAI 兼容协议里,tool_choice 控制模型“什么时候可以调用函数”。
- auto:模型自己判断是否需要调用工具,适合大部分场景。
- none:模型只能用对话回复,即使你定义了 tools 也不调用,适合“闲聊模式”。
- required:强制模型必须调用一个工具,适合“必须走工具”的业务规则。
实际项目里我建议默认用 auto,然后在业务层面对“模型没有调用工具”的情况做兜底。比如用户问“你好”,你就别指望模型调工具,直接走普通对话即可。而 required 模式在某些场景里很有用,比如“帮我订个酒店”,你希望模型无论如何都要先调查询接口,而不是直接猜一个答案。
4. 让模型会填参:JSON Schema 定义与参数工程
4.1 参数描述比参数类型更重要
很多开发者第一次写 tools 参数时,把注意力全放在参数类型上,比如 city 是 string、num 是 integer,然后发现模型经常填错参数。问题通常出在 description 写得太简单。
举个例子,如果 description 只写“城市”,模型有可能把“首都”这种词直接填进去。但如果你写“需要查询天气的城市名称,必须是中文全称,例如:北京、上海、广州,不接受拼音或简称”,模型的表现会立刻提升一个档次。这不是玄学,是训练数据里真实存在的关联模式:模型会优先依据 description 中的示例来生成参数。
4.2 用 enum 限制取值范围,减少无效调用
当参数只有固定几个候选值时,一定要用 enum。比如“查询天气”的城市,假设你只支持 100 个城市,那你最好在枚举里列出来,或者至少把常见城市列出来。模型在生成参数时会优先选择 enum 里的值。
{ "type": "object", "properties": { "city": { "type": "string", "enum": ["北京", "上海", "广州", "深圳"], "description": "城市中文全称" }, "date": { "type": "string", "description": "查询日期,格式为 YYYY-MM-DD" } }, "required": ["city", "date"] }有人可能觉得 enum 太死板,后面加城市还要改代码。但函数调用本来就是强规范性协议,宁可多维护一个枚举列表,也不要让模型自由发挥然后返回一堆你处理不了的脏数据。enum 是成本最低的输入校验手段。
4.3 复杂嵌套参数:让模型学会处理对象和数组
除了基础字符串,Function Calling 也支持嵌套 JSON。比如“创建订单”这样一个动作,需要传递商品列表、收货地址、优惠券 ID,参数结构就复杂了。
{ "type": "object", "properties": { "order_no": { "type": "string", "description": "订单号,业务系统生成的唯一编号" }, "items": { "type": "array", "items": { "type": "object", "properties": { "sku_id": {"type": "string", "description": "商品 SKU ID"}, "qty": {"type": "integer", "description": "购买数量,必须是大于0的整数"} }, "required": ["sku_id", "qty"] } }, "address": { "type": "object", "properties": { "province": {"type": "string"}, "city": {"type": "string"}, "detail": {"type": "string"} }, "required": ["province", "city", "detail"] } }, "required": ["order_no", "items", "address"] }我测过 qwen2.5:7b 对这种嵌套结构的支持,只要 description 写得清楚,它基本能抽出正确的参数树。不过有一点要注意:嵌套层级不要太深,超过三层以后,小参数模型容易抽漏字段。如果你发现模型经常漏掉内层字段,优先考虑拍平参数结构,而不是继续嵌套。
5. 多轮调用与会话记忆:真正“干活”的复杂场景
5.1 从一个函数到多个函数:让模型学会“选择”
真实业务不会只有一个函数。我做过一个简单的“物流客服助手”,里面同时有查订单、查物流、修改地址、申请售后的四个函数。模型需要根据用户一句话,决定调用哪个函数、传哪些参数。
多函数的定义方式和单函数差不多,就是把多个 JSON 结构放进 tools 数组。真正的问题在于:当函数变多之后,description 的“辨识度”就变得极其重要。比如“查天气”和“查温度”这两个工具,如果不仔细写描述,模型很可能总是调用错的那个。我的经验是,每个函数的 description 开头第一句,就要明确指出这个函数“适合什么请求、不适合什么请求”。
5.2 多轮调用中的状态管理:模型怎么记住“刚才的订单号”
在多轮对话里,用户可能先说“帮我查一下订单”,你问他订单号,他再告诉你“20250101”。如果每次都重新发一个空的 messages,模型肯定不知道订单号是什么。所以你必须把历史消息完整地带到下一次请求里。
messages = [] # 第一轮 messages.append({"role": "user", "content": "帮我查一下订单"}) # 模型会反问订单号,或者调一个获取订单列表的工具 # 第二轮(用户补充信息) messages.append({"role": "user", "content": "订单号是 20250101"})这里的关键点在于:messages 才是模型“记忆”的唯一来源。只要你不把历史消息丢弃,模型就能引用之前的上下文。Function Calling 本身没有独立的“会话状态”,它的记忆完全靠 messages 传递,所以设计好 messages 的追加规则,就是设计好会话状态。
我做过的几个项目里,踩过最大的坑是“把 tool_call 结果拼错位置”。比如第一轮模型调用查订单函数返回了几个订单列表,第二轮用户说“选第一个”,结果我把第一轮的 tool 结果丢掉了,模型就不知道“第一个”指的是哪条。后来我的统一做法是:只要一个会话内的消息,全部保留在 messages 里,不管中间经过多少次函数调用,都不做“裁剪”,除非消息长度超过了模型上下文窗口。
5.3 循环调用:如果第一次工具结果不够,怎么继续问
有些复杂的任务,一次工具调用不够。比如用户问“广州和上海哪个冷”,理论上模型需要先调两次 get_weather,然后把两次结果对比回答。这时代码里就需要写一个循环,不断检查模型是否还想调用工具。
while True: response = client.chat.completions.create( model="qwen2.5:7b", messages=messages, tools=tools, ) message = response.choices[0].message if not message.tool_calls: # 模型不再调用工具,输出最终答案 print("最终答复:", message.content) break # 逐个处理工具调用 for tool_call in message.tool_calls: messages.append(message) # 注意,把 assistant 消息(带 tool_calls)追加进去 result = execute_tool(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result })在实际运行中,这个循环通常 2 到 3 轮就能结束。如果出现模型反复调用同一个函数、参数却不变的情况,大概率是 messages 里少了 assistant 那一条,模型“忘了刚才已经查过了”。我在代码里特意加了这行注释:message 本身有 tool_calls,不能只把 content 的内容丢进去,必须把整个 assistant message 追加进去。
6. 工具选型与避坑:从 API 到本地部署怎么选
6.1 OpenAI API、Ollama、vLLM 之间怎么选
很多初学者会问:我到底应该用云端 API 还是本地部署?热词里也频繁出现“免费大模型 API”“大模型下载”“本地部署大模型”。我把主流方案放在一起对比一下,方便你根据场景选择。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| OpenAI API | 效果好,Function Calling 格式最稳定 | 收费,数据出外网,可能有延迟 | 生产环境要求高、数据不敏感 |
| 国内大模型 API(千问、GLM 等) | 免备案、中文效果不错 | 各家兼容性略有差异 | 国内业务,对合规要求高 |
| Ollama + Qwen 2.5 | 本地部署、免费、离线可用 | 7B 模型能力有限,复杂参数容易出错 | 开发调试、内网数据敏感场景 |
| vLLM + 开源大模型 | 吞吐高、可并发 | 部署门槛高,需要 GPU | 生产环境的本地推理服务 |
从开发效率角度来说,我强烈建议你“先用 Ollama 联调,再切云端模型”。因为函数调用的链路和模型关系不大,你的代码逻辑只要遵循 OpenAI 兼容协议,本地能跑通,云端基本上也能跑通。差别只在于模型能力的强弱,而不是接口格式。
6.2 免费大模型 API 的“坑”有哪些
热词里有人问“有可以免费使用的大模型吗”,答案是肯定的。很多平台推出了免费额度或限时免费模型。但你在做 Function Calling 项目时,必须确认以下几个关键点:
- 是否支持 tools 参数。有些入门模型只开放基础对话接口,看到 tools 直接忽略或者报错。
- 工具调用的响应格式是什么。有些平台的 tool_calls 结构并不完全标准,需要你在代码里做兼容适配。
- 每天/每月的调用次数限制。免费额度往往够你学习测试,但不够生产使用。
我实际测过几个免费 API,最大的问题是模型不按照参数的 JSON Schema 输出,导致解析层报错。这种场景下,你可以在代码里加一个“参数校验”步骤,发现不符合 Schema 就重试一次或者让模型重新提取。重试逻辑看起来简单,但在免费模型上极其实用。
6.3 别急着上微调,先用提示词和函数定义解决问题
排行榜和热词里经常出现“大模型微调”,很多朋友搞了几天 Function Calling 觉得不完美,就想着微调模型。我的建议是:不要一上来就微调。函数调用的稳定性和三个因素有关:模型本身能力、函数定义质量、代码处理逻辑。很多时候问题出在后两者,而不是模型。
举个例子,小模型总是漏传参数,你把 description 写得更详细、把必填字段提到 required 里,稳定性会明显提升。只有当你在现有模型上无论如何优化描述、调整提示词都无法达到业务要求时,才值得考虑微调。微调是“最后的手段”,不是“最佳实践”。
7. 常见问题与排查技巧实录
7.1 模型不调用函数,只直接回答,怎么办
这种问题经常发生。我建议按以下顺序排查:
- 确认 messages 和 tools 传参正确。用最简单的“查天气”示例跑一遍,排除代码问题。
- 检查函数 description 里的文字。模型把函数调用当成了一种“语言行为”,如果描述里没有明确的触发词,它可能不会调用。把“查询指定城市的当前天气情况”改成“当用户想了解某个城市的天气时,必须使用此工具”,效果立竿见影。
- 换成更强的大模型试试。如果 qwen2.5:3b 不调用,换 7b 往往就好了。模型虽然架构相似,但在指令遵循能力上有明显差距。
7.2 模型调用了函数,但参数解析失败
参数解析失败最常见的两个原因:一是模型返回的 arguments 不是合法 JSON,二是参数结构和 Schema 不一致。针对前者,我建议在代码里做一层“清洗”,比如使用json.loads失败后,用正则提取其中的 JSON 片段再解析。针对后者,最简单的方法是“用 enum 限定取值 + 用 required 强制必填”。
我写了一个简易的参数解析函数,一般在生产环境里足够用:
def safe_parse_arguments(arguments: str) -> dict: """带兜底的参数解析:移除代码块标记,提取最外层 JSON""" arguments = arguments.strip() if arguments.startswith("```"): arguments = arguments.strip("`") if arguments.startswith("json"): arguments = arguments[4:] try: return json.loads(arguments) except json.JSONDecodeError: # 尝试提取 { 到 } 之间的内容 start = arguments.find("{") end = arguments.rfind("}") if start != -1 and end != -1: return json.loads(arguments[start:end+1]) raise7.3 模型死循环调用同一个函数,怎么止损
上一节提过,死循环一般和 messages 历史不完整有关,尤其是漏掉了带 tool_calls 的 assistant 消息。除此之外,还有一种场景是模型认为函数返回结果“不满足要求”,于是反复调用。这时你在代码里加一个“最大调用次数”限制,比如 5 轮之后强制终止,并把当前的上下文或多轮结果交给模型,让它尝试直接回答。这个兜底逻辑不复杂,但能避免生产环境里出现巨额 token 消耗。
| 问题现象 | 可能原因 | 排查方式 |
|---|---|---|
| 模型不调用函数 | description 触发词不明确 | 把“必须使用此工具”写进描述 |
| 模型调用函数但不填参数 | 参数 description 太模糊 | 增加示例值和初始值 |
| 参数解析失败 | 模型输出非标准 JSON | 加 safe_parse_arguments 兜底 |
| 死循环调用同一个函数 | messages 历史缺失 assistant 消息 | 将完整 assistant message 追加进历史 |
| 多函数选择错误 | description 不具备辨识度 | 每个函数开头写明“适合/不适合” |
| 小模型漏传嵌套字段 | 模型能力不足 | 拍平 JSON 结构,降低嵌套层级 |
7.4 对于免费 API 和在线平台,Function Calling 响应慢怎么办
如果你用的是免费 API,响应速度往往不稳定,比如 30 秒才返回结果。这时你需要注意:超时时间要设得足够长,避免前端主动断连。如果你在服务端调用,建议加一个异步队列,不要让用户请求一直挂着。最简单的方式是把调用放到后台任务里,轮询拿结果,前端只负责展示“处理中”状态。这个方案在老旧的业务系统改造里特别常用。
8. 最后分享两个实用小技巧
第一个小技巧:用 Function Calling 做“参数提取”的时候,不一定要真的去执行函数。我经常把 Function Calling 当成一个“结构化信息抽取器”来用——定义好我想提取的字段,让模型从用户消息里抽出来,然后只取 tool_calls 里的参数,不真正执行任何工具。这种用法在表单自动填充、信息录入场景里非常有效,本质上是用函数调用的协议来约束模型的输出格式,比让模型输出普通 JSON 要稳定得多。
第二个小技巧:如果你用的是 Ollama,可以直接在Modelfile里通过PARAMETER stop或者自定义系统提示词来增强小模型的函数调用稳定性。比如给模型加一句“当用户询问天气时,务必调用 get_weather 工具”,效果往往比你在 SDK 端用 system prompt 更稳定。这个方法看起来不起眼,但在 3B 级别的模型上提升非常明显。
坦白说,Function Calling 这条路,我一开始也走得很曲折。印象最深的一次是把线上客服机器人全部切到函数调用架构,结果刚上线那天晚上,因为 messages 历史漏了一条 assistant 消息,整个机器人在“查订单”场景里疯狂死循环,token 费用跑出一个让我肉疼的账单。后来加上了循环次数限制和完整的消息追加逻辑,这套系统才真正稳定下来。那次的教训让我明白:Function Calling 的门槛不在“会不会调接口”,而在“能不能把工具链路、消息状态和异常兜底设计完整”。希望这篇实战指南能帮你少踩几个坑,把大模型真正变成你业务里能干活的角色。