Este é o primeiro artigo da série Assistentes de IA de Bancada. Aqui a gente monta a ponte entre um microcontrolador de bancada e um LLM rodando na sua máquina. A pergunta que guia o texto é simples: o que faz sentido rodar no ESP32 e o que deve ficar no host? A resposta curta — tudo que é inferência fica no host; o MCU cuida de entrada, saída e presença física.
(a) Conceito e Arquitetura
O ESP32 nunca vai rodar um LLM de verdade. Um modelo quantizado de 3B parâmetros ainda exige gigabytes de RAM e uma GPU decente. O que o MCU faz bem é ser a interface física do modelo: captura áudio, desenha estado, lê botões e envia texto. A arquitetura tem três camadas:
- Edge (ESP32-S3): Wi-Fi, I2S para microfone/alto-falante, display e lógica de interação. Protagoniza a experiência.
- Host (PC/Raspberry Pi): o Ollama serve o modelo localmente e expõe uma API compatível com OpenAI em
http://localhost:11434/v1. - Proxy (opcional): um pequeno serviço FastAPI que normaliza requisições, faz streaming SSE e converte áudio em texto (STT) quando necessário.
O fluxo é: MCU grava o comando → host transcreve (ou recebe texto pronto) → Ollama gera a resposta → MCU recebe o texto final (ou tokens em streaming) e renderiza. O ponto crítico é que o estado da conversa vive no host; a placa é stateless. Isso simplifica OTA, permite trocar de modelo sem reflash e mantém o firmware pequeno.
(b) BOM
| Item | Modelo sugerido | Função | Faixa de preço (USD) |
|---|---|---|---|
| MCU | ESP32-S3-DevKitC-1 (N16R8) | WROOM-1 com 16 MB flash + 8 MB PSRAM | 8–14 |
| Display | GC9A01 1.28″ 240×240 SPI | Painel redondo para o “rosto” | 4–7 |
| Áudio | INMP441 (I2S MEMS) + MAX98357A | Microfone digital + amp classe D | 5–9 |
| Alto-falante | 4Ω 3W 40 mm | Saída de voz | 2–4 |
| Alimentação | Fonte 5V/2A + capacitor 470µF | Evitar brownout no Wi-Fi TX | 3–6 |
| Botão/Encoder | Push-button 12 mm + KY-040 | Push-to-talk e navegação | 1–3 |
| Extra | Cartão microSD (opcional) | Cache de áudio/prompts | 2–4 |
Total realista: 25 a 45 dólares por unidade, sem contar o host — que pode ser o notebook que você já tem.
(c) Wiring & Esquemático
O segredo é separar os barramentos: I2S em pinos dedicados, o display SPI em outro conjunto e nada de compartilhar clock entre eles. Um mapeamento que funciona no DevKitC-1:
- I2S microfone (INMP441): SCK=GPIO14, WS=GPIO15, SD=GPIO32, L/R→GND (canal esquerdo), VDD=3V3.
- I2S saída (MAX98357A): BCLK=GPIO26, LRC=GPIO25, DIN=GPIO22, SD→3V3.
- Display GC9A01: SCLK=GPIO12, MOSI=GPIO11, CS=GPIO10, DC=GPIO9, RST=GPIO8, BLK=GPIO7.
- Botão: GPIO0 com pull-up (cuidado: é o pino de boot — segure só depois do boot, ou use GPIO4).
Cuidados elétricos que evitam dor de cabeça:
- O pico de corrente do Wi-Fi (transmissão) chega a ~500 mA. Um capacitor de 470µF entre 5V e GND, perto da placa, evita reset aleatório.
- O MAX98357A puxa corrente pelo VIN; se alimentado em 3V3, a potência cai. Alimente em 5V e mantenha nível de sinal lógico — o chip é tolerante a 3V3.
- Use fios curtos (< 15 cm) no I2S. Sinais de clock longos geram ruído audível.
- Nunca ligue o pino RST do display em pull-up externo junto com o boot strap do S3 sem revisar o esquema — alguns módulos travam o boot.
(d) Código
Do lado do MCU, a ideia é: ler o prompt (via Serial ou STT), montar um JSON pequeno, fazer POST e parsear a resposta. Segue um esqueleto funcional em C++ (Arduino/PlatformIO) usando HTTPClient e ArduinoJson:
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
const char* SSID = "lab-iot";
const char* PASS = "senha-local";
const char* ENDPOINT = "http://192.168.0.42:11434/v1/chat/completions";
void setup() {
Serial.begin(115200);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) { delay(200); }
Serial.println("WiFi OK");
}
String askLLM(const String& prompt) {
HTTPClient http;
http.begin(ENDPOINT);
http.setTimeout(60000); // inferencia local pode demorar
http.addHeader("Content-Type", "application/json");
JsonDocument doc;
doc["model"] = "llama3.2:3b";
doc["stream"] = false;
JsonArray msgs = doc["messages"].to<JsonArray>();
JsonObject m = msgs.add<JsonObject>();
m["role"] = "user";
m["content"] = prompt;
String body;
serializeJson(doc, body);
int code = http.POST(body);
if (code != 200) { http.end(); return "ERRO HTTP " + String(code); }
String payload = http.getString();
http.end();
JsonDocument resp;
DeserializationError err = deserializeJson(resp, payload);
if (err) return "JSON invalido";
return resp["choices"][0]["message"]["content"].as<String>();
}
void loop() {
if (Serial.available()) {
String line = Serial.readStringUntil('\n');
String out = askLLM(line);
Serial.println(out);
}
}
Do lado do host, o Ollama já fala o dialeto OpenAI nativamente. Se você precisar de streaming (tokens chegando aos poucos) ou de transcrição de áudio, um proxy FastAPI resolve:
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx, json
app = FastAPI()
OLLAMA = "http://localhost:11434/api/chat"
@app.post("/v1/chat/completions")
async def proxy(req: Request):
body = await req.json()
payload = {
"model": body.get("model", "llama3.2:3b"),
"messages": body["messages"],
"stream": True,
}
async def gen():
async with httpx.AsyncClient(timeout=None) as c:
async with c.stream("POST", OLLAMA, json=payload) as r:
async for line in r.aiter_lines():
if not line:
continue
chunk = json.loads(line)
tok = chunk.get("message", {}).get("content", "")
if tok:
# formata como SSE estilo OpenAI
yield "data: " + json.dumps(
{"choices": [{"delta": {"content": tok}}]}
) + "\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
Com streaming, o ESP32-S3 parseia SSE linha a linha. Não monte um buffer gigante: processe cada data: assim que chegar e libere a linha. A placa tem 8 MB de PSRAM, mas a pilha HTTP é o gargalo real.
(e) Troubleshooting — latência, contexto e PSRAM
A primeira decepção de quem monta isso é a latência. Um modelo 3B em CPU leva de 1 a 4 segundos até o primeiro token; em GPU, algumas centenas de ms. Some o round-trip Wi-Fi (~30–80 ms) e o tempo de TTS. Resultado honesto: uma resposta falada em 5–8 s. Para conversa natural, pré-carregue o modelo (ollama run mantém em memória) e use keep_alive longo.
Contexto come tokens e RAM no host. Envie apenas o histórico relevante; corte janelas antigas. No MCU, o pior erro é tentar guardar o histórico inteiro em String — use nomes fixos e um único buffer rotativo.
PSRAM: habilite CONFIG_SPIRAM e, se usar o framework Arduino, ative PSRAM em qualquer alocação de framebuffer. Sem PSRAM, o heap interno (~320 KB) estoura ao abrir um socket TLS ou um buffer de áudio. Regra prática: buffers > 8 KB vão para PSRAM.
O que travar na placa vs. delegar ao host:
- Na placa: detecção de wake word, debounce de botão, render de animação, timeout de rede, buffer de áudio, TLS (se necessário).
- No host: inferência, histórico de conversa, embeddings, STT/TTS de alta qualidade, guardrails e roteamento de modelo.
Outros clássicos: brownout em TX Wi-Fi (capacitor + fonte boa), HTTPClient sem timeout (trava o loop) e JSON com caracteres escapados que quebram o parse — sempre serialize com biblioteca, nunca concatene strings na mão.
(f) Aplicações reais
- Painel de bancada do dev: pergunte à placa sobre logs, regex ou nomes de flags sem sair do terminal. O modelo roda offline e não vaza código para a nuvem.
- Terminal de laboratório: em bancadas com rede isolada, o LLM local responde dúvidas de pinagem, datasheet e fórmulas.
- Interface para acessibilidade: entrada por voz + saída no display para quem não usa teclado.
- Protótipo de produto: valide UX de um “assistente de mesa” antes de decidir sobre hardware custom e certificação.
- Base da série: nos próximos artigos, esta mesma arquitetura recebe display com LVGL, animações de estado e integração com o firmware open-source xiaozhi-esp32.
Privacidade é o argumento mais forte: nada sai da sua rede. Custo marginal zero por consulta. E o melhor — você aprende de verdade onde está o gargalo, porque ele aparece na sua frente em cada iteração.
Não some depois da matéria
Radar do hub: Núcleo Hits, Além do Oculto, Bobinho. Sem spray, sem lista comprada.
Radar do hub
Um e-mail quando sair matéria que importa — sem spray de newsletter genérica.

Deixe um comentário
Entre com Google ou Facebook para comentar. Nome e e-mail vêm da sua conta — sem preencher formulário.
Se o login falhar, configure Google e Facebook em Configurações → Nextend Social Login.
Outras opções de login