Salta al contenuto principale

06 - Ollama: Eseguire LLM Locali su Laptop e Raspberry

Guida completa a Ollama: Eseguire LLM Locali su Laptop e Raspberry: architettura, implementazione pratica e best practices per developer e team tecnici.

Cosa ne pensi di questo articolo?

06 - Ollama: Eseguire LLM Locali su Laptop e Raspberry
Deep Learning AvanzatoFoto:Daniil KomovPexels

In Breve

Guida completa a Ollama: Eseguire LLM Locali su Laptop e Raspberry: architettura, implementazione pratica e best practices per developer e team tecnici.

Ollama e LLM Locali: Eseguire Modelli sul Proprio Hardware

Nel 2023, eseguire un Large Language Model in locale era un'operazione riservata a chi aveva expertise tecnica profonda: compilare llama.cpp, convertire pesi, configurare parametri GGML, gestire dipendenze complesse. Poi e arrivato Ollama, e tutto e cambiato. Con un singolo comando - ollama run llama3 - chiunque può avere un LLM competitivo in esecuzione sul proprio laptop in pochi minuti.

Il trend e esplosivo. Ollama ha raggiunto oltre 1 milione di download mensili nel 2024, con una crescita del 300% anno su anno. Il mercato sta chiaramente scegliendo la privacy (i dati non lasciano il dispositivo), il costo zero di API, la personalizzazione (modelli custom, system prompt fissi) e la disponibilità offline. Questi vantaggi stanno guidando la migrazione di molti workflow aziendali da API cloud a deployment locale.

In questa guida, dalla installazione alla produzione: come configurare Ollama, scegliere il modello giusto, creare Modelfile personalizzati, esporre API REST, integrare LangChain per pipeline RAG offline, e fare fine-tuning di modelli GGUF per casi d'uso specifici, su laptop, server e Raspberry Pi.

Cosa Imparerai

  • Installazione Ollama su Windows, macOS e Linux
  • Guida alla scelta del modello: Llama, Qwen, Phi, Gemma, Mistral, DeepSeek
  • Modelfile: creare assistenti personalizzati con parametri custom
  • API REST Ollama: integrazione con Python, JavaScript e cURL
  • Ollama con Python via libreria ufficiale e compatibilità OpenAI
  • Pipeline RAG offline con LangChain e FAISS
  • Deployment su Raspberry Pi e server headless con systemd
  • OpenWebUI: interfaccia ChatGPT-like completamente offline
  • Benchmark dettagliati e scelta del quantization level
  • Multi-model management e ottimizzazione per produzione

Come Funziona Ollama Internamente

Prima di usare Ollama, e utile capire cosa fa sotto il cofano. Ollama e un wrapper attorno a llama.cpp, il motore di inferenza C++ che ha reso possibile eseguire modelli quantizzati su hardware commodity. Ollama aggiunge:

  • Model registry: sistema di pull/push simile a Docker Hub per modelli GGUF
  • REST API server: espone un server HTTP locale sulla porta 11434
  • Model caching: mantiene i modelli carichi in RAM tra le richieste
  • GPU detection: rileva automaticamente NVIDIA CUDA, AMD ROCm e Apple Metal
  • Context management: gestisce la finestra di contesto e il KV cache
# Architettura Ollama - diagramma semplificato
#
#  Client (Python/cURL/Browser)
#         |
#         v
#  [Ollama REST API - port 11434]
#         |
#         v
#  [Model Manager]  ---  ~/.ollama/models/  (storage GGUF)
#         |
#         v
#  [llama.cpp backend]
#         |
#    _____|______
#   |           |
#  [CPU]     [GPU/Metal]
#  ARM/x86   CUDA/ROCm/Metal
#
# Formato modelli: GGUF (GPT-Generated Unified Format)
# Quantization levels: Q4_K_M, Q5_K_M, Q6_K, Q8_0, F16
#
# Dove sono i modelli sul disco:
# macOS/Linux: ~/.ollama/models/
# Windows:     C:\Users\USERNAME\.ollama\models\
#
# Struttura directory:
# ~/.ollama/models/
# ├── blobs/       (file GGUF binari, identificati da SHA256)
# └── manifests/   (metadata: quale blob = quale modello:tag)

import subprocess, json

def ollama_status():
    """Controlla status Ollama e modelli caricati."""
    result = subprocess.run(
        ["ollama", "list"], capture_output=True, text=True
    )
    print("Modelli installati:")
    print(result.stdout)

    # Controlla processo
    ps = subprocess.run(
        ["pgrep", "-x", "ollama"], capture_output=True, text=True
    )
    running = ps.returncode == 0
    print(f"Ollama in esecuzione: {running}")

ollama_status()

Installazione e Primi Passi

Ollama si installa con un singolo comando e non richiede configurazione. Supporta macOS (Apple Silicon e Intel), Windows (con GPU NVIDIA o AMD) e Linux (deb/rpm/generic).

# ================================================================
# INSTALLAZIONE OLLAMA
# ================================================================

