ESP32-S3+大模型:从零构建端云协同AI语音助手
2026/9/17 5:29:35 网站建设 项目流程

我手里这个项目,用一句话概括就是:用一块支持Wi-Fi的ESP32-S3开发板,配合麦克风阵列扩展板和喇叭,做成一个能“对话”的桌面语音助手,语音识别和AI对话都在电脑端或云端的大语言模型上完成,板子只负责采集声音、播报回答

这听起来像是一个很“硬核”的嵌入式项目,但说实话,它比你想象中简单得多,因为它巧妙避开了在单片机本地跑神经网络这个最大的坑,把重活都交给了Python和大模型API。我前后折腾了大概两周,踩了不少坑,也总结出了一套从零到能用的完整方案。这篇文章就沿着我当时的思路,把硬件选型、代码框架、大模型接入到最后的调试排错,一整个链路写清楚,如果你也想做一个类似的AI语音助手,可以直接照着走。

1. 项目整体设计与思路拆解

1.1 为什么选ESP32-S3,而不是树莓派或者普通ESP32

先聊选型。很多人拿到这个项目的第一反应是:既然还要接Python和大模型,那我直接用树莓派不就行了?树莓派当然能做,但体积、功耗、启动速度、价格都不是一个量级。桌面级语音助手要的是“随时唤醒、即开即用”,树莓派那套完整Linux系统启动就要十几秒,而且长时间待机发热和成本都是问题。ESP32-S3则是一颗专门为AIoT场景设计的双核MCU,主频240MHz,带向量指令扩展,性价比非常高,一两百块的开发板就能过得很好。

再对比老款ESP32。经典ESP32(双核240MHz)也不是不能用,但内存只有320KB左右的SRAM,想要做稍微像样点的音频缓冲、FFT处理或者本地唤醒词检测,余量就非常紧张。ESP32-S3的SRAM达到512KB,还带16MB的Flash,支持PSRAM外部扩展,处理PCM音频数据流更从容,而且它对I2S外设的支持更完善。最关键的是,ESP32-S3自带向量指令,跑一些轻量神经网络(比如关键词唤醒)性能翻倍。既然标题叫“从零构建”,那就选上限更高、资料更多、后期还能继续玩本地AI的S3。

1.2 系统架构:板子负责耳朵和嘴,电脑负责脑子

整个系统的分工,我是按下面这张“隐形架构图”来设计的:

  • ESP32-S3端:负责通过I2S接口从麦克风采集16kHz/16bit的PCM音频流;通过Wi-Fi把音频上传到电脑端软件;接收电脑端返回的文本;通过I2S/DAC输出到喇叭播放TTS语音结果;本地可以预留一个唤醒词检测(比如“你好小智”),避免全程录音上传带来的隐私和流量浪费。
  • 电脑端(Python):架设一个简单的WebSocket或HTTP服务,接收ESP32发来的音频数据;调用语音识别(ASR)引擎把音频转成文本;将文本交给大语言模型(LLM)API获取回答;再把回答文本交给语音合成(TTS)引擎,生成音频返回给ESP32播放。
  • 大语言模型API:这里的选型比较灵活,可以是云端的GLM、通义千问、DeepSeek、Kimi等任何兼容OpenAI接口格式的服务,也可以用本地部署的Qwen、ChatGLM等模型。项目里我用的云端API,因为免去显卡和部署的烦恼。

这个分工的最大好处是:ESP32-S3的固件量可以做得非常精简,稳定性高;Python端拥有整个生态,可以随意调库、换模型,不需要在单片机上抠内存。换句话说,这是一条典型的“端云协同”路线,和市面在售的智能音箱原理是一模一样的,只不过我们把云端大脑换成了自由度更高的开源模型。

1.3 为什么把ASR/TTS/LLM全部独立成模块

一开始我图省事,想把整个链路塞进一个大Python脚本里。后来发现每次换TTS引擎或者想调试某个环节,都要动整个链路,调试日志混乱不堪。于是我花了一点时间,把程序拆成了六个模块:主服务(Main Server)、音频接收(Audio Receiver)、语音识别(ASR Engine)、大模型对话(LLM Engine)、语音合成(TTS Engine)、设备管理(Device Manager)。

