LiteLLM y Langfuse: el par operativo, y los cuatro sitios donde la traza se pierde entre el gateway y el panel

Este post abre el track operativo de la capa de control. Los artículos anteriores dejaron las piezas elegidas y montadas: elegir el gateway OSS y el router de inferencia L7 explican por qué LiteLLM acaba delante de la flota, y Langfuse por dentro desmonta el backend que recibe las trazas. Aquí se trata la costura entre ambos, que es donde vive el trabajo de los siguientes tres años.

TL;DR

El par LiteLLM más Langfuse se configura con una lista de callbacks y dos claves de API, y a partir de ahí hay cuatro asuntos que deciden si el panel sirve para operar o solo para enseñar en una demo.

Las rutas de integración son tres y no son equivalentes. El callback nativo langfuse está atado al SDK Python v2 (langfuse>=2.59.7,<3.0 en el pyproject.toml de LiteLLM), que escribe contra la ingesta legacy que Langfuse Cloud retira el 16 de noviembre de 2026. El preset langfuse_otel exporta por OTLP y es el camino recomendado en la documentación actual de ambos proyectos. La ruta OTel v2, tras la bandera LITELLM_OTEL_V2=true, cambia nombres de span, deja el estado de éxito en UNSET y, sobre todo, su mapper de Langfuse no lee la metadata de la petición: session_id, trace_user_id, tags y trace_id dejan de llegar.

El coste de un modelo self-hosted llega a cero salvo que se registre precio. get_model_info() lanza excepción para un modelo que no está en el mapa de precios, completion_cost() la propaga, el logger la captura, la guarda en un campo de depuración y devuelve None, y el payload la convierte en 0.0. No hay warning al nivel de log por defecto. El presupuesto de un equipo nunca se agota porque su gasto siempre es cero.

La correlación cliente-gateway-motor no viene de serie. LiteLLM sí continúa la traza del cliente si llega un traceparent, y además extrae de él el trace-id de 32 hex para usarlo como litellm_trace_id, lo que iguala el identificador de Langfuse al de W3C. Hacia arriba la historia cambia: forward_traceparent_to_llm_provider vale False por defecto, la función que lo aplica sale antes si no hay logger otel cargado, y cuando actúa reenvía la cabecera original del cliente, con lo que el span de vLLM queda hermano del de LiteLLM en lugar de hijo. Añádase que vLLM exporta OTLP por gRPC por defecto y Langfuse no acepta gRPC.

Entre la petición y el panel hay cuatro colas acotadas que descartan eventos. El pool de hilos de logging de LiteLLM (100 hilos, 10.000 tareas pendientes, descarte al llenarse), el LoggingWorker asíncrono (50.000, y al llenarse vacía agresivamente la mitad), el BatchSpanProcessor de OTel (2.048 spans por defecto) y el ClickhouseWriter de Langfuse, que descarta filas tras agotar los reintentos sin cola de mensajes muertos. Ninguna de las cuatro aplica contrapresión sobre la petición, que es exactamente lo que se quiere, y ninguna garantiza la entrega, que es exactamente lo que un auditor pregunta.

El resto del artículo desarrolla los cuatro puntos con configuración aplicable a un cluster genérico de 4×H100 con vLLM, las cifras de overhead que publica el propio proyecto, y el límite honesto de lo que estos datos sostienen ante ENS o ISO/IEC 42001.

Estás aquí: la costura entre DEPLOY y OBSERVE

En el stack de siete capas, LiteLLM vive en la capa de gateway y Langfuse en la de observabilidad. La separación es limpia en el diagrama y falsa en la operación: el gateway es el único punto del sistema que ve la petición completa antes de trocearla y la respuesta completa después de recomponerla, así que es también el único sitio razonable desde el que emitir la traza. El motor de inferencia ve tokens y latencias, no ve quién pregunta ni cuánto lleva gastado ese equipo este mes.

Eso convierte a LiteLLM en productor de telemetría además de en repartidor de tráfico, y traslada al gateway una responsabilidad que no estaba en el diseño original: si el callback falla, la petición se sirve igual y nadie se entera de que el panel lleva dos días incompleto.

La analogía: el peaje que emite el ticket

Un peaje de autopista hace dos trabajos independientes. Levanta la barrera, que es su función visible, y emite un ticket con matrícula, hora, tramo e importe, que es la función de la que vive la concesionaria. Las dos comparten cabina y no comparten destino: la barrera responde en cien milisegundos porque hay un coche esperando, y el ticket viaja a un centro de proceso por un canal más lento que agrupa miles de registros antes de enviarlos.

