☰
Function Calling 实战指南:从原理到避坑,让大模型学会调用工具
2026/10/15 5:24:17 网站建设 项目流程

说到大模型应用开发,这两年最绕不开的一个词就是 Function Calling。很多朋友把它想得很神秘,一听到“工具调用”就觉得是特别高深的技术。其实它本质上就是一套协议:大模型读完用户的问题,判断“这事儿我自己办不了,得调个外部工具”,于是按照你事先定义好的格式,输出一段包含函数名和参数的结构化 JSON。你的程序拿到这段 JSON 后去执行真正的函数,再把结果喂回给模型,最后由模型基于真实返回组织语言答复用户。一句话总结,Function Calling 解决的核心问题就是:让大模型从“只会说”变成“会办事”。

这篇文章我打算从背景原理讲到流程拆解,再给一份可以直接复现的 Demo 代码,最后把我实际项目中踩过的坑全部摊开讲。适合正在用大模型开发应用、被工具调用折磨过,或者刚接触 Function Calling 想快速上手的朋友。内容不绕弯,直接进入正题。

1. 为什么大模型必须依赖工具调用

1.1 大模型的“三不”:不知道、看不到、做不到

很多初学者会问一个很本质的问题:大模型已经这么聪明了,为什么不能直接让它回答“今天北京天气怎么样”?答案是它根本没有办法知道。

模型的知识来自训练数据,而训练数据一定有一个截止时间点。凡是这个时间点之后发生的事情,对模型来说都是未知的。这是第一重局限:不知道。

第二重局限是看不到。模型没有像浏览器、天气服务、数据库连接池这类实时数据通道。你问它“当前服务器负载多少”,它只能基于训练时见过的相似文本做猜测,猜出来的数字没有任何真实依据。就算它的回答再像模像样,本质依然是“一本正经地编”。

第三重局限是做不到。模型只能输出文本,不能主动去改数据库、发请求、操作文件系统。它可以在回答里写“建议你查询一下订单状态”,但它自己查不了。这句话听起来像废话,但恰恰是很多人设计应用时最容易忽略的。

我常用一个类比:大模型就像一个口才极好、业务知识扎实的实习生,但被关在一个只有纸笔的房间里。你问什么他都能分析得头头是道,但他没法到外面去查资料,也没法动手改变任何东西。Function Calling 就是给他开了一扇窗,让你可以通过一个标准化窗口把外部世界的数据递进去,再把他决定的动作递出来。

1.2 Function Calling 到底改变了什么

其实函数调用这个概念本身并不新奇。在传统的软件工程里,程序 A 调用程序 B 的接口再正常不过。Function Calling 的新意在于“决策方变成了模型”。它解决的是三个具体问题。

第一个是输出结构化。没有 Function Calling 的时候,你想让模型输出一段可解析的指令,只能靠提示词“请以 JSON 格式返回”。结果模型可能回复你好几段话,其中只有一句是 JSON,还带了一堆解释。Function Calling 定义了标准协议,让模型直接返回带函数名和参数的字段,你的代码不用再从自然语言里做正则表达式抽取。

第二个是精准路由。当你定义好多个函数时,模型会根据用户请求自己判断应该调用哪一个。这相当于你给模型配了一个“前台分诊员”,把问题分流到对应科室。没有这套机制,你要么把所有业务逻辑塞进一个 prompt 让模型自己选,要么写一大堆 if-else 规则去猜用户意图,维护成本非常高。

第三个是闭环更新。这一点最关键。模型第一次输出“我建议调用天气接口,但我不知道气温”,然后程序真的把气温查回来了,再把这个真实数据放进模型的下一次推理里,模型才能告诉你“北京今天 28 度,体感舒适”。这个“模型决策→程序执行→结果回填→模型再回答”的循环,就是工具调用与传统文本生成最大的区别。

2. 一次完整的 Function Calling 调用:六个步骤拆解

2.1 参与方:模型、应用、函数各司其职

在 Function Calling 的体系里,至少涉及四方角色:用户、模型、你的应用代码、你的业务函数。区分清楚每一方干什么,是理解整个链路的第一步。

参与方核心职责典型示例
用户提出需求,提供输入信息“帮我查一下北京今天的气温”
大模型理解用户意图,决策是否需要调用工具,生成最终答复输出tool_calls: get_weather(city="北京")
应用代码对话管理、请求组装、结果路由、执行函数、结果回填解析 JSON,把请求分发给 get_weather 函数
业务函数执行真实动作,返回结构化数据调用天气服务 API,返回{"temp": 28, "desc": "晴"}

