Rodando LLM local: quando fazer self-hosting
1. Visão geral
Chamar a API de um provedor de LLM na nuvem é a maneira mais rápida de trazer IA para seu aplicativo. Cada vez mais equipes querem executar modelos em sua própria infraestrutura, que pode ser um laptop, servidor interno ou GPU alugada.
Neste artigo, vamos explorar o que significa realmente “executar LLM localmente” e por que essa tendência está gerando interesse. Depois você vai configurar um modelo local, chamá-lo de um aplicativo Spring Boot e escrever testes para a integração. No final do artigo, há critérios para decidir quando fazer self-hosting e quando não.
2. O que significa “executar LLM localmente”?
2.1. Três componentes básicos
Um sistema de LLM auto-hospedado normalmente possui três camadas:
- Model weights: arquivo com os pesos do modelo. Você geralmente baixa modelos open-weight de repositórios públicos.
- Inference runtime: software que carrega os pesos na RAM ou VRAM e gera tokens. Exemplos são Ollama, llama.cpp ou vLLM.
- API layer: um endpoint HTTP para que o aplicativo chame. Muitos runtimes fornecem uma API compatível com o padrão OpenAI, então o código do lado do cliente quase não precisa mudar.
Seu aplicativo só vê a terceira camada. Esse é um ponto importante para permitir que você alterne entre local e nuvem sem ter que reescrever a lógica de negócio.
2.2. Quantização e memória
O fator decisivo para “minha máquina consegue executar isso?” é a memória. Você pode fazer uma estimativa áspera da memória para os pesos da seguinte forma:
Memória de weights ≈ número de parâmetros × bytes por parâmetro
16-bit (FP16): 2 bytes / parâmetro
8-bit: 1 byte / parâmetro
4-bit: ~0,5 bytes / parâmetro
Além dos pesos, você precisa de espaço para o KV cache. Essa parte aumenta com o comprimento do contexto e o número de solicitações executadas em paralelo. Quantização é uma técnica que reduz a precisão numérica, por exemplo, de 16-bit para 4-bit, para tornar o modelo muito menor. Em troca, a qualidade das respostas cai um pouco. Para muitas tarefas como classificação, sumarização ou extração de informações, essa degradação geralmente é aceitável.
3. Por que o auto-hospedagem de LLM está ganhando atenção?
Existem quatro motivadores principais:
- Privacidade e conformidade: dados de clientes, contratos ou código-fonte interno não precisam sair de sua infraestrutura.
- Custos previsíveis: APIs na nuvem cobram por token. Com carga alta e constante, o custo fixo de uma máquina GPU pode ser mais barato.
- Latência e capacidade offline: não há round-trip pela Internet, nenhuma dependência de limites de taxa de terceiros, funcionamento em ambientes air-gapped.
- Modelos open-weight cada vez melhores: modelos pequenos já são suficientes para muitas tarefas especializadas. Você também pode fazer fine-tune para seu domínio específico.
Nota: “Local” não precisa ser seu laptop. Um cluster de GPU em uma VPC privada também conta como auto-hospedagem. O ponto-chave é que você controla o modelo e os dados.
4. Configurando um modelo local com Ollama
Ollama é a escolha simples para começar. Ele encapsula o runtime, gerencia modelos e fornece uma API HTTP. Você pode executá-lo com Docker Compose:
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama-data:/root/.ollama
restart: unless-stopped
volumes:
ollama-data:
O volume ollama-data retém os modelos baixados. Assim, você não precisa baixar novamente cada vez que o container for reiniciado. A porta 11434 é a porta padrão da API Ollama.
Em seguida, você baixa um modelo e faz um teste de chamada:
docker compose up -d
docker compose exec ollama ollama pull llama3.2
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [{"role": "user", "content": "Resumo: pedido entregue 3 dias atrasado, cliente quer reembolso."}],
"stream": false
}'
Você pode substituir llama3.2 por qualquer modelo adequado ao seu hardware. Com "stream": false, o resultado retornado é um único JSON na forma:
{
"model": "llama3.2",
"message": { "role": "assistant", "content": "Cliente solicita reembolso porque o pedido foi entregue com 3 dias de atraso." },
"done": true,
...
}
Se sua máquina tem GPU NVIDIA, você precisa instalar o NVIDIA Container Toolkit e atribuir a GPU ao container. Caso contrário, o modelo será executado em CPU. Funciona dessa forma, mas é muito mais lento.
5. Chamando o modelo local a partir do Spring Boot
O exemplo nesta seção é um cenário real: rascunhar uma resposta para uma mensagem de atendimento ao cliente. Os dados da mensagem permanecem completamente na infraestrutura interna.
5.1. Dependências e configuração
Você só precisa do RestClient que vem com Spring Web. Nenhuma biblioteca de IA adicional necessária:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Declare a configuração em application.properties:
local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s
O timeout precisa ser definido com margem. Um modelo executado localmente, especialmente em CPU, pode levar dezenas de segundos para gerar uma resposta longa.
5.2. Properties e RestClient
@ConfigurationProperties(prefix = "local-llm")
public record LocalLlmProperties(String baseUrl, String model, Duration timeout) {}
@Configuration
@EnableConfigurationProperties(LocalLlmProperties.class)
public class LlmClientConfig {
@Bean
RestClient ollamaRestClient(RestClient.Builder builder, LocalLlmProperties props) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(props.timeout());
return builder
.baseUrl(props.baseUrl())
.requestFactory(factory)
.build();
}
}
Existem dois tipos de timeout aqui com funções diferentes. Connect timeout curto (5 segundos) ajuda a detectar rapidamente quando Ollama não está em execução. Read timeout longo para esperar o modelo terminar de gerar a resposta.
5.3. DTO e service
public record ChatMessage(String role, String content) {}
public record ChatRequest(String model, List<ChatMessage> messages, boolean stream) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatResponse(ChatMessage message, boolean done) {}
@Service
public class SupportReplyService {
private static final String SYSTEM_PROMPT =
"Você é um agente de atendimento ao cliente. Responda educadamente, de forma concisa, em português.";
private final RestClient ollama;
private final String model;
public SupportReplyService(RestClient ollamaRestClient, LocalLlmProperties props) {
this.ollama = ollamaRestClient;
this.model = props.model();
}
public String draftReply(String customerMessage) {
ChatRequest request = new ChatRequest(model, List.of(
new ChatMessage("system", SYSTEM_PROMPT),
new ChatMessage("user", customerMessage)
), false);
ChatResponse response = ollama.post()
.uri("/api/chat")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(ChatResponse.class);
if (response == null || response.message() == null) {
throw new IllegalStateException("LLM não retornou conteúdo");
}
return response.message().content().strip();
}
}
O fluxo de processamento de draftReply é o seguinte:
- O serviço monta uma mensagem
systempara definir o tom, então adiciona a mensagem do cliente como mensagemuser. streamé definido comofalse, então Ollama retorna toda a resposta em um único response.@JsonIgnorePropertiesignora campos estatísticos que o serviço não usa, como tempo de processamento ou contagem de tokens.- Uma resposta vazia é convertida em uma exception clara, em vez de expor um
NullPointerExceptionconfuso.
6. Testando a integração
Você não deve deixar que testes unitários dependam de um modelo real, pois é lento e os resultados são não determinísticos. Em vez disso, use MockRestServiceServer para verificar que o serviço envia a solicitação correta e lê a resposta corretamente:
class SupportReplyServiceTest {
private MockRestServiceServer server;
private SupportReplyService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost:11434");
server = MockRestServiceServer.bindTo(builder).build();
var props = new LocalLlmProperties("http://localhost:11434", "llama3.2", Duration.ofSeconds(60));
service = new SupportReplyService(builder.build(), props);
}
@Test
void givenCustomerMessage_whenDraftReply_thenReturnsAssistantContent() {
server.expect(requestTo("http://localhost:11434/api/chat"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.model").value("llama3.2"))
.andExpect(jsonPath("$.stream").value(false))
.andExpect(jsonPath("$.messages[1].content").value("Onde está meu pedido?"))
.andRespond(withSuccess("""
{"model":"llama3.2",
"message":{"role":"assistant","content":" Estamos verificando seu pedido. "},
"done":true,
"total_duration":123456}
""", MediaType.APPLICATION_JSON));
String reply = service.draftReply("Onde está meu pedido?");
assertThat(reply).isEqualTo("Estamos verificando seu pedido.");
server.verify();
}
}
Este teste verifica quatro pontos:
- A solicitação é enviada para o endpoint correto
/api/chatusando o método POST. - O corpo tem o nome correto do modelo e
stream=false. - A mensagem do cliente está na segunda mensagem.
- O serviço ignora o campo desconhecido
total_duratione remove espaços em branco desnecessários no início e fim da resposta.
Se você quiser testar a qualidade da resposta, deve separar em um conjunto de testes separado que executa com um modelo real, por exemplo, usando Testcontainers. Esse conjunto de testes deve executar periodicamente, não em cada build.
7. Mantendo a capacidade de alternar entre local e nuvem
Ollama também suporta um endpoint compatível com OpenAI em /v1/chat/completions. Se você escrever o cliente de acordo com esse padrão, a alternância entre local e nuvem é apenas uma mudança de configuração por perfil:
# application-local.properties
llm.base-url=http://localhost:11434/v1
llm.model=llama3.2
# application-cloud.properties
llm.base-url=https://api.your-provider.example/v1
llm.model=your-cloud-model
llm.api-key=${LLM_API_KEY}
Você deve colocar as chamadas ao LLM atrás de uma interface separada, como ReplyDrafter. Dessa forma, você pode usar o modelo local para dados sensíveis e chamar a nuvem para tarefas que exigem qualidade superior, sem que a lógica de negócio precise saber onde o modelo está sendo executado.
8. Quando fazer e não fazer self-hosting?
8.1. Fazer self-hosting quando
- Dados sensíveis, ou regulamentações legais não permitem enviar dados para terceiros.
- Carga alta e constante, então o custo por token excede o custo de possuir GPU, mesmo contando com overhead operacional.
- Tarefas estreitas e repetitivas como classificação de tickets, extração de campos de faturas ou sumarização de logs. Modelos pequenos geralmente são bons nessas tarefas.
- Precisa executar offline, em ambientes air-gapped ou na borda.
- Precisa de fine-tuning profundo, ou precisa controlar completamente a versão do modelo para manter resultados estáveis ao longo do tempo.
8.2. Não fazer self-hosting quando
- Você precisa das melhores capacidades de raciocínio para tarefas complexas. Modelos grandes na nuvem geralmente ainda superam nessa área.
- Tráfego baixo ou irregular. GPU ociosa na maioria do tempo é quase certamente mais cara do que pagar por token.
- Equipe sem experiência em operação de GPU, monitoramento, autoscaling e atualizações de segurança para runtime.
- Você está na fase de prototipagem. Valide a ideia com APIs na nuvem primeiro, depois otimize custos.
Uma forma simples de comparar custos:
Custo self-hosting / mês = hardware (depreciação ou aluguel) + eletricidade/hospedagem + horas-pessoa operacionais
Custo nuvem / mês = tokens/mês × preço por token
Faça self-hosting apenas se o custo for significativamente mais baixo na carga de trabalho real, não na carga esperada.
8.3. Erros comuns
Nota: Ollama por padrão só escuta em localhost e não tem autenticação. Se você expor a porta para a rede, coloque-a atrás de um proxy reverso com autenticação. Nunca exponha diretamente à Internet.
- Avaliar modelo por intuição: testar alguns exemplos, achar ok e enviar para produção. Você precisa de um conjunto de dados de avaliação fixo para sua tarefa específica.
- Esquecer KV cache: modelo cabe na VRAM, mas fica sem memória quando o contexto é longo ou com muitas requisições paralelas.
- Timeout muito curto: a primeira chamada após inicialização deve esperar o carregamento do modelo, então é muito lenta. Você pode “aquecer” o modelo na inicialização da aplicação.
- Escolher o runtime errado para produção: Ollama é conveniente para desenvolvimento e carga moderada. Com muitos usuários simultâneos, considere runtimes otimizados para throughput como vLLM.
9. Conclusão
Fazer self-hosting de LLM não é automaticamente “mais barato” ou “melhor”. É um trade-off: você ganha controle sobre dados, custos previsíveis e latência baixa, em troca de responsabilidades operacionais e aceitação típica de modelos menores.
Neste artigo, você configurou Ollama com Docker e o chamou do Spring Boot usando RestClient. Você também escreveu testes para a integração sem precisar de um modelo real e aprendeu a projetar para alternar entre local e nuvem via configuração. A abordagem mais prática geralmente é combinação de ambas: modelo local para dados sensíveis e tarefas repetitivas, nuvem para o que exige a melhor capacidade.