El diseño está bien pensado mientras se entienda su consecuencia. Si el centro de proceso se cae, los coches siguen pasando. Si la cola de tickets se llena, se tiran tickets, no se detiene el tráfico. La concesionaria acepta perder registros antes que provocar un atasco, y cobra sobre un muestreo estadístico que sabe imperfecto.

LiteLLM y Langfuse funcionan igual. Las cuatro colas de la sección correspondiente son el canal lento, y todas prefieren descartar a bloquear. Quien monta el par heredando la analogía completa acierta en la operación. Quien piensa que el ticket es un asiento contable acaba explicándole a un auditor por qué faltan trazas de un martes por la tarde.

Tres rutas, y una con fecha de caducidad

A septiembre de 2026 conviven tres caminos para llevar una petición de LiteLLM a Langfuse. La documentación de LiteLLM etiqueta ya el primero como legacy.

RutaCómo se activaTransporteEstado
Callback nativo langfusesuccess_callback: ["langfuse"]SDK Python de Langfuse v2, ingesta legacyMantenido por compatibilidad. Atado a langfuse<3.0
Preset langfuse_otel (v1)callbacks: ["langfuse_otel"]OTLP/HTTP contra /api/public/otelRecomendado por ambos proyectos
Ruta OTel v2callbacks: ["langfuse_otel"] + LITELLM_OTEL_V2=trueOTLP/HTTP, mapper nuevoOpt-in. Cambia semántica y pierde metadata

La fecha que ordena la decisión es el 16 de noviembre de 2026. La matriz de compatibilidad de Langfuse marca la ingesta batch legacy del SDK Python v2 como retirada en esa fecha para Cloud, momento en el que la ingesta de trazas por esa vía deja de funcionar. En self-hosted el endpoint sobrevive, pero un servidor v4 en modo events_only devuelve 400 para todo tipo de evento salvo score-create y sdk-log.

Como el callback nativo de LiteLLM está fijado a langfuse>=2.59.7,<3.0 en su propio pyproject.toml, y su código llama a APIs que solo existen en el SDK v2 (self.Langfuse.trace(...), trace.generation(...)), no hay forma de subir de SDK sin cambiar de ruta. Quien tenga hoy success_callback: ["langfuse"] contra Langfuse Cloud tiene una migración con fecha. Quien lo tenga contra un Langfuse self-hosted la tiene con margen, porque la v3 recibe parches de seguridad hasta finales de enero de 2027, pero la tiene igual.

Un detalle de la ruta OTLP que merece comprobarse en despliegues antiguos: la cabecera x-langfuse-ingestion-version: 4 es la que activa la ingesta en tiempo real. Sin ella las trazas llegan, pero con un retraso que la documentación de Langfuse sitúa en el entorno de los diez minutos (la página de compatibilidad dice quince, y esa discrepancia sigue sin resolverse en sus propios documentos). LiteLLM empezó a enviarla en la versión 1.95.0, de agosto de 2026. Un proxy anterior a esa versión sobre un Langfuse v4 produce el síntoma clásico: el panel funciona, pero siempre va con retraso y nadie sabe por qué.

La configuración que funciona hoy

Para un cluster genérico de 4×H100 con vLLM detrás y Langfuse self-hosted en el mismo Kubernetes, la ruta OTLP en su variante v1 es la que da más funcionalidad con menos sorpresas.

model_list:
  - model_name: llama-70b
    litellm_params:
      model: hosted_vllm/meta-llama/Llama-3.3-70B-Instruct
      api_base: http://vllm-llama70b.inference.svc.cluster.local:8000/v1
      api_key: os.environ/VLLM_API_KEY
      # Precio derivado del €/GPU-hora del cluster. Sin esto, spend = 0,0
      input_cost_per_token: 0.00000031
      output_cost_per_token: 0.00000089

litellm_settings:
  callbacks: ["langfuse_otel"]
  turn_off_message_logging: false      # decisión consciente, ver sección de PII
  langfuse_default_tags: ["model_group", "user_api_key_team_alias", "cache_hit"]

general_settings:
  disable_error_logs: true             # los errores de proveedor inflan SpendLogs
  proxy_batch_write_at: 60

