☰
Function Calling 从原理到实践:用真实工具调用解锁大模型生产力
2026/10/19 4:26:10 网站建设 项目流程

做过大模型应用的人大概都有过这种体验:让模型回答问题时,它说得头头是道,可一旦涉及需要真实数据的环节——查一下今天的天气、读一下数据库里的订单、算一笔贷款月供——它就露馅了。要么一本正经地编数据,要么告诉你“我可以帮你查”,然后原地不动。Function Calling 就是专门解决这个问题的机制:它让大模型在对话中主动发起一次外部工具调用,把参数准备好、把结果拿到手,再基于真实返回继续回答。这篇文章我想结合自己从零搭一个工具调用助手的实际过程,把原理、写法、踩过的坑一次讲清楚,适合正在做 LLM 应用的开发者,尤其是被“模型只会聊天”折磨过的人。

1. 为什么需要 Function Calling

1.1 模型天生不具备“动手”能力

先说一个最容易被忽略的事实:大语言模型本质上是一个文本生成器。它的训练目标是根据前面的文本预测下一个 token,而不是执行 SQL、调接口、算加减乘除。你说“帮我查一下北京天气”,它能给你写出一段像模像样的天气描述,但那个描述是基于历史语料的“想象”,不是实时数据。

这就带来一个尴尬的局面:模型可以写诗、写代码、做翻译,但在需要“真实世界反馈”的任务上,它只能靠猜。知识有截止日期、没有访问权限、没有计算引擎,这就是 LLM 的天花板。要突破这个天花板,就得让它学会调用工具,把“不知道的”交给外部系统,把“算不出来的”交给计算器,把“实时变化的”交给 API。

1.2 传统的“曲线救国”方案为什么难用

在 Function Calling 还没普及之前,想让模型去调外部工具,大家普遍的做法是“提示词硬拽”:

  • 在系统提示词里写:如果要查天气,请输出 JSON,格式是{"action": "get_weather", "args": {"city": "北京"}}。
  • 然后我们自己写正则、写解析器,从模型的输出里把这段 JSON 抠出来,再手动执行。

这条路子理论上可行,实际用起来非常痛苦。首先是格式不稳定:模型偶尔会在 JSON 前后加解释文字,偶尔把键名改成city_name,偶尔给你输出一坨 Markdown 代码块。解析器写了一堆补丁,还是防不住。

其次是安全风险高:模型生成的参数往往是幻觉参数,明明用户说的是“北京”,它能给你填成“背景”。更要命的是,模型对自己的能力边界没有认知,你让它选工具,它可能编一个不存在的工具名。

1.3 Function Calling 到底解决了什么

Function Calling 是模型服务商在接口层提供的一种结构化交互能力。简单说,你不用再费劲引导模型“输出一段 JSON”,而是把工具的“使用说明书”作为结构化参数传给模型。模型在推理时如果认为需要调用某个工具,会直接返回一个结构化的工具调用请求,包括工具名、参数 JSON,并且这个 JSON 是符合你定义的参数规范的。

这相当于把“让模型自由发挥文本”变成了“让模型在一个受限的选择空间里做决策”。它天生就规避了格式漂移问题,参数合法性也由接口层约束,比你自己写提示词硬拽稳定一个数量级。

而且它是模型原生的能力,不需要额外微调。业务逻辑完全外置在本地代码里,模型的角色只是“决定调用哪个工具、填入什么参数”,这个分工非常清晰。

1.4 适用场景与边界

Function Calling 适合的场景很多:

  • 查数据类:查天气、查股票、查订单、查库存。
  • 算数类:贷款计算、单位换算、统计汇总。
  • 操作类:发邮件、建工单、开关设备。
  • 信息整合类:从多个接口拉数据后综合回答。

但也要泼一盆冷水:它不适合所有任务。常识问答、文本改写、翻译这些模型自身能力范围之内的任务,挂工具反而多余。另外,如果你希望模型做复杂的多步推理,单靠一次 Function Calling 也不够,那是后文要讲的 Agent 循环。

