Construindo um JARVIS local: agentes, voz e as armadilhas dos LLMs pequenos
Nos últimos dias montei um assistente pessoal do zero rodando 100% na minha
máquina — sem chamadas pra API paga, sem dados saindo do notebook. Ele
pesquisa na internet, escreve e salva código, entende voz, responde falando,
tem memória de longo prazo e roda um LLM local via Ollama.
O resultado funciona bem, mas o caminho até aqui expôs um monte de conceitos
que todo mundo que trabalha com LLMs locais/agentes acaba esbarrando —
principalmente a diferença brutal entre “um LLM de 70B+ na nuvem” e “um LLM
de 8-14B rodando numa GPU de laptop”. Esse artigo é sobre esses conceitos.
A arquitetura, em linhas gerais
Voz do usuário
│
▼
VAD (detecção de fala) ──► Whisper (fala → texto)
│
▼
Agente LangChain/LangGraph ──► Ollama (LLM local)
│ │
│ ├─► Tools: busca web, clima, memória...
▼ │
Piper (texto → fala) ◄─────────────┘
│
▼
Áudio de resposta
Enter fullscreen mode Exit fullscreen mode
Tudo roda localmente: o LLM via Ollama, a transcrição via
faster-whisper, a síntese de voz via Piper, e um SQLite guardando memória. Nada trafega pra fora da máquina, exceto as buscas na web em si.
Conceito 1: agentes são só “LLM + loop + ferramentas”
Um “agente” de IA não é magia — é um LLM rodando dentro de um loop que pode,
a cada passo, decidir entre responder em texto ou chamar uma função
(“tool”), observar o resultado, e decidir de novo. O
LangChain 1.0 formaliza isso com
create_agent, construído em cima do LangGraph, que modela esse loop como
uma máquina de estados.
Cada “tool” é só uma função Python decorada, com uma docstring que vira a
descrição que o modelo lê pra decidir quando usá-la:
@tool
def get_weather(city: str) -> str:
"""Retorna o clima atual de uma cidade..."""
...
Enter fullscreen mode Exit fullscreen mode
O modelo nunca executa a função diretamente — ele gera uma estrutura
(“tool call”) dizendo “quero chamar get_weather com city='São Paulo'“, o
framework executa de verdade e devolve o resultado pro modelo continuar.
Conceito 2: tool calling em modelos locais é frágil — mas o problema é o encanamento, não a qualidade do modelo
Isso foi a maior fonte de dor de cabeça do projeto, e vale separar dois
problemas que parecem iguais mas não são.
Problema A — o template de tool calling do runtime. Tool calling
depende do modelo ter sido treinado pra gerar uma estrutura específica
(geralmente JSON), e do runtime (Ollama, no meu caso) saber interpretar
essa estrutura de volta. Num dos testes com qwen2.5:14b, o modelo
simplesmente imprimia o texto <tool_call>{"name": "web_search", ...}</tool_call>
como se fosse a resposta final, em vez de o runtime interceptar isso como
uma chamada de função de verdade — um problema de parsing/template, não
de capacidade do modelo. O mesmo qwen2.5:14b, aliás, foi o que escreveu o
jogo da cobrinha mais completo e o relatório de pesquisa mais bem escrito
do projeto inteiro, sempre que o texto não precisava passar por esse
encanamento quebrado.
Problema B — preguiça dentro do argumento de uma tool call. Esse foi
mais visível com llama3.1:8b (cujo tool calling funcionava
tecnicamente): pedi pra ele escrever um jogo da cobrinha via uma tool
save_code_file, e ele escreveu # código do jogo da cobrinha... como se
fosse suficiente. O mesmo modelo, escrevendo o código como texto solto numa
resposta normal (sem precisar empacotar num campo JSON), escreveu o jogo
inteiro e funcional.
Os dois casos apontam pra mesma lição, por caminhos diferentes: o
gargalo geralmente não é o modelo “não saber” escrever a coisa certa — é a
camada de tool calling entre o modelo e o resultado final (seja porque o
runtime não interpreta certo, seja porque o modelo se comporta diferente
dentro de um argumento estruturado). Pra geração de conteúdo longo (código,
relatórios), deixar o modelo escrever naturalmente e fazer o parsing do
resultado depois — com um comentário convencional tipo # arquivo: nome.py
que o próprio código extrai via regex — foi muito mais confiável do que
depender do modelo preencher um campo JSON com cuidado, independente de
qual dos dois modelos eu estava usando.
Conceito 3: quando não confiar na decisão do modelo, decida por ele
Outro padrão que precisei adotar: pedir “pesquise sobre X e salve um
resumo” fazia o modelo, em vez de pesquisar de verdade, escrever um script
que abriria o Google depois — tecnicamente relacionado ao pedido, mas inútil
na prática.
A correção não foi “melhorar o prompt” (tentei, várias vezes). Foi tirar a
decisão das mãos do modelo: detectar esse padrão de pedido por palavras-chave
e executar a busca antes de chamar o LLM, injetando os resultados reais
já prontos no contexto. O modelo só precisa sintetizar o que já está na mão
dele — não decide mais se pesquisa ou não.
def is_research_and_save_request(user_text: str) -> bool:
lower = user_text.lower()
has_research = any(w in lower for w in _RESEARCH_WORDS)
has_save = any(w in lower for w in _SAVE_WORDS)
return has_research and has_save
Enter fullscreen mode Exit fullscreen mode
Isso é um lembrete geral trabalhando com LLMs: quanto mais determinística
uma decisão pode ser feita fora do modelo, mais confiável o sistema fica.
Delegar tudo pro “raciocínio” do modelo é elegante, mas modelos pequenos
simplesmente erram decisões que pareceriam óbvias.
Conceito 4: memória de agente não é uma coisa só
“Memória” em um agente na verdade são pelo menos três coisas diferentes:
-
Contexto da conversa atual — as mensagens trocadas nesse loop
específico. No LangGraph isso é gerenciado por um
checkpointer. -
Memória de conversa persistente — o checkpoint sobrevivendo entre
execuções do programa, associado a uma
thread_idfixa. Troquei deInMemorySaver(que zera a cada reinício) paraSqliteSaver. -
Memória de longo prazo — fatos que fazem sentido lembrar
independentemente de qual conversa está rolando (nome, preferências).
Isso não é responsabilidade do checkpointer — implementei como uma tabela
SQLite separada, com tools próprias (
remember_fact/recall_facts) que o modelo aciona quando julga relevante.
Um detalhe que pegou muita gente de surpresa (inclusive eu): memória de
conversa persistente cresce sem limite. Depois de várias interações, o
histórico inteiro sendo reenviado a cada turno começou a consumir o espaço
de contexto disponível, cortando respostas longas (como código) no meio.
A solução é resumir automaticamente o histórico antigo quando ele passa de
um limite de tokens, mantendo as mensagens recentes intactas — no LangChain
1.0 isso já vem pronto como SummarizationMiddleware.
Conceito 5: VAD não é “medir volume”
Pra detectar quando alguém está falando (e não gravar silêncio pra sempre),
a abordagem ingênua é medir a amplitude do áudio e comparar com um
threshold. Funciona mal — ruído de fundo, cliques do teclado e a respiração
já bastam pra confundir.
VAD de verdade (usei webrtcvad,
originalmente do WebRTC do Google) analisa o espectro do áudio em janelas
curtas (10-30ms) pra decidir se aquilo tem características de fala humana,
não só volume. É ordens de magnitude mais confiável.
Conceito 6: barge-in esbarra em física, não em código
Queria que o assistente pudesse ser interrompido no meio da fala — falar
por cima dele deveria pará-lo. A implementação em si é simples: uma thread
monitorando o microfone com VAD enquanto o áudio da resposta toca, e um
evento compartilhado que cancela a reprodução.
Só que num laptop sem fone de ouvido, o alto-falante e o microfone estão a
poucos centímetros um do outro — a própria voz sintetizada do assistente
“vaza” pro microfone, o VAD interpreta isso como o usuário falando, e ele se
autointerrompe imediatamente. Isso é o problema clássico de eco acústico
que qualquer sistema de videoconferência precisa resolver com cancelamento
de eco (AEC) — e não existe solução puramente em nível de VAD pra isso. A
saída prática foi deixar o barge-in desligado por padrão, funcional só com
fone de ouvido.
Conceito 7: prompting não substitui ancoragem factual
Um erro recorrente no início do projeto: o modelo inventava a data atual, e
até “explicações” plausíveis pra números que ele mesmo não sabia (tipo
justificar a variação de uma cotação sem ter buscado o motivo). LLMs não têm
noção de tempo real — a “data” que eles “sabem” é qualquer coisa do período
de treinamento.
A correção foi sempre injetar a data/hora real do sistema operacional em
todo turno, como parte do contexto, deixando explícito que essa informação
sobrepõe qualquer suposição do modelo. Combinado com a regra explícita de
“nunca invente uma explicação que não veio literalmente da busca”, reduziu
bastante (não elimina 100%) a alucinação.
O que ficou de lição
Trabalhar com LLMs locais pequenos é um exercício constante de não confiar
demais no modelo. As técnicas que funcionam bem com modelos de fronteira
na nuvem (delegar decisões complexas via prompt, confiar no tool calling
pra tudo) precisam de reforços de engenharia quando o modelo tem 8-14
bilhões de parâmetros rodando quantizado numa GPU de notebook: detecção
determinística de padrões de pedido, extração de conteúdo por regex como
rede de segurança, resumo automático de contexto, e ancoragem factual
explícita.
Não é menos interessante por isso — é, na real, onde a engenharia em cima
do modelo importa mais.
O projeto completo (agente, pipeline de voz, memória persistente) está
disponível no meu repositório — comenta aí se quiser que eu detalhe alguma
parte específica num próximo post.
답글 남기기