Agenti AI con Memoria Persistente: Architetture e Codice

Agenti AI con Memoria Persistente: Architetture e Codice

Perché la Memoria Persistente negli Agenti AI nel 2026

Il salto di qualità più importante nell’evoluzione degli agenti AI non è stato il ragionamento o il tool use — è stata la memoria persistente. Un agente senza memoria è come un collaboratore che dimentica tutto ogni mattina: capace, ma inutile per task complessi e continuativi.

Nel 2026 la distinzione tra un agente “giocattolo” e uno production-ready si gioca proprio qui. Gli agenti moderni devono ricordare le preferenze utente tra sessioni diverse, accumulare contesto su task multi-step, imparare dai propri errori e mantenere stati complessi distribuiti su più tool call. Se hai già letto la guida su come creare un agente AI con JavaScript e Claude, sai che costruire un agente base è relativamente semplice. La memoria persistente, invece, richiede scelte architetturali precise.

Esistono quattro tipi di memoria che un agente può gestire: memoria episodica (conversazioni passate), memoria semantica (fatti e conoscenze), memoria procedurale (come eseguire task) e memoria di lavoro (contesto attuale). Ogni tipo richiede un approccio di storage diverso. In questo articolo vediamo le architetture concrete, con codice reale e funzionante che puoi integrare oggi stesso nei tuoi agenti.

Architetture di Memoria: Dalle Più Semplici alle Più Robuste

Prima di scrivere codice, serve chiarire l’architettura. Le soluzioni di memoria per agenti AI si dividono in tre macro-categorie, ognuna con trade-off precisi.

In-process (in-memory): dati tenuti in RAM nella stessa sessione del processo. Velocissimo, ma volatile. Adatto per memoria di lavoro e contesto breve.

Database relazionale/key-value: SQLite, PostgreSQL, Redis. Persistente tra riavvii, strutturato, query potenti. Ideale per memoria episodica e preferenze utente.

Vector store: Pinecone, Chroma, pgvector. Ricerca semantica per similarità. Indispensabile per memoria semantica su grandi corpus. Un pattern complementare all’approccio RAG — se non l’hai già letto, dai un’occhiata alla guida RAG con Claude API per capire come integrare retrieval e generazione.

La scelta dipende dal caso d’uso. Per la maggior parte degli agenti business, una combinazione di SQLite (memoria episodica) + vector store leggero (Chroma locale) copre il 90% dei casi d’uso senza infrastruttura costosa.

Implementazione Base: Memoria Episodica con SQLite

Partiamo dall’architettura più semplice ed efficace per un agente con memoria persistente: SQLite per storicizzare le conversazioni e recuperarle nelle sessioni future. Il pattern è semplice — ogni messaggio viene salvato con metadata (user_id, session_id, timestamp, role) e recuperato quando serve.

// memory/episodic.js
import Database from "better-sqlite3";
import path from "path";

const DB_PATH = path.join(process.cwd(), "data", "agent_memory.db");

export class EpisodicMemory {
  constructor(userId) {
    this.userId = userId;
    this.db = new Database(DB_PATH);
    this.init();
  }

