Como criar um agente com LLM local de graça e sem depender de APIs
Neste post vamos rodar o modelo Qwen2.5 completamente offline em um Mac de uso pessoal, expô-lo como uma API OpenAI-compatible localmente e construir um agente com LangChain usando tools e saída estruturada sem gastar um centavo
POR QUE RODAR LLMs LOCALMENTE?
Nos últimos anos, o mundo dos modelos de linguagem de grande porte (LLMs) deixou de ser um território exclusivo das gigantes americanas. Uma leva de modelos open-weight de origem chinesa como o Qwen (Alibaba), o DeepSeek e o Yi chegaram com qualidade surpreendente e, mais importante, com pesos disponíveis publicamente. Li sobre alguns deles em um post que apareceu no meu LinkedIn e fiquei curioso para testar: será que dá para rodar um desses modelos localmente no meu Mac e ainda plugar ele num agente LangChain funcional?
Mas porque alguém escolheria rodar um LLM localmente ao invés de simplesmente chamar uma API? A resposta vai depender da necessidade do usuário:
- Privacidade de dados: nenhum token sai da sua máquina. Para dados sensíveis como contratos, código proprietário, prontuários e isso pode ser um requisito não negociável.
- Custo zero de inferência: sem cobrança por token, sem surpresas no cartão de crédito no fim do mês.
- Funcionamento offline: avião, servidor sem acesso externo, ambiente air-gapped. O modelo simplesmente roda.
- Controle total: você decide qual versão do modelo usa, quando atualiza, como configura.
Claro que esse caminho tem seus trade-offs. Hardware limitado significa que modelos maiores (70B+) estão fora de alcance sem uma GPU dedicada. A quantização (processo de reduzir a precisão dos pesos de 32 bits para 4 ou 8 bits) é o que torna possível rodar modelos de bilhões de parâmetros em hardware consumer, mas com alguma perda de qualidade e latência, que costuma ser maior do que a de uma API otimizada em data center. Mas para prototipagem, estudos e contextos onde privacidade importa, essa equação ainda faz muito sentido.
📌 Em posts anteriores usei o GPT-4o-mini via API da OpenAI para construir agentes. Aqui vamos por um caminho diferente: zero API externa, zero custo, tudo rodando localmente.
O ECOSSISTEMA: QWEN2.5, GGUF E LLAMA.CPP
O modelo: Qwen2.5
O Qwen2.5 é uma família de modelos desenvolvida pela Alibaba, disponível em múltiplos tamanhos (0.5B até 72B parâmetros) e com versões especializadas para código, matemática e visão computacional. Para este projeto, escolhi o Qwen2.5-4B-Instruct que tem um ponto de equilíbrio razoável entre capacidade e consumo de memória para um MacBook caseiro com Apple Silicon.
O formato: GGUF
O GGUF é o formato padrão para rodar modelos quantizados via llama.cpp. Ele empacota os pesos do modelo junto com os metadados necessários para inferência em um único arquivo portátil. A escolha da variante de quantização define o trade-off entre tamanho e qualidade:
| Variante | Precisão | Tamanho (4B) | Qualidade |
|---|---|---|---|
q4_k_m |
4 bits | ~2.5 GB | boa |
q5_k_m |
5 bits | ~3.1 GB | melhor |
q8_0 |
8 bits | ~4.5 GB | próxima do original |
Para este experimento usei q4_k_m, que cabe confortavelmente na memória unificada do M1 e ainda entrega respostas coesas.
A infraestrutura: llama.cpp + llama-server
O llama.cpp é uma biblioteca de inferência em C++ otimizada para rodar LLMs em hardware commodity, incluindo suporte a aceleração via Metal no Apple Silicon. Um setup_environment.sh do projeto já automatiza toda a configuração:
# Instalar llama.cpp via Homebrew
# (com suporte a Metal para Apple Silicon)
brew install llama.cpp
# Instalar dependências Python (versões pinadas no requirements.txt)
pip install -r requirements.txt
# Baixar o modelo do Hugging Face
huggingface-cli download Qwen/Qwen2.5-4B-Instruct-GGUF \
qwen2.5-4b-instruct-q4_k_m.gguf \
--local-dir ~/.hf_cache/
O requirements.txt do repositório já traz as versões exatas utilizadas neste projeto:
langchain==1.2.10
langchain_openai==1.1.10
langchain-core==1.2.13
langchain-community==0.4.1
langgraph==1.0.8
llama-cpp-python==0.3.16
huggingface_hub==1.4.1
pydantic==2.12.5
Com o modelo baixado, subir o servidor é uma única linha:
# Subir um servidor local com interface compatível com a API OpenAI
llama-server \
-m ~/.hf_cache/qwen2.5-4b-instruct-q4_k_m.gguf \
--port 8000 \
-ngl 1
A flag -ngl 1 ativa o offload de uma camada para a GPU via Metal, nesse caso suficiente para nosso teste aproveitar a aceleração do M1 sem saturar a memória (o usual é ngl -99 ou ngl-1 para usar todas as camadas). A partir daí, o servidor expõe um endpoint REST em http://localhost:8000/v1 com a mesma interface da API da OpenAI. Esse detalhe é o que torna tudo o que vem a seguir possível.
CONECTANDO AO LANGCHAIN VIA API OPENAI-COMPATIBLE
O LangChain tem uma abstração chamada ChatOpenAI que, por padrão, aponta para os servidores da OpenAI, mas aceita uma openai_api_base customizada. Basta trocar a URL base e o ecossistema inteiro funciona sem alteração nenhuma:
from langchain_openai import ChatOpenAI
model = ChatOpenAI(
model="qwen2.5-4b-instruct",
openai_api_base="http://localhost:8000/v1",
openai_api_key="not-needed",
temperature=0,
)
Duas observações sobre esta configuração:
openai_api_key="not-needed": ollama-servernão implementa autenticação, então qualquer string serve. O campo apenas precisa estar preenchido para satisfazer a validação do cliente.temperature=0: tecnicamente, determinismo total em LLMs envolve outros fatores (como seed e top_k), mas mantertemperature=0elimina a amostragem aleatória e aproxima o modelo de uma resposta “mais racional” ou “menos criativa”.
DEFININDO FERRAMENTAS COM @tool
A Anthropic publicou um guia sobre construção de agentes eficazes em 2024 que eu acho uma das melhores referências práticas disponíveis. A definição deles é direta ao ponto: agentes são sistemas onde o LLM dirige dinamicamente o próprio fluxo de execução, escolhendo quais ações tomar e em que ordem e é exatamente o que vamos construir aqui.
O mecanismo central é o Tool Calling: o LLM recebe uma lista de funções disponíveis (com nomes, parâmetros e descrições) e decide, a cada passo, qual chamar e com quais argumentos. O Python executa a função e devolve o resultado para o LLM, que então decide o próximo passo. Esse ciclo continua até o objetivo ser atingido.
No LangChain, o decorator @tool transforma qualquer função Python em uma ferramenta que o LLM pode invocar. O ponto crítico são os docstrings: é a partir deles que o modelo entende para que serve cada função. Uma descrição ruim leva a chamadas erradas ou desnecessárias.
import math
from langchain.tools import tool
@tool
def multiply(a: float, b: float) -> float:
"""Multiplica dois números."""
return a * b
@tool
def add(a: float, b: float) -> float:
"""Soma dois números."""
return a + b
@tool
def divide(a: float, b: float) -> float:
"""Divide dois números."""
return a / b
@tool
def sqrt(x: float) -> float:
"""Calcula a raiz quadrada de um número."""
return math.sqrt(x)
@tool
def power(base: float, exponent: float) -> float:
"""Eleva um número à potência especificada."""
return math.pow(base, exponent)
@tool
def absolute(x: float) -> float:
"""Calcula o valor absoluto de um número."""
return abs(x)
@tool
def round_number(x: float, digits: int = 0) -> float:
"""Arredonda um número para o número especificado de casas decimais."""
return round(x, digits)
tools = [add, multiply, divide, sqrt, power, absolute, round_number]
As operações matemáticas são deliberadamente simples, mas o ponto aqui não é o problema em si, mas demonstrar o padrão arquitetural. Basta substituir sqrt por buscar_dados_de_mercado ou absolute por consultar_base_de_conhecimento e a lógica é exatamente a mesma.
Outro elemento importante é o system_prompt. Aqui ele tem um papel muito específico: proibir explicitamente que o LLM calcule diretamente, forçando toda aritmética a passar pelas tools Python. Isso garante precisão computacional e auditabilidade enquanto cada operação tem uma entrada e saída rastreável.
You are a strict mathematical agent.
- Respect parentheses.
- Follow PEMDAS.
- Use tools for ALL computations.
- Never compute directly.
- Return structured output only.
Essa separação entre raciocínio (responsabilidade do LLM) e execução (responsabilidade do Python) é uma das ideias centrais no design de agentes robustos.
SAÍDA ESTRUTURADA COM PYDANTIC
Por padrão, LLMs retornam texto livre. Em pipelines de produção isso pode ser um problema: difícil de parsear, inconsistente entre execuções, incompatível com sistemas downstream.
O LangChain suporta structured output via Pydantic: você define um schema e passa via response_format. O modelo é instruído a produzir exatamente aquele formato:
from pydantic import BaseModel, Field
class MathResult(BaseModel):
result: float = Field(..., description="Final numerical result")
O schema aqui é simples com um único campo float. Mas o padrão é o mesmo que usei no post sobre análise de ligações de telemarketing, onde o schema RelatorioLigacao tinha dezenas de campos aninhados (sentimento, tipo de ligação, resultado, respostas criativas). A lógica é idêntica; a complexidade do schema depende do problema.
CRIANDO E EXECUTANDO O AGENTE COM STREAMING
Com todas as peças prontas, a montagem do agente é direta:
from langchain.agents import create_agent
agent = create_agent(
model=model,
tools=tools,
system_prompt="""
You are a strict mathematical agent.
- Respect parentheses.
- Follow PEMDAS.
- Use tools for ALL computations.
- Never compute directly.
- Return structured output only.
""",
response_format=MathResult,
)
Para executar, uso agent.stream() com stream_mode="updates". Essa configuração expõe cada etapa do ciclo de raciocínio como um evento separado sendo uma forma de observabilidade nativa que dispensa ferramentas extras como o LangSmith para entender o que o agente está fazendo:
input_payload = {
"messages": [
{
"role": "user",
"content": "Calcule: (sqrt(16) + pow(2,3)) / abs(-4)"
}
]
}
final_result = None
for event in agent.stream(input_payload, stream_mode="updates"):
if "model" in event:
model_step = event["model"]
for message in model_step.get("messages", []):
if message.tool_calls:
for call in message.tool_calls:
print(f"[MODEL → TOOL CALL] {call['name']} args={call['args']}")
if "tools" in event:
tool_step = event["tools"]
for message in tool_step.get("messages", []):
print(f"[TOOL RESULT] {message.content}")
if "structured_response" in event:
final_result = event["structured_response"].result
print("\nFinal Result:", final_result)
O loop distingue três tipos de evento:
"model": o LLM tomou uma decisão e quer chamar uma tool."tools": a tool foi executada e devolveu um resultado."structured_response": o agente encerrou o ciclo e emitiu a resposta final tipada.
Para a expressão (sqrt(16) + pow(2,3)) / abs(-4), o agente resolve em 6 chamadas sequenciais, respeitando a ordem de precedência:
[MODEL → TOOL CALL] sqrt args={'x': 16}
[TOOL RESULT] 4.0
[MODEL → TOOL CALL] power args={'base': 2, 'exponent': 3}
[TOOL RESULT] 8.0
[MODEL → TOOL CALL] add args={'a': 4.0, 'b': 8.0}
[TOOL RESULT] 12.0
[MODEL → TOOL CALL] absolute args={'x': -4}
[TOOL RESULT] 4.0
[MODEL → TOOL CALL] divide args={'a': 12.0, 'b': 4.0}
[TOOL RESULT] 3.0
[MODEL → TOOL CALL] round_number args={'x': 3.0, 'digits': 2}
[TOOL RESULT] 3.0
Final Result: 3.0
O resultado \(\frac{\sqrt{16} + 2^3}{|-4|} = \frac{4 + 8}{4} = 3.0\) está correto, e cada etapa do raciocínio é rastreável. Isso é o que torna esse padrão valioso: não apenas o resultado final, mas a cadeia de decisões que levou até ele.
CONCLUSÃO
O que começou como curiosidade depois de ler sobre modelos se transformou em uma stack que pode ser replicável sem necessidade de super máquinas. A combinação llama.cpp + GGUF + API OpenAI-compatible fornece um acesso a LLMs locais de um jeito que, há dois anos, seria impensável em hardware consumer.
Dá para construir um agente funcional, auditável e com saída tipada sem pagar nada, sem enviar dados para terceiros e sem depender de conexão com a internet. Para cenários onde privacidade e custo são requisitos (e isso inclui boa parte dos projetos corporativos reais) essa stack passou de curiosidade para uma opção viável. Mesmo que a empresa forneça licença para APIs pagas, bastaria mudar o modelo que toda stack continua funcional.
O agente matemático é só um exemplo, mas o padrão LLM local + tools Python + structured output + streaming observável é a estrutura que pode ser usada em outros contextos como ferramentas de acesso a APIs internas, consulta a bases de conhecimento privadas, processamento de documentos sensíveis.
Sobre o autor
Me chamo Fellipe Gomes, sou formado em estatística e atuo como cientista de dados desde 2018. Compartilho meus estudos e evolução por meio de artigos, tutoriais e projetos de código aberto. Se quiser saber mais sobre meu trabalho, sinta-se à vontade para entrar em contato através das minhas redes sociais LinkedIn, GitHub e Kaggle.
Gostou do conteúdo? Compartilhe e deixe suas dúvidas nos comentários. Sua experiência e feedback são fundamentais para continuar criando conteúdo de qualidade!

Share this post
Twitter
LinkedIn
Email