☰
Tool Calling工具调用实战指南:从机制到代码的完整解析
2026/10/9 3:22:14 网站建设 项目流程

“第二阶段第6节课:工具调用(Tool Calling) - 让AI拥有执行能力”这个标题,我当时看到的第一反应是:这课讲的东西,刚好是现在做AI应用开发最绕不开的一个坎。很多团队demo跑得很欢,一上生产就卡住,卡住的原因十有八九是模型只会“说”不会“做”。Tool Calling,也就是工具调用,解决的就是这个问题。这篇内容不是课程原文复述,而是我结合自己的实践,把工具调用的机制、代码实现、落地经验和坑点完整盘了一遍,给正在做Agent、做AI自动化任务的同学一个能直接参考的实操地图。

1. 为什么“会聊天”的AI不够用:工具调用到底补上了哪块短板

大语言模型本质上是一个超强的文本生成器,它的能力边界非常清晰:你给它一段文字,它给你一段文字。但现实世界的任务,往往不是“生成一段话”就能完成的。比如用户说“帮我查一下明天杭州的天气”,再强的模型如果没有外部数据源,它的回答只能是“我无法实时查询天气,请你打开天气App”这类正确的废话。这就像你雇了一个知识渊博的助理,但这个助理没有手机、没有电脑、不识字以外的任何文件,只能靠脑子里的记忆跟你聊天。

工具调用(Tool Calling,早期也叫Function Calling)就是给这个“助理”配上了手脚。它的核心逻辑不是让模型自己去执行动作,而是让模型在需要外部能力的时候,输出一个结构化的“调用意图”,由你的程序去真正执行这个动作,再把执行结果喂回给模型,让模型基于真实结果继续回答。这个闭环一旦跑起来,AI就从“知识问答”升级成了“任务执行”。

我见过不少刚接触这个概念的同学会混淆两个东西:一个是让模型输出代码然后你执行代码,另一个是Tool Calling。前者是“模型写程序”,后者是“模型发指令”。Tool Calling更接近组织行为:模型像一个项目经理,它不亲手搬砖,但它明确告诉你“现在需要调用哪个工具、传什么参数”,搬砖的是你写在系统里的执行函数。这个抽象层的价值在于,你可以在不改变模型能力的前提下,灵活地给AI接入任何外部系统:查天气、查数据库、发邮件、调API、操作浏览器,甚至控制硬件。

那么这一课为什么重要?因为Agent(智能体)这个词最近被炒得非常热,但拆开看,Agent的核心骨架无非是“模型+规划+记忆+工具调用”。其中工具调用是模型和环境交互的唯一通道,是Agent真正能“干活”的关键。没有工具调用的对话模型,本质上是一个高级玩具;有了工具调用的模型,才具备成为生产力工具的潜力。后面所有关于多AI协作、AI编程、AI自动化的进阶玩法,全都建立在工具调用这个地基之上。

2. 工具调用的完整链路:模型、工具定义和结果回填这三方怎么配合

要把Tool Calling用好,首先得把它的运行机制彻底吃透。它不是一个魔法开关,而是一个有明确协议的多方协作过程。整个链路可以拆成五个环节,每一步都有它自己的职责。

第一步:声明工具清单。你在调用模型API的时候,需要在请求参数里带上一个tools数组,数组里每个元素描述一个工具的名称、用途和参数要求。这个描述用的是JSON Schema格式,模型会“阅读”这些描述,然后在需要的时候从中挑选。这一步的质量直接决定模型能不能正确调用工具。工具描述写得含糊,模型就很容易猜错参数类型,甚至完全忽略这个工具。

第二步:模型判断是否调用工具。模型收到用户消息后,会先进行内部推理:要回答这个问题,是否需要外部信息或外部动作?如果不需要,它直接返回正常文本;如果需要,它就会返回一个结构化的工具调用指令,包含工具名称和一个JSON格式的参数对象。注意,这时候模型并没有真正执行任何东西,它只是表达了意图。这一步是整个链条最容易让人困惑的地方——模型返回的不再是字符串,而是一个特殊的数据结构。