Las credenciales viajan por entorno. LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY y LANGFUSE_HOST bastan: el preset construye la cabecera Authorization: Basic base64(public:secret) y añade la de versión de ingesta, así que no hay que tocar OTEL_EXPORTER_OTLP_HEADERS a mano. El host por defecto del preset apunta a la región estadounidense de Langfuse Cloud, detalle relevante para cualquiera que esté construyendo una plataforma con requisitos de soberanía del dato: si la variable no se define, la telemetría sale de España sin que nadie lo haya decidido.

Para multi-tenencia real, LiteLLM admite credenciales de Langfuse por equipo (POST /team/<id>/callback con callback_vars) y por clave (metadata.logging[] al generar la clave), y la de clave gana sobre la de equipo sin mezclarse. Hay una limitación que rompe la arquitectura de quien pensaba separar tenants en instancias distintas de Langfuse: bajo OTel v2, langfuse_host por clave o equipo no mueve a ese tenant a otro host, porque el exportador fija el destino en el arranque y lo único que varía por tenant son las cabeceras.

Y una advertencia de operación con años de historia detrás: MAX_LANGFUSE_INITIALIZED_CLIENTS vale 50 por defecto porque cada cliente de Langfuse arranca un hilo. El comentario en el código de LiteLLM es explícito sobre el incidente que lo motivó, con la CPU al 100% por inicializaciones repetidas. Un despliegue con credenciales por equipo y muchos equipos toca ese techo.

Lo que se pierde al pasar a OTel v2

La bandera LITELLM_OTEL_V2=true presenta un salto de calidad en la instrumentación general del proxy, y a la vez un retroceso concreto en el camino a Langfuse que la documentación reconoce sin subrayarlo.

El mapper de Langfuse de la ruta v2 emite langfuse.observation.type, langfuse.observation.model.name, el identificador de observación, los parámetros del modelo, entrada y salida, usage_details y cost_details, más langfuse.trace.metadata.team_id y team_alias. No emite session.id, ni user.id, ni langfuse.trace.tags, ni langfuse.trace.id, ni el nombre de generación, ni parent_observation_id. Los documentos de LiteLLM lo formulan así: esos atributos los pone el preset a partir de la petición y la respuesta, no de una metadata suministrada por el cliente.

La consecuencia práctica es que una aplicación que hoy agrupa conversaciones enviando metadata: {"session_id": "...", "trace_user_id": "..."} en el cuerpo de la petición deja de agruparlas al activar la bandera, sin error y sin aviso. Las trazas siguen llegando, sueltas.

Los otros cambios de v2 son de semántica y afectan a alertas ya escritas. El span raíz pasa de llamarse Received Proxy Server Request a llamarse como la ruta (POST /v1/chat/completions), el span de inferencia pasa de litellm_request a {operación} {modelo}, los spans de guardrails dejan de colgar del span de inferencia para colgar de la raíz, y el estado de éxito queda en UNSET en lugar de OK. Cualquier regla que dispare sobre status == OK deja de disparar. La vuelta atrás es quitar la bandera y reiniciar.

El coste: por qué la traza dice cero

Esta es la parte que más tiempo cuesta diagnosticar y menos cuesta arreglar.

LiteLLM calcula el coste de una petición a partir de un mapa de precios por modelo. Para los modelos de proveedores comerciales el mapa viene relleno. Para hosted_vllm/loquesea, que es todo lo que hay en una factoría de inferencia propia, el mapa está vacío, y el camino del fallo es el siguiente:

  1. litellm.get_model_info() lanza una excepción con el mensaje This model isn't mapped yet.
  2. completion_cost() y response_cost_calculator() propagan la excepción.
  3. Logging._response_cost_calculator() la captura, escribe un StandardLoggingModelCostFailureDebugInformation en response_cost_failure_debug_information, lo registra a nivel debug y devuelve None.
  4. El payload de logging estándar hace llm_response_cost = raw_response_cost or 0.0.

El sistema entrega un gasto de cero, sin error visible al nivel de log por defecto, y lo escribe en la columna spend de LiteLLM_SpendLogs. Los presupuestos por clave, equipo y organización se evalúan contra ese cero y no se agotan nunca. El panel de Langfuse muestra un coste total de cero para todo lo self-hosted, que es justo la parte que se quería medir.

Registrar el precio

Las claves de precio se aceptan tanto en litellm_params como en model_info, y las de litellm_params ganan cuando están en ambos sitios. El conjunto completo tiene 91 campos, pero para un despliegue con vLLM bastan dos, y hay tres cosas que saber sobre ellos.

