ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Function Calling实战:从LLM到自主Agent的关键闭环

Function Calling实战:从LLM到自主Agent的关键闭环

前阵子帮某团队做内部客服助手,最头疼的其实不是模型答错,而是它答得很有道理却什么事都做不了。用户说“帮我查一下上周的订单数据”,模型只能回一段话,不会真正去翻数据库,更别提把报表发进邮件里。后来把方案切到Function Calling,在LLM大模型之上搭了一个能自主调用工具、完成多步操作的AI Agent,问题才算彻底解开。这篇文章就从这次实践出发,顺着Function Calling技术原理讲到具体实例,把构建自主Agent的关键环节完整拆开。适合正在做Agent方向、或者想把手里的LLM从“聊天框”变成“执行器”的开发者参考。

1. 先看清Function Calling在Agent里解决哪些事

1.1 从“问答模型”到“行动体”的思路转变

大模型刚流行起来的时候,大家习惯把它当“更聪明的搜索引擎”用:你问一句,它回一段。这种模式适合做文案、整理资料、问答客服,但一旦遇到“需要操作系统、调用接口、改数据库”这类真实任务,它就卡住了。原因很简单:模型只会生成文本,不会执行动作。

Agent和普通对话助手的本质区别,就在于它把“生成文本的能力”和“执行动作的能力”串成了一个闭环。模型负责感知用户意图、拆解任务、判断该调什么工具,工具负责真正落地执行,执行完的结果再回到模型手里,让模型决定下一步怎么办。这个“决定—行动—观察—再决定”的循环,就是自主Agent的核心形态。

Function Calling在这条链路里干了一件事:让模型在生成回复的同时,额外输出一个“我要调用哪个函数、参数是什么”的结构化指令。有了这个指令,程序就能识别出模型的意图,并帮它把对应的工具执行起来。换句话说,模型还是那个模型,但多了一双可以“动手”的手。

我后来常用一个类比:大模型是一座大脑,Function Calling就是大脑与四肢之间的神经传导信号。过去信号只通向嘴巴,所以只能说话;现在信号能通向脚、手、眼睛,所以能走路、做事、确认结果。整个Agent框架的本质,就是把这套“大脑—信号—四肢”的回路搭出来,再做稳定、做安全。

1.2 为什么是Function Calling,而不是简单让模型输出JSON

刚开始做这个方向时,我查过一些老方案,比如在提示词里写“如果用户要查天气,请返回 JSON:{"action": "weather", "city": "..."}”,然后程序解析JSON去执行。这种做法短期内能跑通,但维护成本很高。

一是输出不稳定。模型有时会把JSON包在代码块里,有时加上一段解释文字,有时字段名对不上,解析逻辑越写越脆。二是多函数选择能力弱。工具一多,靠提示词约束模型“只能从这五个函数里选”会频繁出问题,模型经常自作聪明编一个函数名。三是参数校验麻烦。模型返回的参数可能是字符串类型、缺字段、甚至直接返回空对象,程序判断“该走哪个分支”时极易出错。

Function Calling的方式是让模型在训练阶段就对齐了一套“工具调用”范式。开发者通过统一格式把函数名、参数schema告诉模型,模型内部会重新组织输出,返回一个结构固定的tool_calls字段,里面清清楚楚写着函数名和JSON格式的参数。程序侧再看这个字段时,就基本不需要做过度的容错处理了。

从实践来看,启用Function Calling后最大的感受是“可预期”。模型什么时候该调工具、什么时候该直接回复,判断都稳定不少;即便它判断错了,也能从tool_calls结构里定位问题,而不是在一大段自由文本里大海捞针。尤其是同一个请求里可以返回多个函数调用时,Agent一次就能完成“查库存,再查价格”这类组合操作,效率和稳定性比纯prompt方案高一个量级。

2. 核心细节解析:Function Calling的完整调用闭环

2.1 一次调用闭环里的四个关键环节

把Function Calling落地成Agent,不能只理解“模型能返回函数名”这一点,真正重要的是把整个闭环跑通。我按平时编码的习惯,把它拆成四个环节:

第一,定义工具集合。把Agent能做的所有事情都描述成一个函数,包括函数名、功能说明、参数结构。模型看到的就是这份“能力清单”。第二,带着消息列表和工具清单去请求模型。模型会根据对话内容决定是直接回复、还是选择一个或多个函数,并填好参数。第三,程序执行相应函数。这里执行逻辑是真实的,比如请求监控接口、读写数据库、发送通知。第四,把执行结果作为一条tool消息回传给模型。模型看到工具结果后,再决定是否还需要调用别的工具,直到它认为任务完成,返回最终答案。