# macOS / Linux (un comando):
# curl -fsSL https://ollama.com/install.sh | sh

# Windows:
# Download installer da https://ollama.com/download
# (include supporto CUDA automatico se GPU NVIDIA presente)

# Verifica installazione:
# ollama --version
# ollama serve  (avvia il server manualmente se non e attivo)

# ================================================================
# COMANDI BASE
# ================================================================

# Esegui un modello (download automatico se non presente)
# ollama run llama3.2

# Lista modelli disponibili localmente
# ollama list

# Pull senza eseguire (per pre-scaricare)
# ollama pull llama3.2:3b

# Informazioni dettagliate su un modello
# ollama show llama3.2

# Rimuovi un modello (libera spazio disco)
# ollama rm llama3.2:old-version

# Copia un modello con nome diverso
# ollama cp llama3.2 my-custom-model

# ================================================================
# VARIABILI D'AMBIENTE UTILI
# ================================================================

# Ascolta su tutte le interfacce (per accesso dalla rete)
# export OLLAMA_HOST=0.0.0.0:11434

# Directory custom per i modelli
# export OLLAMA_MODELS=/mnt/ssd/ollama-models

# Numero massimo richieste parallele (default: 1)
# export OLLAMA_NUM_PARALLEL=4

# Massimo modelli in memoria (default: 1)
# export OLLAMA_MAX_LOADED_MODELS=2

# Tempo prima di scaricare un modello dalla RAM (default: 5m)
# export OLLAMA_KEEP_ALIVE=30m

# ================================================================
# MODELLI POPOLARI e REQUISITI HARDWARE (2025)
# ================================================================

MODELS_GUIDE = {
    # Modelli PICCOLI (per Raspberry Pi / laptop 8 GB)
    "qwen2.5:1.5b":      {"size": "0.9 GB", "ram": "2 GB",  "quality": 7, "rpi5_tps": 4.5},
    "llama3.2:1b":       {"size": "1.3 GB", "ram": "2 GB",  "quality": 7, "rpi5_tps": 5.1},
    "phi3.5:mini":       {"size": "2.2 GB", "ram": "4 GB",  "quality": 8, "rpi5_tps": 2.8},
    "qwen2.5:3b":        {"size": "1.9 GB", "ram": "4 GB",  "quality": 8, "rpi5_tps": 2.1},
    "gemma2:2b":         {"size": "1.6 GB", "ram": "3 GB",  "quality": 8, "rpi5_tps": 3.2},

    # Modelli MEDI (laptop 16+ GB / desktop)
    "llama3.2:3b":       {"size": "2.0 GB", "ram": "4 GB",  "quality": 8, "rpi5_tps": 1.8},
    "mistral:7b":        {"size": "4.1 GB", "ram": "8 GB",  "quality": 9, "rpi5_tps": 0.8},
    "llama3.1:8b":       {"size": "4.7 GB", "ram": "8 GB",  "quality": 9, "rpi5_tps": 0.6},
    "qwen2.5:7b":        {"size": "4.4 GB", "ram": "8 GB",  "quality": 9, "rpi5_tps": 0.7},
    "deepseek-r1:8b":    {"size": "4.9 GB", "ram": "8 GB",  "quality": 9, "rpi5_tps": 0.5},

    # Modelli GRANDI (workstation 24+ GB / server)
    "llama3.1:70b":      {"size": "40 GB", "ram": "64 GB", "quality": 10, "rpi5_tps": None},
    "qwen2.5:72b":       {"size": "41 GB", "ram": "64 GB", "quality": 10, "rpi5_tps": None},
    "deepseek-r1:32b":   {"size": "19 GB", "ram": "32 GB", "quality": 10, "rpi5_tps": None},
}

print("Modelli consigliati per hardware:")
print("  Raspberry Pi 5 (8GB): qwen2.5:1.5b, llama3.2:1b, gemma2:2b")
print("  Laptop 16GB:          llama3.1:8b, qwen2.5:7b, mistral:7b")
print("  Mac M2/M3 (24GB):     llama3.1:8b, gemma2:9b, qwen2.5:14b")
print("  Workstation 48GB+:    llama3.1:70b, deepseek-r1:32b")

Quantization Levels: Quale GGUF Scegliere?

Quando si fa ollama pull llama3.1:8b, Ollama scarica automaticamente la quantizzazione ottimale per il tuo hardware. Ma e possibile scegliere esplicitamente il livello di quantizzazione, con un importante trade-off qualità/dimensione/velocità.

Guida ai Livelli di Quantizzazione GGUF

Tag / Formato Bits/peso Dim (7B) Perplexity loss Raccomandato per
Q2_K 2.63 bit 2.7 GB +15-20% Solo se RAM e il vincolo assoluto
Q4_K_S 4.37 bit 4.5 GB +2-3% Buon bilanciamento velocità/qualità
Q4_K_M 4.58 bit 4.8 GB +1-2% Default consigliato (sweet spot)
Q5_K_M 5.68 bit 5.7 GB +0.5-1% Massima qualità con <6 GB RAM
Q6_K 6.57 bit 6.6 GB +0.1-0.3% Quasi identica a F16, richiede più RAM
Q8_0 8.5 bit 8.5 GB ~0% Massima qualità, richiede 9+ GB RAM
F16 16 bit 14 GB 0% (baseline) Training/fine-tuning, non per inference
# Scegliere esplicitamente la quantizzazione su Ollama
# I tag dipendono dal modello - usa 'ollama show' per vedere le opzioni

