
之前在调试嵌入式端的小型语言模型时最痛苦的事情不是模型跑不起来而是模型跑起来之后完全是一个“黑盒”。串口里刷出来一串串 token你只知道它在生成文本却不清楚每一步是怎么选的、模型对下一个字符有多少置信度、温度参数改大改小到底影响了什么。直到接触了 Brainscope 这类可视化调试工具才真正体会到“看着单片机的 LLM 思考”是什么体验。这篇文章围绕Brainscope/examples/ESP32 Watch a microcontrollers LLM think这个示例展开从概念、环境、原理到 ESP32 上跑通一个微型 LLM 并实现“思考过程可视化”的完整流程都会讲到。新手可以照着搭建环境有嵌入式开发经验的读者可以直接跳到代码部分复用思路。1. 背景与核心概念1.1 当 LLM 跑进单片机提到大语言模型LLM很多人第一反应是云端 API、GPU 推理集群、几十 GB 的权重文件。但在端侧推理场景里我们讨论的是另一条路线把模型压缩到几十 MB 甚至几 MB跑在 MCU 上。云侧 LLM 和端侧 LLM 的核心区别在于对比维度云侧 LLM端侧 LLM网络依赖强离线不可用无可完全离线延迟受网络波动影响本地推理延迟可控隐私数据需上传数据不出设备模型规模几十亿到几千亿参数几百万到几亿参数算力要求高性能 GPUMCU / 边缘 NPU典型设备服务器、PCESP32-S3、树莓派、手机端侧在智能音箱、儿童玩具、传感器节点、工业控制面板这类场景中很多需求并不需要 GPT-4 级别的能力只需要在本地完成“分类、补全、纠错、简单对话”等任务。这时候ESP32 这类低成本 Wi-Fi MCU 就成了很合适的载体。1.2 为什么选择 ESP32ESP32 并不是第一个能跑神经网络的单片机但它有几个优势让它成为 LLM 端侧实验的热门平台生态成熟Arduino、PlatformIO、ESP-IDF 三套开发方式都可用。内存可选带 PSRAM 的型号如 ESP32-S3-WROOM-1-N16R8可以挂载 8MB 甚至更大的外部 RAM给 KV Cache 和激活值留出空间。算力够用ESP32-S3 支持向量指令对量化后的矩阵运算有一定加速效果。外设齐全Wi-Fi、蓝牙、SPI、I2C、USB 都有方便对接屏幕、传感器和数据通道。这里要说明一点ESP32 能跑的“LLM”并不是 ChatGPT 那种规模的大模型而是微型语言模型比如基于 llama2.c 架构的小型模型、字符级 RNN、或者经过大幅压缩的 TinyLlama 变体。模型能跑多大主要取决于 PSRAM 容量和 Flash 大小。1.3 Brainscope 解决什么问题Brainscope 是一个偏“模型内部状态可视化”的调试工具。它的思路是在模型推理的每一步把关键内部状态采集出来通过串口、WebSocket 或文件方式传给外部展示端让开发者能看到当前生成到第几个 token。候选 token 的概率分布。logits 经过 softmax 之后的结果。温度、top-k、top-p 等采样参数对生成结果的影响。每一步的熵值用来判断模型“确定”还是“不确定”。官方 examples 目录里的 ESP32 示例题目就叫 “Watch a microcontrollers LLM think”本质上就是把“模型思考过程”这件事从抽象变具体。下面从环境准备开始逐步实现这套链路。2. 环境准备与版本说明2.1 硬件清单建议准备以下硬件主控ESP32-S3-DevKitC-1 开发板或者任意带有 8MB PSRAM 的 ESP32-S3 模组。Flash 容量建议 16MB方便存放模型权重。屏幕可选OLED 或 TFT 小屏用来显示实时 token增强演示效果。数据线USB-C 数据线注意要支持数据传输不能只用充电线。如果你的开发板没有 PSRAM也可以跑但模型规模会受限后面讲内存优化时会说明原因。2.2 开发环境本文示例以 Arduino IDE 为主同时也兼容 PlatformIO。Arduino IDE 方式对新手更友好。安装 Arduino IDE建议使用 2.x 版本。安装 ESP32 开发板支持包。在“文件 → 首选项 → 附加开发板管理器网址”中添加https://espressif.github.io/arduino-esp32/package_esp32_index.json打开“工具 → 开发板 → 开发板管理器”搜索esp32安装 Espressif 官方支持包。如果下载慢可以在国内镜像源下离线安装包具体操作网上已有较多教程这里不展开。2.3 依赖库后续代码需要用到WiFi 库ESP32 自带。WebSocketsServer搜索并安装Websockets作者为 Markus Sattler。ArduinoJson搜索并安装ArduinoJson。注意 v7 和 v6 的 API 有差异本文代码按 v7 写法演示如果你用的是 v6需要把JsonDocument换成DynamicJsonDocument。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。3. 核心原理拆解LLM 是怎么“想”的要理解“看模型思考”先要理解自回归语言模型生成一个 token 的完整流程。3.1 自回归生成循环一个微型 LLM 的推理过程可以拆成三步循环输入当前的 token 序列prompt 或已经生成的内容。模型前向计算输出一个 logits 向量向量长度等于词表大小。对 logits 做 softmax 得到概率分布再按采样策略选出下一个 token。生成的 token 又被拼接到输入序列尾部进入下一轮。这个过程叫自回归生成。用伪代码表示input prompt_tokens while not stopped: logits model.forward(input) probs softmax(logits) next_token sample(probs, temperature, top_k, top_p) output.append(next_token) input.append(next_token)可以看到每一步都存在一个可供采集的概率分布这正是 Brainscope 展示“思考过程”的数据基础。3.2 logits、softmax 与采样参数几个关键概念需要分清logits模型输出的未归一化分数可以是任意实数。数值越大代表模型越倾向选择该 token。softmax把 logits 变成总和为 1 的概率分布。temperature对 logits 做缩放。temperature 越大分布越平滑生成越随机越小分布越尖锐生成越确定。top-k只从概率最高的 k 个 token 中采样过滤长尾。top-p从累计概率超过 p 的最小集合中采样也叫核采样。熵entropy衡量分布的不确定性。熵接近 0 时模型对某个 token 高度自信熵很大时模型处于“纠结”状态。当你在可视化面板上看到某个位置有多个候选 token 的概率接近就意味着模型在这里“犹豫”这比只看最终文本有趣得多也更有调试价值。3.3 量化FP32 到 INT8 / INT4模型在 PC 上通常用 FP32 或 FP16 权重。但 ESP32 的浮点运算能力有限而且权重体积大所以必须量化。以 100 万参数模型为例精度单个参数占用权重体积FP324 字节约 4 MBINT81 字节约 1 MBINT40.5 字节约 0.5 MB在 ESP32-S3 上INT8 和 INT4 量化是常见做法。量化会带来少量精度损失但微型模型本身能力有限推理速度的提升和内存的节省通常更值得。3.4 Brainscope 的采集链路把“思考过程”可视化需要一条完整的数据链路ESP32 模型推理 ↓ 每步采集 StepInfotoken、topK概率、熵、温度 ↓ 序列化 JSON WebSocket ServerESP32 上运行 ↓ 广播 浏览器 DashboardPC / 手机采集端负责从模型推理循环中提取数据传输端负责把数据发出去展示端负责渲染。三者互相独立可以分别替换。4. 实战一让 ESP32 跑一个微型 LLM下面我们开始搭建实际工程。为了让读者既能理解模型侧逻辑又不会被几百行模型实现淹没本文把模型推理封装成一个LLMInference类并给出一个用于测试链路的 Mock 实现。实际项目中你可以把这个类的方法替换成真实模型的调用接口。4.1 创建项目结构在 Arduino IDE 中新建工程命名为esp32_llm_brainscope。在工程目录下创建以下文件esp32_llm_brainscope/ ├── esp32_llm_brainscope.ino ├── llm_wrapper.h ├── llm_wrapper.cpp └── dashboard.html其中dashboard.html是可视化面板可以直接用浏览器打开。4.2 定义模型接口先定义StepInfo结构和LLMInference类。// 文件路径esp32_llm_brainscope/llm_wrapper.h #pragma once #include Arduino.h // 每个候选 token 的信息 struct Candidate { const char* piece; // token 文本 float prob; // 概率 }; // 模型每一步的“思考快照” struct StepInfo { uint32_t step; // 当前步数 const char* nextPiece; // 被选中的 token float temperature; // 当前温度 float entropy; // 当前熵值 uint8_t candidateCount; // 候选数量 Candidate topK[8]; // 概率最高的前 8 个候选 }; class LLMInference { public: bool begin(); // 执行一步推理返回该步的思考快照 StepInfo step(); // 设置采样温度 void setTemperature(float t); private: float temperature 0.8f; };这个头文件是所有对接逻辑的核心。你的真实模型实现只要能产出StepInfo可视化链路就可以直接复用。4.3 Mock 模型实现为了先跑通可视化链路这里提供一个假的实现。它不真正推理语言模型而是模拟每一步的概率分布用来验证 WebSocket 和 Dashboard 是否正常工作。// 文件路径esp32_llm_brainscope/llm_wrapper.cpp #include llm_wrapper.h // 模拟词表中的几个候选词 static const char* vocab[] { the, is, a, of, you, I, and, to}; static const uint8_t vocabSize 8; bool LLMInference::begin() { return true; } void LLMInference::setTemperature(float t) { if (t 0.1f t 2.0f) { temperature t; } } StepInfo LLMInference::step() { StepInfo info; info.step 0; info.nextPiece ; info.temperature temperature; info.entropy 0.0f; info.candidateCount vocabSize; // 用伪随机数模拟 logits然后做 softmax float logits[8]; float sum 0.0f; for (int i 0; i 8; i) { logits[i] random(-20, 100) / 100.0f; logits[i] logits[i] / temperature; // 温度缩放 sum exp(logits[i]); } float maxProb 0.0f; int maxIdx 0; for (int i 0; i 8; i) { float p exp(logits[i]) / sum; info.topK[i].piece vocab[i]; info.topK[i].prob p; if (p maxProb) { maxProb p; maxIdx i; } // 粗略计算熵真实场景应按完整分布计算 info.entropy p * log(p); } info.entropy -info.entropy; info.nextPiece vocab[maxIdx]; info.step millis() / 50; // 用时间模拟步数方便观察 return info; }说明这个 Mock 实现的熵计算没有覆盖完整词表只作为演示。真实模型只需要把这里的step()内部换成model.forward()和真正的采样逻辑即可外层传输代码不用改动。4.4 主程序Wi-Fi 与 WebSocket接下来编写主程序。ESP32 启动后连接 Wi-Fi启动 WebSocket 服务浏览器通过 IP 访问 Dashboard。// 文件路径esp32_llm_brainscope/esp32_llm_brainscope.ino #include WiFi.h #include WebSocketsServer.h #include ArduinoJson.h #include llm_wrapper.h const char* ssid YOUR_WIFI_SSID; const char* password YOUR_WIFI_PASSWORD; WebSocketsServer webSocket(81); LLMInference llm; bool clientConnected false; void sendStep() { StepInfo info llm.step(); // ArduinoJson v7 写法v6 请改为 DynamicJsonDocument doc(1024); JsonDocument doc; doc[step] info.step; doc[nextPiece] info.nextPiece; doc[temperature] info.temperature; doc[entropy] info.entropy; JsonArray topK doc[topK].toJsonArray(); for (uint8_t i 0; i info.candidateCount; i) { JsonObject obj topK.addJsonObject(); obj[piece] info.topK[i].piece; obj[prob] info.topK[i].prob; } String json; serializeJson(doc, json); webSocket.broadcastTXT(json); } void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { switch (type) { case WStype_CONNECTED: clientConnected true; Serial.printf(Client %u connected\n, num); break; case WStype_DISCONNECTED: Serial.printf(Client %u disconnected\n, num); clientConnected false; break; case WStype_TEXT: { // 收到 Dashboard 发来的控制指令 JsonDocument doc; if (deserializeJson(doc, payload, length) DeserializationError::Ok) { if (doc[setTemperature].isfloat()) { llm.setTemperature(doc[setTemperature].asfloat()); Serial.printf(Set temperature to %.2f\n, doc[setTemperature].asfloat()); } if (doc[next].asbool() true) { sendStep(); } } break; } default: break; } } void setup() { Serial.begin(115200); WiFi.begin(ssid, password); Serial.print(Connecting to WiFi); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); } Serial.println(); Serial.print(ESP32 IP Address: ); Serial.println(WiFi.localIP()); webSocket.begin(); webSocket.onEvent(webSocketEvent); llm.begin(); Serial.println(LLM wrapper ready.); } void loop() { webSocket.loop(); // 与浏览器建立连接且没有待处理数据时每 800ms 推一步 if (clientConnected millis() % 800 20) { sendStep(); } delay(10); }4.5 运行与验证把代码里的 Wi-Fi 信息改成你自己的编译烧录到 ESP32 开发板。打开串口监视器波特率 115200会看到类似输出Connecting to WiFi........ ESP32 IP Address: 192.168.1.123 LLM wrapper ready.到这里模型“有状态”了但还看不到思考过程下一步给浏览器做一个展示面板。5. 实战二构建可视化“思考面板”5.1 面板功能设计Dashboard 需要展示三类信息当前生成的文本流把每个nextPiece拼起来。Top-8 候选概率柱状图直观显示模型每一步的置信度。熵值变化曲线判断模型是果断还是犹豫。同时提供两个控制项温度调节滑块、手动触发下一步按钮。5.2 HTML 页面在工程目录下创建dashboard.html用浏览器打开把顶部 IP 改成你的 ESP32 IP。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleESP32 LLM 思考面板/title style body { font-family: PingFang SC, Microsoft YaHei, sans-serif; margin: 20px; background: #f7f9fb; color: #222; } h1 { font-size: 22px; } .card { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 16px; margin-bottom: 16px; } #textStream { min-height: 60px; font-size: 18px; line-height: 1.8; word-break: break-all; border: 1px solid #eee; border-radius: 8px; padding: 12px; } #entropyText { font-size: 14px; color: #666; } canvas { display: block; max-width: 100%; } button { background: #10a37f; color: #fff; border: none; border-radius: 8px; padding: 8px 16px; font-size: 14px; cursor: pointer; } button:hover { opacity: 0.85; } input[typerange] { width: 300px; } /style /head body h1ESP32 LLM 思考面板/h1 div classcard h2状态/h2 div idconnStatus连接中.../div div identropyText熵值--/div /div div classcard h2候选概率分布/h2 canvas idprobChart width700 height360/canvas /div div classcard h2文本流/h2 div idtextStream/div /div div classcard h2采样参数控制/h2 labelTemperature: span idtempLabel0.8/span/label input typerange idtempSlider min0.1 max2.0 step0.1 value0.8 brbr button idnextBtn触发下一步/button /div script // 改成你的 ESP32 IP const WS_URL ws://192.168.1.123:81; let ws null; let history []; function connect() { ws new WebSocket(WS_URL); ws.onopen function () { document.getElementById(connStatus).textContent 已连接; }; ws.onclose function () { document.getElementById(connStatus).textContent 连接断开尝试重连中...; setTimeout(connect, 2000); }; ws.onmessage function (event) { handleMessage(JSON.parse(event.data)); }; } function handleMessage(data) { document.getElementById(entropyText).textContent 熵值 data.entropy.toFixed(3); const streamEl document.getElementById(textStream); history.push(data.nextPiece); if (history.length 200) { history.shift(); } streamEl.textContent history.join(); drawChart(data.topK); } function drawChart(topK) { const canvas document.getElementById(probChart); const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); const barWidth 60; const gap 20; const baseY 330; const maxH 280; // 坐标轴 ctx.strokeStyle #ccc; ctx.beginPath(); ctx.moveTo(30, baseY); ctx.lineTo(690, baseY); ctx.stroke(); for (let i 0; i topK.length; i) { const prob topK[i].prob; const h prob * maxH; const x 50 i * (barWidth gap); ctx.fillStyle #10a37f; ctx.fillRect(x, baseY - h, barWidth, h); ctx.fillStyle #222; ctx.font 14px sans-serif; ctx.fillText(topK[i].piece, x, baseY 18); ctx.font 12px sans-serif; ctx.fillStyle #888; ctx.fillText((prob * 100).toFixed(1) %, x, baseY - h - 8); } } // 温度滑块 document.getElementById(tempSlider).addEventListener(input, function () { const val parseFloat(this.value); document.getElementById(tempLabel).textContent val.toFixed(1); if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ setTemperature: val })); } }); // 手动触发下一步 document.getElementById(nextBtn).addEventListener(click, function () { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ next: true })); } }); connect(); /script /body /html5.3 运行结果说明浏览器打开dashboard.html如果一切正常会出现以下现象状态区显示“已连接”。候选概率柱状图每 800ms 刷新一次不同候选词的柱长会变化。文本流区域不断追加新 token。拖动温度滑块ESP32 串口会打印新的温度值后续概率分布会随之变化。这就是“看模型思考”的基础体验。你把llm_wrapper.cpp中的 Mock 换成真实模型后看到的就是真实推理过程。5.4 真实模型对接建议目前 ESP32-S3 社区常用的微型模型路线有几种llama2.c 移植版把 Karpathy 的 llama2.c 移植到 ESP32-S3配合 INT8 量化运行小型 GPT 模型。ESP-DL / ESP-LLM乐鑫官方的神经网络库和 LLM 示例对 ESP32-S3 的指令集有优化。自训练字符级 RNN把莎士比亚、古诗文等语料做成字符级模型权重非常小100MB Flash 可以装好几个。无论选择哪种核心对接方式都一样在llm.step()内部调用模型的前向计算把输出的 logits 转成概率填入StepInfo。注意真实模型的词表往往有几千到几万项topK数组不可能全部传输建议先排序再取前 8 个候选熵值则基于完整词表计算。6. 常见问题与排查思路问题现象常见原因解决思路编译时报JsonDocument未定义ArduinoJson 版本是 v6 而不是 v7v6 使用DynamicJsonDocument doc(1024)并在末尾调用serializeJson烧录后串口无输出开发板选择错误或驱动未安装确认选择的是 ESP32S3 Dev Module安装 CP2102/CH340 驱动浏览器连不上 WebSocketESP32 IP 不一致或端口被占用检查串口打印的 IP确认 dashboard 中 WS_URL 与之一致概率柱状图不刷新mock 的millis() % 800 20逻辑持续时间窗口太短改为在loop()中用unsigned long lastSend做定时模型加载后反复重启模型文件太大Flash 分区或 PSRAM 不足确认开发板规格使用带 PSRAM 的型号检查分区表生成速度太慢未使用量化模型或没有启用编译优化使用 INT8/INT4 权重PlatformIO 下开启-O2Wi-Fi 连接不上路由器 5G 频段兼容问题部分模组对 5GHz 支持不好改为 2.4GHz 频段温度滑块拖动后效果不明显Mock 模型随机数据对温度不敏感换真实模型观察明显差异也可增大 logits 随机范围如果遇到编译期报错堆栈很长优先检查库版本冲突。ESP32 生态里库的 API 变动很快先看报错信息里是哪个库文件抛出的再针对性调整。7. 最佳实践与工程建议7.1 采样参数不要写死温度和 top-k 直接影响生成质量和延迟。建议把采样参数设计为可配置项由外部面板动态调整这样调试时就不需要反复烧录固件。生产环境如果不需要调试可以把 WebSocket 服务和采样参数入口一起裁剪掉减少固件体积。7.2 通信协议建议使用 JSON-Lines 或二进制WebSocket 的 JSON 消息很方便调试但每帧都带完整 key 名会浪费带宽。ESP32 的串口和 Wi-Fi 带宽有限建议调试阶段保留 JSON方便肉眼观察。稳定阶段改成紧凑的二进制格式用 1 字节type字段 若干float数组减少序列化和传输开销。7.3 内存管理是嵌入式 LLM 的重点ESP32-S3 的内部 SRAM 有限大块数据尽量放在 PSRAM权重矩阵放 PSRAM。KV Cache 按 batch size 动态分配。避免在每一步都创建大的临时数组提前分配好缓存区。ArduinoJson 的文档大小要按实际消息规模设置防止内存碎片。7.4 安全边界如果设备暴露在局域网中WebSocket 服务默认没有鉴权。建议不要在公网直接暴露设备端口。生产环境可增加简单 token 验证或限定 IP。只开放局域网使用或在路由器层做隔离。7.5 打日志要有“开关”嵌入式设备的日志输出会影响推理时序。建议加一个编译期开关#define BRAINSCOPE_DEBUG 1 #if BRAINSCOPE_DEBUG #define LOG(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__) #else #define LOG(fmt, ...) #endif发布固件时关掉调试日志推理速度会明显提升。7.6 量化精度要结合任务验证前面提到 INT8/INT4 量化会损失精度。不要只看模型体积要在目标任务上对比量化前后的输出分布和准确率。对需要精确数字任务的场景建议保留关键层为 FP16 或混合精度其余层用 INT8。8. 总结与学习路线通过这篇文章你应该理解了以下几个关键点云侧 LLM 和端侧 LLM 的适用边界ESP32 在端侧推理中的定位。一个微型 LLM 在 ESP32 上是如何逐步生成 token 的logits、softmax、temperature、top-k、熵这些概念分别起什么作用。Brainscope 这类工具的核心思路把模型每一步的内部状态采集出来、传出去、渲染出来。从零搭建了一套可以跑通的演示链路ESP32 模型包装类 WebSocket 传输 浏览器可视化面板。基于 Mock 实现验证了链路后续可以直接替换为真实模型。如果你是从零开始建议按这个顺序继续加深先用 Mock 把可视化链路跑通理解数据从哪来、到哪去。找一个 llama2.c 的 ESP32-S3 移植项目把训练好的 TinyStories 模型转换成适合烧录的格式跑出真实文本。尝试用 PlatformIO 替代 Arduino IDE通过menuconfig调整 PSRAM、Flash 分区和编译优化。研究量化流程把 FP32 权重转成 INT8记录量化前后的体积、速度和生成差异。最后可以试着把生成结果通过蓝牙串口或屏幕展示出来做一个脱离 PC 也能演示的完整小设备。在实际项目中最优先关注三个风险内存是否够用、生成延迟是否可接受、量化后的输出是否仍然满足业务需求。只要先把这三个问题量化清楚ESP32 上的 LLM 应用就成功了一大半。如果本文对你有帮助可以收藏备用。接下来动手烧录一块 ESP32-S3亲眼看一次单片机“思考”的过程远比只看文章更有收获。