La primera es que el precio es por token individual, en USD. No existen variantes por millar: buscar per_1k o per_1000 en el modelo de precios devuelve vacío. Un error de tres órdenes de magnitud aquí es fácil de cometer y difícil de ver, porque produce cifras plausibles.

La segunda es que input_cost_per_request existe en el esquema y no se aplica en las llamadas de chat: un modelo registrado con ese campo devuelve coste 0,0. Para cobrar por petición hay que derivarlo a coste por token.

La tercera es que input_cost_per_second necesita el tiempo de respuesta y se calcula como precio por response_time_ms / 1000, así que fuera del camino de petición del proxy devuelve cero en silencio.

El número que va en esas dos claves sale del artículo de FinOps y multi-tenencia con LiteLLM y del de coste por token y por petición: amortización más energía dividido entre tokens servidos. La aritmética de ejemplo, para fijar el orden de magnitud: un cluster cuyo coste totalmente cargado sean 9,60 USD por hora sosteniendo 3.000 tokens de salida por segundo produce 10,8 millones de tokens a la hora, es decir 0,00000089 USD por token de salida. Ese es el valor que va en output_cost_per_token. Si la utilización media del cluster es del 30% y no del 100%, el divisor cambia y el precio se triplica, que es la misma palanca que aquel artículo desarrolla.

Antes de mandar tráfico, el endpoint /spend/calculate acepta los mismos parámetros que completion_cost y devuelve el coste calculado. Es la forma barata de comprobar que el precio resuelve.

Cómo llega ese número a Langfuse, y quién manda

Las dos rutas de integración escriben el coste en campos distintos. El callback nativo lo manda como usage.total_cost del esquema v2, y lo duplica en la metadata de la traza como litellm_response_cost. El preset OTLP emite langfuse.observation.cost_details como cadena JSON con la forma {"total": coste}, y langfuse.observation.usage_details con entrada, salida y total.

La regla del lado de Langfuse está documentada y es la correcta para este caso: cuando hay coste ingerido y coste inferido, el ingerido tiene prioridad. Langfuse solo calcula a partir de su propia tabla de precios cuando el emisor no manda coste, y los cambios en una definición de modelo aplican a generaciones nuevas, sin recálculo retroactivo. Un modelo self-hosted no necesita, por tanto, ninguna definición de precios en Langfuse: basta con que LiteLLM mande cost_details.

Queda un detalle que explica un síntoma desconcertante. Langfuse guarda el coste ingerido y el calculado en columnas separadas, totalCost y calculatedTotalCost, y los totales de cabecera de sesión suman solo la primera. Un despliegue mixto donde parte del tráfico trae coste y parte lo deja calcular a Langfuse muestra sesiones a cero mientras las generaciones individuales muestran coste. Hay incidencia abierta al respecto.

Los tokens de una respuesta en streaming

Casi todo el tráfico de un asistente va en streaming, y ahí el objeto usage de OpenAI solo aparece si el cliente pide stream_options.include_usage. LiteLLM resuelve esto inyectando la opción por su cuenta: en las rutas de completado asíncrono con stream: true, si el llamante no la puso y todos los despliegues candidatos la soportan, la añade y marca _litellm_strip_stream_usage para que el bloque de uso se aproveche en el cálculo de coste y no se le devuelva al cliente. La bandera general_settings.always_include_stream_usage fuerza o desactiva ese comportamiento, y merece ponerse a True cuando la detección de capacidades no reconoce el despliegue de vLLM.

Cuando el uso falta de todos modos, LiteLLM recompone la respuesta con stream_chunk_builder() y cuenta tokens con el tokenizador. Es una aproximación, y produce el segundo camino hacia un coste cero. Los dos caminos hacia el cero se distinguen mirando total_tokens en LiteLLM_SpendLogs: cero tokens con precio correcto es un problema de uso, tokens correctos con coste cero es un problema de precio.

Correlación: del cliente al motor

La pregunta operativa que justifica todo el montaje es poder coger una queja de un usuario, encontrar su petición y ver dónde se fue el tiempo. Eso exige que el identificador de traza sobreviva tres saltos.