# Default (Ollama sceglie automaticamente, solitamente Q4_K_M):
# ollama pull llama3.1:8b

# Specifica quantizzazione manualmente (sintassi dipende dal modello):
# ollama pull llama3.1:8b-instruct-q4_K_M
# ollama pull llama3.1:8b-instruct-q5_K_M
# ollama pull llama3.1:8b-instruct-q8_0

# Per modelli HuggingFace non su Ollama registry:
# Scarica GGUF manualmente e importa con Modelfile:

IMPORT_GGUF_MODELFILE = """
FROM ./path/to/model-q4_k_m.gguf

PARAMETER temperature 0.7
PARAMETER num_ctx 4096
SYSTEM "Sei un assistente utile."
"""

# echo IMPORT_GGUF_MODELFILE > Modelfile
# ollama create mio-modello -f Modelfile
# ollama run mio-modello

# Confronto performance Q4 vs Q5 vs Q8 (Llama 3.1 8B, MacBook M3 Pro):
QUANT_BENCHMARK = {
    "Q4_K_M": {"size_gb": 4.8, "tps": 38.2, "quality_vs_f16": "98.5%"},
    "Q5_K_M": {"size_gb": 5.7, "tps": 33.1, "quality_vs_f16": "99.2%"},
    "Q6_K":   {"size_gb": 6.6, "tps": 29.4, "quality_vs_f16": "99.7%"},
    "Q8_0":   {"size_gb": 8.5, "tps": 24.8, "quality_vs_f16": "99.9%"},
}

for quant, data in QUANT_BENCHMARK.items():
    print(f"{quant}: {data['size_gb']}GB, {data['tps']}t/s, qualità={data['quality_vs_f16']}")

Modelfile: Creare Assistenti Personalizzati

Un Modelfile e il meccanismo di Ollama per creare modelli personalizzati. Permette di definire: il modello base, il system prompt, i parametri di generazione (temperatura, top_p, context window), e persino estendere un modello con file aggiuntivi. E equivalente a un Dockerfile, ma per i modelli linguistici.

# ================================================================
# ESEMPI PRATICI DI MODELFILE
# ================================================================

# --- Modelfile 1: Assistente tecnico italiano ---
MODEL_FILE_TECH = """
FROM qwen2.5:7b

# Parametri di generazione
PARAMETER temperature 0.3         # Bassa = risposte più deterministiche
PARAMETER top_p 0.9               # Nucleus sampling
PARAMETER top_k 40                # Top-k sampling
PARAMETER num_ctx 8192            # Context window (4096-32768)
PARAMETER repeat_penalty 1.1      # Evita ripetizioni

# System prompt (definisce il comportamento del modello)
SYSTEM \"\"\"
Sei un assistente tecnico esperto in Python, deep learning e machine learning.
Rispondi SEMPRE in italiano, in modo conciso e tecnico.
Quando mostri codice, usa sempre blocchi markdown con il linguaggio specificato.
Se non sei sicuro di qualcosa, dillo esplicitamente.
Non inventare informazioni o API che non esistono.
\"\"\"

# Messaggio di benvenuto
MESSAGE user "Ciao!"
MESSAGE assistant "Ciao! Sono il tuo assistente tecnico. Come posso aiutarti oggi con Python, deep learning o machine learning?"
"""

# Crea il modello:
# echo MODEL_FILE_TECH > Modelfile-tech-it
# ollama create assistente-tech-it -f Modelfile-tech-it
# ollama run assistente-tech-it

# --- Modelfile 2: Code review assistant ---
MODEL_FILE_CODE = """
FROM llama3.1:8b

PARAMETER temperature 0.1         # Molto deterministico per codice
PARAMETER num_ctx 16384           # Context grande per file lunghi
PARAMETER repeat_penalty 1.05

SYSTEM \"\"\"
You are an expert code reviewer. When reviewing code:
1. Identify bugs, security issues, and performance problems
2. Suggest specific improvements with code examples
3. Follow PEP8/language standards
4. Be concise: list issues with severity (CRITICAL/HIGH/MEDIUM/LOW)
Be direct and actionable. Never hallucinate API methods.
\"\"\"
"""

# --- Modelfile 3: RAG con documenti aziendali ---
MODEL_FILE_RAG = """
FROM qwen2.5:7b

PARAMETER temperature 0.1
PARAMETER num_ctx 32768         # Contesto lungo per documenti
PARAMETER repeat_penalty 1.0

SYSTEM \"\"\"
Sei un assistente che risponde SOLO basandosi sui documenti forniti nel contesto.
Se non trovi la risposta nel contesto, dì esattamente: "Non ho informazioni su questo nei documenti forniti."
Non aggiungere mai informazioni esterne. Cita sempre il documento sorgente nella risposta.
Rispondi in italiano.
\"\"\"
"""

