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:
- Flag
enabled: Disabilita i provider senza rimuovere la configurazione type: Può esserehttp_apio basato su SSH (per inference remota)endpoint: URL del servizio del providerapiKey: Referenziata come env var (${OPENAI_API_KEY}) per sicurezzamodels: Modelli disponibili con relative capacità e costidefaultModel: Utilizzato se non specificato nella richiesta- Flag
local: Contrassegna i provider che non richiedono internet 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:
- La connessione SSH effettua l'autenticazione
- La richiesta viene incanalata verso la macchina remota
- La macchina remota chiama la OpenAI API
- La risposta torna indietro attraverso il tunnel
- 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:
- RAD System (open-source): Github source code
- RAG System (codice non disponibile): Github RAG System Overview