Un identificador, tres saltos y dos sitios donde se pierdeClientetraceparent: 00-abc…-01baggage: session.id=…LiteLLM Proxylitellm_trace_id = abc…span padre del gatewayvLLMgen_ai.latency.*OTLP gRPC por defectoCollectorgRPC a HTTPrenombra atributos121 · Entrada: funcionaLiteLLM extrae el trace-id de 32 hex del traceparent y lo usa2 · Salida: apagado por defectoforward_traceparent_to_llm_provider = FalseLas tres formas de que el salto 2 no ocurraa · La función sale antes si no hay callback otel cargado, aunque la bandera esté a Trueb · Reenvía la cabecera del cliente, no el contexto del span propio: vLLM queda hermano, no hijoc · Si el cliente no manda traceparent, no se crea ninguno

Lo que sí funciona

Si el cliente manda traceparent, los spans de LiteLLM cuelgan de la traza existente. Además, y de forma independiente del subsistema OTel, litellm_pre_call_utils.py extrae por expresión regular el trace-id de 32 caracteres hexadecimales y lo asigna a data["litellm_trace_id"] como último recurso, y saca el session_id de la cabecera baggage de W3C. Eso iguala el identificador de traza de Langfuse al identificador de traza de W3C, que es la clave de correlación más robusta disponible: con ella, un identificador copiado del log de la aplicación abre la traza en Langfuse sin traducción.

Para las rutas que no son OTel v2, además, las cabeceras con prefijo langfuse_ inyectan metadata por petición. Están documentadas langfuse_trace_id, langfuse_trace_user_id y langfuse_trace_metadata, pero el código quita el prefijo y escribe el resto en la metadata, así que cualquier clave del cuerpo vale como cabecera. Dos avisos sobre esto: los valores de cabecera llegan como cadenas y no se interpretan como JSON, y Nginx descarta por defecto las cabeceras con guión bajo (underscores_in_headers off), lo que produce el síntoma de que la metadata funciona en local y desaparece detrás del ingress.

Lo que no funciona sin trabajo

Hacia el motor, la propagación está apagada. La bandera es litellm.forward_traceparent_to_llm_provider, vale False, y su implementación tiene tres comportamientos que hay que conocer antes de activarla:

  • La función retorna inmediatamente si open_telemetry_logger is None. Un despliegue con callbacks: ["langfuse_otel"] y sin el callback otel genérico no propaga nada, tenga la bandera como la tenga.
  • Cuando propaga, copia la cabecera traceparent del cliente tal cual, en lugar de inyectar el contexto del span activo de LiteLLM. El span de vLLM queda colgado del span-id del cliente, hermano del span de la llamada del gateway. La traza es una, la jerarquía es incorrecta.
  • Si el cliente no manda traceparent, no se crea ninguno.

Añádase que la política general de LiteLLM es no reenviar cabeceras de cliente al proveedor, y que la lista blanca de forward_client_headers_to_llm_api cubre las cabeceras con prefijo x-, entre las que traceparent no está. Esta es la única vía.

El lado de vLLM, y por qué el Collector deja de ser opcional

vLLM habilita trazas con --otlp-traces-endpoint, y --collect-detailed-traces con model, worker o all añade tiempos de forward y de execute, con impacto de rendimiento que la propia documentación advierte. Los atributos que emite están en vllm/tracing/utils.py y son la parte más útil del montaje: gen_ai.latency.time_in_queue, time_to_first_token, time_in_scheduler, time_in_model_prefill, time_in_model_decode, time_in_model_forward, time_in_model_execute, time_in_model_inference y e2e, más los contadores de tokens. Esa descomposición es la que convierte un “va lento” en “el tiempo se va en cola, no en decodificado”, que es la conversación que se quiere tener durante un incidente. Encaja con lo que ya se cubrió en instrumentar vLLM con OTel y con las métricas de DCGM y vLLM.

Hay tres restricciones que deciden la arquitectura:

vLLM exporta por gRPC salvo que se ponga OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf, y su exportador gRPC está construido con insecure=True fijo en el código. Langfuse, por su parte, no soporta gRPC. Y vLLM emite los nombres antiguos de la convención (gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens), renombrados en la especificación a input_tokens y output_tokens, más una familia gen_ai.latency.* que nunca fue estándar y que el propio fichero documenta como atributos propios hasta que se normalicen.

Un OpenTelemetry Collector entre medias resuelve las tres a la vez: recibe gRPC, exporta HTTP con la cabecera Basic hacia /api/public/otel/v1/traces, y renombra atributos con un transform. También es el único sitio donde se puede hacer muestreo por cola, que es el punto siguiente. Y añádase el aviso de la comunidad de llm-d, que vale para cualquier despliegue con Envoy o sidecars por medio: hay que fijar OTEL_SERVICE_NAME por motor, porque vLLM no manda nombre de servicio y sin él todos los spans llegan como unknown_service.