print("Modelfile pronti. Per creare:")
print("  ollama create assistente-tech-it -f Modelfile-tech-it")
print("  ollama create code-reviewer -f Modelfile-code")
print("  ollama create rag-assistant -f Modelfile-rag")

API REST Ollama: Integrazione con Python

Ollama espone due API: la propria API nativa e un'API compatibile con OpenAI. La compatibilità OpenAI permette di sostituire le API di OpenAI con Ollama semplicemente cambiando l'URL base - senza modificare il codice applicativo.

# pip install ollama openai requests

import ollama
import json, time
from typing import Iterator

# ================================================================
# 1. LIBRERIA OLLAMA UFFICIALE (Python)
# ================================================================

# Chat semplice (non-streaming)
def chat_simple(model: str, message: str) -> str:
    response = ollama.chat(
        model=model,
        messages=[{"role": "user", "content": message}]
    )
    return response['message']['content']

# Chat con streaming (token per token)
def chat_streaming(model: str, messages: list) -> Iterator[str]:
    stream = ollama.chat(
        model=model,
        messages=messages,
        stream=True
    )
    for chunk in stream:
        if chunk['message']['content']:
            yield chunk['message']['content']

# Embeddings per RAG (usa nomic-embed-text o mxbai-embed-large)
def get_embedding(model: str, text: str) -> list:
    response = ollama.embeddings(model=model, prompt=text)
    return response['embedding']

# Chat con immagini (modelli multimodali: llava, bakllava, moondream)
def chat_with_image(model: str, prompt: str, image_path: str) -> str:
    import base64
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = ollama.chat(
        model="llava:7b",  # oppure moondream
        messages=[{
            "role": "user",
            "content": prompt,
            "images": [image_data]
        }]
    )
    return response['message']['content']

# Chatbot con storia della conversazione
def interactive_chat(model: str = "llama3.2:3b"):
    history = []
    print(f"Chat con {model} (digita 'exit' per uscire)")

    while True:
        user_input = input("Tu: ").strip()
        if user_input.lower() == "exit":
            break

        history.append({"role": "user", "content": user_input})

        print("Assistant: ", end="", flush=True)
        full_response = ""
        for chunk in chat_streaming(model, history):
            print(chunk, end="", flush=True)
            full_response += chunk
        print()

        history.append({"role": "assistant", "content": full_response})


# ================================================================
# 2. API COMPATIBILE OPENAI (drop-in replacement)
# ================================================================
from openai import OpenAI

# Cambia solo la base_url: zero modifiche al codice!
client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama"  # Qualsiasi stringa
)

def chat_openai_compatible(model: str, prompt: str) -> str:
    """Identico all'API OpenAI, ma usa Ollama in locale."""
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=0.7,
        max_tokens=500
    )
    return response.choices[0].message.content

# ================================================================
# 3. RAW REST API (senza librerie Python)
# ================================================================
import requests

def ollama_raw_api(model: str, prompt: str, stream: bool = False) -> str:
    """Chiama l'API Ollama direttamente con requests."""
    resp = requests.post(
        "http://localhost:11434/api/generate",
        json={
            "model": model,
            "prompt": prompt,
            "stream": stream,
            "options": {
                "temperature": 0.7,
                "num_predict": 200,
                "num_ctx": 4096
            }
        },
        timeout=120
    )
    if not stream:
        return resp.json()["response"]
    else:
        # Streaming: ogni riga e un JSON
        result = ""
        for line in resp.iter_lines():
            if line:
                data = json.loads(line)
                result += data.get("response", "")
                if data.get("done"):
                    break
        return result

# ================================================================
# 4. BENCHMARK VELOCITA MODELLI
# ================================================================
def benchmark_model(model: str, n_runs: int = 3):
    """Misura velocità di generazione in token/s."""
    prompt = "Explain quantum computing in one paragraph."
    results = []
    for _ in range(n_runs):
        t0 = time.time()
        response = ollama.generate(
            model=model,
            prompt=prompt,
            options={"num_predict": 100}
        )
        elapsed = time.time() - t0
        eval_count = response.get('eval_count', 100)
        tps = eval_count / elapsed
        results.append(tps)

    avg_tps = sum(results) / len(results)
    print(f"{model}: {avg_tps:.1f} token/s (media {n_runs} run)")
    return avg_tps

# Risultati tipici su MacBook M3 Pro 18GB:
# qwen2.5:1.5b  ~85 t/s
# llama3.2:3b   ~62 t/s
# qwen2.5:7b    ~42 t/s
# llama3.1:8b   ~38 t/s
# qwen2.5:14b   ~22 t/s
# llama3.1:70b  ~8  t/s

Ollama con LangChain: Pipeline RAG Offline