第三步:你的代码解析指令并执行。你需要检查模型返回的内容里有没有tool_calls字段。如果有,就取出工具名和参数,去你预先写好的函数映射表里找到对应的函数,执行它。这个是纯业务代码的活,模型干不了,你必须要自己实现。比如工具名是get_weather,你的代码里就会有一个get_weather(city, date)函数,把模型给的参数传进去跑。

第四步:把结果回填给模型。函数执行完会得到一个结果,你需要把这个结果作为一条新的消息追加到对话历史中,这条消息的角色是tool,并且要带上对应的工具调用ID,让模型知道这个结果是给哪一次调用回应的。这一步容易出错:很多人直接把结果拼进用户的提问里,而不是作为独立的tool消息返回,这样模型会分不清数据的来源和身份。

第五步:模型生成最终回答。模型看到工具执行结果后,会基于这个真实结果组织语言回答用户。比如天气工具返回了“杭州明天小雨,21到25度”,模型就会告诉用户“明天杭州有小雨,出门记得带伞”。如果模型觉得结果还不够,它还可以再次发起新的工具调用,这就是Agent循环的基础。

整条链路用一个生活化的比喻来理解就是:用户向“助理”(模型)提需求,助理判断自己不知道答案,就写一张“工单”(结构化调用指令),上面写明“找张三(工具名)要某个数据(参数)”,你拿着工单去问张三,张三给你一份结果,你把结果贴在工单上还给助理,助理看了结果给你出一份汇报。

这里有一个关键细节值得多说一句:tool角色的消息回填时,tool_call_id必须和模型返回的调用ID完全匹配。如果你用的是不同供应商的SDK,字段名可能略有不同,但协议本质一致。这是很多人第一天接入就报错的高频原因,我在后面章节会专门展开。

3. 从零手写一个最小Tool Calling系统:跟着代码走一遍

理论讲再多,不如直接把代码跑起来。我这边用一个最小可用的例子演示完整的工具调用流程,这个例子用Python写,假设你用的是OpenAI风格的接口(目前大多数模型供应商都兼容这个协议,通义、Kimi、智谱等也都支持工具调用)。先做一个最朴素的天气查询工具,让模型具备获取实时天气的能力。

3.1 定义一个最简单的业务函数

我们模拟一个实际的天气查询函数,真实项目里这个函数会去请求气象服务商的API,这里先返回固定数据。

def get_weather(city: str, date: str) -> str: """ 模拟实时天气查询,真实场景可以替换为第三方天气API。 """ # 真实项目里,这里会调用天气服务接口 mock_weather = { "city": city, "date": date, "weather": "小雨", "temperature": "21~25℃", "humidity": "78%", } return str(mock_weather)

这个函数本身没有任何魔法,关键在下一步:怎么让模型知道有这个工具的存在,以及需要哪些参数。

3.2 用结构化Schema让模型知道“能干什么”

工具定义是一个JSON列表,每个工具至少包含type、function,function里又有name、description和parameters。这个description特别重要,很多同学的坑都在这里。

tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市在指定日期的天气情况,包括天气现象、温度和湿度。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:杭州、北京、上海" }, "date": { "type": "string", "description": "日期,格式为YYYY-MM-DD,例如:2025-06-01" } }, "required": ["city", "date"] } } } ]

这里有一个容易踩的点:description不是写给你自己看的,是写给模型看的。写“查询天气参数”和写“查询指定城市在指定日期的天气情况,包括天气现象、温度和湿度”对模型而言完全是两个信息量。前面的那种写法,模型经常不知道该填什么;后面的写法,模型基本一次就能生成正确参数。工具描述本质上是你给模型的“使用说明书”,说明书越清晰,模型出错率越低。

3.3 发起对话并解析模型返回的工具调用指令

接下来用对话补全接口发起请求,代码逻辑分三步:先发消息,再检查返回,最后执行回调。我直接贴核心代码。

from openai import OpenAI client = OpenAI( api_key="your-api-key", base_url="https://api.example.com/v1" # 换成你的接口地址 ) def run_conversation(user_input: str, tools: list): messages = [{"role": "user", "content": user_input}] response = client.chat.completions.create( model="model-name", # 换成你使用的模型名 messages=messages, tools=tools, tool_choice="auto" ) assistant_msg = response.choices[0].message # 检查模型是否想要调用工具 if assistant_msg.tool_calls: # 先把assistant的消息加入对话历史 messages.append(assistant_msg) # 遍历所有工具调用 for tool_call in assistant_msg.tool_calls: function_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) if function_name == "get_weather": result = get_weather(**arguments) # 构造tool角色的回填消息 messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) # 把带有工具结果的完整上下文再次发给模型 second_response = client.chat.completions.create( model="model-name", messages=messages, tools=tools ) return second_response.choices[0].message.content else: # 模型没有调用工具,直接返回内容 return assistant_msg.content # 测试调用 print(run_conversation("明天杭州天气怎么样?", tools))

这段代码有几个细节需要特别注意。首先是tool_choice参数,设为auto表示让模型自主决定是否调用工具;如果设为none,就强制模型不许调用工具;设为{"type": "function", "function": {"name": "get_weather"}}则强制模型必须调用这个工具。实际生产场景中,auto是最常用的,但某些任务你明确知道必须走某个工具,强制选择能提高稳定性。

其次是arguments字段,模型返回的是一个JSON字符串,不是对象,必须用json.loads解析。如果你的模型偶尔返回不合法JSON(这是有概率发生的),后面我会讲怎么兜底。最后是消息历史的管理:assistant_msg必须原样加入对话历史,而不是只把它的文本部分加进去,因为其中携带的tool_calls信息对下一次请求是必要的。

3.4 把执行结果回填给模型生成最终回答

执行到这里,模型会看到这样一条对话历史:

user: 明天杭州天气怎么样? assistant: tool_calls: [get_weather(city="杭州", date="2025-06-01")] tool: {"city": "杭州", "date": "2025-06-01", "weather": "小雨", "temperature": "21~25℃", "humidity": "78%"}

注意最后一条tool消息的tool_call_id必须和上面assistant的tool_calls[0].id一致。如果你用messages.append(assistant_msg)保存历史,然后再追加tool消息,这两条消息在协议上就构成了一个“一问一答”的配对。模型读到这个配对后,就会用函数返回的真实数据组织回答。如果ID对不上,很多API直接报错,有些API虽然不报错但模型会答非所问,这个坑特别隐蔽。

我用这个“最小系统”跑了几个不同的问法,比如“今天北京会下雨吗?”“杭州后天适合出游吗?”,模型都能正确提取城市和日期参数,并且基于模拟数据输出逻辑完整的回答。但注意,上面这个demo只做了一轮工具调用,真实场景里经常需要多轮调用,比如用户说“依次查北京、上海、广州三个城市的天气并做个对比”,这时候模型可能会连续发起三个工具调用,你需要全部执行完,再一次性把三个结果都回填。这部分的循环逻辑要设计好,不能只处理第一个调用就返回。

4. 一个贴近实战的小例子:让AI替你跑通“查库→计算→汇报”全流程

单工具的demo只是开胃菜,工具调用的真正威力在于多工具协同。我在自己的一个数据运营项目里实现过一个“数据问答助手”,它需要同时操作三个工具:查数据库、做数值计算、查部门成员名单。这个场景足够典型,我把设计思路拆开讲。

tools = [ { "type": "function", "function": { "name": "query_sales_data", "description": "查询指定时间范围内公司各业务线的销售数据。返回结果为JSON数组。", "parameters": { "type": "object", "properties": { "start_date": {"type": "string", "description": "开始日期,YYYY-MM-DD"}, "end_date": {"type": "string", "description": "结束日期,YYYY-MM-DD"}, "metric": {"type": "string", "description": "指标名称,可选值为:revenue, orders, new_users"} }, "required": ["start_date", "end_date", "metric"] } } }, { "type": "function", "function": { "name": "calculate", "description": "执行数学计算,支持加减乘除、百分比、平均值。表达式用Python语法。", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,例如: (100+200)/3"} }, "required": ["expression"] } } }, { "type": "function", "function": { "name": "get_product_owner", "description": "获取当前负责某条业务线的同事姓名,用于对接或汇报。", "parameters": { "type": "object", "properties": { "biz_line": {"type": "string", "description": "业务线名称,例如: 华东区、华南区、华北区"} }, "required": ["biz_line"] } } } ]

用户的问题是:“华东区6月总营收是多少?环比5月增长了多少?这个数字应该找谁汇报?”这个看似简单的问题,实际上需要模型完成一次规划:先查华东区6月的销售数据,再查5月的数据,然后用calculate工具计算增长率,最后调用get_product_owner找到负责人。模型会在一次请求里逐步返回多个工具调用,我的代码就需要处理多工具迭代。

这里我强烈建议用循环结构来写,而不是写死“先后调两次”的代码。伪代码如下:

def agent_loop(user_input, tools, max_turns=5): messages = [{"role": "user", "content": user_input}] for _ in range(max_turns): resp = client.chat.completions.create( model="model-name", messages=messages, tools=tools ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content # 模型不再调用工具,输出最终回答 messages.append(msg) for tool_call in msg.tool_calls: result = execute_tool(tool_call.function.name, tool_call.function.arguments) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) return "已达到最大工具调用轮次,请简化你的问题。"

这个max_turns限制非常关键。你以后做Agent一定会遇到模型陷入“工具调用死循环”的情况,比如它反复查询同样的数据不知道收敛。设置一个上限能保证程序不会无限耗下去,我在生产环境默认设置5轮,复杂任务最多10轮。顺便说一句,每一轮调用都会消耗token,工具调用比普通对话烧钱快得多,尤其是把大段工具结果回填给模型的时候,上下文一涨,费用肉眼可见地往上跳。这个问题我在第五节还会讲。

回到那个数据问答的例子,模型实际产生的调用顺序是:先query_sales_data拿6月营收→再拿5月营收→再calculate算增长率→再get_product_owner找人。整个链条四步,每步的结果都作为下文的依据。最终模型生成的回答是:“华东区6月总营收为XXX万元,环比5月增长XX%,目前该业务线负责人是王XX,可以直接找他对接。”整个过程用户只发了一句话,剩下的脏活累活全由工具调用链完成。这就是Tool Calling典型的生产力场景。

我建议你做集成测试的时候,专门准备几组“多跳问题”,比如“查A和B的数据并做对比,然后告诉我今年给A业务定的目标是多少”,这类问题最能暴露模型在规划上的薄弱点。有些模型会在参数里把“目标”错误地填成销售数据查询,有些模型会算错增长率。出现这些问题不一定是模型笨,很多时候是工具描述互相混淆,模型分不清哪个工具负责哪件事。我的经验是:每个工具的description要写得像一份独立的职责说明书,而不是简单的“XX功能”。

5. 我把Tool Calling接入真实项目后遇到的坑与取舍

从demo到生产,工具调用这个环节会暴露出非常多文档里不会写的问题。我把自己在项目中踩过的坑逐个列出来,每个都是真金白银换来的教训。

5.1 模型“死活不调用工具”怎么办

最让人血压飙升的情况就是:你明明把tools传进去了,模型却装作看不见,直接瞎编一个答案。比如你问“查询一下订单ID为10086的交易状态”,模型直接回复“该订单已发货”,但你的系统根本没有这个订单。这种情况在真实环境中很常见,原因通常有三个。

第一个原因是模型能力不够强或模型版本不支持工具调用。不同模型对工具调用的支持差异极大,有些老版本模型要么不支持这个协议,要么支持得很勉强。解决办法是换模型或升级版本,这不是你能通过prompt解决的。

第二个原因是工具描述不清晰,模型不知道什么时候该用。比如你只写“订单查询函数”,模型可能不知道“交易状态”就是它的职责范围。我的做法是在description里写清楚“当用户询问订单状态、物流信息、发货情况时,必须使用此工具;不要根据记忆或推测回答订单相关内容”。给模型划清楚边界,比含糊的职责描述有效得多。

第三个原因是工具数量太多,模型被干扰了。当你一次性传给模型20多个工具时,模型很容易漏选。解决思路是分组:第一轮先根据用户意图让模型选择一个工具类别,第二轮再在类别内部细分。这种分层的工具设计在大型Agent里几乎是必须的。我自己的经验是,单次请求的工具数量最好不要超过10个,超过之后调用准确率会明显下降。

如果以上方法都试过还是不行,还可以用tool_choice强制指定。我之前遇到过一个场景,用户一定会先查询身份令牌再调用其他功能,我就把tool_choice设成{"type": "function", "function": {"name": "get_token"}},强制模型第一步必须调这个工具,问题立刻解决。

5.2 参数幻觉和JSON解析失败:工具调用的兜底策略

模型生成的工具参数并不总是合法的。常见问题包括:参数名拼错(比如把start_date写成startdate)、日期格式不对(给了“6月1号”而不是“2025-06-01”)、参数类型错了(required里写["city", "date"],模型却漏了date)。还有一种更麻烦的情况:模型的的arguments字段返回的不是合法JSON,而是带着解释性文字的文本。

针对解析问题,我写了一个兜底函数,逻辑是:先直接json.loads;失败就尝试截取第一个{到最后一个}之间的内容重新解析;再失败就返回一个错误消息给模型,让它重新生成。这比程序直接崩溃要体面得多。

import json import re def safe_parse_arguments(raw_str: str): """尝试多种方式解析模型返回的参数,某一层失败就进入下一层。""" if not raw_str: return {} # 方式1:直接解析 try: return json.loads(raw_str) except json.JSONDecodeError: pass # 方式2:截取JSON对象片段 try: start = raw_str.find("{") end = raw_str.rfind("}") if start != -1 and end != -1: return json.loads(raw_str[start:end+1]) except json.JSONDecodeError: pass # 方式3:提取所有key-value对(简易版) pattern = r'"(\w+)"\s*:\s*"([^"]*)"' matches = re.findall(pattern, raw_str) if matches: return {k: v for k, v in matches} return {}

这个兜底函数没法做到100%完美,但能把解析失败率从肉眼可见的频繁降到偶尔发生。对于参数值的问题(比如日期格式不对),更好的办法是在工具描述里给示例值。比如说"date": "日期,格式为YYYY-MM-DD,例如:2025-06-01",模型就会照着示例给格式正确的值。少给一个清晰示例,模型就可能还给你一个“明天”或者“6月1日”。

我还遇到过一个更隐蔽的问题:模型在参数里填了一个不存在的业务线,比如明明只有“华东区、华南区、华北区”,它给你填了个“华西区”。这种幻觉不会在JSON层面报错,但会在执行函数时报错。我的做法是在执行函数内部做参数校验,发现不合法就返回一条结构化错误信息给模型,让模型基于错误信息重新调用或向用户澄清。这也是工具调用链路里不可缺失的一环。

5.3 多工具并发、上下文膨胀和费用控制

当工具数量多了,每个工具结果都在往对话历史里塞,token消耗会迅速增加。举个例子:query_sales_data返回一个100行的JSON数据,每轮对话都要带着这100行数据来回传给模型,一轮两次两次翻倍,上下文窗口很快就满了。我的应对措施有三条。

第一条是精简工具返回值。很多API会把不需要的字段一起返回,我在工具函数内部就做好裁剪,只保留模型回答用户问题所需要的最小字段集。比如查询订单信息,只需要订单状态、金额、时间,其他内部字段一律不返回。有人说“让模型自己看完整数据更准”,但实际测试下来,裁剪字段对回答质量影响不大,却能显著降低token消耗。

第二条是清理历史消息。在长对话场景中,如果中间过程已经得出结论,之前的工具结果就没有存在意义了。我写了一个简单的清理逻辑:当对话历史超过一定长度时,把前面的assistant工具调用记录和对应的tool消息压缩成一句摘要,比如“用户查询过A产品的销量,结果为1000件”。这样既保留了上下文逻辑,又控制了窗口大小。

第三条是合理设置max_turns。这个前面提到过,但我要强调它同时也是一个费用控制手段。每一轮工具调用都有成本,如果任务很复杂,可以拆成多个独立的小任务分别处理,而不是让一个Agent一口气做完所有事。拆分是控制成本和提升准确率最朴素但最有效的方法。

5.4 安全边界:工具调用不能什么都让它干

工具调用赋予了AI执行能力,执行能力必须有边界。我在项目里给自己定了几条硬性原则,分享出来供你参考。

第一,涉及发送消息、转账、删除数据等高风险操作的工具,必须加二次确认护栏。模型只负责“建议调用”,真正的执行权必须由人工确认放行。我见过有人把“发送邮件”工具直接接进Agent,结果模型误判用户意图,给所有人发了一封测试邮件。这种错误你没法指望模型自己避免,只能在流程上做约束。

第二,工具内部要做权限校验。调用delete_order之前,先检查当前会话有没有权限执行删除操作。模型的天职是生成自然语言和调用意图,它不可能替你判断权限边界,权限属于业务层,必须写死在代码里。

第三,不要给模型暴露危险参数的自由度。比如查询数据库的工具,最好在函数内部把表名、字段名做白名单校验,防止模型生成一段危险查询语句。不要天真地认为模型没有恶意,它只是可能犯错,而犯错的方向往往超出你的预期。

安全这块不能省,因为工具调用的每一步都是真实世界的操作。你之前可能习惯了“模型说错了也无所谓”,但接了工具之后,说错就可能意味着发错了邮件、删错了数据,这种代价不是token能换回来的。

6. 从这节课提炼出的Tool Calling接入清单

最后我把实践中的要点整理成一个清单,你接入工具调用时照着检查一遍,能省掉不少排查时间。

检查项具体要求常见错误
工具描述写清楚职责边界、参数含义、示例值描述太短,模型不知道何时调用
参数Schemarequired列全必填项,类型要准漏必填项,类型写成number但实际传string
消息历史assistant消息原样保存,tool消息带正确tool_call_id只保存文本,丢失工具调用结构
参数解析对arguments做容错解析直接json.loads,非法JSON就崩溃
循环控制设置max_turns上限无限循环,token烧完
返回值精简只保留必要字段,不塞无关数据大段JSON来回传,上下文飞速膨胀
工具数量单次请求控制在10个以内,多则分组一次传几十个工具,模型错选漏选
安全护栏高风险操作人工确认,执行函数做参数校验模型说调用就直接执行,不设闸门
多轮规划设计循环结构支持连续调用多个工具只处理第一轮调用就返回

这个清单不是课本上的标准答案,是每一条都在线上环境验证过的经验。你按这个顺序配置你的工具调用系统,至少能避开80%的初学者问题。我在做真实项目的过程中最大的感受是:Tool Calling本身不难,难的是把每个细节都考虑周全。模型的能力会随着版本更新越来越强,但工具调用的架构思想是稳定的——模型负责意图理解,你的系统负责真实执行,两边各司其职,才能构建出真正可靠的AI应用。

如果你正在做Agent,或者准备做AI自动化工具,我建议你先把这节课的工具调用机制吃透,再往Agent方向走。一个稳定可靠的单个工具调用,比一个能连续调用但频繁出错的高级Agent有价值得多。等你把工具层的稳定性打磨到位,再去看那些复杂的编排框架,会发现很多高级特性其实就是工具调用在更大规模上的重复应用。

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

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

立即咨询