Libertà di Scelta

Architettura Configuration-Driven per il Supporto Multi-LLM

Negli ultimi tre articoli hai imparato a conoscere un backend solido, embeddings veloci e un frontend accattivante. Ora arriva il momento che rende questo sistema veramente straordinario: puoi cambiare LLM provider senza toccare il codice.

Vuoi usare GPT-4 di OpenAI per un contesto e far girare Ollama localmente per un altro? Configuration. Hai bisogno di testare Anthropic Claude prima di impegnarti? Aggiorna un file JSON. Lavori su una rete ristretta dove le cloud API non sono ammesse? n8n ha la soluzione per te.

Questo articolo riguarda l'architettura che rende l'agnosticismo del provider non solo possibile, ma elegante.

La Filosofia Provider Agnostic

La maggior parte dei sistemi RAG utilizza chiamate LLM hardcoded:



# Bad: Vincolato a OpenAI

from openai import OpenAI
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
response = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": question}]
)

Questo approccio comporta delle conseguenze:

  • Cambiare provider richiede modifiche al codice
  • API di provider diversi implicano percorsi di codice differenti
  • Testare alternative è complicato
  • Lock-in su funzionalità specifiche del provider

L'approccio del Sistema RAG:


Configuration Layer (decide: quale provider, quale modello, dove inviare le richieste)
         ↓
Provider Interface Layer (normalizza richieste e risposte)
         ↓
n8n Orchestration Layer (instrada al servizio appropriato)
         ↓
Provider-Specific Services (FastAPI, LM Studio, ecc.)

La chiave: la configurazione guida il comportamento. Nessuna modifica al codice dopo il deployment.

Provider Supportati

Il sistema supporta:

Cloud APIs

  • OpenAI: GPT-4, GPT-3.5-turbo, GPT-4 Turbo
  • Google Gemini: Gemini 2.0 Flash, Pro, Vision
  • Anthropic Claude: Claude 3.5 Sonnet, Opus, Haiku
  • Groq: Modelli ottimizzati per la velocità di inference

Local Engines

  • Ollama: Gestione semplice dei modelli locali (llama2, mistral, ecc.)
  • LM Studio: GUI + API per modelli locali
  • vLLM: LLM server ad alta produttività
  • llama.cpp: Inference C++ ottimizzata

Custom HTTP Endpoints

  • Qualsiasi servizio che segua un'interfaccia standard

Struttura della Configurazione

Le configurazioni del provider risiedono nel database all'interno della tabella rag_config, identificate dalla chiave PROVIDER_CONFIG:


{
  "version": "1.0.0",
  "providers": {
    "openai": {
      "enabled": true,
      "type": "http_api",
      "endpoint": "[https://api.openai.com/v1"](https://api.openai.com/v1");,
      "apiKey": "{ANTHROPIC_API_KEY}",
      "models": [
        {
          "id": "claude-3-5-sonnet",
          "name": "Claude 3.5 Sonnet",
          "type": "chat",
          "costPer1kTokens": 0.015,
          "contextWindow": 200000
        }
      ],
      "defaultModel": "claude-3-5-sonnet"
    }
  },
  "routing": {
    "default": "openai",
    "contextSpecific": {
      "hr_policies": "openai",
      "internal_wiki": "ollama",
      "sensitive_data": "ollama"
    },
    "fallback": "openai"
  }
}

Punti Chiave:

  1. Flag enabled: Disabilita i provider senza rimuovere la configurazione
  2. type: Può essere http_api o basato su SSH (per inference remota)
  3. endpoint: URL del servizio del provider
  4. apiKey: Referenziata come env var (${OPENAI_API_KEY}) per sicurezza
  5. models: Modelli disponibili con relative capacità e costi
  6. defaultModel: Utilizzato se non specificato nella richiesta
  7. Flag local: Contrassegna i provider che non richiedono internet
  8. routing: Quale provider usare per contesto + strategia di fallback

Come Funziona: Il Routing Layer

Quando un utente pone una domanda:


User Query
    ↓
NestJS Backend /chat/query endpoint
    ↓
Estrazione: question, contextId, modelId opzionale
    ↓
RagProxyService.proxyQuery(dto)
    ↓
Controllo regole di routing:
  - Il modelId è specificato? Usa quel provider
  - È definito un routing specifico per il contesto? Usa quello
  - Fallback al provider "default"
    ↓
Costruzione della richiesta normalizzata per il provider scelto
    ↓
n8n Workflow: Embed query, Search Qdrant, Retrieve context
    ↓
Invio di context + question all'LLM scelto
    ↓
La risposta dell'LLM viene normalizzata in un formato standard
    ↓
Ritorno al frontend

Il Servizio di Routing


