Langfuse por dentro: el centro de clasificación que no debe convertirse en el cuello de botella que vino a observar
Este post cierra una trilogía de la capa Observe: en Tracing LLM con OpenTelemetry GenAI se montó el pipeline
SDK → Collector → backendy se trató Langfuse como una caja negra que recibe spans; en Prompt versioning con Langfuse y MLflow se usó su capa de prompt management. Aquí abrimos la caja: qué hay dentro de Langfuse, por qué v3 dejó de ser un monolito sobre Postgres, y cómo se opera para que aguante el tráfico de un cluster de inferencia sin convertirse en el problema.
TL;DR
Langfuse v3 (estable desde diciembre de 2024) no es una aplicación, son seis servicios: dos contenedores propios (Web y Worker) y cuatro dependencias de estado (Postgres, ClickHouse, Redis/Valkey y un blob store S3-compatible). El cambio arquitectónico clave respecto a v2 —que era un monolito Next.js sobre Postgres— es la tubería de ingesta asíncrona: las trazas se reciben en lotes, se escriben inmediatamente a S3, se encola solo una referencia en Redis, y un Worker las ingiere a ClickHouse en segundo plano. Esto desacopla la velocidad de recepción (limitada solo por la latencia de escritura de Redis, ~1-5 ms) del coste de persistir y mergear en la base analítica. El resultado: el contenedor Web sostiene cientos de eventos por segundo sin que un pico bloquee al cliente que sirve la inferencia. Pero ese diseño solo rinde con los ajustes correctos. Este post cubre la arquitectura, su interacción con el resto del stack on-premise, y diez knobs de backend —del batching a ClickHouse al sharding de colas, del modificador FINAL a la higiene de las system log tables— que deciden el throughput real y el coste de almacenamiento. Y marca dónde el async esconde ventanas de pérdida de datos que conviene conocer antes de prometer “trazabilidad total”.
Estás aquí: OBSERVE (y la capa que sostiene a las demás)
La analogía: el centro de clasificación postal
Imagina la oficina central de clasificación de correos de una gran ciudad en hora punta. Llegan camiones cargados de sacas (lotes de cartas) a un ritmo que no para. Si el empleado de la ventanilla tuviera que abrir cada saca, leer cada carta, decidir su destino y archivarla antes de aceptar el siguiente camión, la cola de camiones daría la vuelta a la manzana en diez minutos. Ningún centro de clasificación serio funciona así.
Lo que hacen es desacoplar la recepción del procesado:
- La ventanilla de recepción acepta la saca, le pone un sello de acuse, la deja en un casillero del almacén y suelta un ticket en una cinta transportadora. Tiempo por saca: segundos. La ventanilla nunca se bloquea.
- Más atrás, en la sala de clasificación, un equipo de operarios va cogiendo tickets de la cinta, recupera la saca de su casillero, la abre, clasifica las cartas y las archiva en el archivo permanente —ordenado, indexado, consultable.
Langfuse v3 es exactamente este centro de clasificación:
| Centro postal | Langfuse v3 | Función |
|---|---|---|
| Ventanilla de recepción | Contenedor Web (endpoint de ingesta) | Acepta lotes de eventos, da acuse inmediato (HTTP 207) |
| Almacén de casilleros | S3 / Blob store (MinIO on-prem) | Guarda la saca cruda (el evento completo) |
| Ticket en la cinta | Redis / Valkey (cola BullMQ) | Solo la referencia al objeto en S3, no el contenido |
| Sala de clasificación | Contenedor Worker | Coge tickets, lee S3, transforma y archiva |
| Archivo permanente indexado | ClickHouse (OLAP) | Trazas, observaciones y scores, consultables por proyecto+tiempo |
| Registro administrativo | Postgres (OLTP) | Usuarios, proyectos, API keys, prompts, datasets, config |
La tesis de todo el post se deriva de esta analogía: el valor de Langfuse está en que la ventanilla nunca bloquee al cliente que sirve la inferencia. Una herramienta de observabilidad que añade latencia o caídas a la ruta de servir tokens es peor que no tener observabilidad —porque degrada justo el sistema que pretendía cuidar. Todo el diseño de v3, y todos los knobs de este post, existen para mantener esa promesa bajo carga.
El mecanismo en sí: seis servicios, dos planos
Langfuse v3 separa dos planos que en v2 estaban fundidos:
- Plano de ingesta y consulta (los dos contenedores propios, stateless, escalables horizontalmente): Web y Worker.
- Plano de estado (cuatro dependencias, cada una con su perfil de carga): Postgres (OLTP transaccional), ClickHouse (OLAP analítico), Redis/Valkey (cola + caché), Blob store (objetos crudos).
Lo que hay que retener de este diagrama:
- Web y Worker son intercambiables y stateless. No guardan nada localmente. Puedes correr 1 o 20 réplicas de cada uno; el estado vive en las cuatro dependencias. Esto es lo que permite escalar por carga sin coreografías.
- Redis nunca lleva el contenido del evento, solo la referencia al objeto en S3. Por eso Redis aguanta el pico: una escritura de Redis es ~1-5 ms y mueve bytes, no kilobytes. El cuello de botella del contenedor Web es, literalmente, la velocidad de escritura de Redis.
- Postgres y ClickHouse tienen perfiles opuestos. Postgres es OLTP: muchas lecturas/escrituras pequeñas y transaccionales (¿esta API key es válida?, ¿qué versión tiene el label
production?). ClickHouse es OLAP: pocas escrituras enormes en batch y consultas analíticas sobre miles de millones de filas (dame el p95 de TTFT del proyecto X en los últimos 7 días). Meter trazas en Postgres —lo que hacía v2— funciona hasta que no funciona: a volumen de producción, Postgres se ahoga en una carga para la que no está diseñado. Ese fue el motivo del rediseño.
El flujo de ingesta paso a paso (y las matemáticas del desacoplo)
El corazón del diseño es la ruta de ingesta. Vista en detalle, una request POST /api/public/ingestion con un lote de eventos hace esto:
El punto matemático es el acuse temprano (early ACK). La latencia que el cliente percibe al enviar trazas es:
$$ t_{\text{cliente}} = t_{\text{S3 write}} + t_{\text{Redis enqueue}} \approx 10\text{–}40,\text{ms} $$
mientras que el coste real de persistir —leer S3, transformar, mergear contra la versión previa, insertar en ClickHouse, dejar que los background merges compacten— ocurre fuera de esa ruta, en el Worker, y puede tardar cientos de ms o segundos sin que al cliente le importe. El desacoplo convierte un sistema cuyo throughput estaría limitado por la velocidad de ClickHouse en uno limitado por la velocidad de Redis. Y Redis, en hardware modesto, sostiene del orden de 50.000 operaciones/segundo.
Esto tiene una consecuencia de dimensionado importante. Si tu carga de inferencia genera $E$ eventos/segundo (un chat con RAG + 2 tool calls produce fácilmente 6-10 spans = eventos por petición), el contenedor Web los absorbe mientras $E \ll 50.000$. El Worker, en cambio, escala con el coste de procesar: ese es el componente que hay que vigilar y replicar, y el primer knob del post.
Escepticismo honesto. El early ACK tiene una cara B: entre el HTTP 207 y la persistencia en ClickHouse hay una ventana de pérdida potencial. Si el evento está en S3 y la referencia en Redis, y Redis se cae sin persistencia (AOF/RDB) antes de que el Worker procese, la referencia se pierde —el dato sigue en S3 pero ya nadie lo reclama. Más sutil: el Worker bufferiza escrituras a ClickHouse en memoria y las hace flush por lotes; un crash del Worker con el buffer lleno pierde ese lote. Existe un bug reportado donde el
ClickhouseWriterdescarta filas tras agotar reintentos de flush sin dead-letter queue. Para observabilidad esto suele ser tolerable (perder el 0,01 % de las trazas no rompe nada). Para auditoría regulatoria —donde la traza es evidencia— no lo es, y conviene tratar Langfuse como “best-effort” y no como libro contable. Volveremos sobre esto en el cierre.
Interacción con el resto del stack: Langfuse en el cluster 4×H100 de ejemplo
Langfuse no vive aislado. En el stack de siete capas ocupa la capa de observabilidad LLM-aware, y se relaciona con casi todas las demás. Sobre el cluster genérico de referencia que usamos en todo el blog —4×H100 SXM 80 GB (320 GB VRAM agregada), NVLink, 640 GB RAM de sistema, NVMe-oF, red 25/100 GbE— el flujo de telemetría es así:
Tres ideas de esta topología:
Langfuse recibe del OTel Collector, no de la aplicación directamente (en el patrón recomendado). El SDK de la app o vLLM emiten spans con las semantic conventions
gen_ai.*; el Collector hacebatch,tail-sampling(preserva el 100 % de errores y latencias altas, muestrea el resto) y enriquece con atributos propios (tenant_id,priority_tier); y reparte: las trazas LLM van a Langfuse, los spans de infraestructura a Tempo, las métricas (DCGM de GPU, métricas de vLLM) a Prometheus. Langfuse es un exporter más, no el único destino. Esto está cubierto en detalle en el post de tracing OTel.Langfuse corre fuera de las GPU. Es un consumidor de CPU, RAM, disco y red —ClickHouse quiere memoria, MinIO quiere disco, Redis quiere CPU para networking— pero no toca la VRAM. En el cluster 4×H100, Langfuse vive en un nodo de CPU (o en los nodos GPU pero con
nodeSelector/taintsque lo mantengan lejos de los pods de vLLM). Mezclar ClickHouse con vLLM en el mismo nodo sin límites de recursos es pedir que un pico de ingesta robe ancho de banda de memoria a la inferencia. Aislamiento por diseño.La ruta de telemetría es “fría” y la de datos es “caliente”. El plano de datos (izquierda) sirve tokens con presupuesto de latencia de milisegundos; el plano de telemetría (derecha) tolera segundos. El acuse temprano de la ingesta es lo que mantiene estos dos relojes separados: la app no espera a que Langfuse archive nada para devolver la respuesta al usuario.
Los 10 knobs de backend que más mueven la aguja
Estos son, por orden aproximado de impacto/frecuencia, los ajustes que deciden si tu Langfuse self-hosted ingiere 50 eventos/s o 5.000, y si tu disco crece de forma sostenible o explota en tres semanas. Todos son variables de entorno o config que se inyectan en los contenedores Web y Worker (salvo los de ClickHouse, que van en su config server-side). El detalle canónico está en la doc de scaling de Langfuse.
Knob 1 — Escalar el Worker por carga (la primera palanca, siempre)
El Worker es el componente que se satura primero, porque es quien hace el trabajo caro: leer S3, transformar, mergear, insertar en ClickHouse. La regla operativa de Langfuse es simple: un contenedor Worker de 2 CPU por encima del 50 % de uso de CPU está saturado; añade réplicas. Mejor aún que la CPU, el Worker publica vía statsd la métrica langfuse.queue.ingestion.length (longitud de la cola de ingesta), que es la señal directa para autoescalar: si la cola crece sin drenar, faltan Workers.
# El autoscaler ideal mira la profundidad de cola, no solo CPU.
# (KEDA ScaledObject sobre la métrica statsd → Prometheus)
triggers:
- type: prometheus
metadata:
query: langfuse_queue_ingestion_length
threshold: "10000" # si la cola pasa de 10k refs, escala
En despliegues AWS existe ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true para empujar estas métricas a CloudWatch. On-premise, el camino es statsd → Prometheus → KEDA, encajado con el autoscaling en Kubernetes con KEDA que ya cubrimos para vLLM. Empieza siempre por aquí: la mayoría de los problemas de “Langfuse va lento” son simplemente Workers insuficientes, no afinado fino.
Knob 2 — Separar el deployment de ingesta del de UI
Cuando la ingesta va muy cargada, las consultas de la UI y la API pública se vuelven lentas porque comparten el mismo contenedor Web. La solución es partir langfuse-web en dos deployments idénticos y enrutar por path: todo lo que sea /api/public/ingestion*, /api/public/media* y /api/public/otel* va al deployment de ingesta; el resto (UI, API de lectura) al de interfaz.
# Regla de Ingress / gateway
location ~ ^/api/public/(ingestion|media|otel) {
proxy_pass http://langfuse-web-ingest; # réplicas dedicadas a escribir
}
location / {
proxy_pass http://langfuse-web-ui; # réplicas dedicadas a leer
}
Es la misma idea que la separación read/write de cualquier sistema con cargas mixtas: que una tormenta de escrituras no deje sin recursos a quien intenta mirar el dashboard justo durante el incidente —que es precisamente cuando más lo necesitas.
Knob 3 — Batching de escrituras a ClickHouse (interval + batch size)
ClickHouse odia las inserciones pequeñas y frecuentes: cada INSERT crea una part en disco que luego hay que mergear, y miles de inserts diminutos generan miles de parts y una tormenta de background merges que satura el disco. La defensa es acumular en un buffer en memoria del Worker y hacer flush por lotes grandes:
# Worker: menos flushes, lotes más grandes → menos parts, menos merges
LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=1000 # sube p.ej. a 2000-5000
LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=10000 # sube si hay throughput
Subir el intervalo y el tamaño de lote reduce la frecuencia de flushes y mejora el throughput sostenido. El trade-off es directo y hay que entenderlo: lotes más grandes y menos frecuentes significan más datos en el buffer volátil del Worker, es decir, una ventana de pérdida mayor si el Worker se cae (knob acoplado al escepticismo del cierre). Langfuse además usa async_insert de ClickHouse, que acumula server-side antes de confirmar; suma otra capa de buffering a tener presente.
Knob 4 — Saltar la lectura previa a ClickHouse en la ingesta
Por defecto, al ingerir un evento el Worker lee de ClickHouse el evento existente y lo mergea con lo entrante (necesario cuando los SDKs legacy mandan eventos parciales: un start, luego un end, luego un update de la misma observación). Esa lectura por evento carga ClickHouse en la ruta de escritura y limita el throughput total.
Si tus proyectos no vienen migrados de una versión antigua —porque el histórico completo ya vive en S3— puedes desactivar esa lectura:
# Fecha anterior a la creación de tu primer proyecto
LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE=2025-01-01
Con los SDKs modernos de Langfuse o con ingesta vía OpenTelemetry, esto no te afecta negativamente y quita una lectura por evento. Aviso de la propia doc: si combinas esto con reglas de borrado (lifecycle) agresivas en S3 más updates tardíos de eventos, puedes generar duplicados en el histórico. Conócelo antes de activarlo.
Knob 5 — Concurrencia de escritura a S3/Blob storage
En escenarios de alto throughput, el cliente de S3 puede agotar sus sockets y empezar a encolar y throttlear escrituras. El síntoma es inconfundible en los logs del contenedor Web que procesa ingesta:
@smithy/node-http-handler:WARN - socket usage at capacity=150
and 387 additional requests are enqueued.
…acompañado de una subida de memoria en ese contenedor (las requests encoladas se acumulan en RAM). La cura es subir el límite de escrituras concurrentes desde su default de 50:
LANGFUSE_S3_CONCURRENT_WRITES=100 # sube gradualmente desde 50
Cada socket adicional tiene un pequeño coste de memoria, así que el consejo oficial es subirlo de forma gradual observando el comportamiento, no saltar a 1000 de golpe.
Knob 6 — Sharding de colas Redis + concurrencia por shard
Si Redis pasa del 90 % de CPU, primero lo obvio: instancia con al menos 4 CPU (para que Redis reparta networking y tareas de fondo en cores distintos) y Redis Cluster mode activado. Si aún así la CPU no baja, se pueden shardear las colas que usa Langfuse:
# Avanzado: solo si Redis va ahogado y ya hiciste lo anterior
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT=6 # ~2-3× nº de shards del cluster Redis
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT=6
# La concurrencia cuenta POR SHARD; objetivo ~20 por worker
LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY=3 # 6 shards × ~3 ≈ 18
LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY=3
Dos trampas que la doc subraya y conviene tatuarse: una vez shardeas, no reduzcas el número de shards (rompe el reparto); y la concurrencia se cuenta por shard, no global —si tienes 10 shards y quieres concurrencia 20 por worker, pon 2, no 20. Es un knob avanzado: la mayoría de despliegues on-premise nunca lo necesitan.
Knob 7 — El modificador FINAL para proyectos solo-OTel
Langfuse guarda las observaciones en un ReplacingMergeTree de ClickHouse y, por defecto, añade el modificador FINAL a las consultas de la API para que gane la última versión de cada fila en tiempo de lectura. FINAL es necesario cuando la ingesta produce varias versiones de la misma observación (los SDKs legacy con sus eventos start/end/update), pero añade trabajo de merge en cada lectura y la ralentiza.
Los proyectos que ingieren exclusivamente por OpenTelemetry escriben cada observación como una fila inmutable única, así que FINAL les sobra:
# Recomendado en despliegues mixtos: per-project, marca en Redis con TTL 24h
LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS=true
# Solo si TODOS los proyectos son OTel-only: global, sin lookup en Redis
LANGFUSE_API_CLICKHOUSE_DISABLE_OBSERVATIONS_FINAL=true
Como en el cluster de ejemplo la instrumentación es 100 % OTel (gen_ai.* vía Collector), este knob es dinero gratis en latencia de lectura del dashboard. Cuidado con la versión global: no la actives si algún proyecto sigue usando ingesta legacy, o las lecturas pueden devolver filas duplicadas o stale.
Knob 8 — Separar lecturas analíticas del path de escritura (compute-compute)
Las consultas pesadas del dashboard (percentiles sobre millones de spans) compiten con los inserts de ingesta y con los background merges sobre el mismo ClickHouse. Si tu despliegue soporta separación compute-compute (ClickHouse Cloud o BYOC), puedes enrutar las lecturas a un grupo de cómputo de solo-lectura:
CLICKHOUSE_URL=http://clickhouse-primary:8123 # writes, migraciones, ingesta
CLICKHOUSE_READ_ONLY_URL=http://clickhouse-reader:8123 # lecturas UI + API pública
Matiz crítico para on-premise —y aquí toca ser escéptico con la utilidad de este knob en nuestro contexto: en un ClickHouse single-node o en un cluster self-managed sin separación de cómputo, esta variable no aporta nada, porque el endpoint de lectura sería el mismo que el de escritura. Es un knob para arquitecturas cloud con almacenamiento separado del cómputo. En un cluster 4×H100 on-premise con ClickHouse en un nodo, la alternativa real es escalar ClickHouse verticalmente (la doc recomienda ≥16 GiB de RAM para deployments grandes; ClickHouse escala vertical bien) y asegurar que todas las consultas filtran por projectId y tiempo, que es como están indexadas las tablas. Sin filtro temporal, hasta el ClickHouse más gordo sufre.
Knob 9 — Retención de datos: TTL en ClickHouse + lifecycle en S3
El disco es el coste que crece solo. Las trazas LLM cargan inputs y outputs enteros (a veces prompts de decenas de KB), y ClickHouse además acumula sus propias tablas de sistema. La palanca de primer orden es una política de retención que borra nightly trazas, observaciones, scores y media más viejos que N días, coordinando ClickHouse y blob storage. Donde la feature de retención no esté disponible, se hace a mano:
-- ClickHouse: TTL sobre las tablas de tracing
ALTER TABLE traces MODIFY TTL toDateTime(timestamp) + INTERVAL 90 DAY;
ALTER TABLE observations MODIFY TTL toDateTime(start_time) + INTERVAL 90 DAY;
ALTER TABLE scores MODIFY TTL toDateTime(timestamp) + INTERVAL 90 DAY;
ALTER TABLE event_log MODIFY TTL toDateTime(timestamp) + INTERVAL 30 DAY;
# S3/MinIO: lifecycle rule, p.ej. 30 días para el bucket de eventos crudos
# ¡OJO! NO apliques retención al bucket de MEDIA:
# - rompe los ficheros referenciados en trazas
# - rompe futuras subidas (el estado se trackea por hash en Postgres)
Dos parámetros de operación que evitan sustos en borrados grandes:
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS=600000 # default 10 min; súbelo si los borrados expiran
# ClickHouse 25.7+: menos presión de mutaciones en borrados masivos
CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE=lightweight_update
CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE=true
La regla mental: retención corta para eventos crudos (S3, 30 días suele bastar — son recuperables/recomputables), retención por valor de negocio para las tablas de ClickHouse (90 días, 180, lo que pida compliance), y nunca toques el bucket de media con lifecycle ciego.
Knob 10 — Higiene de las system log tables de ClickHouse (el asesino silencioso del disco)
Este es el knob que nadie configura y que llena el disco sin que aparezca en ninguna métrica de Langfuse, porque no es dato de Langfuse: son las tablas de sistema del propio ClickHouse (trace_log, text_log, opentelemetry_span_log, asynchronous_metric_log, metric_log, latency_log). Por defecto no tienen TTL, y el query profiler escribe en system.trace_log continuamente. En un ClickHouse con tráfico, estas tablas pueden dominar el uso de disco mientras tú buscas el problema en tus trazas. Langfuse no lee de ellas, así que se pueden recortar sin miedo. Dos opciones:
<!-- Opción A — desactivar las que Langfuse nunca lee
(fichero en /etc/clickhouse-server/config.d/) -->
<clickhouse>
<trace_log remove="1"/>
<text_log remove="1"/>
<opentelemetry_span_log remove="1"/>
<asynchronous_metric_log remove="1"/>
<metric_log remove="1"/>
<latency_log remove="1"/>
</clickhouse>
<!-- Mantén query_log, part_log y error_log: útiles para debug y pequeños -->
-- Opción B — TTL agresivo + apagar el profiler, si quieres conservarlas para debug
-- (en config: query_profiler_real_time_period_ns = 0)
SET max_table_size_to_drop = 0;
TRUNCATE TABLE system.trace_log;
ALTER TABLE system.trace_log MODIFY TTL event_date + INTERVAL 7 DAY;
-- repetir para cada tabla de log a capar
Para identificar qué tabla se está comiendo el disco, la consulta de oro:
SELECT table, formatReadableSize(sum(bytes)) AS size, sum(rows) AS rows
FROM system.parts WHERE active GROUP BY table ORDER BY sum(bytes) DESC;
Si solo te llevas un knob de este post a tu primer despliegue real, que sea este: la diferencia entre un ClickHouse que crece 2 GB/día de datos útiles y uno que crece 20 GB/día de logs de sistema que nadie mira.
Tabla resumen de los 10 knobs
| # | Knob | Variable / acción | Cuándo |
|---|---|---|---|
| 1 | Escalar Worker | réplicas por CPU>50 % / langfuse.queue.ingestion.length | siempre, primero |
| 2 | Separar ingesta/UI | enrutar /ingestion*,/media*,/otel* a réplica dedicada | UI lenta bajo carga |
| 3 | Batching a ClickHouse | LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS / _BATCH_SIZE | throughput alto |
| 4 | Saltar lectura previa CH | LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE | proyectos no migrados |
| 5 | Concurrencia S3 | LANGFUSE_S3_CONCURRENT_WRITES (def. 50) | “socket usage at capacity” |
| 6 | Sharding colas Redis | LANGFUSE_*_QUEUE_SHARD_COUNT + *_CONCURRENCY (por shard) | Redis CPU >90 % |
| 7 | Quitar FINAL (OTel) | LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS=true | instrumentación 100 % OTel |
| 8 | Read/write split CH | CLICKHOUSE_READ_ONLY_URL (solo cloud/BYOC) | compute-compute disponible |
| 9 | Retención + TTL | TTL en CH + lifecycle S3 + LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS | siempre (coste disco) |
| 10 | Higiene system logs CH | <trace_log remove="1"/> o TTL agresivo | siempre (disco oculto) |
Cómo maximizar Langfuse en el cluster 4×H100 de ejemplo
Con la arquitectura y los knobs claros, este es un dimensionado concreto para sacar el máximo a Langfuse sobre el cluster genérico de referencia (4×H100 SXM, 320 GB VRAM, 640 GB RAM, NVMe-oF, 25/100 GbE), sin robar un solo GB de VRAM a la inferencia.
Reparto de componentes
Langfuse es 100 % carga de CPU/RAM/disco/red, así que su sitio natural es fuera de los nodos GPU o, si se cohabita, con taints/nodeSelector que lo confinen lejos de los pods de vLLM. Reparto sugerido:
nodo-cpu-01 (control + observabilidad, sin GPU)
├── langfuse-web-ingest ×3 (2 CPU / 4 GiB c/u) ← ingesta, escala con carga
├── langfuse-web-ui ×2 (2 CPU / 4 GiB c/u) ← dashboard/API lectura
├── langfuse-worker ×4 (2 CPU / 4 GiB c/u) ← el que más escala
├── redis/valkey ×1 (4 CPU / 4 GiB, cluster mode)
└── postgres ×1 (2 CPU / 8 GiB, réplica para HA)
nodo-storage-01 (estado pesado, NVMe local)
├── clickhouse ×1 (8 CPU / 32 GiB / NVMe) ← ≥16 GiB es el mínimo; 32 holgado
└── minio (S3) ×1 (4 CPU / 8 GiB / HDD+NVMe cache)
nodo-gpu-01..02 (4×H100 SXM cada uno) → SOLO inferencia
└── vLLM, embeddings, reranker, guardrails (emiten spans, no alojan Langfuse)
Dimensionado por carga real
Pongamos números a una carga de ejemplo. Supongamos el cluster sirviendo 300 peticiones/segundo de chat-con-RAG, donde cada petición genera del orden de 8 spans (request, retrieval, rerank, 2× tool, guardrail in, llm, guardrail out):
$$ E = 300,\tfrac{\text{req}}{\text{s}} \times 8,\tfrac{\text{spans}}{\text{req}} = 2.400\ \text{eventos/s} $$
Frente al techo de Redis (~50.000 ops/s), $E = 2.400$ deja la ventanilla de recepción al ~5 % de su capacidad: holgura enorme. El componente a vigilar es el Worker. Con un objetivo de ~20 de concurrencia por Worker y lotes de 10.000 eventos cada ~1-2 s, 4 Workers drenan 2.400 ev/s con margen; la métrica langfuse.queue.ingestion.length debe mantenerse plana cerca de cero. Si crece, el knob 1 (más Workers) es la respuesta antes que cualquier afinado.
Tail-sampling es el multiplicador que cambia la economía. Si el Collector preserva el 100 % de errores/latencias-altas pero muestrea el tráfico normal al, digamos, 10 %, los 2.400 ev/s que almacenas en ClickHouse bajan a ~240-300 ev/s efectivos sin perder la señal que importa. La regla: muestrea en el Collector, no en Langfuse —Langfuse debe recibir ya filtrado lo que merece persistirse. Esto está desarrollado en el post de tracing OTel; aquí basta con notar que el sampling de aguas arriba es, de facto, el knob 0 que multiplica a todos los demás.
Estimación de almacenamiento
Una observación LLM con input+output completos pesa, comprimida en ClickHouse, del orden de 1-3 KB (ClickHouse comprime texto muy bien, 5-10×). Con sampling al 10 % sobre 2.400 ev/s:
$$ 240,\tfrac{\text{ev}}{\text{s}} \times 2,\text{KB} \times 86.400,\tfrac{\text{s}}{\text{día}} \approx 41\ \text{GB/día (cruda)} ;\xrightarrow{\text{compresión}}; \sim 5\text{–}8\ \text{GB/día en CH} $$
A 90 días de retención (knob 9), el archivo permanente se estabiliza en torno a 500-700 GB en ClickHouse —cómodo en el NVMe del nodo de storage— más los eventos crudos en MinIO con lifecycle de 30 días. Sin la higiene de system logs (knob 10), súmale fácilmente otro tanto de basura que nadie consulta. Los dos knobs de disco juntos son la diferencia entre planificar storage una vez al año o pelearte con el disco lleno cada mes.
Checklist de “máximo aprovechamiento”
- Sampling en el Collector (tail: 100 % errores + N % normal) — antes de tocar nada en Langfuse.
- Workers escalados por longitud de cola vía KEDA (knob 1), no fijos.
- Ingesta separada de UI (knob 2) para que el dashboard responda durante incidentes.
SKIP_FINAL_FOR_OTEL_PROJECTSactivo (knob 7) porque la instrumentación es 100 % OTel.- Batching CH generoso (knob 3) ajustado al throughput, asumiendo la ventana de pérdida.
- Retención + TTL + higiene de system logs (knobs 9 y 10) configurados el día 1, no cuando el disco grite.
- ClickHouse con ≥16 GiB y todas las queries filtrando por
projectId+tiempo (knob 8 en su versión on-premise: escala vertical). - Langfuse aislado de las GPU por
taints/nodeSelector: ni un MB de VRAM, ni contención de ancho de banda de memoria con vLLM.
Trampas y cosas que no son lo que parecen
“Langfuse me garantiza trazabilidad total.” No: el diseño es best-effort de alto rendimiento, no libro contable. Entre el HTTP 207 y la fila en ClickHouse hay buffers volátiles (Redis sin persistencia dura, el buffer en memoria del Worker, el async_insert server-side de ClickHouse). Hay un bug conocido donde el writer descarta filas sin dead-letter queue tras agotar reintentos. Para observabilidad operativa, perder el 0,01 % de spans es irrelevante. Para evidencia de auditoría ENS/EU AI Act —donde la traza es la prueba— Langfuse no debe ser el único registro; el log de auditoría regulatorio necesita garantías de durabilidad que esta tubería no promete. Distinción tratada en los controles técnicos ENS/42001/EU AI Act.
Subir el batching de ClickHouse “para ir más rápido” sin más. El knob 3 mejora throughput a costa de agrandar la ventana de pérdida y la latencia de aparición del dato en el dashboard. Lotes de 50.000 cada 10 s rinden de maravilla… hasta que el Worker se reinicia con 50.000 eventos en el buffer. Ajusta con conciencia del trade-off, no maximizando ciegamente.
Meter ClickHouse en el mismo nodo que vLLM sin límites. ClickHouse es voraz con el ancho de banda de memoria durante los merges. Compartir nodo con vLLM sin resources.limits ni aislamiento NUMA significa que un pico de ingesta puede degradar el TTFT de la inferencia —exactamente el pecado original que toda esta arquitectura quería evitar. Aísla.
Olvidar el filtro temporal en consultas propias. Las tablas de ClickHouse están indexadas por projectId y tiempo. Un dashboard custom o una consulta de la API sin filtro de tiempo escanea todo el histórico y tumba el rendimiento para todos. No es Langfuse que “va lento”: es una query mal escrita.
Aplicar lifecycle al bucket de media. Romper los ficheros referenciados en trazas y bloquear futuras subidas (el estado se trackea por hash en Postgres). El bucket de media se gestiona solo con la feature de retención de Langfuse, nunca con reglas ciegas de S3.
Tratar el sharding de colas como optimización de rutina. Es un knob avanzado para Redis ahogado de verdad, irreversible (no reduzcas shards) y con semántica de concurrencia por shard fácil de malinterpretar. En la inmensa mayoría de despliegues on-premise no hace falta; si lo activas “por si acaso”, te complicas la vida sin ganar nada.
Conclusión
Langfuse v3 resolvió el problema estructural de la observabilidad LLM —que el observador no asfixie al observado— mudándose de un monolito sobre Postgres a un centro de clasificación de seis servicios con ingesta asíncrona. Ese diseño es lo que permite que un cluster sirviendo miles de tokens por segundo se instrumente entero sin que la app espere jamás a que se archive una traza. Pero el diseño es condición necesaria, no suficiente: rinde si se ajustan las palancas correctas. De los diez knobs, tres deciden casi todo en un despliegue on-premise típico —escalar Workers por longitud de cola (1), retención + TTL (9), e higiene de system logs (10)—; el resto son afinados que aparecen cuando la carga aprieta. Y por encima de todos ellos vive el knob 0, que no es de Langfuse: el sampling en el Collector, que decide cuánto llega a la tubería antes de que ningún ajuste interno importe. Maximizar Langfuse en el cluster 4×H100 no es exprimir su throughput pico: es ponerlo fuera de las GPU, alimentarlo con tráfico ya muestreado, dimensionar el Worker por la cola, y configurar la retención el día uno —para que la herramienta que vino a contar la historia no acabe siendo el capítulo del incidente.
Ver también
- Tracing LLM con OpenTelemetry GenAI — el pipeline
SDK → Collector → backendque alimenta a Langfuse. Allí se trata Langfuse como destino; aquí se abre por dentro. El sampling de dos capas de aquel post es el knob 0 que multiplica a los diez de este. - Prompt versioning con Langfuse y MLflow — la capa de prompt management que vive en Postgres (no en ClickHouse). El
prompt_id@versionque aquel post propaga como span attribute aterriza en las tablas de tracing descritas aquí. - Evals: la capa después del tracing — los datasets y evaluators de Langfuse se apoyan en este mismo backend; las trazas almacenadas son el input del eval continuo.
- El catálogo OSS para LLMOps en seis etapas — la ficha de Langfuse junto a Phoenix y el resto del ecosistema de observabilidad.
- El stack de inferencia LLM on-premise en siete capas — dónde encaja Langfuse (capa 5, observabilidad LLM-aware) en el edificio completo y cómo se dimensiona sobre el mismo cluster 4×H100.
- Autoescalado de LLMs en Kubernetes con KEDA — el mecanismo concreto para escalar los Workers de Langfuse por
langfuse.queue.ingestion.length(knob 1), el mismo patrón que para vLLM. - Controles técnicos: ENS, ISO 42001 y EU AI Act — por qué Langfuse es observabilidad best-effort y no sustituye al log de auditoría regulatorio con garantías de durabilidad.
Referencias
- Langfuse, Scaling Langfuse Deployments (doc oficial de sizing y todos los env vars de este post): https://langfuse.com/self-hosting/configuration/scaling.
- Langfuse, Self-host Langfuse y Configuration via Environment Variables: https://langfuse.com/self-hosting · https://langfuse.com/self-hosting/configuration.
- Langfuse, ClickHouse (self-hosted): https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse.
- Langfuse, From Zero to Scale: Langfuse’s Infrastructure Evolution (el porqué del rediseño v2→v3): https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution.
- Langfuse, Migrate v2 to v3 (self-hosted): https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3.
- ClickHouse, Langfuse and ClickHouse: A new data stack for modern LLM applications: https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications.
- Langfuse, issue #13468 — ClickhouseWriter drops rows after max flush attempts with no DLQ (la ventana de pérdida documentada): https://github.com/langfuse/langfuse/issues/13468.
- ClickHouse, TTL for tables and columns: https://clickhouse.com/docs/guides/developer/ttl.
- OpenTelemetry, Semantic Conventions for Generative AI (
gen_ai.*): https://opentelemetry.io/docs/specs/semconv/gen-ai/.