这个循环和人类处理事件的思路很像。你把供应商报价单翻出来了,接下来是否要给客户发确认邮件?发完邮件之后是否要更新台账?每一步都要先看结果,再定下一步。Agent里的循环正是这个模型。

需要注意的一点是,如果不把工具结果喂回模型,那模型永远不知道刚才那个动作成功还是失败。很多初版Agent“做事做一半”就是卡在这里:调用了工具,拿到了结果,程序却直接丢掉了,没有把它写进messages再发一次请求。工具结果对模型而言是最重要的“观察”,缺少这个观察,Agent的决策链就断了。

还有一点,函数执行的异常结果也要正常返回。函数执行器里经常会有一段代码:如果求购接口没返回数据,就返回“查不到数据”文本;如果系统异常,就返回“调系统服务失败”。这些错误信息对模型是有效的上下文,它会据此向用户解释,或尝试另一个方案。如果异常直接被程序吞掉,模型只能瞎猜,Agent的自主性就体现不出来。

2.2 工具定义怎么写,模型才会选对函数

工具定义的质量,直接决定模型“会不会用、有没有用对”。我见过不少Agent方案,模型明明有很好的工具,但就是不去调用;排查到最后,大多是工具描述写得太模糊。

一份典型的函数定义包含函数名、描述和参数schema。描述部分的作用是给模型“决策依据”,一定要写清楚这个工具是干什么的、什么时候该调用它、返回值是什么。比如:

  • 差的描述:查询CPU
  • 好的描述:查询指定服务器的CPU使用率,返回百分比数值,通常用于排查服务器负载过高问题

模型不靠函数名理解工具,它靠description理解工具。函数名和参数名主要是给开发者看的,但description是唯一指导模型“这个工具什么时候能用”的信息。

参数schema方面,除了定义类型,还要为每个参数写description,尤其说明参数含义和是否必填。参数越少越好,能用一个字符串拼起来的,就不要让模型生成嵌套对象。模型填参时如果对字段含义不明确,很容易漏传或传错,导致函数执行报错。

一个非常实用的技巧是,用enum给参数加合法值范围。比如“告警级别”只有低、中、高三种,就不要让模型自由发挥,用enum限定之后,模型填参几乎不会出错。对于日期、时间这类参数,建议在description里写明格式,比如“格式为YYYY-MM-DD”,否则模型有时会返回“昨天”“下周”这些自然语言,程序根本没法直接用。

工具数量多了以后,还有一个容易被忽略的点:工具之间的描述要尽量写清边界。比如“获取订单列表”和“获取订单详情”,描述里应该分别写明“用于查询交易记录列表”和“用于查询单笔订单的物流、商品、金额明细”。边界模糊时,模型会频繁选错函数,看起来像模型不聪明,其实是工具定义没做好。

2.3 模型返回的几种形态与终止条件设计

在循环调用中,模型每一轮返回的消息形态可以分成几类。第一类是纯文本回复,不带任何tool_calls,这意味着模型认为任务已经完成,循环可以直接结束。第二类是普通文本附带一个tool_call,表示模型在回复的同时还要做一件事。第三类是一个消息里附带多个tool_call,表示模型认为有几个独立、无依赖的操作可以并行执行。

处理并行tool_call时要格外小心。程序需要把所有tool_call都执行完,再按每个tool_call的id逐条回填结果。不少实现会漏掉其中的一部分,只执行第一个函数。这样模型拿到的工具结果不完整,容易造成“部分执行、整体失败”的假象,并且还会多消耗好几轮调用,成本翻倍。

终止条件的设计,是整个Agent里最重要的控制逻辑。我按自己经验整理过几个必做项:

  • 设置最大迭代次数,比如5轮,防止模型陷入循环。
  • 如果某一轮没有tool_calls,直接返回,不强制继续。
  • 对工具执行结果做长度限制,避免把大量数据一次性塞进上下文。
  • 危险操作单独设置“人工确认”开关,不纳入自动循环。

为什么必须限制最大迭代次数?因为模型一旦在“查询—结果异常—再查询”中来回横跳,单次任务的花费会直线上升,而且这种循环通常不是模型“深入思考”,只是陷入了模式重复。我遇到过一个案例,模型因为某个参数一直不符,连续查了十几次库存,每次结果都一样,它还在试图重试。那之后再写循环,我一定加一个硬性上限。

