Módulo 12: LangSmith y Producción

Evaluation: Datasets y Evaluators

Descripción de la cápsula

"¿Es buena la respuesta?" no es evaluación. Es una opinión sin criterio, sin repetibilidad, y sin escala. Evaluación real usa criterios específicos (relevancia, completitud, exactitud, formato), respuestas de referencia (ground truth), y medición automatizada que puedes ejecutar cada vez que cambias un prompt, actualizas un modelo, o agregas una feature.

LangSmith provee el framework completo: datasets con preguntas y respuestas esperadas, evaluators built-in para criterios comunes, evaluators custom en Python para lógica específica, y LLM-as-judge para criterios que requieren razonamiento. Esta cápsula te enseña a usarlos todos — y a no confiar ciegamente en ninguno.


Por qué evaluation importa

Sin evaluación automatizada, estás adivinando. Y adivinar no escala:

Escenario sin evaluación:
  1. Cambias el system prompt del agente
  2. Pruebas con 3 consultas manualmente
  3. "Se ve bien" → deploy
  4. Los usuarios reportan que las respuestas empeoraron
  5. Revertir → probar otra cosa → 3 consultas → "se ve bien" → deploy
  6. Ciclo infinito de iteración ciega

Escenario con evaluación:
  1. Cambias el system prompt del agente
  2. Ejecutas el evaluation dataset (50 consultas con respuestas esperadas)
  3. Score de relevancia: 0.85 → 0.72 (bajó)
  4. Score de completitud: 0.78 → 0.82 (subió)
  5. Decisión informada: el cambio mejoró completitud pero empeoró relevancia
  6. Ajustar → re-evaluar → deploy cuando los números son buenos

La evaluación transforma el desarrollo de agentes de un arte subjetivo a una disciplina medible.


Crear un dataset de evaluación

Un dataset es una colección de examples: cada example tiene un input (la consulta), un output esperado (la referencia), y opcionalmente metadata:

from dotenv import load_dotenv
load_dotenv()

from langsmith import Client

client = Client()

dataset_name = "research-assistant-eval"

dataset = client.create_dataset(
    dataset_name=dataset_name,
    description="Dataset de evaluación para el Research Assistant. "
                "Consultas de investigación con respuestas de referencia.",
)

examples = [
    {
        "input": {"query": "¿Qué es RAG y cómo se implementa?"},
        "output": {
            "reference": "RAG (Retrieval-Augmented Generation) combina un sistema de retrieval "
                         "(que busca documentos relevantes) con un modelo generativo (que produce "
                         "la respuesta). Se implementa con: 1) un vector store para indexar documentos, "
                         "2) un embedding model para convertir texto a vectores, 3) un retriever para "
                         "buscar documentos similares, y 4) un LLM para generar la respuesta final "
                         "usando los documentos recuperados como contexto."
        },
        "metadata": {"difficulty": "easy", "topic": "RAG"},
    },
    {
        "input": {"query": "Compara fine-tuning vs RAG: cuándo usar cada uno"},
        "output": {
            "reference": "Fine-tuning modifica los pesos del modelo para especializarlo en un dominio. "
                         "RAG agrega contexto externo sin modificar el modelo. Usa fine-tuning cuando: "
                         "necesitas cambiar el estilo/formato del modelo, tienes datos de entrenamiento "
                         "estables, y el conocimiento no cambia frecuentemente. Usa RAG cuando: los datos "
                         "cambian frecuentemente, necesitas citar fuentes, y quieres controlar qué "
                         "información usa el modelo. Muchos sistemas combinan ambos."
        },
        "metadata": {"difficulty": "medium", "topic": "architecture"},
    },
    {
        "input": {"query": "¿Cómo funciona el mecanismo de attention en transformers?"},
        "output": {
            "reference": "El mecanismo de attention permite al modelo ponderar la importancia relativa "
                         "de cada token en la secuencia. Funciona con tres matrices: Query (Q), Key (K), "
                         "y Value (V). El score de attention se calcula como softmax(QK^T/√d_k)V, donde "
                         "d_k es la dimensión de las keys. Multi-head attention ejecuta este proceso "
                         "múltiples veces en paralelo con diferentes proyecciones lineales, capturando "
                         "diferentes tipos de relaciones entre tokens."
        },
        "metadata": {"difficulty": "hard", "topic": "transformers"},
    },
]

for example in examples:
    client.create_example(
        dataset_id=dataset.id,
        inputs=example["input"],
        outputs=example["output"],
        metadata=example["metadata"],
    )

print(f"Dataset creado: '{dataset_name}'")
print(f"Examples: {len(examples)}")
for ex in examples:
    print(f"  - [{ex['metadata']['difficulty']}] {ex['input']['query'][:50]}...")
# Output esperado:
# Dataset creado: 'research-assistant-eval'
# Examples: 3
#   - [easy] ¿Qué es RAG y cómo se implementa?...
#   - [medium] Compara fine-tuning vs RAG: cuándo usar cada uno...
#   - [hard] ¿Cómo funciona el mecanismo de attention en transf...

Qué hace un buen dataset

AspectoMaloBueno
Tamaño3 examples30-50 examples mínimo
DiversidadTodas las queries son similaresCubren easy/medium/hard y diferentes topics
Reference"Sí" / "No"Respuesta completa con los puntos clave esperados
MetadataNingunaDifficulty, topic, expected_format
ActualizaciónSe crea una vez y se olvidaSe actualiza con cada nuevo tipo de consulta

Los 4 criterios de evaluación

"¿Es buena?" no es un criterio. Estos son los cuatro criterios que necesitas:

1. Relevancia: ¿la respuesta aborda la pregunta?

Si preguntas "¿Qué es RAG?" y la respuesta habla de fine-tuning, no es relevante. No importa que sea una respuesta correcta — no responde lo que se preguntó.

2. Completitud: ¿cubre todos los aspectos?

Si preguntas "Compara RAG vs fine-tuning" y la respuesta solo explica RAG sin mencionar fine-tuning, es incompleta. La respuesta puede ser relevante y correcta, pero no cubre todo lo que se pidió.

3. Exactitud (accuracy): ¿los hechos son correctos?

