La mia chat AI locale con Python e Gemma — Parte 1: il primo prototipo

  • Home
  • AI & ChatBot
  • La mia chat AI locale con Python e Gemma — Parte 1: il primo prototipo
Image

La mia chat AI locale con Python e Gemma — Parte 1: il primo prototipo

Questo articolo apre una serie dedicata alla costruzione, passo dopo passo, di una chat AI completamente locale usando Python, Gemma e gli strumenti che servono per trasformare un semplice esperimento in un’applicazione sempre più completa.

Se hai mai provato a far girare un modello linguistico sul tuo PC, probabilmente conosci già LM Studio, Ollama o strumenti simili. Sono ottimi, ma volevo capire cosa c’è “sotto il cofano” e costruirmi qualcosa di mio, un’app minimale, in Python, in grado di far girare una AI in locale.

Il risultato è una chat locale basata su Gemma 4B con interfaccia web in stile ChatGPT, che gira interamente sul mio computer, nessun dato esce mai dalla macchina. Ecco cosa serve per replicarla, e gli scogli in cui sono inciampato lungo la strada.

L’architettura, in breve

Tre pezzi principali:

  • llama-cpp-python: la libreria che fa girare il modello. Sotto il cofano usa llama.cpp, il motore di inferenza open source diventato lo standard de facto per eseguire LLM in locale, sia su CPU che su GPU.
  • Flask: un micro-framework web Python, per esporre il modello con un’API HTTP e servire una pagina di chat nel browser.
  • Gemma 4B in formato GGUF: la versione del modello di Google DeepMind, quantizzata (compressa) in un formato pensato apposta per llama.cpp.

L’interfaccia è una singola pagina HTML/CSS/JS senza framework, che parla con il backend via fetch e riceve le risposte token per token (streaming), esattamente come nei chatbot commerciali.

I package da installare

Nella pratica, llama-cpp-python non è un pacchetto Python “puro”: contiene codice C++ compilato per parlare direttamente con CPU e GPU, e questo è dove iniziano le complicazioni.

Quando lanci pip install, pip cerca un wheel che è un pacchetto già compilato per il tuo sistema operativo e la tua versione di Python. Se non lo trova, prova a compilare da sorgente, cosa che richiede un compilatore C++ installato (su Windows, gli strumenti di build di Visual Studio). Se non ce l’hai, l’installazione fallisce con errori tipo:

Plaintext
CMake Error: CMAKE_C_COMPILER not set, after EnableLanguage

La soluzione più semplice, che evita del tutto la compilazione, è puntare pip a un indice di wheel già pronti pubblicato dal maintainer del progetto:

Plaintext
# Versione CPU (funziona ovunque)
pip install llama-cpp-python --prefer-binary --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu

# Versione con accelerazione GPU NVIDIA (molto più veloce)
pip install llama-cpp-python --prefer-binary --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124

La scelta tra CPU e GPU dipende dal tuo hardware e dalla pazienza che hai per aspettare le risposte.

l problema di Python 3.14

Il primo inciampo è stato banale ma istruttivo: avevo Python 3.14 appena uscito, e per quella versione non esistono ancora wheel precompilati di llama-cpp-python su nessun indice. Risultato: pip prova sempre a compilare da sorgente, qualunque wheel-index tu usi.

Lezione imparata: per progetti con dipendenze native come questa, conviene restare su una versione di Python “matura” — 3.11 o 3.12 — invece dell’ultima release. Con py -3.11 -m venv venv puoi creare un ambiente virtuale con la versione specifica, anche se sul sistema hai già installato una versione più recente.

GPU sì, ma con un tassello in più: CUDA Toolkit

Avere una GPU NVIDIA non basta. Il driver (quello che ti dà nvidia-smi funzionante e fa girare i giochi) non include le librerie di calcolo generico che programmi come llama-cpp-python usano per parlare con la GPU.

Serve il CUDA Toolkit, che installa le librerie runtime (cudart64_12.dll, cublas64_12.dll, ecc.). Senza di esso, il wheel GPU si installa correttamente ma va in crash all’avvio con un errore di libreria mancante:

FileNotFoundError: Could not find module '...\llama_cpp\lib\llama.dll' (or one of its dependencies)

Un dettaglio interessante emerso strada facendo: LM Studio non ha questo problema perché include il proprio runtime CUDA “incorporato” nel pacchetto dell’app è uno dei motivi per cui è più comodo da installare rispetto a costruirsi qualcosa da zero.

Punto d’attenzione: installa la versione del Toolkit compatibile col wheel che usi (es. CUDA 12.4 per un wheel cu124), non necessariamente l’ultima disponibile sul sito NVIDIA — le versioni troppo recenti (es. 13.x) potrebbero non essere ancora supportate dai wheel precompilati.

Un bug curioso: il crash del banner colorato

Ultimo scoglio, questa volta lato Windows/terminale: Flask stampa all’avvio un banner colorato (“Serving Flask app…”). Su alcune configurazioni di terminale Windows, la libreria che gestisce i colori (colorama) va in crash con un errore criptico:

OSError: Windows error: 6

Il server in realtà si sarebbe avviato correttamente — il crash avviene proprio mentre tenta di scrivere quel messaggio decorativo. La soluzione è disabilitare il banner colorato con poche righe di codice, senza toccare nient’altro nella logica dell’app.

Riassumendo

Di seguito solo i passaggi corretti che hanno funzionato sulla mia configurazione, Win 11, RTX 2070 8GB VRAM:

Plaintext
--
1. Installato Python (v 3.11.9) dal sito python.org, spuntando "Add to PATH"
--
2. Installato CUDA TOOLKIT (v 12.4) dal sito nvidia.com
--
3. Creato un env per la v 3.11.9:

cd "D:\Private Mind"
py -3.11 -m venv venv
venv\Scripts\activate

Testare dall'interno della cartella:
python --version
--
4. Installate le dipendenze Python mancanti:

pip install llama-cpp-python --prefer-binary --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121

pip install flask
--

app.py carica automaticamente il primo file .gguf trovato in models/, espone un endpoint /api/chat che risponde in streaming, e apre da solo il browser all’avvio. Il numero di layer offloadati sulla GPU è configurabile con una singola variabile (N_GPU_LAYERS = -1 per usarla tutta).

static/index.html è una chat minimale in HTML/CSS/JS puro, senza dipendenze esterne, che mostra le risposte token per token man mano che arrivano.

Plaintext
gemma-chat-app/
├── app.py              # backend Flask + inferenza
├── static/
│   └── index.html      # interfaccia chat
├── models/             # qui va il modello .gguf (scaricato a parte)
└── venv/               # setup versione python

app.py

Plaintext
"""
Gemma Chat - backend locale
----------------------------
Server Flask che carica un modello Gemma in formato GGUF tramite
llama-cpp-python e lo espone via API HTTP con risposta in streaming.
Alla partenza apre automaticamente il browser sull'interfaccia di chat.
"""

import json
import os
import sys
import threading
import webbrowser
from glob import glob

from flask import Flask, Response, request, send_from_directory
from llama_cpp import Llama

# ---------------------------------------------------------------------------
# Configurazione
# ---------------------------------------------------------------------------

# Quando l'app viene "congelata" con PyInstaller, i file vengono estratti
# in una cartella temporanea indicata da sys._MEIPASS. In sviluppo normale
# usiamo semplicemente la cartella dello script.
BASE_DIR = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))

# La cartella "models" invece NON deve stare dentro il bundle PyInstaller
# (sarebbe enorme): la cerchiamo accanto all'eseguibile finale.
if getattr(sys, "frozen", False):
    APP_DIR = os.path.dirname(sys.executable)
else:
    APP_DIR = os.path.dirname(os.path.abspath(__file__))

MODELS_DIR = os.path.join(APP_DIR, "models")
STATIC_DIR = os.path.join(BASE_DIR, "static")

PORT = 5001
N_CTX = 4096          # dimensione del contesto
N_THREADS = os.cpu_count() or 4
N_GPU_LAYERS = -1     # -1 = scarica tutti i layer possibili sulla GPU se disponibile

SYSTEM_PROMPT = "Sei un assistente utile, conciso e onesto. Rispondi in italiano."

# ---------------------------------------------------------------------------
# Caricamento modello
# ---------------------------------------------------------------------------

def find_model_path() -> str:
    """Trova il primo file .gguf nella cartella models/."""
    candidates = glob(os.path.join(MODELS_DIR, "*.gguf"))
    if not candidates:
        raise FileNotFoundError(
            f"Nessun file .gguf trovato in '{MODELS_DIR}'. "
            "Scarica un modello Gemma in formato GGUF e mettilo in quella cartella."
        )
    return candidates[0]


print("Caricamento modello in corso, potrebbe richiedere qualche secondo...")
MODEL_PATH = find_model_path()
print(f"Modello: {MODEL_PATH}")

llm = Llama(
    model_path=MODEL_PATH,
    n_ctx=N_CTX,
    n_threads=N_THREADS,
    n_gpu_layers=N_GPU_LAYERS,
    verbose=False,
)
print("Modello caricato. Server pronto.")

# ---------------------------------------------------------------------------
# App Flask
# ---------------------------------------------------------------------------

app = Flask(__name__, static_folder=STATIC_DIR, static_url_path="")


@app.route("/")
def index():
    return send_from_directory(STATIC_DIR, "index.html")


@app.route("/api/chat", methods=["POST"])
def chat():
    """
    Riceve: {"messages": [{"role": "user"/"assistant", "content": "..."}, ...]}
    Restituisce: testo in streaming (text/event-stream semplificato)
    """
    data = request.get_json(force=True)
    history = data.get("messages", [])

    messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history

    def generate():
        stream = llm.create_chat_completion(
            messages=messages,
            stream=True,
            temperature=0.7,
            max_tokens=1024,
        )
        for chunk in stream:
            delta = chunk["choices"][0]["delta"]
            token = delta.get("content", "")
            if token:
                # Server-Sent-Events style: ogni riga "data: ..." + riga vuota
                yield f"data: {json.dumps({'token': token})}\n\n"
        yield "data: [DONE]\n\n"

    return Response(generate(), mimetype="text/event-stream")


def open_browser():
    webbrowser.open(f"http://127.0.0.1:{PORT}")


if __name__ == "__main__":
    # Su alcuni terminali Windows, il banner colorato che Flask stampa
    # all'avvio va in crash (bug noto colorama/click, OSError: Windows
    # error 6). Lo disabilitiamo: e' solo estetico, non serve al
    # funzionamento del server.
    try:
        from flask import cli as flask_cli
        flask_cli.show_server_banner = lambda *args, **kwargs: None
    except Exception:
        pass

    threading.Timer(1.0, open_browser).start()
    print(f"Server in ascolto su http://127.0.0.1:{PORT}")
    app.run(host="127.0.0.1", port=PORT, threaded=True)

Quello che sembrava un progetto da weekend si è rivelato un buon ripasso pratico su come funzionano davvero i pacchetti Python con dipendenze native, la differenza tra driver e toolkit su una GPU, e quanto i tool “consumer” come LM Studio nascondano complessità reale sotto un’interfaccia semplice.

Il risultato però vale lo sforzo: una chat AI completamente locale, veloce (grazie all’accelerazione GPU), senza abbonamenti né dati che lasciano il computer e soprattutto, capita fino in fondo, pezzo per pezzo.

Se l’articolo ti è piaciuto restiamo in contatto su linkedin a: https://www.linkedin.com/in/andreatonin/

Releated Posts

La mia chat AI locale con Python e Gemma — Parte 4: salvare le conversazioni senza database

Questa quarta parte racconta come ho aggiunto il salvataggio. Il codice è banal, è JSON su disco, non…

DiByAndrea Tonin Ago 5, 2026

La mia chat AI locale con Python e Gemma — Parte 3: i prompt di sistema multipli

Questa terza parte racconta la prima funzionalità vera costruita sopra quel refactoring: la possibilità di scegliere, prima di…

DiByAndrea Tonin Ago 5, 2026

La mia chat AI locale con Python e Gemma — Parte 2: refactoring

Quando chiedi a Claude Code di generare un’applicazione, di solito ottieni qualcosa di funzionante. Ma c’è un “ma”……

DiByAndrea Tonin Ago 4, 2026

Gamescom 2026: il futuro dei videogiochi passa dal lavoro degli sviluppatori

Dal 26 al 30 agosto 2026, Colonia tornerà a essere uno dei principali punti d’incontro dell’industria videoludica internazionale.…

DiByAndrea Tonin Ago 3, 2026