每个模块之间通过简单的消息队列或回调函数通信,互不依赖具体实现。比如今天我用的是讯飞ASR,明天想换成Whisper,只需要把ASR Engine内部代码替换一下,对外接口不变即可。这种“插件化”的思路不仅让整个项目好维护,也方便社区协作——别人想贡献一个更好的TTS实现,直接写个新类替换就行。

2. 硬件准备与开发环境搭建

2.1 开发板、麦克风、喇叭怎么选

我先列一下我的配置,给大家参考:

硬件型号/规格说明与避坑建议
主控开发板ESP32-S3-DevKitC-1(8MB Flash,8MB PSRAM)建议带PSRAM后续可玩图像/更多内存场景
麦克风INMP441(I2S MEMS麦克风)便宜好用,I2S数字输出无需运放
功放+喇叭MAX98357A I2S功放 + 3W/8Ω小喇叭MAX98357A同样走I2S,接线超简单
电源5V/2A USB供电喇叭音量开大后电流波动,劣质充电头会导致重启
其他杜邦线若干、面包板实验阶段别急着焊接,改线会崩溃

这里有个非常关键的点:麦克风和功放都使用I2S接口,但I2S在同一时刻是单向通信的——麦克风占用接收通道(RX),功放占用发送通道(TX),在ESP32-S3上两者可以共用一个I2S外设的不同引脚,但需要开启全双工模式。很多人第一次做的时候发现只有录音或只有播放,多半就是把这层关系搞混了。

2.2 麦克风与喇叭的接线速查表

INMP441和MAX98357A都是标准I2S从设备,所以接线思路一致:各自的SCK(位时钟)、WS(左右声道选择)、SD/SDOUT(数据)接到ESP32-S3的对应引脚即可。

我用了如下引脚分配(通过Arduino IDEESP-IDF代码都可修改):

ESP32-S3引脚连接设备/功能说明
GPIO 4麦克风SCK / 功放SCK共用I2S位时钟(BCLK)
GPIO 5麦克风WS / 功放WS共用左右声道时钟(LRCK)
GPIO 6麦克风SD(数据输出)麦克风数据进ESP32
GPIO 7功放DIN(数据输入)ESP32数据出到功放
GPIO 15功放SD_MODE拉高使能功放,也可接PWM控制音量
3.3V/GND各设备电源注意INMP441的VDD和GND不能反接

我实际使用中,I2S的BCLK频率设为2.4MHz,采样率16000Hz,16bit单声道,效果很稳定。注意INMP441有个L/R引脚,决定它输出到左还是右声道,如果接GND则输出左声道,接VDD则输出右声道。因为我只用一个麦克风,所以把L/R直接接GND,然后代码里读取的是I2S的left通道数据。

2.3 烧录ESP32-S3固件:Arduino还是ESP-IDF

给ESP32-S3写固件,主流有两条路:Arduino框架或ESP-IDF框架。我的建议是:如果你只做这个语音助手项目,直接用Arduino;如果你想长期做复杂音频产品,学ESP-IDF。标题说“从零构建”,所以我的重点放在快速落地,Arduino生态里库多、上手快,很多I2S音频例子改改就能用。

环境配置方面,在Arduino IDE里添加ESP32开发板支持包链接,然后安装esp32 by Espressif Systems开发板包。值得注意的是,Arduino IDE开发板管理器现在能直接搜到esp32,不要下载来路不明的第三方包,否则后面调试GPIO和Wi-Fi问题会让你崩溃。

装好之后,选板型ESP32S3 Dev Module,Flash大小和PSRAM都要按你板子的真实配置来,比如我的是16MB Flash / 8MB PSRAM。烧录前按住板上的BOOT键,多数开发板这样才能进入下载模式。串口波特率选921600,烧录速度会快很多。

2.4 电脑端Python环境准备

电脑端我用的Python 3.10(3.11/3.12也完全兼容,但某些音频库对3.12的wheel支持还不全,稳妥起见用3.10)。强烈建议用condavenv建独立虚拟环境,避免污染系统Python。

