MimiClaw 项目全景解析
说明这份介绍以当前仓库里的实际代码为准重点参考main/下的实现。仓库中的部分文档和 TODO 记录已经略早于当前代码状态所以这里会优先按源码来讲。1. 先用一句话说清楚这个项目是什么MimiClaw 是一个运行在ESP32-S3上的嵌入式 AI Agent 固件。它不是 Linux 设备也不是树莓派那类“小电脑”而是一个真正跑在ESP-IDF FreeRTOS 纯 C上的微控制器项目。它做的事情可以概括成一句话把一块带 Wi-Fi、屏幕、灯带、音频和按键的 ESP32-S3 开发板变成一个能聊天、能调用工具、能保存记忆、能做定时任务、还能和真实硬件交互的 AI 助手。这个项目的特别之处不在“接了一个大模型 API”而在于它把下面几件事完整串起来了多消息入口Telegram、飞书、本地 WebSocket、本机语音。统一消息总线所有入口都进入同一套 Agent 处理链路。工具调用模型不仅回答还能搜索、读写文件、控制 GPIO、控制灯带、添加定时任务。本地持久化配置、会话、记忆、技能、心跳任务都落在 Flash 中。板级交互LCD 显示状态、WS2812 灯带反馈工作状态、语音键触发录音与播报。所以它更像一个“小型嵌入式 AI 操作系统”而不只是一个“ESP32 调 API 的 Demo”。2. 这个项目的整体架构可以怎么理解如果把整个项目看成一个系统它其实可以拆成 5 层设备与驱动层负责 Wi-Fi、SPIFFS、NVS、LCD、灯带、I2S 音频、I2C IO 扩展等底层能力。接入层负责把 Telegram、飞书、WebSocket、本地语音这些输入统一转成内部消息。Agent 执行层真正的“大脑”。它负责组装上下文、调用大模型、执行工具、循环思考最后得到回复。能力层也就是工具系统包括搜索、文件读写、GPIO 控制、灯带控制、定时任务等。存储与运维层包括配置、会话历史、长期记忆、技能文件、配网门户、串口 CLI、OTA、心跳检查等。从结构上说它非常接近一个缩小版的 Agent Runtime。3. 技术栈与运行基础从源码可以看到这个项目的技术栈很明确框架ESP-IDF5.5.0,5.6.0并发模型FreeRTOS Task Queue Timer网络能力esp_http_client、esp_http_server、esp_websocket_client数据格式cJSON存储NVS SPIFFS安全通信esp-tls 证书包升级esp_https_ota硬件外设I2S、I2C、SPI、RMT、GPIOmain/idf_component.yml里唯一显式声明的额外组件是dependencies: idf: version: 5.5.0,5.6.0 espressif/esp_websocket_client: ^1.4.0这也说明项目主体尽量建立在 ESP-IDF 原生能力上没有引入很重的第三方运行时。从 README 和配置看项目默认面向一块资源比较充足的板子ESP32-S316 MB Flash8 MB PSRAMLCDWS2812 灯带音频编解码器与按键也正因为有 PSRAM这个项目才有空间去容纳大一点的 JSON、上下文、HTTP 响应缓冲区。4. 程序启动时到底做了什么项目的总入口在main/mimi.c的app_main()。它的设计非常像“系统启动编排器”不是直接执行业务而是按阶段拉起所有子系统。核心片段如下ESP_ERROR_CHECK(message_bus_init()); ESP_ERROR_CHECK(memory_store_init()); ESP_ERROR_CHECK(skill_loader_init()); ESP_ERROR_CHECK(session_mgr_init()); ESP_ERROR_CHECK(wifi_manager_init()); ESP_ERROR_CHECK(http_proxy_init()); ESP_ERROR_CHECK(telegram_bot_init()); ESP_ERROR_CHECK(feishu_bot_init()); ESP_ERROR_CHECK(llm_proxy_init()); ESP_ERROR_CHECK(tool_registry_init()); ESP_ERROR_CHECK(cron_service_init()); ESP_ERROR_CHECK(heartbeat_init()); ESP_ERROR_CHECK(agent_loop_init()); ESP_ERROR_CHECK(serial_cli_init());上面这一段很重要它说明了整个系统启动顺序的思想先把基础设施准备好。 例如消息总线、存储、技能、会话、网络配置、代理配置。再初始化“接入端”和“大脑”。 例如 Telegram、飞书、LLM、工具系统、Agent 主循环。串口 CLI 提前启动。 即使 Wi-Fi 不通也能通过串口进行修复或重新配置。随后程序会尝试连接 Wi-Fi。如果失败不是简单报错退出而是进入配网流程if (!wifi_ok) { wifi_onboard_start(WIFI_ONBOARD_MODE_CAPTIVE); return; }Wi-Fi 成功后系统才会继续启动真正对外提供服务的部分出站消息分发任务Agent 主任务WebSocket 服务Telegram 通道飞书通道Cron 调度器Heartbeat 定时检查本地管理门户本地语音服务这套启动顺序说明作者非常清楚“嵌入式系统不是只要主逻辑能跑就行”还要考虑没网怎么办配置错了怎么办服务失败后能不能降级继续运行用户怎么修复现场这也是这个项目比较成熟的地方。5. 一条消息在系统里是怎么流动的5.1 统一消息结构项目内部所有消息都被统一成一个很简单的结构体typedef struct { char channel[16]; char chat_id[96]; char *content; } mimi_msg_t;这个设计很朴素但非常有效。它把不同来源的消息都抽象成三件事从哪里来channel属于哪个会话chat_id具体文本是什么content这意味着 Telegram、飞书、WebSocket、语音其实都只是“消息生产者”后面的 Agent 并不需要关心它到底来自哪一种入口。5.2 消息总线消息总线在main/bus/message_bus.c中实现本质上就是两个 FreeRTOS 队列s_inbound_queue xQueueCreate(MIMI_BUS_QUEUE_LEN, sizeof(mimi_msg_t)); s_outbound_queue xQueueCreate(MIMI_BUS_QUEUE_LEN, sizeof(mimi_msg_t));这两个队列分别承担inbound入口消息 - AgentoutboundAgent 回复 - 具体通道这样做的好处是接入层和处理层解耦。某个通道短暂卡住时不会直接拖死整个系统。以后加新通道时不需要重写 Agent 核心逻辑。5.3 Agent 主循环真正的核心在main/agent/agent_loop.c。一条消息进入后主要经过下面几步context_build_system_prompt(system_prompt, MIMI_CONTEXT_BUF_SIZE); append_turn_context_prompt(system_prompt, MIMI_CONTEXT_BUF_SIZE, msg); session_get_history_json(msg.chat_id, history_json, MIMI_LLM_STREAM_BUF_SIZE, MIMI_AGENT_MAX_HISTORY); cJSON *messages cJSON_Parse(history_json); if (!messages) messages cJSON_CreateArray(); cJSON *user_msg cJSON_CreateObject(); cJSON_AddStringToObject(user_msg, role, user); cJSON_AddStringToObject(user_msg, content, msg.content); cJSON_AddItemToArray(messages, user_msg);可以把它理解为先拼系统提示词。再把当前通道信息补进去。从 SPIFFS 里取出该会话的历史。把当前这条用户消息压到消息数组尾部。然后进入 ReAct 循环。ReAct 循环的关键部分是err llm_chat_tools(system_prompt, messages, tools_json, resp); if (!resp.tool_use) { final_text strdup(resp.text); break; } cJSON *asst_msg cJSON_CreateObject(); cJSON_AddStringToObject(asst_msg, role, assistant); cJSON_AddItemToObject(asst_msg, content, build_assistant_content(resp)); cJSON_AddItemToArray(messages, asst_msg); cJSON *tool_results build_tool_results(resp, msg, tool_output, TOOL_OUTPUT_SIZE);它表达的是一种非常标准的 Agent 思路如果模型直接给出最终回答就结束。如果模型要求调用工具就执行工具。再把“模型刚才说了什么”和“工具返回了什么”都塞回上下文。再调用一次模型。最多循环MIMI_AGENT_MAX_TOOL_ITER次。这已经不是“单次问答”而是一个真正的多轮工具代理循环。6. 项目最核心的几个模块6.1mimi_config.h全局配置中枢这个文件相当于项目的全局开关和资源预算表里面集中定义了Wi-Fi 重连策略各任务的栈大小、优先级、绑定核心LLM 默认模型和 providerSPIFFS 路径Cron/Heartbeat 配置GPIO、WS2812、音频参数NVS 命名空间和键名这类文件在嵌入式项目里很关键因为它把“系统行为”和“代码逻辑”分开了。以后要调内存、改任务优先级、换模型、改灯带引脚不需要在业务代码里到处搜。6.2 接入层Telegram、飞书、WebSocket、语音TelegramTelegram 通道使用的是长轮询。核心行为很清晰snprintf(params, sizeof(params), getUpdates?offset% PRId64 timeout%d, s_update_offset, MIMI_TG_POLL_TIMEOUT_S); char *resp tg_api_call(params, NULL); process_updates(resp);它的特点有几个用offset做增量拉取。offset会持久化到 NVS重启后还能续上。做了重复消息去重缓存。回复超过 4096 字符时会自动分片发送。如果 Telegram Markdown 失败会自动回退为纯文本发送。这说明作者并不是只把 API 打通而是补了不少实际使用中的“脏活”。飞书飞书通道比 Telegram 更复杂它走的是官方 WebSocket 模式而且代码里手写了协议解析流程包括帧头、二进制消息、事件处理和 ping 保活。这个模块说明项目并不局限于“HTTP 接个 webhook”而是在努力做一个长期在线的多通道 Agent。WebSocket 网关WebSocket 服务用于本地或网页端接入设计非常直接客户端连接后分配一个chat_id收到 JSON 消息后丢进 inbound 队列回复时按chat_id发回对应客户端也就是说WebSocket 在这个系统中扮演的是“最轻量的自定义客户端入口”。本地语音本地语音是这个项目非常有意思的一部分。它不是云端 Telegram 语音而是板子本地按键触发的“按住说话”链路。关键逻辑在voice_service.cbool pressed xl9555_pin_read(KEY1_IO) 0; if (s_voice.state VOICE_STATE_IDLE pressed !last_pressed) { voice_start_recording(); } else if (s_voice.state VOICE_STATE_RECORDING !pressed last_pressed) { voice_handle_record_release(); }含义非常直白按下KEY1开始录音松开KEY1停止录音并提交识别接下来它会走这条链I2S 录音转成 WAV发给 DashScope 做 ASR把识别文本包装成voice通道消息扔进 AgentAgent 生成回复再做 TTS通过板载音频电路播出来语音服务只在provider dashscope且 API Key 有效时启用这一点在代码里写得很明确if (!provider || strcmp(provider, dashscope) ! 0 || !api_key || !api_key[0]) { voice_set_state(VOICE_STATE_DISABLED, 语音未启用, 需要DashScope配置, NULL, YELLOW); return ESP_ERR_NOT_SUPPORTED; }这意味着作者把“聊天模型”和“语音模型”做了现实主义绑定DashScope 负责本地语音链路其他 provider 则主要负责文本 Agent 能力。6.3 LLM 层不仅支持一个模型接口main/llm/llm_proxy.c是整个项目的模型适配层。它不是写死 Anthropic而是抽象出了多个 providerstatic bool provider_uses_openai_schema(void) { return provider_is_openai() || provider_is_openrouter() || provider_is_dashscope(); }也就是说现在项目实际上支持anthropicopenaiopenrouterdashscope更关键的是它不只是“换一个 URL”而是做了协议层转换如果是 Anthropic就按messages tools的 Anthropic 风格发。如果是 OpenAI/OpenRouter/DashScope就把消息和工具转换成 OpenAI 兼容格式。这段代码最能体现它的抽象能力if (provider_uses_openai_schema()) { cJSON *openai_msgs convert_messages_openai(system_prompt, messages); cJSON_AddItemToObject(body, messages, openai_msgs); if (tools_json) { cJSON *tools convert_tools_openai(tools_json); cJSON_AddItemToObject(body, tools, tools); cJSON_AddStringToObject(body, tool_choice, auto); } } else { cJSON_AddStringToObject(body, system, system_prompt); cJSON_AddItemToObject(body, messages, cJSON_Duplicate(messages, 1)); }这层抽象非常重要因为它让上层 Agent 不需要关心底层模型接口长什么样只需要统一调用llm_chat_tools(...)。另外LLM 层还支持代理访问配合http_proxy.c可以走HTTP CONNECTSOCKS5这对国内网络环境非常实用。6.4 Context Builder提示词不是写死一句话main/agent/context_builder.c负责构造系统提示词。它不是简单返回一个固定字符串而是把多种信息拼在一起内置角色说明工具使用规则硬件控制规则SOUL.mdUSER.mdMEMORY.md最近几天的 daily notes已安装 skills 的摘要相关代码如下off append_file(buf, size, off, MIMI_SOUL_FILE, Personality); off append_file(buf, size, off, MIMI_USER_FILE, User Info); if (memory_read_long_term(mem_buf, sizeof(mem_buf)) ESP_OK mem_buf[0]) { off snprintf(buf off, size - off, \n## Long-term Memory\n\n%s\n, mem_buf); } if (memory_read_recent(recent_buf, sizeof(recent_buf), 3) ESP_OK recent_buf[0]) { off snprintf(buf off, size - off, \n## Recent Notes\n\n%s\n, recent_buf); }这说明项目的上下文来源不是“全靠会话历史”而是做了三层记忆拼装会话历史短期上下文MEMORY.md长期记忆最近 daily notes中短期背景另外它还会在每一轮补充当前回合上下文\n## Current Turn Context\n - source_channel: %s\n - source_chat_id: %s\n - If using cron_add for Telegram in this turn, set channeltelegram and chat_id to source_chat_id.\n这非常有用因为模型能知道当前消息来自哪个通道也能在调用cron_add这类工具时把目标会话带对。6.5 工具系统这是项目“像 Agent”的关键工具注册表在main/tools/tool_registry.c。它做的事情是注册工具为每个工具生成 JSON Schema拼成一份工具清单给模型在运行时按名字派发执行注册逻辑是这样的mimi_tool_t ws { .name web_search, .description Search the web for current information via Tavily (preferred) or Brave when configured., .input_schema_json {\type\:\object\, \properties\:{\query\:{\type\:\string\,\description\:\The search query\}}, \required\:[\query\]}, .execute tool_web_search_execute, }; register_tool(ws);当前项目里实际已经注册了不少工具大致可分成四类1. 信息类web_searchget_current_time2. 文件类read_filewrite_fileedit_filelist_dir这些文件类工具都限制在/spiffs路径下意味着模型能管理本地持久化文件但不会随意越界到系统其他地方。3. 调度类cron_addcron_listcron_remove4. 硬件类gpio_writegpio_readgpio_read_allled_setled_breatheled_blinkled_sosstrip_colorstrip_breathestrip_rainbowstrip_chasestrip_autostrip_off这套硬件工具非常完整已经不只是“点一个 LED”而是把板载 LED 和 WS2812 灯带区分开了。为了避免模型误操作危险引脚项目还加了 GPIO 策略检查比如在 ESP32-S3 上阻止访问 USB Serial/JTAG 占用的GPIO19/20。6.6 一个很实用的优化常见灯带命令走快速通道这是agent_loop.c里非常值得一提的一点。项目并不是所有硬件命令都一定先请求大模型而是对一些高频、明确、低歧义的命令做了“直通优化”。例如handled_direct_command try_handle_direct_strip_off_command(msg, tool_output, ...); if (!handled_direct_command) { handled_direct_command try_handle_direct_strip_chase_command(msg, tool_output, ...); } if (!handled_direct_command) { handled_direct_command try_handle_direct_strip_rainbow_command(msg, tool_output, ...); }也就是说像“关闭灯带”“彩虹灯效”“跑马灯”“呼吸灯”这种明确指令系统会先尝试本地解析并直接调用对应工具而不是每次都让大模型理解一遍。这样做的好处很明显响应更快更省 token更稳定对网络抖动更不敏感这说明项目在往“真正可用的嵌入式产品”靠而不是一切都交给模型判断。6.7 存储系统这部分非常像“本地 Agent 文件系统”项目把持久化数据大体分成几类配置NVS运行时配置mimi_secrets.h编译时默认配置记忆与上下文文件/spiffs/config/SOUL.md/spiffs/config/USER.md/spiffs/memory/MEMORY.md/spiffs/memory/YYYY-MM-DD.md会话/spiffs/sessions/tg_chat_id.jsonl会话文件的写入方式是 JSONL一行一条记录{role:user,content:Hello,ts:1738764800} {role:assistant,content:Hi there!,ts:1738764802}加载时并不会把整个历史全塞进上下文而是做了一个固定长度的环形缓冲只取最近若干条。定时任务与心跳/spiffs/cron.json/spiffs/HEARTBEAT.md技能/spiffs/skills/*.md从spiffs_data/可以看到项目已经预置了一些文件config/SOUL.mdconfig/USER.mdmemory/MEMORY.mdskills/daily-briefing.mdskills/gpio-control.mdskills/weather.md也就是说设备第一次启动时并不是“空白 AI”而是会带着一组初始人格、用户模板和技能模板一起上电。6.8 配置系统编译期默认值 运行时覆盖这部分是当前项目比很多 ESP32 示例更成熟的地方。它不是只有mimi_secrets.h而是做成了双层配置编译期默认值来自mimi_secrets.h运行时覆盖值来自 NVS可通过串口 CLI 或配网页面修改比如llm_proxy_init()里就是这么写的if (MIMI_SECRET_MODEL_PROVIDER[0] ! \0) { safe_copy(s_provider, sizeof(s_provider), MIMI_SECRET_MODEL_PROVIDER); } if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, nvs) ESP_OK) { ... if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider_tmp, len) ESP_OK provider_tmp[0]) { safe_copy(s_provider, sizeof(s_provider), provider_tmp); } }这表示编译时可以给一个默认 provider用户后期可以在设备上直接切换重启后仍然生效配网模块wifi_onboard.c甚至还提供了本地管理门户可开 SoftAP可扫描周围 Wi-Fi可读取当前生效配置可写回 NVS保存后自动重启这使得项目具备了比较完整的“脱离重新编译即可维护设备”的能力。6.9 调度系统Cron 和 Heartbeat这两个模块让 MimiClaw 不再只是“等人来问”而是开始具备主动性。Croncron_service.c会周期性扫描任务到点后把消息重新注入 Agentmsg.content strdup(job-message); message_bus_push_inbound(msg);也就是说定时任务的本质不是执行一段硬编码逻辑而是“在未来某个时间点帮用户发起一轮新的 Agent 对话”。这个思路很漂亮因为它复用了已有的 Agent 处理链。HeartbeatHeartbeat 更像一个被动巡检器。它会定期查看HEARTBEAT.md中有没有未完成事项如果有就推送一条系统消息msg.channel system; msg.chat_id heartbeat; msg.content strdup(HEARTBEAT_PROMPT); message_bus_push_inbound(msg);这等于给系统加了一种“自我检查、自我提醒”的后台机制。7. 板级外设在项目里的作用这个项目里硬件不是配角而是交互的一部分。LCDLCD 主要负责显示系统状态例如开机Wi-Fi 连接中配网模式Agent 就绪本地语音录音中 / 识别中 / 播报中所以它承担的是“设备自解释界面”的角色。WS2812 灯带灯带不仅能被工具控制也被系统拿来做状态反馈开机动画网络就绪AI 思考中收到消息回复完成报错状态这说明灯带在项目里既是“可被 AI 控制的输出设备”又是“系统状态指示器”。音频板audio_board.c把 ES8388 编解码器、I2S、扬声器使能、录音/播放切换都封装了起来。语音服务层不需要直接处理底层寄存器只需要调用audio_board_start_record()audio_board_read_mono()audio_board_play_pcm_mono()这种分层做得很合理。8. 目录怎么读最合适如果你要系统看这个项目我建议按下面顺序读路径作用main/mimi.c全系统启动总入口main/mimi_config.h全局配置和资源预算main/bus/消息总线main/wifi/Wi-Fi 管理main/onboard/配网门户与本地管理页main/channels/telegram/Telegram 接入main/channels/feishu/飞书接入main/gateway/本地 WebSocket 网关main/agent/Agent 主循环与上下文构建main/llm/LLM provider 抽象层main/tools/工具注册与具体工具实现main/memory/长期记忆与会话存储main/cron/定时任务main/heartbeat/心跳检查main/voice/本地语音交互main/bsp/LCD、音频、I2C、SPI、灯带等板级驱动spiffs_data/首次烧录到 SPIFFS 的默认数据docs/架构说明和补充文档如果只是想快速掌握核心优先读这几个文件main/mimi.cmain/agent/agent_loop.cmain/llm/llm_proxy.cmain/tools/tool_registry.cmain/voice/voice_service.c9. 这个项目最值得肯定的地方从工程角度看我觉得它有几个非常明显的优点。1. 它不是“单点功能”而是完整系统很多 ESP32 AI 项目只做了连网调一次模型打印回复而 MimiClaw 已经有多通道输入统一消息总线Agent 工具循环本地文件与记忆定时任务心跳任务本地配网与配置本地语音灯带与屏幕反馈这已经是“产品原型级别”的整合度。2. 对嵌入式限制有清醒认识项目中有很多细节都体现了这一点大缓冲区优先放 PSRAM通道和 Agent 分任务运行常见灯带命令走快速通道队列隔离不同模块网络异常时允许降级这不是桌面程序思维而是明显考虑过 MCU 资源限制。3. 配置和维护体验做得不错双层配置、串口 CLI、配网门户、NVS 覆盖这些设计大幅降低了后期维护成本。10. 如果把它当成学习项目最应该学什么如果你是想“看懂整个项目”我建议重点学习这 4 个设计思想1. 用消息总线解耦输入通道和 Agent这是项目最核心的架构思想。2. 用统一工具注册表把“模型能力”和“硬件能力”连起来这让大模型从“会说话”变成“能做事”。3. 用 SPIFFS 文件组织人格、用户信息、长期记忆和技能这是一种非常适合轻量设备的 Agent 持久化方式。4. 用 FreeRTOS 任务把接入、处理、输出拆开这让系统可扩展也更稳定。11. 最后总结MimiClaw 的本质不是“ESP32 接大模型”而是在极小的硬件资源上做了一个具备输入通道、上下文管理、工具调用、记忆存储、主动调度和硬件反馈能力的嵌入式 AI Agent 系统。如果你从源码角度看它可以把它理解成三句话mimi.c负责把整个设备世界启动起来。agent_loop.c负责把用户意图变成模型思考和工具执行。tools/ memory/ channels/ voice/负责让这个 Agent 真正落地到现实设备上。如果后续你愿意我还可以继续基于这份文档帮你再生成一版更适合答辩/汇报的简版更适合源码阅读的模块导图版更适合新手入门的“阅读顺序版”