实战)
AI Agent开发绕不开的核心环节就是把大模型从“只会聊天”变成“能干活”。我这次要分享的是AI Agent系列教程的第三篇围绕大模型接口与工具调用Function Calling展开。上一篇讲了Agent的骨架和规划能力这次会深入到底层怎么让模型稳定地调用外部工具怎么处理接口返回的结构化数据以及我在实际项目中踩过的那些坑。这篇内容适合两类人一是已经跑通了大模型API调用想给Agent加上“手脚”的开发者二是想理解工具调用原理为后面做多Agent协作、复杂工作流打基础的读者。我会从接口协议、工具定义、调用循环、参数校验、异常处理这一条线讲下来全程配可运行的代码和实测数据保证你照着敲就能跑出一个会调用工具的Agent。1. 为什么工具调用是Agent的“手脚”很多人对Agent有误解以为它就是个大模型包装壳用户问一句模型答一句。真正的Agent是要去执行任务的比如查天气、订机票、操作数据库、调内部系统接口。可大模型本身只有“大脑”没有“手脚”它不联网、不读库、不执行代码所有动作都停留在“生成文本”这个层面。工具调用Function Calling / Tool Use就是给模型装上手和脚的关键机制。它的思路很直接事先告诉模型有哪些工具可用、每个工具长什么样参数结构、什么时候该用哪个工具。模型在生成回复时如果判断任务需要调用工具就会输出一个结构化的“工具调用请求”里面包含工具名称和参数。你的程序拦截到这个请求去真实执行对应的函数再把执行结果回传给模型模型基于结果组织最终回复。这一来一回就是Agent的核心工作循环。我在第一版Agent里就是只调大模型API发现它连“查天气”这种基础功能都做不到只会胡编一个天气给你。接入工具调用之后整个系统的能力边界立刻就不一样了。1.1 工具调用和普通API调用的本质区别普通API调用是你写死逻辑定义一个函数传参拿结果。工具的调用则带上了“智能决策”的味道——模型自己根据上下文判断该调哪个工具、该传什么参数。这个判断是基于模型对语义的理解所以同一个问题换个说法模型也能选出合适的工具。打个比方普通API调用像你用遥控器按按钮按哪个亮哪个全程你自己控制工具调用像你给助手描述需求助手自己去决定开电视还是开空调。这个“自主决策”的过程就是Agent智能体区别于普通程序的核心点。当然这种自主性也带来了不确定性你必须做好兜底方案后文我会详细展开。1.2 工具调用在Agent架构中的位置一个完整的Agent架构通常包含这几层感知层接收用户输入可能是文本、语音、图片规划层拆解任务决定先做什么后做什么工具层提供可调用的外部能力比如API、代码执行器、数据库查询记忆层保存对话历史、任务状态、长期偏好执行层把规划转化为具体的工具调用拿到结果继续决策工具层和执行层之间靠的就是大模型接口中的Function Calling机制。规划层决定“要做什么”工具层决定“能做什么”Function Calling就是这两者之间的桥梁。你得把工具定义写得够清晰模型的规划能力才能落地。2. 大模型接口的技术底座聊工具调用之前得先把大模型接口的基础说清楚。现在市面上主流的大模型厂商API风格基本都向OpenAI的接口规范靠拢这已经成为事实上的行业标准。你用熟一家切别的厂商就是改改base_url和密钥的事。我这边用OpenAI格式的接口来讲解因为它的文档最全、生态最成熟而且很多开源模型比如Qwen系列、GLM系列也都提供了兼容OpenAI格式的调用方式。2.1 接口协议基础认证、请求和响应大模型接口的核心就是一个HTTP POST请求访问一个/chat/completions或类似的端点。请求体是一个JSON里面主要包含这几个字段model模型名称不同厂商模型名不一样得在文档里确认messages对话消息列表每条消息包含role和contenttemperature温度参数控制随机性数值越大越发散max_tokens最大输出token数认证方式几乎清一色是Bearer Token也就是在请求头里放一个Authorization: Bearer sk-xxxx这样的密钥。大多数服务商还提供了OpenAI SDK包你只需要设置api_key和base_url两个环境变量就能开始调。一个最简的接口请求长这样from openai import OpenAI client OpenAI( api_key你的密钥, base_url你的接口地址 ) response client.chat.completions.create( modelgpt-4o-mini, messages[ {role: system, content: 你是一个智能助手。}, {role: user, content: 今天天气怎么样} ] ) print(response.choices[0].message.content)这里要注意虽然很多库会兜底填默认值但建议你显式设置好每个参数尤其是temperature不同任务的合适值差异很大。比如代码生成我倾向用0.1左右创意写作才会用到0.7以上。2.2 从聊天补全到结构化输出普通的聊天接口返回的是纯文本模型自由发挥的空间比较大。但Agent场景下程序需要的是能直接解析执行的结构化指令——比如“调用名称为get_weather的工具参数是{city: 北京}”。让模型用自然语言表达这层意思再让程序去解析既容易出错又低效。这时候就需要结构化输出能力。Function Calling的基础版本就是让模型输出一个JSON格式的工具调用指令程序拿到这个JSON直接反序列化成对象立刻就能执行。更进一步的还有JSON Mode可以强制模型只输出合法的JSON适合那些需要严格结构化数据的场景。接口层面对应的是tools参数和tool_choice参数tools系统支持的工具列表每个工具包含名称、描述、参数Schematool_choice控制模型何时使用工具有“自动”“必选某个工具”“禁用工具”这几个选项理解了这两个参数工具调用的大门就打开了。3. 工具定义是写给模型看的说明书工具定义的质量直接决定了模型能不能做出正确的调用决策。这步最容易被新手忽略却又恰恰是整个工具调用链路上最重要的一环。我的经验是花在写工具定义上的时间至少是写函数本体时间的1到2倍。工具定义的标准格式是这样的{ type: function, function: { name: get_weather, description: 获取指定城市的当前天气信息包括温度、湿度、风力等, parameters: { type: object, properties: { city: { type: string, description: 城市名称如北京、上海、广州 } }, required: [city] } } }看到没有这里面有一个容易忽视的字段叫description。这可是模型判段要不要调用这个工具的重要依据。描述写得像“获取天气信息”这种笼统的话模型很容易和别的工具搞混。你得写清楚“什么情况下用这个工具”“它接收什么参数”“返回什么数据”。3.1 参数Schema的正确书写姿势参数Schema用的是JSON Schema规范这其实就是一套描述“数据长什么样”的标准语言。初学者最容易犯的错是把参数写得过于粗糙导致模型要么不会填、要么乱填。几个关键要求每个参数必须有type包括嵌套对象里的属性也要标注每个参数必须有description这直接影响模型填参的准确性required数组必须准确列出必填字段漏了会导致调用时缺参数枚举值用enum列出来模型会严格遵守这能磨掉很多异常情况嵌套对象要完整展开结构不要只写一个object就完事比如你要设计一个获取天气的工具city参数你加一句“城市名称如北京、上海不要加‘市’字”模型就知道传参时别带后缀。这种细节实测能让参数准确率提升好几个百分点。3.2 工具粒度与命名规范工具不是越少越好也不是越多越好。工具粒度的拿捏我总结下来是“独立、内聚、语义清晰”这三个词。独立每个工具只做一件事不要把“查天气然后订机票”塞进一个工具里内聚强相关的操作放一起比如“获取用户信息”和“更新用户信息”可以合成一个用户服务但保留为两个独立tool语义清晰命名要用动词开头比如get_weather、send_email不要用weather或util1这种模糊名字工具数量上我建议一次对话最多传10到20个工具定义。太多了模型会挑花眼响应延迟也会明显上升。如果工具数量很大可以先做一层分类或者写一个“路由工具”作为入口让模型先选分类再选具体工具。3.3 我常用的工具定义模板不同业务场景的工具定义大同小异我给你一个能直接改着用的模板tools [ { type: function, function: { name: execute_sql, description: 执行只读SQL查询用于从业务数据库获取数据仅支持SELECT语句, parameters: { type: object, properties: { query: { type: string, description: 完整的SQL查询语句必须以SELECT开头 }, limit: { type: integer, description: 返回结果的最大行数默认10最大100, default: 10 } }, required: [query] } } }, { type: function, function: { name: get_current_time, description: 获取当前的日期和时间精确到秒常用于需要计算时间的场景, parameters: { type: object, properties: {} } } } ]这个模板里我特意展示了一个无参数工具get_current_time。很多开发者不知道无参工具也是常用场景properties设成空对象{}就行。模型会知道这类工具不需要传任何参数。4. 深度拆解工具调用的运行流程现在我们正式进入工具调用的核心环节。理解了整个调用循环你才能自己动手写一个Agent框架而不是只会调现成的库。4.1 工具调用的完整循环一个标准的工具调用周期分为下面的步骤用户发送请求附带系统Prompt和工具列表模型判断需要调用工具返回一个tool_calls结构程序解析tool_calls拿到工具名和参数程序执行对应函数拿到执行结果程序把工具执行结果作为一条新的消息传给模型模型基于工具的返回结果生成最终回复给用户这个过程可以用下面这段代码完整演示出来import json from datetime import datetime # 模拟执行工具 def get_weather(city: str) - str: # 这里接真实API我做了一个模拟返回 return json.dumps({city: city, weather: 晴, temperature: 24}, ensure_asciiFalse) def get_current_time() - str: return json.dumps({time: datetime.now().strftime(%Y-%m-%d %H:%M:%S)}) # 工具注册表实际项目中建议用装饰器自动收集 tools_map { get_weather: get_weather, get_current_time: get_current_time } # 第一次请求 messages [ {role: system, content: 你是一个智能助手可以帮用户查询天气和时间。}, {role: user, content: 北京现在天气怎么样} ] response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, tool_choiceauto ) assistant_message response.choices[0].message print(assistant_message)跑完这段代码你会发现assistant_message.tool_calls参数里装着模型决定要调用的工具信息。它的结构大致是ToolCall(idcall_abc123, functionFunction(arguments{city:北京}, nameget_weather), typefunction)拿到这个结构接下来就要通过遍历工具调用、执行函数、组装新消息这几个步骤完成整个循环。4.2 工具执行与结果回传工具调用的结果回传过程是新手最容易写错的地方。正确的做法是把工具执行结果放进一条roletool的消息里并用tool_call_id把这条消息和刚才的工具调用关联起来。代码继续写下去# 组装工具消息 tool_messages [] for tool_call in assistant_message.tool_calls: func_name tool_call.function.name func_args json.loads(tool_call.function.arguments) func_result tools_map[func_name](**func_args) tool_messages.append({ role: tool, tool_call_id: tool_call.id, content: func_result }) # 把助手消息和工具消息都追加到历史里 messages.append(assistant_message) messages.extend(tool_messages) # 第二次请求让模型看到工具结果后组织最终回复 final_response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools ) print(final_response.choices[0].message.content)注意几个细节assistant_message必须整个追加进messages不能只把content加进去否则后面的对话会丢失工具调用的上下文tool_call_id必须和之前返回的id一模一样这是消息关联的凭证工具执行结果建议用JSON字符串返回结构化数据比自然语言好解析4.3 多次工具调用的场景处理一个复杂任务可能触发多次工具调用。比如用户问“北京天气如何顺便告诉我今天几号”模型可能会同时发起两个工具调用。OpenAI格式的接口支持一次返回多个tool_calls你的程序要循环处理把每个工具的结果都组织成独立的tool消息。更复杂的情况是链式调用第一次工具调用的结果模型看了之后又需要调用另一个工具。比如用户问“北京和上海哪个更冷”模型先调get_weather(北京)、get_weather(上海)拿到结果后可能觉得需要额外查一下风力又调get_weather_wind(北京)。实现链式调用就是把这套循环包进一个while里直到模型不再返回tool_calls为止while True: response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools ) assistant_message response.choices[0].message messages.append(assistant_message) # 没有工具调用了说明可以返回最终结果 if not assistant_message.tool_calls: return assistant_message.content for tool_call in assistant_message.tool_calls: # 执行工具组织tool消息 ...这个while True就是Agent执行循环的雏形。等到后面加上了任务规划、自我反思它就是Agent的主干框架了。5. 实操从零搭建一个能调用工具的Agent理论部分讲了不少现在我把一套完整的实操过程走下来。我们会搭建一个能在命令行里对话的Agent它支持查询天气和当前时间并且能处理多轮对话。5.1 环境准备与依赖安装第一步确保环境里装好了Python 3.10以上版本然后安装OpenAI SDKpip install openai注意新版OpenAI SDK1.x版本的接口和旧版0.x差异很大很多老教程的代码直接跑不通。我建议直接用最新的pip install --upgrade openai装好后设置环境变量export OPENAI_API_KEY你的密钥 export OPENAI_BASE_URL你的接口地址如果你用的是OpenAI官方服务OPENAI_BASE_URL可以不设。但你要是接了国内厂商的兼容接口或者本地部署的模型服务比如vLLM、Ollama这个就必须设对了。很多开发者卡在这一步建议先在别的API调试工具里确认接口地址能通再来做开发。5.2 Agent类的设计与实现我习惯把Agent封装成一个类核心结构如下class ToolAgent: def __init__(self, modelgpt-4o-mini, toolsNone): self.client OpenAI() self.model model self.tools tools or [] self.tools_map {} def register_tool(self, name, func, tool_def): self.tools.append(tool_def) self.tools_map[name] func def run(self, user_input, system_prompt你是智能助手。): messages [ {role: system, content: system_prompt}, {role: user, content: user_input} ] while True: response self.client.chat.completions.create( modelself.model, messagesmessages, toolsself.tools, tool_choiceauto ) assistant_message response.choices[0].message messages.append(assistant_message) if not assistant_message.tool_calls: return assistant_message.content for tool_call in assistant_message.tool_calls: func self.tools_map.get(tool_call.function.name) if not func: # 工具不存在返回错误信息给模型 messages.append({ role: tool, tool_call_id: tool_call.id, content: 错误不存在的工具 }) continue try: args json.loads(tool_call.function.arguments) result func(**args) result_str json.dumps(result, ensure_asciiFalse) except Exception as e: result_str f工具执行失败{str(e)} messages.append({ role: tool, tool_call_id: tool_call.id, content: result_str })这个类里有两个地方是实际项目中一定要做到的工具执行必须做异常捕获不能让一个工具挂了整个Agent就崩溃工具不存在或者执行失败要以错误信息的形式回传给模型让模型知道“这个事没办成”它才能做出下一步决策5.3 实际运行效果与参数调节注册两个工具进去跑一下实际效果agent ToolAgent() agent.register_tool( nameget_weather, funcget_weather, tool_def{ type: function, function: { name: get_weather, description: 获取指定城市的当前天气信息, parameters: { type: object, properties: { city: {type: string, description: 城市名称} }, required: [city] } } } ) agent.register_tool( nameget_current_time, funcget_current_time, tool_def{ type: function, function: { name: get_current_time, description: 获取当前的日期和时间, parameters: {type: object, properties: {}} } } ) print(agent.run(北京现在天气怎么样)) print(agent.run(现在几点了))实测跑下来模型能准确选出对应工具并正确填空。这里有一个参数调节经验temperature默认是1.0但在工具调用场景下建议调到0.2左右太高容易让模型在参数上“自由发挥”导致参数格式不标准。5.4 上下文管理与记忆增强上面这个Agent有个致命缺陷每轮对话只传了本轮用户输入没有保留历史。实际项目里用户可能来回好几轮跳到第5轮还在问第3轮提到的某个信息。所以我们必须把messages历史维护起来。简单方案是把run方法里的messages抽成类成员每轮结束把整个messages存进去class ToolAgent: def __init__(self, ...): self.messages [] self.max_history 20 # 限制历史轮数防token爆炸 def run(self, user_input): self.messages.append({role: user, content: user_input}) # 历史太久远的消息裁剪掉 if len(self.messages) self.max_history * 2: self.messages [self.messages[0]] self.messages[-self.max_history * 2:] while True: # 同上使用self.messages ...注意裁剪策略有讲究system消息要保留裁剪从用户消息开始裁。这步看着不起眼实际做长对话时没有它token消耗会直线上升而且模型容易“遗忘”最初的指令。6. 常见问题与排查技巧实录工具调用看着简单实际跑起来坑比想象中多。我整理了几个高频问题每个都是从实际项目里捞出来的有问题的现象、排查路径和最终解法。6.1 模型返回空参数或者缺少必填字段现象模型发起了工具调用但arguments是{}或缺少必填字段。解析时直接KeyError程序报错。排查思路检查工具定义的description是否写得足够清楚特别是参数部分检查required数组有没有正确列出所有必填参数看看是不是模型版本太老不支持复杂的参数Schema我遇到过一种情况参数是嵌套对象模型老是只填外层不填内层。解决方法是把参数打平降低嵌套深度模型填参的准确率立刻上来了。6.2 模型重复调用同一个工具导致死循环现象Agent卡在一个while True里出不来反复调同一个工具而且返回的结果一模一样。排查思路检查工具执行结果是否每次都一样如果是考虑加入随机性或缓存标记检查工具出错后回传的错误信息如果错误信息太笼统模型无法判断下一步该怎么修正就会反复尝试同样的操作我的解法是给错误信息加上足够多的上下文。比如“执行失败城市参数city的值无效可尝试查询city的备选名”模型看了才能调整策略而不是原地打转。同时建议在循环外层加一个最大迭代次数比如5次超过就强制退出并返回提示。这是最保险的防线。MAX_ITERATIONS 5 current_iteration 0 while True: if current_iteration MAX_ITERATIONS: return 已经进行了多次工具调用仍未完成任务请检查并重试。 current_iteration 1 ...6.3 工具调用结果格式不一致现象有些工具返回纯文本有些返回JSON字符串有些返回Python对象。模型在组织最终回答时经常被冗杂的格式搞得晕头转向。我的建议是所有工具统一返回JSON字符串。这个约定能减少模型的理解负担也方便调试。工具内部就算业务逻辑复杂最后返回前也做一次json.dumps。注意JSON里不要放太长字段值。比如数据库查询返回1000行数据塞给模型之前要先做截断或者聚合否则一次调用的token消耗就是灾难。6.4 工具错误处理不当导致“崩溃链”现象工具内部抛了异常程序没捕获整个Agent进程崩了。排查思路看异常是哪个工具抛出来的给那个工具补充异常捕获并让上层兜底。我建议在工具函数内部主动捕获并返回错误信息不要让它抛出去。同时在上层的工具调用循环里也再包一层try-except双层防护。实践下来这种“错误隔离”让Agent的稳定产出提高了不少。6.5 工具定义过多导致模型“选择困难症”现象工具列表从10个增加到30个以后模型选择工具的准确率肉眼可见地下降还经常调错。排查思路工具太多模型的注意力被分散了。这不能怪模型这是attention机制的物理限制。解法是分级路由先让模型调用一个“总入口工具”它根据用户意图去加载下一级的子工具列表。这样每一层暴露给模型的工具数量控制在10个左右准确率就稳住了。6.6 兼容性问题不同模型对Function Calling的支持差异这个点很现实。OpenAI的GPT-4系列支持完整Function Calling但很多开源模型或者小参数的模型要么支持不完整要么输出格式不稳定。我实测过几个模型表现差异很大模型Function Calling支持参数准确性稳定性GPT-4o完整支持高稳定GPT-4o-mini完整支持中高稳定Qwen2.5-72B支持高较稳定Qwen2.5-7B基本支持中偶有格式错误Llama-3.1-8B部分支持低不稳定结论是生产环境优先选支持完整Function Calling的模型小模型可以做测试验证但不要贸然上生产。如果必须用小模型只能走提示词解析原生输出的老路自己做JSON解析和校验。7. 工具调用的进阶玩法与思考基础的工具调用跑通之后这个机制能玩出的花样远超你的想象。我这里分享几个我实测过的进阶方向每一个都能显著扩展Agent的能力边界。7.1 多工具协作让Agent自己编排工作流前面讲了单工具和简单链式调用但真实业务很少这么简单。比如用户问“本季度销售数据和去年同期对比怎么样”Agent需要先调query_sales_data再调query_sales_last_year可能还要调calculate_growth_rate最终组织成报告。多工具协作的关键在于工具之间的“数据契约”。上一个工具的输出能不能作为下一个工具的输入得在设计工具时就想清楚。我常用的做法是让每个工具返回统一的JSON结构比如都包含status和data两个字段这样模型面对不同工具的结果时处理逻辑是一致的。7.2 并行调用同时抓多个数据源OpenAI接口支持一次返回多个工具调用这意味着Agent可以先并行拉取几个独立的数据源再把结果汇合。比如问“给我推荐北京和上海适合周末去的餐厅”模型可以同时调search_restaurants(city北京)和search_restaurants(city上海)然后再汇总。这比串行调用快了一倍用户体验提升明显。值得注意的细节是被并行调用的工具彼此要确实独立如果B依赖A的输出就不能并行得排队队调。7.3 自定义工具的常见类型实际项目里工具的类型比想象中丰富。除了最常见的HTTP API封装还有下面这些代码解释器execute_python_code让Agent能动态写代码解决计算问题向量检索search_memory_documents接入RAG给Agent提供领域知识数据库查询query_database但要严格控制只能执行SELECT防止注入用户交互确认ask_user_confirmation这在执行敏感操作前非常有用子Agent调度spawn_sub_agent复杂任务拆给专门的子Agent去跑每一种工具的封装难度和风险不一样从架构上把它们的权限分开管理是很有必要的。8. 写在最后的踩坑血泪史工具调用这块我前后迭代了四五个版本踩过的坑写出来能凑一篇长文。这里挑几个最有价值的经验分享希望能帮你少走弯路。第一个教训是别让工具返回“自然语言”。早期版本里我有个工具返回“查询成功结果如下xx”模型在组织回答时经常把这句提示语也带上导致用户看到的回复里有莫名其妙的“查询成功”。改成纯JSON输出之后这个现象彻底消失了。第二个教训是工具定义务必版本化。工具参数变了、字段废弃了要留有余地。我有一次改了工具参数的字段名结果已部署的Agent全乱套了排查了半天才发现是新旧工具定义混用了。现在我在工具定义里加version字段改动时做兼容映射情况好多了。第三个教训是不要信任模型的参数一定要校验。就算模型选对了工具参数也可能不合法。比如execute_sql工具接进来的SQL如果没做只读校验Agent可能会执行写操作。我在工具执行前加了白名单校验和语句类型检查防止Agent被注入带偏。第四个教训日志记录比调试器好用。加了工具调用之后异步推进的调用栈非常复杂单步跟踪不太现实。我在关键节点打了结构化日志每条日志带上消息ID、工具名、参数摘要、执行耗时。排障时拉出日志一看问题在哪一步一目了然。工具调用这块内容就讲到这。如果你照着把上面这套循环跑通了你的Agent就已经具备“指挥外部工具干活”的基本能力。下一步可以试试给Agent接入真实的业务API、加上记忆能力或者搭一个前端页面让大家直接对话。这些都是很自然的延伸遇到新的坑欢迎再交流。