简介:这是一份基于乐鑫ESP-IDF框架打造的国产开源小智AI机器人完整工程源码,主要面向物联网嵌入式开发者、机器人创客以及AI应用学习者,解决在ESP32系列芯片上快速接入大模型并实现自然语音对话的关键问题,帮开发者少走弯路。资源包内共包含306个文件,以C++源文件与头文件为主体(各86个),二者一一对应、结构工整,另含JSON配置、图片素材、Markdown说明文档以及少量Python辅助脚本,整体压缩后仅1.26MB,目录清晰、层次分明,便于按模块阅读、编译与二次开发。当前已有644人学习下载,实用性与热度可见一斑。通过阅读这份源码,读者能够系统了解DeepSeek、OpenAI、通义千问Qwen 2.5-Max等多款主流大模型的接入流程,掌握语音采集、音频编解码、显示屏驱动、OTA远程升级、MQTT通信等关键模块的实现思路;同时,代码对M5Stack、ESP32-S3等常见开发板做了适配,并保留了大量实用注释,可大幅降低二次开发门槛。无论是用于毕业设计、课程项目,还是作为产品原型参考,这套源码都能提供扎实的基础,是研究端侧AI与嵌入式机器人技术时一份接地气的参考资料,值得深入阅读与反复实践。
1. 小智AI机器人接入大模型:为什么是ESP-IDF
小智AI机器人是一个在乐鑫ESP32-S3平台上运行的开源语音助手。虽然ESP32-S3的算力跑不动DeepSeek、OpenAI这种参数规模的大模型,但它有完整的ESP-IDF驱动生态,能把“语音输入—云端大模型推理—语音输出”这条链路补成真正可用的产品。ESP-IDF在这里不只是点灯,它同时管着I2S音频、WiFi连接、TLS握手、OTA升级和JSON流解析,几乎覆盖AI硬件的全部底层逻辑。
选择ESP-IDF做这个项目,还有一个现实原因:生态里对音频和网络的支持非常成熟。esp-sr提供唤醒词和离线语音识别,esp_http_client内置TLS证书包,分区表和OTA接口都是现成的。更重要的是,社区里大量智能音箱、对话机器人、教育硬件的方案都跑在ESP-IDF上,遇到问题能查到别人的解法,而不是在裸机代码里慢慢熬。
这篇文章面向两类人:一类是想把类似结构搬到自有硬件上的工程师,另一类是刚接触ESP32-S3的开发。接下来会从零建工程、跑通音频链路、真正把DeepSeek、OpenAI、通义千问Qwen 2.5-Max接到板子上,最后聊几个只有真做对话硬件才会踩到的坑。
2. 搭建ESP-IDF开发环境与小智工程骨架
2.1 安装ESP-IDF工具链并初始化目标芯片
常见的开发方式是在一台Ubuntu或macOS主机上安装ESP-IDF,交叉编译后把固件烧到ESP32-S3模组。ESP-IDF从v5.x开始把工具链安装和项目构建分离,日常开发建议固定一个release分支,避免跟着master跑出现API波动。以Ubuntu 22.04为例,安装流程如下:
mkdir -p ~/esp && cd ~/esp git clone -b v5.4 --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32s3 . ./export.shinstall.sh只安装esp32s3对应的交叉编译工具链,不会把整个ESP32家族的编译器都拉下来,省磁盘也省时间。export.sh负责导出IDF_PATH和PATH环境变量,当前终端每次都要执行一次。如果想省事,可以把最后一行追加到~/.bashrc,但多版本ESP-IDF并存时这样做容易造成混乱,我一般会在每个项目的Makefile里显式source所需的export.sh。
2.2 创建小智机器人工程并规划组件目录
用idf.py自带的工程模板初始化一个项目,目标芯片设为esp32s3:
idf.py create-project xiaozhi cd xiaozhi idf.py set-target esp32s3create-project生成的是hello_world结构,只能确认编译链路通不通,离机器人还差得远。我会在main同级建立components目录,按功能拆组件,保证对话状态机、音频、大模型API互相不纠缠,后续单独升级某一模块时不用动其他代码。
构建配置集中在sdkconfig.defaults里,首次编译前把它复制成sdkconfig,再进menuconfig确认几个关键项。以下是推荐的工程目录布局:
| 组件/目录 | 职责说明 |
|---|---|
| main | 程序入口、WiFi连接、事件循环、对话状态机 |
| components/audio | I2S初始化、PCM采集与播放、环形缓冲管理 |
| components/wake_word | 唤醒词模型加载与检测封装 |
| components/llm | DeepSeek/OpenAI/Qwen API客户端,JSON序列化与SSE解析 |
| components/ota | OTA升级、固件校验、版本管理 |
| sdkconfig.defaults | 预置配置项,保证新同事拉取代码后编译结果一致 |
sdkconfig.defaults里至少要写入以下几项,否则后面音频缓冲和HTTPS握手都会在内存上卡住:
CONFIG_SPIRAM=y CONFIG_SPIRAM_USE_MALLOC=y CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y CONFIG_ESPTOOLPY_FLASHSIZE_8MB=ySPIRAM把外部PSRAM挂到堆分配器上,JSON解析和大模型响应的临时缓冲才能申请到大块内存。分区表选single_app_large是为后续OTA预留足够空间,8MB Flash在ESP32-S3模组很常见。
2.3 menuconfig里的三个必调参数
idf.py menuconfig打开图形配置界面,路径比较深,直接说结论:
Component config -> ESP32S3-specific -> Support for external, SPI-connected RAM,确认开启。小智机器人的音频环形缓冲和HTTP数据缓冲都放在PSRAM里,不开这个选项,编译能过,跑到一半会直接重启。Component config -> mbedTLS -> TLS 1.2,默认开启,但要看证书包有没有选上。Component config -> mbedTLS -> Enable esp_crt_bundle必须打开,后面调用大模型API的HTTPS连接才可以用乐鑫的根证书包。Component config -> FreeRTOS -> HZ,默认1000即可,唤醒词检测对实时性要求高,不要为了省电改成100。
这些配置改完后保存退出,idf.py build首次全量编译大概需要三到五分钟。编译通过后先烧一版空固件,确认板子和串口链路没问题,再继续加功能,后面排查问题时很多困惑都会消失。
3. 语音链路与唤醒词:小智AI的本地听觉系统
3.1 用I2S接口接入数字麦克风
小智AI机器人通常选用INMP441这类I2S数字麦克风,接口只有BCLK、WS和DOUT三根信号线。ESP-IDF v5.x的I2S驱动统一走driver/i2s_std.h这套新接口,收和发分别配置通道。下面代码初始化一个单声道16kHz采样率的输入通道:
#include "driver/i2s_std.h" #define I2S_SAMPLE_RATE 16000 i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); i2s_channel_handle_t rx_chan = NULL; ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &rx_chan)); i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(I2S_SAMPLE_RATE), .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = GPIO_NUM_4, .ws = GPIO_NUM_5, .din = GPIO_NUM_6, .dout = I2S_GPIO_UNUSED, }, }; i2s_channel_init_std_mode(rx_chan, &std_cfg); i2s_channel_enable(rx_chan); int16_t pcm[480]; // 480个采样点,16kHz下正好30ms size_t bytes_read = 0; i2s_channel_read(rx_chan, pcm, sizeof(pcm), &bytes_read, pdMS_TO_TICKS(100));I2S_CHANNEL_DEFAULT_CONFIG先定义通道编号和角色,决定DMA描述符数量等底层参数。接着用i2s_std_config_t设定时钟、位深和引脚。这里选16kHz/16bit/单声道,是因为唤醒词模型和后续的STT服务都以这个参数作为常用标准。pdMS_TO_TICKS(100)是阻塞超时,DMA在一段时间内没有数据时线程不会永久挂死。
INMP441的引脚接法比较固定,不同板子的I2S引脚定义各不相同,直接抄一份不可靠,可以先对照原理图查清GPIO编号,再用逻辑分析仪或示波器确认SCK波形,避免板子插上去读出来的全是杂音。
3.2 唤醒词检测与对话状态切换
小智AI机器人的交互流程:
- 待机状态,麦克风持续采集音频,喂给唤醒词模型
- 检测到唤醒词后,进入聆听状态,开始把PCM数据写入环形缓冲
- 检测到用户静音超过800毫秒,认为说话结束,组装音频发送给大模型
- 拿到文本响应后,进入播放状态,通过I2S输出TTS音频
- 播放完成回到待机
这个流程用一个简单状态机维护即可,不用上RTOS消息队列:唤醒词检测由专用任务循环调用,录音和播放状态通过全局枚举切换。
typedef enum { DIALOG_STATE_IDLE, DIALOG_STATE_LISTENING, DIALOG_STATE_PROCESSING, DIALOG_STATE_SPEAKING } dialog_state_t; volatile dialog_state_t dialog_state = DIALOG_STATE_IDLE;唤醒词检测在esp-sr组件中封装得很干净。esp_sr_wakenet_init加载模型,esp_sr_wakenet_detect输入PCM数据,返回正数代表命中唤醒词。具体API签名在不同版本里有差异,但整体思路一致:模型在系统初始化时只加载一次,之后每次喂30ms的PCM帧,主循环不做阻塞操作。
3.3 用RingBuffer承接持续到来的音频流
I2S是一个持续产生数据的硬件外设,即使在待机状态,DMA也在搬运数据。如果对话线程直接去读某个数组,音频数据被覆盖是迟早的事。常见做法是用FreeRTOS的RingBuffer做解耦:
#include "freertos/ringbuf.h" RingbufHandle_t pcm_ringbuf = xRingbufferCreate(128 * 1024, RINGBUF_TYPE_BYTEBUF);采集任务把i2s_channel_read读到的数据原样写入ringbuf,录音任务等到静音超时后consume整段数据。128KB的缓冲区对5秒16kHz/16bit单声道PCM来说足够,不会把对话线程阻塞到错过说话停顿。
注意RingBuffer内存只能用ps_malloc或heap_caps_malloc分配到PSRAM,不要占用内部SRAM,否则WiFi的LwIP缓冲和TLS工作区会被挤爆。这一个改动往往能解决一半以上的音频线程崩坏问题。
4. 对接DeepSeek、OpenAI、Qwen 2.5-Max的API实现
4.1 三种大模型API的兼容性与差异化选型
DeepSeek、OpenAI、通义千问在对话补全接口上都走OpenAI Chat Completions格式,ESP32端可以写成一套HTTP客户端,通过配置切换不同服务商,这是小智AI机器人接入多家大模型最省力的做法。
| 服务商 | Base URL | 默认模型 | 鉴权方式 |
|---|---|---|---|
| OpenAI | https://api.openai.com/v1 | gpt-4o-mini | Bearer Token |
| DeepSeek | https://api.deepseek.com | deepseek-chat | Bearer Token |
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen2.5-max | Bearer Token |
三家的请求和响应结构大致相同,但细节有差别。DeepSeek的content字段不会返回空,通义千问部分模型会额外返回reasoning_content,OpenAI的流式响应里delta.content有时是空字符串。解析时只要遇到content不是字符串类型就跳过,不要直接断言字段一定存在。
在模型选择上,小智AI需要低延迟和稳定输出,DeepSeek的deepseek-chat和通义千问qwen2.5-max都适合中文场景。OpenAI接入通常是为了英文问答,或者作为多模型对比调试时的基准。
4.2 esp_http_client发起HTTPS流式请求
在ESP-IDF里请求大模型API,最直接的工具是esp_http_client。它支持事件驱动回调、TLS证书包和自定义超时,不需要额外引入libcurl。下面代码初始化一个POST请求到DeepSeek:
#include "esp_http_client.h" #include "esp_crt_bundle.h" static esp_err_t llm_event_handler(esp_http_client_event_t *evt) { if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data_len > 0) { sse_parse(evt->data, evt->data_len); } return ESP_OK; } esp_http_client_config_t cfg = { .url = "https://api.deepseek.com/chat/completions", .method = HTTP_METHOD_POST, .event_handler = llm_event_handler, .timeout_ms = 15000, .buffer_size = 4096, .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&cfg);.crt_bundle_attach挂载乐鑫内置CA根证书包,比直接cacert_buf指定单一证书更稳妥,因为大模型API域名大多走CDN,证书链随时可能换。buffer_size决定单次事件回调最多能拿到多少数据,SSE流式响应里设置为4096已经足够。
esp_http_client_perform是同步阻塞的,整个HTTP生命周期里的事件都通过handler回调返回。不要在回调里做耗时操作,SSE解析只做字符串处理,对话状态转移放到外部任务。
4.3 用cJSON构造请求体并设置鉴权头
构造JSON请求体一定要用cJSON,不要手写字符串拼接。用户问答文本里会出现引号、换行、特殊符号,cJSON能自动做转义,手拼早晚出事。
#include "cJSON.h" cJSON *payload = cJSON_CreateObject(); cJSON_AddStringToObject(payload, "model", "deepseek-chat"); cJSON *messages = cJSON_AddArrayToObject(payload, "messages"); cJSON *user_msg = cJSON_CreateObject(); cJSON_AddStringToObject(user_msg, "role", "user"); cJSON_AddStringToObject(user_msg, "content", user_text); cJSON_AddItemToArray(messages, user_msg); cJSON_AddNumberToObject(payload, "temperature", 0.7); cJSON_AddBooleanToObject(payload, "stream", true); char *json_str = cJSON_PrintUnformatted(payload); esp_http_client_set_header(client, "Content-Type", "application/json"); char auth_header[64]; snprintf(auth_header, sizeof(auth_header), "Bearer %s", api_key); esp_http_client_set_header(client, "Authorization", auth_header); esp_http_client_set_post_field(client, json_str, strlen(json_str)); esp_http_client_perform(client);cJSON_PrintUnformatted分配的字符串会自己开内存,发送完成必须调cJSON_free释放。api_key不要直接硬编码在源码里,建议做成CONFIG项编译进固件,避免git提交时泄露到仓库。
常用参数其实就三个:temperature控制随机性,机器人交互场景调到0.7左右;max_tokens限制输出长度,防止模型在回答时把整个TTS播放器撑爆;stream设为true,首token到达时间会明显快于非流式,用户等待体感差异很大。
4.4 SSE流式解析的断包处理
大模型API以Server-Sent Events格式返回数据,每段消息以data:开头,以两个换行结束。问题是HTTP_EVENT_ON_DATA回调拿到的chunk长度不固定,一条完整JSON可能被拆成两个chunk发过来。处理断包最稳的办法是在解析层维护一个静态行缓冲,遇到换行才判定为一条完整消息。
static void sse_parse(const char *chunk, int len) { static char line[2048]; static int line_len = 0; for (int i = 0; i < len; i++) { if (chunk[i] == '\n') { line[line_len] = '\0'; if (strncmp(line, "data: [DONE]", 12) == 0) { dialog_state = DIALOG_STATE_PROCESSING; } else if (strncmp(line, "data: ", 6) == 0) { parse_choice_delta(line + 6); } line_len = 0; } else { if (line_len < (int)sizeof(line) - 1) { line[line_len++] = chunk[i]; } } } }static void parse_choice_delta(const char *json_str) { cJSON *root = cJSON_Parse(json_str); if (!root) return; cJSON *choices = cJSON_GetObjectItem(root, "choices"); cJSON *choice = cJSON_GetArrayItem(choices, 0); cJSON *delta = cJSON_GetObjectItem(choice, "delta"); cJSON *content = cJSON_GetObjectItem(delta, "content"); if (cJSON_IsString(content) && content->valuestring) { printf("%s", content->valuestring); } cJSON_Delete(root); }每次回调先累积到line,等换行符到了再整行处理。这个细节是小智对话流畅与否的分水岭:不处理断包,长回答会频繁截断或直接解析失败。cJSON_Parse失败时不要轻易重启任务,可能只是网络包被TCP分段,下一段数据来了拼接后就能修复。
4.5 多轮对话上下文与内存清理策略
让机器人具备多轮对话能力,不能让用户每轮都隔断上下文。做法是维护一个消息数组,在每次请求时把历史消息和当前问题一起发给大模型。
#define MAX_HISTORY 6 cJSON *messages = cJSON_AddArrayToObject(payload, "messages"); cJSON_AddStringToObject(sys_msg, "role", "system"); cJSON_AddStringToObject(sys_msg, "content", "你是一个桌面机器人,回答要简短自然,不要输出Markdown。");系统提示词放在数组最前,之后按顺序放用户和助手消息。历史满了就把最旧的非系统消息丢掉,保证请求体不超过服务商的token限制。ESP32-S3的RAM有限,多轮后历史缓冲逐步膨胀是家常便饭,建议用heap_caps_malloc(MALLOC_CAP_SPIRAM)申请历史缓冲,不要把内部SRAM耗尽。
每轮请求结束,cJSON_Delete(payload)释放整个JSON树,HTTP client同样销毁重建。不要复用同一个esp_http_client_handle_t反复发起POST,连接复用在大模型API场景下收益有限,反而容易遇到stream残留状态。
5. 固件烧录、OTA升级与调试手段
5.1 flash与串口日志过滤
工程编译通过后,一条命令烧录并进入串口监视器:
idf.py -p /dev/ttyUSB0 flash monitormonitor会把日志按级别染色输出。日志量太大时可以先烧录,再在代码里动态调整日志级别,只保留你想看的模块:
esp_log_level_set("*", ESP_LOG_WARN); esp_log_level_set("llm_client", ESP_LOG_DEBUG); esp_log_level_set("audio", ESP_LOG_INFO);第一行走*会把所有模块降到WARN,后面针对llm_client模块打开DEBUG,这样唤醒词检测、I2S DRI噪音都不会刷屏,只有大模型API的请求和响应细节进入视野。串口掉线时,按提示重新执行idf.py monitor即可,不用重新build。
5.2 内存回收与对话可靠性
大模型API返回的文本可能远长于TTS愿意播放的长度,我习惯在system prompt里加一句“回答控制在40字以内”,从源头限制输出。另外,esp_http_client在超时或断连后会留下一些内部缓冲,对话任务每次结束前主动打印空闲堆内存,能直观看到是否有泄漏:
ESP_LOGI("heap", "free heap: %" PRIu32 " bytes", (uint32_t) esp_get_free_heap_size());如果连续对话十轮后free heap持续下降且不恢复,优先检查环形缓冲有没有被consume,其次检查SSE解析里cJSON对象是否漏删。这里多数问题不在内存大小,而在路径上的资源释放。
5.3 OTA分区表与固件升级的设计要点
小智这类设备部署后无法接调试器,固件更新只能靠OTA。ESP-IDF的esp_ota_ops组件提供完整流程,关键前提是分区表要改成双槽:factory+ota_0+ota_1,而不是默认的single factory。
# partitions.csv # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, factory, app, factory, 0x20000, 2M, ota_0, app, ota_0, , 2M, ota_1, app, ota_1, , 2M,双槽的好处是升级失败后旧固件还在另一个分区,设备可以回滚。OTA写入流程是esp_ota_begin拿到句柄,分块调用esp_ota_write,写完校验后esp_ota_end,最后esp_ota_set_boot_partition指向新分区。每次写入4KB对齐,避免Flash擦写跨块。
验证OTA是否生效的命令:
esptool.py --port /dev/ttyUSB0 read_flash 0xe000 0x2000 otadata.bin0xe000是otadata分区起始地址,读取出来的内容可以配合esp_ota_get_running_partition的日志一起看,确认设备当前从哪个分区启动。如果OTA升级后设备反复连接不上大模型API,先检查设备系统时间和SNTP是否完成同步,TLS证书校验对时间偏差极其敏感,时间不对,证书链验证必挂。WiFi连上后调一次SNTP同步,问题往往立刻消失。
本文还有配套的精品资源,点击获取