LLMs Locales y Privados: Cómo integrar Ollama o llama.cpp en un Backend Node.js para alimentar tu App iOS

Las APIs de IA en la nube son muy cómodas hasta que llega la primera factura grande o un cliente te pregunta a dónde van sus datos. Ahí es cuando montar tu propio modelo empieza a tener sentido.

La buena noticia es que hoy es mucho más fácil de lo que parece: con Ollama tienes un LLM funcionando en tu servidor en unos minutos. En este artículo montamos el camino completo: el modelo en local, un backend en Node.js que hace de intermediario y una app en SwiftUI que recibe la respuesta palabra a palabra, como en ChatGPT.


¿Por qué optar por LLMs locales y privados en el desarrollo móvil?

Ejecutar modelos de lenguaje grandes (como Llama 3, Mistral o Gemma) en tu propia infraestructura para servir a clientes móviles ofrece ventajas estratégicas:

  • Privacidad total (Zero-Data Retention): La información sensible de los usuarios nunca sale de tus servidores. Esto facilita el cumplimiento normativo estricto como el RGPD europeo, HIPAA en salud o regulaciones bancarias.
  • Costes predecibles e independientes del volumen: En lugar de pagar por cada millón de tokens consumidos, tus costes se limitan al hardware o servidor dedicado (GPU en la nube como RunPod, Lambda Labs, o bare-metal).
  • Personalización y ajuste fino (Fine-tuning): Tienes libertad absoluta para utilizar modelos personalizados, cuantizaciones específicas (GGUF) y técnicas avanzadas de RAG (Retrieval-Augmented Generation) sin restricciones de censura impuestas por proveedores externos.
  • Menor tiempo de respuesta (Latencia): Al tener control total sobre el hardware y el procesamiento de lotes (batching), puedes optimizar la infraestructura exactamente para los casos de uso de tu app.

La batalla del motor de inferencia: Ollama vs. llama.cpp

Antes de escribir código, conviene entender qué herramienta usar para ejecutar la inferencia del modelo en el servidor:

1. llama.cpp

Es la implementación en C/C++ más eficiente para ejecutar modelos en formato GGUF sobre arquitecturas x86 y ARM.

  • Ventajas: Rendimiento máximo, consumo mínimo de memoria, soporte nativo para aceleración por GPU (CUDA, Metal, ROCm) y control milimétrico sobre parámetros de inferencia.
  • Desventajas: Curva de aprendizaje más pronunciada; requiere compilar binarios y configurar servidores HTTP manualmente.

2. Ollama

Construido internamente sobre llama.cpp, Ollama empaqueta la inferencia, la gestión de modelos y una API REST estilo Docker en una sola herramienta accesible.

  • Ventajas: Instalación en un solo comando (curl), descarga directa de modelos desde su registro (ollama run llama3), y API HTTP nativa lista para producción.
  • Desventajas: Menor flexibilidad para configuraciones hiperespecíficas de bajo nivel en comparación con el binario puro de llama.cpp.

Recomendación: Para la mayoría de proyectos modernos, Ollama es la opción recomendada por su agilidad de desarrollo y excelente API REST interna.


Paso 1: Configurar el motor de inferencia con Ollama

Para comenzar, instala Ollama en tu servidor (o máquina de desarrollo local macOS/Linux):

curl -fsSL https://ollama.com/install.sh | sh

Una vez instalado, descarga y levanta un modelo ligero y eficiente, como Llama 3.2 (3B o 1B), ideal para respuestas rápidas:

ollama run llama3.2

Ollama expondrá automáticamente una API REST en http://localhost:11434. Puedes verificar su funcionamiento enviando una petición HTTP rápida:

curl http://localhost:11434/api/generate -d '{
  "model": "llama3.2",
  "prompt": "¿Por qué el cielo es azul?",
  "stream": false
}'

Paso 2: Creando el Backend Intermediario en Node.js