很多人写代码时只盯着模型那一端,觉得只要把 tools 参数加上就万事大吉。但实际工作量的大头在应用代码侧:你要处理模型返回的 tool_calls、校验参数、执行函数、把结果正确拼回对话历史,最后还要处理好各种异常。整套链路里,模型只是“做决策”的那一环,真正干活的是你自己的代码。

2.2 核心链路:模型决策、程序执行、结果回填

一次完整的工具调用,标准化来看可以分为六步。我建议你把这六步刻在脑子里,因为它能解释你未来遇到的绝大多数问题。

第一步,组装请求。你需要在一次请求里带上系统提示词、历史对话消息,以及当前可用的函数定义列表。这一步很容易被忽略的是:函数定义本身也会消耗 token,而且描述越精细,模型决策越准确。

第二步,模型决策。模型在收到消息和工具列表后,返回两种结果之一。第一种是普通的 assistant 消息,表示“不需要调用任何工具,我直接回答用户”。第二种是带有 tool_calls 字段的消息,里面包含函数名和 JSON 字符串形式的参数。后一种情况才是我们要重点处理的。

第三步,解析并执行。你的代码读取 tool_calls 数组,解析函数名和参数,在本地注册表里找到对应的函数,把参数传进去执行。这一步要注意:arguments 是字符串,必须用 JSON 解析,而且解析可能失败;函数可能抛出异常,也必须在调用前做参数校验。

第四步,结果回填。把函数执行结果包装成一条 role 为 tool 的消息,追加到对话历史上。这里有一个极其重要的细节:tool 消息必须带上 model 返回的 tool_call_id,否则模型无法把结果和上一步的调用对应起来。

第五步,第二轮推理。把更新后的完整消息列表再次发回给模型。此时模型已经能看到“真实数据”,而不是凭空猜测。它会基于函数结果组织最终回复。

第六步,回复用户。应用拿到模型第二次返回的内容,展示给用户或触发后续流程。

2.3 模型到底是怎么“决定”调用哪个函数的

关于模型内部的决策机制,我常跟人讲:函数调用层面,模型并没有真的在“运行”你的代码,它是在做文本生成。它之所以会输出tool_calls,是因为在训练阶段见过大量带工具使用的对话模式,识别出了“用户求助于工具”的模式,然后按照这种模式去生成结构正确的 JSON。

所以不要把它理解成程序化的 if-else 路由,而是“指令遵循 + 模式补全”。这意味着你写的系统提示词和函数描述,会直接影响模型的决策倾向。比如你在系统提示里写了“当你知道答案时直接回答,不确定时不要乱猜”,模型可能倾向于少调用工具;如果你写“涉及实时信息时必须调用对应工具”,调用率会显著上升。这里面没有魔法,全是提示语对生成倾向的调节。

3. 函数定义:写对 description,模型才不会跑偏

3.1 一份函数定义的完整结构

所有主流大模型的函数调用协议,结构都大同小异。核心逻辑就是三个部分:函数名、描述、参数定义。我直接给一份示例,这是主流服务商常见的 tools 格式。

{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气。当用户询问天气、气温、降水、湿度、风力等信息时必须调用。如果用户没有明确给出城市,先向用户确认,不要猜测。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如北京、上海、广州。只接受城市名称,不接受区县。" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认 celsius。" } }, "required": ["city"] } } }

这里面,函数名和参数结构是给程序看的,而 description 是给模型看的。很多人写 description 时像在写 API 文档,冷冰冰一句“Get weather”就完事。但你要想清楚一件事:模型不会打开你的源代码,它不会去看函数体内部怎么实现,它判断“什么时候该用这个工具、参数该怎么填”的唯一依据,就是你写的那段描述文字。

3.2 把“触发条件”和“禁止条件”写进描述里

我见过非常多选错工具、乱传参数的案例,根因都是描述太笼统。合格的 description 应该包含三层信息:什么时候调用、什么时候不调用、参数填写的规则。

拿最常用的天气工具举例,“查询指定城市的实时天气”只是基本信息。更好的写法是:“当用户询问某城市当前天气、气温、是否下雨、湿度、风力等信息时调用。如果用户只是闲聊提到天气,不要求实时数据时不要调用。如果用户没有给出城市名,不要猜测,必须先向用户确认城市名称后再调用。”

