简介:ESP32S3接入deepseek大模型的项目源码包,面向嵌入式开发者与物联网爱好者,特别适合希望在低成本硬件上体验云端大模型能力的入门及中级开发者,旨在解决在资源受限的ESP32S3开发板上调用云端大模型实现串口问答的问题。代码基于Arduino IDE环境组织,包含WiFi网络初始化、deepseek API请求发送与JSON响应解析等核心闭环,用户通过串口输入问题即可获得模型返回的回答。压缩包共3个文件,以inscode配置、HTML辅助页面、gitignore版本管理文件为主,整体仅7KB,结构紧凑,适合快速查看与移植。目前已有194人学习下载。通过这份源码,读者能获得完整的接入思路和可运行的示例框架;考虑到现有实现存在响应较慢、缺少记忆功能等局限,也可作为后续优化网络传输、加入本地缓存的实践基础。相关思路可延伸至智能家居、边缘设备等需要云端AI能力的应用场景。
1. 方案设计与整体架构
1.1 为什么是ESP32S3 + DeepSeek API
先回答一个所有人都会问的问题:ESP32S3这种MCU上怎么跑大模型?答案是——跑不动,也没必要跑。以DeepSeek-R1这类模型为例,动辄几十亿参数,光权重文件就要好几个GB,别说ESP32S3那8MB PSRAM,就算树莓派都吃得够呛。
那这个项目到底在做什么?说白了就是让ESP32S3当一个"聪明终端":它负责联网、采集输入(按键、语音、传感器数据)、把问题发给DeepSeek的云端API,再把返回结果展示出来(屏幕、喇叭、串口)。大模型的计算发生在云端,ESP32S3只做I/O和网络通信。这套思路在物联网圈其实很常见,类比一下就是智能音箱的模式——本地只做拾音和播放,大脑在云端。
选ESP32S3而不是ESP32、ESP8266,主要理由有几点:
- 双核240MHz Xtensa LX7处理器,跑HTTP/HTTPS、JSON解析这类任务余量充足
- 自带512KB SRAM,加上板载8MB PSRAM(HMI开发板标配),可以缓存较长的模型回复
- 内置向量指令加速,虽然跑不了大模型,但未来接语音唤醒模型、关键词识别时优势明显
- USB-OTG原生支持,接USB麦克风或者调试都方便,不需要额外转接芯片
1.2 系统架构与交互流程
我搭的这个项目架构分三层:
用户输入层(按键/串口/语音) → ESP32S3主控层(联网、组包、解析) → DeepSeek API(大模型推理)实际交互流程是这样的:
- 设备上电,连接Wi-Fi
- 用户通过板载按键(或串口输入)触发提问
- ESP32S3把问题封装成DeepSeek Chat API要求的JSON格式
- 通过HTTPS POST到
https://api.deepseek.com/chat/completions - 接收返回的JSON,解析出回答文本
- 显示在OLED/LCD屏幕上,同时通过串口打印
这里有个关键设计决策:整个链路里最耗时的是网络请求和模型生成,本地代码要保证在等待期间不卡死其他任务。所以我用了FreeRTOS跑两个任务——主任务负责UI和输入,网络任务负责请求和接收,二者通过队列通信。一开始我也写过单线程阻塞式代码,结果屏幕在等待响应时完全黑屏,体验非常差,这个后面细说。
1.3 功能边界:做什么不做什么
这个项目的定位是"AI助手硬件终端底座",而不是一个完整的产品。我刻意砍掉了这些功能:
- 不做流式输出(SSE),第一版就老老实实用一次性返回,等模型生成完了再一次性显示。流式输出能大幅改善体验,但ESP32S3这边要处理chunked transfer编码,复杂度高不少,放在后续优化
- 不做连续多轮对话的记忆管理,每次请求都把完整历史记录发过去太费流量,ESP32S3的RAM也不够存太多上下文
- 不做本地离线模型,上面已经说过了,不现实
做得好的核心功能就三个:Wi-Fi稳定连接、API请求封装、响应解析与显示。这三个是后面所有扩展的地基。
2. 硬件准备与开发环境
2.1 开发板选型与硬件清单
我用的是ESP32-S3-DevKitC-1,8MB PSRAM版本。如果手头没有,买任何带PSRAM的ESP32S3开发板都行,比如合宙ESP32S3、微雪ESP32-S3 LCD Kit。注意:一定要选带PSRAM的版本,因为后面跑HTTP请求时,TLS握手和JSON解析对内存的要求远超想象。
完整硬件清单:
| 硬件 | 型号/规格 | 用途 |
|---|---|---|
| 主控板 | ESP32-S3-DevKitC-1(8MB PSRAM) | 主控 |
| 屏幕 | 0.96寸OLED(I2C,SSD1306) | 显示问题和回答 |
| 按键 | 轻触按键 x 2 | 触发提问/切换问题 |
| USB线 | USB Type-C数据线 | 供电和烧录 |
| 可选:麦克风 | INMP441(I2S接口) | 语音输入(进阶) |
| 可选:功放喇叭 | MAX98357A + 3W喇叭 | 语音播报(进阶) |
在没有屏幕的情况下也能跑,串口监视器就是你的交互窗口。不过实际用下来,有了屏幕才真正有"AI终端"的感觉,OLED虽然只是单色显示,但能滚动显示出完整回答,已经很酷了。
2.2 开发环境配置
基础环境:
我用的Arduino IDE 2.x,不是ESP-IDF。原因很直白:Arduino下能用的库(WiFiClientSecure、ArduinoJson)足够完成任务,代码量少,调试方便。如果追求极致的内存优化和更精细的task调度,再考虑迁移到ESP-IDF。
安装步骤:
- 安装Arduino IDE 2.x
- 在"开发板管理器"中添加ESP32支持:文件 → 首选项 → 附加开发板管理器URL,填入
https://espressif.github.io/arduino-esp32/package_esp32_index.json - 在开发板管理器中搜索"esp32",安装
esp32 by Espressif Systems(版本建议3.x) - 开发板选择
ESP32S3 Dev Module
需要安装的库:
| 库名 | 用途 | 安装方式 |
|---|---|---|
| ArduinoJson | JSON解析/构造 | 库管理器搜索安装,我用7.x版本 |
| U8g2 | OLED屏幕驱动 | 库管理器安装,支持SSD1306 |
| WiFiClientSecure | HTTPS请求 | ESP32自带,无需安装 |
注意:ArduinoJson 7.x和6.x的API有区别,很多老教程用的是6.x语法。本项目代码基于7.x,如果你用的是6.x,
JsonDocument的声明方式会不一样,需要对应调整。
2.3 获取DeepSeek API Key
这一步属于必做的前置工作。登录DeepSeek开放平台后,在控制台创建API Key。注意几件事:
- API Key只显示一次,关掉页面就看不到了,务必先复制保存
- 新用户通常有免费额度,但过了赠送额度后是按token计费,个人测试跑不了多少钱
- 请求地址是
https://api.deepseek.com/chat/completions,不是https://api.deepseek.com/v1/chat/completions,这一点很多教程搞混过
模型名称填deepseek-chat,对应的就是DeepSeek-V3。如果未来出了新模型,以官方文档为准。
3. 核心代码实现与解析
3.1 API请求的核心要点
DeepSeek提供的API是OpenAI兼容格式,所以代码结构跟调用ChatGPT API完全相同。请求体的核心结构:
{ "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是一个嵌入式AI助手,回答要简洁。"}, {"role": "user", "content": "ESP32S3有多少个GPIO?"} ], "stream": false }这里有个细节容易被忽略:system提示词。别看这个字段简单,它对回答质量影响非常大。实测下来,加一句"回答控制在100字以内",返回内容会精简很多,对ESP32S3有限的显示空间非常友好。不加system提示词,模型经常给你长篇大论,屏幕上要滚好几屏。
3.2 完整代码实现
全局定义和Wi-Fi连接:
#include <WiFi.h> #include <WiFiClientSecure.h> #include <ArduinoJson.h> #include <U8g2lib.h> // Wi-Fi 配置 const char* WIFI_SSID = "你的WiFi名"; const char* WIFI_PASS = "你的WiFi密码"; // DeepSeek API 配置 const char* API_KEY = "sk-你的APIKey"; const char* API_HOST = "api.deepseek.com"; const char* API_PATH = "/chat/completions"; // 屏幕初始化(I2C地址0x3C) U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8C8_PIN_NONE, 21, 22); // 按键引脚 #define BUTTON_ASK 0 #define BUTTON_NEXT 14 String lastQuestion = "你好,介绍一下你自己"; String lastAnswer = ""; void connectWiFi() { Serial.print("连接WiFi"); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); int retry = 0; while (WiFi.status() != WL_CONNECTED && retry < 30) { delay(500); Serial.print("."); retry++; } if (WiFi.status() == WL_CONNECTED) { Serial.println("\n连接成功,IP地址: " + WiFi.localIP().toString()); } else { Serial.println("\nWiFi连接失败,请检查配置"); } }HTTPS请求封装:
这里是最关键的部分。ESP32-S3发送HTTPS请求需要TLS握手,用WiFiClientSecure的安全模式时,要么安装证书、要么用setInsecure()跳过证书验证。出于内存和开发效率考虑,第一版我用的是setInsecure(),但这里必须说明:生产环境绝对不能这么干,有中间人攻击风险。个人项目玩一玩没问题,做产品必须加载服务器证书。
String callDeepSeek(String userMessage) { WiFiClientSecure client; client.setInsecure(); // 个人项目跳过证书验证,生产环境请替换为正式证书 if (!client.connect(API_HOST, 443)) { return "连接API服务器失败"; } // 构造请求体 StaticJsonDocument<1024> requestBody; requestBody["model"] = "deepseek-chat"; requestBody["stream"] = false; JsonArray messages = requestBody.createNestedArray("messages"); JsonObject systemMsg = messages.createNestedObject(); systemMsg["role"] = "system"; systemMsg["content"] = "你是一个嵌入式AI助手,回答要简洁,控制在100字以内。"; JsonObject userMsg = messages.createNestedObject(); userMsg["role"] = "user"; userMsg["content"] = userMessage; String payload; serializeJson(requestBody, payload); // 构造HTTP请求 client.println("POST " + String(API_PATH) + " HTTP/1.1"); client.println("Host: " + String(API_HOST)); client.println("Content-Type: application/json"); client.println("Authorization: Bearer " + String(API_KEY)); client.println("Content-Length: " + String(payload.length())); client.println("Connection: close"); client.println(); client.println(payload); // 等待响应,设置超时 unsigned long timeout = millis() + 30000; while (!client.available() && millis() < timeout) { delay(10); } if (millis() >= timeout) { return "请求超时"; } // 读取响应 String response; while (client.available()) { response += client.readString(); } client.stop(); return extractContent(response); }响应解析:
HTTP响应里包含了状态行、响应头和JSON正文。我们需要的是JSON里的choices[0].message.content字段。用ArduinoJson来解析,注意先要跳过HTTP头:
String extractContent(String httpResponse) { // 找到空行,跳过HTTP响应头 int headerEnd = httpResponse.indexOf("\r\n\r\n"); if (headerEnd == -1) headerEnd = httpResponse.indexOf("\n\n"); String jsonBody = httpResponse.substring(headerEnd + 4); // 解析JSON JsonDocument doc; DeserializationError error = deserializeJson(doc, jsonBody); if (error) { Serial.print("JSON解析失败: "); Serial.println(error.c_str()); return "JSON解析失败: " + String(error.c_str()); } const char* content = doc["choices"][0]["message"]["content"]; if (content == nullptr) { return "未找到回答内容"; } return String(content); }主循环与按键交互:
void setup() { Serial.begin(115200); pinMode(BUTTON_ASK, INPUT_PULLUP); pinMode(BUTTON_NEXT, INPUT_PULLUP); u8g2.begin(); displayText("正在初始化..."); connectWiFi(); displayText("WiFi已连接,按左键提问"); } void loop() { if (digitalRead(BUTTON_ASK) == LOW) { delay(50); // 简单消抖 if (digitalRead(BUTTON_ASK) == LOW) { askQuestion(); while (digitalRead(BUTTON_ASK) == LOW) delay(10); // 等待释放 } } } void askQuestion() { displayText("正在思考..."); Serial.println("问题: " + lastQuestion); String answer = callDeepSeek(lastQuestion); lastAnswer = answer; Serial.println("回答: " + answer); displayAnswer(lastQuestion, answer); }3.3 代码里容易踩的坑
这段代码我调试的时候被三个问题卡过很久,值得单独拿出来说。
第一个坑:StaticJsonDocument的容量。
7.x版本用JsonDocument统一了动态和静态的实现,但如果你用的是6.x,StaticJsonDocument<1024>这里的1024字节约等于1024字节。问题描述+system提示词整体可能超过这个大小,结果就是serializeJson返回false,请求体为空。我的经验是:请求体缓冲区至少给2048,因为URL编码后的中文字符会膨胀。
第二个坑:client.available()的判断。
DeepSeek的响应速度受模型负载影响,快的时候1秒,慢的时候可能20秒。用client.available()判断数据到达,在等待期间不能阻塞太久。我上面用了30秒超时,实测足够。但注意,如果你用client.readString()去读全部响应,它会把响应头也读进去,所以解析前必须先跳过头,否则deserializeJson会报错。
第三个坑:Wi-Fi重连逻辑。
ESP32S3在长时间运行后Wi-Fi可能断开,这时候直接发API请求会失败。我后来加了自动重连:每次请求前检查WiFi.status(),如果不等于WL_CONNECTED就重新执行connectWiFi()。这个习惯从那时起保留到了我所有ESP32项目里。
3.4 语音交互扩展思路
如果想进阶做语音对话(这也是我下一步的方向),流程会变成:
按键 → INMP441录音 → 语音识别(云端ASR)→ 得到文字 → 调用DeepSeek → 得到回答 → TTS语音合成 → MAX98357A播放注意语音识别和TTS也都在云端做,ESP32S3只负责采集和播放。这个链路里最大的风险是延迟叠加,实测完整一轮下来可能超过10秒,体验很糟糕。优化思路是引入本地唤醒词(比如用ESP-SR库的WakeNet),本地识别到"小助手"再启动云端链路。这套方案是ESP32S3 + AI应用里最经典的落地组合,等我把音频链路调通再单独写一篇。
4. 常见问题与排查技巧实录
4.1 问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 烧录失败/串口无输出 | 没有按住BOOT键,或驱动没装 | 按住BOOT键再点烧录;安装CP210x/CH340驱动 |
| WiFi连不上 | SSID中文名、5G频段不支持 | 改用2.4G频段,SSID临时改为英文+数字 |
| 返回"连接API服务器失败" | 网络问题/TLS握手失败 | 检查网络,确认client.connect返回值为true |
| 返回"JSON解析失败" | 响应头没跳过或响应被截断 | 检查headerEnd定位逻辑,增大超时时间 |
| 屏幕显示乱码 | OLED驱动初始化参数不对 | 确认I2C地址(0x3C还是0x3D),用I2C扫描确认 |
| 提问后长时间无响应 | API Key无效或额度用完 | 检查串口打印的HTTP状态码,401就是Key问题 |
| 回答被截断显示不全 | PSRAM未启用或显示缓冲不足 | 开发板配置里确认PSRAM为"OPI PSRAM",U8g2使用全缓冲模式 |
4.2 排查思路分享
遇到问题不要瞎试,用串口打印的状态码定位问题最靠谱。我加了一行调试代码:client.println("User-Agent: ESP32S3-Client/1.0");虽然不解决实际问题,但有些API服务会校验User-Agent,提前加上能减少一个变量。
再说说HTTP状态码的定位思路。收到401说明API Key不对,收到429说明触发了限流,收到400基本是JSON格式有误。串口监视器里务必打印出完整的HTTP响应头和状态行,格式问题一看便知。
注意:如果响应体特别长(超过1-2KB),ESP32S3的串口缓冲和
String对象拼接会非常吃内存。建议在代码里做保护:如果响应超过4000字符,用reserve()预分配String容量,避免频繁realloc导致堆碎片。
4.3 内存优化实战
这块算是我反复调整最多的部分。ESP32S3虽然有512KB SRAM + 8MB PSRAM,但Arduino环境默认把PSRAM当扩展RAM用,不会自动把大对象放进去。实测跑一轮完整请求的内存峰值:
- WiFiClientSecure + TLS握手的RAM开销:约70KB
- JSON请求体 + 响应体 + String拼接:约15-30KB
- U8g2全缓冲模式:8KB
整体峰值在100KB上下,SRAM余量还算够。但如果你的代码里还有其他任务(传感器读取、MQTT等),就需要注意用ps_malloc()分配PSRAM内存来存大String。关于PSRAM的分配有个简单办法:String没提供底层分配接口,我会用char* buf = (char*)ps_malloc(4096);配合snprintf和sprintf来组装数据,把SRAM留给系统调用。
4.4 API调用策略建议
调用DeepSeek API时,我建议在请求头里加上"Accept-Encoding: identity",显式禁用gzip压缩。虽然服务器支持的gzip能让响应体小一些,但ESP32S3的HTTP客户端对gzip解码支持不完善,解压失败反而得不偿失。
另外就是提问频率控制。DeepSeek免费额度有限,如果loop循环里忘记加while(digitalRead(BUTTON_ASK)==LOW)等待释放,按键抖动可能导致一次按下触发多次请求,白白消耗额度。消抖逻辑不能省。
5. 项目扩展与后续优化
5.1 让回答更"好用"的提示词工程
这个项目的灵魂其实在system提示词。我试了几组不同配置,效果差异很大:
| 提示词 | 回答风格 | 体验评价 |
|---|---|---|
| (空) | 详细的教科书式回答 | 太长,屏幕显示体验差 |
| "回答简洁" | 中等长度,偶尔啰嗦 | 可用,但还不够稳定 |
| "你是桌面助手,回答不超过50字,带序号" | 精炼务实 | 最优,适合屏幕展示 |
建议在system里把角色、回答长度、格式、语言这四个维度都约束清楚。实测一句"回答控制在100字以内,使用中文,分点列出"之后,回答质量稳定很多,也省token。
5.2 显示优化
OLED屏幕只有128x64,直接显示长文本需要滚动。我实现了一个简单的分页显示逻辑:把回答按每屏四行切块,按BUTTON_NEXT翻页。关键技巧是按UTF-8字符串做切分时要看字符边界,不能截在半个中文字符中间。判断方法是:字符的UTF-8编码首字节如果大于0x7F,说明是中文多字节字符,需要完整复制3个字节再切。
5.3 更远的可能性
这个项目本质上打通了"MCU + 云端大模型API"的任督二脉,玩法可以很多:接上DHT22温湿度传感器,让模型帮你分析数据含义;接入本地MQTT,做成家庭智能中枢;把ESP32S3放到机器人底盘上,模型变成机器人的"大脑"来规划路径。另外,很多朋友也提到了SDIO驱动TF卡,我可以做个离线提示词库和日志系统,把每次问答都存到TF卡里,跑几天后就能做数据分析。
最后再分享一个我个人很受用的调试习惯:每次改动代码之前,先给当前版本拍个照(Git提交),出问题随时回退。嵌入式开发里改一处其他功能挂掉的情况太常见了,特别是Arduino这种以宏定义为主的环境,变量名冲突、库版本不对都能让你莫名其妙调一晚上。项目虽小,版本管理习惯一定要跟上。
本文还有配套的精品资源,点击获取