10 美元微控制器上的语言模型
一个从头训练的自定义 transformer 在 ESP32-S3 上解析英文硬件命令——1.2 MB 的权重,无 WiFi、无云端、无 API 密钥。
AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo
我训练了一个体积只有 1.2 MB、运行在 10 美元微控制器 上、完全不依赖任何网络的的语言模型。你在串口终端里输入 blink pin 4 every 5 seconds 3 times,一个 LED 就开始闪烁。没有任何数据离开这块板子——没有 WiFi、没有云端 API、甚至根本没有编译进任何网络协议栈。
1、系统
一个从头训练、把英文转换成 GPIO 命令的 transformer,完全运行在 ESP32-S3 微控制器上。没有 WiFi、没有云端、没有 API 密钥——没有编译进任何网络协议栈。你在串口终端里输入,引脚就会切换状态。
模型不输出 JSON。它输出一种符号文法:
turn on pin 4 -> <set> <pin> 4 <high> <end>
blink pin 4 every 500ms -> <blink> <pin> 4 <int> 5 0 0 <cnt> 0 <end>
chase pins 4 5 6 -> <seq> <pin> 4 <pin> 5 <pin> 6 <end>
一个 C 头文件把这些符号重新组装成结构体。没有会引错引号的字符串,没有会拼错的字段名,没有会漏掉的括号。格式错误的命令根本无法表示,而这部分从第一天起就完全按设计工作。
它是一个仅解码器(decoder-only)的 transformer,312,128 个参数:
vocab 1024 d_model 64 layers 6 heads 8 head_dim 8
ffn_hidden 128 (SwiGLU) seq_len 96 RoPE theta 10000
tied input embedding and output head fp32 no biases anywhere
每个块——一共六个——是 41,088 个参数:

再加上与输出头共享的 (1024, 64) = 65,536 的 token 嵌入,以及最后的归一化层。这就是从专用 flash 分区内存映射出来的 1,219 KB fp32 权重。KV 缓存——288 KB——位于 PSRAM 中,启动时一次性分配。推理路径中没有动态分配。
这些组件是现代小型模型的标准配置:RMSNorm、作用于 query 和 key 的 RoPE、因果多头注意力、SwiGLU 前馈层、pre-norm 残差块。没有什么奇特之处。有两个细节对后续内容很重要。
RoPE 采用 split-half 而非 interleaved 形式:向量被切成两半,按 [x1·cos − x2·sin, x2·cos + x1·sin] 旋转。interleaved 公式更常见且数值上不同,而 C 运行时必须逐操作复现 PyTorch 的行为,所以两边都固定使用同一约定。
数字是独立的 token。分词器在 ByteLevel 之前应用 Digits(individual_digits=True),因此任何合并都不可能跨越两个数字——300ms 变成 3 0 0 ms,绝不会是 30 0。十六个保留符号占据 id 0 到 15,所以模型发出的每个 token 要么是保留符号,要么是单个数字。
最后一个决定正是本文的全部主题,而第一个版本把它搞反了。
2、那个看起来显然正确的设计
第一版文法给每个在白名单内的引脚分配了自己的 token:<p4>、<p18>,一共二十五个。这块板子有二十五个可用的 GPIO 引脚,所以模型的词汇表恰好包含所有真实存在的引脚。
因此,非法引脚是无法表示的。模型说不出"pin 100",就像它说不出词汇表之外的任何词一样。这感觉像是一个真正的安全属性,而非便利之举——不是可能有 bug 的校验,而是一种结构性保证。
然后我在保留数据上对它进行了测量。
switch off pin 100 -> <set> <p10> <low> switches pin 10
name pin 35 as back door -> <alias> <p39> ... renames pin 39
blink pin 8 at 12000 ms -> <blink> <p8> 1200 runs at 1200 ms
一个说不出"pin 100"的模型不会拒绝。它会说出它能说的最接近的东西。
引脚 10 是板子上真实存在的引脚。命令顺利通过校验、通过硬件白名单,然后驱动了错误的硬件——悄无声息,日志里没有任何线索能把它和正确命令区分开。用户请求一个不存在的引脚,却得到了一个真实存在的引脚。
> An unrepresentable request does not produce a refusal. It produces a plausible substitute.
这种失败比错误更糟糕,因为错误会自我宣告。而它产生的是一个格式良好、合法、可执行的命令,做的是没有人要求的事。
3、为什么这具有普遍性
我不认为这是微控制器特有的问题,这也是我把它写出来而不是仅仅修掉它的原因。
受限解码(constrained decoding)、文法受限采样、JSON-schema 强制输出和函数调用 API 都共享同一种机制:它们限制模型被允许输出的内容,然后采样最有可能的允许 token。这种限制并不会教会模型某个请求是无效的。它只是把请求重定向到最近的合法输出。
如果你的 schema 有五个状态的枚举,而用户描述了第六种,模型会返回你五种之一。如果你的函数签名接受一个客户 ID,而用户说出一个不存在的客户,模型会返回一个存在的 ID。两种情况你都会收到语法完美的响应,却没有任何信号表明出错了。
一般形式是:如果你的输出空间无法表达"输入超出范围",你的模型就会表达某个范围内的东西,而在下游你永远看不到差别。
4、修复:先转录,再裁决
重新定义范围的做法是:移除那个保证,用一个真正可能失败的检查来替代它。
引脚号现在逐位输出——<pin> 1 0 0——这意味着模型可以说出"pin 100"。硬件层本来就持有板子的白名单,由它来执行拒绝。模型负责转录;硬件负责裁决。
同样的输入现在产生:
> switch off pin 100
refused: pin 100 is not a GPIO on this board (779ms)
数字之所以会出现在拒绝信息里,是因为模型能够说出它。这不是表面上的改进。这是"报告不匹配的系统"与"隐藏不匹配的系统"之间的区别。
同样的重构应用于每个数字槽位。间隔、重复次数和引脚列表都逐位转录,并由硬件层对照真实边界进行校验:
> blink pin 4 every 60 seconds
refused: interval 60000ms is outside 50-10000ms (1101ms)
5、三个 LED 找到了评估套件找不到的问题
同样的失败形态又出现了四次,出现在我没想到要检查的槽位里:

我是通过在引脚 4、5 和 6 上接上 LED,然后输入 turn on pin 4 5 and 6 发现第三个问题的。只有一个亮了。
它对项目中的每一个指标都不可见,而且必然如此。保留集是依据旧文法收集的,所以它不可能包含旧文法无法表达的语句。
> An evaluation derived from a grammar cannot find what that grammar cannot say.
这是一个具体且被低估的盲区。保留数据感觉像是一种独立的检查,但如果数据是用模型输出的同一套 schema 生成、采样或标注的,那么 schema 的局限也被烙进了评估本身。数据集和模型共享同一个假设,再多的保留数据也无法让它浮出水面。
在这里,硬件与评估集并非冗余。它覆盖了数据集结构性固有的盲区。三个 LED 和一块面包板测试了整条流水线在结构上根本无法测试的属性。
这四个 bug 各有不同的成因,却有相同的形态:set 接受一个目标,read 接受一个,blink 接受一个,stop 接受一个。四个独立的限制,四个独立的静默失败。修复是一条规则而非四个补丁——每个接受引脚的动作都接受一个引脚列表。

6、运行起来是什么样子
板子上一次真实的会话,包含计时:
> turn on pin 4
pin 4 high (515ms)
> turn on pin 4 and 5
pin 4, 5 high (947ms)
> blink pin 4 every 5 seconds 3 times
blinking 1 pin every 5000ms, 3 times (1155ms)
> chase pins 4 and 5 every 1 second
chasing 2 pins every 1000ms (1429ms)
> switch off pin 100
refused: pin 100 is not a GPIO on this board (779ms)
> turn on the desk lamp
refused: I don't understand that (359ms)
每条命令的延迟在 150 ms 到 1.5 s 之间,取决于需要生成多少个符号。那两条拒绝信息是设计在起作用,而不是模型在失败。
7、注意事项
模型没有达到自己的目标。精确匹配率是 91.7%,目标是 95%;误接受率是 4.4%,目标是 2%。这是一个可用的系统,而不是一个完成品,第 2 部分会讲剩余错误出现在哪里。
关于受限解码的普遍性推论是论证出来的,而不是测量出来的。我没有在带有 JSON schema 或 GBNF 文法的大型模型上做过等价实验。请把它当作一个有精心测量支持案例的假设。
命名和别名不在范围内。"Turn on the desk lamp" 按设计返回 <unknown>——目前还没有别名表,而构建一个别名表正是本文结论的直接复赛。
所有测量都在一块运行单一狭窄命令语言的板子上完成。
8、自己试试
仓库附带一个可烧录的单一镜像,包含引导加载程序、分区表、固件和模型,因此不需要工具链或训练运行:
pip install esptool
esptool.py --chip esp32s3 --port /dev/cu.usbmodemXXXX --baud 921600 \
write_flash 0x0 espcontrol-esp32s3-v0.2.1.bin
以 115200 打开串口监视器并输入。你需要一块带 PSRAM 的 ESP32-S3、一个 LED,以及在 GPIO 4 和地之间串联一个 220Ω 到 1kΩ 的电阻。
9、我学到的东西
限制模型的输出空间并不会让它变得安全。它只会让它变得沉默。逐引脚 token 的文法是整个项目中最自信的设计决策,而它把"我做不到"变成了"给你另一个引脚"。每一个结构化输出系统都继承了这一属性。
校验需要有可以拒绝的东西。位于一个无法表达非法值的模型下游的检查永远不会触发,而这看起来和正在通过的检查一模一样。给模型犯错的能力,给校验器可捕获的东西。
测试属性本身,而不是它的代理。由文法生成的评估共享该文法的盲区。三个 LED 找到了整条流水线在结构上无法发现的 bug。
模型仍然没有达到四个目标中的三个。但它运行在一块十美元的芯片上,没有网络,而且当你让它关掉一个不存在的引脚时,它会把你说的那个数字告诉你。
原文链接: A Language Model on a $10 Microcontroller: 312K Parameters Driving Real GPIO Pins, Fully Offline
汇智网翻译整理,转载请标明出处