Ejecutar LLM localmente: cuándo hacer self-hosting de modelos
1. Descripción general
Llamar a la API de un proveedor de LLM en la nube es la forma más rápida de traer IA a tu aplicación. Cada vez más equipos desean ejecutar modelos en su propia infraestructura, ya sea en una laptop, servidor interno o GPU alquilado.
En este artículo, exploraremos qué significa realmente “ejecutar LLM localmente” y por qué esta tendencia atrae tanto interés. Luego construirás un modelo local, lo llamarás desde una aplicación Spring Boot y escribirás pruebas para la integración. Al final del artículo encontrarás los criterios para decidir cuándo hacer self-hosting y cuándo no.
2. ¿Qué significa “ejecutar LLM localmente”?
2.1. Tres componentes fundamentales
Un sistema de LLM auto-alojado generalmente consta de tres capas:
- Model weights: archivos de pesos del modelo. Típicamente cargas modelos open-weight de repositorios públicos.
- Inference runtime: software que carga los pesos en RAM o VRAM y genera tokens. Ejemplos: Ollama, llama.cpp o vLLM.
- API layer: un endpoint HTTP para que las aplicaciones lo llamen. Muchos runtimes proporcionan API compatibles con el estándar OpenAI, por lo que el código del lado cliente casi no necesita cambios.
Tu aplicación solo ve la tercera capa. Este es el punto clave que te permite cambiar entre local y cloud sin reescribir la lógica de negocio.
2.2. Cuantización y memoria
El factor decisivo “¿mi máquina puede ejecutarlo?” es la memoria. Puedes estimar aproximadamente la memoria para los pesos de la siguiente manera:
Memoria de pesos ≈ número de parámetros × bytes por parámetro
16-bit (FP16): 2 bytes por parámetro
8-bit: 1 byte por parámetro
4-bit: ~0.5 bytes por parámetro
Además de los pesos, necesitas espacio para el KV cache. Esta parte crece con la longitud del contexto y el número de solicitudes concurrentes. La cuantización es una técnica que reduce la precisión numérica, por ejemplo de 16-bit a 4-bit, para que el modelo sea mucho más pequeño. A cambio, la calidad de las respuestas disminuye un poco. Para muchas tareas como clasificación, resumen o extracción de información, esta degradación suele ser aceptable.
3. ¿Por qué hacer self-hosting de LLM está ganando atención?
Hay cuatro motivaciones principales:
- Privacidad y cumplimiento: los datos de clientes, contratos o código fuente interno no tienen que salir de tu infraestructura.
- Costo predecible: las APIs en la nube cobran por token. Con cargas grandes y consistentes, el costo fijo de una máquina GPU puede ser más barato.
- Latencia baja y capacidad offline: sin viajes de ida y vuelta a través de Internet, sin dependencias de límites de velocidad de terceros, puede ejecutarse en entornos aislados.
- Modelos open-weight cada vez mejores: modelos más pequeños son suficientes para muchas tareas especializadas. También puedes hacer fine-tuning para tu dominio específico.
Nota: “Local” no tiene que ser tu laptop. Un clúster de GPU en una VPC privada también cuenta como self-hosting. El punto clave es que controlas el modelo y los datos.
4. Configurar un modelo local con Ollama
Ollama es una opción simple para empezar. Empaqueta el runtime, gestiona modelos y proporciona una API HTTP. Puedes ejecutarlo con Docker Compose:
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama-data:/root/.ollama
restart: unless-stopped
volumes:
ollama-data:
El volumen ollama-data conserva los modelos descargados. De esta manera no tienes que descargarlos nuevamente cada vez que se reinicia el contenedor. El puerto 11434 es el puerto predeterminado de la API de Ollama.
Luego, descargas un modelo y lo pruebas:
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": "Resumen: pedido entregado 3 días tarde, cliente solicita reembolso."}],
"stream": false
}'
Puedes reemplazar llama3.2 con cualquier modelo que se ajuste a tu hardware. Con "stream": false, el resultado se devuelve como un único JSON con la forma:
{
"model": "llama3.2",
"message": { "role": "assistant", "content": "El cliente solicita reembolso porque el pedido se entregó 3 días tarde." },
"done": true,
...
}
Si tu máquina tiene GPU NVIDIA, necesitas instalar NVIDIA Container Toolkit y asignar la GPU al contenedor. Si no, el modelo se ejecutará en CPU. Esto sigue siendo posible pero mucho más lento.
5. Llamar al modelo local desde Spring Boot
El ejemplo en esta sección es una situación real: redactar una respuesta a un mensaje de atención al cliente. Los datos del mensaje permanecen completamente dentro de tu infraestructura.
5.1. Dependencia y configuración
Solo necesitas RestClient que ya viene en Spring Web. No necesitas agregar ninguna librería de IA:
<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>
Declara la configuración en application.properties:
local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s
El timeout debe ser generoso. Un modelo ejecutándose localmente, especialmente en CPU, puede tardar decenas de segundos en generar una respuesta larga.
5.2. Properties y 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();
}
}
Hay dos tipos de timeout aquí con roles diferentes. El connect timeout corto (5 segundos) ayuda a detectar rápidamente cuando Ollama no se está ejecutando. El read timeout largo espera a que el modelo termine de generar la respuesta.
5.3. DTO y servicio
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 =
"Eres un representante de servicio al cliente. Responde con cortesía, de manera concisa.";
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("El LLM no devolvió contenido");
}
return response.message().content().strip();
}
}
El flujo de procesamiento de draftReply es el siguiente:
- El servicio concatena un mensaje
systempara orientar el tono de voz, luego añade el mensaje del cliente como mensajeuser. streamse establece enfalse, por lo que Ollama devuelve la respuesta completa en una sola respuesta.@JsonIgnorePropertiesignora campos estadísticos que el servicio no usa, como el tiempo de procesamiento o el número de tokens.- Una respuesta vacía se convierte en una excepción clara, en lugar de permitir que se escape un
NullPointerExceptionconfuso.
6. Prueba de integración
No debes hacer que las pruebas unitarias dependan de un modelo real, porque es lento y los resultados son indeterminados. En su lugar, usa MockRestServiceServer para verificar que el servicio envía la solicitud correcta y lee la respuesta correcta:
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("¿Dónde está mi pedido?"))
.andRespond(withSuccess("""
{"model":"llama3.2",
"message":{"role":"assistant","content":" Estamos verificando tu pedido. "},
"done":true,
"total_duration":123456}
""", MediaType.APPLICATION_JSON));
String reply = service.draftReply("¿Dónde está mi pedido?");
assertThat(reply).isEqualTo("Estamos verificando tu pedido.");
server.verify();
}
}
Esta prueba verifica cuatro puntos:
- La solicitud se envía al endpoint
/api/chatcorrecto usando el método POST. - El cuerpo tiene el nombre de modelo correcto y
stream=false. - El mensaje del cliente está en el mensaje número dos.
- El servicio ignora el campo desconocido
total_durationy elimina los espacios en blanco sobrantes al principio y al final de la respuesta.
Si deseas verificar la calidad de la respuesta, debes separar esto en un conjunto de pruebas separado que se ejecute con el modelo real, por ejemplo usando Testcontainers. Ese conjunto de pruebas debe ejecutarse periódicamente, no en cada compilación.
7. Mantener la capacidad de cambiar entre local y cloud
Ollama también soporta un endpoint compatible con OpenAI en /v1/chat/completions. Si escribes el cliente según este estándar, cambiar entre local y cloud es solo cuestión de cambiar la configuración 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}
Debes poner la llamada al LLM detrás de una interfaz separada, por ejemplo ReplyDrafter. De esta manera, puedes usar el modelo local para datos sensibles y llamar a la nube para tareas que requieren mayor calidad, mientras que la lógica de negocio no necesita saber dónde se ejecuta el modelo.
8. ¿Cuándo hacer y no hacer self-hosting?
8.1. Debes hacer self-hosting cuando
- Tienes datos sensibles, o las regulaciones no permiten enviar datos a terceros.
- Tienes cargas grandes y estables, entonces el costo por token supera el costo de ser propietario de una GPU, incluso considerando gastos operativos.
- Tareas estrechas y repetitivas como clasificación de tickets, extracción de campos de facturas o resumen de registros. Los modelos pequeños suelen ser buenos en estas tareas.
- Necesitas ejecutar sin conexión, en entornos aislados o en el edge.
- Necesitas hacer fine-tuning profundo, o necesitas controlar completamente la versión del modelo para obtener resultados consistentes a lo largo del tiempo.
8.2. No debes hacer self-hosting cuando
- Necesitas la mejor capacidad de razonamiento para tareas complejas. Los modelos grandes en la nube suelen ser superiores en este aspecto.
- Tráfico bajo o inconsistente. Una GPU ociosa la mayoría del tiempo es casi seguramente más cara que pagar por token.
- Tu equipo carece de experiencia operativa con GPU, monitoreo, autoescalado y actualizaciones de seguridad para el runtime.
- Estás en la fase de experimentación de ideas. Verifica el valor con una API en la nube primero, luego optimiza los costos.
Una forma simple de comparar costos:
Costo de self-hosting / mes = hardware (depreciación o alquiler) + electricidad/hosting + horas de trabajo operativo
Costo en la nube / mes = tokens por mes × precio por token
Solo debes hacer self-hosting si el costo es significativamente más bajo en tu carga real, no en la carga que esperas.
8.3. Errores comunes
Nota: Ollama escucha solo en localhost por defecto y no tiene autenticación. Si abres el puerto a la red, colócalo detrás de un proxy inverso con autenticación. Nunca lo expongas directamente a Internet.
- Evaluar el modelo por intuición: probar unos pocos ejemplos que parecen bien y luego pasar a producción. Necesitas un conjunto de datos de evaluación fijo para tu tarea específica.
- Olvidar el KV cache: el modelo cabe en VRAM pero se queda sin memoria cuando el contexto es largo o hay muchas solicitudes concurrentes.
- Timeout muy corto: la primera llamada después del arranque debe esperar a que se cargue el modelo, por lo que suele ser muy lenta. Puedes hacer “warm up” del modelo cuando se inicia la aplicación.
- Elegir el runtime incorrecto para producción: Ollama es muy conveniente para desarrollo y cargas moderadas. Con muchos usuarios simultáneos, considera runtimes optimizados para throughput como vLLM.
9. Conclusión
El self-hosting de LLM no es “más barato” o “mejor” por defecto. Es un intercambio: tienes control sobre los datos, costo predecible y baja latencia, pero asumes responsabilidades operativas y típicamente aceptas modelos más pequeños.
En este artículo, configuraste Ollama con Docker y lo llamaste desde Spring Boot usando RestClient. También escribiste pruebas para la integración sin necesidad de un modelo real, y aprendiste cómo diseñar para cambiar entre local y nube mediante configuración. El enfoque más pragmático es típicamente usar ambos: modelos locales para datos sensibles y tareas repetitivas, nube para tareas que requieren las máximas capacidades.