还有一个不算严重的坑:模型断言已完成但实际没做。比如用户问“总共几笔订单”,模型可能只凭对话印象直接回答,而不是先去调用查询工具。解决的思路是在System提示词里明确写“在回答需要数据的问题前,必须先调用相应工具,否则不要给出结论”,同时把temperature调低,让模型倾向于走工具链路,而不是自由发挥。

3. 实战:基于Function Calling构建一个自主Agent实例

3.1 实例场景与工具集合的规划

光讲原理容易飘,还是落到一个可以上手的例子上。这里做一个“智能运维助手”,用户可以用自然语言描述排查工作,Agent自己决定调用哪些工具、按什么顺序执行,最后给出结论。

先规划工具集合。演示版只需要四个函数,覆盖常见运维排查链路:

  • query_cpu_usage(host):查询服务器CPU使用率。
  • get_disk_usage(host, path):查询服务器磁盘占用情况。
  • send_alert(level, message):发送告警通知。
  • run_command(host, command):在远程服务器执行命令,因为有较高操作风险。

前面三个可以直接进入自动闭环,但run_command要区分场景。比如查询进程列表可以自动跑,删除文件、重启服务这类操作必须先经过二次确认。为了让演示清晰,这里把run_command定义为“只允许执行查询类命令”,并搭配一个confirm_action开关。真实项目中如果有删除、重启等高危操作,建议单独定义成另一个函数,并让它在执行前先调用确认接口,不要让你Agent的一行工具定义承载所有权限。

工具集合规划好后,再把System提示词写清楚:“你是服务器运维助手。用户提出排查需求时,先查询监控数据,再结合数据给出结论。涉及危险操作时必须先向用户确认,不要擅自执行。”这个提示词决定了Agent性格和底线,重要性不亚于工具定义。

3.2 核心代码实现:Agent主循环与函数执行器

下面直接放一段可参考的核心代码,按当下常见大模型接口的开放格式写,逻辑通用,字段大同小异。它包含两部分:函数执行器和Agent主循环。

import json import requests API_URL = "https://your-llm-endpoint/v1/completions" API_KEY = "your-api-key" TOOLS = [ { "type": "function", "function": { "name": "query_cpu_usage", "description": "查询指定服务器的CPU使用率,返回百分比数值,通常用于排查服务器负载过高问题。", "parameters": { "type": "object", "properties": { "host": { "type": "string", "description": "目标服务器主机名或IP地址" } }, "required": ["host"] } } }, { "type": "function", "function": { "name": "get_disk_usage", "description": "查询指定服务器的磁盘使用情况,返回每个分区的使用率列表。", "parameters": { "type": "object", "properties": { "host": { "type": "string", "description": "目标服务器主机名或IP地址" }, "path": { "type": "string", "description": "可选,需要查询的分区路径,默认为 /" } }, "required": ["host"] } } }, { "type": "function", "function": { "name": "send_alert", "description": "发送一条告警通知给值班负责人,用于服务器异常上报。", "parameters": { "type": "object", "properties": { "level": { "type": "string", "enum": ["low", "medium", "high"], "description": "告警级别" }, "message": { "type": "string", "description": "告警内容" } }, "required": ["level", "message"] } } } ] def execute_tool(tool_name, arguments): if tool_name == "query_cpu_usage": host = arguments.get("host") # 这里替换成真正的监控查询接口 return {"host": host, "cpu_percent": 87.5} if tool_name == "get_disk_usage": host = arguments.get("host") path = arguments.get("path", "/") # 这里替换成真正的磁盘扫描接口 return {"host": host, "path": path, "partitions": [{"mount": "/", "used_percent": 92}]} if tool_name == "send_alert": level = arguments.get("level") message = arguments.get("message") # 这里替换成真正的消息推送通道 return {"status": "sent", "level": level, "message": message} raise ValueError(f"未注册的函数:{tool_name}")

函数执行器很简单,按名字分发到不同处理逻辑。真实项目里建议加一层异常捕获,确保工具内部报错时返回一段描述性的错误文本,而不是让整个Agent进程崩掉。

再来看主循环代码:

def run_agent(user_input, max_iterations=5): messages = [ { "role": "system", "content": "你是服务器运维助手。用户提出排查需求时,先查询监控数据,再结合数据给出结论。如果是危险操作,必须先向用户确认,不要擅自执行。" }, { "role": "user", "content": user_input } ] for step in range(max_iterations): payload = { "model": "your-model-name", "messages": messages, "tools": TOOLS, "temperature": 0.1 } resp = requests.post( API_URL, headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, json=payload, timeout=30 ) resp.raise_for_status() reply = resp.json()["choices"][0]["message"] tool_calls = reply.get("tool_calls") if not tool_calls: return reply.get("content", "") # 把模型这条消息原样追加到对话里,尤其是保留tool_calls字段 assistant_msg = { "role": "assistant", "content": reply.get("content") or "", "tool_calls": tool_calls } messages.append(assistant_msg) for call in tool_calls: fn_name = call["function"]["name"] fn_args = json.loads(call["function"]["arguments"]) result = execute_tool(fn_name, fn_args) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False) }) return "已达到最大迭代次数,任务未能完成,请简化需求后重试。"