Las cuatro colas en serie

La telemetría de una petición atraviesa cuatro buffers acotados antes de ser consultable. Los cuatro descartan al llenarse y ninguno bloquea la petición.

ColaCapacidadQué hace al llenarse
Pool de hilos de logging de LiteLLM100 hilos, 10.000 tareas pendientesDescarta las nuevas con un aviso limitado por frecuencia
LoggingWorker asíncrono50.000 eventos, concurrencia 100Vacía agresivamente el 50% de la cola; el enqueue() documenta que nunca bloquea
BatchSpanProcessor de OTel2.048 spans (OTEL_BSP_MAX_QUEUE_SIZE), lote 512, envío cada 5 sDescarta spans en silencio
ClickhouseWriter de LangfuseReintentos acotadosDescarta filas tras agotar reintentos, sin cola de mensajes muertos

El primero se documenta en el propio código de LiteLLM con una frase que ahorra media hora de discusión: el logging es best-effort y, alcanzado el tope, las nuevas entradas se descartan con un aviso limitado en lugar de encolarse para siempre. El tercero es una consecuencia poco visible: LiteLLM construye el BatchSpanProcessor sin ajustar max_queue_size, así que gobiernan los valores por defecto del SDK de OTel, y ninguna documentación de LiteLLM menciona las variables OTEL_BSP_* que los cambian. El SDK de Langfuse hace lo mismo por su cuenta: fija tamaño de lote e intervalo, y deja el tamaño de cola en el valor por defecto.

A esto se suma un quinto punto de pérdida que no es una cola: LiteLLM no llama a flush() por petición en la ruta nativa, así que un SIGKILL al pod se lleva el último lote. En Kubernetes, esto es un argumento para un terminationGracePeriodSeconds holgado y un preStop que dé margen.

Nada de esto es un defecto. Es la decisión de diseño correcta para una pieza que está en el camino crítico de la inferencia. Lo que cambia es qué se puede afirmar con los datos resultantes.

Lo que no se puede prometer al auditor

Un cuadro de mando construido sobre este pipeline responde bien a preguntas estadísticas: cuál es el p95 de tiempo hasta el primer token del grupo de modelos, qué equipo consume más, qué proporción de peticiones falla, cómo evoluciona el coste por petición. Para eso está pensado, y para eso sirve.

No responde bien a preguntas de completitud, que son las que hace un auditor de ENS o de un sistema de gestión ISO/IEC 42001. Ante “demuéstrame que tengo registro de todas las interacciones con el sistema de IA durante marzo”, la respuesta honesta es que las trazas son un registro best-effort con cuatro puntos documentados de descarte silencioso, sin cola de mensajes muertos en el último salto y sin garantía de durabilidad en ninguno.

La separación limpia es la que ya se planteó en controles técnicos ENS, 42001 y AI Act: el registro de auditoría regulatorio es otro artefacto, con otra ruta de escritura, garantías de durabilidad y retención propias. En el par que trata este artículo, la tabla LiteLLM_SpendLogs en Postgres es un candidato mucho mejor que Langfuse para esa función, porque es una escritura transaccional contra una base relacional, y porque contiene ya request_id, api_key en forma de hash, team_id, organization_id, end_user, model, tokens, startTime, endTime, status y requester_ip_address. Escribir el evento de auditoría desde el mismo sitio y con la misma transacción que el gasto es la vía sensata; delegarlo en el pipeline de trazas no lo es.

Aquí aparece una tensión de operación que hay que resolver a conciencia. La recomendación de rendimiento de LiteLLM para producción incluye disable_error_logs: true y, en despliegues muy cargados, disable_spend_logs: true, porque los errores de proveedor inflan la tabla y las escrituras concurrentes contra las mismas filas producen bloqueos y agotan las conexiones de Postgres. Desactivar spend_logs para ganar rendimiento y a la vez apoyarse en esa tabla para el registro de auditoría son decisiones incompatibles. Con más de mil peticiones por segundo o más de diez instancias, la salida es use_redis_transaction_buffer: true, que agrupa las actualizaciones de gasto en Redis antes de bajarlas a Postgres, vigilando las métricas litellm_in_memory_spend_update_queue_size y litellm_redis_spend_update_queue_size.

PII: apagar el contenido antes de que salga