Si la respuesta dice "GPT-4 tiene 100 billones de parámetros" o "RAG fue inventado en 2023", los datos son incorrectos. La respuesta puede ser relevante y completa, pero factualmente errónea.

4. Formato: ¿la estructura es la solicitada?

Si pediste "lista de 5 puntos" y la respuesta es un párrafo narrativo, el formato es incorrecto. La respuesta puede ser relevante, completa, y correcta, pero no en el formato esperado.

Una respuesta puede ser:
  ✅ Relevante + ✅ Completa + ✅ Exacta + ✅ Formato    → Excelente
  ✅ Relevante + ❌ Incompleta + ✅ Exacta + ✅ Formato  → Necesita mejorar
  ✅ Relevante + ✅ Completa + ❌ Inexacta + ✅ Formato  → Peligrosa (parece buena pero tiene errores)
  ❌ Irrelevante + ... + ... + ...                       → Fallo total

Cada criterio es un evaluator separado. No los mezcles.


Custom evaluators: funciones Python

El evaluator más simple es una función Python que recibe el output del agente y retorna un score:

from dotenv import load_dotenv
load_dotenv()

from langsmith import Client
from langsmith.evaluation import evaluate

client = Client()


def relevance_evaluator(run, example) -> dict:
    """Evalúa si la respuesta es relevante a la pregunta."""
    output = run.outputs.get("response", "") if run.outputs else ""
    query = example.inputs.get("query", "")

    query_keywords = set(query.lower().split())
    response_lower = output.lower()

    stop_words = {"¿", "?", "cómo", "qué", "es", "y", "de", "en", "el", "la", "los", "las", "un", "una"}
    relevant_keywords = query_keywords - stop_words

    if not relevant_keywords:
        return {"key": "relevance", "score": 0.5}

    matches = sum(1 for kw in relevant_keywords if kw in response_lower)
    score = matches / len(relevant_keywords) if relevant_keywords else 0

    return {"key": "relevance", "score": min(score, 1.0)}


def completeness_evaluator(run, example) -> dict:
    """Evalúa si la respuesta cubre los puntos de la referencia."""
    output = run.outputs.get("response", "") if run.outputs else ""
    reference = example.outputs.get("reference", "") if example.outputs else ""

    if not reference:
        return {"key": "completeness", "score": 0.5}

    ref_sentences = [s.strip() for s in reference.split(".") if len(s.strip()) > 10]
    if not ref_sentences:
        return {"key": "completeness", "score": 0.5}

    output_lower = output.lower()
    covered = 0
    for sentence in ref_sentences:
        key_words = [w for w in sentence.lower().split() if len(w) > 4]
        if key_words:
            matches = sum(1 for w in key_words if w in output_lower)
            if matches / len(key_words) > 0.3:
                covered += 1

    score = covered / len(ref_sentences) if ref_sentences else 0
    return {"key": "completeness", "score": min(score, 1.0)}


def format_evaluator(run, example) -> dict:
    """Evalúa si la respuesta tiene formato estructurado."""
    output = run.outputs.get("response", "") if run.outputs else ""

    has_bullets = any(line.strip().startswith(("-", "•", "*", "1.", "2.")) for line in output.split("\n"))
    has_headers = any(line.strip().startswith("#") for line in output.split("\n"))
    has_paragraphs = len([p for p in output.split("\n\n") if p.strip()]) > 1
    min_length = len(output) > 100

    format_score = sum([has_bullets, has_headers, has_paragraphs, min_length]) / 4
    return {"key": "formatting", "score": format_score}


test_output = "RAG combina retrieval con generación. Usa vector stores para indexar documentos."
test_query = "¿Qué es RAG?"
test_reference = "RAG (Retrieval-Augmented Generation) combina un sistema de retrieval con un modelo generativo."

class MockRun:
    def __init__(self, outputs):
        self.outputs = outputs

class MockExample:
    def __init__(self, inputs, outputs):
        self.inputs = inputs
        self.outputs = outputs

mock_run = MockRun({"response": test_output})
mock_example = MockExample({"query": test_query}, {"reference": test_reference})

print("=== EVALUACIÓN MANUAL ===")
print(f"Query: {test_query}")
print(f"Output: {test_output}")
print(f"Reference: {test_reference[:60]}...")
print()

for evaluator in [relevance_evaluator, completeness_evaluator, format_evaluator]:
    result = evaluator(mock_run, mock_example)
    bar = "█" * int(result["score"] * 10)
    print(f"  {result['key']:<15} {result['score']:.2f} {bar}")
# Output esperado:
# === EVALUACIÓN MANUAL ===
# Query: ¿Qué es RAG?
# Output: RAG combina retrieval con generación. Usa vector stores para indexar documentos.
# Reference: RAG (Retrieval-Augmented Generation) combina un sistema de...
#
#   relevance       0.50 █████
#   completeness    0.67 ██████
#   formatting      0.25 ██

LLM-as-judge: usar un modelo para evaluar otro

Para criterios que requieren razonamiento (¿la respuesta es factualmente correcta? ¿el tono es apropiado?), un evaluator Python no es suficiente. Necesitas un LLM que evalúe:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model


def llm_relevance_judge(query: str, response: str, reference: str) -> dict:
    """Usa un LLM para evaluar relevancia en una escala de 1-5."""
    judge_model = init_chat_model("openai:gpt-4.1-mini")

    judge_prompt = f"""Eres un evaluador experto. Evalúa la RELEVANCIA de la respuesta a la pregunta.

Pregunta: {query}

Respuesta a evaluar:
{response}

Respuesta de referencia:
{reference}

Criterio de relevancia: ¿La respuesta aborda directamente lo que se preguntó? ¿Los temas mencionados son pertinentes?

Responde SOLO con un JSON:
{{"score": <1-5>, "reasoning": "<explicación breve>"}}

Escala:
1 = Completamente irrelevante
2 = Parcialmente relevante pero se desvía
3 = Relevante pero con información innecesaria
4 = Muy relevante
5 = Perfectamente relevante"""

    result = judge_model.invoke(judge_prompt)
    content = result.content.strip()

    import json
    try:
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {
            "key": "llm_relevance",
            "score": parsed["score"] / 5.0,
            "comment": parsed.get("reasoning", ""),
        }
    except (json.JSONDecodeError, KeyError):
        return {"key": "llm_relevance", "score": 0.5, "comment": f"Parse error: {content[:50]}"}


