1. 从“会聊天”到“能干活”:工具调用到底解决了什么问题
大模型刚火起来那阵子,大家最直观的体验就是“它能陪我聊天”。你问它答,写诗、翻译、润色、编故事,样样都行。但真把它放进一个需要“动手”的场景里,问题立刻就暴露了:你让它帮你查一下明天某地的天气,它只能凭训练数据里的旧信息瞎猜;你让它帮你算一笔复杂的账,它可能一本正经地给出一个错误答案;你让它帮你把一段文字存进某个系统,它压根没有“手”,只能告诉你“我做不到”。
这就是纯语言模型的天花板——它本质上是一个“下一个词预测器”,所有的能力都局限在“生成文本”这个动作里。它没有实时数据,没有计算器,没有数据库,没有外部系统的操作权限。你问它今天有什么新闻,它只能基于训练截止日期之前的记忆来回答,而那个日期可能已经是几个月甚至几年前了。
工具使用与函数调用,就是用来捅破这层天花板的。它的核心思路非常朴素:既然模型自己不会查天气、不会算数、不会操作数据库,那就给它配一套“外部工具”,让模型在需要的时候主动“喊一声”——“我要调用查天气的工具,参数是某某城市”——然后由外部的程序去真正执行这个动作,再把结果塞回给模型,让它基于真实结果继续对话。
打个比方,纯语言模型就像一个知识渊博但被关在房间里的人,他读过很多书,但看不到窗外,也没有电话。函数调用就是给他装了一部电话和一本通讯录,他可以在需要的时候拨号给“天气台”“计算中心”“数据库管理员”,拿到最新信息后再继续跟你聊。
这套机制的价值在于,它把大模型从一个“信息孤岛”变成了一个“调度中枢”。模型负责理解你的意图、决定该用什么工具、提取工具需要的参数、解读工具返回的结果;而真正的执行动作,交给外部程序去完成。两者分工明确,各司其职。
对于正在学习这一讲的读者来说,你需要建立的第一个认知是:函数调用不是模型在“执行”函数,而是模型在“生成一段结构化的调用意图”。真正执行函数的是你自己的代码。这个认知如果搞混了,后面所有的调试都会一头雾水。
这一讲的内容,我会围绕“工具怎么定义”“模型怎么决定调用”“参数怎么传”“结果怎么回灌”“多轮怎么串起来”“生产环境怎么防坑”这几条线展开,尽量把每一步背后的“为什么”讲透,同时给出可以直接抄作业的代码骨架和参数配置。
2. 工具描述文件:模型眼里的“工具说明书”长什么样
2.1 工具定义的三要素:名称、描述、参数模式
模型本身并不知道你有哪些工具可用。它之所以能“决定调用某个工具”,是因为你在每一次请求里,把可用工具的清单以结构化格式一起发给了它。这份清单就是模型眼里的“工具说明书”。
一份标准的工具描述,通常包含三个核心部分:
- 名称(name):工具的标识符,模型在生成调用意图时会引用这个名字。命名要短、要唯一、要能自解释,比如
get_weather、search_flights、create_calendar_event。不要用tool1、func_a这种毫无信息量的名字,模型在多个工具之间做选择时,名称本身就是重要的判断依据。 - 描述(description):用自然语言说明这个工具是干什么的、什么时候该用、什么时候不该用。这是整个工具描述里最容易被低估、却最影响调用准确率的部分。很多人写描述就一句话“查询天气”,结果模型在用户问“明天出门要不要带伞”时,根本想不到该调用它。好的描述应该写清楚:功能边界、典型触发场景、返回什么信息。
- 参数模式(parameters schema):用 JSON Schema 格式描述这个工具需要哪些参数、每个参数是什么类型、哪些是必填、取值范围是什么。模型会根据这个模式来提取和组装参数。
下面是一个查天气工具的完整描述示例:
{ "name": "get_weather", "description": "查询指定城市在指定日期的天气情况。当用户询问天气、气温、是否下雨、是否需要带伞、适合穿什么衣服等问题时使用。返回该城市当天的天气状况、最高最低气温、降水概率。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如'北京'、'上海'、'广州'" }, "date": { "type": "string", "description": "查询日期,格式为YYYY-MM-DD。如果用户说'今天'或'明天',请根据当前日期换算成具体日期。" } }, "required": ["city", "date"] } }2.2 描述写得好不好,直接决定调用准不准
我踩过的最大的坑,就是早期写工具描述太随意。当时做了一个“发送邮件”的工具,描述只写了“发送邮件”。结果用户说“帮我通知一下团队明天开会”,模型完全没反应,因为它不知道“通知团队”可以通过发邮件来实现。后来我把描述改成“向指定收件人发送邮件。当用户需要通知某人、告知信息、发送提醒、传递文件时使用”,调用率立刻上来了。
这里有一条经验法则:工具描述要站在“用户会怎么说”的角度来写,而不是站在“程序怎么实现”的角度来写。用户不会说“调用SMTP协议发送一封MIME格式的邮件”,用户会说“帮我告诉老王明天别迟到”。你的描述里要包含这些口语化的触发场景。
另一个常见问题是描述里塞了太多技术细节。比如“使用OAuth2.0认证的RESTful接口,返回JSON格式数据”——这些信息对模型判断“该不该调用”毫无帮助,反而会干扰它的判断。描述应该聚焦在“这个工具能帮用户完成什么事”上。
2.3 参数模式的设计陷阱:类型、枚举与嵌套
参数模式看起来简单,但有几个坑非常容易踩。
第一个坑是类型模糊。比如日期参数,如果你只写"type": "string",模型可能会传“明天”“下周三”“2024年3月”各种格式。正确做法是在描述里明确格式要求,必要时用enum限定取值范围。如果日期必须是YYYY-MM-DD格式,就在描述里写死,并且给一个示例。
第二个坑是该用枚举却用了自由文本。比如一个“设置优先级”的参数,取值只有“高、中、低”三种,如果你不限定,模型可能传“紧急”“一般”“普通”各种同义词。用enum限定后,模型就只能从固定集合里选,下游处理逻辑会简单很多。
{ "priority": { "type": "string", "enum": ["high", "medium", "low"], "description": "任务优先级,只能是high、medium、low三者之一" } }第三个坑是嵌套对象。有些工具需要复杂参数,比如“创建一个会议”需要标题、时间、参与人列表、地点等多个字段。这时候用嵌套的object和array来描述。但要注意,嵌套层级越深,模型出错的概率越高。我的经验是尽量把参数拍平,能一层解决就不要两层。如果确实需要嵌套,在描述里给一个完整的参数示例,模型照着抄的准确率会高很多。
2.4 工具数量与选择准确率的权衡
还有一个实战中才会遇到的问题:当你给模型提供几十个工具时,它的选择准确率会明显下降。这很好理解,就像你同时给一个人递过去三十份说明书,让他快速决定用哪一份,他也会懵。
业界的常见做法是分层路由:先用一个轻量级的分类器(或者模型本身)判断用户意图属于哪个大类,然后只把该类下的工具清单发给模型。比如用户问的是“日程相关”的问题,就只传日历、提醒、会议这几个工具,而不是把天气、计算、数据库所有工具都塞进去。
如果工具数量确实很多又没法分层,另一个技巧是在工具描述里加入“互斥提示”。比如在“查询天气”的描述里写“本工具仅用于查询天气,不用于查询航班、酒店等其他信息”,帮助模型划清边界。
3. 模型如何“决定”调用:从意图识别到参数组装
3.1 调用决策发生在哪一步
很多人以为函数调用是模型“额外做了一件事”,其实不是。在支持函数调用的模型里,整个流程仍然是一次普通的文本生成,只不过模型被允许生成一种特殊格式的输出——通常是一个结构化的 JSON,里面包含tool_calls字段,标明它想调用哪个工具、传什么参数。
换句话说,模型在生成每一个 token 的时候,它的候选空间里既包含“普通文本回复”,也包含“工具调用意图”。当它判断“这个问题我需要借助外部工具才能回答”时,它就会生成工具调用意图,而不是直接编一个答案。
这个判断过程,本质上是一次意图识别 + 能力匹配:用户想要什么,我手上有没有对应的工具,如果有,该用哪个。这三步里任何一步出错,都会导致调用失败或调用错误。
3.2 什么情况下模型会选择调用工具
模型决定调用工具,通常基于几个信号:
- 问题涉及实时信息:用户问“今天天气”“现在股价”“最新新闻”,模型知道自己没有实时数据,倾向于调用工具。
- 问题涉及精确计算:用户问“1234乘以5678等于多少”,模型知道自己算数容易出错,倾向于调用计算器工具。
- 问题涉及私有数据:用户问“我上个月的订单”,模型知道自己没有这个数据,倾向于调用数据库查询工具。
- 问题涉及外部动作:用户说“帮我发一封邮件”“帮我创建一个提醒”,模型知道自己没有执行能力,倾向于调用对应工具。
反过来,如果用户问的是“解释一下什么是递归”“写一首关于春天的诗”,模型知道自己能直接回答,就不会调用工具。
这里有一个反直觉的点:模型并不总是“理性”地判断该不该调用。有时候它明明可以直接回答,却非要调用一个工具;有时候它明明该调用工具,却硬编了一个答案。前者叫“过度调用”,后者叫“调用遗漏”。这两个问题在后面的调试章节会详细讲怎么处理。
3.3 参数提取:从自然语言到结构化数据
模型决定调用工具之后,下一步是从用户的自然语言里提取参数。比如用户说“帮我查一下后天上海的天气”,模型需要提取出city="上海"、date="后天对应的日期"。
这一步的难点在于自然语言里的隐含信息。用户不会说“请调用get_weather工具,参数city为上海,date为2024-03-17”,用户说的是“后天上海天气怎么样”。模型需要:
- 识别出“后天”是相对日期,需要结合当前日期换算;
- 识别出“上海”是城市参数;
- 把这两个信息组装成工具要求的格式。
如果工具描述里写清楚了“如果用户说今天或明天,请根据当前日期换算成具体日期”,模型就能正确处理相对日期。如果没写,模型可能直接把“后天”两个字塞进 date 参数,导致下游解析失败。
还有一个常见问题是参数缺失。用户说“帮我查一下天气”,没说哪个城市。这时候模型有两种处理方式:一是追问用户“请问您想查哪个城市的天气”,二是在调用时留空或填一个默认值。哪种更好取决于你的产品设计。如果工具参数是必填的,模型应该追问;如果参数有合理默认值,模型可以自动填充。
3.4 并行调用与串行调用
当用户的一个请求需要多个工具配合时,就涉及调用顺序的问题。
并行调用是指模型一次性生成多个工具调用意图,这些调用之间没有依赖关系,可以同时执行。比如用户问“北京和上海今天天气怎么样”,模型可以同时调用两次get_weather,一次查北京,一次查上海。
串行调用是指后一个调用依赖前一个调用的结果。比如用户问“帮我查一下我最近的订单,然后给订单里的商品写一条评价”,模型需要先调用“查询订单”工具,拿到订单信息后,再调用“写评价”工具。这种依赖关系模型通常能自己推理出来,但前提是工具描述里写清楚了输入输出关系。
在实际工程中,并行调用能显著降低延迟,因为多个工具可以同时执行。但要注意,不是所有模型都支持一次生成多个调用意图,这取决于你使用的具体模型版本和接口能力。
4. 结果回灌:工具返回后模型怎么接着聊
4.1 工具结果的消息格式
工具执行完之后,结果需要以特定格式回传给模型。在大多数接口里,这个格式是一条role为tool的消息,里面包含tool_call_id(对应之前模型生成的调用 ID)和content(工具返回的实际内容)。
{ "role": "tool", "tool_call_id": "call_abc123", "content": "{\"city\": \"上海\", \"date\": \"2024-03-17\", \"weather\": \"多云\", \"temp_high\": 18, \"temp_low\": 12, \"precipitation\": \"10%\"}" }模型收到这条消息后,会把它当作“自己刚才调用的工具返回了结果”,然后基于这个结果继续生成回复。比如它会说“上海后天多云,气温12到18度,降水概率10%,出门不用带伞”。
这里有一个关键细节:工具返回的内容最好是结构化的 JSON,而不是一大段自然语言。结构化数据让模型更容易准确提取信息,也方便你在日志里排查问题。如果工具返回的是一大段文本,模型可能会漏掉关键信息或者误读。
4.2 结果太长怎么办:截断、摘要与分页
工具返回的结果可能非常长。比如你调用一个“搜索文档”的工具,返回了二十页的搜索结果。如果原封不动塞给模型,会带来两个问题:一是超出上下文窗口,二是模型在长文本里找关键信息的能力会下降。
常见的处理方式有三种:
- 截断:只取前 N 条结果,或者只取最相关的几条。比如搜索结果只返回前5条,每条只保留标题和摘要。
- 摘要:在工具内部先用一个小模型或者规则逻辑把结果压缩成关键信息,再回传给主模型。
- 分页:如果结果确实需要全部展示,可以让模型先拿到第一页,然后根据需要再调用一次工具拿下一页。
我的经验是,工具返回给模型的内容,应该以“模型能快速做决策”为目标来设计,而不是“把所有数据都倒给模型”。模型需要的是“够用的信息”,不是“全部的信息”。
4.3 工具执行失败时的回传策略
工具执行失败是常态,不是异常。网络超时、参数错误、权限不足、下游服务挂了,各种情况都可能发生。关键问题是:失败信息怎么回传给模型。
有两种策略:
第一种是把错误信息原样回传,让模型自己决定怎么跟用户解释。比如回传{"error": "城市名称无法识别"},模型可能会说“抱歉,我没能找到这个城市,请您确认一下城市名称”。
第二种是在工具层做一层包装,把技术错误翻译成用户能理解的语言,再回传。比如把{"error": "HTTP 503"}翻译成{"error": "天气服务暂时不可用,请稍后再试"}。
两种策略各有适用场景。如果错误是用户输入导致的(比如城市名写错了),适合第一种,让模型引导用户修正。如果错误是系统层面的(比如服务挂了),适合第二种,直接给用户一个友好的提示,不要让模型去解释技术细节。
注意:无论哪种策略,都不要把原始的技术堆栈信息直接回传给模型。模型可能会把这些信息原样复述给用户,造成困惑甚至信息泄露。
4.4 多轮对话中的工具调用状态管理
在多轮对话里,工具调用的状态管理是一个容易被忽略的复杂点。
假设用户第一轮问“上海天气怎么样”,模型调用了天气工具,拿到了结果。第二轮用户接着问“那北京呢”。这时候模型需要理解“那北京呢”是在延续上一轮的意图,应该再次调用天气工具,只是把城市换成北京。
这个“延续”能力,依赖于你把完整的对话历史(包括之前的工具调用和工具返回)都保留在上下文里。如果只保留用户的自然语言消息,丢掉了工具调用的记录,模型就会失去上下文,不知道“那北京呢”指的是什么。
所以,在多轮场景下,对话历史里必须完整保留assistant的工具调用消息和tool的工具返回消息。这些消息虽然用户看不到,但它们是模型理解对话状态的关键依据。
5. 从 Demo 到生产:那些只有踩过才知道的坑
5.1 参数校验不能省:模型也会“手滑”
即使工具描述写得再清楚,模型仍然可能传错参数。我见过模型把日期传成“2024-13-45”,把城市名传成“上海省”,把枚举值传成“HIGH”而不是“high”。这些错误如果不在工具层做校验,直接打到下游服务,轻则报错,重则写入脏数据。
所以,工具函数的第一件事永远是参数校验。校验内容包括:必填参数是否存在、类型是否正确、枚举值是否在允许范围内、数值是否在合理区间、字符串长度是否超限。校验不通过时,返回一个清晰的错误信息给模型,让它重新提取参数或者向用户追问。
def get_weather(city: str, date: str): if not city or not isinstance(city, str): return {"error": "城市名称不能为空"} if not re.match(r"^\d{4}-\d{2}-\d{2}$", date): return {"error": "日期格式必须为YYYY-MM-DD"} # 继续执行实际查询逻辑5.2 超时与重试:别让一个慢工具拖垮整个对话
外部工具的执行时间是不可控的。查天气可能200毫秒返回,查数据库可能2秒,调用一个第三方接口可能10秒还没响应。如果不设超时,用户就会一直等在那里,体验极差。
我的做法是给每个工具设置一个合理的超时时间(通常3到10秒,视场景而定),超时后立即返回一个“服务繁忙”的错误信息给模型,让模型告诉用户“稍后再试”。同时,对于幂等的查询类工具,可以配置一次自动重试;对于非幂等的操作类工具(比如“创建订单”),重试要非常谨慎,避免重复创建。
5.3 安全边界:模型能调用的工具必须白名单化
这是一个安全红线问题。模型能调用的工具,必须是你明确注册在白名单里的。绝对不能允许模型动态生成工具名称或者动态执行任意代码。
我见过一些早期实现,为了“灵活”,让模型直接生成 SQL 或者 shell 命令去执行。这是极其危险的。模型可能被用户诱导生成恶意命令,造成数据泄露或系统破坏。
正确的做法是:所有可调用工具都是预先定义好的、经过审核的、参数受控的函数。模型只能在给定的工具集合里选择,不能越界。对于涉及敏感操作的工具(比如删除数据、发送邮件、修改配置),还应该增加额外的确认机制,比如要求用户二次确认后才真正执行。
5.4 日志与可观测性:出问题时你怎么查
函数调用链路比普通对话长得多:用户输入 → 模型生成调用意图 → 参数提取 → 工具执行 → 结果回传 → 模型生成最终回复。任何一个环节出问题,最终表现都是“模型回答不对”,但根因可能在链路的任何一处。
所以,完整的日志记录是必须的。至少要记录:每次请求的完整消息列表、模型生成的工具调用意图(工具名 + 参数)、工具的实际执行结果(成功或失败)、最终返回给用户的回复。有了这些日志,出问题时你才能快速定位是模型选错了工具、参数提取错了、工具执行失败了,还是结果回传后模型解读错了。
我习惯在日志里给每次调用打一个唯一的 trace_id,把上述所有环节串联起来。排查问题时,拿着 trace_id 一搜,整条链路一目了然。
5.5 成本控制:工具调用会显著增加 token 消耗
这一点很多人一开始不会注意到。工具描述本身要占 token,工具调用的意图生成要占 token,工具返回的结果要占 token,模型基于结果生成的回复也要占 token。一轮带工具调用的对话,token 消耗可能是普通对话的三到五倍。
如果工具描述很长、工具数量很多、返回结果很大,成本会进一步上升。所以在上线前,一定要估算清楚:平均每次对话会触发多少次工具调用、每次调用带来多少额外 token、总体成本是否在可接受范围内。
优化方向包括:精简工具描述、控制返回结果长度、对简单问题引导模型直接回答而不调用工具、对高频查询做缓存等。
6. 把这一讲串起来:一个可复现的最小实现骨架
6.1 整体流程回顾
把前面几节的内容串起来,一次完整的函数调用流程是这样的:
- 你定义好工具清单(名称、描述、参数模式),随请求一起发给模型;
- 模型判断需要调用工具,生成包含工具名和参数的结构化调用意图;
- 你的代码解析这个意图,找到对应的工具函数,执行它;
- 工具函数返回结果(成功数据或错误信息);
- 你把结果以
tool消息格式回传给模型; - 模型基于结果生成最终的自然语言回复给用户。
这个流程可以循环多次,直到模型不再生成工具调用意图,而是直接输出最终回复。
6.2 关键参数配置速查
| 配置项 | 建议值 | 说明 |
|---|---|---|
| 工具超时 | 3-10秒 | 视工具类型调整,查询类可短,操作类可长 |
| 最大调用轮数 | 5-10轮 | 防止模型陷入无限调用循环 |
| 返回结果长度 | 控制在上下文窗口的10%以内 | 过长时截断或摘要 |
| 参数校验 | 必做 | 类型、枚举、范围、长度全查 |
| 日志级别 | 记录完整消息链路 | 便于排查问题 |
| 敏感操作 | 二次确认 | 删除、发送、支付类操作必须加确认 |
6.3 我个人的几条经验
最后分享几条我在实际项目里总结出来的经验,都是文档里不会写的。
第一条:先从一两个工具开始,不要一上来就搞几十个。工具越多,模型选择越容易出错,调试也越复杂。先把一两个核心工具跑通,把描述、参数、错误处理都打磨好,再逐步扩展。
第二条:工具描述要反复迭代。第一版描述几乎不可能完美。上线后观察哪些该调用没调用、哪些不该调用乱调用,针对性地修改描述,通常迭代三五轮之后准确率会有明显提升。
第三条:给模型“不调用工具”的选项。有些问题模型直接回答就好,不需要调用工具。如果你在提示词里强调“尽量使用工具”,模型可能会过度调用。反过来,如果你发现模型该调用却不调用,可以在系统提示里加一句“当问题涉及实时信息或外部数据时,优先使用提供的工具”。
第四条:工具返回结果要“说人话”。虽然结构化 JSON 方便模型解析,但如果返回的是给用户看的最终数据,最好在工具层就做好格式化。比如温度返回“18°C”而不是“18”,日期返回“3月17日”而不是“2024-03-17”。这样模型在生成最终回复时,直接引用即可,减少二次转换出错的可能。
第五条:永远假设模型会犯错。参数会传错,工具会选错,结果会解读错。你的系统设计要能容忍这些错误,而不是假设模型永远正确。参数校验、超时重试、错误回传、日志记录,这些“防御性”的工作,才是让一个 Demo 变成可用产品的关键。