ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

函数调用:Agent从“会聊天”到“能办事”的核心实战

函数调用:Agent从“会聊天”到“能办事”的核心实战 1. 函数调用Agent从“会聊天”到“能办事”的关键一跃做Agent开发这段时间我最大的一个感受是很多人把Agent等同于“一个接了大模型API的聊天机器人”但真正让Agent变得有用的恰恰是函数调用Function Calling这一环。你可以让模型写诗、写代码、做翻译但只要它不能去查天气、查数据库、发消息、操作文件它就始终是个“纸上谈兵”的顾问而不是一个能帮用户把事办成的助手。函数调用的本质是让大模型在生成文本的基础上额外输出一个结构化的“调用意图”——包括要调用哪个函数、传入什么参数——然后由我们的代码去真正执行这个函数再把执行结果返回给模型模型基于结果继续回答或进行下一步操作。这个机制解决的核心问题就是大模型本身不具备访问外部世界的能力但通过函数调用我们可以把外部能力“接入”模型的推理循环让模型成为指挥中枢而不是执行终端。这篇文章适合谁看如果你正在做Agent开发或者刚接触AI Agent、打算在自己的项目里给模型加上工具能力那么这份总结值得你完整读一遍。我会从函数调用的设计思路、核心实现细节、完整的实操代码到我在实际项目中踩过的坑和排查经验一次性讲清楚。文章里的代码和方案都是我在真实项目里验证过的你几乎可以照着抄。2. 三种主流函数调用方式对比别一上来就选错路子在开始写代码之前你需要先搞清楚一个事情函数调用并不是只有一种实现方式。市面上常见的方案有三大类每一类的适用场景和坑都不一样。2.1 API原生函数调用最省心但绑定平台第一种是模型服务商在API层面直接支持的原生函数调用。典型代表就是OpenAI的functions/tools参数、Anthropic的tool_use以及国内许多厂商现在跟进支持的类似接口。你只需要在请求里把函数的定义包括函数名、参数描述、类型以JSON Schema的形式传进去模型在需要调用时就会在返回内容里多出一个tool_calls字段里面是结构化的调用请求。这个方案最大的优点是不需要你费劲去“教”模型怎么输出JSON——模型本身已经针对这个能力做过专门训练输出格式稳定解析也容易。缺点也很明显你被绑定在某一家厂商的接口风格上。换一个模型厂商或者用一些自部署的开源模型这个能力可能就不存在或者格式不兼容。不过如果你用的就是主流闭源模型API这依然是最值得推荐的第一选择。2.2 提示词式调用兼容性强但需要“调教”第二种方式是纯提示词方案。你不依赖任何API的原生能力而是在系统提示词里跟模型约定“当需要查询天气时请输出一个JSON格式为{tool: get_weather, params: {...}}”。然后你的代码去解析模型输出的文本匹配到对应的工具并执行。这个方案的核心优势是兼容性极强——任何能聊天的模型都能用包括那些没开放函数调用能力的开源模型。代价则是它的稳定性完全依赖提示词写得够不够清楚以及模型本身的理解能力。模型可能今天老老实实输出JSON明天换个措辞就加了点解释性文字你的解析器就得跟着修。所以用这个方案可以再配合下一条要说的“结构化输出”。2.3 结构化输出与手动解析夹缝中的折中方案第三种方案介于前两者之间利用模型API提供的JSON Mode或结构化输出能力强制模型返回合法JSON但JSON的字段含义由我们自己定义再由我们手动解析并分发到对应的函数。这个方案的优点在于既获得了相对稳定的输出格式又保留了对“调用协议”的完全控制权——比如你可以在JSON里加入业务流水号、加入多个工具的同时调用请求这些在原生功能里往往受限。缺点则是你需要自己写更多的解析和校验代码而且JSON Mode只能保证格式正确不能保证字段内容一定合理。为了让你更直观地做选择我把三种方式的比较整理成一个表格对比维度API原生函数调用提示词式调用结构化输出手动解析输出稳定性高低依赖模型能力中高实现复杂度低低但调提示词耗时中跨模型迁移性差好中多工具并行调用多数已支持看约定格式完全可控建议适用场景生产环境、主流API快速原型、开源模型需要高度自定义协议的场景我个人在生产环境里的经验是能用API原生函数调用就用原生的这是性价比最高的路径只有当模型没有原生支持、或者你需要一个跨厂商的统一Agent底层时才去走后两条路。3. 核心细节拆解一个高质量函数调用方案需要抠哪些细节很多人写函数调用就直接把函数定义往参数里一塞跑通了就算完事。但真正到了Agent项目里调用成功率、参数准确性、异常恢复能力这些细节才是决定一个Agent“好用”还是“鸡肋”的分水岭。这部分我拆成三个小节逐一讲透。3.1 工具定义你的函数签名写得越清楚模型就越不容易犯错函数调用的第一步是把你代码里的函数“翻译”成模型能理解的语言。以OpenAI的tools参数为例一个函数定义长这样tools [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气情况包括温度、天气状况和风力。当用户询问天气、气温、是否会下雨时使用。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、广州。必须是中文城市名。 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位默认使用摄氏度。, default: celsius } }, required: [city] } } } ]这里有几个细节是很多教程不会强调的。第一个是描述要“精确到使用时机”。description不只是给模型介绍这个函数是干嘛的更重要的是告诉模型“什么时候该用它”。我见过很多人写“查询天气”四个字就完了结果模型在用户说“今天适合穿什么”的时候完全没想过可以调天气接口。加上“当用户询问天气、气温、是否会下雨时使用”这种触发条件描述后调用率立刻上了一个台阶。第二个细节是参数描述里要写取值范围、格式约定、甚至默认行为。比如city参数如果你不写明“必须是中文城市名”模型有可能会给你输出“Beijing”而不是“北京”你的查询接口很可能因此报错。越细的约束越能减少下游解析的麻烦。第三个细节是required字段不能偷懒。必填的参数必须列出来否则模型偶尔会漏掉你其实必须要的参数。我在一个项目里就是因为没把user_id设为必填导致后台一连串的鉴权错误。3.2 参数解析与校验别信模型输出的每个字节都是金子模型不是数据库它的输出有一定概率出错。函数调用返回的JSON里参数类型不对、字段缺失、甚至整个JSON无法解析都是常见情况。因此在真正执行函数之前你要有一道校验关卡。我的做法是写一个通用的校验器在分发之前做三层校验第一层是JSON格式校验判断tool_calls里的arguments字符串能不能正常解析成字典。第二层是Schema校验把解析出来的参数再用定义时的那套JSON Schema格式跑一遍确认类型合法、必填项都在。第三层是业务校验这一步是校验一些Schema管不了的东西比如日期格式是不是合理的、金额是不是大于0、用户ID是不是存在。前两层可以用现成的库比如JSON Schema的Python实现jsonschema来做第三层就得自己在每个函数里写。很多人觉得这太繁琐但我实测下来加一道校验至少能拦截掉约5%~10%的模型错误输出在长期运行的Agent服务里这个比例足以避免大量线上事故。注意参数校验失败的时候不要直接抛异常终止整个对话循环。正确的做法是构造一条“工具执行错误”的消息返回给模型告诉它“参数不合法请修改后重试”。模型通常会自动修正Agent的容错能力就是这么一点点建立起来的。3.3 执行分发与结果回流把函数返回变成模型能消化的“事实”校验通过之后就进入执行分发环节。我推荐用注册表模式来管理函数与执行器的映射。注意这里有一个新手很容易踩的坑**模型传递过来的只是函数名和参数字典而不是真正的Python函数对象。**你不能直接拿字符串去eval更不应该用动态import这种危险操作。正确的做法是维护一个“名称到函数”的映射字典或者用装饰器把函数注册进一个全局注册表。比如TOOL_REGISTRY {} def register_tool(nameNone): def decorator(func): registry_name name or func.__name__ TOOL_REGISTRY[registry_name] func return func return decorator register_tool() def get_weather(city: str, unit: str celsius): # 实际去调用天气API return {temperature: 18, condition: 多云, city: city}执行完函数得到结果之后还有一个关键步骤结果回流。你要把执行结果组装成一条tool角色的消息追加到对话上下文中然后再带着这条消息去请求模型让它继续决定是给出最终回答还是发起下一轮函数调用。这里有个小技巧返回给模型的内容应该是“结构化的摘要”而不是原始API响应的整段JSON。比如天气接口可能返回50个字段的气象数据但模型做后续决策只需要其中的“温度、天气状况、风力”。所以你在工具函数里就应该把结果裁剪、浓缩只保留对后续推理有意义的字段。4. 实操全流程从零写一个带函数调用的Agent循环理论讲再多不如直接上一份能跑的代码。这一节我会带你从零写一个最小可用的Agent它支持两个工具查天气和给指定邮箱发提醒邮件。等这个循环跑通了你就等于掌握了函数调用的全链路骨架之后换工具、加工具、上框架都只是往里面添砖加瓦。4.1 环境准备与模型选型这个示例我用的Python 3.10OpenAI的Python SDK模型用gpt-4o-mini。你如果用的是其他兼容OpenAI接口格式的服务商比如国内的一些厂商或自建的网关代码逻辑完全不用改只要换掉base_url和api_key就行。pip install openai写代码之前先把API Key配置到环境变量里。我建议你创建项目根目录下的.env文件然后用python-dotenv加载避免把Key硬编码进代码。密钥这东西一旦提交到Git仓库后面漏出去补救了基本就晚了。4.2 构建工具定义与Agent主循环下面是完整的核心代码这个结构我建议你保存下来后面做任何Agent项目都可以在这个骨架上扩展import os import json from openai import OpenAI # 初始化客户端 client OpenAI( api_keyos.getenv(OPENAI_API_KEY), # base_urlhttps://你的网关地址, # 如果用了代理网关放开这行 ) # ---------- 1. 工具定义 ---------- TOOLS [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气。当用户问到天气、气温、是否下雨、是否适合出行时必须调用此工具。, parameters: { type: object, properties: { city: {type: string, description: 中文城市名例如北京}, }, required: [city] } } }, { type: function, function: { name: send_reminder_email, description: 给指定邮箱发送一封提醒邮件。当用户要求发送邮件、提醒事项、通知时调用。, parameters: { type: object, properties: { to_email: {type: string, description: 收件人邮箱地址}, subject: {type: string, description: 邮件标题}, body: {type: string, description: 邮件正文内容} }, required: [to_email, subject, body] } } } ] # ---------- 2. 真实函数实现模拟 ---------- def get_weather(city: str): # 实际项目里这里换成真实天气API调用 return {city: city, temperature: 16, condition: 多云转晴, humidity: 45} def send_reminder_email(to_email: str, subject: str, body: str): # 实际项目里这里换成邮件服务商API print(f[邮件发送] 收件人{to_email}, 主题{subject}) return {status: success, message: 邮件已进入发送队列} TOOL_REGISTRY { get_weather: get_weather, send_reminder_email: send_reminder_email, } # ---------- 3. 主循环Agent的核心 ---------- def run_agent(user_input: str, max_steps: int 5): messages [{role: user, content: user_input}] for step in range(max_steps): response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolsTOOLS, tool_choiceauto, ) msg response.choices[0].message # 判断模型是否需要调用工具 if not msg.tool_calls: # 没有工具调用说明模型已经准备好直接回答了 print(f最终回答: {msg.content}) return msg.content # 有工具调用先把assistant消息放入上下文 messages.append(msg) # 逐个处理工具调用 for tool_call in msg.tool_calls: fn_name tool_call.function.name fn_args json.loads(tool_call.function.arguments) print(f[调用工具] {fn_name}({fn_args})) # 在注册表里查找并执行 if fn_name in TOOL_REGISTRY: result TOOL_REGISTRY[fn_name](**fn_args) else: result {error: f未知工具: {fn_name}} # 把工具执行结果作为tool角色消息追加进上下文 messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse), }) # 超过最大步数兜底 raise RuntimeError(fAgent执行超过{max_steps}轮仍未结束) # ---------- 4. 执行测试 ---------- if __name__ __main__: run_agent(北京今天天气怎么样顺便帮我给 zhangsanexample.com 发个提醒邮件提醒他明天下午开会。)这个循环其实就是前面说的“五步循环”发给模型→模型决定调工具→执行工具→结果回传→再给模型。代码跑起来后你会在日志里看到模型先调get_weather、再调send_reminder_email、最后根据两个工具的返回结果生成一段完整的答案。这个时序是模型自主决策的不是你写死的这正是Agent和传统脚本最大的区别。4.3 解析为什么要设置max_steps上限一个Agent循环里如果模型不够聪明或者工具返回的信息有误导它有可能陷入“反复调工具、反复失败”的死循环里一次对话消耗几十次API调用。设置max_steps本质上是给整个循环加了一个“熔断器”。我在生产环境里通常不会设太高3到5步足够覆盖绝大多数场景因为一个正常的任务最多也就连续调用两三次工具。如果超过这个轮数还没结果宁可返回“我无法完成这个任务”也不要让用户等半分钟还看到一直在转圈。4.4 多工具场景下的进阶从手写分发到装饰器注册上面的示例里用一个字典做分发够用但工具一多就会很凌乱。我建议趁早改成装饰器注册的方式把“工具定义”和“业务函数”写在一起减少维护成本import inspect import functools def tool(nameNone): def decorator(func): registry_name name or func.__name__ TOOL_REGISTRY[registry_name] func # 自动从函数签名生成JSON Schema描述 TOOL_SCHEMAS.append({ type: function, function: { name: registry_name, description: func.__doc__ or , parameters: generate_schema_from_func(func), } }) return func return decorator tool() def get_weather(city: str): 查询指定城市的实时天气。当用户问到天气、气温、是否下雨时使用。 ...这个方案的好处是工具定义不再单独维护一份而是从函数签名和docstring里自动生成函数和Schema永远同步不会出现“代码里改了参数定义忘了更新”这种低级问题。等你的Agent工具数量超过10个你会发现这个设计帮你节省了大量精力。5. 进阶能力扩展函数调用怎么跟Agent框架、记忆、多Agent编排结合掌握了基础循环之后你会发现函数调用其实只是Agent系统的“执行底座”。真正让Agent强大的是在这个底座之上叠加记忆、技能Skill、多Agent协作等能力。这一节我结合当前Agent开发社区的热门方向谈谈怎么把函数调用往更完整的Agent架构上延伸。5.1 从零散函数到Agent Skill给函数加“使用层”最近社区里“Agent Skill”这个概念很火包括Claude发布的Agent Skills本质上也是在解决一个问题单个函数的能力太弱应该把“实现同一目标的一组操作”打包成一个可复用的Skill。举个例子单纯一个get_weather(city)函数是单一能力但如果Agent接到的任务是“帮用户规划一次周末旅行”它可能先调天气查询、再调酒店搜索、再调地图规划这就是三个函数的组合。与其让模型每次从头一步步试探不如提前把这些函数的调用链封装成一个高阶工具比如plan_trip(city, date_range)内部去编排天气、酒店、路线三个子调用。这个抽象层次的思想跟软件工程里的“函数→模块→服务”演进路径是一模一样的。我建议你做Agent时不要一上来就堆50个细粒度函数那会让模型在选择工具时无所适从。更好的策略是先做十几个粗粒度的Skill每个Skill内部再封装细粒度的函数。5.2 函数调用的结果如何写入记忆与工作区Agent在调用函数执行任务的过程中会产生大量中间结果。这些结果如果每次都只在上下文里流转一方面浪费Token另一方面下次对话就全丢了。这就是“Agent记忆”和“工作区”要解决的问题。我目前比较推荐的做法是函数调用的结构化结果除了返回到对话上下文之外同时写入到一个Agent工作区里。这个工作区可以是一个本地的JSON文件、一个向量数据库、或者干脆就是目标项目的文件目录。比如Agent执行完save_document工具后文件写入了本地路径返回给模型的是一条{status: saved, path: /data/doc_123.md}这样的消息而不是整个文件内容。模型知道文件已经存到哪儿了但不会把大段文件内容塞进上下文这样就同时兼顾了可追溯性和Token开销。5.3 多Agent编排中的函数调用谁调用结果归谁多Agent系统现在也是一个热门话题。这里要特别注意函数调用不再只是“一个Agent调用一堆工具”而是“多个Agent各自拥有不同的工具集”。架构上要做的是给每个Agent配置独立的tools列表和独立的工具注册表不能让Agent A调用Agent B的私有工具否则就失去了隔离的意义。还有一个容易出错的地方是状态归属Agent A调用了写文件工具Agent B随后要读这个文件如果两个Agent跑在不同的进程甚至不同的机器上A的写和B的读之间就需要一个共享的存储层。我建议小规模项目直接用一个Redis或者数据库表来当共享工作区而不是让Agent B直接去读Agent A的内存变量。在多Agent的编排下函数调用的结果还需要带上“执行者”和“时间戳”信息否则日志排查时你会疯掉。折腾过一两次就知道这种跨Agent的调用链路一旦出问题没有元信息根本定位不到是谁调错了参数。6. 常见问题与排查技巧实录这些坑我不希望你重踩一遍这一部分是全文最“贵”的内容全部来自我实际开发Agent时被折磨过的问题。我把它们整理成速查表和分析优先看那些跟你症状匹配的。6.1 模型该调用函数却不调用症状用户明确问了“北京天气怎么样”模型却自己编了一段“根据我的了解北京今天晴……”的幻觉答案完全没走工具调用。排查顺序先看tools参数是否真的传进去了、tool_choice是否被误设成了none。这两个是低级错误检查完基本能排除。然后再看函数描述里有没有写清楚“触发时机”如果只写了“查询某个城市的天气”这种静态描述模型确实容易把工具调用当成可选项。解决办法把描述改成带触发条件的动态表述。比如“当用户询问道市天气、气温、是否下雨、是否适合户外活动时必须调用此工具来获取实时数据不得自行编造天气信息。”加一句“不得自行编造”对于抑制幻觉很有效。6.2 参数类型不对传了字符串而不是数字症状函数定义里days参数是integer类型模型却传了3天这种值导致类型校验报错。原因模型的Token化过程对中文和数字的混合输入很不敏感。你定义的是JSON Schema但模型是在做文本生成它不一定严格遵循类型约定。解决办法除了在Schema里声明类型还要在参数描述里写清楚“只传数字不要带单位”。更稳妥的办法是在工具函数的入口做一次显式类型转换比如def set_reminder(days): days int(str(days).replace(天, ).strip()) ...这属于防御式编程宁可代码里多两行也不要让一个参数错误导致整条链路崩溃。6.3 并发场景下函数调用状态串了症状Agent服务上线后一旦同时服务多个用户就出现A用户的工具调用结果跑到了B用户的上下文里或者函数执行时用错了参数。原因绝大多数情况是消息列表被设计成了共享变量。记住一个原则Agent的对话上下文是强隔离的每一个用户会话必须有自己独立的messages列表不能有全局共享的上下文。解决方案生产环境里用会话ID做维度管理上下文。每一次请求都从会话存储里读取属于该会话的消息列表函数执行结果也按会话ID写回。如果需要并发执行多个工具调用还记得给每个工具调用的结果带上tool_call_id确保模型能正确匹配。6.4 工具返回结果太大上下文爆炸症状跑了一阵之后发现每次请求的Token消耗越来越大后来一查是某个工具把一份5000行的数据全量返回给了模型。原因工具返回结果进入了一直累积的对话上下文不会被自动清理大结果反复出现Token消耗自然飙升。解决办法三个字截、摘、引。截是只返回前N行摘是让工具内部先做一次摘要只把摘要返回给模型引是对于极大的数据量把数据存到数据库/文件只返回一个可以检索的ID或路径。我在生产项目里这三招组合使用Token成本直接降了60%以上。6.5 安全边界函数调用的权限必须收口症状有开发者在Agent里暴露了一个执行任意Shell命令的工具结果模型在某个输入诱导下执行了一串危险命令。原因没有做权限管控。函数调用的本质是把你系统里已有的能力暴露给模型而模型又会听用户的。用户输入是无限的模型的判断不是万无一失的所以你必须假设“用户正在尝试攻击这个Agent”。解决办法这几点我建议作为Agent开发的红线第一高危工具删除文件、执行命令、转账、发短信一律不暴露给模型改成由Agent生成“待确认操作卡片”由用户在前端手动确认后再执行。第二所有工具函数的参数要做白名单校验比如文件路径只能落在指定目录内。第三复杂操作增加一个“审计日志”记录每次工具调用的完整参数和结果。这三条都做到你基本就挡住了90%的常规攻击路径。7. 函数调用排查速查表与最后的经验总结把前面几节的内容压缩成一张速查表直接在排查时对号入座症状常见原因优先排查项模型不调用工具tools参数未传、描述缺少触发条件tool_choice设置、description触发词参数格式错误类型不符、单位混淆参数描述明确类型、函数入口防御转换调用结果不生效tool_call_id不匹配、上下文漏追加消息assistant消息和tool消息的顺序、id匹配Token消耗暴涨工具返回全量数据、上下文无限累积结果截断/摘要、消息列表窗口管理并发结果串线会话上下文全局共享按会话ID隔离messages列表函数报错中断执行异常未捕获try/except包裹工具执行、异常转为错误消息回传模型模型越权调用高危函数权限未收敛高危操作人工确认、参数白名单我个人在实际操作中的体会是函数调用的实现其实不难难的是围绕它的整套工程化设计——容错、隔离、审计、成本控制这些才是Agent能否长期稳定跑下去的关键。很多人觉得Agent开发就是“调一个API就完事了”但真正沉淀下来的能力恰恰是在这些细节里。最后再分享一个小技巧给你的每个工具写一句话的“使用时机说明”。这句话不是给用户看的也不是给代码看的而是给模型看的。模型决定调不调用工具主要依据就是工具描述和当前对话意图之间的匹配度。这一句描述写得好不好直接影响工具调用率值得你花时间反复打磨。我通常是跑一批真实用户日志看看哪些工具调用率低、哪些工具被误调用针对性调描述调完再跑一轮一般两三轮之后工具选择准确率就能稳定在95%以上。这个经验希望你下次做个带工具的Agent时能帮你少走不少弯路。
返回列表