def llm_accuracy_judge(query: str, response: str, reference: str) -> dict:
    """Usa un LLM para evaluar exactitud factual."""
    judge_model = init_chat_model("openai:gpt-4.1-mini")

    judge_prompt = f"""Eres un evaluador experto. Evalúa la EXACTITUD FACTUAL de la respuesta.

Pregunta: {query}

Respuesta a evaluar:
{response}

Respuesta de referencia (ground truth):
{reference}

Criterio: ¿Los hechos mencionados son correctos? ¿Hay afirmaciones falsas o engañosas?

Responde SOLO con un JSON:
{{"score": <1-5>, "reasoning": "<explicación>", "factual_errors": ["<error 1>", "<error 2>"]}}

Escala:
1 = Múltiples errores factuales graves
2 = Algunos errores factuales
3 = Mayormente correcto con imprecisiones menores
4 = Correcto con una imprecisión menor
5 = Factualmente impecable"""

    result = judge_model.invoke(judge_prompt)
    content = result.content.strip()

    import json
    try:
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {
            "key": "llm_accuracy",
            "score": parsed["score"] / 5.0,
            "comment": parsed.get("reasoning", ""),
            "errors": parsed.get("factual_errors", []),
        }
    except (json.JSONDecodeError, KeyError):
        return {"key": "llm_accuracy", "score": 0.5, "comment": f"Parse error: {content[:50]}", "errors": []}


query = "¿Qué es RAG?"
response = "RAG es Retrieval-Augmented Generation. Combina búsqueda de documentos con generación de texto. Fue propuesto por Facebook AI Research en 2020."
reference = "RAG (Retrieval-Augmented Generation) combina un sistema de retrieval con un modelo generativo. Fue propuesto por Lewis et al. en 2020."

relevance = llm_relevance_judge(query, response, reference)
accuracy = llm_accuracy_judge(query, response, reference)

print(f"Query: {query}")
print(f"Response: {response[:80]}...")
print(f"\n=== LLM-AS-JUDGE RESULTS ===")
print(f"  Relevancia: {relevance['score']:.2f}{relevance['comment']}")
print(f"  Exactitud:  {accuracy['score']:.2f}{accuracy['comment']}")
if accuracy.get("errors"):
    print(f"  Errores: {accuracy['errors']}")
# Output esperado:
# Query: ¿Qué es RAG?
# Response: RAG es Retrieval-Augmented Generation. Combina búsqueda de doc...
#
# === LLM-AS-JUDGE RESULTS ===
#   Relevancia: 1.00 — La respuesta aborda directamente qué es RAG...
#   Exactitud:  0.80 — Mayormente correcto, el origen es correcto...

Calibración del LLM-as-judge: no confíes ciegamente

Un LLM judge tiene sesgos predecibles. El más común: es demasiado generoso. Tiende a dar scores de 4-5 incluso cuando la respuesta tiene problemas. Necesitas calibración:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
import json


def judge_with_calibration(query: str, response: str, reference: str) -> dict:
    """LLM judge con ejemplos de calibración incluidos en el prompt."""
    model = init_chat_model("openai:gpt-4.1-mini")

    prompt = f"""Eres un evaluador ESTRICTO. Evalúa la calidad de la respuesta.

EJEMPLOS DE CALIBRACIÓN (para que calibres tu escala):

Ejemplo 1 (Score: 5/5 — Perfecto):
  Q: "¿Qué es machine learning?"
  A: "Machine learning es una rama de la inteligencia artificial donde los sistemas aprenden patrones
     de datos sin ser programados explícitamente. Usa algoritmos como regresión, árboles de decisión,
     y redes neuronales. Se aplica en reconocimiento de imágenes, NLP, y recomendaciones."
  → Relevante, completo, factual, bien estructurado.

Ejemplo 2 (Score: 2/5 — Deficiente):
  Q: "¿Qué es machine learning?"
  A: "Es algo de computadoras que aprenden solas."
  → Relevante pero extremadamente incompleto e impreciso.

Ejemplo 3 (Score: 1/5 — Inaceptable):
  Q: "¿Qué es machine learning?"
  A: "Python es un lenguaje de programación muy popular."
  → Completamente irrelevante.

AHORA EVALÚA:
  Q: "{query}"
  A: "{response}"
  Reference: "{reference}"

Responde SOLO con JSON: {{"score": <1-5>, "reasoning": "<explicación>"}}
Sé ESTRICTO. Un score de 4 o 5 requiere excelencia real."""

    result = model.invoke(prompt)
    content = result.content.strip()

    try:
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {"score": parsed["score"] / 5.0, "reasoning": parsed.get("reasoning", "")}
    except (json.JSONDecodeError, KeyError):
        return {"score": 0.5, "reasoning": f"Parse error: {content[:80]}"}


test_cases = [
    {
        "query": "¿Qué es RAG?",
        "response": "RAG es una técnica de AI.",
        "reference": "RAG combina retrieval con generación usando vector stores y LLMs.",
        "expected": "low (incompleta)",
    },
    {
        "query": "¿Qué es RAG?",
        "response": "RAG (Retrieval-Augmented Generation) combina un sistema de retrieval que busca "
                    "documentos relevantes en un vector store con un LLM que genera respuestas usando "
                    "esos documentos como contexto. Se implementa con embeddings, vector databases "
                    "como Chroma o Pinecone, y un modelo generativo.",
        "reference": "RAG combina retrieval con generación usando vector stores y LLMs.",
        "expected": "high (completa y detallada)",
    },
    {
        "query": "¿Qué es RAG?",
        "response": "Python es un lenguaje de programación interpretado.",
        "reference": "RAG combina retrieval con generación.",
        "expected": "very low (irrelevante)",
    },
]

