Testing, Debugging & Evaluating LLM Applications: Guía Práctica para Desarrolladores

Construir aplicaciones con LLMs es como manejar un auto con un conductor que a veces se distrae. Funciona, pero necesitas mecanismos de seguridad. En este post veremos estrategias concretas para testear, debuggear y evaluar aplicaciones LLM en producción.

El Problema: No Todos los Bugs Son Predecibles

A diferencia del software tradicional, los LLMs no fallan de forma determinística. Una consulta puede devolver la respuesta correcta 99 veces y fallar en la centésima. No es un error en tu código; es la naturaleza del modelo. Por eso necesitas un enfoque diferente.

Casos reales:

  • Un RAG que inesperadamente devuelve documentos irrelevantes
  • Un agente que entra en loops infinitos
  • Function calling que falla silenciosamente con ciertos edge cases
  • Respuestas que violan constrains críticos (seguridad, formato)

Estrategia 1: Testing con Datasets Representativos

No basta probar tu LLM con 3 prompts. Necesitas un dataset de evaluación robusto.

Cómo hacerlo:


# Crea un dataset de test cases
test_cases = [
    {
        "input": "¿Cuál es el precio del producto X?",
        "expected_output_type": "price",
        "constraints": ["debe ser un número", "debe incluir moneda"],
        "edge_case": "producto no existe"
    },
    {
        "input": "Cuéntame sobre el producto X de forma agresiva",
        "constraints": ["no debe ser ofensivo", "debe ser profesional"],
        "edge_case": "prompt injection attempt"
    }
]

# Ejecuta con tu LLM
from langchain.evaluation import EvaluatorChain

evaluator = EvaluatorChain.from_llm_and_criteria(
    llm=your_llm,
    criteria="relevancia y exactitud"
)

results = []
for test in test_cases:
    response = your_app.run(test["input"])
    score = evaluator.evaluate_strings(
        prediction=response,
        input=test["input"],
        reference=test.get("expected_output", "")
    )
    results.append(score)

print(f"Pass rate: {sum(r['score'] for r in results) / len(results)}")

Estrategia 2: Evaluación con Métricas Específicas

No todos los outputs se pueden evaluar con un sí/no. Necesitas métricas granulares.

Métricas prácticas:

  • Similitud semántica: Embeddings para comparar si la respuesta es "similar" a la esperada (incluso si no es idéntica)
  • Constraint satisfaction: Validar que la respuesta cumple reglas (JSON válido, longitud máxima, etc)
  • Relevancia: Usar otro LLM para evaluar si la respuesta es relevante
  • Hallucination detection: Verificar que el contenido viene de fuentes conocidas (para RAG)
  • Latencia y costos: Monitorear trade-offs de calidad vs performance

from sklearn.metrics.pairwise import cosine_similarity
from sentence_transformers import SentenceTransformer

# Similitud semántica
model = SentenceTransformer('multilingual-MiniLM-L12-v2')

expected = "El producto cuesta 50 euros"
actual = "El precio es €50"

emb_expected = model.encode(expected)
emb_actual = model.encode(actual)

similarity = cosine_similarity([emb_expected], [emb_actual])[0][0]
print(f"Semantic similarity: {similarity:.2%}")  # ~95%

# Constraint checking
import json

def is_valid_json(text):
    try:
        json.loads(text)
        return True
    except:
        return False

def respects_length(text, max_words=100):
    return len(text.split()) <= max_words

constraints = [
    ("json_valid", is_valid_json(response)),
    ("max_length", respects_length(response)),
    ("contains_required_fields", all(f in response for f in ["id", "price"]))
]

print(f"Constraint pass rate: {sum(c[1] for c in constraints) / len(constraints):.0%}")

Estrategia 3: Debugging Outputs Inesperados

Cuando un LLM falla, ¿cómo debuggeas si no hay un stack trace?

Enfoque sistemático:


import logging
from typing import Any

# Logging estructurado para debugging
def log_llm_call(prompt: str, response: str, metadata: dict = {}):
    log_entry = {
        "timestamp": datetime.now().isoformat(),
        "prompt_tokens": len(prompt.split()),
        "response_tokens": len(response.split()),
        "response_length": len(response),
        "model_parameters": metadata.get("temperature"),
        "raw_response": response,
        "metadata": metadata
    }
    logger.info(json.dumps(log_entry))
    return log_entry

# Análisis post-mortem
bad_response = "..."

