DEV Community

Lincoln Romais
Lincoln Romais

Posted on

Construindo um assistente pessoal 100% local com Ollama, LangChain e voz — e as armadilhas que ninguém conta

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 pequenos é frágil

Isso foi a maior fonte de dor de cabeça do projeto. Tool calling depende do
modelo ter sido treinado pra gerar uma estrutura específica (geralmente
JSON) num formato que o runtime (Ollama, no meu caso) sabe interpretar. Só
que:

  • Nem todo modelo/quantização tem o template de tool calling configurado corretamente — em um dos testes, 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.
  • Mesmo quando o tool calling técnico funciona, modelos pequenos (8B) são "preguiçosos" dentro do argumento de uma chamada de função. Pedi pra ele escrever um jogo da cobrinha em Python via uma tool save_code_file, e ele escreveu # código do jogo da cobrinha... como se fosse suficiente. O mesmo modelo, escrevendo código como texto solto numa resposta normal, escreveu o jogo inteiro e funcional.

A lição prática: não force tudo a passar por tool calling 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.

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:

  1. Contexto da conversa atual — as mensagens trocadas nesse loop específico. No LangGraph isso é gerenciado por um checkpointer.
  2. Memória de conversa persistente — o checkpoint sobrevivendo entre execuções do programa, associado a uma thread_id fixa. Troquei de InMemorySaver (que zera a cada reinício) para SqliteSaver.
  3. 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.

Top comments (0)