print("=== CALIBRACIÓN DEL JUDGE ===\n")
for i, tc in enumerate(test_cases):
    result = judge_with_calibration(tc["query"], tc["response"], tc["reference"])
    bar = "█" * int(result["score"] * 10)
    print(f"Test {i+1} (expected: {tc['expected']}):")
    print(f"  Response: {tc['response'][:60]}...")
    print(f"  Score: {result['score']:.2f} {bar}")
    print(f"  Reasoning: {result['reasoning'][:80]}")
    print()
# Output esperado:
# === CALIBRACIÓN DEL JUDGE ===
#
# Test 1 (expected: low (incompleta)):
#   Response: RAG es una técnica de AI....
#   Score: 0.40 ████
#   Reasoning: La respuesta es relevante pero extremadamente incompleta...
#
# Test 2 (expected: high (completa y detallada)):
#   Response: RAG (Retrieval-Augmented Generation) combina un sistema de r...
#   Score: 0.90 █████████
#   Reasoning: Respuesta completa, factual, y bien estructurada...
#
# Test 3 (expected: very low (irrelevante)):
#   Response: Python es un lenguaje de programación interpretado....
#   Score: 0.20 ██
#   Reasoning: Completamente irrelevante a la pregunta...

Reglas de calibración

  • Incluye ejemplos de calibración en el prompt del judge: uno bueno (5/5), uno mediocre (2-3/5), y uno malo (1/5)
  • Pide que sea estricto explícitamente: "Un 4 o 5 requiere excelencia real"
  • Incluye ground truth siempre que sea posible: sin referencia, el judge no puede evaluar exactitud
  • No confíes en un solo criterio: usa múltiples evaluators (relevance + completeness + accuracy)
  • No asumas que el judge es correcto: valida con ejemplos humanos periódicamente

Ejecutar evaluación con LangSmith

LangSmith integra datasets y evaluators en un workflow de evaluación completo:

from dotenv import load_dotenv
load_dotenv()

from langsmith import Client
from langsmith.evaluation import evaluate
from langchain.chat_models import init_chat_model
import json

client = Client()


def research_agent(inputs: dict) -> dict:
    """Simula el Research Assistant respondiendo una consulta."""
    model = init_chat_model("openai:gpt-4.1-mini")
    response = model.invoke(
        f"Responde de forma concisa y técnica:\n\n{inputs['query']}"
    )
    return {"response": response.content}


def relevance_eval(run, example) -> dict:
    """Evalúa relevancia con LLM judge."""
    output = run.outputs.get("response", "") if run.outputs else ""
    query = example.inputs.get("query", "")

    model = init_chat_model("openai:gpt-4.1-mini")
    result = model.invoke(
        f"¿La siguiente respuesta es relevante a la pregunta? "
        f"Responde con un JSON: {{\"score\": <0.0-1.0>, \"reasoning\": \"...\"}}\n\n"
        f"Pregunta: {query}\n\nRespuesta: {output}"
    )

    try:
        content = result.content.strip()
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {"key": "relevance", "score": float(parsed.get("score", 0.5))}
    except (json.JSONDecodeError, KeyError, ValueError):
        return {"key": "relevance", "score": 0.5}


def length_eval(run, example) -> dict:
    """Evalúa si la respuesta tiene una longitud adecuada (>50 caracteres)."""
    output = run.outputs.get("response", "") if run.outputs else ""
    score = 1.0 if len(output) > 50 else len(output) / 50.0
    return {"key": "adequate_length", "score": score}


def has_structure_eval(run, example) -> dict:
    """Evalúa si la respuesta tiene estructura (bullets, párrafos, etc.)."""
    output = run.outputs.get("response", "") if run.outputs else ""
    has_bullets = any(line.strip().startswith(("-", "•", "*", "1.")) for line in output.split("\n"))
    has_paragraphs = len([p for p in output.split("\n\n") if p.strip()]) > 1
    score = 0.5 * has_bullets + 0.5 * has_paragraphs
    return {"key": "structure", "score": score}


dataset_name = "research-assistant-eval"

try:
    existing = client.read_dataset(dataset_name=dataset_name)
except Exception:
    existing = client.create_dataset(dataset_name=dataset_name)
    examples = [
        {"input": {"query": "¿Qué es RAG?"}, "output": {"reference": "RAG combina retrieval con generación."}},
        {"input": {"query": "¿Cómo funciona attention?"}, "output": {"reference": "Attention usa Q, K, V matrices."}},
        {"input": {"query": "Compara fine-tuning vs RAG"}, "output": {"reference": "Fine-tuning modifica el modelo, RAG agrega contexto."}},
    ]
    for ex in examples:
        client.create_example(dataset_id=existing.id, inputs=ex["input"], outputs=ex["output"])

results = evaluate(
    research_agent,
    data=dataset_name,
    evaluators=[relevance_eval, length_eval, has_structure_eval],
    experiment_prefix="research-eval-v1",
)

print(f"\n=== RESULTADOS DE EVALUACIÓN ===")
print(f"Experiment: research-eval-v1")
print(f"Dataset: {dataset_name}")
print(f"\n→ Abre LangSmith para ver los resultados detallados por ejemplo")
print(f"→ Cada example tiene scores de relevance, adequate_length, y structure")
# Output esperado:
# === RESULTADOS DE EVALUACIÓN ===
# Experiment: research-eval-v1
# Dataset: research-assistant-eval
#
# → Abre LangSmith para ver los resultados detallados por ejemplo
# → Cada example tiene scores de relevance, adequate_length, y structure

Interpretar resultados: distribuciones y patrones

Los scores individuales importan, pero los patrones importan más:

from dotenv import load_dotenv
load_dotenv()

import random

random.seed(42)
results = []
for i in range(20):
    results.append({
        "query": f"Query {i+1}",
        "relevance": random.uniform(0.6, 1.0),
        "completeness": random.uniform(0.3, 0.9),
        "accuracy": random.uniform(0.7, 1.0),
        "formatting": random.uniform(0.2, 1.0),
    })

print("=== DISTRIBUCIÓN DE SCORES ===\n")

for metric in ["relevance", "completeness", "accuracy", "formatting"]:
    scores = [r[metric] for r in results]
    avg = sum(scores) / len(scores)
    min_s = min(scores)
    max_s = max(scores)
    low_count = sum(1 for s in scores if s < 0.5)

    bar = "█" * int(avg * 20)
    print(f"  {metric:<15} avg={avg:.2f} min={min_s:.2f} max={max_s:.2f} low(<0.5)={low_count} {bar}")