@Injectable()
export class LlmRoutingService {
  constructor(
    private providerConfigService: ProviderConfigService,
    private httpService: HttpService
  ) {}

  /**
   * Determina quale provider usare per questa richiesta
   */
  resolveProvider(
    requestedModelId?: string,
    contextId?: number
  ): IProviderConfig {
    const config = this.providerConfigService.getConfig();

    // 1. Richiesta modello esplicito
    if (requestedModelId) {
      for (const [providerName, providerConfig] of Object.entries(config.providers)) {
        if (providerConfig.enabled) {
          const model = providerConfig.models.find(m => m.id === requestedModelId);
          if (model) {
            return providerConfig;
          }
        }
      }
    }

    // 2. Routing specifico per contesto
    if (contextId && config.routing.contextSpecific[contextId]) {
      const providerName = config.routing.contextSpecific[contextId];
      const provider = config.providers[providerName];
      if (provider?.enabled) {
        return provider;
      }
    }

    // 3. Provider di default
    const defaultProviderName = config.routing.default;
    const defaultProvider = config.providers[defaultProviderName];
    if (defaultProvider?.enabled) {
      return defaultProvider;
    }

    // 4. Fallback: Primo provider abilitato
    for (const [providerName, providerConfig] of Object.entries(config.providers)) {
      if (providerConfig.enabled) {
        return providerConfig;
      }
    }

    throw new Error('No LLM provider available');
  }

  /**
   * Ottiene il formato request/response per uno specifico provider
   */
  getNormalizer(provider: IProviderConfig): LlmNormalizer {
    switch (provider.name) {
      case 'openai':
        return new OpenAiNormalizer();
      case 'claude':
        return new ClaudeNormalizer();
      case 'gemini':
        return new GeminiNormalizer();
      case 'ollama':
        return new OllamaNormalizer();
      default:
        return new GenericHttpNormalizer();
    }
  }
}

Provider Normalizers: Astrarre le Differenze

Provider diversi hanno formati di request/response differenti. Un normalizer converte tra di essi.

Formato OpenAI (standard de facto)


{
  "model": "gpt-4",
  "messages": [
    {"role": "system", "content": "You are helpful..."},
    {"role": "user", "content": "What is RAG?"}
  ],
  "temperature": 0.7,
  "max_tokens": 500
}

Risposta:


{
  "choices": [{
    "message": {"role": "assistant", "content": "RAG is..."},
    "finish_reason": "stop"
  }],
  "usage": {"prompt_tokens": 50, "completion_tokens": 100}
}

Formato Claude (Struttura differente)


{
  "model": "claude-3-5-sonnet-20241022",
  "max_tokens": 500,
  "system": "You are helpful...",
  "messages": [
    {"role": "user", "content": "What is RAG?"}
  ]
}

Risposta:


{
  "content": [{"type": "text", "text": "RAG is..."}],
  "usage": {"input_tokens": 50, "output_tokens": 100}
}

Pattern Normalizer


interface LlmNormalizer {
  normalize(query: string, context: string): any;
  denormalize(response: any): { answer: string; usage: Usage };
}

class OpenAiNormalizer implements LlmNormalizer {
  normalize(query: string, context: string): any {
    return {
      model: "gpt-4",
      messages: [
        { role: "system", content: `Context:\n${context}` },
        { role: "user", content: query }
      ],
      temperature: 0.7,
      max_tokens: 1000
    };
  }

  denormalize(response: any): { answer: string; usage: Usage } {
    return {
      answer: response.choices[0].message.content,
      usage: {
        inputTokens: response.usage.prompt_tokens,
        outputTokens: response.usage.completion_tokens
      }
    };
  }
}

class ClaudeNormalizer implements LlmNormalizer {
  normalize(query: string, context: string): any {
    return {
      model: "claude-3-5-sonnet-20241022",
      max_tokens: 1000,
      system: `Context:\n${context}`,
      messages: [
        { role: "user", content: query }
      ]
    };
  }

  denormalize(response: any): { answer: string; usage: Usage } {
    return {
      answer: response.content[0].text,
      usage: {
        inputTokens: response.usage.input_tokens,
        outputTokens: response.usage.output_tokens
      }
    };
  }
}

class OllamaNormalizer implements LlmNormalizer {
  normalize(query: string, context: string): any {
    return {
      model: "llama2:13b",
      prompt: `${context}\n\nQuestion: ${query}\n\nAnswer:`,
      stream: false
    };
  }

  denormalize(response: any): { answer: string; usage: Usage } {
    return {
      answer: response.response,
      usage: {
        inputTokens: response.prompt_eval_count || 0,
        outputTokens: response.eval_count || 0
      }
    };
  }
}

Inference Remota basata su SSH

Per le organizzazioni con reti isolate (senza internet), il modulo rag-ssh abilita l'inference remota su una macchina sicura.

