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

> Source: <https://dev.to/lincoln_romais/construindo-um-assistente-pessoal-100-local-com-ollama-langchain-e-voz-e-as-armadilhas-que-df4>
> Published: 2026-07-17 17:05:46+00:00

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](https://ollama.com).

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](https://github.com/SYSTRAN/faster-whisper), a síntese de

voz via [Piper](https://github.com/OHF-Voice/piper1-gpl), 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](https://python.langchain.com) 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:

``` php
@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.

``` php
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](https://github.com/wiseman/py-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.*