项目依赖的库其实不多,核心是:

pip install websockets pyaudio requests openai

如果你要在本地用FunASR或Vosk做离线ASR,再额外装:

pip install vosk funasr modelscope

PyAudio在Windows上偶尔会因为没有预编译的wheel安装失败,简单粗暴的解决方案是:下载PyAudio‑0.2.14‑cp310‑cp310‑win_amd64.whl这一类的二进制文件,然后本地pip install。macOS上则更流畅,直接brew install portaudiopip install pyaudio

3. 核心代码实现:从录音到对话到播报

3.1 ESP32-S3侧:I2S录音与播放

先上ESP32端最简录音示例(Arduino框架)。这段代码初始化了I2S全双工模式,一个输入麦克风、一个输出功放:

#include <driver/i2s.h> #define I2S_BCLK 4 #define I2S_WS 5 #define I2S_SD 6 #define I2S_DIN 7 // 功放数据输入 #define SAMPLE_RATE 16000 #define BUFFER_SIZE 512 void setup() { i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_TX), .sample_rate = SAMPLE_RATE, .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = BUFFER_SIZE, .use_apll = false, .tx_desc_auto_clear = true, .fixed_mclk = 0 }; i2s_pin_config_t pin_config = { .bck_io_num = I2S_BCLK, .ws_io_num = I2S_WS, .data_out_num = I2S_DIN, .data_in_num = I2S_SD }; i2s_driver_install(ESP32_I2S_NUM0, &i2s_config, 0, NULL); i2s_set_pin(ESP32_I2S_NUM0, &pin_config); }

录到数据后,你需要加上Wi-Fi传输逻辑。我的做法:ESP32连上路由器,通过WebSocket连接到电脑端服务,每采集到一段2048字节数据就立刻发送。音频数据是16bit PCM,按小端序打包后直接传二进制帧。这一步很考验网络稳定性,我会在调试篇详细讲。

播放端则反过来,电脑端把TTS生成好的PCM流拆包推过来,ESP32收到后调用i2s_write写到功放。这里有个经验:播放前先清空DMA缓冲,不然会夹杂开机或上一次播放的残留数据

3.2 Python服务端:WebSocket接收音频流

Python端我用websockets库起一个异步服务,监听8765端口。每个ESP32设备连上来后分配一个会话ID,后续所有音频帧和文本都挂在这个会话下管理。

import asyncio import websockets SESSIONS = {} async def handle_client(websocket): device_id = await websocket.recv() SESSIONS[device_id] = {"ws": websocket, "audio_buffer": b""} print(f"[设备上线] {device_id}") try: async for message in websocket: process_audio_chunk(device_id, message) except websockets.exceptions.ConnectionClosed: print(f"[设备离线] {device_id}") SESSIONS.pop(device_id, None) async def main(): async with websockets.serve(handle_client, "0.0.0.0", 8765): await asyncio.Future() asyncio.run(main())

process_audio_chunk里做的事情很简单:收到的音频帧先缓存,缓存长度超过一定阈值后,把整块交给ASR引擎识别。为什么要攒够一帧再识别?因为Vosk这类流式ASR是按固定帧长处理的,你一次扔一个不完整的块,识别错误率会明显升高。我的阈值设为3200字节(约100ms的16kHz音频)。

3.3 语音识别(ASR):云端API还是本地Vosk

ASR是整个链路里最容易出效果的环节,也是翻车最狠的环节。我建议根据你的网络和机器情况二选一:

  • 在线ASR方案(比如讯飞、阿里云、腾讯云),识别率高、支持多语言,但私密性和免费额度都有限。适合先跑通全流程。
  • 本地Vosk方案,完全不依赖外网,模型也不大(几十MB),中文识别效果在安静环境下还不错。适合离线场景。

我最终的方案是本地Vosk为主 + 云端ASR备用。Vosk的接入代码非常简单,加载模型后用KaldiRecognizer边接收音频边输出结果:

from vosk import Model, KaldiRecognizer import json model = Model("vosk-model-small-cn-0.22") rec = KaldiRecognizer(model, 16000) def asr_recognize(pcm_bytes): if rec.AcceptWaveform(pcm_bytes): result_json = rec.Result() text = json.loads(result_json).get("text", "") return text return ""

