做LLM应用开发的人,大概率都经历过这一段尴尬:辛辛苦苦把用户指令塞给大模型,让它输出一段JSON用于触发业务动作,结果模型生成一个带Markdown注释的、缺字段的、甚至多出幻想字段的“假JSON”,程序解析直接报错。我当初在一套问答系统里,为了解析模型输出的工具参数,前后写了七个补丁函数。直到系统性地把Function Calling的文档啃完,我才搞明白一件事:那些解析问题并不是模型笨,而是我用错了方式。Function Calling不是给你一个“输出JSON的更好办法”,而是把“调用外部工具”这件事从文本层面提到了协议层面。这篇内容就是我从概念、机制、实操到排坑的完整记录,适合正在做大模型Agent、自然语言查询、工作流自动化的开发者参考。
1. Function Calling到底解决了什么问题:从“只能聊天”到“可以动手”
1.1 一次自然语言请求,如何变成一次系统操作
先说一个最常见的场景。用户在对话框里输入“帮我查一下某市明天的天气,顺便算一下满100打八折后多少钱”。程序要做的事情有两件:调天气接口、做价格计算。如果模型没有工具调用的能力,它只能干一件事:给你返回一段看起来像模像样的文字,告诉你“好的,我来帮你查”或者干脆自己编一个天气数据出来。这显然不可接受。
Function Calling的做法是:在发起请求时,把你能执行的函数清单一起作为“工具”塞给模型。模型读完用户输入之后,如果判断需要外部数据或动作,就不再硬编自然语言,而是按照清单里某个函数的定义,生成一段结构化的调用指令。指令内容包括函数名和参数列表。你的程序拿到这段指令,执行真实的函数,再把执行结果作为消息回传给模型,模型基于真实结果生成最终回答。
也就是说,整个链路的职责分得很清楚:
- 模型负责:判断是否需要调用工具、选择哪个工具、生成符合要求的参数
- 程序负责:真正执行函数、做权限校验、处理异常、把结果格式化回传
- 模型再负责:基于函数执行结果,组织成对用户友好的自然语言回复
这个分工看起来理所当然,但和“让模型输出JSON再解析”完全是两类东西。后者把模型的输出当作纯文本来处理,前者把“工具选择”提升到了协议层,模型在训练阶段就已经见过大量类似样例,知道返回的字段应该是什么语义、什么结构。
1.2 传统JSON输出和Function Calling的关键差异
为了把这件事讲透,我拿自己踩过的对比列表放在一起看:
| 方式 | 典型做法 | 常见翻车现场 |
|---|---|---|
| 提示词要求输出JSON | “请以JSON格式返回结果,包含name和price字段” | 模型输出带```json代码块、字段缺失、值随意编造 |
| 后处理+提示词约束 | 在提示词里给一个JSON示例,再用解析器强行提取 | 模型理解了格式,但业务语义理解错,参数值填错 |
| Function Calling | 通过tools参数声明函数,由模型生成结构化tool_calls | 相对受控,但仍需要程序端校验和错误兜底 |
表格里的前两种方式,本质上都在“赌”模型今天心情好,愿意老老实实按格式输出。可一旦上下文长了、用户表述绕了、模型换了版本,输出就很容易出问题。Function Calling最大的进步在于:工具格式和调用逻辑被“显式建模”进了模型的训练和推理过程中。模型不是在“生成一段包含函数名的文本”,而是在“选择动作并填充参数”,这两件事的概率分布空间完全不一样。
1.3 什么项目适合用它
基于我自己的落地经验,适合引入Function Calling的场景有几类:
- 垂直行业Agent:客服助手、订单查询、售后处理,用户输入自然语言,系统执行真实业务动作
- NL2SQL:把“上个月华东区销售总额是多少”转换成数据库查询动作
- 工作流自动化:邮件发送、日程创建、审批流触发、提醒设置
- 数据分析助手:让模型调用统计函数、图表生成函数,再把结果汇总给用户
不太适合的场景也很明确:纯闲聊、文本润色、翻译、内容生成,这些场景没有“动作”需要执行,强行挂一串工具反而浪费token、增加延迟。判断标准就一条:用户意图里是否存在外部数据获取或者系统副作用操作。有,就用Function Calling;没有,别硬上。
2. 拆解函数调用的底层交互机制:模型只是决策者,不是执行者
2.1 模型眼里的“函数”长什么样
初学者最容易误会的一点是:以为Function Calling是把Python函数直接丢给模型。实际上,模型能看到的只是函数的“描述文本”,通常包含四件事:
- 函数名:机器可读的唯一标识,比如query_weather
- 函数说明description:告诉模型这个函数是干什么的、什么时候用
- 参数定义parameters:使用JSON Schema描述每个参数的类型、含义、约束
- 必填项与枚举值:明确哪些参数必须有,哪些值才合法
这段话才是模型做决策的全部依据。它看不到你的函数体,更不会替你执行代码。你在函数体里藏的任何逻辑,模型一概不知。所以函数描述写得清不清楚,直接决定了模型选得准不准。我见过很多团队把工具描述写得三行以内、参数名用缩写,结果模型在多个相似函数之间反复横跳,就是这个原因。
2.2 一次完整的函数调用循环
我用文字描述一个标准的调用流程,走完一遍你就不会再把“模型直接返回JSON”当作工具调用了:
- 用户输入进入消息列表,例如“某市明天天气怎么样”
- 请求携带tools参数,里面包含query_weather等函数声明
- 模型处理输入,在函数清单里判断需要调用query_weather,返回tool_calls字段,内容包含函数名和参数,比如city=某市
- 你的程序拦截到tool_calls,暂停“让模型继续回答”的动作,转而去执行真实的天气接口
- 天气接口返回结果,程序把它封装成一条role=tool的消息,消息里带上对应的调用ID
- 这条工具结果被追加进消息历史,再次发回给模型
- 模型看到查询结果后,组织成一句自然语言:“某市明天晴,18度”
这里最关键的一步是第5步。工具结果必须以结构化的消息形式回传,而不是直接拼进用户原始输入。主流的兼容协议里,工具结果的role固定为tool,并且必须和上一步的tool_call_id一一对应。这个对应关系一旦错乱,模型就会把某次调用的结果安到另一个函数头上,产生的幻觉非常隐蔽。
2.3 模型的三种返回形态
在实际调用中,模型不会只有“直接返回工具调用”一种表现,我归纳了三种:
- 纯自然语言回答:没有tool_calls字段。常见于问候、澄清、权限不足等场景
- 纯工具调用:content为空,tool_calls里带完整函数调用指令
- 边回答边调用:content里有“好的,我来查询一下”之类的过渡文字,同时带tool_calls
第三种形态最容易让人踩坑。有的同学写解析逻辑时只判断content,发现返回里有文字就直接把工具调用丢了;有的同学则相反,看到tool_calls就直接忽略content,导致聊天记录缺失了一段上下文。我的建议是:工具处理逻辑只认tool_calls的存不存在,不认content是否为空;但在把消息写回历史时,content和tool_calls都要保留,这样下一轮模型才能保持一致性。
2.4 和ReAct模式的区别
很多文章把Function Calling和ReAct放在一起讲,我简单梳理一下。ReAct是一种通用的推理范式,核心是让模型在上下文中写出thinking步骤,然后调用工具,再观察工具返回结果,循环往复。它是一种“思想框架”,不限制具体实现方式。Function Calling则把这个思想变成了工程可落地的协议:模型不需要写自由文本式的行动描述,而是直接输出结构化字段。你可以理解成ReAct是大方向,Function Calling是其中一个更可控、更省心的具体实现方式。
3. 完整实现一个函数调用流程:注册、分发、回传、再推理
3.1 环境准备
实操之前先说环境。现在主流的大模型服务基本都对齐了一套通用的tools协议,你只需要确认你用的模型支持工具调用能力就行。主流的商用闭源模型和一批中大规模开源模型都有不错的支持。客户端方面,常规的Python SDK就可以完成任务,不需要额外安装复杂框架。
这里提醒一点:如果你用的是自部署的开源模型,先确认版本是否覆盖tools参数的兼容实现。有些旧版本聊天模型表面上能接收tools参数,实际是把它当成普通文本吞进去了,返回的还是自然语言。
3.2 定义你的工具清单
我以两个最简单的业务函数为例:一个查天气,一个计算折扣价。工具定义的JSON结构大致长这样:
[ { "type": "function", "function": { "name": "query_weather", "description": "查询指定城市在指定日期的天气情况,适合用户在询问天气、出行建议、是否需要带伞时使用", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如某市" }, "date": { "type": "string", "enum": ["today", "tomorrow", "day_after_tomorrow"] } }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate_discount", "description": "根据原价和折扣率计算最终价格,适用于满减、打折等价格计算场景", "parameters": { "type": "object", "properties": { "original_price": { "type": "number", "description": "商品原价,单位为元" }, "discount_rate": { "type": "number", "description": "折扣率,例如打八折填0.8" } }, "required": ["original_price", "discount_rate"] } } } ]写这个JSON的时候注意几件事。第一,description不要写太抽象,“查询天气”四个字远远不够,要写清楚适用场景。第二,日期这种有枚举约束的字段,尽量用enum把合法值列出来,不然模型会给你填“明天”还是“2025-06-01”,完全不可预测。第三,required只放真正必须的参数,别把所有可选参数都塞进去,否则模型为了满足必填项反而会编个不存在的值。
3.3 核心代码流程
下面是一段简化的Python骨架,展示了从首次请求到工具结果回传的完整闭环:
import json def execute_tool_call(tool_call): """根据模型返回的调用指令分发到具体函数,返回执行结果字符串""" func_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) if func_name == "query_weather": return query_weather(arguments["city"]) elif func_name == "calculate_discount": return calculate_discount( arguments["original_price"], arguments["discount_rate"] ) else: return "未知函数,请重新选择" def run_conversation(user_input, tools, max_iter=6): messages = [{"role": "user", "content": user_input}] for _ in range(max_iter): response = client.chat.completions.create( model=MODEL_NAME, messages=messages, tools=tools, tool_choice="auto" ) msg = response.choices[0].message if not msg.tool_calls: # 模型不再调用工具,返回最终回答 return msg.content # 把模型的回复追加进历史,保留content和tool_calls messages.append({ "role": "assistant", "content": msg.content, "tool_calls": [tc.model_dump() for tc in msg.tool_calls] }) # 逐个执行函数,并回传结果 for tc in msg.tool_calls: result = execute_tool_call(tc) messages.append({ "role": "tool", "tool_call_id": tc.id, "name": tc.function.name, "content": result }) return "已达最大轮次,对话终止"这段代码有几个细节必须强调。
第一,msg.tool_calls的判断才是核心,不是msg.content。第二,assistant消息要把tool_calls原样存回消息历史,否则下一轮模型不知道你刚才调用了哪个函数。第三,tool消息的tool_call_id必须和tc.id对应上,这是协议层面的关联键。第四,循环要有上限,我建议单个对话里最大调6到10轮,超过就强制终止,防止模型陷入反复调同一个函数的死循环。
3.4 函数失败不该直接崩溃
实际的函数调用不会每次都成功。天气接口超时、数据库查询报错、参数非法,这些情况都很常见。新手容易犯的错是:函数一旦抛异常,整个对话流程就崩了。但更合理的做法是:把错误信息作为工具结果回传,让模型自己决定下一步。比如你是查订单接口,函数返回“用户不存在”,模型收到这个结果后可以改成让用户确认账号再接一次。这种容错设计能让整套系统比以前做规则判断灵活得多。
4. 实战中反复踩到的坑:从工具描述到循环退出条件
4.1 别用正则去抠函数调用字段
我见过有人在代码里直接对模型返回的文本做正则匹配,试图从content里抠出函数名和参数。这属于典型的用处理自然语言的方式处理结构化协议字段。正确的姿势是直接读message对象上的tool_calls属性。只有一种情况需要你动字符串:有些网关的兼容层把function.arguments以字符串形式返回,而不是字典。这时你需要json.loads一次,再拿里面的字段。另外要留意,少部分模型服务返回的arguments是带转义符的字符串,解析前可以先strip掉多余空白。
4.2 函数描述不清楚,模型就会选错
有一个我印象很深的案例。一个项目里有两个函数,一个叫“查询订单状态”,一个叫“查询退款进度”。前者的description写的是“查询用户订单”,后者写的是“查询用户退款”。用户问“我之前退货的钱到哪了”,模型直接调了“查询订单状态”,结果自然是查不到任何信息。后期把两个description改写得更具体、加入触发场景,准确率才明显上来。
描述优化有几个通用技巧:
- 写清楚“什么时候用”,比写“是什么”更重要
- 存在相似函数时,明确指出和另一个函数的区别,比如“仅用于查询正常订单,不含售后退款单”
- 参数说明里给一个形象示例,比如日期字段写“格式为YYYY-MM-DD,例如2025-01-15”
4.3 参数幻觉防不胜防
即使有JSON Schema约束,模型仍然可能填入不存在的枚举值,或者把数字格式搞错。比如schema要求temperature是number,模型传了个字符串“cold”。这类问题的核心原因是:模型的本质是概率生成,它理解schema语义,但遇到模糊上下文时仍会“自由发挥”。
我的建议是程序端做二次校验,不能信任任何来自模型的参数值。轻量做法是写校验函数,检查必填项是否齐全、枚举值是否在预期范围内、数字是否可解析。更重的做法是引入校验引擎或者让工具自己处理。尤其是涉及金额、城市编码、订单号这类关键参数,宁可多一层防御,也不要因为省几行代码把一个错误请求发出去了。
4.4 工具列表膨胀带来的选择灾难
项目早期只有五个工具,模型选得又快又准。等工具增加到二十个以上,情况就变了:token消耗明显上升、响应变慢、模型开始在相似函数之间犹豫。这时候就要做工具管理了。
我实践下来比较有效的方法是分层路由。第一步先用一个轻量分类器(可以是小模型或规则)判断用户请求属于哪个业务域,比如订单域、客服域、财务域;第二步把对应域的工具子集作为tools参数传给模型。这样做的好处是既控制token成本,又减少模型的选择空间,准确率提升非常明显。当然,跨域请求会麻烦一点,但实际业务里绝大多数请求都能归属到单一领域。
4.5 循环退出的边界条件
工具调用的循环也有翻车点。最基础的退出条件是“模型不再返回tool_calls”,但我建议再加一层轮次上限。原因很简单:模型并非总能理性收敛。比如某个查询工具返回结果为空,模型可能认为是查询条件不对,就换个参数反复查同一个工具,形成事实上的死循环。轮次上限建议调成6到10之间。太少不够完成贝联操作(比如先查用户再查订单),太多会让单次对话迟迟不结束。
另一个容易忽略的问题是消息历史越滚越大。每轮函数调用至少增加一条assistant消息和一条tool消息,几轮下来上下文体积膨胀得很快。对于上下文窗口较小的模型,你需要考虑压缩策略:把早期工具调用的长结果截断、只保留关键结论,或者只保留最近几轮的完整消息。
4.6 并行执行导致的结果错配
一次返回多个tool_calls时,程序端通常会并行执行。这时候最怕的就是结果顺序错乱。协议要求每个工具结果绑定自己的tool_call_id,所以并行场景下,建议把每个调用ID和其对应的执行结果放进同一个结构里,最后按ID回传。如果某个子任务超时或异常,也要返回与该ID对应的错误信息。千万别把“所有调用都成功”当默认假设。同时,涉及改数据或副作用的工具,最好做幂等设计,防止模型在一次对话里因为用户重复表述而重复触发同一个动作。
5. 进阶用法:并行、级联、流式与状态保存
5.1 并行调用:一次决策执行多个动作
当单个用户请求里包含多个独立意图时,模型可以在一条回复里返回多个tool_calls。比如用户说“某市天气怎么样,顺便看一下我账户余额”,模型可能同时返回query_weather和query_balance两个调用。程序端拿到多个调用后,可以并发执行,再把各自的返回结果按序回传。这样比串行执行节省了一大段等待时间,而且用户只需要一条消息就能触发所有动作。
不过并行调用也有适用边界。如果第二个函数依赖第一个函数的输出,比如先拿到用户ID才能查订单,那就不适合并行,需要走级联。所以写工具定义的时候,可以把不存在依赖关系的函数放一起;存在明确依赖关系的,尽量在一个函数内部封装,避免模型把执行顺序搞乱。
5.2 级联调用:用上一次结果驱动下一次动作
级联是更常见的Agent形态。经典流程是:用户问“我上个月买了多少钱的东西”,程序先调用get_user_id,拿到用户ID后,再调用get_monthly_orders,最后调用calculate_total。这种场景下,每轮函数结果都要回传并追加进消息历史,模型在下一轮看到结果后,才知道下一步该选哪个函数。
级联调用在实现上有一个技巧:工具结果不要太长。如果第一个函数返回了一大段完整订单明细,动辄几千字,模型不仅处理慢,还容易在后续决策时分心。建议在函数内部做一次数据裁剪,只返回和下一步决策相关的关键字段。比如订单线条数、总金额、状态类型,而不是把每条明细的备注全部塞回上下文。
5.3 流式输出下的工具调用处理
带tools请求也可以开启流式输出,但坑在于:流式模式下tool_calls不是一次性完整返回的,而是像自然语言一样分片到来。有的实现里,函数名、参数、ID分别在多个chunk里才补全。处理策略是:先把所有delta累积起来,等整个流结束后,再拼接出完整的arguments字符串,然后统一执行工具。在用户界面层面,我的经验是不要试图在“函数执行前”给用户播打字机效果,因为函数执行期间模型不会产字。更好的体验是:检测到模型即将调用工具时,前端显示一个“正在查询...”的卡片状态,等工具结果回来后,再展示模型最终生成的文字。
5.4 结构化输出和Function Calling的配合
现在不少平台还提供了严格的结构化输出能力,可以保证模型最终回复内容的字段完全符合某个JSON Schema。它与Function Calling的定位不太一样:Function Calling管的是“模型要调哪个动作”,结构化输出管的是“模型最终说的内容是什么格式”。二者可以配合。比如你先用Function Calling让模型查了一圈数据,最后一步可以要求模型以规定结构输出汇总结果,方便前端直接渲染。这种组合在实际项目里比单纯靠“提示词要求格式”稳定得多。
6. 选型与体感:接口差异、模型选择和落地建议
6.1 不同服务商接口格式的取舍
虽然主流协议趋同,但不同服务商在函数声明上仍然存在细节差异。一类是“函数声明+调用ID”结构,这是当前最主流的做法;另一类会额外要求提供output_schema,也就是除了入参,还得声明返回值结构,这样能增强返回的规范性。还有个别老牌服务仍在用“消息文本+约定符号”的方式,这种方式自由度最高,但程序解析的稳定性也最差。
如果你只是做一个原型或小工具,选第一类主流通用格式就够了;如果要上线生产、对结果规范性要求高,优先考虑支持output_schema声明的那类服务,它能在协议层面就把返回字段框死。切换不同服务的时候,最省力的做法是在工程层封装一层统一的工具注册和结果适配器,把各家的格式差异隔离在适配层内部。
6.2 模型选择的基本盘
工具调用对模型的底层能力要求不低,不是所有模型都能做好。选型的核心标准是:在工具调用评测集上的表现、对复杂schema的理解能力、以及多轮工具调用任务的成功率。从我实际体验来看,第一梯队和第二梯队模型在工具调用上的差距非常明显,具体表现为:小模型或老旧模型经常选错函数、乱传参数、在流式场景下丢字段。如果你预算有限,我建议采用分层策略:用一个便宜的小模型做意图路由,把最终的工具选择和参数生成交给能力更强的模型。这个组合在成本和效果之间能找到不错的平衡点。
6.3 上线生产前一定要做的事
最后整理几条落地经验,都是我从线上踩坑换来的:
- 每个函数调用的入参和返回都要有日志,排查线上问题时,没有日志等于没有线索
- 所有工具结果统一封装成字符串返回,别把复杂对象直接塞给模型,模型喜欢简洁文本
- 对可能修改数据或产生副作用的工具,在程序端做二次确认或权限校验
- 上线前做“影子模式”:让模型在测试流量里调用工具,但不真正执行系统动作,只记录决策日志,对比人工标注后再放开
- 设置全局超时时间,函数执行超过15秒就该中断并回传错误,避免用户长时间等待
6.4 一点个人体会
最后说点实际感受。做带Function Calling的系统,最难的不是跑通代码,而是让模型在你的一堆业务工具里做出稳定的决策。函数描述是你给模型画的地图,描述写得越清楚,模型走得越准;程序端的校验是你给模型系的安全带,真正稳住线上稳定性的,从来都是这些不起眼的防御代码。我第一次把函数调用放进生产环境时,最大的感触是:以前担心模型乱说话,现在更担心模型乱调用工具。好在Function Calling至少让“乱调用”这件事变得可追踪、可约束、可回滚。只要把工具定义做实、把循环和校验做严,这套机制就能成为大模型应用里最靠谱的那块地基。