这段代码的骨架里藏着几个关键点。第一个是temperature设成了0.1。Agent执行任务时我们优先要的是稳定和正确,而不是发散创意。把temperature拉低后,模型会更倾向于严格按工具链执行,减少编造参数和临时起意。第二个是assistant消息里保留了tool_calls字段,这是很多实现容易漏掉的地方。模型这一轮“发出了调用指令”,后续的tool结果必须挂在这次调用上,如果assistant消息里没有原样回传,模型就不知道这些工具结果对应的是哪个指令,接口也会校验失败。

第三个是tool_call_id回填。每个函数调用都有自己的id,给工具结果标记上对应的id,模型才能把结果和调用一一对应起来,这是Function Calling协议规范的核心环节。执行结果统一用JSON字符串传给模型,结构化数据也尽量不要直接塞原始对象,保持消息数组内所有元素都是可序列化的普通字典,既方便调试,也避免一些接口对格式的额外要求。

我建议拿到这段代码后,先不要急着接真实数据库或真实服务器,用一个mock的执行器跑通循环。让模型走两轮工具调用,确认顺序、参数、回填都正确,再接真实接口。这样排查问题时能把“模型决策问题”和“真实服务问题”分开处理。

3.3 多步骤任务与上下文回填的关键逻辑

演示代码跑通后,可以试试更复杂的任务。比如用户说“服务器A的磁盘快满了,查一下哪些目录占用高,如果超过90%就发一条高等级告警”。这个任务需要模型依次调用get_disk_usage、可能再查一个进程或目录命令,最后根据结果决定是否调用send_alert。整个过程依赖多轮对话状态,每一步都要能看到前一步的结果。

多步骤任务里,上下文回填的正确性决定成败。我遇到过几次典型的错误写法:有人把工具结果拼接在system提示词里,有人干脆只在代码中用变量保存结果,不把它放进messages。这些写法都会让模型在下一轮决策时“失忆”,因为它看不到工具到底返回了什么,只能从用户原始描述推测,推理链条就断了。

正确做法就是上面代码里写的:每一轮模型返回的assistant消息原样保存,每一个工具执行结果按tool_call_id回填到messages里。这样messages会变成一个不断变长的对话记录,模型每轮都能看见:用户说了什么、自己上一步调了什么工具、工具返回了什么、接下来该怎么办。

在实践里我还会对工具结果做一层“摘要式叠放”。如果查询接口返回几千行数据,全部塞进messages会很快撑爆上下文窗口。更稳妥的办法是:把原始结果落库或缓存,只把精简摘要(比如前5行、最大值、均值、状态码)放回messages。模型不需要看全量日志,它只需要知道结果里和任务相关的关键信息。这个“工具结果压缩”的习惯,能让你在同样上下文窗口下多跑好几轮循环,Token成本也会明显下降。

还有一种常见情况:模型连续调用了两次同一个查询工具,但参数完全一样。这通常不是模型想确认结果,而是上一步的工具结果没有正确传回。可以检查messages里是否真的追加了对应的tool消息。如果确认追加了,再检查tool_call_id是否和assistant消息里的调用id一致。多数时候问题都出在这两个环节。

4. 常见问题、成本控制与安全兜底

4.1 高频报错与排查思路

Function Calling闭环跑起来不难,但实际开发过程中,我遇到过不少让人挠头的报错和“伪报错”。下面整理成速查表,方便对号入座。

现象最可能的原因解决办法
接口返回400,提示tool消息校验失败assistant消息没有回传原来的tool_calls字段按上面代码的做法,把assistant消息完整保存,包含tool_calls
接口返回400,提示tool_call_id不匹配执行完函数后回填的id对不上从模型返回的tool_call里取id,不要自行生成
模型始终不调用工具,直接给答案System提示词没强调必须先查数据;工具描述太弱强化System约束,优化工具description,降低temperature
模型返回参数类型错误参数schema里没写清楚类型和格式给参数加description、enum,必要时在代码里做parse与校验
函数执行后模型又重复调用同一工具工具结果没正确回填,模型看不到结果检查messages是否追加、tool_call_id是否匹配
输出里出现“函数不存在”类错误模型幻觉出未注册的函数名缩小函数名集合,每个工具的description写清楚边界
结果太长导致超token限制工具返回了全量数据执行器返回前做截断、摘要、采样
模型在几轮循环里原地打转缺少终止条件;模型推理不自洽增加最大迭代次数;对相同参数调用做去重

