☰
大模型工具调用实战:从Function Calling到多工具调度
2026/9/26 11:46:55 网站建设 项目流程

1. 工具调用是什么:先想清楚一个反直觉的问题

先说个我在接入时踩过的大坑。前阵子给一个AI助手加"查天气"能力,模型接进来了,API key也配好了,结果不管我怎么问"今天上海冷不冷",模型都一本正经地给我编一个答案。它不是笨,是它压根没有手——大模型只能生成文字,不能替你发起HTTP请求、不能查数据库、不能执行任何真实操作。这就是"工具调用"这个词存在的意义:给模型一双手,让它通过调用你准备好的函数,去接触真实世界。

很多人第一次听说"工具调用",会下意识觉得这就是"调API"。如果你也是这样理解,那这个观念得先纠正一下。传统的API调用是你在代码里写死逻辑:用户说"查天气",你就在代码里if判断,然后去调天气API。这是开发者替模型做决策。而现代大模型里的工具调用(OpenAI叫Function Calling,Anthropic叫Tool Use,国内一些平台叫工具调用或插件机制,核心思想都一致),是模型自己决定要不要调用某个工具、传什么参数,你作为开发者只负责把工具清单给模型,然后等着响应里出现一个结构化的"调用请求"。

这里有个关键区别值得展开。传统流程里,意图识别是个大瓶颈——用户的话稍微绕一点,你的if-else就崩了。工具调用把"意图理解"这件事交给了模型,模型直接输出一个JSON,里面写清楚"我要调用get_weather这个函数,参数是city=上海"。这等于把最难的部分外包给了大模型的语言理解能力,而你需要负责的部分,反而是定义清楚"有哪些工具、每个工具的参数长什么样、工具执行完怎么把结果还给模型"。

从热搜词的角度看,"工具调用"这个关键词最近热度一直很高,而最新的网络热词又把它具体成了"api调用工具"。这两个词放在一起看就很有意思:大家想搜的是怎么把一个工具/服务变成API让程序调,但在大模型时代,真正核心的玩法是反过来——把API变成"模型可以调用的工具"。简单说:以前是代码调API,现在是模型调工具,工具背后才是API。

这一篇我用实际项目来拆这件事。我会先带你跑通一个最小可用的工具调用链路,再把我调试时踩过的坑完整复盘一遍,最后聊多工具并存时的调度设计和可观测性。内容不挑平台,OpenAI、Anthropic和国内兼容格式的平台都能套用,适合刚接触Agent开发、或者已经写了几个Demo但总觉得调用不稳定的同学。

2. 从0到1跑通一个最小闭环:天气助手是怎么活过来的

2.1 工具定义的格式:JSON Schema是模型和代码之间的契约

工具调用的第一步,是把你准备好的函数"翻译"成模型能读懂的描述。这个描述通用格式长这样:

{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市当前天气情况,包括温度、湿度和风力", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:上海、北京" } }, "required": ["city"] } } }

我第一次看到这玩意儿时觉得很简单,不就是个JSON吗?后来才发现,description写得好不好,直接决定模型会不会用这个工具。工具名、参数名虽然重要,但最容易被忽略的是描述信息。模型不是读你的代码,它是读你的描述来理解"什么时候该用这个工具、参数该怎么填"。如果description写得太笼统,比如就一句"查询天气",模型就可能在其他不该查天气的时候也去调用它,或者把参数填错。

以查天气这个函数为例,一个好的description应该是"查询指定城市当前天气情况,包括温度、湿度和风力。当用户询问某地天气、是否需要带伞、体感温度等问题时使用"。这样等于告诉模型两件事:这个工具能干什么,什么场景下该启用。

2.2 核心循环:模型决策、代码执行、结果回传

工具调用的请求-响应循环一共三步。很多刚入门的人只做了前两步,忘了第三步,结果模型就开始胡说八道。完整链路是这样的:

第一步,把用户的提问连同工具定义一起发给模型。第二步,模型返回一个"我想调用get_weather,参数是city=上海"的结构化结果,你的代码收到后去真实执行这个函数。第三步,把函数的执行结果作为一条新的消息回传给模型,模型基于真实数据组织回答。

用Python代码写出来,一个最小实现长这样:

