ESP32 como interface física de um LLM local rodando via Ollama

Ouvir

Carregando voz…

ESP32 como interface física de um LLM local rodando via Ollama

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:

  1. Edge (ESP32-S3): Wi-Fi, I2S para microfone/alto-falante, display e lógica de interação. Protagoniza a experiência.
  2. Host (PC/Raspberry Pi): o Ollama serve o modelo localmente e expõe uma API compatível com OpenAI em http://localhost:11434/v1.
  3. 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.