Una traza de LLM contiene, por definición, el prompt del usuario. En un despliegue on-premise para un cliente regulado, ese contenido puede ser exactamente lo que no puede salir del dominio de la aplicación, ni siquiera hacia el backend de observabilidad del mismo cluster.

Los controles del lado del proxy, de más global a más fino:

  • litellm_settings.turn_off_message_logging: true corta mensajes y respuestas en todos los callbacks, y mantiene la metadata y el gasto. La redacción sustituye el contenido por el centinela REDACTED_BY_LITELLM en mensajes, choices, reasoning_content, deltas de streaming, argumentos de llamadas a herramientas y en el objeto de logging estándar.
  • La cabecera x-litellm-enable-message-redaction: true activa la redacción por petición, y LiteLLM-Disable-Message-Redaction: true la desactiva. La segunda se comprueba primero y gana sobre todo lo demás, incluida la configuración global, lo que la convierte en un control que hay que bloquear en el ingress si la política es que el contenido no se registra nunca.
  • redact_user_api_key_info: true quita del log el token con hash, el user_id y el team_id.
  • Por petición y por callback, metadata: {"mask_input": true, "mask_output": true}.
  • El parámetro de cuerpo "no-log": true desactiva el logging de esa llamada. Es parámetro de cuerpo, no cabecera, y se puede desactivar globalmente con global_disable_no_log_param: true para que un cliente no pueda hacerse invisible.

En la ruta OTel v2 la política se invierte hacia el lado seguro: el contenido está apagado por defecto y se abre con OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, con valores no_content, span_only, event_only y span_and_event. La documentación subraya que la puerta se aplica de forma centralizada y que una petición de un usuario no puede forzar su prompt hacia el backend mientras la captura esté desactivada. Es la postura que recomienda también la especificación de OpenTelemetry, cuyo patrón preferido para producción es guardar el contenido en almacenamiento externo y dejar solo la referencia en el span.

Una versión concreta que hay que mirar en el inventario: por debajo de LiteLLM 1.98.0, con credenciales de Langfuse por equipo, las credenciales del propio equipo acababan dentro de sus trazas. Esa versión cambió el origen de la metadata emitida al StandardLoggingPayload para cortarlo. El mismo cambio quitó una veintena de campos de la metadata (model_group, deployment, queue_time_seconds, attempted_retries y otros), así que rompe paneles y alertas guardadas que filtren por ellos. Es una actualización con trabajo asociado, y hay que hacerla igualmente. Lo mismo aplica al hardening general del stack, tratado en hardening y secretos del stack soberano.

El precio en latencia

LiteLLM publica dos cifras de sobrecarga que se diferencian en un factor de treinta, y las dos son suyas.

La página de benchmarks, con Locust, mil usuarios con tiempo de reflexión y un endpoint OpenAI falso, da para cuatro instancias una x-litellm-overhead-duration-ms de mediana 2 ms, p95 8 ms y p99 13 ms, a 1.170 peticiones por segundo. Para dos instancias, mediana 12 ms, p95 29 ms y p99 43 ms. La propia página advierte que esas cifras se sostienen con unas 130 peticiones en vuelo, y que un cliente en bucle cerrado sin tiempo de reflexión mantiene mil en vuelo y ve del orden de ocho veces esa latencia a igual tasa, por la ley de Little.

El banco de pruebas AIGatewayBench, de julio de 2026, mide el proxy Python en 257,7 ms de p99 añadido con 329,5 MB de memoria de pico, frente a 0,7 ms de la variante en Rust en beta. Esa medición no tiene callbacks, ni seguimiento de gasto, ni persistencia.

La lectura operativa es que la primera cifra es el mejor caso y la segunda el orden de magnitud a planificar para carga agéntica, que es la que llega en bucle cerrado contra respuestas rápidas. Para vigilarlo hay dos cabeceras siempre activas: x-litellm-overhead-duration-ms y x-litellm-callback-duration-ms, y la documentación fija el umbral útil de la segunda en 100 ms, por encima del cual el diagnóstico es que los payloads son demasiado grandes.

Tres ajustes de producción que aparecen en la documentación de LiteLLM y que están mal por defecto:

request_timeout vale 6.000 segundos. Cien minutos sosteniendo una conexión contra un upstream colgado. La recomendación es bajarlo a 600.

LITELLM_LOG=DEBUG serializa el payload con json.dumps(indent=4) de forma síncrona, y la documentación cifra en 2 a 5 segundos el coste de esa serialización con payloads de más de 2 MB. Es la primera causa de latencia que su guía de diagnóstico menciona.

