029、Agent调用工具:Function Calling概览
今天不聊概念,先看一段我昨天凌晨两点半还在调的日志。对方是个刚跑通的Agent项目,LLM已经能正常对话了,但一旦让它去查天气、算个税、调个数据库,模型就开始“一本正经地胡说八道”。日志里模型明确说:“我将调用get_weather函数,参数为北京”,但下游工具执行引擎压根没收到任何调用请求。更诡异的是,模型回复里包含了完整的函数名和参数JSON,可系统提示里明明告诉它“可以调用工具”。我当时盯着屏幕,脑子里只有一个念头:这玩意儿到底有没有走Function Calling那道工序?
答案是:它走了,但走歪了。模型只是把函数名当成了文本生成的一部分,而不是触发了一个结构化的工具调用协议。这就是今天要讲的Function Calling概览的核心——你需要的不是让模型“提到”工具,而是让模型在生成过程中“决策”并“发出”一个可解析、可校验、可执行的调用指令。
Function Calling,说白了就是让LLM在输出正常文本之外,额外输出一个结构化的“工具请求”。这个请求不是自然语言,而是严格遵守你预先定义的模式。你在系统提示里塞了一堆函数描述,模型看完用户的问题,内部做一次推理,如果觉得“这活儿需要外部工具”,它就在生成的输出流里插入一个特殊的标记块,里面写着函数名和参数。你的代码拿到这个标记块,解析、执行、把结果塞回给模型,模型再基于结果生成最终回答。整个循环就是这么回事。
但真到工程落地,麻烦全在细节上。先说说最常见的坑:你给模型看的函数描述,和实际执行函数之间,隔着一层“翻译”。OpenAI的函数定义格式里,description字段写得全是人话,比如“获取指定城市的当前天气”,但模型理解“指定城市”时,可能把用户的“北京”映射成{ "location": "北京", "unit": "celsius" },也可能映射成{ "city": "beijing", "temp": "c" }。你如果没在parameters里用JSON Schema把每个字段的类型、枚举值、约束条件钉死,模型就会自由发挥。自由发挥的后果就是,你的JSON解析器收到了一个unit字段,值写成了"摄氏度",而不是"celsius"。我见过不止一个团队在线上环境被这种问题搞崩,最后只能加一堆模糊匹配逻辑,越补越臭。
更隐蔽的问题在于,模型有时候会“过度调用”。用户只是随口问一句“今天适合穿什么”,你给模型挂了五个工具:天气、穿衣指数、紫外线、限行、空气质量。模型一看,好家伙,五个全能用,于是它一次性输出了五个函数调用。你的执行引擎挨个跑,再挨个把结果塞回给模型。模型又基于五个结果生成一段长篇大论。用户本来想听一句“短袖就行”,结果收到一篇环境报告。这就是工具粒度没设计好。Function Calling不是让你把所有可能用到的东西全挂在模型面前,而是让你只挂那些“用户当前问题明确指向的”工具。判断标准很简单:如果你作为人类,看了用户的这句话,需要去查三个外部系统才能回答,那才需要三个工具;如果查一个就够了,就只给那一个。
再聊聊参数校验。很多初学者以为模型输出的JSON就是最终结果,直接拿来调用Python函数。这是最容易出事故的地方。模型输出{"location": "北京市", "days": 3},你的函数签名是def get_forecast(city: str, days: int),看起来没问题,但模型可能输出{"location": "北京市", "days": "3"}——字符串“3”。你的类型系统可能能容忍,但更常见的是模型把必填参数漏掉。比如用户问“北京和上海哪个冷”,你只定义了一个get_weather(location),模型根本不知道要调两次。它可能会自作聪明输出一次调用,参数写成"北京和上海",然后你的API查了个寂寞。正确的做法是在Function Calling的设计阶段就考虑“一对多”的情况,让模型支持数组参数,或者定义成get_weather_batch(locations: List[str])。你千万别指望模型会自动拆解为两次调用,大多数开源模型的Function Calling能力还没那么智能。
代码层面,我这里有一段实际跑过的伪代码,注释里都是当年踩过的坑:
deffunction_call_loop(user_query,tools,execute_fn,max_iter=5):messages=[{"role":"user","content":user_query}]# 注意:tools必须每次请求都带上,别只带一次就缓存# 模型可能根据上下文选择不调用,但你删掉tools它就直接拒绝foriinrange(max_iter):resp=llm.chat(messages,tools=tools,tool_choice="auto")msg=resp.choices[0].message# 如果模型没有工具调用意图,正常结束ifnotmsg.tool_calls:returnmsg.content# 这里有个坑:tool_calls可能是列表,一次请求可能包含多个调用# 别只处理msg.tool_calls[0],要遍历forcallinmsg.tool_calls:fn_name=call.function.name# 解析参数时一定要用try-except,别相信模型的JSON是合法的try:args=json.loads(call.function.arguments)exceptjson.JSONDecodeError:# 别直接报错,可以构造一个错误信息返回给模型重新生成args={"_error":"invalid_json"}result=execute_fn(fn_name,args)# 这里把工具返回值以tool角色回传给模型,role不能写错messages.append({"role":"tool","tool_call_id":call.id,"content":json.dumps(result)})# 再把模型的这条回复也加进上下文,模型需要知道它刚才发出了调用messages.append(msg)return"重试次数过多"看到中间那段try-except没?别觉得多余。我在生产环境里见过模型生成{"location": "北京"少一个右括号的情况,也见过{"location": 北京}少引号的情况。尤其是用那些参数量较小的模型,输出JSON稳定性差得离谱。你如果写死了json.loads,整个链路直接断裂。正确方法是捕获异常后,把错误信息作为一条消息塞回给模型,告诉它“你刚才的调用参数格式不对,请重新生成”。这就相当于给了模型一次自我纠正的机会,比你自己硬修字符串靠谱得多。
还有一点,很多人在系统提示里写“你可以调用以下工具”,但忘了在tool_choice上做约束。tool_choice有三个常用值:"auto"让模型自己决定,"none"禁止调用,"forced"强制调用某个指定的函数。实战中,如果你在做多步任务,比如先查天气再根据天气推荐穿搭,第一步可以用"auto",但第二步如果你确定必须调用某个工具,就用{"type": "function", "function": {"name": "recommend_clothing"}}强推一把。别小看这个选项,它能省掉一轮“模型拒绝调用”的无谓对话。我见过一个团队,模型明明已经推断出需要推荐服装,但不知道抽什么风,就是不调用工具,直接凭记忆瞎编。后来一查,是description里写得太模糊,模型以为这个“推荐”是个纯文本生成任务。你在函数描述里就得写清楚:“仅当用户询问穿什么衣服时调用,不要自己发挥。”
另外,开源模型的Function Calling和OpenAI的格式不完全一样。有的模型用的是特殊token标记,比如Qwen的<|tool_call|>,有些是逐字生成Action:前缀。你在接不同模型时,不能只改API地址,还要改解析逻辑。最稳妥的办法是在模型输出层直接拦截流式事件,识别到工具调用的起始标志后,单独收集那部分token,直到遇到结束标志,再整体解析。别把工具调用和正文混在一个流里处理,否则你会发现模型生成了一半函数名就开始说人话,或者参数还没输出完就被正文截断了。这块儿我推荐你看一下各大模型的最新文档,但文档归文档,自己写一个轻量的ToolCallParser类,针对不同的模型格式写几个适配器,才是长期工程正道。
说说调试方法。你如果在本地跑,最直观的是打印每次请求和响应的完整JSON,包括messages里的tool_calls字段。不要怕刷屏,这是最快定位问题的方式。我一般会加一个环境变量DEBUG_TOOL_CALL=1,开启后把每个函数的调用输入输出都写到日志文件里。你慢慢就会发现规律:某些失败是模型对参数语义理解偏差,某些是JSON schema要求太严格(比如日期格式定成YYYY-MM-DD,模型却给了2024/03/01),还有某些纯粹是模型没睡醒,把函数名拼错了。遇到后者,你根本不需要修代码,直接在函数定义里加一个别名"weather"和"get_weather"同时指向同一个函数,模型就算拼错一半也能命中。这种容错设计在工程上很有效,但别过度,加了二十个别名反而会让模型困惑。
最后给个自己的经验性建议:永远不要把Function Calling当作一个纯生成任务,它本质上是“结构化的决策输出”。你与其反复调优提示词,不如把函数定义写得越窄越好。一个函数只干一件最小的事,参数不超过三个,每个参数都加上description和示例值。比如“获取天气”这个函数,参数只有location和date,你干脆写上"date": "格式如2025-03-01,默认为今天"。模型在看到示例后,输出的准确率会有明显提升。再一个,如果条件允许,给每个函数写一个“当用户意图符合以下条件时才调用”的说明,这比夸夸其谈地写“这个函数非常有用”强一百倍。模型不是人,不会因为你的形容词而更想用它。
做Agent调试,最忌讳的就是把模型当成一个确定性的逻辑引擎。它会飘,会漏,会自作主张。Function Calling系统的核心功能不是“让模型调用工具”,而是“让模型在可控范围内尝试调用工具,并且你的系统能优雅地处理所有意外”。你要做的,是给模型搭一个坚固的梯子,而不是指望它学会飞行。梯子搭好了,Agent才敢伸手去够那个函数。