failures = [r for r in results if r["relevance"] < 0.5 or r["accuracy"] < 0.5]
print(f"\n=== PATTERN ANALYSIS ===")
print(f"  Total examples: {len(results)}")
print(f"  Failures (relevance<0.5 OR accuracy<0.5): {len(failures)}")

if failures:
    print(f"  Failed queries:")
    for f in failures:
        print(f"    - {f['query']}: relevance={f['relevance']:.2f}, accuracy={f['accuracy']:.2f}")
else:
    print(f"  ✅ No critical failures detected")

weakest = min(
    ["relevance", "completeness", "accuracy", "formatting"],
    key=lambda m: sum(r[m] for r in results) / len(results)
)
print(f"\n  Weakest criterion: {weakest}")
print(f"  → Focus optimization efforts here")
# Output esperado:
# === DISTRIBUCIÓN DE SCORES ===
#
#   relevance       avg=0.82 min=0.62 max=0.99 low(<0.5)=0 ████████████████
#   completeness    avg=0.58 min=0.31 max=0.88 low(<0.5)=6 ███████████
#   accuracy        avg=0.87 min=0.71 max=1.00 low(<0.5)=0 █████████████████
#   formatting      avg=0.59 min=0.22 max=0.98 low(<0.5)=5 ███████████
#
# === PATTERN ANALYSIS ===
#   Total examples: 20
#   Failures (relevance<0.5 OR accuracy<0.5): 0
#   ✅ No critical failures detected
#
#   Weakest criterion: completeness
#   → Focus optimization efforts here

Qué buscar en los resultados

  • Score promedio por criterio: ¿qué criterio es el más débil? Enfoca la optimización ahí
  • Distribución: un promedio de 0.7 puede significar "todo alrededor de 0.7" (consistente) o "mitad en 0.9, mitad en 0.5" (inconsistente)
  • Failures por tipo de query: ¿las queries difíciles fallan más? ¿Un topic específico tiene scores bajos?
  • Cambios entre versiones: la misma evaluación antes y después de un cambio de prompt

Evaluation como práctica continua

La evaluación no es un paso final. Es parte del workflow de desarrollo:

Ciclo de desarrollo con evaluación:

1. Cambias algo (prompt, modelo, tool, lógica)
     ↓
2. Ejecutas el evaluation dataset
     ↓
3. Comparas scores con la versión anterior
     ↓
4. ¿Mejoró? → Deploy
   ¿Empeoró? → Revertir y re-iterar
   ¿Mezclado? (mejoró en X, empeoró en Y) → Decisión informada
     ↓
5. Repite

Cada experiment en LangSmith tiene un nombre (experiment_prefix). Nombralos con la versión del cambio para comparar:

research-eval-v7-baseline
research-eval-v7-new-prompt
research-eval-v7-gpt4.1-mini
research-eval-v7-with-reranking

Troubleshooting

Problema 1: "El LLM judge da scores muy altos a todo"

Síntoma: Todos los examples obtienen 4-5/5 incluso cuando las respuestas son mediocres.

Causa: El LLM judge tiene un sesgo de generosidad natural.

Solución: Incluye ejemplos de calibración en el prompt del judge (un ejemplo bueno, uno mediocre, uno malo). Pide explícitamente que sea estricto. Agrega la instrucción "Un score de 5 es raro y requiere excelencia excepcional."

Problema 2: "Los scores del evaluator custom no son consistentes"

Síntoma: La misma respuesta obtiene scores diferentes en ejecuciones distintas.

Causa: El LLM judge no es determinista. Con temperature > 0, las respuestas varían.

Solución: Usa temperature=0 para el modelo judge. Si aún hay variación, ejecuta el judge 3 veces y usa la mediana.

Problema 3: "El dataset es demasiado pequeño para ser significativo"

Síntoma: 5 examples no te dan confianza en los scores.

Causa: Los resultados con muestras pequeñas son ruidosos.

Solución: Apunta a 30-50 examples mínimo. Cubre diferentes niveles de dificultad, topics, y formatos esperados. Agrega examples cada vez que encuentres un nuevo tipo de consulta en producción.

Problema 4: "No sé cómo crear respuestas de referencia"

Síntoma: Tienes las preguntas pero no las respuestas esperadas.

Causa: Crear ground truth es trabajo manual y requiere expertise.

Solución: Empieza con respuestas generadas por un modelo potente (GPT-4.1) y revísalas manualmente. No necesitan ser perfectas — necesitan capturar los puntos clave que esperas en una buena respuesta.

Problema 5: "evaluate() tarda mucho"

Síntoma: La evaluación con 50 examples tarda 10+ minutos.

Causa: Cada example ejecuta el agente + los evaluators. Con LLM-as-judge, son múltiples llamadas LLM por example.

Solución: Usa gpt-4.1-mini para el judge (más rápido y barato). LangSmith ejecuta evaluaciones en paralelo por defecto. Para datasets muy grandes, considera evaluar un subset representativo.


Ejercicios

Ejercicio 1: Crear un dataset de evaluación (Fácil)

Crea un dataset en LangSmith con 5 examples sobre temas de AI. Cada example debe tener: query, reference answer, y metadata (difficulty, topic). Verifica que el dataset aparece en el dashboard.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langsmith import Client

client = Client()

dataset = client.create_dataset(
    dataset_name="ai-basics-eval",
    description="Evaluación de conocimiento básico de AI",
)

