Assistentes de IA de Bancada — Parte 2: memória e contexto do assistente — histórico em NVS/littlefs, resumo de contexto enxuto e RAG leve sobre notas locais

Ouvir

Carregando voz…

Assistentes de IA de Bancada — Parte 2: memória e contexto do assistente — histórico em NVS/littlefs, resumo de contexto

(a) Conceito e Arquitetura

Se a Parte 1 desta série ensinou o assistente a falar (rede, TLS, JSON), esta parte ensina a lembrar. Um assistente de bancada sem memória repete perguntas, esquece o nome do projeto e trata cada turno como se fosse o primeiro. Em um microcontrolador, “memória” não é uma coisa só: é uma hierarquia de camadas com custo e velocidade diferentes.

A camada mais rápida é a RAM, onde vive a janela de contexto ativa — pequena e volátil. Abaixo dela, o NVS (Non-Volatile Storage) guarda pares chave-valor persistentes: configurações, identificadores e um histórico curto de conversa. O littlefs é o sistema de arquivos em flash, ideal para logs maiores, notas e material que alimenta o RAG. E, no topo, a nuvem guarda o modelo. O ESP32 é o bibliotecário que decide o que sobe de camada a cada chamada.

O conceito central é orçamento de contexto. Cada token enviado custa dinheiro e latência, então o histórico não pode crescer indefinidamente. A técnica padrão é uma janela deslizante (mantém os últimos K turnos) combinada com um resumo enxuto dos turnos antigos. Sobre isso, plugamos uma camada leve de RAG: em vez de um banco vetorial, o MCU busca palavras-chave nas notas locais e injeta os trechos relevantes no prompt.

Vale quantificar para não trabalhar no escuro. Em modelos com janela de 128 k tokens, um assistente de bancada raramente precisa passar de 1 k a 2 k tokens por chamada: algumas centenas para o system prompt, o resumo em 1–2 frases, os últimos seis a oito turnos e três trechos de nota. Esse teto mantém a latência previsível e evita que uma conversa longa vire uma fatura surpresa. A regra de ouro é: o MCU decide o que entra na janela, nunca o modelo.

(b) BOM e camadas de armazenamento

Nenhum hardware novo é necessário em relação à Parte 1 — o mesmo ESP32-S3 (N16R8) com display e áudio serve. O que muda é o uso da flash interna.

Camada Chave / caminho Uso Observação
RAM buffer de contexto Janela ativa do prompt Volátil; limitada por heap/PSRAM
NVS namespace chat, chaves t0..t19 Histórico curto (ring de 20 turnos) Wear leveling interno do NVS
NVS namespace cfg API keys, modelo, preferências Nunca em texto puro em produto
littlefs /littlefs/notes/*.txt Notas locais para o RAG Leitura por streaming
littlefs /littlefs/log/ Logs técnicos e telemetria Rotacionar para não esgotar flash

(c) Flash e particionamento

Antes do código, é preciso reservar espaço na flash. Uma tabela de partições típica para esse assistente com 16 MB fica assim (recorte de partitions.csv):

Nome Tipo Tamanho Função
nvs data/nvs 0x6000 Config e histórico curto
otadata data/ota 0x2000 Seleção de slot OTA
phy_init data/phy 0x1000 Calibração de RF
factory app/factory 0x400000 Firmware principal
storage data/littlefs 0x100000 Notas e logs (littlefs)

Ampliar storage permite mais notas para o RAG; ampliar nvs permite histórico maior. Lembre que a NVS já faz wear leveling internamente, então gravações frequentes de turno são aceitáveis — desde que você evite reescrever a flash a cada token.

(d) Código — NVS, littlefs, resumo de contexto, RAG e montagem do prompt

Começamos pelo histórico curto em NVS, usando um anel de 20 chaves para não crescer sem limite.

#include "nvs_flash.h"
#include "nvs.h"

#define NS "chat"

// Grava o histórico curto como um blob/string JSON por turno (ring de N itens)
esp_err_t hist_put(int turno, const char *json_turno) {
    nvs_handle_t h;
    esp_err_t err = nvs_open(NS, NVS_READWRITE, &h);
    if (err != ESP_OK) return err;
    char key[16];
    snprintf(key, sizeof(key), "t%d", turno % 20); // ate 20 turnos
    err = nvs_set_str(h, key, json_turno);
    if (err == ESP_OK) err = nvs_commit(h);
    nvs_close(h);
    return err;
}

// Le um turno; retorna ESP_ERR_NVS_NOT_FOUND se nao existir
esp_err_t hist_get(int turno, char *out, size_t *len) {
    nvs_handle_t h;
    esp_err_t err = nvs_open(NS, NVS_READONLY, &h);
    if (err != ESP_OK) return err;
    char key[16];
    snprintf(key, sizeof(key), "t%d", turno % 20);
    err = nvs_get_str(h, key, out, len);
    nvs_close(h);
    return err;
}

As notas que alimentam o RAG moram no littlefs, montado a partir da partição storage:

#include "esp_littlefs.h"

void monta_fs(void) {
    esp_vfs_littlefs_conf_t conf = {};
    conf.base_path = "/littlefs";
    conf.partition_label = "storage";
    conf.format_if_mount_failed = true;
    esp_vfs_littlefs_register(&conf);
}

// Guarda uma nota de bancada para o RAG local
void nota_salvar(const char *titulo, const char *texto) {
    char path[96];
    snprintf(path, sizeof(path), "/littlefs/notes/%s.txt", titulo);
    FILE *f = fopen(path, "w");
    if (f) { fputs(texto, f); fclose(f); }
}

Para montar o prompt, combinamos três pedaços: um resumo curto dos turnos antigos, os últimos turnos literais e os trechos trazidos pelo RAG. Um único snprintf monta o JSON final sem fragmentar a heap:

// Monta o prompt com contexto: system + resumo + ultimos K turnos + RAG
void monta_prompt(char *out, size_t out_sz,
                  const char *system, const char *resumo,
                  const char *rag, const char *user) {
    snprintf(out, out_sz,
        "{\"messages\":["
        "{\"role\":\"system\",\"content\":\"%s%s%s\"},"
        "{\"role\":\"user\",\"content\":\"%s\"}]}",
        system,
        (resumo && resumo[0]) ? " Contexto anterior: " : "",
        (rag && rag[0]) ? rag : "",
        user);
}

O RAG local é deliberadamente simples: sem embeddings nem banco vetorial no MCU. A busca por termos nas notas já resolve a maioria dos casos de bancada e cabe em poucos KB de código:

// RAG leve: busca por termos (sem embeddings) nas notas locais
int rag_buscar(const char *consulta, char *out, size_t out_sz) {
    const char *dir = "/littlefs/notes";
    DIR *d = opendir(dir);
    if (!d) return 0;
    struct dirent *e;
    int achados = 0;
    out[0] = 0;
    while ((e = readdir(d)) && achados < 3) {
        char path[160];
        snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
        FILE *f = fopen(path, "r");
        if (!f) continue;
        char buf[512];
        size_t n = fread(buf, 1, sizeof(buf) - 1, f);
        buf[n] = 0;
        fclose(f);
        if (strcasestr(buf, consulta)) {           // match simples
            strncat(out, buf, out_sz - strlen(out) - 1);
            achados++;
        }
    }
    closedir(d);
    return achados;
}

O truque para o resumo é periódico: a cada N turnos, o próprio modelo recebe os turnos antigos e devolve 1–2 frases de resumo, que substituem os originais no envio seguinte. Assim o contexto enviado fica estável em tamanho, com custo previsível. Como o resumo também é um texto que vai e volta pela rede, ele deve ser curto por design — duas frases no máximo. Guarde-o na NVS sob a chave resumo para sobreviver a reinicializações, e invalide-o quando o usuário mudar de assunto de forma explícita, evitando que um resumo obsoleto contamine as próximas respostas.

(e) Troubleshooting

  • NVS cheia: partição pequena demais gera ESP_ERR_NVS_NO_FREE_PAGES. Aumente a partição nvs ou use um anel menor de chaves.
  • Corrupção da NVS: apagões durante a escrita podem deixar a NVS inválida. Trate o erro na inicialização com nvs_flash_erase() + reinit, e limpe a chave com nvs_erase_key.
  • littlefs não monta: verifique o rótulo da partição e use format_if_mount_failed apenas em desenvolvimento; em produção, prefira recuperar os dados.
  • Contexto grande demais: sintomas são latência alta, custo explodindo e estouro de buffer. Trunque por turno, resuma com mais frequência e limite o RAG a um top-3.
  • Corte de UTF-8 no meio: truncar por bytes pode partir um caractere acentuado. Corte sempre em fronteira de caractere e valide antes de enviar.

(f) Aplicações reais

Com memória e contexto, o assistente deixa de ser um oráculo sem estado e passa a ser um companheiro de bancada: ele lembra que o projeto atual usa um sensor BMP280, recorda o estilo de código preferido e retoma o assunto de ontem. Em aplicações reais, isso viabiliza um diário técnico por voz que grava notas no littlefs e depois as consulta; um tutor de bancada que busca trechos de datasheets locais via RAG antes de responder; e um painel doméstico que mantém preferências de cada morador. Combinada com o 78/xiaozhi-esp32 como firmware de referência e um proxy ollama/ollama para reduzir custo, a arquitetura vira uma base sólida para assistentes embarcados persistentes.

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.