注意它的AcceptWaveform不是一次把所有音频塞进去就出结果,而是持续投喂PCM数据,等到说话停顿或结束才返回整句。我在系统里做了一个音量检测,当检测到用户沉默超过800ms时,就调用rec.FinalResult()强制取出最终结果,避免对话等待时间太长。

3.4 大语言模型接入:兼容OpenAI接口的通用方案

这一部分是整个项目的灵魂。考虑到目前可选的模型API很多,而且经常有免费或低价额度,我推荐的做法是:使用OpenAI SDK,但把base_url换成你想要调用的大模型服务的地址。这样可以随时切换后端模型,不用改业务代码。

例如调用一个兼容OpenAI协议的在线免费/低价模型:

from openai import OpenAI client = OpenAI( base_url="https://你的模型服务地址/v1", api_key="你的API_KEY或占位符" ) def ask_llm(system_prompt, user_message): response = client.chat.completions.create( model="deepseek-chat", # 或 qwen-turbo / glm-4-plus / 本地模型名 messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_message} ], temperature=0.7, max_tokens=1024 ) return response.choices[0].message.content

说到“还有没有免费的大模型API”,我的实践体会是:真正的长期免费商用几乎没有,但各家新用户/开发者试用额度很充足。比如有些平台会送几百万tokens,对小语料个人助手来说够玩一两个月。与其纠结找一个永久的免费源,不如写一个可配置的适配层,把API的keybase_url放在一个config.ini里,随时切换。尤其别因为某个渠道免费就去薅,毕竟这种渠道稳定性没保障,今天能用明天挂掉的概率很大。

另外,如果你有一定预算或手头有显卡,本地部署大模型也是非常推荐的进阶路线。用ollama跑一个7B-14B的量化模型,整体体验完全碾压在线小模型,而且延迟更低、隐私更好、也没有内容审核限制。本地部署的命令非常简单:

ollama pull qwen2.5:7b ollama serve

然后Python端直接把base_url改成http://localhost:11434/v1即可。

3.5 语音合成(TTS):让助手开口说话

TTS方案的选择直接影响助手的“人味”。我测试过几种:

  • Edge TTS:微软出品,免费,有多种中文音色,音质自然度很高。它对标的是在线TTS服务。缺点是需要联网,且接口偶尔会抖动。
  • pyttsx3:本地计算,完全离线但音色机械,适合快速验证流水线。
  • PaddleSpeech / GPT-SoVITS:本地高质量TTS,但部署相对繁琐,适合进阶。

日常使用我强烈推荐edge-tts,一行命令就能生成MP3,而且支持调节语速、音量、音调:

import edge_tts async def text_to_speech(text, output_path): tts = edge_tts.Communicate(text, voice="zh-CN-XiaoxiaoNeural", rate="+10%") await tts.save(output_path)

生成MP3之后,怎么给ESP32播放是个小坑。ESP32的I2S功放不支持MP3硬解,所以你必须先把MP3转成PCM单声道16bit WAV,再拆成小块推送过去。这个转换用Python的pydubffmpeg一行命令搞定。我自己是写了一个工具函数:

from pydub import AudioSegment def mp3_to_pcm16_mono(mp3_path): audio = AudioSegment.from_mp3(mp3_path) audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) return audio.raw_data

拿到PCM裸数据之后,按照上面说的WebSocket协议分包发送给ESP32就行。播放完成的同步很重要,我是在ESP32收到最后一个数据包后主动回发一个<END_PLAY>标记,Python端收到标记才认为语音播放完毕,再进入下一轮唤醒监听。

3.6 全流程串联:状态机让系统有序运转

把ASR、LLM、TTS串起来后,最容易遇到的问题就是“数据流打架”。比如用户上一句话还没回复完,下一句又开始识别了。我听过大佬的建议是:用状态机管理整个流程

我定义了几个简单状态:

