Osservare e misurare le performance del Data Agent
Tracing con MLflow e RAG Triad con gli scorer TruLens: Context Relevance, Groundedness e Answer Relevance sul sistema multi-agente LangGraph.
Nel post precedente abbiamo costruito un Multi-Agent (Data Agent) con LangGraph: Planner, Executor, researcher e synthesizer collaborano su uno stato condiviso. Dobbiamo capire in che modo gli agenti portano a termine il loro lavoro e se lo fanno in modo corretto ed efficiente.
In questa parte aggiungiamo la parte del tracing e sistemi di valutazione del lavoro svolto dagli agenti. Registriamo ogni passo dell’esecuzione e misuriamo tre metriche con un LLM-as-judge: Context Relevance, Groundedness e Answer Relevance — la cosiddetta RAG Triad. Useremo MLflow per i trace e gli scorer TruLens esposti da MLflow per le metriche.
Perché valutare un Data Agent
Un Data Agent, nella sostanza, fa ricerca e sintesi: recupera contesto da fonti interne o dal web e poi genera una risposta. La RAG Triad, nata per i sistemi Retrieval-Augmented Generation, si applica bene anche per questi sistemi:
| Fase del Data Agent | Metrica | Domanda che risponde |
|---|---|---|
| Ricerca (researcher) | Context Relevance | Il contesto recuperato è pertinente alla sotto-query? |
| Sintesi (synthesizer / chart) | Groundedness | La risposta è supportata dal contesto recuperato? |
| Risposta end-to-end | Answer Relevance | La risposta è pertinente alla query dell’utente? |
Senza queste misure, un fallimento resta opaco: non sappiamo se il problema è nel retrieval, nell’allucinazione del modello, o in una risposta semplicemente fuori tema.
Le metriche della RAG Triad sono calcolate da un LLM-as-judge: un modello separato riceve l’input, l’output e contesto e produce uno score con reasoning.
Queste metriche sono già definite in TruLens come feedback function (Groundedness, Context Relevance, Answer Relevance). MLflow le espone come scorer di terze parti in mlflow.genai.scorers.trulens, così possiamo usarle nello stesso flusso di tracing e evaluate senza passare dalla dashboard nativa di TruLens.
| Scorer MLflow (TruLens) | Input tipici | Cosa valuta |
|---|---|---|
ContextRelevance | query + contesto recuperato | Qualità del retrieval |
Groundedness | output + contesto | Assenza di allucinazioni rispetto al contesto |
AnswerRelevance | input (query) + output | Pertinenza end-to-end |
Tracing: OpenTelemetry, trace e span
Per calcolare Groundedness e Context Relevance serve conoscere, dunque, quali passi l’agente ha compiuto e quale contesto ha recuperato lungo il percorso. Queste informazioni fanno parte del tracing.
Una trace è il registro dell’intera esecuzione su una query: ogni nodo visitato, ogni tool chiamato, ogni output prodotto. In un Data Agent un percorso tipico può partire da un nodo di ricerca, passare da un chart, tornare a un altro researcher e chiudere con la sintesi. Dentro quella sequenza i passi di retrieval sono quelli che contengono i dati chiave per la RAG Triad.
Il tracing che useremo è costruito su OpenTelemetry: un sistema di tracing distribuito indipendente dal linguaggio. TruLens e MLflow condividono questo modello: catturano ogni passo che l’agente compie per raggiungere l’obiettivo, senza legarsi a un runtime specifico.
Quei passi si chiamano span, ovvero l’unità minima di lavoro all’interno della trace. In un Data Agent gli span coprono, tra gli altri:
| Tipo di span | Cosa rappresenta nel grafo |
|---|---|
| Planning | Il Planner decompone la query in step |
| Routing | L’Executor sceglie il prossimo sub-agent |
| Retrieval | Un researcher recupera contesto |
| Tool use | Chiamata a uno strumento (SQL, search, …) |
| Generation | Synthesizer o chart summarizer |
MlFlow mette a disposizione vari tipi di span. Per questa valutazione prestiamo attenzione soprattutto a quelli di tipo retrieval (RETRIEVER in MLflow). Contengono la sotto-query e i documenti o/e i contesti recuperati che servono a calcolare Context Relevance e Groundedness.
MLflow offre autolog per LangGraph: mlflow.langchain.autolog() che registra automaticamente i nodi del grafo, le chiamate LLM e i tool.
Setup MLflow e TruLens
Gli scorer TruLens sono disponibili in MLflow.
Abilitiamo tracing e scegliamo un experiment:
1
2
3
4
5
6
import mlflow
mlflow.langchain.autolog()
mlflow.set_experiment("Sales Data Agent")
# opzionale in locale:
# mlflow.set_tracking_uri("http://localhost:5000")
Il modello “judge” si specifica come URI LiteLLM, ad esempio openai:/gpt-4o oppure openai:/gpt-4o-mini etc.. La soglia threshold di default è 0.5: lo score passa se è ≥ soglia. Per una valutazione più severa si può alzare a 0.7 etc.
Gli scorer TruLens via MLflow
Gli scorer sono definiti in mlflow.genai.scorers.trulens. Si possono invocare direttamente su una singola tripla input/output/contesto:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from mlflow.genai.scorers.trulens import Groundedness, AnswerRelevance, ContextRelevance
judge = "openai:/gpt-4o"
groundedness = Groundedness(model=judge, threshold=0.5)
answer_relevance = AnswerRelevance(model=judge)
context_relevance = ContextRelevance(model=judge)
feedback = groundedness(
outputs="MLflow è una piattaforma open-source per il ciclo di vita ML.",
expectations={
"context": (
"MLflow è una piattaforma open-source per experiment tracking, "
"model registry e deployment."
),
},
)
print(feedback.value) # "yes" / "no"
print(feedback.metadata["score"]) # 0.0 – 1.0
In batch, su un dataset di esempi già materializzati (query, risposta, contesto), si usa mlflow.genai.evaluate:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import mlflow
from mlflow.genai.scorers.trulens import Groundedness, AnswerRelevance, ContextRelevance
eval_dataset = [
{
"inputs": {"query": "What is MLflow?"},
"outputs": "MLflow is an open-source AI engineering platform.",
"expectations": {
"context": "MLflow is an ML platform for experiment tracking and model deployment."
},
},
]
results = mlflow.genai.evaluate(
data=eval_dataset,
scorers=[
Groundedness(model="openai:/gpt-4o"),
AnswerRelevance(model="openai:/gpt-4o"),
ContextRelevance(model="openai:/gpt-4o"),
],
)
print(results.tables["eval_results"])
print(results.metrics) # es. Groundedness/mean, AnswerRelevance/mean, …
Instrumentation dei nodi di ricerca
Autolog cattura il grafo in automatico, ma per la RAG Triad dobbiamo esporre esplicitamente query e contesto recuperato sugli span di retrieval. Per questo motivo si usa annotare i nodi di ricerca con @mlflow.trace e SpanType.RETRIEVER.
Riprendiamo i due researcher del post precedente e li annotiamo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from typing import Literal
import mlflow
from mlflow.entities import SpanType, Document
from langchain.schema import HumanMessage
from langgraph.types import Command
from helper import State, cortex_agent, web_search_agent
@mlflow.trace(span_type=SpanType.RETRIEVER, name="cortex_researcher") # <-------
def cortex_agents_research_node(
state: State,
) -> Command[Literal["executor"]]:
query = state.get("agent_query") or state.get("user_query", "")
agent_response = cortex_agent.invoke({"messages": query})
content = agent_response["messages"][-1].content
span = mlflow.get_current_active_span()
if span is not None:
span.set_inputs({"query": query})
span.set_outputs([Document(page_content=content)])
new_message = HumanMessage(content=content, name="cortex_researcher")
return Command(
update={"messages": [new_message]},
goto="executor",
)
@mlflow.trace(span_type=SpanType.RETRIEVER, name="web_researcher") # <-------
def web_research_node(
state: State,
) -> Command[Literal["executor"]]:
agent_query = state.get("agent_query")
result = web_search_agent.invoke({"messages": agent_query})
content = result["messages"][-1].content
span = mlflow.get_current_active_span()
if span is not None:
span.set_inputs({"query": agent_query})
span.set_outputs([Document(page_content=content)])
result["messages"][-1] = HumanMessage(
content=content, name="web_researcher"
)
return Command(
update={"messages": result["messages"]},
goto="executor",
)
Senza questa etichetta, MLflow vedrebbe comunque il nodo come generico chain/agent; gli scorer TruLens non troverebbero un retrieval strutturato da cui estrarre il contesto per Groundedness e Context Relevance.
Ricostruiamo il grafo con i researcher instrumentati (Planner, Executor e gli altri nodi restano quelli del post precedente):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from langgraph.graph import START, StateGraph
from helper import (
State, planner_node, executor_node,
chart_node, chart_summary_node, synthesizer_node,
)
workflow = StateGraph(State)
workflow.add_node("planner", planner_node)
workflow.add_node("executor", executor_node)
workflow.add_node("web_researcher", web_research_node)
workflow.add_node("cortex_researcher", cortex_agents_research_node)
workflow.add_node("chart_generator", chart_node)
workflow.add_node("chart_summarizer", chart_summary_node)
workflow.add_node("synthesizer", synthesizer_node)
workflow.add_edge(START, "planner")
graph = workflow.compile()
Dataset di eval
Costruiamo un mini dataset di eval, sottoponiamo l’agente a tre query progressive e valutiamo la risposta. Non serve che la risposta sia perfetta: l’obiettivo è produrre trace e score da cui diagnosticare i failure mode.
- Quali sono i nostri 3 deal clienti più importanti? Crea un grafico del valore di ciascun deal.
- Identifica i nostri deal in sospeso, verifica se potrebbero essere soggetti a cambiamenti regolamentari e, usando le note delle riunioni di ciascun cliente, proponi una nuova proposta di valore per ciascuno alla luce di tali cambiamenti.
- Identifica il nostro deal cliente più grande, poi trova gli argomenti importanti nelle note delle riunioni con quell’azienda e trova un articolo di news correlato agli argomenti discussi.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from langchain.schema import HumanMessage
from mlflow.genai.scorers.trulens import (
Groundedness, AnswerRelevance, ContextRelevance,
)
ENABLED = [
"cortex_researcher", "web_researcher",
"chart_generator", "chart_summarizer", "synthesizer",
]
QUERIES = [
"Quali sono i nostri 3 deal clienti più importanti? Crea un grafico del valore di ciascun deal.",
(
"Identifica i nostri deal in sospeso, verifica se potrebbero essere "
"soggetti a cambiamenti regolamentari e, usando le note delle riunioni "
"di ciascun cliente, proponi una nuova proposta di valore per ciascuno "
"alla luce di tali cambiamenti."
),
(
"Identifica il nostro deal cliente più grande, poi trova gli argomenti "
"importanti nelle note delle riunioni con quell'azienda e trova un "
"articolo di news correlato agli argomenti discussi."
),
]
def run_data_agent(inputs: dict) -> str:
query = inputs["query"]
state = {
"messages": [HumanMessage(content=query)],
"user_query": query,
"enabled_agents": ENABLED,
}
result = graph.invoke(state)
# ultima risposta utile nello stato
return result["messages"][-1].content
eval_data = [{"inputs": {"query": q}} for q in QUERIES]
results = mlflow.genai.evaluate(
data=eval_data,
predict_fn=run_data_agent,
scorers=[
Groundedness(model="openai:/gpt-4o"),
AnswerRelevance(model="openai:/gpt-4o"),
ContextRelevance(model="openai:/gpt-4o"),
],
)
In alternativa, dopo aver invocato il grafo a mano, si recuperano i trace già loggati e si valutano offline:
1
2
3
4
5
6
7
8
9
10
traces = mlflow.search_traces(experiment_ids=["<experiment_id>"])
results = mlflow.genai.evaluate(
data=traces,
scorers=[
Groundedness(model="openai:/gpt-4o"),
AnswerRelevance(model="openai:/gpt-4o"),
ContextRelevance(model="openai:/gpt-4o"),
],
)
L’output di un LLM non è deterministico: ripetendo le stesse query puoi ottenere score diversi. Un fallimento (grafico senza testo, deal in sospeso non filtrati, sorgente dati non raggiungibile) non va ritentato a oltranza: è materiale di diagnosi.
Come leggere i fallimenti
Nella UI MLflow trovi i trace (albero degli span: planner → executor → researcher → …) e la tabella di evaluate con score e rationale del judge. Vediamo alcuni casi tipici che possono verificarsi nel caso del Data Agent:
Query 1 — top 3 deal + grafico.
Spesso compare il grafico ma manca il riassunto testuale. Answer Relevance crolla verso zero: la risposta non affronta la richiesta in forma utile all’utente. Se i researcher non hanno portato deal pertinenti, anche Context Relevance resta bassa.
Query 2 — deal in sospeso + regolamentazione + proposta di valore.
La risposta può sembrare pertinente (Answer Relevance alta) ma Groundedness bassa: il synthesizer inferisce proposte di valore non supportate dai contesti recuperati. Spesso il filtro “solo deal in sospeso” non è stato applicato: nel trace vedi un path lungo (planner → executor → cortex → replan → web → cortex → synthesizer) e puoi ispezionare input/output di ogni nodo.
Query 3 — deal più grande + note riunioni + news.
Se il retrieval sulla sorgente dati fallisce, a monte non c’è contesto sul deal più grande: Context Relevance e Groundedness riflettono un problema di accesso ai dati, non solo di prompting.
Il punto della RAG Triad è proprio questo: tre score diversi isolano tre failure mode distinti.
| Pattern di score | Failure mode probabile |
|---|---|
| Answer Relevance ↓ | Risposta fuori tema o incompleta (es. solo chart, niente testo) |
| Groundedness ↓, Answer Relevance ↑ | Allucinazione / inferenza non supportata dal retrieval |
| Context Relevance ↓ | Retrieval sbagliato o sotto-query mal formulata |