Ollama si integra nativamente con LangChain, permettendo di costruire pipeline RAG (Retrieval-Augmented Generation) completamente offline. Questo e particolarmente rilevante per applicazioni enterprise che non possono inviare dati sensibili al cloud. Il modello nomic-embed-text e ottimale per gli embeddings locali.

# pip install langchain langchain-ollama langchain-community
# pip install faiss-cpu chromadb pypdf

from langchain_ollama import OllamaLLM, OllamaEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import (
    DirectoryLoader, TextLoader, PyPDFLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.chains import RetrievalQA, ConversationalRetrievalChain
from langchain.prompts import PromptTemplate
from langchain.memory import ConversationBufferMemory
import os

# ================================================================
# PIPELINE RAG OFFLINE CON OLLAMA - VERSIONE PRODUZIONE
# ================================================================

class OllamaRAGSystem:
    """
    Sistema RAG completo e offline con Ollama.
    Supporta PDF, TXT e directory intere.
    Usa FAISS per vector store locale.
    """

    def __init__(
        self,
        llm_model: str = "llama3.1:8b",
        embed_model: str = "nomic-embed-text",  # ollama pull nomic-embed-text
        kb_dir: str = "./knowledge_base"
    ):
        self.llm_model = llm_model
        self.embed_model = embed_model
        self.kb_dir = kb_dir

        self.embeddings = OllamaEmbeddings(model=embed_model)
        self.llm = OllamaLLM(
            model=llm_model,
            temperature=0.1,
            num_ctx=8192,
            num_predict=512
        )
        self.vectorstore = None

    def load_documents(self, docs_dir: str) -> list:
        """Carica documenti da directory (PDF, TXT, MD)."""
        docs = []

        # Carica TXT e MD
        txt_loader = DirectoryLoader(
            docs_dir, glob="**/*.txt", loader_cls=TextLoader
        )
        docs.extend(txt_loader.load())

        # Carica PDF
        for pdf_file in os.listdir(docs_dir):
            if pdf_file.endswith(".pdf"):
                loader = PyPDFLoader(os.path.join(docs_dir, pdf_file))
                docs.extend(loader.load())

        print(f"Caricati {len(docs)} documenti da {docs_dir}")
        return docs

    def build_knowledge_base(self, docs_dir: str) -> None:
        """Crea e salva la knowledge base da una directory."""
        documents = self.load_documents(docs_dir)

        splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000,
            chunk_overlap=200,
            separators=["\n\n", "\n", " ", ""]
        )
        texts = splitter.split_documents(documents)
        print(f"Creati {len(texts)} chunk")

        self.vectorstore = FAISS.from_documents(texts, self.embeddings)
        self.vectorstore.save_local(self.kb_dir)
        print(f"Knowledge base salvata in {self.kb_dir}")

    def load_knowledge_base(self) -> None:
        """Carica knowledge base esistente da disco."""
        self.vectorstore = FAISS.load_local(
            self.kb_dir, self.embeddings,
            allow_dangerous_deserialization=True
        )
        print(f"Knowledge base caricata: {self.vectorstore.index.ntotal} vettori")

    def create_qa_chain(self) -> RetrievalQA:
        """Crea chain per Q&A su documenti."""
        prompt_template = """Usa il seguente contesto per rispondere alla domanda.
Se non trovi la risposta nel contesto, dì esplicitamente che non lo sai.
Non inventare informazioni non presenti nel contesto.

Contesto:
{context}

Domanda: {question}

Risposta in italiano:"""

        PROMPT = PromptTemplate(
            template=prompt_template,
            input_variables=["context", "question"]
        )

        retriever = self.vectorstore.as_retriever(
            search_type="mmr",  # Maximum Marginal Relevance (più diversificato)
            search_kwargs={"k": 5, "fetch_k": 20}
        )

        return RetrievalQA.from_chain_type(
            llm=self.llm,
            chain_type="stuff",
            retriever=retriever,
            chain_type_kwargs={"prompt": PROMPT},
            return_source_documents=True
        )

    def ask(self, question: str, qa_chain: RetrievalQA) -> dict:
        """Poni una domanda al sistema RAG."""
        result = qa_chain.invoke({"query": question})
        sources = list(set([
            doc.metadata.get("source", "Unknown")
            for doc in result["source_documents"]
        ]))
        return {
            "answer": result["result"],
            "sources": sources,
            "n_docs": len(result["source_documents"])
        }


# Utilizzo:
# rag = OllamaRAGSystem(llm_model="llama3.1:8b")
# rag.build_knowledge_base("./documenti_aziendali")
# chain = rag.create_qa_chain()
# result = rag.ask("Qual e la policy ferie aziendale?", chain)
# print(result["answer"])
# print("Fonti:", result["sources"])

print("Sistema RAG pronto!")

OpenWebUI: Interfaccia ChatGPT per Ollama

OpenWebUI (precedentemente Ollama WebUI) e l'interfaccia più usata per Ollama, con un'esperienza utente identica a ChatGPT ma completamente offline. Supporta chat, upload di documenti, gestione di conversazioni, condivisione di prompt, RAG integrato e modalità multimodale per immagini.

