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_iterations5): 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: fBearer {API_KEY}, Content-Type: application/json}, jsonpayload, timeout30 ) 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_asciiFalse) }) 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这东西本质上就是在不断地给模型降低决策难度把模糊的问题变清晰把清晰的问题变可执行剩下的交给循环就够了。