examples = [
    {"input": {"query": "¿Qué es un transformer?"}, "output": {"reference": "Un transformer es una arquitectura de red neuronal basada en el mecanismo de self-attention, propuesta en 2017 por Vaswani et al."}, "metadata": {"difficulty": "easy", "topic": "architecture"}},
    {"input": {"query": "¿Qué es fine-tuning?"}, "output": {"reference": "Fine-tuning es el proceso de re-entrenar un modelo pre-entrenado en un dataset específico para adaptarlo a una tarea particular."}, "metadata": {"difficulty": "easy", "topic": "training"}},
    {"input": {"query": "Explica el dilema de bias-variance"}, "output": {"reference": "El dilema bias-variance describe el trade-off entre modelos simples (alto bias, baja varianza) y complejos (bajo bias, alta varianza). El objetivo es encontrar el punto óptimo."}, "metadata": {"difficulty": "medium", "topic": "ML-theory"}},
    {"input": {"query": "¿Cómo funciona RLHF?"}, "output": {"reference": "RLHF (Reinforcement Learning from Human Feedback) entrena un reward model basado en preferencias humanas, y luego usa PPO para optimizar el LLM según ese reward model."}, "metadata": {"difficulty": "hard", "topic": "training"}},
    {"input": {"query": "Compara GPT-4 vs Claude en capacidades"}, "output": {"reference": "GPT-4 y Claude son LLMs competitivos. GPT-4 destaca en razonamiento y coding. Claude destaca en contexto largo y seguimiento de instrucciones. Ambos soportan multimodal."}, "metadata": {"difficulty": "medium", "topic": "models"}},
]

for ex in examples:
    client.create_example(
        dataset_id=dataset.id,
        inputs=ex["input"],
        outputs=ex["output"],
        metadata=ex["metadata"],
    )

print(f"Dataset creado: 'ai-basics-eval'")
print(f"Examples: {len(examples)}")
for ex in examples:
    print(f"  [{ex['metadata']['difficulty']:>6}] {ex['input']['query']}")
# Output esperado:
# Dataset creado: 'ai-basics-eval'
# Examples: 5
#   [  easy] ¿Qué es un transformer?
#   [  easy] ¿Qué es fine-tuning?
#   [medium] Explica el dilema de bias-variance
#   [  hard] ¿Cómo funciona RLHF?
#   [medium] Compara GPT-4 vs Claude en capacidades

Ejercicio 2: Implementar un evaluator de longitud (Fácil)

Crea un evaluator que verifique si la respuesta tiene entre 50 y 500 caracteres. Score 1.0 si está en rango, 0.5 si es un poco fuera, 0.0 si es muy corta o muy larga. Pruébalo con 3 respuestas de diferentes longitudes.

Ver solución
from dotenv import load_dotenv
load_dotenv()


def length_range_evaluator(response: str, min_len: int = 50, max_len: int = 500) -> dict:
    """Evalúa si la respuesta está en un rango de longitud aceptable."""
    length = len(response)

    if min_len <= length <= max_len:
        score = 1.0
        comment = f"Longitud perfecta: {length} chars"
    elif length < min_len:
        score = max(0, length / min_len)
        comment = f"Demasiado corta: {length} chars (mínimo: {min_len})"
    else:
        overshoot = (length - max_len) / max_len
        score = max(0, 1.0 - overshoot)
        comment = f"Demasiado larga: {length} chars (máximo: {max_len})"

    return {"key": "length_range", "score": round(score, 2), "comment": comment}


test_responses = [
    "AI es genial.",
    "Machine learning es una rama de la inteligencia artificial que permite a los sistemas aprender de datos. Se usa en reconocimiento de imágenes, NLP, y recomendaciones.",
    "A" * 800,
]

print("=== TEST DE EVALUATOR DE LONGITUD ===\n")
for i, response in enumerate(test_responses):
    result = length_range_evaluator(response)
    bar = "█" * int(result["score"] * 10)
    print(f"Test {i+1}: '{response[:50]}{'...' if len(response) > 50 else ''}'")
    print(f"  Score: {result['score']:.2f} {bar}")
    print(f"  {result['comment']}")
    print()
# Output esperado:
# === TEST DE EVALUATOR DE LONGITUD ===
#
# Test 1: 'AI es genial.'
#   Score: 0.28 ██
#   Demasiado corta: 14 chars (mínimo: 50)
#
# Test 2: 'Machine learning es una rama de la inteligencia artif...'
#   Score: 1.00 ██████████
#   Longitud perfecta: 165 chars
#
# Test 3: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...'
#   Score: 0.40 ████
#   Demasiado larga: 800 chars (máximo: 500)

Ejercicio 3: LLM-as-judge con calibración (Medio)

Implementa un LLM judge que evalúe completitud. Incluye 3 ejemplos de calibración en el prompt (completo, parcial, incompleto). Pruébalo con 3 respuestas de diferente completitud y verifica que los scores reflejan la calidad real.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
import json


def completeness_judge(query: str, response: str, reference: str) -> dict:
    """LLM judge calibrado para completitud."""
    model = init_chat_model("openai:gpt-4.1-mini", temperature=0)

    prompt = f"""Eres un evaluador ESTRICTO de completitud.

CALIBRACIÓN:

Ejemplo 1 (5/5 — Completo):
  Q: "¿Qué es ML?"
  Reference: "ML es una rama de AI que aprende de datos. Incluye supervisado, no-supervisado, y refuerzo."
  A: "ML es una rama de la inteligencia artificial. Los sistemas aprenden patrones de datos.
     Tres tipos principales: aprendizaje supervisado (con labels), no-supervisado (sin labels),
     y por refuerzo (reward signals)."
  → Cubre todos los puntos de la referencia.

Ejemplo 2 (3/5 — Parcial):
  Q: "¿Qué es ML?"
  Reference: "ML es una rama de AI que aprende de datos. Incluye supervisado, no-supervisado, y refuerzo."
  A: "ML es inteligencia artificial que aprende de datos. El tipo más común es supervisado."
  → Cubre la definición pero solo un tipo de los tres.

Ejemplo 3 (1/5 — Incompleto):
  Q: "¿Qué es ML?"
  Reference: "ML es una rama de AI que aprende de datos. Incluye supervisado, no-supervisado, y refuerzo."
  A: "Es un tema de computación."
  → No cubre ningún punto específico de la referencia.

EVALÚA:
  Q: "{query}"
  Reference: "{reference}"
  A: "{response}"

Responde SOLO con JSON: {{"score": <1-5>, "reasoning": "<explicación>", "covered": ["<punto cubierto>"], "missing": ["<punto faltante>"]}}"""

    result = model.invoke(prompt)
    content = result.content.strip()

    try:
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {
            "score": parsed["score"] / 5.0,
            "reasoning": parsed.get("reasoning", ""),
            "covered": parsed.get("covered", []),
            "missing": parsed.get("missing", []),
        }
    except (json.JSONDecodeError, KeyError):
        return {"score": 0.5, "reasoning": f"Parse error", "covered": [], "missing": []}