# ================================================================
# SETUP OPENWEBUI CON DOCKER
# ================================================================

# Caso 1: Ollama sullo stesso host
# docker run -d -p 3000:8080 \
#     -v open-webui:/app/backend/data \
#     -e OLLAMA_BASE_URL=http://host.docker.internal:11434 \
#     --name open-webui \
#     ghcr.io/open-webui/open-webui:main

# Caso 2: OpenWebUI con Ollama integrato (tutto in uno)
# docker run -d -p 3000:8080 \
#     -v ollama:/root/.ollama \
#     -v open-webui:/app/backend/data \
#     --gpus all \
#     --name open-webui \
#     ghcr.io/open-webui/open-webui:ollama

# Accesso: http://localhost:3000

# ================================================================
# DOCKER COMPOSE (raccomandato per produzione)
# ================================================================
DOCKER_COMPOSE = """
version: '3.8'

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    environment:
      - OLLAMA_NUM_PARALLEL=4
      - OLLAMA_MAX_LOADED_MODELS=2
    # Per GPU NVIDIA:
    # deploy:
    #   resources:
    #     reservations:
    #       devices:
    #         - driver: nvidia
    #           count: all
    #           capabilities: [gpu]
    restart: unless-stopped

  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: open-webui
    ports:
      - "3000:8080"
    volumes:
      - webui_data:/app/backend/data
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
      - WEBUI_AUTH=True
      - WEBUI_SECRET_KEY=cambia-questa-chiave-segreta
    depends_on:
      - ollama
    restart: unless-stopped

volumes:
  ollama_data:
  webui_data:
"""

# ================================================================
# OLLAMA COME SERVIZIO SYSTEMD (Linux production)
# ================================================================
SYSTEMD_SERVICE = """
# /etc/systemd/system/ollama.service
[Unit]
Description=Ollama LLM Service
After=network-online.target

[Service]
ExecStart=/usr/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="OLLAMA_HOST=0.0.0.0"
Environment="OLLAMA_MODELS=/opt/ollama/models"
Environment="OLLAMA_NUM_PARALLEL=2"
Environment="OLLAMA_MAX_LOADED_MODELS=2"
Environment="OLLAMA_KEEP_ALIVE=10m"

[Install]
WantedBy=default.target
"""

# sudo systemctl enable ollama
# sudo systemctl start ollama
# sudo journalctl -u ollama -f  # Log in real-time

print("Setup Ollama come servizio completato!")

Deployment su Raspberry Pi: Setup Ottimizzato

Il Raspberry Pi 5 con 8 GB di RAM e il dispositivo edge più accessibile per i LLM locali. Con la giusta configurazione, modelli da 1.5B parametri raggiungono 4-5 token/s - sufficiente per molti use case non real-time: chatbot a basso volume, analisi di testo in batch, automazione con trigger eventi.

# ================================================================
# OLLAMA SU RASPBERRY PI 5 (setup ottimizzato)
# ================================================================

# Installazione (identica a Linux x86):
# curl -fsSL https://ollama.com/install.sh | sh

# Configurazione ottimale per RPi5 in /etc/environment:
# OLLAMA_NUM_PARALLEL=1       # Un request alla volta (RAM limitata)
# OLLAMA_MAX_LOADED_MODELS=1  # Un modello in memoria
# OLLAMA_KEEP_ALIVE=5m        # Scarica modello dopo 5 min inattivita
# OLLAMA_NUM_THREAD=4         # Tutti i core Cortex-A76

# Modelli raccomandati per RPi5 (8GB):
# ollama pull qwen2.5:1.5b    (veloce: ~4.5 t/s, 1.8 GB RAM)
# ollama pull llama3.2:1b     (bilanciato: ~5.1 t/s, 1.4 GB RAM)
# ollama pull gemma2:2b       (qualità: ~3.2 t/s, 2.5 GB RAM)

import ollama
import time, statistics, psutil

def benchmark_ollama_rpi(model: str = "qwen2.5:1.5b",
                          n_tests: int = 5):
    """Test velocità e consistenza su RPi."""
    prompt = "Spiega in 3 frasi cos'è il machine learning."
    results = []
    latencies_to_first = []

    print(f"Benchmark {model} su {n_tests} test...")
    for i in range(n_tests):
        t0 = time.time()
        first_token = None
        full_response = ""

        for chunk in ollama.chat(
            model=model,
            messages=[{"role": "user", "content": prompt}],
            stream=True,
            options={"temperature": 0, "top_k": 1, "num_predict": 50}
        ):
            content = chunk['message']['content']
            if content and first_token is None:
                first_token = time.time() - t0
                latencies_to_first.append(first_token * 1000)
            full_response += content

        elapsed = time.time() - t0
        n_tokens = len(full_response.split())  # Approssimazione
        tps = n_tokens / elapsed
        results.append(tps)
        print(f"  Test {i+1}: {tps:.1f} t/s, TTFT: {first_token*1000:.0f}ms")

    mean_tps = statistics.mean(results)
    mean_ttft = statistics.mean(latencies_to_first)
    mem = psutil.virtual_memory()

    print(f"\nRisultati {model} su RPi5:")
    print(f"  Velocita media: {mean_tps:.1f} t/s")
    print(f"  TTFT medio:     {mean_ttft:.0f} ms")
    print(f"  RAM usata:      {mem.used/(1024**3):.1f} GB / {mem.total/(1024**3):.1f} GB")

    return mean_tps

