El fallo que no dispara ninguna alerta
Un servicio web roto es ruidoso. Devuelve un 500, la latencia se dispara, el gráfico se pone rojo y alguien recibe un aviso. Un servicio con un LLM dentro se rompe en silencio: devuelve un 200 OK, en 900 milisegundos, con una respuesta educada, bien formateada y equivocada.
Ese es el problema entero. La monitorización clásica mide si el sistema respondió. Con un LLM lo que importa es qué respondió, y eso no cabe en un contador de errores. Llevo tiempo viendo equipos con dashboards impecables que se enteran de que el retriever lleva dos semanas devolviendo basura porque un cliente lo dice por correo.
Un LLM rompe tres supuestos del logging clásico
El determinismo. Reproducir un bug clásico es volver a ejecutar la misma entrada. Con temperatura por encima de cero, la misma entrada no da la misma salida. Si no guardaste lo que pasó, no lo vas a recuperar.
El coste plano. Una petición HTTP normal cuesta más o menos lo mismo que la anterior. Aquí el coste depende del número de tokens, y ese número lo decide en parte el usuario. Una petición puede costar 200 veces más que la media sin que nada falle.
El fallo binario. No hay excepción que capturar. La respuesta truncada por max_tokens y la respuesta completa salen ambas con código 200. La diferencia está en un campo que nadie mira.
La unidad de observación es la traza, no la línea de log
Una petición a un sistema RAG no es una operación: son cuatro o cinco encadenadas. Se reescribe la consulta, se recupera contexto, se llama al modelo, quizá se ejecuta una herramienta y se vuelve a llamar. Cuando la respuesta es mala, la pregunta no es “¿falló?” sino “¿en qué eslabón se torció?”.
Un log plano no distingue entre un retriever que trajo documentos irrelevantes y un modelo que ignoró documentos buenos. Son dos bugs distintos, con dos arreglos distintos, y desde fuera se ven exactamente igual. Una traza con spans anidados sí lo distingue: abres el span de recuperación, miras qué fragmentos entraron y en dos segundos sabes a quién culpar.
Los nombres importan: convenciones semánticas GenAI
Puedes inventarte los nombres de los atributos. Es lo que hace casi todo el mundo el primer día, y es lo que obliga a reescribir los dashboards seis meses después.
OpenTelemetry mantiene un conjunto de convenciones semánticas para IA generativa: nombres acordados para el modelo, el proveedor, los tokens consumidos, los motivos de parada o el top_k de una recuperación. Usarlos significa que cualquier backend que las entienda —Grafana, Langfuse, Datadog, Phoenix— pinta tus trazas sin configuración a medida.
from opentelemetry import trace
tracer = trace.get_tracer("servicio-soporte")
def responder(pregunta: str, conversacion_id: str) -> str:
# Un span raíz por petición de usuario. Todo lo demás cuelga de aquí,
# y ese "aquí" es lo que abrirás cuando llegue la queja.
with tracer.start_as_current_span("responder_pregunta") as raiz:
raiz.set_attribute("gen_ai.conversation.id", conversacion_id)
# Span de recuperación: sin él no puedes separar un fallo del
# retriever de un fallo del modelo.
with tracer.start_as_current_span("retrieval base-soporte") as rec:
rec.set_attribute("gen_ai.operation.name", "retrieval")
rec.set_attribute("gen_ai.data_source.id", "base-soporte")
rec.set_attribute("gen_ai.retrieval.top_k", 5)
fragmentos = buscar(pregunta, k=5)
# Identificadores y puntuaciones, no el texto. Basta para
# reconstruir el caso y no arrastras datos personales.
rec.set_attribute("app.retrieval.doc_ids", [f.id for f in fragmentos])
rec.set_attribute("app.retrieval.score_max", fragmentos[0].score)
# Span de inferencia. Nombre canónico: "{operación} {modelo}".
with tracer.start_as_current_span("chat claude-sonnet-5") as inf:
inf.set_attribute("gen_ai.operation.name", "chat")
inf.set_attribute("gen_ai.provider.name", "anthropic")
inf.set_attribute("gen_ai.request.model", "claude-sonnet-5")
inf.set_attribute("gen_ai.request.temperature", 0.2)
# Versionar el prompt es lo que convierte "ha empeorado" en
# "empeoró el martes con la v7".
inf.set_attribute("gen_ai.prompt.version", "v7")
r = llamar_al_modelo(pregunta, fragmentos)
inf.set_attribute("gen_ai.response.model", r.model)
inf.set_attribute("gen_ai.response.finish_reasons", [r.stop_reason])
inf.set_attribute("gen_ai.usage.input_tokens", r.usage.input_tokens)
inf.set_attribute("gen_ai.usage.output_tokens", r.usage.output_tokens)
inf.set_attribute("gen_ai.usage.cache_read.input_tokens",
r.usage.cache_read_input_tokens)
return r.text
Un aviso honesto: buena parte de estas convenciones sigue en estado development, y los nombres han cambiado más de una vez. Escribe tus atributos detrás de una función propia, registrar_llamada_modelo(span, respuesta), y el día que renombren algo tocas un fichero en vez de treinta.
Qué mirar y qué ignorar
| Señal | Qué diagnostica | Trampa habitual |
|---|---|---|
| Tiempo hasta el primer token | Latencia percibida en respuestas en streaming | Medir solo la latencia total, que no es lo que siente el usuario |
| Tokens de entrada, en p95 | Deriva del contexto y coste real | Mirar la media: la media la aplasta el caso corto |
finish_reasons = length | Respuestas truncadas | Se contabilizan como éxito, porque devuelven 200 |
| Aciertos de caché de prompt | Si tu prefijo estable lo es de verdad | Un timestamp en el sistema invalida la caché entera |
| Puntuación máxima de recuperación | Preguntas sin contexto suficiente | Contar documentos recuperados en lugar de su calidad |
| Versión del prompt por traza | Atribuir una regresión a un cambio | Prompts escritos a pelo en el código, sin versión |
La última fila es la que más veces me ha salvado. Sin versión de prompt, “el asistente responde peor que la semana pasada” es una conversación sin salida.
De las trazas al conjunto de evaluación
Aquí está el retorno real de todo esto. Una traza guardada con su entrada, su contexto recuperado y su salida es un caso de prueba a medio hacer.
El bucle que funciona: muestrea un porcentaje pequeño del tráfico, revisa a mano las trazas con pulgar abajo o con finish_reason raro, y mueve las interesantes a tu conjunto de evaluación. En seis semanas tienes un dataset que refleja lo que tus usuarios preguntan de verdad, no lo que imaginaste en la reunión de diseño.
Con una condición: los mensajes completos son datos personales en cuanto tu producto toca a un cliente. La especificación marca los cuerpos de entrada y salida como opt-in precisamente por eso. Decide desde el día uno qué guardas, con qué retención y con qué anonimización, porque un backend de trazas lleno de conversaciones sin filtrar es un incidente esperando su turno.
Por dónde empezar el lunes
No montes una plataforma. Instrumenta una ruta.
Coge el endpoint que más te preocupa y añádele tres cosas: un span raíz por petición, un span por llamada al modelo con tokens, modelo y motivo de parada, y un identificador de versión del prompt. Exporta a lo que ya tengas; si es un colector OTLP, ya está resuelto. Ponle un muestreo del 100 % la primera semana y bájalo cuando veas el volumen.
Con eso solo, la próxima vez que alguien diga “esto contesta mal” tendrás una traza que abrir en lugar de una teoría que defender. La diferencia entre depurar y adivinar suele ser exactamente esa.