从Function Calling到最小Agent:大模型应用开发第二天实战

发布时间:2026/10/5 12:17:42
从Function Calling到最小Agent:大模型应用开发第二天实战
昨天我完成了AI应用开发学习的第一天把大模型API调通能让他像聊天机器人一样回话今天第二天我想聊点更值钱的——让模型不只是回话而是根据我的指令调用外部工具完成实际任务。说白了就是把一个大模型包装成能干活的AI应用。很多人学到这里就开始幻想起飞其实第二天最容易卡住不是不会调API而是不知道下一步该做啥。我给自己定了一个明确目标搭一个最小可用的Agent支持查时间、查天气、做点简单计算。如果你也在按AI应用开发学习路线走今天的内容可以直接照搬。1. 第二天我给自己定的目标从“会对话”到“会干活”1.1 为什么第二天就碰Agent而不是先学框架市面上很多教程一上来就让学LangChain、LlamaIndex这类框架我不太推荐。原因很简单在不懂底层机制之前框架只是帮你把代码包了一层壳出了问题你连错误日志都看不懂。第二天最应该学的是大模型应用开发的地基——Function Calling也就是常说的函数调用/工具调用。名字听起来高大上但逻辑特别朴素大模型本身不会查日历、不会查天气、不会做精确计算但它能“看懂”你的问题并且用一段结构化文本告诉你“我需要用某个工具”。我们开发者要做的就是把这个文本翻译成真正的函数执行再把结果喂回去让它继续回答。Agent的本质其实就是这个闭环模型提出调用意图程序执行工具执行结果返回模型模型继续推理直到不再需要工具为止。如果你理解了这条链路后面学ReAct、Plan-and-Execute、多Agent协作都不是问题因为它们都是基于这个基础循环加东西。1.2 今天要交付的小东西为了避免漫无目的地学我给自己定了一个可以验收的交付物一个命令行版AI小助手。它的功能范围很小只做三件事询问“现在几点”或“今天日期”它返回系统当前时间询问“某个城市今天天气怎么样”它通过一个写死的天气函数返回模拟结果询问“一些需要多步计算的题目”比如“3.14乘以2的3次方等于多少”它能拆解并在工具辅助下完成计算。范围小有小的好处你能把主循环彻底跑通看到模型从“选择工具”到“拿到工具结果”再到“生成最终回复”的完整过程。很多人卡住不是因为工具不智能而是因为第一遍链路没通就急着加各种能力结果到处报错。先把小闭环跑通再扩功能这是我在AI应用开发学习路线里最想强调的节奏。2. 先把工具箱准备好我这次用了这些依赖和配置2.1 技术选型为什么只依赖OpenAI兼容接口现在国内外的模型平台很多接口风格千奇百怪。但如果只为了学原理我不建议为一个平台单独适配SDK。更省事的做法是选择一个提供了OpenAI兼容接口的大模型服务也就是支持/chat/completions格式的API。这样代码里只需要用OpenAI官方Python包改两个环境变量base_url和api_key就能在不同厂家的模型之间切换。我见过不少初学者一上来就问“我该用哪个框架”其实应该先问“我用的模型支持哪种接口协议”。Function Calling目前已经是很多大模型平台的标准能力只是不同平台实现细节略有差异。建议你选一个支持tools参数和tool_calls返回的模型这是今天代码能跑通的前提。2.2 最小依赖清单这次我尽量少装东西降低你在环境上踩坑的概率。依赖就三个Python 3.10 及以上版本OpenAI Python SDKopenaipython-dotenv用来读取环境变量用pip安装的话在项目目录下执行pip install openai python-dotenv如果你的网络环境安装OpenAI包很慢也可以直接用requests手写请求但那样要自己处理鉴权和错误信息代码会多一些。我建议学习阶段还是用SDK把精力放在Function Calling逻辑上。2.3 环境变量与模型参数在项目根目录创建一个.env文件内容长这样API_BASEhttps://你的模型服务地址 API_KEY你的密钥 MODEL_NAME你的模型名称写.env文件的意义在于不把密钥硬编码到代码里后续切换模型环境也更加方便。配置完成后再写一个config.py或直接在代码开头加载import os from dotenv import load_dotenv load_dotenv() client OpenAI( api_keyos.getenv(API_KEY), base_urlos.getenv(API_BASE), )这里有几个参数值得刻意调一下。temperature建议设成0或者接近0因为Agent调用工具时必须稳定输出结构化内容太高的随机性会让它经常“发挥创意”编造参数。max_tokens也要设置一个较大的值否则模型生成的消息被截断后可能只输出半个JSON解析的时候直接报错。我自己习惯把这两个参数放在一个全局配置里方便后续调试。3. Function Calling让模型学会“说我要用工具”3.1 核心机制的三步循环我用一个生活化的类比来理解Function Calling你是一个实习生老板你的助手不会任何专业技能但他手里有一本工具书书上写着“遇到日期问题查日历遇到天气问题查天气App”。当你问他“今天几号”时他不会直接编一个日期而是告诉你“我需要查一下日历”然后你去查日历把结果告诉它它再回答你。这里的“工具书”在代码里就是tools参数助手说“我需要查一下日历”就是模型返回的tool_calls字段。完整的循环分三步把用户问题、历史消息、可用工具列表一起发给模型模型判断需要调用工具返回一个包含tool_call_id、函数名和参数的请求我们在程序中用真实函数执行并把“工具执行结果”作为一条新消息返回给模型让它接着生成最终答案。只要模型还请求工具就重复第2步和第3步一旦模型不再返回tool_calls就把它的回复展示给用户。3.2 一次请求和响应长什么样直接看数据比看概念直观。假设用户问“北京天气怎么样”我们发给模型的请求核心部分大致是这样{ model: 你的模型名称, messages: [ {role: system, content: 你是一个智能助手可以帮助用户获取时间和天气。}, {role: user, content: 北京天气怎么样} ], tools: [ { type: function, function: { name: get_weather, description: 获取指定城市的当前天气情况, parameters: { type: object, properties: { city: {type: string, description: 城市名称例如北京} }, required: [city] } } } ] }模型如果觉得需要查天气就不会直接说“北京天气很好”而是返回类似这样的结果{ tool_calls: [ { id: call_abc123, type: function, function: { name: get_weather, arguments: {\city\: \北京\} } } ] }注意模型返回的arguments是一个字符串不是对象。你需要对它做json.loads解析再传给真正的Python函数。这一步是很多人报错的重灾区后面我会专门讲。3.3 工具定义怎么写决定模型聪不聪明工具定义看起来只是几个字段但里面的description非常关键。模型不像人一样能看你的函数实现它只能根据文字描述判断“这个问题该选哪个工具”。如果工具描述含糊它就会频繁选错或者干脆不调用。举个例子你写一个时间工具tools [ { type: function, function: { name: get_current_time, description: 获取当前的日期和时间例如今天是几号、现在几点钟。, parameters: { type: object, properties: {}, required: [] } } }, { type: function, function: { name: get_weather, description: 查询指定城市的实时天气参数为城市中文名例如北京、上海。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京 } }, required: [city] } } } ]我把“参数含义”也写到description里模型生成参数时会更有依据。实测下来描述越接近日常口语模型的选择准确率越高。你甚至可以多写几个边界情况如果用户问“北京天气”参数应该是“北京”而不是“北京市”如果用户问“现在”那就是时间工具。这些细节会在调试阶段帮你省下大量时间。4. 一个能跑的最小Agent我写出的完整代码4.1 主循环逻辑下面这份代码是我在第二天的学习中反复调整后留下来的版本尽量简洁但保留完整的主循环。真实项目中可能还要加异常处理和日志学习阶段先跑通再说。import json import os from datetime import datetime from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(API_KEY), base_urlos.getenv(API_BASE), ) MODEL os.getenv(MODEL_NAME) # ---------- 工具函数 ---------- def get_current_time(): return datetime.now().strftime(%Y-%m-%d %H:%M:%S) def get_weather(city: str): # 这里用模拟数据接入真实天气API只需要改这一个函数 return f{city}晴气温22摄氏度东南风2级 # ---------- 工具注册表 ---------- tools [ { type: function, function: { name: get_current_time, description: 获取当前的日期和时间例如今天是几号、现在几点钟。, parameters: { type: object, properties: {}, required: [] } } }, { type: function, function: { name: get_weather, description: 查询指定城市的实时天气参数为城市中文名例如北京、上海。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京 } }, required: [city] } } } ] # ---------- 工具分发 ---------- def call_function(name: str, arguments: str): args json.loads(arguments) if name get_current_time: return get_current_time() elif name get_weather: return get_weather(cityargs[city]) else: raise ValueError(f未知工具: {name}) # ---------- 主循环 ---------- def run_agent(user_input: str): messages [ {role: system, content: 你是智能助手可以调用工具获取信息。}, {role: user, content: user_input} ] while True: response client.chat.completions.create( modelMODEL, messagesmessages, toolstools, tool_choiceauto, temperature0.1, ) message response.choices[0].message # 如果模型请求调用工具 if message.tool_calls: # 1. 先把模型的工具调用请求加入消息列表 messages.append({ role: assistant, tool_calls: [ { id: tc.id, type: function, function: { name: tc.function.name, arguments: tc.function.arguments } } for tc in message.tool_calls ], content: message.content or }) # 2. 逐个执行工具并把结果作为 roletool 的消息追加回去 for tc in message.tool_calls: tool_result call_function(tc.function.name, tc.function.arguments) messages.append({ role: tool, tool_call_id: tc.id, content: tool_result }) # 3. 继续循环让模型基于工具结果生成下一句 continue # 不再调用工具输出最终答案 print(message.content) break if __name__ __main__: run_agent(现在几点了顺便看一下北京天气。)这份代码里最重要的不是函数本身而是消息追加的格式。你如果去看官方文档会发现messages序列中assistant消息可以同时带content和tool_calls而tool消息必须用它对应的tool_call_id来匹配。两者顺序错了、字段少了接口就会报错“invalid message format”。4.2 踩坑工具返回结果必须按消息格式追加我第一天用LangChain写过一个半成品当时还不理解为什么工具调用总是报错。后来换成手写循环才彻底明白模型的每一条历史消息都必须完整保留尤其是那个带有tool_calls的assistant消息。很多人喜欢只把工具执行结果塞进messages比如加上一条“工具返回了天气”。这样做模型会失去“我刚刚请求了什么工具”的上下文无法对齐调用关系。正确做法是先追加原始assistant消息其中包含tool_calls字段再追加对应的tool消息字段里带上tool_call_id最后靠循环重新调用模型。用大白话说这就好比你跟同事协作你说“帮我查一下北京天气”这半句话本身也是对话上下文的一部分同事查完把答案给你你再回复别人。如果你把“帮我查一下”这句删了只留“北京天气晴”后面的对话就断线了。4.3 跑通后的效果我输入“现在几点了顺便看一下北京天气。”模型先给出一条带两个tool_calls的响应然后程序依次执行两个工具最后输出类似“现在是2025年5月10日 14:30:25。北京今天晴气温22摄氏度东南风2级。”虽然是一个很小的例子但它已经具备了Agent的基本结构识别意图、拆解任务、调用工具、汇总结果。有了这个地基后面加联网搜索、加数据库查询都只是换工具函数的事。5. 今天最有价值的一部分让Agent在低代码平台里快速成型5.1 为什么要聊低代码平台写代码跑通主循环之后我又去体验了一把低代码平台比如热词里常提到的“扣子”这类AI应用搭建平台。很多学习者会有一个误区觉得会写代码的人不需要低代码平台。其实不是这样的低代码平台最大的价值是让你用可视化的方式验证想法。比如我想测试“给Agent加一个搜索工具之后它会不会主动搜索”手写代码可能要半小时在低代码平台拖两个节点十分钟就搞定了。另外低代码平台天然帮你处理了会话记忆、工具封装、发布部署这些脏活。学习阶段先用它理解“节点编排”的抽象再回到代码里实现同样的流程你会忽然看明白很多框架背后的设计思路。所以我不建议“二极管式”地在代码方案和低代码方案之间二选一而是先写代码再上平台对照。5.2 在低代码平台里搭一个同样的Agent以扣子的标准流程为例创建一个Agent项目之后线路通常是开始节点接收用户输入大模型节点配置模型和系统提示词告诉它“你可以使用工具获取时间和天气”工具节点选择平台自带的天气插件或时间组件或者自定义一个API工具结束节点输出最终回答。每一步都有可视化配置项底层其实就是在帮你生成类似tools和messages的结构。我实际测试下来如果只做时间查询和天气查询低代码平台可以在五分钟内跑通而且自带调试面板能看到模型每一步的调用记录。对于不懂代码的人来说这是快速体验AI应用开发最没有门槛的方式。5.3 代码方案和低代码方案怎么选这里我给一张对照表是我自己做选择时的判断依据对比维度代码方案低代码平台原理可见性高每一步都清楚低被封装的比较多学习价值高能理解底层循环中适合验证想法上线速度慢但可控快适合做MVP调试粒度细能看到原始消息比较依赖平台日志扩展性高什么都能接受平台插件限制适合场景正式项目、深度定制快速原型、非技术人员我的结论是如果你是奔着“成为AI应用开发者”去的代码方案是必修课低代码平台只能当辅助工具。如果你是业务方只想快速看看Agent能干什么那直接用低代码平台效率最高。两种方式不冲突甚至可以先用平台验证Agent逻辑再用代码重写核心模块。6. 实测过程里的坑与排查思路重点6.1 模型就是不调用工具排查思路比背教程更值钱。我先说现象我给模型发“现在几点”它直接回答“抱歉我不知道当前时间”而不是调用工具。第一次我以为是代码问题换了模型之后才发现是参数漏了。这里有一套排查链路我后来一直这么用检查模型名称是否配置正确模型本身是否支持Function Calling。有些对话模型只适合聊天不返回tool_calls。检查请求里是否传了tools参数并且tools中每个函数结构是否合法。最简单的办法是把请求体打印出来手动确认tools字段不为空。检查tool_choice参数。设成auto让模型自己决定如果你设成了none模型永远不会调用工具。检查提示词是否明确写了“你可以使用工具”。大部分模型不会因为你没提就完全不调用但明确写了准确率更高。我最终的问题就出在tool_choice被上次实验改成了none忘记改回来。这种坑不是因为不懂原理而是实验没做记录。所以我的建议是把每次修改的参数打成一个配置文件别直接改代码里的硬编码。6.2 返回的JSON解析失败模型生成的arguments是字符串而且偶尔会带一些奇怪内容比如在JSON前后加注释或者用单引号代替双引号。我第一次用json.loads直接解析就崩了。更隐蔽的是如果max_tokens设得太小模型输出到一半被截断整段JSON是残缺的。后来我写了一个更健壮的解析函数import json import re def safe_parse_arguments(raw: str): raw raw.strip() # 如果模型在JSON外面加了注释或说明尝试只保留最外层花括号部分 start raw.find({) end raw.rfind(}) if start ! -1 and end ! -1 and end start: raw raw[start:end1] try: return json.loads(raw) except json.JSONDecodeError: # 兜底把单引号替换成双引号再试一次 cleaned re.sub(r(\\{1,2})?, , raw) return json.loads(cleaned)这个函数不能保证100%解析成功但能解决大部分“模型话多”的问题。根本解法还是把temperature调低并且对重要工具做重试机制解析失败就让模型重新生成。6.3 上下文无限增长预算越跑越高工具的返回结果、系统提示、历史对话都会累积到messages里。多轮调用后你会发现每次请求的token数量越来越大响应也越来越慢。原因很简单你必须把历史消息都发给模型它才能知道前面发生了什么。但有些历史不需要全部保留。我在第二天只做了非常朴素的优化每轮对话结束后统计当前消息总token数如果超过阈值就把最早的用户消息和工具结果丢弃只保留最近几轮。更高级的做法是让模型对历史做摘要把摘要作为新的系统消息放进去。但摘要方案也有风险摘要本身可能丢失关键信息。如果你的Agent只是查询时间天气这种无状态任务用“滑动窗口丢弃历史”是最省事且够用的方案。6.4 今天最想分享的一个调试习惯写Agent和写普通程序不一样普通程序报错有堆栈Agent的“错误”往往是模型打了个擦边球选了错误工具、编了不存在的参数、或者答非所问。这时候靠肉眼看日志效率太低。我建议大家一定要给每条消息加一行结构化日志大致包含[roleuser] 北京天气怎么样 [roleassistant tool_calls1] get_weather(city北京) [roletool] 北京晴22度 [roleassistant] 北京今天晴最高22度。这样你一眼就能看出模型在哪个环节出了问题。如果模型压根没调用工具你看到第二条就会缺失如果工具结果有问题你看到第三条就能定位。这个习惯我到现在做复杂Agent还在用区别只是把日志从print换成了独立的log文件。坦白说第二天做不到完美。我在调试时发现模型选工具偶尔还是看运气尤其是两个工具描述相近时。后来我养成了一个习惯把工具描述写得像给实习生下指令越具体越好。AI应用开发这条路最重要的不是学多少框架而是能亲手把一条链路跑通。今天这个最小Agent已经能让我后面的学习不再心虚。明天我打算继续加一个能联网搜索的工具再去补Agent的记忆和规划。回来我会接着写day03。