Los trabajos de fondo se registran por worker de uvicorn, no por pod. Con --num_workers 4 y diez réplicas salen cuarenta copias de cada trabajo periódico. La variable LITELLM_JOB_ROLE separa los pods que sirven tráfico de la réplica que ejecuta trabajos.

Y un modo de fallo con incidencia abierta que merece estar en el runbook: bajo 429 sostenidos del upstream, se han reportado pods incapaces de responder a las sondas de readiness durante el arranque, con el consiguiente bucle de reinicios. Encaja con lo que se trató en runbooks de respuesta a incidentes.

Checklist de arranque

Diez comprobaciones antes de dar por buena la instalación del par:

  1. callbacks: ["langfuse_otel"] en lugar de success_callback: ["langfuse"], salvo que se dependa de la gestión de prompts, que el preset OTLP no soporta.
  2. LANGFUSE_HOST definido de forma explícita. El valor por defecto del preset apunta a la región estadounidense.
  3. LiteLLM en 1.98.0 o superior, por la fuga de credenciales de equipo en las trazas, y en 1.95.0 o superior por la cabecera de ingesta v4.
  4. Precio registrado para cada modelo self-hosted, por token y en USD, verificado con /spend/calculate antes de abrir tráfico.
  5. Una petición de prueba con traceparent conocido, comprobando que ese mismo identificador aparece en Langfuse.
  6. OpenTelemetry Collector entre vLLM y Langfuse, con OTEL_SERVICE_NAME por motor y renombrado de los atributos antiguos de token.
  7. request_timeout: 600 y LITELLM_LOG fuera de DEBUG.
  8. Muestreo por cola en el Collector, con política de latencia y de código de estado, para conservar el 100% de lo lento y lo fallido.
  9. Decisión escrita sobre el contenido de los prompts, y bloqueo en el ingress de la cabecera LiteLLM-Disable-Message-Redaction si la política es no registrar.
  10. Un panel con x-litellm-overhead-duration-ms, x-litellm-callback-duration-ms y la profundidad de cola de ingesta de Langfuse (langfuse.queue.ingestion.depth), que es la señal de autoescalado de sus workers.

Trampas y cosas que no son lo que parecen

La traza llega, pero con diez minutos de retraso. Falta la cabecera x-langfuse-ingestion-version: 4, que LiteLLM manda desde 1.95.0. No es un problema de capacidad de ClickHouse.

La metadata funciona en desarrollo y desaparece en producción. Nginx descarta cabeceras con guión bajo por defecto, y todas las de esta integración lo llevan.

El coste de las sesiones sale a cero y el de las generaciones no. Langfuse suma solo el coste ingerido en los totales de sesión, no el que calcula él mismo.

Activar OTel v2 no rompe nada visible y desagrupa las conversaciones. El mapper v2 no lee session_id ni trace_user_id de la petición.

El presupuesto de un equipo nunca se agota. Su gasto es cero porque el modelo no tiene precio, y el fallo del cálculo solo se registra a nivel debug.

Las alertas dejan de disparar tras la migración a v2. El estado de éxito pasa a UNSET; ninguna regla sobre OK se cumple.

Reenviar el traceparent está activado y no llega al motor. Falta el callback otel genérico, sin el cual la función que lo aplica retorna antes de mirar la bandera.

Todos los spans de los motores aparecen bajo unknown_service. vLLM no emite nombre de servicio; hay que fijar OTEL_SERVICE_NAME por despliegue.

Cierre

El gateway y el backend de trazas son las dos piezas del stack de inferencia que menos tardan en instalarse y más tardan en operarse bien. La instalación cabe en un ConfigMap. La operación consiste en cuatro decisiones que ninguna guía de arranque plantea: qué ruta de integración se toma sabiendo que una caduca en noviembre, de dónde sale el precio por token de un modelo que no factura nadie, qué se hace con el traceparent en cada salto, y qué se le dice a un auditor sobre unos datos que el sistema descarta por diseño cuando aprieta.

La respuesta a la cuarta es la que ordena las otras tres. Este pipeline es instrumentación de operación, con precisión estadística y garantías best-effort, y separarlo desde el día uno del registro de auditoría regulatorio evita la conversación incómoda de dentro de dos años. Con esa separación hecha, el par LiteLLM y Langfuse es la pareja de piezas que convierte una flota de GPUs en una plataforma con dueño, coste conocido y latencia explicable.

Ver también

Fuentes