from openai import OpenAI client = OpenAI(api_key="your-api-key") tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市当前天气情况,包括温度、湿度和风力。当用户询问某地天气、是否需要带伞等问题时使用", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,例如:上海、北京"} }, "required": ["city"] } } } ] def get_weather(city: str) -> str: # 真实项目中这里会调天气API,这里用mock数据代替 weather_map = { "上海": "21摄氏度,小雨,湿度80%", "北京": "18摄氏度,晴,湿度30%" } return weather_map.get(city, f"暂不支持查询{city}的天气") messages = [{"role": "user", "content": "上海今天需要带伞吗?"}] response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, ) # 第一步:模型决定调用工具 tool_calls = response.choices[0].message.tool_calls print(tool_calls)

输出的tool_calls大概是这样的:

[ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"上海\"}" } } ]

注意这里有个细节:arguments是字符串,不是对象,你需要先json.loads解析一下再传给函数。拿到这个结果后,第二步执行真实函数,第三步把结果回传:

import json # 第二步:代码执行函数 tool_result = get_weather(json.loads(tool_calls[0].function.arguments)["city"]) # 第三步:把结果回传给模型 messages.append(response.choices[0].message) # 把模型的原始响应加进去 messages.append({ "role": "tool", "tool_call_id": tool_calls[0].id, "content": tool_result }) # 再次请求,模型现在能基于真实天气回答 final_response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, ) print(final_response.choices[0].message.content)

我第一次跑通这个循环时最大的感受是:这玩意儿说白了就是个"对话补全"的变体。你给模型的messages里多了一条role=tool的消息,而这条消息必须准确对应用户触发的那次tool_call_id,否则模型会搞不清这个执行结果是哪个请求的。这个对应关系是很多人后期调试痛苦的根源,后面我会专门讲。

2.3 tool_choice参数:什么时候自动,什么时候强制

还有一个参数你可能已经注意到了:tool_choice。它控制模型"多大程度上倾向调用工具"。默认值是auto,意思是模型自主判断要不要调工具、调哪个。但有一些场景你需要干预。

  • tool_choice: "auto":默认行为,适合大多数对话场景,模型自己决定。
  • tool_choice: "required":强制模型必须调用至少一个工具。适合你明确知道这轮用户一定需要工具结果时,比如查询类Agent。
  • tool_choice: "none":禁止调用工具,纯粹聊天。
  • tool_choice: {"type": "function", "function": {"name": "get_weather"}}:指定模型只能用某个工具。

实际项目中,我一般默认用auto,但会在System Prompt里写清楚"当需要实时数据时必须调用工具"。真正让我意识到tool_choice有用的场景是敏感操作:比如一个"删除文件"工具,绝对不能让它被模型随意调用,这时候就要配合权限校验,而不是指望模型每次都选对。

3. 卡了一整天之后:一次"模型就是不调用工具"的完整排查链路

3.1 现象:不是选错工具,是压根不选

说回开头那个天气助手。我当时遇到的情况是:工具定义写好了,代码逻辑也对,但模型对"上海天气怎么样"这种明确问题,回答永远是"我无法获取实时天气信息,建议您查询天气网站"。你说它错吧,它答得还挺有礼貌;你说它对,可它明明有个get_weather工具可以用,就是不用。

这种"工具明明在清单里,模型却视而不见"的问题,是我见过最多人来问的。它不像报错那样有明确的异常信息,而是悄悄地让整个Agent变成一个"复读机"。我排查了一天,最后锁定了四个层面,按顺序分享给你,下次遇到这个问题可以直接照这个链路查。

3.2 第一层:tools参数真的传到了吗

别看这个问题蠢,它其实是最高频的翻车点。我排查的第一步是打印完整的请求体,确认tools真的跟着messages一起发出去了。很多SDK封装层会把tools参数吞掉,或者你在构造客户端时用了不同的实例,导致请求走到另一个没配置工具的服务上。

排查方法很简单:抓请求。如果你用OpenAI Python SDK,可以开debug模式,或者用一个代理工具(比如mitmproxy)看实际发到服务端的报文。如果你用中转/兼容平台,这一步更要重视,因为部分中转服务对tool参数处理不完整,甚至直接忽略。

我当时打印完请求体就发现了一个问题:tools字段在某个分支里被覆盖成了空列表。代码里有个地方重新赋值了client,导致后面的请求都没带tools。这属于纯代码bug,但如果不先抓请求,我可能会在上面三个层面瞎折腾很久。

3.3 第二层:description到底有没有"说人话"

排除了参数确实传了之后,第二个被怀疑的对象是description。模型对工具的"理解"完全来自这段文字,如果描述写得让模型觉得"这工具和用户问题无关",它就不会触发。