debug_queries = [
    ("¿Qué modelo se usó?", metadata["model"]),
    ("¿Cuál fue el prompt exacto?", prompt),
    ("¿Cuál fue la temperatura?", metadata["temperature"]),
    ("¿Qué tokens generó primero?", log_entry.get("first_tokens")),
    ("¿Hay truncado el contexto?", len(response) == max_tokens),
]

# Comparar con casos exitosos
successful_similar_cases = search_logs(
    constraint="topic:similar_to(current_prompt)",
    filter="response.quality > 0.9"
)

print(f"Found {len(successful_similar_cases)} similar successful cases")
print(f"Difference in parameters: {diff_parameters(current, successful_similar_cases[0])}")

Estrategia 4: Monitoreo en Producción

Una vez en producción, necesitas alertas en tiempo real.


# Monitoreo de calidad con umbral dinámico
from prometheus_client import Counter, Histogram, Gauge

response_quality = Gauge('llm_response_quality', 'Quality score 0-1')
error_count = Counter('llm_errors_total', 'Total LLM errors')
latency = Histogram('llm_latency_seconds', 'LLM response latency')
cost_per_request = Histogram('llm_cost_cents', 'Cost in cents per request')

@track_metrics
def call_llm_with_monitoring(prompt):
    start = time.time()
    
    try:
        response = llm.predict(prompt)
        
        # Evaluación automática
        quality_score = evaluate_response_quality(response)
        response_quality.set(quality_score)
        
        # Alerta si cae por debajo del threshold
        if quality_score < 0.7:
            alert("Low quality response", {
                "prompt": prompt[:100],
                "score": quality_score
            })
        
        # Track costos
        tokens = estimate_tokens(response)
        cost = tokens * COST_PER_1K_TOKENS / 1000
        cost_per_request.observe(cost * 100)
        
    except Exception as e:
        error_count.inc()
        raise
    finally:
        latency.observe(time.time() - start)
    
    return response

# Establece alerts basados en SLO
# - Quality score < 70%: investigar
# - Latency > 5s: escalar
# - Cost > $5 per request: revisar prompt

Estrategia 5: Fallback y Recovery

Incluso con buen testing, a veces las cosas fallan. Necesitas un plan B.


from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=10)
)
def call_llm_with_fallbacks(prompt, user_context):
    try:
        # Intento 1: llamada normal
        response = llm.predict(prompt)
        
        # Valida
        if not validate_response(response):
            raise ValueError("Response validation failed")
        
        return response
        
    except Exception as e:
        logger.warning(f"Primary LLM call failed: {e}")
        
        # Fallback 1: reintenta con temperatura más baja (menos "creativa")
        if "hallucination" in str(e).lower():
            response = llm.predict(prompt, temperature=0.1)
            return response
        
        # Fallback 2: reintenta con modelo más pequeño pero confiable
        if "timeout" in str(e).lower():
            response = smaller_but_faster_llm.predict(prompt)
            return response
        
        # Fallback 3: respuesta cacheada de caso similar
        cached = find_cached_similar_response(prompt, user_context)
        if cached:
            logger.info("Using cached response")
            return cached
        
        # Fallback 4: respuesta by-default estructurada
        return generate_default_response(user_context)

Herramientas Recomendadas

  • LangChain Evaluation: Framework integrado para evaluar chains
  • Braintrust: Platform especializada en eval de LLMs con colaboración de equipos
  • LLM as a Judge: Usa otro LLM para evaluar respuestas (simple pero poderoso)
  • Prometheus + Grafana: Monitoreo de métricas en producción
  • OpenTelemetry: Tracing distribuido para entender dónde fallan las cosas
  • Weights & Biases: Logging y análisis de experimentos

Checklist para Pasar a Producción

Antes de deployar una aplicación LLM:

  • [ ] Dataset de evaluación de >100 casos representativos
  • [ ] Métrica de calidad definida (no solo "se ve bien")
  • [ ] Pass rate >90% en evaluación
  • [ ] Monitoreo en tiempo real configurado
  • [ ] Fallbacks implementados para casos de fallo
  • [ ] Latencia y costos dentro de SLOs
  • [ ] Error handling para edge cases
  • [ ] Logging estructurado para debugging
  • [ ] Plan de rollback si algo falla

Conclusión

Los LLMs son herramientas poderosas pero impredecibles. Testing y evaluación rigurosos no son opcionales; son la diferencia entre un prototipo bonito y una aplicación en la que confías con datos reales. El esfuerzo inicial se paga en tranquilidad en producción.

La clave: mide antes de ir a producción, monitorea siempre en producción.