4.4 Avaluació i Observabilitat d'Agents¶
Per Què Avaluar i Observar?
Un agent que funciona en local pot fallar de maneres imprevistes en producció. L'avaluació ens diu si l'agent fa el que ha de fer; l'observabilitat ens explica per què falla quan falla. Sense aquestes dues pràctiques, un agent en producció és una caixa negra.
🎯 El Repte d'Avaluar Agents¶
Avaluar un agent és molt més difícil que avaluar una funció normal:
Funció clàssica:
input → funció → output determinista
Test: assert output == valor_esperat ✅ Fàcil
Agent d'IA:
input → [LLM + eines + memòria + múltiples passos] → output variable
Test: ??? com avaluem "ha resolt correctament"? 🤔 Difícil
Els reptes principals:
- No-determinisme: el mateix input pot produir outputs lleugerament diferents
- Tasques obertes: no hi ha una única resposta "correcta"
- Cadenes llargues: un error inicial es propaga per tots els passos
- Eines externes: resultats de cerca web, APIs, bases de dades canvien
📏 Mètriques d'Avaluació¶
Mètriques Objectives¶
Quan hi ha una resposta correcta clara:
# Exemple: agent de Q&A sobre documents
def avaluar_exactitud(respostes_agent: list[str],
respostes_correctes: list[str]) -> float:
"""Percentatge de respostes exactament correctes."""
correctes = sum(
1 for agent, correcta in zip(respostes_agent, respostes_correctes)
if agent.strip().lower() == correcta.strip().lower()
)
return correctes / len(respostes_correctes)
def avaluar_f1_token(prediccio: str, referencia: str) -> float:
"""F1 a nivell de tokens (útil per a respostes parcials correctes)."""
tokens_pred = set(prediccio.lower().split())
tokens_ref = set(referencia.lower().split())
if not tokens_pred or not tokens_ref:
return 0.0
precisio = len(tokens_pred & tokens_ref) / len(tokens_pred)
recall = len(tokens_pred & tokens_ref) / len(tokens_ref)
if precisio + recall == 0:
return 0.0
return 2 * precisio * recall / (precisio + recall)
# Exemple d'ús
respostes = ["Barcelona", "Python 3.11", "1991"]
correctes = ["Barcelona", "Python 3.10", "1991"]
print(f"Exactitud: {avaluar_exactitud(respostes, correctes):.1%}")
print(f"F1 'Python 3.11' vs 'Python 3.10': "
f"{avaluar_f1_token('Python 3.11', 'Python 3.10'):.2f}")
LLM-as-Judge¶
Per a respostes obertes, fem servir un LLM com a jutge:
import json
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel
llm_jutge = ChatOpenAI(model="gpt-4o", temperature=0)
class Puntuacio(BaseModel):
puntuacio: int # 1-5
justificacio: str
aspectes_positius: list[str]
aspectes_negatius: list[str]
def llm_com_jutge(pregunta: str, resposta_agent: str,
resposta_referencia: str = "") -> Puntuacio:
"""Usa un LLM per avaluar la qualitat d'una resposta."""
referencia_text = (f"\nResposta de referència:\n{resposta_referencia}"
if resposta_referencia else "")
prompt = (
f"Avalua la qualitat d'aquesta resposta d'un agent d'IA.\n\n"
f"Pregunta: {pregunta}\n"
f"Resposta de l'agent: {resposta_agent}{referencia_text}\n\n"
"Puntua de l'1 al 5 on:\n"
"1=Completament incorrecta | 2=Errors importants | 3=Acceptable\n"
"4=Bona resposta | 5=Excel·lent i precisa\n\n"
'Respon en JSON: {"puntuacio": 1-5, "justificacio": "...", '
'"aspectes_positius": [...], "aspectes_negatius": [...]}'
)
resposta = llm_jutge.invoke([
SystemMessage(content="Ets un avaluador expert i imparcial d'agents d'IA. "
"Respon SEMPRE en JSON vàlid."),
HumanMessage(content=prompt)
])
text = resposta.content.strip()
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].split("```")[0].strip()
return Puntuacio(**json.loads(text))
# Exemple d'ús
resultat = llm_com_jutge(
pregunta="Explica com funciona la memòria RAM",
resposta_agent="La RAM és una memòria volàtil d'accés ràpid que "
"emmagatzema temporalment les dades que el processador "
"necessita en cada moment.",
resposta_referencia="La RAM (Random Access Memory) és memòria volàtil "
"d'accés aleatori que el sistema usa per executar "
"programes actius."
)
print(f"Puntuació: {resultat.puntuacio}/5")
print(f"Justificació: {resultat.justificacio}")
Mètriques de RAG¶
Per a agents amb recuperació de documents (pip install ragas):
from ragas import evaluate
from ragas.metrics import (
answer_relevancy, # La resposta és rellevant a la pregunta?
faithfulness, # La resposta es basa en els documents recuperats?
context_precision, # Els documents recuperats són rellevants?
context_recall, # Es recuperen tots els documents necessaris?
)
from datasets import Dataset
dades = {
"question": ["Què és LangGraph?", "Com funciona RAG?"],
"answer": [
"LangGraph és un framework per construir agents amb estat usant grafs.",
"RAG recupera documents rellevants i els usa com a context per generar."
],
"contexts": [
["LangGraph allows building stateful multi-actor applications..."],
["RAG combines retrieval with generation to ground LLM responses..."]
],
"ground_truth": [
"LangGraph és una llibreria per crear aplicacions LLM amb estat.",
"RAG augmenta els LLM amb recuperació d'informació externa."
]
}
dataset = Dataset.from_dict(dades)
resultats = evaluate(dataset, metrics=[
answer_relevancy, faithfulness, context_precision, context_recall
])
print(resultats)
🔍 Observabilitat: Veure Dins de l'Agent¶
Logging Bàsic amb LangChain¶
import logging
import time
from langchain_openai import ChatOpenAI
from langchain.callbacks.base import BaseCallbackHandler
from langchain_core.outputs import LLMResult
# ── CALLBACK PERSONALITZAT ─────────────────────────────────────
class AgentLogger(BaseCallbackHandler):
"""Captura tots els events de l'agent per a debugging."""
def __init__(self):
self.events = []
self.temps_inici = None
def on_llm_start(self, serialized, prompts, **kwargs):
self.temps_inici = time.time()
event = {
"tipus": "LLM_START",
"model": serialized.get("name", "desconegut"),
"num_prompts": len(prompts),
"timestamp": time.time()
}
self.events.append(event)
logging.info(f"🤖 LLM iniciat | Model: {event['model']}")
def on_llm_end(self, response: LLMResult, **kwargs):
durada = time.time() - self.temps_inici
tokens = response.llm_output.get("token_usage", {}) if response.llm_output else {}
event = {
"tipus": "LLM_END",
"durada_s": round(durada, 2),
"tokens_entrada": tokens.get("prompt_tokens", 0),
"tokens_sortida": tokens.get("completion_tokens", 0),
"cost_estimat_eur": tokens.get("total_tokens", 0) * 0.000003
}
self.events.append(event)
logging.info(f"✅ LLM finalitzat | "
f"{event['durada_s']}s | "
f"{event['tokens_entrada']}+{event['tokens_sortida']} tokens | "
f"~{event['cost_estimat_eur']:.4f}€")
def on_tool_start(self, serialized, input_str, **kwargs):
event = {"tipus": "TOOL_START", "eina": serialized.get("name"), "input": input_str}
self.events.append(event)
logging.info(f"🔧 Eina: {event['eina']} | Input: {input_str[:60]}...")
def on_tool_end(self, output, **kwargs):
event = {"tipus": "TOOL_END", "output_len": len(str(output))}
self.events.append(event)
logging.info(f"📤 Eina finalitzada | Output: {len(str(output))} caràcters")
def on_agent_action(self, action, **kwargs):
logging.info(f"🧠 Acció: {action.tool} | Input: {str(action.tool_input)[:80]}")
def resum(self) -> dict:
"""Genera un resum de l'execució."""
llm_events = [e for e in self.events if e["tipus"] == "LLM_END"]
tool_events = [e for e in self.events if e["tipus"] == "TOOL_START"]
return {
"total_crides_llm": len(llm_events),
"total_eines_usades": len(tool_events),
"temps_total_llm": sum(e["durada_s"] for e in llm_events),
"tokens_totals": sum(
e["tokens_entrada"] + e["tokens_sortida"] for e in llm_events
),
"cost_total_eur": sum(e["cost_estimat_eur"] for e in llm_events),
}
# ── ÚS DEL LOGGER ─────────────────────────────────────────────
logging.basicConfig(level=logging.INFO,
format="%(asctime)s | %(message)s")
logger = AgentLogger()
llm = ChatOpenAI(model="gpt-4o-mini", callbacks=[logger])
# ... executar l'agent normalment ...
print("\n📊 RESUM D'EXECUCIÓ:")
resum = logger.resum()
for clau, valor in resum.items():
print(f" {clau}: {valor}")
LangSmith: Observabilitat Professional¶
LangSmith és la plataforma oficial de LangChain per a traces, avaluació i monitoratge en producció.
# Configuració de LangSmith (sense canviar el codi de l'agent!)
import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls__..." # Clau de LangSmith
os.environ["LANGCHAIN_PROJECT"] = "curs-agents-ia" # Nom del projecte
# A partir d'aquí, TOTES les execucions de LangChain/LangGraph
# es registren automàticament a LangSmith:
from langchain_openai import ChatOpenAI
from langchain.agents import create_react_agent, AgentExecutor
from langchain import hub
llm = ChatOpenAI(model="gpt-4o-mini")
# ... definir eines i agent ...
# Cada execució apareixerà a https://smith.langchain.com amb:
# - Traça completa de cada pas
# - Tokens i cost
# - Latència per node
# - Errors i excepcions
Què ofereix LangSmith:
| Funcionalitat | Descripció |
|---|---|
| Traces | Cada pas de l'agent amb inputs/outputs complets |
| Datasets | Conjunts de dades per a avaluació sistemàtica |
| Evaluators | Avaluadors automàtics per a cada run |
| Monitoring | Alertes quan la qualitat baixa |
| Playground | Provar prompts de forma interactiva |
🧪 Suite d'Avaluació Sistemàtica¶
Un framework complet per avaluar agents de forma reproducible:
# Suite d'avaluació completa
# pip install langchain langchain-openai python-dotenv
import json
import time
from dataclasses import dataclass, field
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
@dataclass
class CasDeTest:
id: str
pregunta: str
resposta_esperada: str
criteris: list[str] = field(default_factory=list)
# Criteris específics per a aquest test (opcional)
@dataclass
class ResultatTest:
cas_id: str
resposta_obtinguda: str
puntuacio: float # 0.0 - 1.0
passes: bool
temps_s: float
tokens_usats: int
feedback: str
class SuiteAvaluacio:
"""Framework per avaluar un agent de forma sistemàtica."""
def __init__(self, agent_func, llm_jutge_model: str = "gpt-4o"):
self.agent_func = agent_func
self.llm_jutge = ChatOpenAI(model=llm_jutge_model, temperature=0)
self.resultats: list[ResultatTest] = []
def _avaluar_amb_llm(self, cas: CasDeTest,
resposta: str, temps: float) -> ResultatTest:
"""Usa un LLM per avaluar la resposta."""
criteris_text = "\n".join(f"- {c}" for c in cas.criteris) if cas.criteris else ""
prompt = (
f"Pregunta: {cas.pregunta}\n"
f"Resposta esperada: {cas.resposta_esperada}\n"
f"Resposta obtinguda: {resposta}\n"
)
if criteris_text:
prompt += f"Criteris específics:\n{criteris_text}\n"
prompt += (
"\nPuntua la resposta de 0.0 a 1.0 i indica si passa (true/false).\n"
'Respon en JSON: {"puntuacio": 0.0-1.0, "passa": true/false, "feedback": "..."}'
)
resp = self.llm_jutge.invoke([
SystemMessage(content="Ets un avaluador expert. Respon SEMPRE en JSON vàlid."),
HumanMessage(content=prompt)
])
text = resp.content.strip()
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].split("```")[0].strip()
dades = json.loads(text)
return ResultatTest(
cas_id=cas.id,
resposta_obtinguda=resposta,
puntuacio=dades["puntuacio"],
passes=dades["passa"],
temps_s=temps,
tokens_usats=0, # Simplificat
feedback=dades["feedback"]
)
def executar_cas(self, cas: CasDeTest) -> ResultatTest:
"""Executa un cas de test i retorna el resultat."""
print(f" 🔄 Executant: {cas.id}...")
inici = time.time()
try:
resposta = self.agent_func(cas.pregunta)
temps = time.time() - inici
resultat = self._avaluar_amb_llm(cas, resposta, temps)
except Exception as e:
temps = time.time() - inici
resultat = ResultatTest(
cas_id=cas.id, resposta_obtinguda=f"ERROR: {e}",
puntuacio=0.0, passes=False, temps_s=temps,
tokens_usats=0, feedback=f"Excepció: {type(e).__name__}"
)
estat = "✅" if resultat.passes else "❌"
print(f" {estat} {cas.id}: {resultat.puntuacio:.2f} | {resultat.temps_s:.1f}s")
self.resultats.append(resultat)
return resultat
def executar_suite(self, casos: list[CasDeTest]) -> dict:
"""Executa tots els casos i retorna un resum."""
print(f"\n🧪 Iniciant suite d'avaluació ({len(casos)} casos)...")
for cas in casos:
self.executar_cas(cas)
aprovats = sum(1 for r in self.resultats if r.passes)
puntuacio_mitjana = sum(r.puntuacio for r in self.resultats) / len(self.resultats)
temps_total = sum(r.temps_s for r in self.resultats)
resum = {
"total_casos": len(casos),
"aprovats": aprovats,
"fallats": len(casos) - aprovats,
"taxa_exit": aprovats / len(casos),
"puntuacio_mitjana": puntuacio_mitjana,
"temps_total_s": round(temps_total, 1),
}
print(f"\n{'='*50}")
print(f"📊 RESUM: {aprovats}/{len(casos)} aprovats "
f"({resum['taxa_exit']:.1%}) | "
f"Puntuació: {puntuacio_mitjana:.2f}")
print(f"{'='*50}")
return resum
# ── EXEMPLE D'ÚS ──────────────────────────────────────────────
llm_simple = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def agent_simple(pregunta: str) -> str:
"""Agent de demostració."""
resposta = llm_simple.invoke([HumanMessage(content=pregunta)])
return resposta.content
casos_de_test = [
CasDeTest(
id="T001",
pregunta="Quina és la capital de Catalunya?",
resposta_esperada="Barcelona",
criteris=["Ha de mencionar Barcelona", "Resposta breu i directa"]
),
CasDeTest(
id="T002",
pregunta="Explica el concepte de recursivitat en programació",
resposta_esperada="Funció que es crida a si mateixa amb un cas base",
criteris=["Ha de mencionar 'cas base'",
"Ha de mencionar que la funció es crida a si mateixa"]
),
CasDeTest(
id="T003",
pregunta="Quants dies té un any de traspàs?",
resposta_esperada="366",
criteris=["Ha de dir 366"]
),
]
suite = SuiteAvaluacio(agent_func=agent_simple)
resum = suite.executar_suite(casos_de_test)
📊 Panell de Monitoratge en Producció¶
# Monitoratge continu d'un agent en producció
import time
from collections import deque
from datetime import datetime
class MonitorAgent:
"""Monitoratge en temps real d'un agent en producció."""
def __init__(self, finestra_temps: int = 3600):
# Guarda les últimes N execucions (finestra lliscant)
self.execucions = deque(maxlen=1000)
self.finestra_temps = finestra_temps # en segons
self.alertes: list[str] = []
def registrar(self, pregunta: str, resposta: str,
temps_s: float, tokens: int, error: bool = False):
"""Registra una execució de l'agent."""
self.execucions.append({
"timestamp": time.time(),
"pregunta_len": len(pregunta),
"resposta_len": len(resposta),
"temps_s": temps_s,
"tokens": tokens,
"error": error,
})
self._comprovar_alertes(temps_s, error)
def _comprovar_alertes(self, temps_s: float, error: bool):
"""Genera alertes si es detecten anomalies."""
stats = self.estadistiques()
if error:
self.alertes.append(
f"[{datetime.now():%H:%M:%S}] ❌ Error detectat"
)
if temps_s > 30:
self.alertes.append(
f"[{datetime.now():%H:%M:%S}] ⚠️ Latència alta: {temps_s:.1f}s"
)
if stats["taxa_error"] > 0.1:
self.alertes.append(
f"[{datetime.now():%H:%M:%S}] 🚨 Taxa d'error >10%: "
f"{stats['taxa_error']:.1%}"
)
def estadistiques(self) -> dict:
"""Calcula estadístiques de la finestra temporal actual."""
ara = time.time()
recents = [e for e in self.execucions
if ara - e["timestamp"] < self.finestra_temps]
if not recents:
return {}
return {
"total_execucions": len(recents),
"taxa_error": sum(1 for e in recents if e["error"]) / len(recents),
"latencia_mitjana_s": sum(e["temps_s"] for e in recents) / len(recents),
"latencia_p95_s": sorted(e["temps_s"] for e in recents)[
int(len(recents) * 0.95)
],
"tokens_per_execucio": sum(e["tokens"] for e in recents) / len(recents),
}
def informe(self):
stats = self.estadistiques()
print(f"\n📊 PANELL DE MONITORATGE (última hora)")
print(f" Execucions: {stats.get('total_execucions', 0)}")
print(f" Taxa d'error: {stats.get('taxa_error', 0):.1%}")
print(f" Latència mitjana: {stats.get('latencia_mitjana_s', 0):.2f}s")
print(f" Latència P95: {stats.get('latencia_p95_s', 0):.2f}s")
print(f" Tokens/execució: {stats.get('tokens_per_execucio', 0):.0f}")
if self.alertes:
print(f"\n🚨 ALERTES RECENTS:")
for alerta in self.alertes[-5:]:
print(f" {alerta}")
🔄 Millora Contínua: el Cicle d'Avaluació¶
Producció
↓
Recollir traces amb errors o puntuació baixa
↓
Afegir als datasets d'avaluació
↓
Identificar patrons de fallada
↓
Millorar el prompt / eines / lògica
↓
Executar suite d'avaluació → confirmar millora
↓
Desplegar nova versió
↓
[Torna a Producció]
Regla dels 80/20 en Avaluació
El 80% dels errors prové del 20% de les causes. Identifica els casos de fallada més freqüents primer i resol-los abans de perseguir casos extrems.
✅ Activitats de Consolidació¶
Exercici 4.4.1 — Logger Personalitzat
Afegeix el callback AgentLogger a un agent ReAct de la unitat 4.2 i:
1. Analitza quantes crides al LLM fa per a 3 preguntes diferents
2. Calcula el cost estimat de cada consulta
3. Identifica quina eina es crida amb més freqüència
Exercici 4.4.2 — Suite d'Avaluació
Crea una suite d'avaluació de 5 casos per a un agent de Q&A senzill: 1. Defineix els casos amb preguntes de l'assignatura (ASIX/DAW) 2. Executa la suite i analitza els resultats 3. Modifica el prompt del sistema per millorar els casos que fallen
Exercici 4.4.3 — Comparació de Models
Usa la SuiteAvaluacio per comparar dos models (gpt-4o-mini vs gpt-4o):
1. Executa els mateixos 5 casos amb tots dos models
2. Compara puntuació, temps i cost estimat
3. Determina quin model ofereix millor relació qualitat/preu per al cas d'ús
📚 Referències¶
- LangSmith Documentation. docs.smith.langchain.com
- Es, S. et al. (2023). "RAGAS: Automated Evaluation of Retrieval Augmented Generation". arXiv:2309.15217
- Zheng, L. et al. (2023). "Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena". arXiv:2306.05685
- Liu, Y. et al. (2023). "G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment". arXiv:2303.16634