状态含义进入条件退出条件
IDLE空闲,等待语音输入系统启动收到唤醒词/按键触发
LISTENING录音中唤醒/按键触发检测到静音超时,或音频超长
PROCESSINGASR+LLM处理中音频采集结束得到回答文本
SPEAKINGTTS播放中文本生成完成播放结束,回到IDLE

每个状态内只响应自己状态允许的事件。比如在PROCESSING阶段收到新的音频帧,直接丢弃;在SPEAKING阶段则只允许按键打断,不支持语音打断(做语音打断要另加VAD线程,比较复杂,初版不做)。

4. 调试心得与常见问题排查

4.1 串口输出乱码或烧录失败

ESP32-S3的串口乱码,首先怀疑波特率不匹配。Arduino IDE默认串口监视器是115200,但如果你的固件里Serial.begin(9600),那当然满屏乱码。还有个经典问题是开发板接线时USB转串口芯片(常见的有CP2102和CH343)驱动没装好,在设备管理器里能看到未知设备,或者能识别但打开串口报错。Windows系统装好CP210x驱动后基本能解决。

烧录失败更常见的是“芯片处于保护状态”或“连接超时”。我的经验是:按住开发板上的BOOT键不放,然后点烧录,看到“Connecting...”提示后立即松开BOOT键,成功率会高很多。另外,杜邦线过长也会导致下载不稳定,USB线尽量用质量好的数据线而不是充电线。

4.2 I2S只有录音没有播放,或全是电流声

I2S的播放问题我排查了很久。只有数据没有声音,先量一下功放MAX98357A的SD_MODE引脚电压,如果为低,芯片就在关断状态。这个引脚不能悬空,必须接高电平或PWM。电流声大则有三个常见来源:

  1. 电源噪声:喇叭和开发板共用USB供电,当音量开大时电源纹波会直接耦合进音频。改用独立5V电源,或在功放电源脚加100uF电解电容+0.1uF瓷片电容。
  2. WS和BCLK接反:I2S三根线里任何两根互换,出来的都是刺耳噪音或完全无声。
  3. GND回路电压差:麦克风、开发板、功放各自的GND都用杜邦线连接时,如果线阻不一致会产生地环路噪声。尽量采用星型接地模式,所有GND从开发板电源引脚单点引出。

4.3 大模型API超时或频繁断连

在线API时不时超时太正常了。ESP32里用WiFiClient直连TCP会经常超时,Python端用WebSocket反而好一点。我的建议是:Python端把LLM调用放进独立线程池,这样某个请求卡住时,不影响ASR和TTS主循环继续工作。如果彻底不想被在线API绑死,就尽早换成本地大模型。

另外,大模型返回格式偶尔会把回答包在Markdown代码块里,TTS会把反引号、星号这些符号读出来,很难听。我在Python端做了一个清理函数,把Markdown语法符号和多余换行全部替换成句号或自然停顿后再送给TTS:

import re def clean_markdown_for_tts(text): text = re.sub(r'[#*`>_~\[\]()]', '', text) text = re.sub(r'\n+', ',', text) text = re.sub(r'\s+', ' ', text).strip() return text

4.4 唤醒词与误唤醒:比想象中麻烦

很多教程里唤醒词是简单粗暴的“一直录音,本地检测到关键词再上传”。但一直录音占带宽、耗电,而且麦克风把空调声、键盘声全采进去,误唤醒率很高。我采用的策略是:先用ESP32-S3本地跑一个轻量的关键词识别,只有识别到唤醒词才开启网络传输

ESP32-S3上可以跑TinyML的唤醒词模型,比如使用ESP-DL框架做关键词分类。不过这个对新手有点门槛。更简单的替代方案是:ESP32本地只做能量检测+过零率检测,当声音能量大于阈值并持续超过200ms才上传给电脑端做云端ASR。这样既减少了网络数据量,又能挡住大部分静音环境下的误触发。

如果你想实现“你好小智”这类真·唤醒词,可以直接用ESP-SR官方语音识别库,它自带中文唤醒词模型,跑在ESP32-S3上非常流畅。不过该库在Arduino框架下的集成文档较少,需要一些耐心。

4.5 延迟优化:从Wi-Fi到TTS播放的链路压缩

语音助手的体验核心就一个字:“快”。我实测全链路的时延分布大致是:音频采集+上传约200-400ms,ASR约100-300ms,LLM推理约500-1500ms(取决于模型和排队情况),TTS合成约300-800ms,播放约500ms。整体大概2-4秒。如果想让体验更好,可以考虑这几个优化点:

  • 语音识别改成流式:ESP32边录音边发,ASR边识别边产出中间结果,用户说完之前就能把前半句提交给LLM。
  • LLM流式输出:大模型边生成边把文本片段送到TTS,语音合成也流式生产,这样用户听到第一个字的延迟能大幅缩短。
  • TTS音频预缓存:针对一些高频固定回复(比如“我在呢”“请讲”),提前生成好语音缓存,播放时直接读内存,省掉一次合成时间。

这几点我在第二版迭代时逐步加了进去,整体响应时间缩短到1.5秒左右,已经接近市售智能音箱的体验。

5. 进阶玩法与后续扩展思路

5.1 从“回话机器”升级为“AI Agent”

目前做出来的语音助手本质上还是一个“问答机器”——用户问什么,它答什么。要让它的实用性上一个台阶,就要引入AI Agent的概念。最简单的方式是给Python端加一个工具调用(Function Calling)层,让大模型在回答前可以调用你预定义好的几个函数,比如:

  • 查询天气:大模型输出{"function": "weather", "args": {"city": "上海"}},Python端自动请求天气接口,把结果再送回模型生成最终回答。
  • 控制智能家居:同样让模型输出控制指令,Python端通过MQTT或HTTP控制家里的灯光、插座。
  • 设置倒计时:模型解析出时间参数后,Python端调用系统定时器,到点后触发播报。

这个扩展不复杂,核心是把大模型返回的内容解析成结构化的工具调用,再走一遍生成流程。一旦跑通,你的语音助手就不是只能回答问题,而是能做事情的助手了。

5.2 加入视觉能力与屏幕交互

考虑到ESP32-S3的PSRAM和算力,还可以接一个OV2640摄像头,做成“AI语音+视觉”的双模态助手。比如你对着它说“帮我看看桌上有什么”,ESP32拍一张照片传到Python端,交给视觉大模型分析,再把结果语音播报出来。

如果你不想接摄像头,也可以加一块小屏幕(如ST7789),把大模型的回答滚动显示在屏幕上,和语音播报互补——有些信息听不清时,看屏幕反而更直观。这部分代码不算复杂,但要额外处理屏幕的刷新逻辑,不要让屏幕绘制阻塞了音频主循环。

5.3 隐私保护的离线全栈方案

我开头说了“板子负责耳朵和嘴,电脑负责脑子”,如果想进一步做隐私保护,可以把这个架构改成完全离线。具体来说:ESP32-S3和电脑之间通过本地局域网或USB直连,ASR用Vosk本地模型,LLM用ollamallama.cpp本地推理,TTS用PaddleSpeechPiper。这样整个链路除了你自己,没有任何第三方服务器参与,不仅隐私绝对安全,响应速度也快,尤其适合在公司或敏感环境里使用。

代价是本地模型的质量和配置门槛比在线API高一些。我的建议是:先把在线方案跑通,体验到完整流程,再慢慢切换到离线方案。一口吃不成胖子。

写在最后:一点个人体会

这个项目带给我最大的收获,不是“语音助手”本身,而是让我真正理解了端云协同的工程化思路——硬件端做好采集和播放,云端做好计算和生成,两边用一条稳定的数据通道解耦。这个思路用在智能家居、可穿戴设备、工业语音交互上都是一脉相通的。ESP32-S3+Python+大模型这套组合,可能是目前玩AI硬件最舒服的组合之一,它把硬件的门槛降到了极低,把AI的能力发挥到了极致。

如果你按这篇文章试着做一次,我敢说你踩的大部分坑都能在最后一章找到答案。如果真的遇到我文章里没提到的问题,也欢迎在评论区留言讨论。做硬件就是这样,最快乐的不是代码跑通的那一刻,而是自己动手把一个个问题从“玄学”变成“科学”的过程。祝你们都能做出一个愿意天天陪自己说话的小助手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询