排查的通用策略是“看messages”。所有状态都在这条消息链里,把每一轮的messages打出来,模型做过什么决策、工具返回了什么、哪条消息对不上,一目了然。肉眼先看一遍,多数问题都能定位。

4.2 循环失控、Token超限与成本控制

Agent能做自主决策,随之而来的就是不可控的成本问题。你不能等线上跑完再哭,要提前把刹车装好。

首先是迭代上限。单次任务最多允许多少轮调用,要有一个明确的数字。我自己做演示场景通常设5轮,生产环境看任务复杂度,但一般不宜超过10轮。模型在有限轮数里解决不了的问题,再多给几轮也大概率是重复试错,不如直接终止并让用户接管。

其次是Token预算。在循环前估算一次任务可能消耗的Token量,公式简单:每轮请求的主要Token = 历史消息Token + 工具定义Token + 输出Token。工具定义如果写得非常长,它会在每一轮请求中重复计算。一个包含十几个大工具定义的系统,光工具描述可能就占掉几千Token,这是很多人忽略的隐性成本。

经验做法是:工具description精简化,能说清楚就行,不要写成长篇文档;历史消息里的工具结果做压缩;对用户输入和中间结果做截断。通过这三板斧,相同任务量下Token消耗通常能下降一半。

最后是“相同调用去重”。如果模型连续两轮请求里调用了同一个函数、参数也一样,那基本说明模型是在无效重试。代码里可以维护一个缓存集合,发现重复调用就直接返回上一次结果,不真正执行工具,也不让上下文无谓增长。这类情况在模型面对异常数据时经常出现,加一个缓存逻辑能明显节约成本,也能打破循环。

4.3 安全兜底:让Agent“想清楚再动手”

Agent一旦能自主调用真实系统里的工具,安全问题就是那条不能越的红线。Function Calling本身只负责传达模型的决定,真正干活的还是代码。因此安全边界必须在代码层牢牢锁住。

我的做法分成三层:

第一层,工具最小权限。给Agent注册的工具,只开放任务所必需的能力。比如运维场景下,查询监控数据的工具可以自动执行;发送告警的工具可以自动执行;但删除、重启、批量修改这类高风险操作,不应该做成普通函数直接摆在Agent面前。或者把这类操作单独拆出来,增加独立的参数确认机制。

第二层,二次确认。对于一些风险介于中间的操作,可以在System提示词里写明“必须先向用户确认再执行”,同时在代码层二次校验。不要完全依赖提示词约束,更可靠的方式是把“确认动作”也做成一个函数。Agent想执行危险操作时,必须先调用确认函数,由程序弹出确认框,用户点击同意后,代码才真正放行到操作接口。这里的原则是:模型能提议,程序能拦阻。

第三层,内容与数据安全。工具返回结果中可能包含敏感字段,在回填给模型前先做一次脱敏处理,比如隐藏密码、手机号、身份证号。同时,工具执行器不能直接接受模型传来的原始命令并拼接到系统命令里,避免注入类风险。凡是需要执行远程命令的场景,应该在执行器里做白名单匹配,而不是把模型生成的命令原样交给系统。

这套三层兜底看起来繁琐,但非常值得。Agent越自动化,出错的代价就越高。用户可能会体谅模型回答不准确,但绝不会体谅Agent执行了一个本不该执行的删除操作。宁可多设几道确认关卡,也不要把信任全部押在模型的“理解能力”上。

我在实际项目里还有一个体会:Function Calling这套机制,真正考验的不是模型,而是工具设计的颗粒度。工具定义得好,模型走起来顺风顺水;工具定义得含糊,模型就会频繁“自作主张”。如果发现Agent经常选错函数,先别急着换模型,把每个工具的description重写一遍,限定触发条件,再调低temperature,大部分选择错误都能收敛。

最后再分享一个小技巧。当你发现某些参数模型总是填不准,可以在工具描述里给它一个“默认值兜底”,比如不传path时自动用根目录;同时告诉模型“该参数不传也行”。少一个必填参数,就少一类报错。Agent这东西,本质上就是在不断地给模型降低决策难度,把模糊的问题变清晰,把清晰的问题变可执行,剩下的交给循环就够了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进