基于MCU的Agent(二)
基于 MCU 的 Agent(二)
上一篇主要讲的是 ESPAgent 如何把 LLM、工具调用和硬件驱动接起来。那时的重点还是单块 ESP32-S3:用户从飞书发来消息,固件把消息送进 Agent loop,LLM 判断是否需要调用工具,最后由工具层去控制 GPIO、RGB 灯、舵机或者读取传感器。
这几天项目变化比较大。ESPAgent 已经不是“单板上跑一个会调用硬件工具的聊天机器人”这么简单了。现在的主线变成了:四块 ESP32-S3 组成轻量 Agent Mesh,ESP32-P4+C6 和 Android 作为显示/调试终端,Coordinator 负责理解和调度,Sensor 负责感知,Control 负责执行,Guardian 负责权限、审计和数据治理。
更关键的是,链路里补上了几个之前缺失的概念:结构化 OutputMessage、异步结果回注、Guardian policy gate、Control command queue,以及每块板都能使用的 capability/Lua runtime。也就是说,Coordinator 不只是把命令发出去,然后告诉用户“我已经发送了”;它现在可以先请求 Guardian 裁决,等待远端节点的结构化执行结果,再把这个结果注入回 Agent loop,让 LLM 继续总结给用户。这才更接近一个真正的 ReAct 闭环。
这篇文章就按当前最新进展重新梳理:四个 ESP32-S3 现在到底怎么分工,ReAct 和 subagent 做到了什么程度,OutputMessage 有什么意义,Guardian 怎么做 policy gate,ESP32-P4/Android 为什么接管显示终端,Lua 和动态硬件扩展为什么不绕过安全边界,以及最近真实压力测试验证到了哪一步。
第一章:四块 ESP32-S3 不再是四个聊天机器人
一开始我想过把四块 ESP32 都做成类似“独立小助手”的形式。但实际推进下来,这个方向并不合理。MCU 不是服务器,不能无限开进程、堆上下文、塞工具和模型。四块板更适合按职责拆开,而不是复制四个聊天机器人。
当前实物映射是:
1 | /dev/ttyUSB0 esp32s3-coordinator-01 coordinator_agent |
这四块板仍然使用同一套 ESPAgent 固件。区别来自 build-time profile,也就是 espagent_secrets.h 里的 NODE_ID、NODE_ROLE、NODE_CAPABILITIES 和 NODE_RESPONSIBILITIES。这样做的好处是协议、工具、topic、调试命令和构建方式统一。缺点是 Flash 还没有按角色裁剪,四块板的 app 镜像基本一致。
角色边界大概是这样:
1 | Coordinator: |
当前系统可以拆成两层:
1 | 四块 ESP32-S3: |
这个分层比“四块 S3 里塞一块显示节点”更清楚。S3 做边缘控制,P4/Android 做复杂 UI。
第二章:主链路现在长什么样
现在 ESPAgent 的核心链路可以写成:
1 | Feishu / WebSocket / Cron / Proactive / Serial |
这条链路里有几个边界很重要。
第一,Feishu、WebSocket、Cron 和 Proactive 都只是消息入口。它们不直接碰硬件。真正决定是否调用工具的是 agent_loop。
第二,LLM 不能随便执行 C 函数。它只能看到 tool_registry 里注册过的工具 schema。比如 get_weather、mesh_send_command、read_temperature_humidity、ws2812_set、spawn_subagent 等。
第三,跨 ESP32 的硬件动作不应该从 MQTT callback 直接打到 GPIO。现在 Control 侧已经要求本地缓存里有 Guardian 的 allow decision,才会执行白名单控制命令。Control 还补了轻量 command queue、重复 command_id 拦截、TTL 检查、单执行器互锁、emergency stop latch 和 actuator state snapshot。也就是说,“先经过 Guardian,再进入 Control 本地安全边界”已经不是 prompt 约定,而是固件路径。
第四,远端执行结果必须结构化。否则 Coordinator 只能收到一段自然语言,很难稳定关联 command_id、trace_id、执行节点、动作、状态和错误。
第三章:关于本项目的 ReAct
之前项目里已经有 ReAct 形态:
1 | 用户消息 |
USB0 上也验证过主 agent_loop 能完成一次真实工具往返,例如让模型调用 get_current_time,工具返回时间后再由 LLM 总结。
但做到这里还不够。对于单板本地工具,tool_result 基本够用;对于跨 ESP32 的 Mesh 命令,只靠 tool_result 就不够了。因为 mesh_send_command 的本地执行结果通常只是:命令已经发布到 MQTT。真正的温湿度读取、灯光控制、舵机动作发生在另一块 ESP32 上。
所以项目里补了结构化 OutputMessage。它不是 LLM provider 的原生 message,而是 ESPAgent 自己定义的节点执行结果消息。当前 schema 是 espagent.output.v1,典型结构是:
1 | { |
它的意义很直接:让结果可关联、可展示、可审计、可回注。
现在 mesh_send_command 默认是异步模式。Coordinator 调用工具后,会先返回类似这样的结果:
1 | OK: queued MQTT mesh command action=set_status_light ... |
后台 task 会继续等待同一个 command_id 的 OutputMessage。等 Sensor 或 Control 发布结果后,Coordinator 会把这个结果作为内部消息注入回 message_bus,让 agent_loop 再跑一轮,让 LLM 用一句用户能看懂的话总结结果。
这就是现在比旧稿进步最大的地方:
1 | 旧状态: |
例如飞书里让远程控制板把 WS2812 设置为蓝色,日志里已经能看到:
1 | Mesh control command executed: id=cmd-... action=set_status_light status=ESP_OK |
然后 Coordinator 会再回复用户:
1 | 远程控制板(esp32s3-control-01)的状态灯已成功设置为蓝色。 |
这比“我已经下发命令”更像一个真正知道执行结果的 Agent。
第四章:第四角色:Guardian
第四块 ESP32-S3 现在的角色是 Guardian。它不是显示屏,也不直接控制硬件,而是给整个 Mesh 加一层安全和审计。
它主要订阅这些 topic:
1 | espagent/cube1345/security/policy_check |
当 Coordinator 要下发 Mesh command 时,不是立刻发给 Sensor 或 Control,而是先发一个 policy_check:
1 | { |
Guardian 收到后,根据白名单、安全等级和目标角色返回 policy_decision:
1 | { |
只有 decision=allow 时,Coordinator 才会继续发布真正的 Mesh command。Control 侧也不是只相信 MQTT payload,它会检查本机缓存里的 Guardian allow decision。这样即使有人伪造 control topic,也不能轻易绕过策略层。
这次压力测试还暴露出一个细节:Guardian 同时订阅 nodes/+/state、nodes/+/telemetry、timeline 和 policy topic,如果每条 state/telemetry 都立刻生成 StateBoard 更新,就可能把 policy_decision 回流拖慢。现在已经做了两层修复:
1 | Guardian watchdog StateBoard: |
因为 policy_check 和 policy_decision 都带同一个 command_id,这个重试是幂等的。真正的 Sensor/Control command 只有拿到 allow 之后才会发出去,所以不会导致硬件动作重复执行。
Guardian 还会观察 timeline。比如看到 mesh_command_result 是 error,就发布 audit 和 alert;看到 Control 执行成功,就记录观察事件。这些会进入 guardian/stateboard,串口上可以通过:
1 | stateboard_show |
查看最近的 policy decision、mesh result、final reply 等事件。
现在 Guardian 还不是最终的生产级安全系统,但已经不只是第一版白名单了。当前固件里已经有人工确认队列:高风险动作可以进入 approval_list,确认后生成一次性 approval_id,由 Coordinator 或用户重试同 action/role 的命令。Mesh command 也支持可选 HMAC 当前 key / previous key 验证,Control 侧支持可选 interlock GPIO。还没完成的是生产 broker ACL/TLS、真实互锁接线验证、密钥轮换 SOP 和更完整的隐私脱敏策略。
这一步的意义是:安全不再只靠“提示词里让 LLM 小心一点”,而是被拆成一个确定性的角色节点和一组可验证的固件路径。
第五章:subagent 是同一块板上的临时子任务
spawn_subagent 仍然是项目里的一个重要能力,但要说清楚它的边界。
它不是第二个物理 ESP32,也不是云端子进程,而是在同一块 ESP32-S3 上创建一个临时 FreeRTOS task。主 Agent 调用它后,子任务会运行自己的短 ReAct loop,完成任务后通过 semaphore 把结果交回来。
它的工具白名单很窄:
1 | web_search |
它不能调用:
1 | mesh_send_command |
这样设计是有意的。subagent 适合做信息类、文件类、总结类任务,不适合直接操作硬件。因为硬件控制必须走主 Agent、Guardian 和 Mesh policy 边界。
USB0 上已经验证过 spawn_subagent。串口调用:
1 | tool_exec spawn_subagent {"task":"Call_get_current_time_and_return_one_sentence"} |
能看到子任务启动、调用 get_current_time、拿到结果、再让 LLM 总结。也就是说,subagent 的“临时子 Agent + 工具调用 + 结果返回”闭环在板端是通的。
但它目前仍是同步等待模式,不是后台长期 worker。真正的异步闭环现在主要体现在 Mesh command:async_task_id -> OutputMessage -> message_bus。
第六章:每块板都有自己的 capability 和 Lua runtime
ESPAgent 现在也开始从“工具注册表”升级到更像单板 Agent runtime 的结构。
原来的 tool_registry 仍然保留,但上面加了一层 capability registry。每个 capability 会描述:
1 | name |
这样做的意义是,工具不再只是给 LLM 看的一串 JSON schema,而是可以被不同调用者以不同身份执行。现在已经有:
1 | tool_registry_execute() |
也就是说,同一个固件里,不同角色看到的能力不一样。Coordinator 更偏通信、LLM、调度;Sensor 更偏环境读取;Control 更偏执行器;Guardian 更偏 policy、audit、StateBoard。这样比“所有角色都暴露所有工具”更接近真正的多节点系统。
另一个变化是 Lua runtime 已经不再只是计划。四块 ESP32-S3 都已经部署了受控 Lua 能力,并做过 smoke test:
1 | USB0 Lua smoke: 7/7 PASS |
Lua 侧可以执行 inline source、SPIFFS 脚本、异步 job,也可以查看 job 状态。脚本里能通过:
1 | local espagent = require("espagent") |
调用 ESPAgent capability。
这里有一条安全红线:Lua 不是裸硬件逃生通道。它不能直接获得 gpio/i2c/adc/pwm/rmt/ble/display/camera/audio 这类底层模块,不能绕过 sandbox、role profile、Guardian policy 或 Control interlock。Lua 想控制硬件,仍然必须通过 ESPAgent 已注册的 capability、manifest primitive 或 Mesh 工具链。
所以这次吸收 esp-claw 的思路,不是把 ESPAgent 改成另一个 esp-claw,而是把它的单板 runtime 思想融合进当前项目:
1 | capability registry |
项目特色仍然是四角色 Agent Mesh、Guardian 权限治理、OutputMessage/ReAct 闭环和 P4/Android 可视化推理链路。
同一条思路也用在动态硬件扩展上。现在 SPIFFS 里可以放设备 manifest,描述一个新硬件使用什么协议、地址、寄存器、GPIO、风险等级和权限声明。开发者不一定每次都要重新写一个完整 C 工具;对于一部分通用协议设备,可以先用 manifest primitive 描述,再通过 virtual_device_read 或 virtual_device_control 进入统一执行路径。
目前软件侧已经有:
1 | I2C / UART / Modbus / SPI / ADC / GPIO input 只读 manifest 示例 |
这里同样不追求“让 AI 随便操控任何陌生硬件”。更合理的目标是:开发者把 datasheet 里的协议知识整理成 manifest,固件负责校验边界,Guardian 负责 policy,Control 负责本地安全执行。这样 AI 可以理解“这个新设备应该怎么读、怎么写”,但不会绕过已有的安全链路。
第七章:MQTT Mesh 现在已经能从飞书跑到硬件
四块 ESP32-S3 之间现在主要通过 MQTT Mesh 通信。当前调试 broker 是:
1 | broker.emqx.io:1883 |
topic prefix 是:
1 | espagent/cube1345 |
核心 topic 包括:
1 | espagent/cube1345/nodes/<node_id>/state |
一个典型的“点亮远程控制板状态灯”流程现在是:
1 | 飞书用户:把远程控制板的 WS2812 状态灯设置为蓝色 |
温湿度读取也是类似,只是目标角色从 control_agent 换成 sensor_agent:
1 | 读取温湿度 -> sensor_agent/read_temperature_humidity |
这里还有一个用户体验上的改进。用户不需要在飞书里说:
1 | 请调用 mesh_send_command,让 esp32s3-sensor-01 执行 read_temperature_humidity |
现在只要说:
1 | 读取温湿度 |
Coordinator 会自动判断这是 Sensor 的事情。控制类请求也一样,例如:
1 | 点亮 WS2812 为蓝色 |
会自动路由到 control_agent。这部分不完全依赖 LLM 自己发挥,项目里已经加了确定性 Mesh 路由和“假成功”拦截。之前模型偶尔会口头说“已经发送 MQTT 命令”,但实际上没有调用工具;现在这类常见请求会优先走确定性路径。
第八章:ESP32-P4 和 Android 不替代 Coordinator,而是显示终端
ESP32-P4+C6 的定位现在更清楚了:它不是第五个 Coordinator,也不是主 LLM 节点,而是现场 Display Terminal。
P4 工程在:
1 | /home/cube/WorkSpace/ESP/lvgl_traffic_control |
当前已经在这个工程里加入了 AgentMesh 页面,包含:
1 | 四角色状态卡 |
P4 侧订阅的就是 S3 发布的 Mesh 数据流:
1 | espagent/cube1345/agent/timeline |
目前 P4 已经烧录到 /dev/ttyACM0,并验证到 Wi-Fi 和 MQTT 连接、订阅成功。现在 S3 侧会往 timeline 里发布更多结构化信息,包括 policy_decision 的 risk_score、Sensor telemetry 的 temp_avg/humidity_avg/light_lux_avg、Control 执行后的 control_state 快照。后续还要继续用四块 S3 的实时流量验证屏幕 timeline 是否稳定刷新。
Android 端则是 P4 的移动增强版。它不应该写成一个普通聊天机器人,而应该写成 Display / Debug / Confirm Terminal。也就是说,Android 端订阅同一套 MQTT topic,把事件组织成 StateBoard 和 Trace:
1 | 用户输入 |
Android 比 P4 更适合做复杂交互,例如:
1 | 按 trace_id 查看完整任务链路 |
这个定位和云端多 Agent 项目里的 StateBoard 很像。所有事件先进状态板,再由 UI 渲染。这样就不会把 UI 写死在某个单一日志格式上。
第九章:天气、时间和 OTA 也进入了工程化阶段
这几天还补了几个看似小、但很影响稳定性的能力。
时间同步
第一角色启动后会使用 SNTP 校时,默认服务器是:
1 | ntp.aliyun.com |
get_current_time 会优先读取已经同步的系统时间。实机验证里,USB0 返回过:
1 | 2026-06-17 14:43:05 CST (Wednesday) |
这比之前依赖 HTTP Date header 更稳定。
高德天气
天气工具现在是结构化工具 get_weather,默认地区是南京市栖霞区,adcode 是:
1 | 320113 |
第一角色调用 get_weather 时,最开始在串口 tool_exec 下会让 console_repl 栈溢出。根因不是高德 key 缺失,而是 ESP-IDF 默认 console REPL 任务栈只有 4KB,HTTPS + TLS + cJSON 对它来说太重。
修复方式是把 CLI 栈提升到 12KB,并真正传给 REPL:
1 |
|
之后又遇到高德 HTTPS 证书校验失败,实机日志里是 Failed to verify certificate。当前处理方式是:优先 HTTPS,如果证书链在当前 CRT bundle 下失败,则 fallback 到高德 HTTP WebService。修复后 USB0 连续调用 5 次 get_weather 都通过:
1 | Live weather for 江苏栖霞区 (adcode=320113): 阴, |
OTA
项目也加入了 OTA 固件升级底座。当前是本地维护能力,不暴露给 LLM,也不建议直接从飞书触发。
串口命令是:
1 | ota_info |
它会把新的 app bin 写入 inactive OTA slot,成功后重启。
这里要强调一个边界:Agent 不会在 MCU 上“写新固件代码”或“编译固件”。OTA 的正确用法是开发者或 CI 先准备好 ESPAgent.bin,Agent 未来可以负责编排升级流程,例如版本发现、角色匹配、Guardian 审批、用户确认、下发 OTA、观察重启和汇报结果。
当前四个 S3 角色还是 build-time profile。如果 OTA 镜像里写死了另一个 NODE_ROLE,就可能把板子升级成错误角色。后续更好的做法是把 role profile 迁移到 NVS,让同一个 OTA 镜像能安全适配四块板。
第十章:压力测试验证到了哪一步
最近的验证不再只是“编译通过”。现在已经跑了真实四板和飞书入口压力测试。
MQTT Mesh 基准测试
脚本:
1 | tools/stress_mesh_usb0_3.py |
它固定只使用:
1 | /dev/ttyUSB0 |
不使用 /dev/ttyACM*,避免把 ESP32-P4 当成 S3 烧录或测试。
最新一次四板回归先重新按 USB0-3 顺序烧录:
1 | USB0 -> coordinator_agent |
然后执行角色自检:
1 | tools/verify_roles_usb0_3.py --echo |
结果四块板全部 PASS。
随后执行:
1 | tools/stress_mesh_usb0_3.py --rounds 5 --interval 1.5 --settle 60 --quiet |
连续两轮都通过。两轮核心指标一致:
1 | sent=10 |
这说明四角色识别、Coordinator 下发、Guardian 裁决、Sensor 接收、Control 执行、OutputMessage 返回、Guardian 审计这条 Mesh 链路在当前压力强度下是通的。
这次测试还顺手修掉了一个真实压力问题:Guardian 同时处理 state、telemetry、timeline 和 policy 时,普通 StateBoard 更新会拖慢 policy decision 回流。修复后,mqtt_state_lines 明显下降,policy 链路不再因为 watchdog 摘要过多而误超时。
飞书入口端到端测试
脚本:
1 | tools/stress_feishu_usb0_3.py |
它会真实向飞书 bot 发消息,比如:
1 | 读取温湿度 |
此前飞书入口结果:
1 | sent_ok=2 sent_failed=0 expected_total=2 |
这里的 async_result_turns=2 很关键。它说明两条请求都不是只停在“命令已发送”,而是走到了异步 OutputMessage 回注。最新一轮重点先压了串口/MQTT Mesh 层,飞书入口仍建议作为下一轮回归测试继续跑。
天气回归测试
USB0 连续执行 5 次:
1 | tool_exec get_weather {"adcode":"320113","extensions":"base"} |
结果全部 ESP_OK,crashes=0,RESULT: PASS。
关于传感器错误
压测里以前 Sensor 节点会出现:
1 | AHT10 not found |
这些目前不算 Mesh 失败。后续 AHT20 已经接线并实测可读,USB1 telemetry 里能看到 AHT20 温湿度;DHT22、MH-Z19、SGP30 之类如果没有接线或当前配置不可用,仍然会在综合环境工具里表现为对应子项错误。判断 Mesh 是否成功,主要看 command 是否到达、是否执行、是否发布 OutputMessage,以及 Guardian 是否完成 policy/audit。
第十一章:资源占用现在怎么看
四块 S3 仍然没有把硬件资源完全吃满。更准确地说,运行时已经按 role 裁剪服务,但固件镜像还没有按角色裁剪。
最近构建的 ESPAgent.bin 大小已经明显增长。加入 capability registry、Lua runtime、动态 manifest、Guardian/Control 安全链路之后,最新 app 大约是:
1 | 0x191d40 |
最小 app 分区是 2MB,还剩约 22%。这说明 Flash 还有余量,但已经不能随便继续堆依赖。USB0 烧录慢也不是因为 app 分区“快满了”,主要是每个 role 都要重新编译/写入约 1.6MB app,再加上 SPIFFS 镜像校验。
四个角色的资源侧重点现在是:
| 节点 | 当前资源重点 | 后续真正吃资源的方向 |
|---|---|---|
| Coordinator | LLM、Feishu、WebSocket、MQTT、TLS、session、skills、subagent、policy retry | 更完整任务拆解、上下文压缩、运行时 role profile |
| Sensor | I2C/UART/GPIO 采样、MQTT telemetry、AHT20、EWMA/cache、阈值事件 | 更多传感器、异常检测、局部自治策略 |
| Control | WS2812/GPIO/Servo、MQTT command receiver、command queue、互锁、actuator state | 真实继电器/风扇/水泵、硬件 interlock 实测、动作回滚 |
| Guardian | policy decision、risk_score、audit、alerts、StateBoard、watchdog、approval queue | 生产级 ACL/TLS、密钥轮换、隐私脱敏、长期异常评分 |
这里我现在不急着把 RAM/PSRAM/Flash 全部压满。MCU 上做 Agent,稳定性比“看起来资源吃满”重要。尤其是 TLS、cJSON、LLM response、MQTT buffer、FreeRTOS task stack 都很容易在压力下出问题。最近修复的 Feishu ACK 栈、timer service 栈、console REPL 栈就是典型例子。
所以现在更合理的策略是:
1 | 先把链路闭环跑稳 |
第十二章:现在还不完美的地方
虽然当前进度已经比旧稿前进很多,但还不能说是完美的 Agent Mesh。
还存在这些边界:
1 | Coordinator 仍然是单 ESP32-S3 上的单 agent_loop,不是多个独立 LLM Agent 进程 |
现在项目真正有 Agent 特征的地方在于:
1 | 自然语言进入 Feishu |
这条链路才是 ESPAgent 的核心价值。
结尾
当前 ESPAgent 已经从单板 Agent 推进到四块 ESP32-S3 的角色协作:Coordinator 负责理解和调度,Sensor 负责感知,Control 负责执行,Guardian 负责权限和审计。ESP32-P4+C6 和 Android 则作为显示终端,把 AI 的推理过程、MQTT 通信过程、节点状态和最终结果展示出来。
我觉得这一步很关键。因为硬件 Agent 最怕的不是“功能少”,而是“看起来执行了,但不知道到底发生了什么”。现在有了 policy_check、policy_decision、OutputMessage、timeline、StateBoard、capability profile、受控 Lua runtime,以及连续两轮四板 Mesh 压力测试,系统至少开始具备可追踪、可回归、可扩展的闭环。
接下来要做的不是继续盲目堆工具,而是继续把这条闭环做厚:P4/Android 的 trace 展示、真实继电器/风扇/水泵的安全联动、Guardian 的隐私治理和生产级 broker 安全、Sensor 的更多异常事件、以及 OTA 的安全编排。
做到这些之后,ESPAgent 才会从“能控制硬件的 MCU Agent”,继续往“能感知、能判断、能协作、能证明自己做了什么的边缘 Agent Mesh”推进。


