Phoenix (Arize) notebook-first observability: experimentation vs production monitoring
Apri Jupyter, importi due righe di codice, e dopo cinque secondi hai una dashboard live nel notebook che mostra ogni chiamata LLM del tuo agent: prompt, completamento, latenza, token, embedding del retrieval. Niente container, niente database, niente API key. Questa è la promessa di Arize Phoenix, e nei mesi in cui ho riscritto il modulo RAG del mio ml-service FastAPI è diventato lo strumento che apro per primo quando voglio capire perché una risposta è sbagliata. Phoenix non vuole sostituire Langfuse o Helicone: vuole occupare lo spazio che quelli non coprono, ovvero la fase di prototipazione interattiva dove un data scientist gira venti varianti di prompt o di chunking strategy in un pomeriggio e ha bisogno di feedback visuale immediato.
Nell'episodio precedente abbiamo deployato Langfuse self-hosted per il monitoring continuo di produzione: dashboard sempre accesa, persistenza Postgres, prompt management, retention multi-mese. Quel mondo è il mondo dei picchi di latenza alle 3 del mattino, dei cost SLA, dei multi-tenant audit trail. Phoenix invece è il mondo del laptop, della funzione che richiami con kernel restart, del grafico UMAP che ti fa scoprire che le query in tedesco erano in un cluster isolato dal corpus italiano. Sono due strumenti complementari, non concorrenti: condividono lo stesso protocollo OpenInference (specifica OpenTelemetry per LLM), quindi gli stessi span possono finire in entrambi senza overhead.
Questo articolo è la guida operativa che avrei voluto trovare quando ho introdotto Phoenix nel mio workflow. Setup pip e launch in notebook, auto-instrumentation di LangChain e Ollama via OpenInference, evaluation LLM-as-judge con llama3.1:8b come giudice locale (zero costo verso provider terzi, in linea con il vincolo Ollama-only del mio stack), visualizzazione UMAP degli embedding, dataset upload con benchmark A/B fra retriever, e infine il pattern ibrido che uso in produzione: double-write OTLP verso Phoenix container e Langfuse self-hosted. Niente marketing, niente "Phoenix risolve tutto": solo i comandi, gli snippet e le decisioni che mi hanno fatto risparmiare giornate di debug.
Cosa Imparerai
- Lanciare Phoenix inline in Jupyter con due righe di codice e visualizzare trace LLM in tempo reale senza alcun container
- Auto-instrumentare LangChain, LlamaIndex e Ollama via OpenInference senza scrivere span manuali, con instrumentor pip-installabili
- Eseguire evaluation LLM-as-judge su batch di trace usando llama3.1:8b on-prem come giudice (privacy preservata, costo zero)
- Proiettare embedding di query e documenti in 2D con UMAP per identificare cluster mal coperti dal corpus retrieval
- Caricare un dataset di test su Phoenix e confrontare due run sperimentali (A/B prompt, A/B retriever) side-by-side
- Implementare il pattern ibrido double-write Phoenix dev + Langfuse prod via OTLP exporter dual senza duplicare instrumentazione
- Decidere quando usare Phoenix (sperimentazione, debug RAG, A/B test) vs Langfuse (monitoring continuo, alerting, audit) in base al ciclo di lavoro
- Strutturare un workflow completo dev-to-prod: prototipo con Phoenix, freeze configurazione, deploy con tracing Langfuse
Prerequisiti
- Aver letto EP02 della serie per il contesto su tracing OpenTelemetry e cost tracking
- Ambiente Python 3.10+ con virtualenv o conda funzionante e pip aggiornato
- Familiarità con Jupyter Notebook o JupyterLab e con il concetto di kernel
- Conoscenza base di RAG (retrieval-augmented generation), embedding semantici e differenza fra dense e sparse retriever
- Comprensione del modello a span e trace di OpenTelemetry (concetti di parent span, child span, attributes)
Sezione 1 - Notebook-first design: filosofia e setup in due righe
Phoenix nasce esplicitamente come strumento di esplorazione interattiva dentro un notebook. Questa scelta architetturale ha conseguenze profonde: non c'è un server da deployare, non c'è autenticazione, non c'è un Postgres da configurare. Quando lanci Phoenix in modalità inline, l'oggetto Python px.Session avvia un piccolo server HTTP locale (porta 6006 di default) e renderizza la UI in un iframe dentro la cella Jupyter. Tutti i trace stanno in memoria del kernel: chiudi il notebook, scompaiono. Questo è un feature, non un bug, perché ti libera dalla cerimonia DevOps quando stai prototipando.
Per chi viene da Langfuse o LangSmith, il salto mentale è capire che Phoenix è progettato per sessioni di lavoro di durata limitata (una mattinata di debug, un pomeriggio di A/B test) non per persistenza multi-mese. Se vuoi persistere i trace, hai tre opzioni in ordine di complessità: salvare il dataframe trace in Parquet con session.get_spans_dataframe(), usare backend SQLite locale (PHOENIX_WORKING_DIR), oppure deployare il container Docker standalone. La maggior parte dei miei flussi usa la prima: pickle dei trace alla fine della sessione, ricaricamento il giorno dopo.
L'installazione è disarmante:
# Installa Phoenix e gli instrumentor OpenInference che useremo
# Versione 6.x è quella stabile a inizio 2026
pip install arize-phoenix==6.4.0
pip install openinference-instrumentation-langchain==0.1.32
pip install openinference-instrumentation-openai==0.1.20
pip install openinference-instrumentation-llama-index==2.2.4
# Optional: per visualizzazione UMAP embedding (richiede umap-learn e plotly)
pip install "arize-phoenix[embeddings]"
# Verifica installazione
python -c "import phoenix as px; print(px.__version__)"
# Output atteso: 6.4.0
Una nota di pragmatismo: pinno sempre la versione major perché Phoenix è in sviluppo attivo e fra una minor e l'altra possono cambiare le API degli evaluator. Negli ultimi sei mesi ho dovuto aggiornare due volte i miei script perché era cambiata la signature di llm_classify. Il pin esplicito ti salva da CI/CD che si rompono di colpo. Lo stesso vale per gli instrumentor: vivono in pacchetti separati con versionamento indipendente.
Ora il momento della verità: lanciamo Phoenix dentro Jupyter. Apri un notebook e scrivi:
# Lancia Phoenix inline nel notebook Jupyter
# Apre un iframe con la UI live, listener su porta 6006
import phoenix as px
# Avvio sessione: spawn server HTTP locale + iframe in output cella
session = px.launch_app()
# La sessione rimane attiva finché il kernel è vivo
# Per accedere alla UI da browser esterno: http://localhost:6006
print(f"Phoenix UI disponibile a: {session.url}")
# Per terminare manualmente (raro):
# session.end()
Se tutto ha funzionato, vedi un iframe con la dashboard Phoenix vuota: nessun trace ancora, ma l'endpoint OTLP è già listening su http://localhost:6006/v1/traces pronto a ricevere span. Da qui in poi qualsiasi instrumentor OpenInference che configuri parlerà con questa istanza locale. Se sei su un server remoto via SSH, fai port forwarding (ssh -L 6006:localhost:6006 user@server) e accedi al browser locale. Questa modalità è quella che uso quando faccio debug RAG sul VPS dove gira Ollama: lancio Jupyter remoto, port forwarding, e ho la UI nel browser di casa.
Sezione 2 - In-memory evaluation e auto-instrumentation OpenInference
Il vero salto di produttività di Phoenix arriva quando colleghi gli instrumentor OpenInference ai framework che già usi. Un instrumentor è un pacchetto Python che monkey-patcha il framework target (LangChain, LlamaIndex, openai SDK, ecc.) e inietta span OpenTelemetry attorno a ogni operazione semantica: chat completion, embedding, retrieval, tool call. Non scrivi una riga di codice di tracing manuale: l'instrumentor lo fa per te con conoscenza specifica di cosa è "interessante" in quel framework.
Esempio concreto sul mio workflow: ho una pipeline RAG con LangChain che orchestra ChatOllama, retriever Redis HNSW e re-ranker cross-encoder. Senza instrumentor dovrei scrivere a mano una decina di context manager tracer.start_as_current_span(...). Con l'instrumentor LangChain, due righe e ho tutto:
# Auto-instrumentazione LangChain con OpenInference
# Tutti i passi della chain diventano span gerarchici in Phoenix
from openinference.instrumentation.langchain import LangChainInstrumentor
from phoenix.otel import register
# Registra tracer provider che punta all'endpoint Phoenix locale
# endpoint default: http://localhost:6006/v1/traces (OTLP HTTP)
tracer_provider = register(
project_name="rag-ml-service-debug",
endpoint="http://localhost:6006/v1/traces",
set_global_tracer_provider=True,
)
# Aggancia l'instrumentor: da ora ogni chain.invoke() genera span
LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
# Ora puoi usare LangChain normalmente, gli span vanno in Phoenix
from langchain_community.chat_models import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
# Modello locale on-prem (rispetta vincolo Ollama-only del mio stack)
llm = ChatOllama(model="llama3.1:8b", base_url="http://localhost:11434")
prompt = ChatPromptTemplate.from_messages([
("system", "Sei assistente tecnico esperto di Spring Boot."),
("user", "{question}"),
])
# Esecuzione chain: span padre + figli automatici (prompt + LLM call)
chain = prompt | llm
risposta = chain.invoke({"question": "Cos'è @Transactional readOnly true?"})
# In Phoenix UI vedi: 1 trace, 3 span (chain, prompt format, ollama call)
# con token in/out, latenza, modello, prompt completo
print(risposta.content[:200])
Aprendo l'iframe Phoenix dopo questa cella vedi un trace con tre span gerarchici: il container LangChain, la formattazione prompt, la chiamata ChatOllama. Ogni span ha attributi standard OpenInference (llm.model_name, llm.token_count.prompt, llm.token_count.completion, llm.invocation_parameters) più gli specifici LangChain (input variables, output schema). Cliccando su uno span vedi prompt e completion in chiaro, latenza in millisecondi, errori se ci sono.
La parte "in-memory evaluation" è dove Phoenix brilla rispetto ai sistemi di monitoring puro. Hai i trace in dataframe pandas, puoi scriverci sopra qualsiasi script di analisi: filtrare le risposte sotto N token, raggruppare per modello, calcolare percentili di latenza. E soprattutto puoi scorare i trace con un altro LLM come giudice direttamente nel notebook, in batch parallelo, senza uscire dall'ambiente Python.
# Evaluation LLM-as-judge con Ollama locale come giudice
# Phoenix.evals esegue il giudice in parallelo su batch di trace
from phoenix.evals import (
OpenAIModel,
llm_classify,
HALLUCINATION_PROMPT_TEMPLATE,
HALLUCINATION_PROMPT_RAILS_MAP,
)
from phoenix.session.evaluation import add_evaluations
import phoenix as px
# Recupera dataframe dei trace dalla sessione attiva
spans_df = px.Client().get_spans_dataframe(project_name="rag-ml-service-debug")
# Per usare Ollama come giudice locale, sfruttiamo il client OpenAI compatible
# Ollama espone /v1/chat/completions su porta 11434
from openai import OpenAI
ollama_client = OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama-local", # placeholder, Ollama non valida
)
# Wrapper che Phoenix.evals chiama in parallelo per ogni trace
class OllamaJudgeModel:
"""Adapter Phoenix-compatibile per llama3.1:8b come giudice."""
def __call__(self, prompt: str) -> str:
risposta = ollama_client.chat.completions.create(
model="llama3.1:8b",
messages=[{"role": "user", "content": prompt}],
temperature=0.0, # giudice deterministico
max_tokens=10,
)
return risposta.choices[0].message.content
# Esegui classificazione hallucination su tutti i trace
# rails: lista valori ammessi nella risposta del giudice
risultati_eval = llm_classify(
dataframe=spans_df,
template=HALLUCINATION_PROMPT_TEMPLATE,
model=OllamaJudgeModel(),
rails=list(HALLUCINATION_PROMPT_RAILS_MAP.values()),
concurrency=4, # parallelismo: 4 chiamate Ollama simultanee
provide_explanation=True,
)
# Pubblica i risultati nella UI Phoenix come "score" per ogni trace
add_evaluations(
span_evaluations=risultati_eval,
annotation_name="hallucination_check",
)
# In Phoenix UI ora ogni trace ha colonna "hallucination_check" con label + spiega
print(f"Trace valutati: {len(risultati_eval)}")
print(risultati_eval["label"].value_counts())
Tre considerazioni operative su questo snippet. Primo: il giudice deve essere deterministico (temperature=0.0), altrimenti due run dello stesso eval danno score diversi e perdi la riproducibilità. Secondo: la concurrency va calibrata sulla capacità del modello giudice locale; nel mio caso llama3.1:8b su VPS Hetzner CCX23 regge 4 chiamate parallele senza degrado, sopra 6 satura GPU CPU offload. Terzo: llama3.1:8b come giudice è meno preciso di GPT-4, ma per regression test interni e CI/CD è più che sufficiente, e ha il vantaggio enorme di non far uscire i prompt verso provider terzi (vincolo non negoziabile del mio stack, vedi Ollama-only).
I tipi di evaluator che Phoenix supporta sono tre famiglie principali, riassunti in tabella:
| Tipo evaluator | Come funziona | Pro | Contro | Quando usarlo |
|---|---|---|---|---|
| LLM judge | Un secondo LLM scora la risposta in base a prompt template (es. hallucination, relevance, toxicity) | Flessibile, semantico, copre dimensioni soggettive | Costoso (ogni eval = 1 chiamata LLM), variabile, lento | Dimensioni qualitative non rule-based: faithfulness RAG, coerenza, tono |
| Code-based | Funzione Python che applica regola deterministica (regex, keyword match, JSON schema validate, fact-check su DB) | Veloce, gratis, riproducibile al 100%, debuggabile | Limitato a regole esprimibili in codice, non coglie sfumature semantiche | Validazione formato output, presenza citazioni, lunghezza, blacklist parole |
| Embedding similarity | Calcolo distanza coseno tra embedding della risposta e embedding della ground truth o del context recuperato | Veloce, deterministico, scala bene su batch grandi (10K+ trace) | Misura solo similarità lessicale-semantica, non correttezza fattuale | Drift detection, similarity al gold standard, novelty score |
| Hybrid (LLM + code) | Pre-filtro code-based su grossi volumi, LLM judge solo sul subset sospetto | Costo controllato, copertura piena, evita LLM eval su casi banali | Pipeline più complessa da mantenere, due strati di logica | Produzione ad alto volume con budget eval limitato |
La mia regola pratica nel ml-service: code-based (validazione JSON output dei tool RAG) + embedding similarity (drift settimanale rispetto al gold dataset di 200 query) + LLM judge (solo su trace flaggati come anomali dai primi due). Questo mi tiene il costo eval sotto i 10 minuti per ciclo CI, contro le 2 ore di LLM judge puro su tutti i 1000+ trace.
Sezione 3 - Embedding visualization UMAP per debug RAG
Una capacità che Langfuse non ha (almeno fino a v3.x) e che è la killer feature di Phoenix per chi fa RAG seriamente: la visualizzazione degli embedding in 2D con UMAP. UMAP (Uniform Manifold Approximation and Projection) è un algoritmo di dimensionality reduction che proietta vector di dimensione alta (384, 768, 1536) in uno spazio 2D o 3D preservando la struttura locale. In pratica: se due embedding sono "vicini" semanticamente nello spazio originale, restano vicini nella proiezione 2D che vedi.
Perché ti interessa? Perché vedi a colpo d'occhio se le query degli utenti cadono dentro o fuori la nuvola degli embedding del corpus. Se le query in tedesco sono in un cluster isolato lontano dai documenti italiani, sai che il retriever non sta trovando documenti rilevanti per gli utenti DACH. Se il 30% delle query è in un'area "vuota" dove non ci sono documenti, hai un knowledge gap nel corpus. Questo tipo di insight è praticamente impossibile da ottenere guardando solo metriche aggregate (recall@k, MRR).
# Visualizzazione UMAP di embedding query vs documenti corpus
# Identifica cluster e gap di copertura nel retrieval
import phoenix as px
from phoenix import Schema, EmbeddingColumnNames
import pandas as pd
import numpy as np
# Carica embedding query degli utenti (output del modello sentence-transformers)
# Schema atteso: id, testo originale, vector (list[float]), timestamp, lang
df_query = pd.DataFrame({
"query_id": ["q1", "q2", "q3", "q4"],
"testo": [
"Come faccio Spring Boot @Transactional?",
"Wie funktioniert RAG mit Vector DB?",
"Setup Postgres pgvector",
"JWT refresh token best practice",
],
"lingua": ["it", "de", "it", "en"],
"embedding": [np.random.rand(384).tolist() for _ in range(4)],
"timestamp": pd.date_range("2026-05-01", periods=4),
})
# Carica embedding documenti corpus (output stesso modello, indicizzati in Redis HNSW)
df_docs = pd.DataFrame({
"doc_id": ["d1", "d2", "d3"],
"testo": ["Articolo Spring", "Articolo RAG", "Articolo Postgres"],
"lingua": ["it", "it", "it"],
"embedding": [np.random.rand(384).tolist() for _ in range(3)],
"timestamp": pd.date_range("2026-04-01", periods=3),
})
# Schema Phoenix: dichiara colonne semantiche per il dataset
schema = Schema(
timestamp_column_name="timestamp",
feature_column_names=["lingua"],
tag_column_names=["testo"],
embedding_feature_column_names={
"vector": EmbeddingColumnNames(
vector_column_name="embedding",
raw_data_column_name="testo",
),
},
)
# Crea due dataset: production (query) e reference (corpus)
ds_query = px.Inferences(dataframe=df_query, schema=schema, name="query_utenti")
ds_corpus = px.Inferences(dataframe=df_docs, schema=schema, name="corpus_articoli")
# Lancia Phoenix con entrambi i dataset
# La UI ora ha tab "Embeddings" con proiezione UMAP combinata
session = px.launch_app(primary=ds_query, reference=ds_corpus)
print(f"Apri {session.url} → tab Embeddings → cluster query vs corpus")
Aprendo la UI vai sulla tab "Embeddings", selezioni la feature vector, e Phoenix ti mostra una proiezione UMAP combinata: punti blu sono i query utenti, punti grigi sono i documenti del corpus. Puoi colorare per lingua, vedere la distribuzione temporale, cliccare su un cluster per leggere i testi raw. Nel mio ml-service questa visualizzazione mi ha mostrato che le query in tedesco erano in un cluster isolato perché gli embedding multilingual all-MiniLM-L6-v2 mappano lingue diverse in subspace differenti. Soluzione: ho aggiunto un layer di traduzione query (DeepL self-hosted) prima dell'embedding, e il recall @5 sugli utenti DACH è salito del 22%.
Tre note pratiche su UMAP. Primo: la proiezione è non deterministica di default, quindi due run danno layout leggermente diversi (la struttura cluster è stabile, le coordinate assolute no). Secondo: per dataset oltre 50K embedding la proiezione è lenta (decine di secondi); usa il sub-sampling nelle settings UI. Terzo: UMAP preserva struttura locale ma non distanze globali: due cluster lontani nella proiezione potrebbero essere vicini nello spazio originale. Per analisi quantitative usa metriche dirette (cosine similarity), per intuizione visuale UMAP è perfetto.
Sezione 4 - Dataset upload e benchmark A/B su sperimenti
Il workflow che differenzia un team che usa Phoenix come "log viewer" da uno che lo usa come banco di test ML è il pattern Dataset + Experiment. L'idea: definisci un dataset di riferimento (input + output atteso o solo input), poi ogni run del tuo sistema RAG diventa un "experiment" che produce risposte e score. Phoenix tiene traccia di tutti gli experiment sullo stesso dataset, ti permette di confrontarli side-by-side e capire se la modifica X ha migliorato o peggiorato.
Nel mio caso uso questo pattern per A/B test fra due retriever (HNSW puro vs HNSW + cross-encoder re-rank), fra due chunking strategy (semantic vs fixed-size), fra due prompt template (system message verbose vs conciso). Senza Phoenix farei file Excel e copia-incolla: con Phoenix è una funzione Python.
# Upload dataset di test e run di esperimenti A/B
# Confronto due strategie chunking sullo stesso set di 50 query
import phoenix as px
from phoenix.experiments import run_experiment
from phoenix.experiments.types import Example
# Definisci dataset di riferimento: input query + ground truth atteso
dataset_test = [
Example(
input={"query": "Come configuro Bucket4j su Spring Boot?"},
output={"expected_keywords": ["RateLimiter", "Bandwidth", "@Bean"]},
),
Example(
input={"query": "JWT refresh token rotation best practice?"},
output={"expected_keywords": ["refresh_token", "rotate", "blacklist"]},
),
# ... altre 48 esempi nel dataset reale
]
# Crea il dataset in Phoenix (persistito se backend SQLite, in memoria altrimenti)
client = px.Client()
dataset_id = client.upload_dataset(
dataset_name="rag_quality_v3_2026_05",
inputs=[ex.input for ex in dataset_test],
outputs=[ex.output for ex in dataset_test],
)
# Definisci due "task" rappresentativi degli A/B
def task_chunking_semantic(example: Example) -> dict:
"""Task A: retrieval con chunking semantico (split per sezione)."""
from ml_service.rag import retrieve_semantic, generate_answer
chunks = retrieve_semantic(example.input["query"], top_k=5)
risposta = generate_answer(example.input["query"], chunks)
return {"answer": risposta, "chunks_used": len(chunks)}
def task_chunking_fixed(example: Example) -> dict:
"""Task B: retrieval con chunking fixed-size (512 token windows)."""
from ml_service.rag import retrieve_fixed, generate_answer
chunks = retrieve_fixed(example.input["query"], top_k=5)
risposta = generate_answer(example.input["query"], chunks)
return {"answer": risposta, "chunks_used": len(chunks)}
# Definisci evaluator: keyword presence sulla risposta
def eval_keyword_recall(output: dict, expected: dict) -> float:
"""Calcola % di keyword attese presenti nella risposta."""
answer_lower = output["answer"].lower()
keywords = expected["expected_keywords"]
presenti = sum(1 for kw in keywords if kw.lower() in answer_lower)
return presenti / len(keywords)
# Esegui i due esperimenti sullo stesso dataset
exp_a = run_experiment(
dataset=dataset_id,
task=task_chunking_semantic,
evaluators=[eval_keyword_recall],
experiment_name="chunking_semantic_v1",
)
exp_b = run_experiment(
dataset=dataset_id,
task=task_chunking_fixed,
evaluators=[eval_keyword_recall],
experiment_name="chunking_fixed_v1",
)
# Confronto in UI: vai su Experiments → seleziona entrambi → Compare
print(f"Experiment A keyword recall: {exp_a.summary['eval_keyword_recall'].mean():.2%}")
print(f"Experiment B keyword recall: {exp_b.summary['eval_keyword_recall'].mean():.2%}")
La UI Experiments di Phoenix ti mostra una tabella con una riga per esempio del dataset e una colonna per experiment, evidenziando in rosso/verde dove i due divergono. Vedi a colpo d'occhio quali query hanno beneficiato del chunking semantico e quali sono regrediti. Questo tipo di analisi qualitativa è esattamente quello che serve quando devi giustificare a uno stakeholder "perché abbiamo cambiato strategia di chunking": un grafico con keyword recall passato dal 62% al 78% è un argomento concreto.
Sezione 5 - Experimentation vs production monitoring: quando Phoenix, quando Langfuse
Arriviamo alla domanda strategica che lega questo articolo all'episodio precedente: quando uso Phoenix e quando uso Langfuse? La risposta breve: Phoenix per fase di sperimentazione e debug, Langfuse per monitoring continuo di produzione. La risposta lunga richiede di confrontare i due tool per use case specifico, perché la sovrapposizione c'è ma le forze sono diverse.
| Use case | Phoenix (notebook exp) | Langfuse (production dashboard) | Vincitore |
|---|---|---|---|
| Debug RAG su 50 query in pomeriggio di analisi | Iframe in notebook, dataframe pandas, rerun rapido | Dashboard web, query SQL su Postgres, dovrebbe entrare via UI | Phoenix (workflow notebook nativo) |
| Monitoring continuo di app in produzione 24/7 | Sessione muore quando chiudi notebook, no alerting | Dashboard sempre attiva, retention multi-mese, alerting Slack | Langfuse (designed per persistenza) |
| Visualizzazione UMAP embedding per analisi cluster | Nativo, plot interattivo, color by feature, zoom | Non disponibile (solo metriche aggregate, no proiezione) | Phoenix (killer feature unica) |
| Prompt versioning e A/B test su feature flag | Niente prompt management built-in | Prompt registry, tag production, rollback istantaneo | Langfuse (designed per prompt mgmt) |
| Cost tracking aggregato per modello e per utente | Calcolo manuale via dataframe pandas | Cost calc automatico per modello, dashboard per user | Langfuse (built-in pricing tables) |
| Evaluation A/B fra due retriever su dataset gold | Experiments UI nativa, comparison side-by-side | Possibile via Datasets feature, meno fluido in notebook | Phoenix (workflow experimentation nativo) |
| Audit trail multi-tenant per compliance GDPR/SOC2 | No autenticazione, no role separation | RBAC, project isolation, audit log | Langfuse (enterprise ready) |
| Alerting su latency spike o error rate | Niente alerting | Alerting via webhook, integrazione PagerDuty/Slack | Langfuse (production focused) |
| Onboarding nuovo data scientist in team | pip install + 2 righe codice = già operativo | Necessario VPN, account, project setup | Phoenix (zero friction) |
| Test di regression in CI/CD pipeline | SDK Python perfetto per pytest, container Docker headless | Possibile ma overhead setup, meno notebook-friendly | Phoenix (integrazione pytest naturale) |
Il pattern che è emerso nel mio workflow ml-service è chiaro: Phoenix vince ovunque ci sia un essere umano davanti al notebook che fa decisioni su prompt, retriever, evaluation. Langfuse vince ovunque ci sia un sistema in produzione che riceve traffico utenti reali. Le due fasi del lavoro (esplorazione e operations) richiedono strumenti diversi ed è perfettamente sano usarne due. La cosa che li rende coesistenti senza attrito è la convergenza sul protocollo OpenInference: lo stesso span generato dall'instrumentor LangChain finisce identico in entrambi i sistemi, garantendo che il debug fatto in Phoenix corrisponde esattamente a quello che vedrai in Langfuse in produzione.
Una nota sul costo cognitivo: imparare entrambi i tool richiede investimento, e il rischio è che il team finisca "diviso" fra chi lavora solo in notebook e chi solo in dashboard. La mitigazione che applico nel mio caso (single developer, ma vale anche per team): documenta esplicitamente i due use case nel README del progetto, fai onboarding sempre da Phoenix prima (perché è zero-friction), introduci Langfuse solo quando il sistema ha utenti reali. Il salto da Phoenix a Langfuse a quel punto è naturale perché conosci già il modello dati semantico (span, trace, attributes OpenInference).
Sezione 6 - A/B hypothesis testing rigoroso con Phoenix
Voglio insistere su un punto perché è dove vedo team sbagliare di più: l'A/B test "quick and dirty" che si fa nel notebook deve avere comunque rigore statistico. Non basta vedere che experiment A ha keyword recall 78% e B 75% per concludere che A è meglio: serve verificare che la differenza sia statisticamente significativa dato il sample size. Phoenix non fa il test statistico per te, ma ti dà i dataframe pronti per scriverlo in 5 righe con scipy. Trasformare un'osservazione aneddotica in una decisione difendibile davanti a uno stakeholder è una skill che separa il prototipo dalla release.
Il workflow che uso: dataset di almeno 100 esempi (ricorda che per detect 5% di differenza con power 80% servono circa 200-400 esempi a coda), evaluator deterministici, tre run di ogni configurazione con seed diverso (perché il sampling LLM è non deterministico se temperature > 0), confronto medie con t-test pareggiato. Se il p-value è sopra 0.05 la differenza non è significativa e devo prendere decisioni su altre dimensioni (latenza, costo, complessità). Mai presentare una metrica media senza intervallo di confidenza: una recall 71% +/- 9% e una 64% +/- 8% sono indistinguibili statisticamente, una 71% +/- 2% vs 64% +/- 2% è un risultato pubblicabile.
Esempio reale del mio ml-service: ho A/B testato due varianti di prompt per il riassunto articoli, A più verbose con 200 token di system message vs B conciso con 50 token. Su 250 articoli del corpus, la qualità delle riassunti (eval LLM-as-judge su scala 1-5) era 4.21 vs 4.18, p-value 0.32. Differenza non significativa. Ma B aveva latenza media 2.1s vs 2.8s di A (-25%) e costava il 30% meno in token. Decisione: deploy B. Senza il test statistico avrei ribaltato la decisione basandomi solo sulle medie qualitative. Il principio: quando la qualità è statisticamente equivalente, vince sempre la dimensione operativa (latenza, costo, semplicità).
Lo snippet che uso come template per chiudere ogni A/B test sui dataframe Phoenix:
# T-test pareggiato fra due experiment Phoenix sullo stesso dataset
# Decide se la differenza fra A e B è statisticamente significativa
import phoenix as px
from scipy import stats
import numpy as np
client = px.Client()
# Recupera dataframe dei due experiment (stesso dataset, task diversi)
df_a = client.get_experiment_dataframe("chunking_semantic_v1")
df_b = client.get_experiment_dataframe("chunking_fixed_v1")
# Estrai score keyword recall per ogni esempio (paired: stesso example_id)
df_merge = df_a.merge(
df_b,
on="example_id",
suffixes=("_a", "_b"),
)
scores_a = df_merge["eval_keyword_recall_a"].values
scores_b = df_merge["eval_keyword_recall_b"].values
n = len(scores_a)
# T-test pareggiato (paired): test se media differenze != 0
t_stat, p_value = stats.ttest_rel(scores_a, scores_b)
# Intervallo di confidenza 95% sulla differenza media
diff = scores_a - scores_b
ci_low, ci_high = stats.t.interval(
confidence=0.95,
df=n - 1,
loc=np.mean(diff),
scale=stats.sem(diff),
)
print(f"Sample size: {n}")
print(f"Mean A: {np.mean(scores_a):.4f}, Mean B: {np.mean(scores_b):.4f}")
print(f"Differenza media (A - B): {np.mean(diff):.4f}")
print(f"95% CI: [{ci_low:.4f}, {ci_high:.4f}]")
print(f"t-stat: {t_stat:.3f}, p-value: {p_value:.4f}")
# Decision rule: rifiuta H0 se p < 0.05 E CI non contiene 0
if p_value < 0.05 and (ci_low > 0 or ci_high < 0):
winner = "A" if np.mean(diff) > 0 else "B"
print(f"DIFFERENZA SIGNIFICATIVA: vincitore {winner}")
else:
print("Differenza NON significativa: decidi su latenza/costo")
Tre cose che faccio sempre con questo template prima di considerarlo conclusivo. Prima: ripeto ogni configurazione 3 volte con seed diversi per misurare anche la varianza interna del run (Phoenix permette di taggare gli experiment con metadata seed). Seconda: tengo lo stesso dataset di esempi fra A e B, mai dataset diversi (altrimenti il confronto è sleale). Terza: scrivo il decision rule prima di guardare i risultati, mai dopo (per evitare il p-hacking).
Sezione 7 - Pattern ibrido: Phoenix dev + Langfuse prod via OTLP dual exporter
Vediamo finalmente il pattern operativo che lega tutto: double-write OTLP verso Phoenix container e Langfuse self-hosted. Entrambi parlano OpenInference (specifica OTel per LLM), quindi puoi configurare due exporter sullo stesso tracer provider e gli span finiscono in entrambi i sistemi senza modificare l'instrumentazione applicativa.
# Pattern dual exporter: span vanno simultaneamente a Phoenix + Langfuse
# Configurazione una sola volta a startup, instrumentation invariata
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from openinference.instrumentation.langchain import LangChainInstrumentor
import os
# Inizializza tracer provider OTel standard
provider = TracerProvider()
# Exporter 1: Phoenix locale (dev/staging) o container deploy
# Endpoint OTLP HTTP, no autenticazione richiesta
phoenix_exporter = OTLPSpanExporter(
endpoint="http://localhost:6006/v1/traces",
)
provider.add_span_processor(BatchSpanProcessor(phoenix_exporter))
# Exporter 2: Langfuse self-hosted con autenticazione Basic
# Endpoint OTel su Langfuse v3+ (porta 3000 di default)
langfuse_endpoint = os.getenv("LANGFUSE_OTEL_ENDPOINT", "http://langfuse:3000/api/public/otel/v1/traces")
langfuse_exporter = OTLPSpanExporter(
endpoint=langfuse_endpoint,
headers={
"Authorization": f"Basic {os.getenv('LANGFUSE_AUTH_HEADER')}",
},
)
provider.add_span_processor(BatchSpanProcessor(langfuse_exporter))
# Registra come tracer globale
trace.set_tracer_provider(provider)
# Da ora qualunque instrumentor (LangChain, OpenAI, LlamaIndex)
# manda span a entrambi gli endpoint senza overhead applicativo
LangChainInstrumentor().instrument(tracer_provider=provider)
# Codice applicativo invariato: una chiamata, due trace store
print("Dual exporter configurato: Phoenix (dev) + Langfuse (prod)")
Il vantaggio operativo enorme: una sola istrumentazione, due frontend. Il data scientist può aprire Phoenix sul laptop quando indaga un problema specifico, mentre il sistema di produzione continua a popolare Langfuse per il monitoring 24/7. Quando il team smette di prototipare attivamente (release stabile, focus su ops), spegni il container Phoenix e tieni solo Langfuse, senza toccare una riga di codice applicativo.
Nel mio ml-service questo pattern è attivo da tre mesi. In dev ho PHOENIX_ENABLED=true e LANGFUSE_ENABLED=false, in staging entrambi true (per validare che Langfuse riceva tutto), in prod solo Langfuse attivo. Un singolo file di config Python legge le env var e abilita selettivamente gli exporter. Costo overhead: il BatchSpanProcessor bufferizza e flusha asincrono, l'impatto sul tempo di risposta della pipeline RAG è sotto il millisecondo (misurato).
Nota di compatibilità: Langfuse v2 non aveva endpoint OTel nativo (richiedeva SDK proprietario), Langfuse v3 ha aggiunto OTel ingestion ufficiale a fine 2025. Se sei ancora su v2, devi usare l'SDK Langfuse separatamente o un OTel collector intermedio che traduce. Per nuovi progetti vai diretto su v3.
Sezione 8 - Esempio reale: il mio workflow ml-service end-to-end
Per chiudere il cerchio, il workflow concreto che uso quando devo introdurre una modifica al modulo RAG del mio ml-service FastAPI. Sequenza esatta, niente teoria:
Step 1: ipotesi. "Sospetto che il chunking fixed-size penalizzi gli articoli con header H3 multipli." Apro un Jupyter notebook nel repo del ml-service.
Step 2: setup Phoenix. Due righe: import phoenix as px e session = px.launch_app(). Ho la dashboard live. Aggiungo l'instrumentor LangChain (la mia chain RAG usa LangChain). Aggiungo l'instrumentor sentence_transformers per tracciare la generazione embedding.
Step 3: dataset. Carico il mio gold dataset di 200 query etichettate con keyword attese, persistito in JSON nel repo. Lo upload come Phoenix dataset.
Step 4: experiment A. Eseguo la pipeline RAG attuale (chunking fixed) su tutto il dataset. Phoenix raccoglie 200 trace, ognuno con span per retrieval, re-rank, generation. Eval keyword recall: 64%.
Step 5: modifica codice. Switcho al chunking semantic (split per H3 markdown). Rerun experiment B. Eval keyword recall: 71%.
Step 6: t-test. 5 righe scipy. p-value 0.008. Differenza significativa.
Step 7: UMAP debug. Apro la tab Embeddings di Phoenix. Vedo che con il nuovo chunking i chunk del corpus sono più "spread" nello spazio (meno overlap fra chunk dello stesso documento). Questo spiega il miglioramento.
Step 8: deploy. Mergeo la PR, deploy su VPS Hetzner. Il codice attivo manda trace via OTLP a Langfuse self-hosted (Phoenix container off in prod).
Step 9: monitoring continuo. Su Langfuse vedo nei giorni successivi la curva latency (p99 stabile a 1.4s), token spend (-12% perché chunk più focalizzati = generazione più corta), error rate (zero regression). Confermo che la modifica è andata in produzione senza side effect.
Tempo totale dall'ipotesi al deploy: 4 ore. Senza Phoenix avrei usato print statement, file CSV manuali, grep su log. Tempo stimato senza Phoenix: 2 giorni con qualità di analisi inferiore. Il ritorno sull'investimento è enorme proprio perché Phoenix è zero-cost in termini di setup: la barriera all'uso è così bassa che lo apri anche per debug "veloci" da 30 minuti, non solo per analisi pianificate. È diventato il mio strumento di default ogni volta che ho un dubbio su come si comporta una pipeline LLM, anche per task piccoli che con strumenti pesanti avrei evitato.
Una critica onesta: Phoenix non è perfetto. Le aree dove ho trovato attrito sono tre. Primo: la documentazione ufficiale è corposa ma a volte disallineata fra versioni (alcuni esempi sul sito non funzionano sull'ultima release minor). Secondo: l'iframe inline in JupyterLab a volte ha problemi di rendering con tema dark (i grafici embedding diventano poco leggibili). Terzo: la persistenza SQLite è funzionale ma non è pensata per sessioni multi-utente concorrenti, se vuoi quello passi al container Postgres-backed. Niente di bloccante, ma vale la pena saperlo prima di investire tempo.
FAQ
Phoenix sostituisce Langfuse o sono complementari?
Sono complementari, non alternativi. Phoenix nasce per esplorazione interattiva in notebook (data scientist, ML engineer in fase di prototipazione e debug RAG): UI inline, dati in memoria o SQLite locale, evaluation rapida su 50-500 trace. Langfuse nasce per monitoring continuo di produzione: dashboard sempre accesa, persistenza Postgres/ClickHouse, alerting, prompt management, retention multi-mese. Nel mio ml-service uso Phoenix sul laptop per debuggare regression del retrieval, e Langfuse self-hosted sul VPS per tracciare ogni chiamata reale degli utenti. Stesso protocollo OpenInference, due frontend diversi.
Phoenix funziona con Ollama on-prem o solo con OpenAI?
Funziona benissimo con Ollama. Phoenix tracerà qualsiasi chiamata HTTP che instrumenti tramite OpenInference: l'instrumentor langchain copre Ollama via ChatOllama, l'instrumentor openai copre il client compatibile OpenAI di Ollama (endpoint /v1/chat/completions sulla porta 11434). Per gli embedding, l'instrumentor llama_index e l'instrumentor sentence_transformers tracciano la generazione vector locale. Il tutto rimane su VPS, nessun token esce verso cloud terzi: questa è la condizione richiesta dal mio stack.
Posso usare Phoenix in produzione senza notebook Jupyter?
Sì, Phoenix offre un container Docker standalone (arizephoenix/phoenix) con dashboard web e storage SQLite o Postgres. Tuttavia il punto di forza Phoenix resta l'esplorazione interattiva, non il monitoring 24/7. Per produzione consiglio Langfuse o un APM tradizionale. Phoenix self-hosted in container ha senso come "environment di analisi shared" del team di data science: ci accedi via browser solo durante incident review o A/B test analysis, non lo lasci aperto come unica fonte di verità per metriche SLA.
Come funziona la visualizzazione UMAP degli embedding in Phoenix?
Phoenix carica gli embedding (vector di dimensione 384, 768 o 1536 a seconda del modello) e li proietta in 2D o 3D usando UMAP (Uniform Manifold Approximation and Projection). Vedi cluster di query simili, identifichi outlier (query mal interpretate), confronti distribuzione query utenti vs documenti del corpus. Nel mio ml-service questa visualizzazione mi ha mostrato che le query in tedesco erano isolate dai documenti italiani: ho aggiunto traduzione query e il recall su utenti DACH è salito del 22%.
Quanto costa Phoenix e ha limitazioni di volume?
Phoenix è completamente open-source Apache 2.0 e gratuito. Non esiste pricing tier, non esiste limite trace. Arize commercializza una piattaforma SaaS enterprise (Arize AX) che include Phoenix come componente, ma il progetto open-source è auto-sufficiente. Le uniche risorse che consumi sono RAM laptop quando la sessione tiene tutti i trace in memoria (su 50K trace pesa circa 500 MB) e disco se usi storage SQLite persistente. Nessun vendor lock-in, nessuna API key richiesta per girarlo offline.
Phoenix supporta evaluation con LLM giudice senza pagare OpenAI?
Sì, Phoenix accetta qualsiasi callable Python come "evaluator model". Puoi scrivere una funzione che chiama Ollama localmente con prompt template di valutazione, parse la risposta in CORRECT/INCORRECT o score numerico, e Phoenix la userà per scorare batch di trace in parallelo. Questo è esattamente il pattern che uso: evaluation LLM-as-judge con llama3.1:8b on-prem, costo zero e privacy preservata. La precisione è inferiore a GPT-4 come judge ma sufficiente per regression test interni e CI/CD.
Posso esportare i trace Phoenix verso Langfuse o altro backend?
Sì, perché entrambi parlano OpenInference (specifica OpenTelemetry per LLM). Configuri due exporter OTLP simultaneamente: uno verso Phoenix (porta 6006 locale), uno verso Langfuse (porta 3000 self-hosted o cloud). Lo stesso span finisce in entrambi i sistemi senza overhead lato applicazione. In alternativa Phoenix esporta dataset in Parquet/CSV per analisi offline con pandas, e Langfuse importa via API REST. Pattern ibrido che uso: dev usa solo Phoenix in notebook, prod manda double-write a Phoenix container + Langfuse, eventuale migrazione totale a Langfuse appena il team smette di prototipare attivamente.
Conclusione
Phoenix non è "ancora un altro tool di observability LLM": è uno strumento mentalmente posizionato in una fase diversa del ciclo di vita di un sistema AI. È il blocco appunti del data scientist quando deve capire perché il RAG ha sbagliato, è la lente d'ingrandimento sull'embedding space quando il recall sembra peggiorato, è il banco di test quando devi decidere fra due chunking strategy con rigore statistico. Tutto questo dentro Jupyter, in due righe di codice, senza container né API key.
Il pattern operativo che porto via da mesi di uso intensivo: Phoenix in fase di esplorazione, Langfuse in fase di operations, dual exporter OTLP per avere entrambi i mondi senza duplicare instrumentazione. Per chi viene dal mondo del backend tradizionale, è la stessa logica del rapporto fra jupyter notebook e Grafana: il primo è dove fai l'analisi, il secondo è dove monitori la produzione, e farli coesistere con lo stesso protocollo di telemetria è la maturità ingegneristica.
Una raccomandazione finale: non commettere l'errore di trasformare Phoenix in "Langfuse povero". Se il tuo workflow è puro monitoring di produzione (zero esplorazione, zero notebook, zero A/B test attivi), Phoenix è overkill e Langfuse è la scelta migliore. Se invece hai un team che continua a iterare su prompt, retriever, chunking, evaluation - cioè se sei in piena fase di product-market fit del tuo sistema RAG - Phoenix accelera quel ciclo del 5-10x rispetto al tradizionale "log + Excel + grep".
Prossimo episodio: Helicone proxy multi-provider per tracciare OpenAI, Anthropic e Ollama locale in un'unica dashboard unificata, con focus su cost comparison fra modelli e cache hit rate.

Commenti
Caricamento commenti...
Accedi per commentare