  init() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        session_id TEXT NOT NULL,
        role TEXT NOT NULL,
        content TEXT NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
      );
      CREATE INDEX IF NOT EXISTS idx_user_session
        ON messages(user_id, session_id);
    `);
  }

  save(sessionId, role, content) {
    const stmt = this.db.prepare(
      `INSERT INTO messages (user_id, session_id, role, content)
       VALUES (?, ?, ?, ?)`
    );
    stmt.run(this.userId, sessionId, role, content);
  }

  // Recupera le ultime N sessioni per costruire il contesto
  getRecentContext(limit = 20) {
    const rows = this.db
      .prepare(
        `SELECT role, content, created_at
         FROM messages
         WHERE user_id = ?
         ORDER BY created_at DESC
         LIMIT ?`
      )
      .all(this.userId, limit);

    return rows.reverse(); // ordine cronologico
  }

  // Cerca conversazioni passate per keyword
  searchByKeyword(query) {
    return this.db
      .prepare(
        `SELECT role, content, created_at, session_id
         FROM messages
         WHERE user_id = ? AND content LIKE ?
         ORDER BY created_at DESC
         LIMIT 10`
      )
      .all(this.userId, `%${query}%`);
  }
}

Il pattern sopra è intenzionalmente minimale. La chiave è il metodo getRecentContext(): viene chiamato prima di ogni nuova richiesta all’LLM per iniettare nella system prompt il contesto delle interazioni passate.

Memoria Semantica con Vector Store: Ricerca per Significato

La memoria episodica con keyword search funziona bene, ma ha un limite evidente: cerca per parole esatte. Se l’utente ha parlato di “budget marketing” in passato e ora chiede “quanto posso spendere per le campagne”, la keyword search non trova nulla. Serve la ricerca semantica.

La soluzione è embeddare ogni messaggio significativo con un modello di embedding e salvarlo in un vector store. Al momento della query, si embeddano le parole dell’utente e si cercano i vettori più vicini per cosine similarity.

# memory/semantic_memory.py
import chromadb
import anthropic
from datetime import datetime

client_ai = anthropic.Anthropic()
chroma_client = chromadb.PersistentClient(path="./data/chroma")

class SemanticMemory:
    def __init__(self, user_id: str):
        self.user_id = user_id
        self.collection = chroma_client.get_or_create_collection(
            name=f"user_{user_id}_memories",
            metadata={"hnsw:space": "cosine"}
        )

    def _embed(self, text: str) -> list[float]:
        # In produzione usa voyage-3 o text-embedding-3-small
        # Esempio: from sentence_transformers import SentenceTransformer
        # model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
        # return model.encode(text).tolist()
        return []  # sostituisci con embedding reale

    def store(self, content: str, metadata: dict = None):
        # Salva un fatto o conversazione importante
        doc_id = f"{self.user_id}_{datetime.now().timestamp()}"
        embedding = self._embed(content)

        self.collection.add(
            ids=[doc_id],
            documents=[content],
            embeddings=[embedding] if embedding else None,
            metadatas=[{
                "user_id": self.user_id,
                "timestamp": datetime.now().isoformat(),
                **(metadata or {})
            }]
        )

    def search(self, query: str, n_results: int = 5) -> list[dict]:
        # Cerca memorie semanticamente simili alla query
        query_embedding = self._embed(query)

        results = self.collection.query(
            query_embeddings=[query_embedding] if query_embedding else None,
            query_texts=[query] if not query_embedding else None,
            n_results=n_results,
            where={"user_id": self.user_id}
        )

        return [
            {"content": doc, "metadata": meta}
            for doc, meta in zip(
                results["documents"][0],
                results["metadatas"][0]
            )
        ]

Nota importante: in produzione usa un modello di embedding dedicato come voyage-3 di Anthropic (ottimizzato per use case AI) o text-embedding-3-small di OpenAI. Non usare l’LLM principale per gli embedding — è troppo costoso e lento.

Pattern Avanzato: Memory Manager Unificato

In un sistema reale, non usi memoria episodica o semantica — le usi insieme. Il MemoryManager è il layer che coordina i due sistemi e decide quale usare in base al contesto della query. È anche il punto in cui implementi la compressione della memoria: quando il contesto episodico diventa troppo lungo, il manager chiede all’LLM di sintetizzarlo in un riassunto compatto.

// memory/manager.js
import Anthropic from "@anthropic-ai/sdk";
import { EpisodicMemory } from "./episodic.js";

const claude = new Anthropic();

export class MemoryManager {
  constructor(userId) {
    this.episodic = new EpisodicMemory(userId);
    this.userId = userId;
    // In produzione aggiungi: this.semantic = new SemanticMemory(userId)
  }

  // Costruisce la system prompt arricchita di contesto
  async buildContext(currentQuery) {
    const recentMessages = this.episodic.getRecentContext(10);

    if (recentMessages.length === 0) {
      return { systemAddendum: "", history: [] };
    }

    const historyText = recentMessages
      .map(m => `${m.role}: ${m.content}`)
      .join("\n");

    const tokenEstimate = historyText.split(" ").length * 1.3;

    let summary = "";
    if (tokenEstimate > 2000) {
      summary = await this._summarize(historyText);
    }

    return {
      systemAddendum: summary
        ? `\n\nRiassunto conversazioni precedenti:\n${summary}`
        : "",
      history: summary ? [] : recentMessages.slice(-6),
    };
  }

  // Sintetizza la storia per ridurre i token
  async _summarize(historyText) {
    const response = await claude.messages.create({
      model: "claude-haiku-4-5",
      max_tokens: 500,
      messages: [
        {
          role: "user",
          content: `Sintetizza in 3-5 punti chiave questa conversazione,
                    mantenendo solo le informazioni rilevanti per future interazioni:

                    ${historyText}`,
        },
      ],
    });
    return response.content[0].text;
  }

  // Salva automaticamente ogni scambio
  saveExchange(sessionId, userMessage, assistantReply) {
    this.episodic.save(sessionId, "user", userMessage);
    this.episodic.save(sessionId, "assistant", assistantReply);
  }
}

Il pattern di compressione adattiva è fondamentale: invece di troncare brutalmente la storia (perdendo contesto), usi lo stesso LLM per distillare l’essenziale. Questo riduce i token del 70-80% mantenendo la coerenza conversazionale.

Questo approccio si integra naturalmente con il prompt caching di Claude — se non l’hai ancora esplorato, la guida al prompt caching in Claude API mostra come ridurre i costi del 90% sui prompt ripetitivi, che è esattamente ciò che accade con le system prompt arricchite di memoria.

Agente Completo con Memoria: Esempio End-to-End

Mettiamo tutto insieme in un agente funzionante. L’esempio mostra un agente per assistenza clienti che ricorda le preferenze dell’utente tra sessioni diverse — lo stesso pattern che potresti integrare in un sistema più articolato come quello descritto nella guida sul tool use con Claude API per agenti con strumenti reali.

// agent.js - Agente con memoria persistente completo
import Anthropic from "@anthropic-ai/sdk";
import { MemoryManager } from "./memory/manager.js";
import { randomUUID } from "crypto";

const claude = new Anthropic();

export class PersistentAgent {
  constructor(userId) {
    this.userId = userId;
    this.sessionId = randomUUID();
    this.memory = new MemoryManager(userId);
    this.tools = this._defineTools();
  }

  _defineTools() {
    return [
      {
        name: "save_user_preference",
        description: "Salva una preferenza o fatto importante dell'utente",
        input_schema: {
          type: "object",
          properties: {
            key: { type: "string", description: "Nome della preferenza" },
            value: { type: "string", description: "Valore della preferenza" },
          },
          required: ["key", "value"],
        },
      },
      {
        name: "recall_preference",
        description: "Recupera una preferenza salvata dell'utente",
        input_schema: {
          type: "object",
          properties: {
            key: { type: "string" },
          },
          required: ["key"],
        },
      },
    ];
  }

  async _executeTool(name, input) {
    if (name === "save_user_preference") {
      this.memory.episodic.save(
        this.sessionId,
        "system",
        `PREFERENZA: ${input.key} = ${input.value}`
      );
      return { saved: true };
    }
    if (name === "recall_preference") {
      const results = this.memory.episodic.searchByKeyword(
        `PREFERENZA: ${input.key}`
      );
      return { value: results[0]?.content || "Non trovato" };
    }
    return { error: "Tool non trovato" };
  }

  async chat(userMessage) {
    // 1. Recupera contesto dalla memoria
    const { systemAddendum, history } = await this.memory.buildContext(
      userMessage
    );

    // 2. Costruisce messaggi con storia
    const messages = [
      ...history.map((m) => ({
        role: m.role === "user" || m.role === "assistant" ? m.role : "user",
        content: m.content,
      })),
      { role: "user", content: userMessage },
    ];

    // 3. Chiama Claude con contesto + tools
    let response = await claude.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 1024,
      system: `Sei un assistente AI con memoria persistente.
               Ricordi le conversazioni passate e le preferenze dell'utente.
               ${systemAddendum}`,
      tools: this.tools,
      messages,
    });

    // 4. Gestisce tool calls in loop
    while (response.stop_reason === "tool_use") {
      const toolUses = response.content.filter((b) => b.type === "tool_use");
      const toolResults = await Promise.all(
        toolUses.map(async (tu) => ({
          type: "tool_result",
          tool_use_id: tu.id,
          content: JSON.stringify(await this._executeTool(tu.name, tu.input)),
        }))
      );

      messages.push({ role: "assistant", content: response.content });
      messages.push({ role: "user", content: toolResults });

      response = await claude.messages.create({
        model: "claude-opus-4-5",
        max_tokens: 1024,
        system: `Sei un assistente AI con memoria persistente.${systemAddendum}`,
        tools: this.tools,
        messages,
      });
    }

    const assistantReply = response.content
      .filter((b) => b.type === "text")
      .map((b) => b.text)
      .join("");

    // 5. Salva lo scambio nella memoria
    this.memory.saveExchange(this.sessionId, userMessage, assistantReply);

    return assistantReply;
  }
}

// Uso:
// const agent = new PersistentAgent("user_123");
// const reply = await agent.chat("Preferisco risposte in italiano");
// // Nella sessione successiva, crea nuovo agente con stesso userId
// // e l'agente ricordera' la preferenza

Gestione della Memoria a Lungo Termine: Strategie di Pruning

Un agente in produzione accumula dati nel tempo. Senza una strategia di pruning (potatura), il database cresce senza limite e le query rallentano. Esistono tre approcci consolidati:

  • TTL (Time-To-Live): i messaggi più vecchi di N giorni vengono eliminati automaticamente. Semplice, ma può eliminare informazioni ancora rilevanti.
  • Importance scoring: ogni messaggio riceve uno score di importanza (calcolato dall’LLM o con euristiche). Solo i messaggi oltre una soglia vengono mantenuti a lungo termine.
  • Summarization periodica: ogni settimana (o ogni N messaggi), un processo batch sintetizza le conversazioni vecchie in un profilo utente compatto. È il pattern più robusto per agenti production-grade.
# memory/pruning.py
import sqlite3
from datetime import datetime, timedelta
import anthropic

def prune_old_messages(db_path: str, days_to_keep: int = 30):
    # Elimina messaggi piu' vecchi di N giorni
    cutoff = datetime.now() - timedelta(days=days_to_keep)

    with sqlite3.connect(db_path) as conn:
        deleted = conn.execute(
            "DELETE FROM messages WHERE created_at < ?",
            (cutoff.isoformat(),)
        ).rowcount

    print(f"Eliminati {deleted} messaggi piu' vecchi di {days_to_keep} giorni")

def build_user_profile(db_path: str, user_id: str) -> str:
    # Sintetizza tutta la storia dell'utente in un profilo compatto.
    # Da eseguire periodicamente (es. ogni settimana via cron).
    client = anthropic.Anthropic()

    with sqlite3.connect(db_path) as conn:
        rows = conn.execute(
            "SELECT role, content FROM messages "
            "WHERE user_id = ? AND role != 'system' "
            "ORDER BY created_at ASC LIMIT 200",
            (user_id,)
        ).fetchall()

    if not rows:
        return ""

    history_text = "\n".join([f"{r[0]}: {r[1]}" for r in rows])

    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=800,
        messages=[{
            "role": "user",
            "content": (
                "Analizza questa cronologia conversazionale e crea "
                "un profilo utente strutturato in JSON con:\n"
                "- preferenze linguistiche e di comunicazione\n"
                "- argomenti di interesse ricorrenti\n"
                "- competenze tecniche dimostrate\n"
                "- preferenze operative (es. formati risposta preferiti)\n\n"
                f"Cronologia:\n{history_text[:4000]}\n\n"
                "Rispondi SOLO con il JSON, nessun testo aggiuntivo."
            )
        }]
    )

    return response.content[0].text

Il profilo utente generato può essere iniettato come parte fissa della system prompt — è una forma di long-term memory compatta che non cresce nel tempo. Per architetture più complesse che richiedono un’orchestrazione di più agenti con memoria condivisa, vale la pena guardare anche alla guida su Claude API per automatizzare workflow da developer dove vengono mostrati pattern di composizione avanzati.

FAQ e Domande Frequenti

Qual è la differenza tra memoria episodica e semantica in un agente AI?

La memoria episodica registra eventi specifici e sequenze di conversazioni: “il 5 marzo l’utente ha chiesto X, ho risposto Y”. È temporale e contestuale. La memoria semantica invece immagazzina fatti e concetti senza ancoraggio temporale: “l’utente preferisce Python a JavaScript”, “il progetto si chiama Andromeda”. In pratica, un agente robusto usa entrambe: l’episodica per ricostruire il contesto recente, la semantica per accedere a fatti chiave indipendentemente da quando sono stati detti.

Quanto costa mantenere la memoria persistente in termini di token?

Il costo dipende dalla strategia di recupero. Se inietti tutta la storia raw nella system prompt, i costi esplodono rapidamente. La soluzione è usare un approccio ibrido: un profilo utente compatto (200-500 token, sempre presente) + recupero selettivo solo dei messaggi rilevanti alla query attuale (300-800 token). Con prompt caching attivo su Claude, i token ripetitivi costano il 90% in meno, rendendo la memoria persistente economicamente sostenibile anche a scale elevate.

SQLite o PostgreSQL per la memoria degli agenti?

SQLite è la scelta giusta per la maggior parte dei progetti: zero configurazione, file singolo, eccellente per carichi di lettura intensi (tipici degli agenti), e funziona benissimo fino a qualche GB di dati. PostgreSQL diventa necessario quando hai accesso concorrente da più processi/server (deploy multi-istanza), quando vuoi usare pgvector per unire memoria episodica e semantica in un unico database, o quando il volume di dati supera i 10-20 GB. Per un agente single-tenant o con traffico moderato, SQLite è superiore in semplicità operativa.

Come si gestisce la privacy e il GDPR con la memoria persistente?

La memoria persistente crea obblighi GDPR concreti: l’utente ha diritto all’accesso, alla rettifica e alla cancellazione dei propri dati. Dal punto di vista implementativo: separazione netta per user_id (mai mescolare dati di utenti diversi), implementazione di un endpoint DELETE /user/:id/memory che elimina tutti i record, crittografia at rest del database SQLite (SQLCipher), e log di accesso ai dati. Per agenti che gestiscono dati sensibili (sanità, finanza), valuta l’approccio a memoria client-side: i dati rimangono nel browser/dispositivo dell’utente e vengono inviati al server solo al momento della query.

Conclusione

La memoria persistente trasforma un agente AI da strumento usa-e-getta a collaboratore digitale che migliora nel tempo. Le architetture che abbiamo visto — episodica con SQLite, semantica con vector store, memory manager unificato con compressione adattiva — coprono i pattern più comuni in produzione.

Il punto di partenza pratico: implementa prima la memoria episodica semplice con SQLite e il pattern di compressione. Aggiungi la memoria semantica solo quando hai un caso d’uso che richiede davvero ricerca per significato (e non per keyword). Infine, implementa il pruning e la generazione del profilo utente prima di andare in produzione — il database cresce più velocemente di quanto pensi.

Il passo successivo naturale è orchestrare più agenti con memoria condivisa: un territorio che esploreremo nei prossimi articoli della serie sugli agenti avanzati. Nel frattempo, se vuoi approfondire come strutturare l’intero ciclo di vita di un agente, la guida al Claude Agent SDK in produzione è il riferimento più completo disponibile.

Suggerimenti e Risorse

🔧 Tool consigliato: better-sqlite3 è la libreria SQLite più performante per Node.js — interfaccia sincrona, 2-3x più veloce di node-sqlite3. Perfetta per memoria episodica dove le query sono rapide e frequenti.

💡 Pro tip: Separa sempre la memoria di sistema (preferenze tecniche, fatti oggettivi) dalla memoria conversazionale (storia dei dialoghi). Usa role system per i fatti e role user/assistant per la storia. Questa distinzione semplifica enormemente il retrieval selettivo.

🎯 Strategia: Per agenti B2B con più utenti, implementa memoria a due livelli: memoria globale dell’organizzazione (prodotti, processi, terminology) condivisa tra tutti + memoria individuale per utente. La memoria globale rimane nel system prompt fisso; quella individuale viene recuperata dinamicamente.

Condividi

Articoli Recenti

Categorie popolari