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.
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
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.
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..."""
...
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.
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:
<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.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.
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
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.
"Memória" em um agente na verdade são pelo menos três coisas diferentes:
checkpointer
.thread_id
fixa. Troquei de
InMemorySaver
(que zera a cada reinício) para SqliteSaver
.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
.
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.
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.
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.
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
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.