¿Por qué no conectar la app iOS directamente a Ollama?
Conectar un cliente móvil directamente a un motor de inferencia sin intermediarios es una mala práctica de seguridad. Un backend en Node.js permite:

  1. Autenticar usuarios (vía JWT o Firebase Auth).
  2. Aplicar limitación de tasa (Rate Limiting).
  3. Mitigar inyecciones de prompts (prompt injection) filtrando y limitando lo que llega al modelo.
  4. Monitorizar logs, gestionar colas y optimizar respuestas.

Inicialización del Proyecto

Crea un directorio para tu backend e instala las dependencias:

mkdir llm-ios-backend
cd llm-ios-backend
npm init -y
npm install express cors dotenv ollama
npm install --save-dev typescript @types/node @types/express ts-node

Código del Servidor (Express + Streaming con Ollama)

El siguiente código implementa un endpoint de streaming mediante Server-Sent Events (SSE), lo que permite que el usuario en iOS reciba las palabras en tiempo real a medida que el LLM las genera:

// server.ts
import express, { Request, Response } from 'express';
import cors from 'cors';
import { Ollama } from 'ollama';

const app = express();
const port = process.env.PORT || 3000;
const ollama = new Ollama({ host: 'http://127.0.0.1:11434' });

app.use(cors());
app.use(express.json());

app.post('/api/chat', async (req: Request, res: Response) => {
  const { prompt } = req.body;

  if (!prompt) {
    return res.status(400).json({ error: 'El prompt es obligatorio' });
  }

  // Configuración de cabeceras para Streaming (SSE)
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  try {
    const response = await ollama.chat({
      model: 'llama3.2',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    });

    for await (const part of response) {
      // Envío de cada chunk de texto al cliente
      res.write(`data: ${JSON.stringify({ text: part.message.content })}\n\n`);
    }

    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error) {
    console.error('Error durante la inferencia:', error);
    // Las cabeceras SSE ya se enviaron: informamos del error dentro del propio stream
    res.write('data: {"error": "Error procesando el modelo"}\n\n');
    res.end();
  }
});

app.listen(port, () => {
  console.log(`Backend LLM escuchando en http://localhost:${port}`);
});

Arranca el servidor con npx ts-node server.ts (con Ollama ya en marcha) y prueba el endpoint con curl -N -X POST http://localhost:3000/api/chat -H "Content-Type: application/json" -d '{"prompt":"Hola"}': verás llegar las líneas data: una a una.


Paso 3: Consumiendo el Backend desde una App iOS con Swift

En el lado del cliente (iOS), utilizaremos Swift Concurrency (async/await) y URLSession para consumir el stream de datos y actualizar la interfaz gráfica en tiempo real usando SwiftUI.

Capa de Red y ViewModel en Swift

import SwiftUI

@MainActor
class ChatViewModel: ObservableObject {
    @Published var responseText: String = ""
    @Published var isLoading: Bool = false

    func sendPrompt(_ prompt: String) async {
        guard !prompt.isEmpty else { return }

        self.responseText = ""
        self.isLoading = true
        defer { self.isLoading = false }

        guard let url = URL(string: "http://TU-IP-SERVIDOR:3000/api/chat") else { return }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body = ["prompt": prompt]
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        do {
            let (bytes, response) = try await URLSession.shared.bytes(for: request)

            guard (response as? HTTPURLResponse)?.statusCode == 200 else {
                self.responseText = "Error: Estado de servidor inválido."
                return
            }

            // Procesamiento reactivo del streaming línea por línea
            for try await line in bytes.lines {
                if line.hasPrefix("data: ") {
                    let dataString = String(line.dropFirst(6))

                    if dataString == "[DONE]" { break }

                    if let data = dataString.data(using: .utf8),
                       let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                       let textChunk = json["text"] as? String {
                        self.responseText += textChunk
                    }
                }
            }
        } catch {
            self.responseText = "Error al conectar con el servidor: \(error.localizedDescription)"
        }
    }
}

Ojo con App Transport Security: iOS bloquea por defecto las conexiones http:// sin cifrar. Para probar en local puedes añadir una excepción temporal en el Info.plist, pero en producción el backend debe servirse siempre por HTTPS (por ejemplo, detrás de Nginx o Caddy con un certificado de Let’s Encrypt).

Vista en SwiftUI

struct ChatView: View {
    @StateObject private var viewModel = ChatViewModel()
    @State private var inputPrompt: String = ""

    var body: some View {
        VStack {
            ScrollView {
                Text(viewModel.responseText.isEmpty ? "El asistente responderá aquí..." : viewModel.responseText)
                    .padding()
                    .frame(maxWidth: .infinity, alignment: .leading)
            }

            HStack {
                TextField("Escribe tu consulta...", text: $inputPrompt)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

                Button(action: {
                    let prompt = inputPrompt
                    inputPrompt = ""
                    Task {
                        await viewModel.sendPrompt(prompt)
                    }
                }) {
                    Image(systemName: "paperplane.fill")
                }
                .disabled(viewModel.isLoading)
            }
            .padding()
        }
        .navigationTitle("IA Privada Local")
    }
}

Mejores prácticas de rendimiento y seguridad

Para llevar esta arquitectura a un entorno de producción real, ten en cuenta las siguientes recomendaciones clave:

  • Cuantización de Modelos: Emplea cuantizaciones Q4_K_M o Q5_K_M. Reducen el uso de VRAM a más de la mitad prácticamente sin pérdida apreciable de calidad semántica o coherencia.
  • Dimensionamiento de Servidores: Si ejecutas Ollama en un servidor cloud, prioriza instancias con GPUs dedicadas (NVIDIA RTX 4090, A10G o Apple Silicon vía Mac Studio/Mini si optas por hardware propio en rack).
  • Protección del Backend: No expongas los puertos internos de Ollama (11434) a la red pública. Utiliza un proxy inverso con Nginx o Caddy, configura HTTPS obligatorio y restringe el acceso mediante API Keys emitidas desde tu app iOS.
  • Gestión de Contexto: Trunca o resume el historial de chat en el backend para evitar que los prompts excedan la ventana de contexto del modelo, lo que degradaría el rendimiento de inferencia.

Errores típicos que conviene evitar

  • Dejar el puerto de Ollama abierto. El 11434 nunca debe ser accesible desde internet: todo el tráfico tiene que pasar por tu backend.
  • Elegir un modelo demasiado grande. Si no cabe en la memoria de la GPU, irá lentísimo. Empieza por uno pequeño y sube solo si la calidad no te llega.
  • No limitar las peticiones. Un solo usuario abusando puede dejar el servidor sin recursos para el resto.
  • Esperar la calidad de los modelos más grandes. Un modelo de 3.000 millones de parámetros es muy útil para tareas concretas, pero pruébalo con tus casos reales antes de prometer nada.

Conclusión

Montar un LLM propio no es para todos los proyectos: si tu app hace cuatro peticiones al día, una API comercial te sale más barata y te ahorra mantener un servidor. Pero si manejas datos sensibles o el volumen empieza a disparar la factura, Ollama con un backend sencillo en Node.js te da control total por el precio fijo de una máquina.

Mi consejo es empezar en tu propio ordenador con un modelo pequeño como Llama 3.2, conectar la app y probar con preguntas reales. En una tarde sabrás si la calidad te vale antes de gastar un euro en servidores.

Artículos relacionados: Local LLMs en el bolsillo: Cómo optimizar y ejecutar modelos Llama 3 en iOS con Swift · Cómo integrar la API de OpenAI en una App iOS usando Swift · Servidores VPS baratos para Apps Móviles: Cómo desplegar tu backend PHP/Node sin morir en el intento · Server-Sent Events (SSE) vs WebSockets en Swift: Cuál elegir para notificaciones y datos en vivo

Deja una respuesta