我之前有个工具叫get_weather,description写的是"获取天气数据"。这个词太干了。模型在判断"上海今天需要带伞吗"时,它需要知道这个工具能回答带伞问题。后来我把description改成"查询指定城市当前天气情况,包括温度、湿度和风力。当用户询问某地天气、是否需要带伞、体感温度等问题时使用",模型一下就"开窍"了。

这里有个容易误解的点:description不是给人看的,是给模型看的。你要站在模型的角度,想象它在一堆工具里做选择,你的描述要让它的"选择理由"足够明显。给每个工具写description时,我都建议至少包含两层信息:这个工具的能力边界(能干什么)、适合回答哪类问题(什么时候用它)。

3.4 第三层:temperature和top_p在捣乱

排查完前两层,我的问题还没解决,开始怀疑采样参数。工具调用本质上是一个"文本生成任务"——模型要从工具列表里"生成"一个选择。如果你把temperature调得很高(比如1.5),模型的输出分布会变乱,可能导致它要么选错工具,要么干脆不选,开始自由发挥。

我当时为了让回答更有"创意"把temperature设成了1.2,这直接导致模型对工具调用这件事变得很随意。把temperature降到0.2之后,工具选择立刻稳定了。

给个参考值:在工具调用相关的场景,我实测下来temperature在0到0.3之间最稳,top_p建议保持在1或者0.9以上,不要和temperature同时做过激调整。如果需要一点对话温度,可以单独对纯聊天场景做差异化配置,而不是全局改。

3.5 第四层:System Prompt在"抢夺方向盘"

最后一层是我排查最久的: System Prompt。很多Agent会在系统提示里写"你是一个友善的AI助手,如果不知道答案,请如实告知用户"。这本身没问题,但问题在于模型对"不知道"的判定。如果你的System Prompt里强调"不要编造信息、不知道就说不知道",模型在你的工具没有百分百把握时,会倾向于"承认自己不知道",而不去尝试调用工具。

那天气这事本来就该调工具啊?问题出在另一句话上。我当时的系统提示里写了"在获得足够信息后再回答问题",模型可能认为"我自己就能回答常识问题,不需要工具"。这是一种微妙的"动力倾斜"。

修复方式是在System Prompt里主动声明工具使用的优先级,比如加上一句:"当用户需求涉及实时数据、外部信息或需要执行具体操作时,请优先调用可用工具,不要仅凭自身知识作答。"这会显著改变模型的行为。系统提示和工具描述这两块文本,本质上是在"争夺"模型的注意力,你要明确告诉它:工具调用是高优先级通道。

3.6 最终修复:一个参数引发的"模型觉醒"

我那次最终定位到的原因其实就是temperature过高叠加system prompt里那句"请如实告知用户"。两件事单独看都不致命,合在一起就导致模型始终不调用工具。把temperature降到0.2、调整System Prompt后,同一段代码、同一个问题,模型立刻给出了正确的tool_calls。

这次排查给我最大的启发是:工具调用失败时,先别急着改代码,先改"文本"。工具描述、系统提示、参数配置,这三样是影响模型行为的三大文本要素,排查顺序也是从"有没有传"到"写得好不好"再到"采样太乱不乱"。

4. 工具返回之后才是真正的坑:结果回传和多轮对话细节

4.1 不回传结果,模型就开始编

把工具调用跑通后,我发现真正的复杂度在"工具执行完到模型再次回答"这个环节。这个环节如果处理不好,前面费劲搭的链路直接变成摆设。

最典型的错误是:模型返回tool_calls之后,你的代码只做了两步——执行函数、把结果打印出来,然后就没有然后了。用户问"上海天气怎么样",代码执行了get_weather("上海"),拿到了"21摄氏度小雨",但忘了把结果放进messages里再发给模型。这时候用户那边看到的是:模型之前已经说了"我来查询一下",然后就没有下文,或者再回复一句"抱歉我无法获取实时信息"。

正确的messages构造顺序长这样:

