Módulo 5: Structured Logging para AI Systems
6. Debugging de Sistemas No-Determinísticos
Descripción
Cuando un sistema determinístico falla, puedes reproducirlo: mismo input → mismo output → mismo error. Cuando un sistema LLM falla, esto no es automáticamente cierto: mismo input puede producir outputs distintos. Esta cápsula aborda el challenge central del debugging AI: cómo capturar suficiente contexto para reproducir un request problemático, y cómo combinar seed, temperature=0, y reproducibility logging para transformar un sistema no-determinístico en algo suficientemente determinístico para ser debuggeable.
El challenge del debugging no-determinístico
Sistema determinístico:
→ User: "Error con el input X"
→ Dev: "Reproduzco con input X"
→ Result: Mismo error, reproducible, debuggeable
→ Time to reproduce: 30 segundos
Sistema LLM no-determinístico:
→ User: "La respuesta fue rara con el input Y"
→ Dev: "Ejecuto con input Y"
→ Result: Respuesta completamente diferente, normal
→ "¿Qué modelo se usó exactamente? ¿Qué temperatura? ¿Había algún context acumulado?"
→ "No recuerdo, eso fue ayer..."
→ Time to reproduce: imposible sin logs
Sin reproducibility logging:
→ El bug existe, pero no puedes reproducirlo
→ No puedes debuggearlo
→ No puedes escribir un test para prevenir regresiones
→ No sabes cuándo está "arreglado"
Con reproducibility logging:
→ El bug ocurrió
→ Tienes el prompt exacto, modelo, temperatura, seed
→ Puedes reproducir el contexto exacto (o muy similar)
→ Puedes experimentar con fixes
→ Puedes escribir un test que captura el caso
Qué guardar para poder reproducir
# src/reproducibility.py
from dataclasses import dataclass, field
from typing import Optional, List
import hashlib
import json
@dataclass
class LLMCallContext:
"""
Contexto completo de una llamada al LLM para reproducibilidad.
Captura todo lo necesario para re-ejecutar el request con condiciones similares.
"""
# Identificación
request_id: str
# Parámetros del modelo
model: str
temperature: float
max_tokens: int
seed: Optional[int] = None
# El prompt (cuidado con PII)
messages: List[dict] = field(default_factory=list)
# Resultado
raw_response: Optional[str] = None
finish_reason: Optional[str] = None # "stop", "length", "content_filter"
# Métricas
input_tokens: int = 0
output_tokens: int = 0
duration_ms: float = 0.0
@property
def messages_hash(self) -> str:
"""Hash del prompt para identificación sin exponer el contenido."""
content = json.dumps(self.messages, sort_keys=True)
return hashlib.sha256(content.encode()).hexdigest()[:16]
@property
def response_hash(self) -> Optional[str]:
"""Hash de la respuesta para detectar cambios."""
if self.raw_response is None:
return None
return hashlib.sha256(self.raw_response.encode()).hexdigest()[:16]
def to_reproduction_script(self) -> str:
"""
Genera un script Python que puede re-ejecutar el request.
Útil para debugging offline.
"""
return f"""
# Reproducción del request {self.request_id}
# Generado automáticamente desde reproducibility log
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="{self.model}",
messages={json.dumps(self.messages, indent=4, ensure_ascii=False)},
temperature={self.temperature},
max_tokens={self.max_tokens},
seed={self.seed},
)
print(response.choices[0].message.content)
"""
Cómo y cuándo loguear para reproducibilidad
# src/llm_wrapper.py (con reproducibility logging)
import structlog
from src.reproducibility import LLMCallContext
log = structlog.get_logger()
def call_llm_with_reproducibility(
client,
model: str,
messages: list,
temperature: float = 0.0,
max_tokens: int = 500,
seed: Optional[int] = None,
request_id: str = None,
debug_mode: bool = False
) -> tuple:
"""
Llamada al LLM con logging de reproducibilidad.
Siempre:
- Hash del prompt (para identificar sin exponer)
- model, temperature, max_tokens, seed
- finish_reason (¿truncado? ¿stop?)
Solo en ERROR:
- Prompt truncado (primeros 500 chars)
Solo en DEBUG mode:
- Prompt completo (cuidado con PII)
"""
ctx = LLMCallContext(
request_id=request_id or "unknown",
model=model,
temperature=temperature,
max_tokens=max_tokens,
seed=seed,
messages=messages
)
start = time.time()
try:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
seed=seed
)
ctx.raw_response = response.choices[0].message.content
ctx.finish_reason = response.choices[0].finish_reason
ctx.input_tokens = response.usage.prompt_tokens
ctx.output_tokens = response.usage.completion_tokens
ctx.duration_ms = (time.time() - start) * 1000
# INFO: hash del contexto para trazabilidad sin exponer contenido
log.info(
"llm_call_completed",
model=ctx.model,
temperature=ctx.temperature,
seed=ctx.seed,
messages_hash=ctx.messages_hash,
response_hash=ctx.response_hash,
finish_reason=ctx.finish_reason,
input_tokens=ctx.input_tokens,
output_tokens=ctx.output_tokens,
duration_ms=round(ctx.duration_ms, 1)
)
# WARNING: si el response fue truncado por max_tokens
if ctx.finish_reason == "length":
log.warning(
"response_truncated",
reason="max_tokens reached",
max_tokens=ctx.max_tokens,
output_tokens=ctx.output_tokens,
messages_hash=ctx.messages_hash
)
# DEBUG: prompt completo (solo si está habilitado)
if debug_mode:
# Solo primeros 500 chars para reducir tamaño incluso en debug
prompt_preview = ""
if messages:
last_msg = messages[-1].get("content", "")
prompt_preview = last_msg[:500]
if len(last_msg) > 500:
prompt_preview += f"...[{len(last_msg)} total chars]"
log.debug(
"llm_reproducibility_context",
model=ctx.model,
temperature=ctx.temperature,
seed=ctx.seed,
max_tokens=ctx.max_tokens,
prompt_preview=prompt_preview,
response_preview=(ctx.raw_response or "")[:300]
)
return response, ctx
except Exception as e:
ctx.duration_ms = (time.time() - start) * 1000
# ERROR: incluir más contexto porque necesitamos debuggear
prompt_preview = ""
if messages:
last_msg = messages[-1].get("content", "")
prompt_preview = last_msg[:300] # Más contexto en error
log.error(
"llm_call_failed",
error_type=type(e).__name__,
error_message=str(e)[:300],
model=ctx.model,
temperature=ctx.temperature,
seed=ctx.seed,
messages_hash=ctx.messages_hash,
prompt_preview=prompt_preview, # En error: más contexto
duration_ms=round(ctx.duration_ms, 1)
)
raise
seed: el parámetro que (casi) hace al LLM determinístico
# ¿Qué hace seed?
# Cuando especificas seed, el modelo debería producir la misma respuesta
# para el mismo input y los mismos parámetros.
# OpenAI lo llama "reproducibility" y la propiedad system_fingerprint
# confirma cuándo la implementación subyacente del modelo no cambió.
import openai
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "¿Cuál es la capital de Francia?"}],
temperature=0.0, # temperature=0 reduce varianza
seed=42 # seed hace la respuesta reproducible
)
# system_fingerprint indica la versión del modelo:
fingerprint = response.system_fingerprint
# "fp_13c70b9f70" — si este cambia, la respuesta puede cambiar aunque seed sea igual
# Loguear el fingerprint:
log.info("llm_call_completed",
...,
seed=42,
system_fingerprint=response.system_fingerprint
)
# Si el fingerprint cambia entre dos calls con el mismo seed,
# la respuesta puede haber cambiado no por tu código sino por una
# actualización del modelo en el servidor de OpenAI.
# Importante: seed NO garantiza 100% reproducibilidad
# → Si system_fingerprint cambia, la respuesta puede cambiar
# → Es "best effort" según OpenAI
# → Para debugging funciona muy bien, no para garantías absolutas
temperature: el control de varianza
# Cómo temperature afecta la reproducibilidad:
# temperature=0.0: Completamente greedy — siempre elige el token más probable
# → Muy alta reproducibilidad (casi siempre la misma respuesta)
# → Bueno para: clasificación, extracción, análisis estructurado
# → Malo para: generación creativa, brainstorming
# temperature=0.3: Bajo pero con algo de varianza
# → Buena para: resúmenes, respuestas con cierta flexibilidad
# → Reproducibilidad moderada
# temperature=0.7 (default de muchos modelos): Alta varianza
# → Para: generación creativa
# → Baja reproducibilidad — el mismo input puede dar respuestas muy distintas
# temperature=1.0+: Muy alta varianza, a veces incoherente
# → Raramente útil en producción
# RECOMENDACIÓN para apps de análisis (como análisis de sentimiento):
# → temperature=0.0 para máxima consistencia
# → Solo usar temperature > 0 si genuinamente necesitas variedad de respuestas
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0.0, # Para análisis: siempre 0.0
seed=42, # Para debugging: siempre especificar
max_tokens=500
)
Workflow completo de debugging con reproducibility logs
ESCENARIO: Usuario reporta que el análisis de sentimiento de su review
"El producto llegó tarde pero el soporte fue excelente"
fue clasificado como "negative" cuando debería ser "mixed".
PASO 1: El usuario reporta el error con el request_id
→ Usuario: "request_id: a7b3c9d2"
PASO 2: Buscar en logs
$ jq 'select(.request_id == "a7b3c9d2")' logs.json | jq -s 'sort_by(.timestamp)'
Output:
{"event": "request_started", "request_id": "a7b3c9d2", "timestamp": "..."}
{"event": "llm_call_completed", "request_id": "a7b3c9d2",
"messages_hash": "abc123", "model": "gpt-4o-mini",
"temperature": 0.7, "seed": null, "finish_reason": "stop",
"input_tokens": 156, "output_tokens": 43, "timestamp": "..."}
{"event": "request_completed", "request_id": "a7b3c9d2",
"result": {"sentiment": "negative"}, "timestamp": "..."}
PASO 3: Identificar el problema
→ temperature=0.7 (alta varianza) — eso explica el resultado inconsistente
→ Sin seed — no podemos reproducir exactamente
→ El modelo generó "negative" en este run, pero puede generar "mixed" en otro
PASO 4: Reproducir (con low temperature para estabilizar)
$ python scripts/reproduce_request.py \
--model "gpt-4o-mini" \
--temperature 0.7 \
--seed 42 \
--messages-hash "abc123"
→ Con temperature=0.7, la respuesta varía. Confirmado: el sistema es inestable
PASO 5: Diagnosis
→ Para análisis de sentimiento, temperature=0.7 es demasiado alto
→ Fix: cambiar a temperature=0.0
PASO 6: Validar el fix
→ Con temperature=0.0, el mismo input siempre da "mixed"
→ Fix validado
PASO 7: Escribir el test de regresión
def test_mixed_sentiment_stable():
"""Input con sentimientos mixtos debe dar 'mixed', no 'negative'."""
result = analyze_sentiment(
"El producto llegó tarde pero el soporte fue excelente"
)
assert result.sentiment == "mixed"
Script de reproducción
# scripts/reproduce_from_logs.py
"""
Script que lee un request_id de los logs y genera el código
para reproducir la llamada al LLM.
Uso: python scripts/reproduce_from_logs.py <request_id> [log_file]
"""
import json
import sys
from pathlib import Path
def find_llm_call_log(request_id: str, log_file: str = "logs/app.json") -> dict:
"""Busca el log de llamada al LLM para un request_id."""
with open(log_file) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if (entry.get("request_id") == request_id and
entry.get("event") == "llm_reproducibility_context"):
return entry
except json.JSONDecodeError:
pass
return None
def generate_reproduction_script(log_entry: dict) -> str:
"""Genera un script Python para reproducir la llamada."""
model = log_entry.get("model", "gpt-4o-mini")
temperature = log_entry.get("temperature", 0.0)
seed = log_entry.get("seed")
max_tokens = log_entry.get("max_tokens", 500)
prompt_preview = log_entry.get("prompt_preview", "[No prompt available]")
seed_line = f" seed={seed}," if seed is not None else " # seed not available"
return f'''#!/usr/bin/env python
"""
Reproducción del request: {log_entry.get("request_id")}
Generado por: scripts/reproduce_from_logs.py
NOTA: El prompt es un preview — puede estar truncado.
"""
from openai import OpenAI
client = OpenAI()
# NOTA: El prompt completo puede no estar disponible si
# el log fue generado con debug_mode=False.
# Usa el prompt real para una reproducción exacta.
prompt = """{prompt_preview}"""
response = client.chat.completions.create(
model="{model}",
messages=[{{"role": "user", "content": prompt}}],
temperature={temperature},
max_tokens={max_tokens},
{seed_line}
)
print("Response:", response.choices[0].message.content)
print("Finish reason:", response.choices[0].finish_reason)
'''
if __name__ == "__main__":
request_id = sys.argv[1] if len(sys.argv) > 1 else None
log_file = sys.argv[2] if len(sys.argv) > 2 else "logs/app.json"
if not request_id:
print("Usage: python reproduce_from_logs.py <request_id> [log_file]")
sys.exit(1)
entry = find_llm_call_log(request_id, log_file)
if not entry:
print(f"No reproducibility log found for request_id: {request_id}")
print("Note: Reproducibility logs are only generated in debug_mode=True")
sys.exit(1)
script = generate_reproduction_script(entry)
output_file = f"reproduce_{request_id}.py"
with open(output_file, "w") as f:
f.write(script)
print(f"Reproduction script generated: {output_file}")
print(f"Run: python {output_file}")
Ejercicios
Ejercicio 1: Identificar por qué no puedes reproducir
Dado este log, explica por qué es difícil reproducir exactamente el request:
{"event": "llm_call_completed", "model": "gpt-4o", "temperature": 0.8,
"seed": null, "finish_reason": "stop", "request_id": "z9x8w7v6"}
Ver solución
Tres problemas:
-
temperature=0.8 es alta — el modelo tiene mucha varianza en sus respuestas. Mismo input puede dar outputs muy diferentes en distintas llamadas.
-
seed=null — sin seed, no hay forma de pedir al modelo que intente reproducir el mismo resultado. La aleatoriedad es completamente libre.
-
No hay prompt en el log — sin el prompt exacto, ni siquiera puedes intentar reproducir. Solo tienes el hash.
Para mejorar: usar temperature=0.0 para análisis determinístico, siempre especificar seed, y en modo debug loguear el prompt.
Ejercicio 2: Diseñar la política de reproducibility logging
Para una app con usuarios reales, define:
- ¿Qué loguear siempre?
- ¿Qué loguear solo en ERROR?
- ¿Qué solo en DEBUG mode?
Ver guía
Siempre (INFO):
- model, temperature, seed (si se usa)
- messages_hash, response_hash
- finish_reason, input_tokens, output_tokens
Solo en ERROR (necesitas más contexto para debuggear):
- Primeros 300 chars del prompt (puede tener PII → usar con cuidado o redactar)
- system_fingerprint (para correlacionar con cambios de modelo)
- messages completos si la app tiene acceso legal y hay consentimiento
Solo en DEBUG mode (explícitamente habilitado):
- Prompt completo (con redacción de PII)
- Respuesta completa
- Todo el objeto messages[]
Ejercicio 3: finish_reason
¿Por qué es importante loguear finish_reason?
Ver guía
finish_reason indica por qué el modelo dejó de generar:
"stop": terminó normalmente — lo esperado"length": se alcanzómax_tokens— la respuesta fue truncada, puede estar incompleta. Esto puede causar JSON inválido si el output es JSON estructurado."content_filter": el contenido fue filtrado por la moderación de OpenAI"tool_calls": el modelo quiere llamar a una herramienta
Si tu app tiene errores de parsing de JSON y el finish_reason es "length", la causa es clara: aumentar max_tokens o truncar el input.
Resumen
- Reproducibilidad no es determinismo: no pedimos que el LLM sea 100% predecible, sino que capturemos suficiente contexto para acercarnos lo máximo posible
temperature=0.0para análisis: máxima consistencia, mismos resultados en la mayoría de casosseedjunto atemperature=0.0: la combinación más efectiva para reproducibilidadsystem_fingerprint: permite detectar cuándo el modelo cambió en el servidor, no en tu código- Siempre loguear: model, temperature, seed, messages_hash, finish_reason
- En ERROR: más contexto (prompt preview) para poder diagnosticar
- El workflow: request_id → logs → params → script de reproducción → diagnóstico
Recursos adicionales
- OpenAI — Reproducible outputs — Documentación de seed en OpenAI
- OpenAI API Reference — seed — Parámetro seed
- Temperature in LLMs — Cómo funciona temperature
- Debugging ML systems — Técnicas de debugging para sistemas ML