volver al blog

Observabilidad de un LLM: tus logs no ven el fallo

llmobservabilidadproduccion

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ñalQué diagnosticaTrampa habitual
Tiempo hasta el primer tokenLatencia percibida en respuestas en streamingMedir solo la latencia total, que no es lo que siente el usuario
Tokens de entrada, en p95Deriva del contexto y coste realMirar la media: la media la aplasta el caso corto
finish_reasons = lengthRespuestas truncadasSe contabilizan como éxito, porque devuelven 200
Aciertos de caché de promptSi tu prefijo estable lo es de verdadUn timestamp en el sistema invalida la caché entera
Puntuación máxima de recuperaciónPreguntas sin contexto suficienteContar documentos recuperados en lugar de su calidad
Versión del prompt por trazaAtribuir una regresión a un cambioPrompts 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.

Fuentes