看到差别了吗?后面的写法增加了两个关键约束:明确触发场景、明确不触发场景、明确参数缺失时的行为。模型在决策时,正是靠这些边界信息来收敛自己的行为。你写得越具体,它跑偏的概率就越低。

3.3 参数设计:类型、枚举与 required 的取舍

参数定义里的每一项都有讲究。type 字段决定模型按什么类型去填值。你把 city 定义成 string,模型就会填文本;定义成 number,它就会填数字。如果类型定义和实际函数需求对不上,执行阶段就会出问题。

enum 枚举是非常好用的约束手段。比如温度单位只有摄氏度和华氏度两种,你写成 enum ["celsius", "fahrenheit"],模型就只会从这两个值里选,不会传出 "C" 或 "摄氏度" 这种奇怪取值。

required 字段则决定了哪些参数是必填的。我的经验是:真正执行函数时不能缺失的参数才放进 required。如果你把一个可选参数也放进 required,模型可能为了凑齐参数去猜测一个值,反而引发更多幻觉。所以 required 要克制,能标注为可选的尽量不设为必填。

还有个容易被忽略的细节:参数描述里最好给出示例值。比如 city 的描述写成“城市名,例如北京、上海、广州”,模型就知道填地名而不是填“某个城市”这种占位符。你给的示例越多,模型理解得越准确。

4. 从零实现一个 Function Calling Demo

4.1 场景设定与核心思路

理论讲再多,不如跑通一个例子。我设计了一个“天气查询 + 基础计算器”的双工具 Demo。选双工具不是为了让代码复杂,而是只有多个函数时才能直观看到模型“选哪个函数”的决策过程。技术栈直接用 Python 加 requests,不带任何第三方大模型 SDK,这样你能看到最底层的协议交互,换任何模型服务商都能对应上。

整个程序的逻辑分三层:第一层是函数定义列表,描述有哪些工具可用;第二层是函数注册表,把函数名映射到真实执行函数;第三层是主循环,负责发请求、解析 tool_calls、回填结果、再次请求。我强烈建议你在自己项目里也采用这种分层结构,不要在业务代码里到处散落着解析逻辑。

4.2 函数注册表与执行器

先看工具本身的实现和注册方式。为了演示方便,天气函数用的是内置假数据,你在真实项目中替换成天气服务调用即可。

import json import requests API_URL = "https://api.example.com/v1/chat/completions" API_KEY = "your-api-key" SYSTEM_PROMPT = """你是一个智能助手。当用户需要实时数据或数值计算时,你必须优先调用对应工具。工具返回结果后,请结合结果用自然语言回答用户。严禁在没有任何数据依据的情况下编造实时信息。""" def get_weather(city: str, unit: str = "celsius"): # 实际项目中,这里应该是天气服务 API 调用 mock_weather = { "北京": {"celsius": 28, "desc": "晴", "humidity": 40}, "上海": {"celsius": 30, "desc": "多云", "humidity": 55}, "广州": {"celsius": 32, "desc": "阵雨", "humidity": 70}, } data = mock_weather.get(city) if data is None: return json.dumps({"error": "查无此城市"}, ensure_ascii=False) temp = data["celsius"] if unit == "fahrenheit": temp = round(temp * 9 / 5 + 32) return json.dumps( {"city": city, "temperature": temp, "desc": data["desc"], "humidity": data["humidity"], "unit": unit}, ensure_ascii=False, ) def calculator(expression: str): # 生产环境严禁使用 eval,这里仅为演示;真实场景请用表达式解析库 allowed = set("0123456789+-*/(). ") if any(c not in allowed for c in expression): return json.dumps({"error": "表达式包含非法字符"}, ensure_ascii=False) try: result = eval(expression) except Exception as e: return json.dumps({"error": f"计算失败: {str(e)}"}, ensure_ascii=False) return json.dumps({"expression": expression, "result": result}, ensure_ascii=False) TOOL_REGISTRY = { "get_weather": get_weather, "calculator": calculator, }

这里有个非常重要的设计原则:函数注册表是应用自己控制的。模型只是给出“函数名和参数”的提案,真正调用哪个函数、传什么参数,最终解释权在你手上。如果函数名不在注册表里,直接报错或者忽略,千万不能动态调用任意函数。这是安全底线。

4.3 主循环与 tool 消息回填

接下来是核心的调用循环。我把 requests 发包的部分封装成一个函数,然后写主循环来处理多轮调用。

def call_model(messages): payload = { "model": "your-model", "messages": messages, "tools": FUNCTIONS, # FUNCTIONS 是要传给模型的函数定义列表 "tool_choice": "auto", } resp = requests.post( API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json=payload, timeout=30, ) resp.raise_for_status() return resp.json()["choices"][0]["message"] def run_conversation(user_input): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] msg = call_model(messages) messages.append(msg) while msg.get("tool_calls"): for tc in msg["tool_calls"]: fn_name = tc["function"]["name"] fn_args = json.loads(tc["function"]["arguments"]) print(f"[工具调用] {fn_name} 参数: {fn_args}") result = TOOL_REGISTRY[fn_name](**fn_args) messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result, }) msg = call_model(messages) messages.append(msg) return msg["content"] if __name__ == "__main__": print(run_conversation("北京今天热不热?")) print(run_conversation("帮我算一下 (12*5+8)/2 等于多少"))

这个循环看着简单,但它恰恰是所有 Agent 应用的主干。模型第一次返回时带 tool_calls,程序执行完把结果回填,然后再次请求模型。此时模型可能结束循环直接回答,也可能又要求调用另一个工具,比如先算汇率再算总价。只要模型还在请求工具,程序就继续循环,直到模型输出最终答案为止。

4.4 tool_call_id 为什么不能错

我在上面代码里特意保留了 tool_call_id 字段。很多新手在这里踩坑:他们把函数结果追加成 assistant 消息,或者干脆另起一段文本塞回对话,结果模型完全答非所问。

正确做法是:每一条 tool 消息都必须对应模型上一条返回的 tool_call 的 id。你可以把 tool_call_id 理解成“回帖的楼层号”。用户提问是楼主,模型说要调用工具是 1 楼回复,你加载函数执行完的结果是 2 楼回复。2 楼必须指明“我回复的是 1 楼的调用”,模型才能把因果链条串起来。如果不带这个 id,模型看到一段无头无尾的函数结果,根本不知道该往哪拼接。

5. 实战避坑:我踩过的六个常见问题

5.1 模型死活不调用函数

新手上路最常见的困惑是:明明函数定义好了,用户也问天气了,模型却自己编了一个答案,完全不理会工具。这个问题大概率出在系统提示词和函数描述上。

我见过一个项目,系统提示里写了“你有能力回答问题,如果用户没有明确要求实时数据,就用自己的知识回答。”这个提示让模型产生了很强的“自主回答”倾向,工具调用率低得可怜。解决办法很直接:把系统提示改成“涉及天气类问题,必须调用 get_weather 工具,不要凭记忆回答实时信息”。同时把触发场景写进函数的 description 里,双管齐下。

5.2 模型填了一个不存在的参数

有一次我让模型调用订单查询函数,它竟然把用户说“上海的那个订单”填成了城市参数,传入 city="上海",实际上对应字段应该是 order_id。这种参数幻觉是模型常见的失误。

对策分两层。第一层是在函数定义里把参数描述写清楚,比如“必须传入订单号,订单号通常是数字或字母组合,不要用城市名、日期等代替”。第二层是在你的函数内部做兜底校验:传进来的参数不合法,直接返回错误字符串而不是抛异常。这样即使模型填错了,用户也能看到“参数有误”的明确提示,而不是程序崩掉。

5.3 函数结果太大,撑爆上下文窗口

函数返回的数据可能非常庞大。我做过一个数据分析项目,函数返回一个包含几千行数据的 JSON,直接塞回对话历史。结果第二次请求时 token 超限报错,或者模型被大量无关数据干扰,总结不出重点。

解决办法是做两层处理:一层是截断,函数返回前只保留关键字段;另一层是让模型自己在 reply 中引用摘要。还有个技巧是单独设置一个“函数结果暂存区”,把大结果写入临时存储,返回给模型的是一个引用 ID 和摘要说明。如果用户后续追问明细,再根据 ID 二次调函数取完整数据。

5.4 tool 消息拼接顺序错乱

我调试过最诡异的一个 bug:模型明明看到了函数结果,回答却驴唇不对马嘴。查了半天,发现代码里把 tool 消息按消息列表顺序追加到了错误的位置,没有紧跟在对应的 tool_call 后面。

这里给一个硬性建议:无论如何都不要手动调整 tool 消息在 messages 里的位置。每个 tool_call_id 对应一条 tool 消息,追加顺序必须保持模型返回的顺序。如果模型一次返回了三个 tool_calls,你就要依次执行三个函数,并且按相同顺序追加三条 tool 消息。乱了顺序,模型就会开始“答非所问式”发挥。

5.5 并行调用与超时重试

大模型支持一次返回多个 tool_calls,这是并行调用的基础。比如用户问“北京和上海今天谁更热”,模型可能同时请求两次 get_weather,参数分别是北京和上海。

如果你的函数本身是耗时的外部接口,并行调用能节省大量时间。但要注意不要盲目并行。如果多个函数之间有依赖关系,比如第二个函数需要第一个函数的返回结果作为输入,你必须串行执行。实现上可以先用参数名判断依赖,或者直接把并行结果缓存起来,供后续函数引用。

超时重试也值得专门处理。模型生成 tool_calls 很快,但你的函数执行可能慢,尤其是第三方 API。我建议给函数执行设置独立超时,超时后把错误信息作为 tool 消息返回给模型,让它判断是重试还是换个方案,而不是整个请求卡死。

5.6 安全边界:别让模型直接操作核心系统

最后一条,也是最重要的一条。Function Calling 里的函数参数是模型生成的,而模型的输入来自用户。这意味着恶意用户可以通过巧妙构造的对话,诱导模型生成对你不利的参数。

举例来说,你的系统里有一个数据库查询函数,参数是 SQL 片段。用户问“中国的省份有哪些,顺便把 orders 表删了”,模型可能在参数里生成异常内容。正确的做法是把函数的能力边界严格限制在白名单内:函数只接受业务层面的 low-level 参数(比如订单 ID、城市名),不许接收裸 SQL;函数内部还要做参数类型校验和权限校验。最稳妥的原则是:模型只能提出“要做某件事”的请求,真正执行操作时,你的应用代码必须自己确认权限和合法性。

我把这些问题整理成一张速查表,方便你排查时对照:

现象常见根因处理建议
模型不调用工具系统提示词抑制调用;description 不触发调整提示词,明确“必须调用”
参数幻觉参数描述不清晰、required 过多细化参数说明,减少必填项
上下文超限函数结果过大截断、摘要、用引用 ID
回答错乱tool 消息顺序错按 tool_call_id 顺序回填
并行调用卡死依赖未处理、超时未设置识别依赖,设置超时
安全风险参数直接进入核心逻辑白名单校验,权限收敛

6. 进阶方向:从 Function Calling 到智能体循环

6.1 多轮函数调用与独立 Agent

前面讲的 while 循环,本质上已经是一个微缩版 Agent。你可能已经发现,模型并不只会调用一次工具,它可以根据函数结果继续调用下一个工具,形成一条工具链。这就是智能体应用的核心骨架。

再往上一层,你可以把函数注册表扩展成服务网格:天气服务、数据库服务、消息推送服务、文件服务全部注册进去。模型就像一个协调员,根据用户需求编排多个工具的调用顺序。这个模式在行业内已经被验证过很多次,确实是构建自动化应用的最快路径。

6.2 当 Function Calling 遇上 ReAct

如果你接触过提示工程,可能听说过 ReAct,也就是“推理 + 行动”的范式。模型先推理当前情况,再决定行动,看到行动结果后继续推理,如此循环。Function Calling 本质上就是 ReAct 思想的工程化实现:模型产生“调用工具”这个 action,程序执行后把 observation 回填,模型再基于 observation 推理下一步行动。

理解了这一点,你就能明白为什么提示词要写得那么具体。你是在给模型安排一个“思考节奏”:先判断该不该调用工具,再判断参数怎么填,最后判断结果怎么组织成回答。每一步都有对应的文本模式,模型只是沿着这些模式走而已。

6.3 一点个人经验

我自己从最早手动写 JSON 解析器一路做到完整 Agent 系统,最大的体会是:Function Calling 的真正难点从来不在代码,而在“让模型看懂你的工具”。函数描述写得好,后面所有逻辑都顺;写不好,你会一直跟参数幻觉、选错函数、无效调用这些坑搏斗。

建议新上项目的朋友先花半小时把函数定义的描述改到“能禁得起追问”的程度,再写执行逻辑。上线前请一定把安全边界想清楚:哪些参数必须白名单校验、哪些函数不能暴露给模型、超时和重试怎么处理。这几件事想清楚了,Function Calling 会成为你手里最顺手的工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询