[ {"role": "user", "content": "上海今天需要带伞吗?"}, # 模型的原始响应,其中包含tool_calls {"role": "assistant", "content": None, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"上海\"}"} } ]}, # 工具的执行结果,role=tool,tool_call_id必须对上 {"role": "tool", "tool_call_id": "call_abc123", "content": "21摄氏度,小雨,湿度80%"} ]

有个细节很多人第一次接触时容易懵:assistant消息里的content应该是None,工具调用信息放在tool_calls字段里。如果你在assistant消息里同时填了content和tool_calls,部分平台会直接报错。另外,role=tool消息里的tool_call_id必须和assistant消息里tool_calls数组里某个id完全一致,这是模型把执行结果和"哪次调用"关联起来的唯一线索。

4.2 工具执行出错,别吞异常,要"告诉"模型

工具不是总能执行成功的。天气API可能超时,数据库可能连不上,文件可能不存在。很多人在这个环节直接try-except吞掉异常,然后给模型返回一句"执行失败",模型就回用户"抱歉我暂时无法获取天气信息"。

但如果你把真实的错误信息回传给模型,效果会好很多:

try: result = get_weather(city) except Exception as e: result = f"工具执行失败,错误原因:{str(e)}。请根据错误信息建议用户稍后重试,或提示用户检查输入。"

模型是具备"容错处理"能力的。你给它返回"查询超时"它可能会说"抱歉服务暂不可用";你给它返回"API返回429限流,建议1分钟后重试",它可能会说"当前查询人数较多,请稍后再试"。后者显然对用户更有用。工具调用的价值不只是"成功时帮你接力","失败时帮你善后"同样重要。

4.3 超时、重试、结构化返回:工程化三件套

我把工具执行这块抽出来做成了一个标准执行器,三个能力是标配:超时控制、自动重试、结果截断。

import time import json from concurrent.futures import TimeoutError def execute_tool(name, args, timeout=10, max_retries=2): if name not in tool_functions: return json.dumps({"error": f"未知工具: {name}"}) for attempt in range(max_retries + 1): try: # 用线程池或异步实现超时控制 result = call_with_timeout(tool_functions[name], args, timeout) # 结果建议压缩,避免超大字符串撑爆上下文 return json.dumps({"success": True, "data": str(result)[:2000]}) except TimeoutError: return json.dumps({"success": False, "error": f"工具执行超时({timeout}s)"}) except Exception as e: if attempt == max_retries: return json.dumps({"success": False, "error": str(e)}) time.sleep(0.5 * (attempt + 1))

为什么结果要截断成JSON?因为模型要靠这段文本来组织回答,如果工具返回一个几十KB的日志,不仅浪费token,还会让模型"读不过来",导致答非所问。我在实践里有一个经验值:工具返回给模型的内容,最好是经过裁剪的关键结论,而不是原始数据。比如查询订单工具,返回"共3条订单,总金额256元,最新一笔是今天15:00"就够,不用把每条订单的所有字段原样塞进去。

5. 多工具并存:从"能调"到"会选"的工程化之路

5.1 工具一多,模型就开始"选择困难"

天气助手跑通后,我开始往上加工具:查新闻、发邮件、算汇率、订会议室……当工具数量超过5个,新问题出现了:模型开始混淆工具边界。比如用户说"帮我查一下明天的会议安排",模型调了get_weather;用户说"这周末天气怎么样",模型反而去调了get_calendar_events。

刚开始我很纳闷,后来想明白了:模型面对一堆工具定义时,就像人面对一份没有分类的菜单,光看名字和短描述,很容易把功能相似的工具搞混。解决"选择混乱"问题,核心不是调模型参数,而是重新设计工具清单。

5.2 用"使用场景描述"给模型画靶子

我给每个工具追加了"使用场景(When to Use)"描述。比如:

{ "type": "function", "function": { "name": "get_calendar_events", "description": "查询用户日历中的日程安排。当用户询问会议、日程、今日安排、明日计划等时间相关问题时使用。注意:天气问题请调用get_weather,不要使用本工具。" } }

注意最后那句"不要使用本工具",这是给工具做"负向边界"约束,效果显著。模型在选择时会同时看正向描述(什么时候该用)和负向描述(什么时候别用),负向描述能减少两个相似工具之间的混淆。

5.3 合并参数 vs 拆分工具的取舍

另一个避坑经验是工具粒度。刚开始我把"发送邮件"定义了三个独立工具:send_email、get_email_drafts、get_email_contacts。后来发现模型经常在用户说完"帮我写封邮件给张三"时,同时调用三个工具,而且参数还可能互相矛盾。

我后来统一合并成send_email一个工具,把收件人、主题、正文放一个对象里,让模型一次性填充。合理的原因是:这几个动作是强关联的,拆成多个工具等于逼着模型做多次推理,出错概率指数增加。工具粒度的核心原则是:一个工具应该对应一个完整的、原子性的用户意图,而不是一个底层函数。但反过来,如果用户可能只需要"查看草稿"而不需要"发邮件",那把它合并进send_email又不够灵活。我自己的判断标准是:看用户最常见的说法是什么,如果一句话对应一个复合操作,就合并;如果一句话可能只触发其中一半操作,就拆分。

5.4 并行调用:提升效率但别贪多

多工具场景还有一个绕不过去的点:并行工具调用(parallel tool calls)。目前主流平台基本都支持模型一次返回多个tool_calls,你可以同时执行多个互不依赖的工具,减少交互轮次。比如用户问"明天北京天气怎么样,顺便帮我订个靠窗的餐厅位置",模型可能会同时返回get_weather和reserve_restaurant两个调用请求。

并行调用虽然爽,但要注意:如果工具之间有依赖关系,千万别并行。比如"先查余额再决定转多少钱",这两步必须串行。而且一次请求里工具调用数量太多后,模型出错的概率会上升,我一般会在系统提示里写明"最多同时调用两个工具,复杂任务拆解后逐步执行"。你还可以在API参数里通过对返回结果数量做约束,或者在后端对模型返回的tool_calls列表做一个简单的数量校验,超出预期就拒绝并让模型重新决策。

6. 最后一公里:可观测性和回归测试决定工具调用能否上生产

6.1 每一步都留痕:完整链路日志怎么打

工具调用有个让我头疼的特点:它是"概率性"行为,这次可能选对工具,下次同一句话可能选错。这意味着你不能靠"跑一次没问题"来判断系统可靠。我后来给工具调用加了一套完整的链路日志,每次请求都会记录以下信息:

{ "request_id": "req_xxx", "user_message": "上海今天需要带伞吗?", "tools_provided": ["get_weather", "send_email", "get_news"], "model_response": { "has_tool_calls": true, "tool_calls": [{"name": "get_weather", "arguments": "{\"city\":\"上海\"}"}] }, "tool_execution": { "selected_tool": "get_weather", "execution_success": true, "execution_time_ms": 120, "result_preview": "21摄氏度,小雨" }, "final_answer": "上海今天有雨,建议带伞。" }

这套日志对排查问题极其重要。如果没有它,模型选错工具时你只能看到一片传播信息,很难定位是工具描述问题、参数问题还是别的因素。有了它,你可以做统计:过去100次请求里,有多少次模型正确选择了工具,有多少次选错了,选错的是哪几个工具在互相混淆。工具调用这个场景,数据永远比感觉靠谱。

6.2 本地Mock测试:不用真实API也能验证逻辑

开发阶段每次都要调真实API太慢,也烧钱。我的做法是准备一个Mock工具执行器,在本地把工具的返回写成固定值,重点验证消息组装逻辑是否正确——尤其是assistant消息的tool_calls和tool消息的tool_call_id是否一一对应、工具结果的JSON格式是否稳定、多轮对话后messages长度是否增长异常。

Mock测试跑通后,再切换到真实API。这样可以隔离两类问题:链路逻辑问题(和真实工具无关)和外部服务问题(API挂了、参数不对等)。实践中链路逻辑问题的排查成本比外部服务问题高得多,先把前者用Mock解决,能省下大量调试时间。

6.3 自动化回归:给工具调用建一套"体检指标"

最后建议给工具调用的链路加自动化回归。不需要很复杂,你甚至可以写一个脚本,把一组典型的测试问题(黄金问题集)定时跑一遍,统计几个关键指标:

指标含义与目标
工具选择准确率该调用的工具是否被正确选中,目标是接近100%
参数合法率模型生成的参数能否被json.loads正常解析且通过校验,目标接近100%
流程完成率从用户提问到最终回答,是否完整走完"调用-执行-回传"链路
内容忠实度最终回答是否基于工具返回结果,而不是模型自己编

工具描述或System Prompt每次调整后,都跑一遍黄金问题集,对比指标变化。我见过很多项目在加了新工具后,旧工具的调用率悄悄下降——这就是回归测试能发现的问题,靠肉眼很难察觉。

我个人在实际操作中最深的体会是:工具调用最怕的不是"调不通",而是"看起来调通了但实际不稳定"。它的核心是文本设计,不是代码逻辑。把工具描述、使用场景边界、返回结果格式这三件文本层面的事情打磨好,配合日志和回归测试,工具调用的稳定性就会有一个质的飞跃。如果你正准备给Agent接入工具,我的建议很简单:先跑通一个最小闭环,把链路日志打全,再加第二个工具。工具调用的复杂度是叠加的,前面每一条经验都会在后面帮你省时间。

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

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

立即咨询