query = "¿Qué es RAG y cómo se implementa?"
reference = "RAG combina retrieval con generación. Se implementa con vector stores, embeddings, un retriever, y un LLM."

test_responses = [
    ("RAG (Retrieval-Augmented Generation) combina un retriever que busca documentos en un vector store "
     "con un LLM que genera respuestas. Se implementa con embeddings para indexar, una base vectorial "
     "como Chroma, un retriever para buscar, y un modelo como GPT-4 para generar.", "complete"),
    ("RAG es una técnica que usa búsqueda para mejorar las respuestas de un LLM.", "partial"),
    ("Es un acrónimo de AI.", "incomplete"),
]

print("=== COMPLETENESS JUDGE (calibrado) ===\n")
for response, expected in test_responses:
    result = completeness_judge(query, response, reference)
    bar = "█" * int(result["score"] * 10)
    print(f"[Expected: {expected}]")
    print(f"  Response: {response[:70]}...")
    print(f"  Score: {result['score']:.2f} {bar}")
    print(f"  Reasoning: {result['reasoning'][:80]}")
    if result["missing"]:
        print(f"  Missing: {result['missing']}")
    print()
# Output esperado:
# === COMPLETENESS JUDGE (calibrado) ===
#
# [Expected: complete]
#   Response: RAG (Retrieval-Augmented Generation) combina un retriever que busca docu...
#   Score: 1.00 ██████████
#   Reasoning: Cubre todos los puntos: retrieval + generación, vector stores, embeddings...
#
# [Expected: partial]
#   Response: RAG es una técnica que usa búsqueda para mejorar las respuestas de un LLM...
#   Score: 0.50 █████
#   Reasoning: Cubre la idea general pero no menciona implementación...
#   Missing: ['vector stores', 'embeddings', 'retriever']
#
# [Expected: incomplete]
#   Response: Es un acrónimo de AI....
#   Score: 0.20 ██
#   Reasoning: No cubre ningún punto de la referencia...

Ejercicio 4: Ejecutar evaluación completa con evaluate() (Medio)

Crea un dataset de 3 examples, implementa 2 evaluators (uno Python y uno LLM-as-judge), y ejecuta evaluate() de LangSmith. Imprime los resultados y el link al experiment en el dashboard.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langsmith import Client
from langsmith.evaluation import evaluate
from langchain.chat_models import init_chat_model
import json

client = Client()

dataset_name = "eval-exercise-4"
try:
    ds = client.read_dataset(dataset_name=dataset_name)
except Exception:
    ds = client.create_dataset(dataset_name=dataset_name)
    examples = [
        {"input": {"query": "¿Qué es un LLM?"}, "output": {"reference": "Un LLM es un modelo de lenguaje grande entrenado en texto masivo para generar y entender lenguaje natural."}},
        {"input": {"query": "¿Qué es embeddings?"}, "output": {"reference": "Embeddings son representaciones numéricas de texto en un espacio vectorial donde textos similares están cerca."}},
        {"input": {"query": "¿Qué es prompt engineering?"}, "output": {"reference": "Prompt engineering es el diseño de instrucciones para obtener mejores respuestas de un LLM."}},
    ]
    for ex in examples:
        client.create_example(dataset_id=ds.id, inputs=ex["input"], outputs=ex["output"])


def my_agent(inputs: dict) -> dict:
    model = init_chat_model("openai:gpt-4.1-mini")
    response = model.invoke(f"Responde en 2-3 oraciones:\n\n{inputs['query']}")
    return {"response": response.content}


def python_length_eval(run, example) -> dict:
    output = run.outputs.get("response", "") if run.outputs else ""
    score = 1.0 if 50 < len(output) < 500 else 0.5
    return {"key": "length_ok", "score": score}


def llm_quality_eval(run, example) -> dict:
    output = run.outputs.get("response", "") if run.outputs else ""
    query = example.inputs.get("query", "")
    reference = example.outputs.get("reference", "") if example.outputs else ""

    model = init_chat_model("openai:gpt-4.1-mini", temperature=0)
    result = model.invoke(
        f"Evalúa calidad de 0.0 a 1.0. Responde SOLO con JSON: {{\"score\": <float>}}\n\n"
        f"Pregunta: {query}\nReferencia: {reference}\nRespuesta: {output}"
    )
    try:
        content = result.content.strip()
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {"key": "llm_quality", "score": float(parsed.get("score", 0.5))}
    except (json.JSONDecodeError, KeyError, ValueError):
        return {"key": "llm_quality", "score": 0.5}


results = evaluate(
    my_agent,
    data=dataset_name,
    evaluators=[python_length_eval, llm_quality_eval],
    experiment_prefix="eval-exercise-4-v1",
)

print(f"✅ Evaluación completada")
print(f"Dataset: {dataset_name}")
print(f"Experiment: eval-exercise-4-v1")
print(f"\n→ Abre LangSmith → Datasets → '{dataset_name}' → ver resultados")
# Output esperado:
# ✅ Evaluación completada
# Dataset: eval-exercise-4
# Experiment: eval-exercise-4-v1
#
# → Abre LangSmith → Datasets → 'eval-exercise-4' → ver resultados

Ejercicio 5: Detectar sesgo del judge (Avanzado)

Crea un test de sesgo: envía al LLM judge una respuesta claramente mediocre pero bien escrita, y una respuesta correcta pero con errores de formato. Compara los scores. ¿El judge penaliza más el formato o la exactitud? Documenta tus hallazgos.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
import json


def biased_judge_test(query: str, response: str, reference: str, label: str) -> dict:
    model = init_chat_model("openai:gpt-4.1-mini", temperature=0)

    prompt = f"""Evalúa la calidad general de esta respuesta de 1 a 5.

Pregunta: {query}
Referencia: {reference}
Respuesta: {response}

Responde con JSON: {{"score": <1-5>, "reasoning": "<explicación>"}}"""

    result = model.invoke(prompt)
    content = result.content.strip()

    try:
        if "```" in content:
            content = content.split("```")[1].replace("json", "").strip()
        parsed = json.loads(content)
        return {
            "label": label,
            "score": parsed["score"],
            "reasoning": parsed.get("reasoning", ""),
        }
    except (json.JSONDecodeError, KeyError):
        return {"label": label, "score": 0, "reasoning": "parse error"}


query = "¿Qué es machine learning?"
reference = "Machine learning es una rama de AI donde los sistemas aprenden de datos. Incluye supervisado, no-supervisado, y por refuerzo."

test_cases = [
    {
        "response": "Machine learning, comúnmente abreviado como ML, representa una fascinante "
                    "intersección entre la estadística computacional y la inteligencia artificial. "
                    "Es un campo que ha revolucionado múltiples industrias y continúa evolucionando "
                    "a un ritmo impresionante en la era moderna de la tecnología.",
        "label": "well-written-but-vague",
        "description": "Bien escrita pero vaga (no menciona tipos, no es técnica)",
    },
    {
        "response": "ml = AI subsection. learns from data. types: supervised (labels), "
                    "unsupervised (no labels), reinforcement (rewards). uses: image recognition, "
                    "nlp, recommendations. key algorithms: linear regression, decision trees, neural nets.",
        "label": "ugly-but-accurate",
        "description": "Mal formateada pero técnicamente completa y correcta",
    },
    {
        "response": "Machine learning es una rama de la inteligencia artificial que permite a los "
                    "sistemas aprender patrones de datos sin programación explícita. Los tres tipos "
                    "principales son: supervisado, no-supervisado, y por refuerzo.",
        "label": "balanced-good",
        "description": "Bien escrita Y correcta (control)",
    },
]

print("=== TEST DE SESGO DEL JUDGE ===\n")
results = []
for tc in test_cases:
    result = biased_judge_test(query, tc["response"], reference, tc["label"])
    results.append(result)
    print(f"[{tc['label']}] — {tc['description']}")
    print(f"  Score: {result['score']}/5")
    print(f"  Reasoning: {result['reasoning'][:80]}")
    print()

print("=== ANÁLISIS DE SESGO ===")
vague = next(r for r in results if r["label"] == "well-written-but-vague")
accurate = next(r for r in results if r["label"] == "ugly-but-accurate")
control = next(r for r in results if r["label"] == "balanced-good")

print(f"  Well-written but vague: {vague['score']}/5")
print(f"  Ugly but accurate:     {accurate['score']}/5")
print(f"  Balanced (control):    {control['score']}/5")

if vague["score"] >= accurate["score"]:
    print(f"\n  ⚠️  SESGO DETECTADO: El judge favorece texto bien escrito sobre exactitud técnica.")
    print(f"  → La respuesta vaga ({vague['score']}/5) scored >= la precisa ({accurate['score']}/5)")
    print(f"  → Solución: agregar criterios explícitos de exactitud en el prompt del judge")
else:
    print(f"\n  ✅ El judge parece priorizar exactitud sobre estilo.")
# Output esperado:
# === TEST DE SESGO DEL JUDGE ===
#
# [well-written-but-vague] — Bien escrita pero vaga
#   Score: 3/5
#   Reasoning: Well-written but lacks specific technical details...
#
# [ugly-but-accurate] — Mal formateada pero correcta
#   Score: 4/5
#   Reasoning: Covers all key points accurately despite poor formatting...
#
# [balanced-good] — Control
#   Score: 5/5
#   Reasoning: Complete, accurate, and well-structured...
#
# === ANÁLISIS DE SESGO ===
#   Well-written but vague: 3/5
#   Ugly but accurate:     4/5
#   Balanced (control):    5/5
#
#   ✅ El judge parece priorizar exactitud sobre estilo.

Resumen

En esta cápsula aprendiste:

  • "¿Es buena?" no es evaluación. Evaluación real usa criterios específicos: relevancia (¿aborda la pregunta?), completitud (¿cubre todos los aspectos?), exactitud (¿los hechos son correctos?), y formato (¿la estructura es la solicitada?). Cada criterio es un evaluator separado
  • Datasets de evaluación son colecciones de examples con query, reference answer, y metadata. Un buen dataset tiene 30-50 examples, cubre diferentes dificultades y topics, y se actualiza continuamente
  • Custom evaluators en Python son funciones que reciben el output del agente y retornan un score. Son rápidos, deterministas, y perfectos para criterios simples (longitud, formato, keywords)
  • LLM-as-judge usa un modelo para evaluar otro. Es poderoso para criterios que requieren razonamiento (relevancia, exactitud), pero tiene sesgos: tiende a ser demasiado generoso, puede ignorar errores factuales, y no es determinista
  • La calibración del judge es obligatoria: incluye ejemplos de calibración en el prompt (bueno, mediocre, malo), pide que sea estricto, y valida con ejemplos humanos periódicamente
  • evaluate() de LangSmith ejecuta un dataset completo con múltiples evaluators y genera un experiment con resultados detallados. Nombra los experiments con la versión del cambio para comparar
  • Evaluation es práctica continua: ejecuta con cada cambio de prompt, cada actualización de modelo, cada nueva feature. Los numbers te dicen si mejoraste o empeoraste — no más adivinanzas

Próxima cápsula: Token Tracking y Cost Control — cómo saber exactamente cuánto cuesta cada operación de tu agente, configurar rate limiting por usuario, y tomar decisiones de negocio basadas en costos reales.


Recursos adicionales

  1. LangSmith — Evaluation Quickstart — Inicio rápido para crear datasets y ejecutar evaluaciones
  2. LangSmith — How to create and manage datasets — Gestión de datasets con el SDK
  3. LangSmith — Custom Evaluators — Cómo crear evaluators custom en Python
  4. LangSmith — LLM-as-Judge — Guía para implementar LLM-as-judge con calibración
  5. LangSmith — Compare experiments — Comparar resultados entre versiones
  6. Judging LLM-as-Judge — Research Paper — Paper sobre sesgos y limitaciones de LLM-as-judge (MT-Bench)

Módulo 12 — LangChain & LangGraph: From Chains to Agents