2. 核心机制与工具描述设计

2.1 一次完整的工具调用长什么样

先建立一个整体认知:一次工具调用不是一个单次请求,而是一个多轮的“会话状态机”。用一句话概括就是:先让模型决定要不要调用工具、调哪个、参数是什么,然后你在本地执行工具,把结果作为一条新消息放回对话里,再请模型基于结果生成最终答案。

这个流程分四步:

  1. 把用户消息、历史对话和工具列表一起发给模型。
  2. 模型返回两种结果之一:要么直接给最终回答,要么返回一个或多个工具调用请求(工具名 + 参数 JSON)。
  3. 你在本地执行对应的函数,拿到真实结果。
  4. 把工具结果作为tool角色的消息放回对话,再次请求模型,模型基于真实结果完成回答。

如果把四次请求分别拆开看,你会发现模型的两次回答上下文是不一样的:第二次多了一条“工具调用结果”。这个结果就是模型生成最终答案的依据。

2.2 工具描述(Schema)是重中之重

既然模型要“决定调用哪个工具”,它至少得先“看懂”你的工具。这就是工具描述 Schema 的作用。一个工具描述一般长这样:

{ "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"] } } }

大多数人第一次写 Schema 时会犯一个错误:把description写成“函数内部怎么实现”。比如写“该函数调用气象接口并解析返回结果”,这对模型毫无帮助。模型不关心你的代码怎么写的,它关心的是“什么情况下该调用这个函数”。

所以description的写法应该像一段“决策说明书”:这个工具是干什么的、什么时候应该用、什么时候不应该用、参数该怎么取。比如“仅当用户询问某个具体城市的天气时调用,注意区分同名城市,尽量补全省份信息”,这样模型就不容易把一个模糊的地名直接塞进去。

2.3 参数设计的三个原则

参数设计直接决定了模型“能不能填对”。我总结出三条经验:

第一,参数名要直观。city比c1好,start_date比date_a好。模型对参数名也有语义理解,名字越贴近自然语言,它填对的概率越高。

第二,能用枚举就别用开放文本。如果单位只有摄氏度和华氏度两种,就用enum限制。模型在受限选项里选,基本不会出错;一旦放开自由输入,你就要做好接收“摄氏度”“摄氏”“C”各种变体的准备。

第三,必填参数尽量少。required里的字段越多,模型“编造”的可能性越大。如果某些参数有默认值,就不要放进required,让模型少承担一项决策。

另外还有一点容易被忽略:工具返回结果也会作为上下文喂给模型。所以返回的内容不是越全越好,而是“越简洁越好”。如果工具返回一个包含 50 个字段的 JSON,模型被无关信息干扰,反而抓不住重点。我通常会在工具函数内部先做一轮裁剪,只把模型回答问题需要的字段拼成一段精简文本返回。

2.4 模型是怎么“选中”工具的

从原理上理解,模型选择工具的过程本质上是一个“语义匹配”过程:它把用户的诉求和每一个工具的description、参数结构做一个语义对齐,选择最匹配的那个。这不是规则匹配,而是概率推理,所以会有不确定性。

这也解释了一个现象:为什么两个功能相近的工具放在一起,模型经常会选错。如果你的系统里同时有“查询今日天气”和“查询一周天气”两个工具,描述又写得模棱两可,模型大概率会随机选一个。解决方案不是写更长的描述,而是把相近工具合并成一个,用参数区分场景。

3. 实操:从零搭一个带工具调用的助手

3.1 场景定义与工具清单

为了让整个流程能落地,我设计了一个最小但完整的场景:做一个天气助手,支持两个工具。

第一个是get_weather,根据城市名返回实时天气;第二个是convert_temperature,做摄氏度和华氏度的换算。为什么要加第二个?因为它能演示一个很关键的能力:模型在回答问题时,可以在一次对话里连续调用多个工具。用户问“北京今天 20 度?那华氏度是多少?”,模型需要先查天气,再把结果换算成华氏度。