# ================================================================
# AUTOMAZIONE: Aggiornamento modelli e monitoring
# ================================================================
import subprocess, datetime

def update_ollama_models(models: list = ["qwen2.5:1.5b", "nomic-embed-text"]):
    """Aggiorna i modelli Ollama (da eseguire con cron)."""
    log = []
    for model in models:
        print(f"Aggiornamento {model}...")
        result = subprocess.run(
            ["ollama", "pull", model],
            capture_output=True, text=True, timeout=600
        )
        status = "OK" if result.returncode == 0 else "FAIL"
        log.append({
            "model": model,
            "status": status,
            "time": datetime.datetime.now().isoformat()
        })
        print(f"  {model}: {status}")

    return log

# Cron job consigliato (ogni domenica alle 3:00):
# 0 3 * * 0 /usr/bin/python3 /home/pi/update_models.py >> /var/log/ollama-update.log 2>&1

Caso di Studio Reale: Chatbot Aziendale Offline

Un caso d'uso reale: un'azienda che gestisce documenti riservati (contratti, HR policy, manuali tecnici) vuole un chatbot interno senza esporre i dati al cloud. Con Ollama + RAG si costruisce un sistema completamente air-gapped in meno di un giorno.

# ================================================================
# CHATBOT AZIENDALE OFFLINE - Stack completo
# ================================================================

# Stack:
# - Ollama con llama3.1:8b (o qwen2.5:7b per italiano migliore)
# - nomic-embed-text per embeddings
# - FAISS per vector store
# - FastAPI per REST API
# - OpenWebUI per interfaccia utente

# fastapi_chatbot.py
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
from typing import Optional
import ollama, json
from pathlib import Path

app = FastAPI(title="Corporate AI Assistant", version="2.0")

# Stato globale (in produzione usa Redis)
conversation_store = {}

class ChatRequest(BaseModel):
    session_id: str
    message: str
    model: str = "qwen2.5:7b"
    use_rag: bool = True

class ChatResponse(BaseModel):
    session_id: str
    response: str
    sources: list = []
    model: str
    tokens_per_sec: float

@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
    # Recupera storia conversazione
    if request.session_id not in conversation_store:
        conversation_store[request.session_id] = []

    history = conversation_store[request.session_id]

    # Aggiungi contesto RAG se richiesto
    context = ""
    sources = []
    if request.use_rag and rag_system and rag_system.vectorstore:
        docs = rag_system.vectorstore.similarity_search(
            request.message, k=3
        )
        context = "\n\n".join([d.page_content for d in docs])
        sources = list(set([d.metadata.get("source", "") for d in docs]))

        # Inietta contesto nel messaggio
        augmented_message = f"""Contesto dai documenti aziendali:
{context}

Domanda: {request.message}"""
    else:
        augmented_message = request.message

    history.append({"role": "user", "content": augmented_message})

    # Genera risposta
    t0 = time.time()
    response = ollama.chat(
        model=request.model,
        messages=history,
        options={"num_ctx": 8192, "temperature": 0.3}
    )
    elapsed = time.time() - t0

    assistant_msg = response['message']['content']
    history.append({"role": "assistant", "content": assistant_msg})

    # Tronca la storia se troppo lunga (sliding window)
    if len(history) > 20:
        history = history[-20:]
    conversation_store[request.session_id] = history

    eval_count = response.get('eval_count', 50)
    tps = eval_count / elapsed if elapsed > 0 else 0

    return ChatResponse(
        session_id=request.session_id,
        response=assistant_msg,
        sources=sources,
        model=request.model,
        tokens_per_sec=round(tps, 1)
    )

@app.delete("/chat/{session_id}")
async def clear_session(session_id: str):
    """Azzera la storia di una sessione."""
    if session_id in conversation_store:
        del conversation_store[session_id]
    return {"status": "cleared"}

@app.get("/models")
async def list_models():
    """Lista modelli disponibili su questo server Ollama."""
    models = ollama.list()
    return {
        "models": [
            {"name": m['name'], "size_gb": m['size'] / 1e9}
            for m in models['models']
        ]
    }

# Avvio: uvicorn fastapi_chatbot:app --host 0.0.0.0 --port 8080

Confronto Modelli per Use Case Comuni

