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

Fellipe Gomes

9 minute read

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": o llama-server nã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 manter temperature=0 elimina 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!

comments powered by Disqus
Política de Privacidade | Termos de Uso