Scenario:

  • Il sistema RAG principale si trova in una rete ristretta
  • Una macchina remota (con internet) esegue i servizi LLM
  • Un tunnel SSH li connette in modo sicuro


# Sulla macchina remota

ssh -R 10.0.0.1:8000:localhost:8000 main-rag-server@10.0.0.1

# Ora http://localhost:8000 sul RAG Principale = http://localhost:8000 sul Remoto

Nella configurazione:


{
  "providers": {
    "openai_remote": {
      "enabled": true,
      "type": "ssh",
      "sshHost": "10.0.0.1",
      "sshPort": 22,
      "sshUser": "rag_service",
      "sshKeyPath": "/secure/keys/rag_ssh_key",
      "localPort": 8000,
      "remoteEndpoint": "[https://api.openai.com/v1"](https://api.openai.com/v1");
    }
  }
}

Quando una query utilizza questo provider:

  1. La connessione SSH effettua l'autenticazione
  2. La richiesta viene incanalata verso la macchina remota
  3. La macchina remota chiama la OpenAI API
  4. La risposta torna indietro attraverso il tunnel
  5. Nessun dato sensibile transita sulla rete locale

Modalità Hybrid: Provider Diversi per Contesti Diversi

Il sistema RAG eccelle nelle configurazioni ibride:


{
  "routing": {
    "contextSpecific": {
      "public_docs": "openai",           // Veloce, alta qualità
      "internal_policies": "ollama",     // Locale, nessun dato in uscita
      "research_papers": "claude",       // Migliore per context lungo
      "customer_data": "ollama_isolated" // Airgapped, massima sicurezza
    }
  }
}

Vantaggi:

  • Ottimizzazione dei costi: Usa API costose per query complesse, locale economico per quelle semplici
  • Privacy dei dati: I dati sensibili rimangono su Ollama locale
  • Performance: Instrada le analisi complesse verso i modelli migliori
  • Compliance: Contesti diversi seguono regole diverse di gestione dati

Valutazione del Modello e Analisi dei Costi

La configurazione include le capacità del modello e i costi:


const models = config.providers.openai.models;

// Trova il modello più conveniente per questa query
const cheapest = models.reduce((a, b) =>
  (a.costPer1kTokens < b.costPer1kTokens) ? a : b
);

// Trova il modello di qualità migliore
const bestQuality = models.reduce((a, b) =>
  (a.qualityScore > b.qualityScore) ? a : b
);

// Routing adattivo basato sulla lunghezza della query
const estimatedTokens = question.length / 4; // Stima approssimativa
if (estimatedTokens > 10000) {
  // Usa un modello con una context window più ampia
  return models.find(m => m.contextWindow > 20000);
}

Gli amministratori possono aggiornare /admin/models per aggiungere dati sui costi ricavati dalle chiamate API recenti:


{
  "models": [
    {
      "id": "gpt-4",
      "actualCostPer1kTokens": 0.031,  // Aggiornato dall'ultima fatturazione mensile
      "avgLatency": 1500,               // millisecondi
      "errorRate": 0.002,               // 0.2%
      "quality": 9.5                    // scala 1-10
    }
  ]
}

Report dei Costi


@Injectable()
export class CostTrackingService {
  async trackQuery(
    provider: string,
    model: string,
    inputTokens: number,
    outputTokens: number
  ): Promise<void> {
    const config = this.providerConfigService.getConfig();
    const modelConfig = config.providers[provider]
      .models.find(m => m.id === model);

    if (!modelConfig) return;

    const costPerM = modelConfig.costPer1mTokens || 0;
    const totalTokens = inputTokens + outputTokens;
    const cost = (totalTokens / 1_000_000) * costPerM;

    // Log per fatturazione e analytics
    await this.costRepository.save({
      provider,
      model,
  0     inputTokens,
      outputTokens,
      cost,
      timestamp: new Date()
    });
  }
}

Genera report di utilizzo:


async getCostReport(startDate: Date, endDate: Date): Promise<CostReport> {
  const entries = await this.costRepository.find({
    where: { timestamp: Between(startDate, endDate) }
  });

  const byProvider = groupBy(entries, 'provider');
  const byModel = groupBy(entries, 'model');

  return {
    totalCost: entries.reduce((sum, e) => sum + e.cost, 0),
    byProvider: Object.entries(byProvider).map(([provider, costs]) => ({
      provider,
      totalCost: costs.reduce((sum, c) => c.cost, 0),
      count: costs.length
    })),
    byModel: Object.entries(byModel).map(([model, costs]) => ({
      model,
      totalCost: costs.reduce((sum, c) => c.cost, 0),
      count: costs.length
    }))
  };
}

Strategie di Fallback

