
1. 工具调用是什么先想清楚一个反直觉的问题先说个我在接入时踩过的大坑。前阵子给一个AI助手加查天气能力模型接进来了API key也配好了结果不管我怎么问今天上海冷不冷模型都一本正经地给我编一个答案。它不是笨是它压根没有手——大模型只能生成文字不能替你发起HTTP请求、不能查数据库、不能执行任何真实操作。这就是工具调用这个词存在的意义给模型一双手让它通过调用你准备好的函数去接触真实世界。很多人第一次听说工具调用会下意识觉得这就是调API。如果你也是这样理解那这个观念得先纠正一下。传统的API调用是你在代码里写死逻辑用户说查天气你就在代码里if判断然后去调天气API。这是开发者替模型做决策。而现代大模型里的工具调用OpenAI叫Function CallingAnthropic叫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_keyyour-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( modelgpt-4o, messagesmessages, toolstools, ) # 第一步模型决定调用工具 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( modelgpt-4o, messagesmessages, toolstools, ) print(final_response.choices[0].message.content)我第一次跑通这个循环时最大的感受是这玩意儿说白了就是个对话补全的变体。你给模型的messages里多了一条roletool的消息而这条消息必须准确对应用户触发的那次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_weatherdescription写的是获取天气数据。这个词太干了。模型在判断上海今天需要带伞吗时它需要知道这个工具能回答带伞问题。后来我把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\:\上海\}} } ]}, # 工具的执行结果roletooltool_call_id必须对上 {role: tool, tool_call_id: call_abc123, content: 21摄氏度小雨湿度80%} ]有个细节很多人第一次接触时容易懵assistant消息里的content应该是None工具调用信息放在tool_calls字段里。如果你在assistant消息里同时填了content和tool_calls部分平台会直接报错。另外roletool消息里的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, timeout10, max_retries2): 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接入工具我的建议很简单先跑通一个最小闭环把链路日志打全再加第二个工具。工具调用的复杂度是叠加的前面每一条经验都会在后面帮你省时间。