3.2 环境准备

我用 Python 来做演示,SDK 用主流模型服务商的兼容格式封装。你只要把llm_sdk替换成你正在用的客户端就行,接口风格基本一致,只有 import 和初始化不同。

import json from llm_sdk import Client client = Client(api_key="your-api-key")

这里要注意:不是所有模型都支持 Function Calling,尤其是经过量化或者蒸馏的小模型,支持情况参差不齐。建议用官方明确标注支持该能力的模型,否则接口可能不报错,但模型不会返回tool_calls。

3.3 定义工具函数与注册表

先定义两个真正的工具函数,它们就是普通 Python 函数,没有任何魔法。

def get_weather_impl(city: str, unit: str = "celsius") -> dict: # 真实项目里这里会调用天气 API,这里直接模拟 data = { "city": city, "temperature": 18, "condition": "晴", "wind": "3级", } return data def convert_temperature_impl(value: float, from_unit: str, to_unit: str) -> dict: if from_unit == "celsius" and to_unit == "fahrenheit": converted = value * 9 / 5 + 32 elif from_unit == "fahrenheit" and to_unit == "celsius": converted = (value - 32) * 5 / 9 else: return {"error": "unsupported unit conversion"} return {"value": round(converted, 2), "from_unit": from_unit, "to_unit": to_unit}

然后定义一个工具注册表,把“模型的工具描述”和“本地函数”绑定在一起。这个注册表是后面所有流程的核心。

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"] } } }, { "type": "function", "function": { "name": "convert_temperature", "description": "温度单位换算,支持摄氏度和华氏度互转。", "parameters": { "type": "object", "properties": { "value": { "type": "number", "description": "需要换算的温度数值。" }, "from_unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "原始单位。" }, "to_unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "目标单位。" } }, "required": ["value", "from_unit", "to_unit"] } } } ] TOOL_REGISTRY = { "get_weather": get_weather_impl, "convert_temperature": convert_temperature_impl, }

3.4 核心循环:发起请求与处理工具调用

接下来是主体逻辑。我把调用过程封装成一个run_conversation函数:

def run_conversation(user_message: str, max_iterations: int = 5): messages = [{"role": "user", "content": user_message}] for step in range(max_iterations): resp = client.chat.completions.create( model="your-model-name", messages=messages, tools=TOOLS, tool_choice="auto", ) assistant_msg = resp.choices[0].message messages.append(assistant_msg) # 模型没有要求调用工具,说明它打算直接回答 if not getattr(assistant_msg, "tool_calls", None): return assistant_msg.content # 模型要求调用工具,逐个执行并把结果回填 for tool_call in assistant_msg.tool_calls: name = tool_call.function.name args = json.loads(tool_call.function.arguments) output = execute_tool(name, args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(output, ensure_ascii=False), }) return "达到最大迭代次数,已终止。"

execute_tool是执行入口,加上异常兜底:

def execute_tool(name: str, args: dict): if name not in TOOL_REGISTRY: return {"error": f"unknown tool: {name}"} try: return TOOL_REGISTRY[name](**args) except Exception as e: return {"error": f"tool execution failed: {str(e)}"}

这里的核心理解点:assistant_msg要原样放回messages,不能只提取里面的文本。因为assistant_msg里包含tool_calls,模型需要在下一轮看到自己刚才的调用意图,上下文才是连贯的。tool角色的消息必须带tool_call_id,把它和模型的某个调用绑定起来。

3.5 试运行与结果观察

我们跑两个例子。第一个是单工具调用:

print(run_conversation("北京今天多少度?"))

模型大概率会返回一个tool_calls,调用get_weather,参数类似{"city": "北京"}。工具执行结果是{"city": "北京", "temperature": 18, "condition": "晴", "wind": "3级"}。模型看到这个结果后,最终回答可能是:

“北京今天 18 度,天气晴,风力 3 级,体感比较凉爽,建议穿一件薄外套。”

这就是一次完整的工具增强回答。

第二个例子是多步骤调用:

print(run_conversation("北京今天多少度?换算成华氏度是多少?"))

模型第一步调用get_weather,拿到 18 度;第二步它看到结果后,不会直接回答“18度等于华氏度多少”,而是再发起一次convert_temperature调用,参数是{"value": 18, "from_unit": "celsius", "to_unit": "fahrenheit"}。执行后拿到 64.4,最终综合回答。

注意这个过程中的关键点:第一次工具调用之后,模型没有立刻生成最终答案,而是继续调第二个工具,直到它认为所有信息都齐了,才停止调用工具,输出最终结果。我们的循环恰好支撑了这个多轮行为。

3.6 循环控制的必要性

不加max_iterations会出问题。我在调试时就遇到过模型陷入“循环调用”的情况:它会反复调用同一个工具,比如先查北京天气,又查北京天气,第三次还在查。看似合理,其实是它没有从结果里提取到足够信息,于是不停尝试。

这种死循环的根源有两个:一是工具返回内容太复杂,模型读不懂;二是你的循环逻辑无条件支持反复调用。所以我强烈建议设置迭代上限,一般 5 到 8 轮就足够。达到上限后直接返回当前内容,或者抛出一个“工具调用超限”的提示,不要无限等下去。

4. 进阶玩法:多工具路由、并行调用与 Agent 雏形

4.1 工具数量增多后,路由精度怎么保

当你只有两三个工具时,模型的选择通常很准确。但工具一多——比如十几个——就会出现“选择困难症”。我见过的最典型的问题是:两个工具描述都很长,模型把用户的问题同时匹配到了多个工具上,最后随机选了一个不太对的那个。

我的经验是三个办法:

  • 合并同类工具。把“按城市查天气”和“按 IP 查天气”合并成一个get_weather,参数里加一个location_type,让模型先判断该用哪种。
  • 限制描述长度。每个工具的description控制在三句话以内,第一句说功能,第二句说触发场景,第三句说排除场景。太长的描述反而稀释了关键信息。
  • 用命名前缀。给一组工具加上统一前缀,比如order_query、order_modify,让模型在语义空间里更容易区分。

工具路由的本质是分类问题。分类问题最怕“类别边界模糊”,所以你的目标是让每个工具的触发条件尽量正交,互不重叠。

4.2 并行调用:同时执行多条工具

很多模型接口支持并行工具调用,也就是一次响应里返回多个tool_calls。比如用户问“北京和上海今天都多少度?”模型可以直接返回两个工具调用请求,参数分别是北京和上海。

这个能力的好处是大幅减少交互轮次,坏处是你要保证工具之间没有依赖关系。如果你有两个工具,第二个需要用到第一个的结果,那就不能并行,必须分步执行。

处理并行调用的代码其实和循环差不多:

if assistant_msg.tool_calls: for tool_call in assistant_msg.tool_calls: # 逐个执行,全部结果都回填到 messages

但要提醒一点:并行调用时,工具执行顺序没有严格规定,如果你的工具函数有副作用(比如修改状态、扣减库存),并行调用可能会引入竞态条件。我一般只对“查询类”工具放开并行,写操作类一律串行。

4.3 用 tool_choice 控制模型的行为边界

tool_choice参数是容易被忽视但很有用的开关。它有几种取值:

  • auto:模型自己决定是否调用工具,适合大多数场景。
  • none:禁止调用工具,哪怕用户要求查天气也不调。
  • required:强制模型必须调用至少一个工具,适合“这个问题一定能用工具解决”的场景。

第三个取值在特定场景下非常有用。比如你的模型是做客服工单分类的,你定义了一个classify_ticket工具,你希望每一次请求都进入这个工具做分类,而不是让模型自由发挥。这时把tool_choice设成required或者直接指定工具名,就能保证模型不会“忘记”调用工具。

4.4 从 Function Calling 到 Agent

如果你理解了上面的循环,你会发现它其实就是 Agent 的雏形。模型提出工具调用需求,你执行,把结果回传,模型再继续推理。整个过程可以无限扩展:不限于天气查询,还可以是数据库查询、HTTP 请求、文件操作。

真正的 Agent 是在这个循环上加了两样东西:记忆和规划。记忆是让模型在长对话中记住前面的推理过程;规划是让模型在遇到复杂任务时,自己拆解成多个子步骤,逐个调用工具完成。Function Calling 提供的正是 Agent 的“行动接口”。

我之前接过一个需求:用户问“帮我查一下上周的订单总额,并和这个月对比”。如果没有 Function Calling,这个需求很难拆。有了它,模型可以自己规划:先调用订单查询工具拿上周数据,再调用订单查询工具拿本月数据,最后调一个计算工具做对比。模型不需要我们告诉它步骤,它天然具备这种拆解能力,我们只要给它足够的工具和正确的反馈循环。

5. 高频问题与排查心得

5.1 工具参数总是不对

这是最常见的坑。模型返回的参数缺字段、类型错、值不对,原因通常有三个方向。

  • 工具的description里没有说清参数取值规则。
  • required字段设置不合理。
  • 参数类型写错,比如把整数写成字符串,模型就会传字符串。

排查方法是记录完整的请求响应日志。我在本地开发时会给每次请求保存一份messages和tool_calls的快照,参数不对时直接看是模型理解错了,还是 Schema 定义错了。90% 的情况是 Schema 写得不够细。

5.2 模型死活不调用工具

明明该查天气,模型却自己编了一个回答。这通常是两个原因:

  • 模型版本太老,不支持 Function Calling,或者支持效果差。
  • 工具描述不够“诱人”,模型觉得自己能答,就不调了。

第二种情况的解法是在description里写清楚“不调工具的后果”。比如写成“当用户询问天气时,你必须调用该工具获取实时数据,否则你无法获得真实天气,只能编造答案”。把“必须”和“为什么必须”写清楚,模型会听话很多。

5.3 调用了错误的工具

两个工具语义相近时容易出这个问题。解决办法上文说过了:合并工具、精简描述、让触发条件正交。

补充一个细节:给工具名加动词前缀也有帮助。get_weather比weather更明确,create_order比order更明确。模型对动词的语义理解通常更精准。

5.4 工具调用成功但模型无视结果

模型调了工具,拿到结果,最后回答却还是按自己的理解胡说。这种情况多半是上下文里信息太多,模型的注意力被稀释了。

我的做法是在工具函数返回前,把结果整理成一段“非常像人类回答”的文本。不要直接返回原始 JSON,而是返回“北京当前温度18摄氏度,晴,风力3级”这样的自然语言。模型读起来省力,引用准确率也会提升。

5.5 上下文被工具结果塞爆

工具结果会一直累积在messages里,多轮调用后上下文窗口容易不够用。这个问题没有银弹,只能靠策略缓解:

  • 工具返回只保留摘要字段。
  • 历史消息做滑动窗口裁剪。
  • 如果基于 Agent 框架,可以把中间步骤折叠成一条摘要,只保留最终结果。

5.6 调试心得速查表

现象可能原因优先排查方向
参数缺字段required 设置不合理检查 Schema 必填项
参数类型错误类型声明不准确检查 parameters 类型定义
模型不调用工具description 不明确重写触发场景描述
调用错误工具工具边界重叠合并工具或精简描述
调用死循环结果太复杂/无终止条件加 max_iterations
回答不引用结果工具返回格式太乱把结果整理成自然语言
上下文爆掉工具结果太长裁剪摘要/滑动窗口

最后分享一个我自己的调试习惯:在开发环境里把每次调用的完整请求体打到日志里,包括messages和tools的最终形态。你可能会惊讶,很多时候模型的“错误表现”其实是你上一步传参传错了。Function Calling 是一个高度依赖“输入质量”的能力,输入里每一个字段都会影响最终结果。把这些细节控制好,它就能成为你把大模型从“聊天玩具”变成“生产力工具”的那座桥。

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

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

立即咨询