E se il provider principale non è raggiungibile?


async proxyQuery(dto: RagQueryDto): Promise<any> {
  const config = this.providerConfigService.getConfig();
 
  // Costruisce i candidati provider in ordine di preferenza
  const candidates = [
    config.routing.contextSpecific[dto.contextId],
    config.routing.default,
    ...Object.keys(config.providers) // Tutti gli altri come fallback
  ].filter(name => config.providers[name]?.enabled);

  for (const providerName of candidates) {
    try {
      const provider = config.providers[providerName];
      const normalizer = this.getNormalizer(provider);
     
      const normalized = normalizer.normalize(dto.question, dto.context);
      const response = await this.callProvider(provider, normalized);
     
      return normalizer.denormalize(response);
    } catch (error) {
      this.logger.warn(
        `Provider ${providerName} failed: ${error.message}`
      );
      // Prova il provider successivo
      continue;
    }
  }

  throw new Error('All LLM providers failed');
}

Configurazione:


{
  "routing": {
    "default": "openai",
    "fallback": "claude",  // Se openai fallisce
    "tertiaryFallback": "ollama"  // Se entrambi falliscono
  }
}

Monitoraggio e Alerting

Traccia lo stato di salute (health) del provider:


@Injectable()
export class ProviderHealthService {
  async checkProviderHealth(): Promise<ProviderStatus[]> {
    const config = this.providerConfigService.getConfig();
    const statuses: ProviderStatus[] = [];

    for (const [name, provider] of Object.entries(config.providers)) {
      let status: 'healthy' | 'degraded' | 'down' = 'healthy';
      let latency = 0;
      let errorRate = 0;

      try {
        const startTime = Date.now();
        await this.pingProvider(provider);
        latency = Date.now() - startTime;

        // Ottiene il tasso di errore dalle ultime 100 query
        const recentErrors = await this.getRecentErrorCount(name);
        errorRate = recentErrors / 100;

        if (latency > 5000 || errorRate > 0.1) {
          status = 'degraded';
        }
      } catch (error) {
        status = 'down';
      }

      statuses.push({
        provider: name,
        status,
        latency,
        errorRate,
        timestamp: new Date()
      });
    }

    return statuses;
  }

  async generateHealthReport(): Promise {
    const statuses = await this.checkProviderHealth();
   
    for (const status of statuses) {
      if (status.status === 'down') {
        await this.alertService.sendAlert(
          `Provider ${status.provider} is DOWN`,
          `severity: critical`
        );
      } else if (status.status === 'degraded') {
        await this.alertService.sendAlert(
          `Provider ${status.provider} is degraded`,
          `latency: ${status.latency}ms, error_rate: ${status.errorRate}`
        );
      }
    }
  }
}

Prompt Engineering Per Provider

Modelli diversi traggono beneficio da prompt differenti:


interface PromptTemplate {
  system: string;
  userPrefix: string;
  citations: string;
}

const promptTemplates = {
  'openai': {
    system: 'You are a helpful assistant. Answer based on provided context.',
    userPrefix: 'Based on the following context:\n',
    citations: '\nCite specific sources [1], [2], etc.'
  },
  'claude': {
    system: 'You are Claude, an AI assistant made by Anthropic.',
    userPrefix: 'I have the following context:\n',
    citations: '\nPlease include citations to source materials.'
  },
  'ollama': {
    system: 'You are a helpful AI assistant.',
    userPrefix: 'Context:\n',
    citations: '\nIncludecitations.'
  }
};

function buildPrompt(provider: string, question: string, context: string): string {
  const template = promptTemplates[provider];
  return `${template.system}\n\n${template.userPrefix}${context}\n\nQuestion: ${question}${template.citations}`;
}

Guardando al Futuro

Il layer di LLM provider switching rende il tuo sistema resiliente, conveniente e a prova di futuro. Non stai scommettendo su OpenAI. Non sei vincolato all'inference locale per tutto.

Nell'ultimo articolo, metteremo tutto insieme: DevOps, Deployment e Scaling. Docker, Kubernetes, monitoraggio e il passaggio di questo sistema dal laptop alla produzione.

---

Punti Chiave:

Configuration-driven: Cambia provider senza modifiche al codice

Interfacce normalizzate: Astrae le differenze tra i provider

Routing ibrido: Contesti diversi utilizzano provider diversi

Tracciamento dei costi: Monitora la spesa tra i vari provider

Strategie di fallback: Il sistema rimane attivo quando un provider fallisce

Inference remota: Tunnel SSH per reti isolate

Monitoraggio della salute: Alert quando le prestazioni dei provider degradano

Basta dire "Possiamo usare solo OpenAI". Ora la frase è "Possiamo usare chiunque abbia senso usare".

---

GitHub: