昨天我完成了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 SDK(
openai) - python-dotenv,用来读取环境变量
用pip安装的话,在项目目录下执行:
pip install openai python-dotenv如果你的网络环境安装OpenAI包很慢,也可以直接用requests手写请求,但那样要自己处理鉴权和错误信息,代码会多一些。我建议学习阶段还是用SDK,把精力放在Function Calling逻辑上。
2.3 环境变量与模型参数
在项目根目录创建一个.env文件,内容长这样:
API_BASE=https://你的模型服务地址 API_KEY=你的密钥 MODEL_NAME=你的模型名称写.env文件的意义在于:不把密钥硬编码到代码里,后续切换模型环境也更加方便。配置完成后,再写一个config.py或直接在代码开头加载:
import os from dotenv import load_dotenv load_dotenv() client = OpenAI( api_key=os.getenv("API_KEY"), base_url=os.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_key=os.getenv("API_KEY"), base_url=os.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(city=args["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( model=MODEL, messages=messages, tools=tools, tool_choice="auto", temperature=0.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. 逐个执行工具,并把结果作为 role=tool 的消息追加回去 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:end+1] 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的“错误”往往是模型打了个擦边球:选了错误工具、编了不存在的参数、或者答非所问。这时候靠肉眼看日志效率太低。我建议大家一定要给每条消息加一行结构化日志,大致包含:
[role=user] 北京天气怎么样 [role=assistant tool_calls=1] get_weather(city=北京) [role=tool] 北京,晴,22度 [role=assistant] 北京今天晴,最高22度。这样你一眼就能看出模型在哪个环节出了问题。如果模型压根没调用工具,你看到第二条就会缺失;如果工具结果有问题,你看到第三条就能定位。这个习惯我到现在做复杂Agent还在用,区别只是把日志从print换成了独立的log文件。
坦白说,第二天做不到完美。我在调试时发现,模型选工具偶尔还是看运气,尤其是两个工具描述相近时。后来我养成了一个习惯:把工具描述写得像给实习生下指令,越具体越好。AI应用开发这条路,最重要的不是学多少框架,而是能亲手把一条链路跑通。今天这个最小Agent,已经能让我后面的学习不再心虚。明天我打算继续加一个能联网搜索的工具,再去补Agent的记忆和规划。回来我会接着写day03。