Use Case Modello Consigliato RAM Min perchè
Chatbot italiano qwen2.5:7b 8 GB Eccellente multilingua, contesto lungo
Code generation qwen2.5-coder:7b 8 GB Fine-tuned su codice, 90+ linguaggi
RAG / Q&A documenti llama3.1:8b 8 GB Ottimo instruction following, 128K context
Ragionamento avanzato deepseek-r1:8b 8 GB Chain-of-thought, math, logica
Raspberry Pi (veloce) llama3.2:1b 2 GB 5+ t/s, task semplici
Raspberry Pi (qualità) qwen2.5:3b 4 GB Bilanciamento ottimale qualità/velocità
Mac M-series (veloce) qwen2.5:14b 16 GB 22+ t/s su M2/M3, qualità vicina a GPT-4
Analisi immagini llava:7b o moondream 8 GB Modelli multimodali ottimizzati per visione

Best Practices per la Produzione

Usare Ollama in produzione richiede alcune considerazioni specifiche rispetto all'uso personale. Ecco i pattern più importanti.

# ================================================================
# PATTERN PRODUZIONE: Load Balancing con più istanze Ollama
# ================================================================

# Se si ha più di un server con Ollama, si può fare load balancing.
# nginx.conf (upstream round-robin):
NGINX_CONFIG = """
upstream ollama_cluster {
    least_conn;  # Instrada alla connessione con meno richieste
    server server1:11434;
    server server2:11434;
    server server3:11434;
}

server {
    listen 80;

    location /api/ {
        proxy_pass http://ollama_cluster;
        proxy_read_timeout 300s;   # Timeout elevato per generazione lunga
        proxy_connect_timeout 10s;
        proxy_set_header Host $host;
    }
}
"""

# ================================================================
# HEALTH CHECK E MONITORING
# ================================================================
import requests, time

def monitor_ollama(host: str = "localhost", port: int = 11434):
    """Controlla disponibilità e carico Ollama."""
    try:
        # API health endpoint
        resp = requests.get(f"http://{host}:{port}/api/tags", timeout=5)
        if resp.status_code == 200:
            models = resp.json().get("models", [])
            print(f"Ollama OK: {len(models)} modelli disponibili")
            return True
    except requests.exceptions.RequestException as e:
        print(f"Ollama NON RAGGIUNGIBILE: {e}")
        return False

# ================================================================
# GESTIONE ERRORI E RETRY
# ================================================================
import functools, random

def with_ollama_retry(max_attempts: int = 3, backoff: float = 1.0):
    """Decorator per retry automatico su errori Ollama."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    wait = backoff * (2 ** attempt) + random.uniform(0, 0.5)
                    print(f"Tentativo {attempt+1} fallito: {e}. Retry in {wait:.1f}s")
                    time.sleep(wait)
        return wrapper
    return decorator

@with_ollama_retry(max_attempts=3, backoff=1.0)
def robust_chat(model: str, message: str) -> str:
    """Chat con retry automatico su errori di rete/timeout."""
    response = ollama.chat(
        model=model,
        messages=[{"role": "user", "content": message}],
        options={"num_predict": 500}
    )
    return response['message']['content']

Limiti e Considerazioni per Produzione

  • Ollama non e multi-tenant by default: in un server condiviso, le richieste vengono serializzate. Imposta OLLAMA_NUM_PARALLEL=4 per gestire richieste concorrenti (richiede più RAM: ~8GB per richiesta con modello 7B).
  • Timeout su RPi con modelli grandi: llama3.1:8b impiega 10-15 secondi per generare la prima risposta su RPi. Usa num_ctx=512 per ridurre il prefill time nei casi time-sensitive. Per TTFT <2s usa modelli da 1-3B.
  • Nessun autoscaling automatico: a differenza delle API cloud, Ollama non scala. Per traffico elevato, usa load balancing con più istanze Ollama su server diversi, oppure valuta vLLM per deployment GPU.
  • Consumo energetico continuo: mantenere Ollama attivo con un modello caricato consuma ~15W su RPi5, ~45W su Jetson Orin NX. Usa OLLAMA_KEEP_ALIVE=0 per scaricare il modello subito dopo ogni richiesta.
  • Sicurezza: Ollama di default non autentica le richieste. In produzione, metti sempre un reverse proxy (nginx) davanti con autenticazione e rate limiting. Non esporre mai la porta 11434 direttamente su internet.

Conclusioni

Ollama ha abbassato la barriera di ingresso all'AI locale a zero. Con un singolo comando puoi avere un LLM competitivo in esecuzione sul tuo laptop, con privacy completa e zero costi API. Il trend verso i Local LLM e inarrestabile: Gartner prevede che entro il 2027 i SLM (Small Language Models) supereranno di 3x i LLM cloud per frequenza di utilizzo, con una riduzione dei costi operativi del 70%.

Per la produzione, Ollama e un ottimo punto di partenza ma richiede alcune considerazioni: gestione della concorrenza, monitoring, aggiornamento dei modelli, sicurezza e integrazione con sistemi esistenti. Il pattern più potente e combinare Ollama con una pipeline RAG per dare al modello accesso a knowledge base private senza inviare dati al cloud.

Il prossimo articolo della serie chiude il cerchio con i Benchmark e Ottimizzazione: come misurare sistematicamente le performance di tutti gli strumenti visti nella serie - quantizzazione, distillazione, pruning, edge deployment - e scegliere la combinazione ottimale per il proprio caso d'uso.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti