XIAO ESP32C3接入ChatGPT API:物联网设备实现AI对话全流程实战
1. 项目概述:当XIAO ESP32C3遇上ChatGPT
如果你手头有一块小巧的XIAO ESP32C3开发板,并且对让它“开口说话”、接入智能对话充满了好奇,那么这篇实战记录就是为你准备的。我们这次的目标很直接:让这块小小的物联网板子,通过WiFi连接到互联网,并使用HTTP协议与ChatGPT的API进行对话。这听起来像是把大象装进冰箱,分三步:联网、发请求、收回复。但实际操作中,从网络连接到数据解析,每一步都有不少细节需要琢磨。我最近刚用XIAO ESP32C3完整走通了这个流程,过程中既体验了它作为RISC-V核心MCU的流畅,也踩了一些关于网络稳定性和数据处理的坑。这篇文章,我就把这些从硬件准备到代码调试的完整经验,毫无保留地分享出来,无论你是刚接触物联网的新手,还是想寻找一个轻量级AIoT方案的开发者,都能找到可以直接“抄作业”的步骤和避坑指南。
整个项目的核心,围绕着两个关键的Arduino库展开:WiFiClient和HTTPClient。前者负责建立和管理到无线路由器的TCP连接,是设备接入网络世界的“网卡驱动”;后者则是在这个TCP连接之上,构建符合HTTP协议(比如我们用的POST请求)的数据包,并处理服务器响应的“快递员”。而ChatGPT的API,就是我们对话的“云端大脑”。我们将通过向这个特定的API地址发送一个结构化的请求(包含你的API密钥和问题),来获取AI生成的文本回复。对于XIAO ESP32C3来说,它的ESP32-C3芯片原生集成了WiFi和蓝牙,内存和计算资源应对这种网络交互任务绰绰有余,关键是写出稳定、健壮的代码。
2. 硬件准备与开发环境搭建
2.1 认识你的武器:XIAO ESP32C3开发板
在写代码之前,我们得先熟悉手里的这块板子。Seeed Studio的XIAO ESP32C3以其极小的尺寸(约21x17.5mm)和完整的ESP32-C3功能而备受青睐。ESP32-C3是一款基于32位RISC-V架构的单核芯片,主频高达160MHz,内置400KB SRAM和4MB Flash。更重要的是,它集成了2.4GHz WiFi和低功耗蓝牙5.0。对于我们的项目,WiFi功能是基石。板子上的USB-C接口不仅用于供电,还直接连接到了芯片的USB串口,这意味着我们不需要额外的USB转串口芯片,开发调试会非常方便。
拿到板子后,第一件事是确认其Bootloader模式,以便后续上传程序。XIAO ESP32C3有两个按键:“B”(Boot)和“R”(Reset)。常规的上传流程是:先按住“B”键不松开,再按一下“R”键然后松开,最后松开“B”键。此时,板子会进入下载模式,串口会识别为一个新的设备。当然,现在Arduino IDE和PlatformIO的插件通常能自动处理这个流程,但手动操作在遇到问题时是很好的排查手段。
2.2 软件环境配置:Arduino IDE与核心库安装
我选择使用Arduino IDE进行开发,主要是因为其库管理生态丰富,对于快速原型开发非常友好。当然,你也可以使用PlatformIO,其工程管理更专业,但本文以Arduino IDE为例进行说明。
- 安装Arduino IDE:从Arduino官网下载并安装最新版本的IDE。
- 添加ESP32开发板支持:打开Arduino IDE,进入“文件”->“首选项”。在“附加开发板管理器网址”中,填入以下网址:
然后点击“确定”。https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - 安装ESP32开发板包:打开“工具”->“开发板”->“开发板管理器”。在搜索框中输入“esp32”,找到由“Espressif Systems”发布的“esp32”开发板包,点击安装。这个过程可能会比较慢,取决于你的网络环境。
- 选择正确的开发板和端口:安装完成后,在“工具”->“开发板”中,选择“XIAO_ESP32C3”。然后用USB线连接板子和电脑,在“工具”->“端口”中,选择新出现的串口(通常名称里包含“USB”或“XIAO”)。
注意:首次安装ESP32核心包后,可能需要重启Arduino IDE才能正确识别开发板和端口。
2.3 关键库的引入:WiFi与HTTP
我们需要的两个库WiFi和HTTPClient,通常已经包含在ESP32的Arduino核心包中,无需额外安装。你可以在代码中直接#include <WiFi.h>和#include <HTTPClient.h>来使用它们。为了后续与ChatGPT API交互时处理JSON数据,我们还需要一个JSON解析库。Arduino生态下最常用的是ArduinoJson。你可以通过“项目”->“加载库”->“管理库”,搜索“ArduinoJson”并安装由Benoît Blanchon维护的版本(目前最新为v6.x)。
至此,软硬件环境就准备妥当了。接下来,我们将深入代码,看看如何让这块小板子“活”起来。
3. 核心代码逻辑与网络连接实现
3.1 项目基础框架与WiFi连接
任何物联网项目的起点都是网络连接。下面是一个最基础的代码框架,包含了WiFi连接和主循环。
#include <WiFi.h> #include <HTTPClient.h> #include <ArduinoJson.h> // 请替换为你自己的WiFi信息 const char* ssid = "你的WiFi名称"; const char* password = "你的WiFi密码"; // ChatGPT API配置 const char* apiKey = "你的OpenAI API密钥"; // 例如:"sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" const char* chatgptEndpoint = "https://api.openai.com/v1/chat/completions"; void setup() { Serial.begin(115200); delay(1000); // 给串口监控一个启动时间 // 连接WiFi WiFi.begin(ssid, password); Serial.print("正在连接到WiFi: "); Serial.println(ssid); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(""); Serial.println("WiFi连接成功!"); Serial.print("IP地址: "); Serial.println(WiFi.localIP()); } void loop() { // 主循环,这里将放置我们与ChatGPT交互的逻辑 // 为了演示,我们每10秒执行一次 static unsigned long lastTime = 0; if (millis() - lastTime > 10000) { lastTime = millis(); chatWithGPT("你好,请用一句话介绍你自己。"); } // 其他任务可以在这里执行 }这段代码在setup()函数中完成了串口初始化和WiFi连接。WiFi.begin()函数是发起连接的起点,while循环会持续检查连接状态,直到成功。这里有一个实操心得:在实际产品中,这种阻塞式的连接循环(while...)可能不是最佳选择,因为它会卡住整个程序。更健壮的做法是使用非阻塞的状态机,或者在连接失败多次后进入深度睡眠/重启。但对于我们的实验和快速原型,这种方式最简单直接。
3.2 构建与发送HTTP请求到ChatGPT API
核心功能在chatWithGPT函数中实现。ChatGPT的聊天补全API(/v1/chat/completions)要求我们以POST方式发送一个JSON格式的请求体。我们需要构建这个JSON,并设置正确的HTTP头部。
void chatWithGPT(const char* userMessage) { // 0. 检查网络连接 if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi未连接,无法发送请求。"); return; } // 1. 创建HTTPClient对象 HTTPClient http; // 2. 指定请求的URL http.begin(chatgptEndpoint); // 3. 添加必要的HTTP头部 http.addHeader("Content-Type", "application/json"); http.addHeader("Authorization", String("Bearer ") + apiKey); // 注意Bearer后面有个空格 // 4. 构建请求JSON数据 // 使用ArduinoJson库动态创建JSON文档 // 根据API文档,我们需要一个类似这样的结构: // { // "model": "gpt-3.5-turbo", // "messages": [{"role": "user", "content": "你的问题"}], // "max_tokens": 150 // } DynamicJsonDocument requestDoc(1024); // 根据预估的JSON大小分配内存 requestDoc["model"] = "gpt-3.5-turbo"; // 也可以使用"gpt-4"等,但需账户支持 JsonArray messages = requestDoc.createNestedArray("messages"); JsonObject message = messages.createNestedObject(); message["role"] = "user"; message["content"] = userMessage; requestDoc["max_tokens"] = 150; // 限制回复长度,节省token和响应时间 String requestBody; serializeJson(requestDoc, requestBody); // 将JSON对象序列化成字符串 Serial.println("请求体: " + requestBody); // 5. 发送POST请求并获取响应代码 int httpResponseCode = http.POST(requestBody); // 6. 处理响应 if (httpResponseCode > 0) { Serial.printf("HTTP响应代码: %d\n", httpResponseCode); String response = http.getString(); Serial.println("响应内容: " + response); // 这里可以调用函数解析响应JSON parseGPTResponse(response); } else { Serial.printf("POST请求失败,错误: %s\n", http.errorToString(httpResponseCode).c_str()); } // 7. 释放资源 http.end(); }关键点解析与避坑指南:
http.begin():这个函数初始化HTTP客户端并指定目标URL。对于HTTPS(URL以https://开头),ESP32的HTTPClient库底层会使用TLS进行加密通信,你通常不需要额外配置证书(库已内置常见CA证书),但这也意味着通信过程是加密的,会消耗更多计算资源和时间。http.addHeader():设置HTTP头至关重要。Content-Type: application/json告诉服务器我们发送的是JSON数据。Authorization: Bearer <你的API密钥>是OpenAI API进行身份验证的方式,格式必须严格正确,Bearer后面有一个空格。- JSON构建与内存管理:使用
DynamicJsonDocument时,构造函数中指定的容量(如1024)是预分配的字节数。如果JSON结构非常复杂或内容很长,这个值需要增大,否则在序列化时会导致内存分配失败。一个实用技巧是,可以先用一个较大的值(比如2048),在串口打印出序列化后的字符串长度,然后根据实际需求调整到一个安全且不浪费内存的值。 http.POST():这个函数执行POST请求并返回HTTP状态码。200表示成功,401通常表示API密钥错误,429表示请求速率超限,等等。http.getString():获取服务器返回的整个响应体。对于ChatGPT API,这同样是一个JSON字符串。
3.3 解析ChatGPT的JSON响应
服务器返回的响应也是一个JSON对象,结构比请求复杂一些。我们需要从中提取出AI回复的文本内容。
void parseGPTResponse(String jsonResponse) { // 动态分配JSON文档内存,响应可能比请求大 DynamicJsonDocument doc(2048); DeserializationError error = deserializeJson(doc, jsonResponse); if (error) { Serial.print("JSON解析失败: "); Serial.println(error.c_str()); return; } // 导航到回复文本的位置 // 响应结构大致为:{"choices":[{"message":{"role":"assistant","content":"回复内容"}}]} if (doc.containsKey("choices") && doc["choices"].is<JsonArray>()) { JsonArray choices = doc["choices"]; if (choices.size() > 0) { JsonObject firstChoice = choices[0]; if (firstChoice.containsKey("message") && firstChoice["message"]["role"] == "assistant") { const char* content = firstChoice["message"]["content"]; Serial.println("\n===== ChatGPT 回复 ====="); Serial.println(content); Serial.println("=======================\n"); // 在这里,你可以将content用于其他用途,比如通过串口发送到其他设备,或者显示在屏幕上。 } } } else { Serial.println("响应JSON中未找到预期的'choices'字段。"); // 有时API返回错误信息也在JSON中,可以打印出来看看 if (doc.containsKey("error")) { Serial.print("API错误: "); serializeJsonPretty(doc["error"], Serial); Serial.println(); } } }解析注意事项:
- 内存大小:响应JSON可能包含很长的文本,所以
DynamicJsonDocument的容量(这里用了2048)要设置得足够大。如果解析失败,首先检查这个值是否太小。 - 健壮性检查:代码中使用了
containsKey()和is<JsonArray>()等方法来检查JSON结构,这是防止程序因意外响应格式而崩溃的好习惯。 - 错误处理:API可能返回错误信息(例如额度不足、模型不可用),这些信息通常包含在
error字段中。我们的代码对此做了检查,能帮助快速定位问题。
将parseGPTResponse函数添加到前面的chatWithGPT函数中,一个完整的、能与ChatGPT对话的XIAO ESP32C3程序就基本成型了。上传代码后,打开串口监视器(波特率115200),你应该能看到WiFi连接成功的提示,然后每隔10秒,会看到发送的请求和接收到的AI回复。
4. 稳定性优化与高级功能探讨
基础功能跑通只是第一步。在实际应用中,我们需要考虑网络的不稳定性、API的调用限制以及如何实现更自然的交互。
4.1 网络连接的重连与看门狗
在loop()函数中,我们可以添加一个WiFi状态检查,在断线时尝试重连。
void loop() { // 检查WiFi连接,如果断开则尝试重连 if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi连接丢失,尝试重连..."); WiFi.disconnect(); WiFi.reconnect(); // 可以加入一个短暂的非阻塞延迟,避免重连过于频繁 delay(2000); } // 原有的主业务逻辑 static unsigned long lastTime = 0; if (millis() - lastTime > 10000) { lastTime = millis(); chatWithGPT("继续用一句话说说物联网。"); } }此外,ESP32-C3内置了硬件看门狗定时器。对于长时间运行的任务,启用软件看门狗(task watchdog)或硬件看门狗可以防止程序因未知错误而完全死锁。在Arduino环境中,有时网络操作或复杂的JSON解析可能偶尔卡住,看门狗能自动重启设备。
#include <esp_task_wdt.h> // 包含看门狗头文件 void setup() { // ... 其他初始化代码 esp_task_wdt_init(10, true); // 初始化看门狗,超时时间10秒,启用panic模式(重启) esp_task_wdt_add(NULL); // 将当前任务(主循环)添加到看门狗监控列表 } void loop() { esp_task_wdt_reset(); // 在主循环中定期“喂狗”,表示程序运行正常 // ... 你的主循环代码 }4.2 实现多轮对话上下文
上面的例子是单轮对话,ChatGPT不会记住之前的问题。要实现多轮对话(上下文),我们需要在请求的messages数组中,不仅包含当前用户的问题,还要包含之前对话的历史记录。
我们需要一个全局或静态的数组来存储对话历史。由于ESP32-C3的内存有限,我们需要设定一个历史记录的最大条数或总字符数限制。
#include <vector> // 使用简单的数组也可以 struct Message { String role; String content; }; std::vector<Message> conversationHistory; // 存储对话历史 const int MAX_HISTORY = 5; // 最多保存5轮对话(包括用户和助理的回复) void addToHistory(const String& role, const String& content) { Message msg = {role, content}; conversationHistory.push_back(msg); // 如果历史记录超过限制,移除最旧的一条 if (conversationHistory.size() > MAX_HISTORY * 2) { // *2 因为一轮对话包含user和assistant两条 conversationHistory.erase(conversationHistory.begin()); } } void chatWithGPTWithContext(const char* userMessage) { // ... 前面的网络检查、HTTPClient初始化代码相同 // 构建JSON时,将历史记录也加入messages数组 DynamicJsonDocument requestDoc(2048); // 需要更大的内存来容纳历史 requestDoc["model"] = "gpt-3.5-turbo"; JsonArray messages = requestDoc.createNestedArray("messages"); // 1. 先添加历史记录 for (const auto& msg : conversationHistory) { JsonObject histMsg = messages.createNestedObject(); histMsg["role"] = msg.role; histMsg["content"] = msg.content; } // 2. 再添加当前用户消息 JsonObject currentMsg = messages.createNestedObject(); currentMsg["role"] = "user"; currentMsg["content"] = userMessage; requestDoc["max_tokens"] = 150; String requestBody; serializeJson(requestDoc, requestBody); // ... 发送请求的代码相同 if (httpResponseCode == 200) { String response = http.getString(); // 解析响应,获取助理回复 DynamicJsonDocument respDoc(2048); deserializeJson(respDoc, response); const char* assistantReply = respDoc["choices"][0]["message"]["content"]; // 3. 将本轮对话加入历史 addToHistory("user", userMessage); addToHistory("assistant", assistantReply); Serial.println("助理回复: " + String(assistantReply)); } // ... 清理资源 }这样,ChatGPT就能根据之前的对话历史来生成更有连续性的回复了。注意:保存历史会显著增加每次请求的数据量,消耗更多的API token(费用)和内存,需要根据实际情况权衡。
4.3 功耗考虑与深度睡眠
对于电池供电的XIAO ESP32C3项目,功耗是关键。我们的当前代码会让ESP32-C3一直处于活动状态,WiFi常开,这非常耗电。一个常见的优化模式是:设备大部分时间处于深度睡眠(Deep Sleep)状态,定时唤醒,连接WiFi,执行一次ChatGPT查询,然后将结果通过其他方式(如蓝牙发送到手机,或存储在Flash中)保存或输出,最后再次进入深度睡眠。
// 在setup()中,我们可以判断唤醒原因 void setup() { esp_sleep_wakeup_cause_t wakeup_reason; wakeup_reason = esp_sleep_get_wakeup_cause(); switch(wakeup_reason) { case ESP_SLEEP_WAKEUP_TIMER: Serial.println("由定时器唤醒"); // 执行我们的主任务:连接WiFi,与ChatGPT对话 connectWiFiAndChat(); break; default: Serial.println("非深度睡眠唤醒(如上电复位)"); // 首次启动,也执行一次任务 connectWiFiAndChat(); break; } // 主任务完成后,配置定时器并进入深度睡眠 Serial.println("准备进入深度睡眠,60秒后唤醒..."); esp_sleep_enable_timer_wakeup(60 * 1000000); // 微秒为单位,这里是60秒 esp_deep_sleep_start(); // 这行代码之后的都不会执行,因为芯片进入深度睡眠了 } void loop() { // 在深度睡眠模式下,loop()函数永远不会被执行 }在connectWiFiAndChat()函数中,你需要包含之前的所有WiFi连接和HTTP请求代码。这种模式下,设备平均功耗可以降到微安级别,非常适合由电池长期供电的物联网应用,比如一个每天自动获取AI生成的名言并显示的桌面摆件。
5. 常见问题排查与实战心得
在实际操作中,你几乎一定会遇到一些问题。下面是我总结的一些常见问题及其解决方法。
5.1 编译与上传问题
- 错误:
Failed to connect to ESP32: Timed out waiting for packet header- 原因:板子没有进入下载模式,或串口被占用。
- 解决:确保在上传前按正确顺序操作Boot和Reset键(先按Boot不放,再按Reset,松开Reset,最后松开Boot)。关闭可能占用串口的其他软件(如串口监视器、其他IDE)。
- 错误:
A fatal error occurred: Failed to connect to ESP32: Invalid head of packet- 原因:串口选择错误,或板子型号选择错误。
- 解决:检查“工具”->“端口”和“开发板”选择是否正确。尝试拔插USB线,或换一个USB口。
5.2 网络与API连接问题
- WiFi连接一直失败
- 原因:SSID或密码错误;路由器设置了MAC地址过滤;信号太弱。
- 解决:检查密码大小写;将路由器加密方式暂时改为WPA2-PSK(AES)试试;让设备靠近路由器。
- HTTP请求返回错误代码401
- 原因:API密钥错误或格式不对。
- 解决:仔细检查
apiKey变量中的字符串,确保没有多余空格或换行。确认你的OpenAI账户有可用额度,并且API密钥有效。
- HTTP请求返回错误代码429
- 原因:请求速率超过API限制(免费用户或新账户有每分钟/每天的请求次数限制)。
- 解决:降低请求频率,在代码中增加
delay()。对于免费额度,OpenAI的限制比较严格,耐心等待一段时间再试。
- 请求超时或无响应
- 原因:网络不稳定;DNS解析失败;OpenAI API服务器访问不畅。
- 解决:增加HTTP客户端的超时设置:
http.setTimeout(10000); // 10秒超时。可以尝试在代码中直接使用IP地址(不推荐,因为IP可能会变)。对于网络环境问题,可能需要检查网络配置。
5.3 内存与性能问题
- 设备运行一段时间后崩溃或重启
- 原因:内存泄漏。在
loop中频繁创建HTTPClient和DynamicJsonDocument对象而没有正确释放或重用。 - 解决:确保每次请求后都调用
http.end()。对于DynamicJsonDocument,它会在函数结束时自动析构,但如果是在全局或静态作用域,需要注意。也可以考虑将HTTPClient和大的JSON文档对象声明为全局变量,在loop中复用,而不是每次都在函数内创建。
- 原因:内存泄漏。在
- JSON解析失败(返回
InvalidInput或NoMemory)- 原因:
DynamicJsonDocument分配的容量不足,或者响应根本不是有效的JSON。 - 解决:首先,在解析前打印出原始的
response字符串,看看服务器到底返回了什么(可能是HTML错误页面)。其次,逐步增大DynamicJsonDocument的容量,例如从2048增加到4096。可以使用serializeJsonPretty(doc, Serial);来漂亮地打印解析后的JSON,帮助调试结构。
- 原因:
5.4 实战心得与技巧
- 从串口调试开始:
Serial.print是你的最佳朋友。在代码的关键节点(连接WiFi前/后、发送请求前、收到响应后)打印状态信息,能极大简化调试过程。 - 先测试简单的HTTP请求:在对接复杂的ChatGPT API之前,可以先尝试用
HTTPClient访问一个简单的公共API(比如http://httpbin.org/get)来验证你的网络连接和基础HTTP功能是否正常。 - 管理好你的API密钥:将API密钥硬编码在代码中并上传到公开仓库是极其危险的。对于个人项目,可以暂时这样,但最好能将其存储在外部(如SPIFFS文件系统),或者通过串口在启动时输入。对于产品,需要考虑更安全的密钥管理方案。
- 理解API计费:ChatGPT API是按token收费的。你的请求内容和AI的回复都计入token。在代码中设置
max_tokens参数可以限制回复长度,从而控制单次请求的成本。在开发调试阶段,可以用简单的问题进行测试。 - XIAO ESP32C3的引脚复用:这个板子引脚有限,如果你计划在此基础上添加传感器或执行器(比如按钮触发提问、OLED显示回复),需要仔细查看引脚定义图,避免冲突。例如,一些引脚在启动时有特殊电平要求,不当使用可能导致设备无法启动。
通过这个项目,你不仅学会了如何在XIAO ESP32C3上使用WiFiClient和HTTPClient,更重要的是掌握了一套让微控制器接入云服务、处理JSON数据、构建稳定物联网应用的通用方法。这套方法稍加修改,就可以用于连接其他无数的Web API,开启更多智能硬件的可能性。