Langfuse v4, día 2 (2 de 9): poner LangGraph delante, y lo que cuesta un turno de agente en observaciones

Índice

Segundo artículo de una serie sobre operar Langfuse v4 en producción. El primero, qué entra de verdad en una traza, recorría el modelo de datos leyendo el código del servidor. Este mide el otro lado. Verificado contra Langfuse 4.15.2 (SDK de Python), LangGraph 1.2.11 y langchain-core 1.6.3, con las cifras tomadas el 13 de septiembre de 2026 en un banco de pruebas sin red.

TL;DR

Un turno de agente son cuatro observaciones más cinco por cada llamada a herramienta. Medido con un exportador en memoria sobre un grafo ReAct mínimo: cero herramientas, 4 observaciones; una, 9; cinco, 29; diez, 54; cuarenta, 204. La fórmula se cumple exactamente en todo el rango.

El volumen en bytes crece de forma cuadrática, no lineal. El mismo turno pasa de 12,2 KB con una llamada a herramienta a 1,87 MB con cuarenta. El 88 % de esos bytes es el campo de entrada, porque cada observación reserializa el historial de mensajes completo tal y como está en ese momento.

Las definiciones de herramientas viajan pegadas a cada llamada al modelo. El handler las añade al campo de entrada como mensajes con rol tool. Medido: 364 bytes por herramienta. Con veinte herramientas son 7,3 KB en cada uno de los pasos del bucle.

El tipo de observación AGENT se asigna por coincidencia de cadena en el nombre. Un nodo llamado planificador sale como CHAIN; el mismo nodo llamado agente_planificador sale como AGENT. La lógica es literalmente buscar agent en la ruta de la clase o en el nombre del nodo (langfuse/langchain/CallbackHandler.py:397).

Una interrupción con reanudación produce dos trazas separadas. Verificado con invoke, con stream y con subgrafos, y también reutilizando el mismo objeto handler. El SDK trae maquinaria para reenganchar la traza, pero solo se dispara si el run raíz termina con una excepción de control de flujo, y en LangGraph 1.2.11 la interrupción vuelve por el valor de retorno.

El arreglo cabe en una línea y está medido. Pasar trace_context={"trace_id": Langfuse.create_trace_id(seed=thread_id)} al construir el handler deja la interrupción y la reanudación en la misma traza, incluso con handlers distintos y, por tanto, con réplicas distintas.

El procesador de spans descarta por defecto lo que no reconoce. Solo exporta spans del propio SDK, spans con algún atributo gen_ai.*, o spans de una lista cerrada de treinta y cinco prefijos de instrumentación (langfuse/_client/span_filter.py:11). Las trazas de tu propio código no llegan salvo que entren por una de esas tres puertas.

Estás aquí: OBSERVE, día 2

La serie se planteó con ocho artículos y crece a nueve. El motivo es de orden: los dos siguientes hablan de migrar y de dimensionar ClickHouse, y las dos cosas necesitan una cifra de entrada que nadie publica, que es cuántas observaciones y cuántos bytes produce una unidad de trabajo real. Un chat produce una traza con dos observaciones. Un agente produce doscientas. Dimensionar con la cifra del chat lleva a un cluster que se cae el día que alguien conecta el agente.

Así que antes de migrar, medir. Y para medir hace falta poner delante lo que la gente despliega.

La analogía: el expediente que se fotocopia entero en cada firma

Un expediente de contratación pasa por seis mesas. En cada mesa alguien lee lo que hay, añade una hoja y lo pasa a la siguiente.

Hay dos maneras de dejar rastro de ese recorrido. La primera es anotar en un registro qué mesa lo tocó, cuándo y qué hoja añadió. Seis anotaciones cortas. La segunda es fotocopiar el expediente completo al entrar en cada mesa y al salir, y archivar las doce fotocopias. El registro ocupa una hoja. Las fotocopias ocupan, en la sexta mesa, seis veces lo que ocupaban en la primera, y en total ocupan del orden del cuadrado del número de mesas.

La instrumentación por callbacks de LangChain hace lo segundo. No registra el delta de cada paso, registra el estado completo a la entrada y a la salida de cada paso. Con un chat eso da igual, porque el estado es corto y hay un paso. Con un agente de cuarenta pasos, el archivo pesa casi dos megabytes por expediente tramitado.

Esto no es un defecto que haya que arreglar. Es lo que permite abrir una traza y ver exactamente qué vio el modelo en el paso 17 sin reconstruir nada. Pero condiciona el dimensionado, y el número hay que saberlo antes de firmar el pliego.

Cuál es la plataforma agéntica más usada

La pregunta tiene dos respuestas según la métrica, y las dos son defendibles.

Por descargas de PyPI en los últimos treinta días, consultadas el 13 de septiembre de 2026:

PaqueteDescargas / 30 días
langgraph52,3 M
strands-agents35,2 M
openai-agents22,9 M
crewai19,1 M
google-adk14,5 M
pydantic-ai8,0 M
llama-index5,0 M
agno1,9 M
autogen-agentchat0,6 M

Por estrellas de GitHub el orden se invierte en cabeza: CrewAI va por 58,4 k, LangGraph por 38,1 k y el SDK de agentes de OpenAI por 28,6 k.

Las descargas de PyPI están infladas por integración continua y por imágenes que reinstalan en cada build, así que son un indicador de presencia en pipelines, no de agentes en producción. Las estrellas miden interés, no despliegue. Con las dos cosas a la vez, LangGraph es la que aparece en más pipelines y la que tiene el camino de instrumentación más trabajado en Langfuse, así que es la que se instrumenta aquí. Lo que sigue sobre volumen aplica igual a cualquier plataforma que instrumente por callbacks de LangChain, porque el coste viene del modelo de callbacks, no del grafo.

Cómo entra LangGraph en Langfuse

Conviene aclarar la ruta, porque el artículo anterior dejaba dos vías abiertas y aquí solo se usa una.

langchain-core 1.6.3 no importa OpenTelemetry en ningún sitio. La comprobación es directa sobre el wheel instalado y no devuelve ni un fichero. LangGraph tampoco emite spans OTLP por su cuenta. Lo que hay es el sistema de callbacks de LangChain, y el CallbackHandler de Langfuse se engancha ahí.

Ese handler no habla con la API de ingesta clásica. Crea observaciones con el SDK v4, que por dentro es OpenTelemetry: el LangfuseSpanProcessor monta un OTLPSpanExporter contra {base_url}/api/public/otel/v1/traces (langfuse/_client/span_processor.py:123). O sea que sí es OTLP, pero generado por el SDK, no por una instrumentación automática de terceros.

La diferencia importa por una razón concreta que quedó pendiente en el artículo anterior. Como los datos pasan por las llamadas del SDK, la función de enmascarado clásica sí se aplica: entrada, salida y metadatos pasan por _process_media_and_apply_mask antes de convertirse en atributos (langfuse/_client/span.py:534). Con una instrumentación automática del tipo OpenInference u OpenLLMetry, los mensajes viajan en eventos de span y el enmascarado no los toca. Para un agente que maneja datos de cliente en las herramientas, esa diferencia decide la ruta.

La contrapartida es que un handler de callbacks solo ve lo que pasa por LangChain. Las llamadas HTTP que haga una herramienta por su cuenta, la consulta a la base de datos, el subprocess, nada de eso aparece. Y si instrumentas tu código con OpenTelemetry para que aparezca, te topas con el filtro del que habla el apartado de más abajo.

El tipo de observación se decide por el nombre del nodo

El primer artículo insistía en que v4 tiene diez tipos de observación y en que usar el correcto cambia lo que la interfaz sabe agrupar. El handler de LangChain emite seis de esos diez: tool, retriever, generation, agent, chain y span. Nunca emite event, embedding, evaluator ni guardrail.

La asignación es directa para cuatro de ellos: callback de herramienta, tipo tool; callback de recuperador, tipo retriever; callback de modelo, tipo generation. Para los callbacks de cadena, que es lo que son todos los nodos de un grafo de LangGraph, el criterio es este (langfuse/langchain/CallbackHandler.py:393):

elif callback_type == "chain":
    # Detect if it's an agent by examining class path or name
    if serialized and "id" in serialized:
        class_path = serialized["id"]
        if any("agent" in part.lower() for part in class_path):
            return "agent"

    name = self.get_langchain_run_name(serialized, **kwargs)
    if "agent" in name.lower():
        return "agent"

    return "chain"

Medido sobre el mismo grafo con el nodo renombrado, y con el resto idéntico:

Nombre del nodoLlamadas a herramientaObservacionesTipos
planificador105411 generation, 33 chain, 10 tool
agente_planificador105411 generation, 22 chain, 11 agent, 10 tool

Once observaciones cambian de tipo por once caracteres en el nombre de una función. Esto tiene dos consecuencias prácticas. La primera es que cualquier cuadro de mando que cuente observaciones de tipo AGENT está contando convenciones de nombres. La segunda es que el grafo de agente de la interfaz se dibuja cuando la traza contiene alguna observación de tipo distinto de span, event o generation, y chain cuenta, así que el dibujo sale igual. Lo que cambia es lo que puedes filtrar y agregar después.

La recomendación operativa es aburrida y funciona: nombrar los nodos que representan una decisión del modelo con un nombre que contenga agent, y dejar el resto como cadenas. Es una convención de nombres elevada a esquema de datos, que no es bonito, pero es lo que hay y es estable.

Lo que cuesta un turno, medido

El montaje es un grafo ReAct mínimo: un nodo que llama al modelo, un ToolNode con una herramienta, una arista condicional con tools_condition y un checkpointer en memoria. El modelo es GenericFakeChatModel de langchain-core, que devuelve una lista fija de mensajes, así que no hace falta ni red ni GPU y el experimento es reproducible en cualquier portátil. El exportador es InMemorySpanExporter de OpenTelemetry, pasado al cliente por el parámetro span_exporter, de modo que las observaciones se cuentan sin servidor Langfuse delante.

Resultado, con el nodo llamado planificador:

Llamadas a herramientaObservacionesgenerationchaintoolBytes de atributos
041304,2 KB
1926112,2 KB
529618564,1 KB
1054113310173,5 KB
20104216320541,4 KB
301543193301.108 KB
4020441123401.874 KB

La cuenta de observaciones es exactamente 4 + 5n. Las cinco por vuelta del bucle son: el nodo del modelo, la llamada al modelo, la arista condicional, el nodo de herramientas y la herramienta. Las cuatro fijas son el grafo raíz, la primera vuelta parcial y el cierre.

Los bytes no siguen esa línea. De 10 a 20 pasos el volumen se multiplica por 3,1; de 20 a 40, por 3,5. Es el crecimiento cuadrático que se espera cuando cada uno de los n pasos arrastra un historial de longitud proporcional a n.

El desglose por campo lo confirma. Con cuarenta pasos, de 1.709 KB medidos en esa ejecución, 1.502 KB están en langfuse.observation.input y solo 56 KB en la salida. El 88 % de lo que se ingiere es el mismo historial repetido en distinto grado de avance. La observación individual más grande de ese turno son 22,2 KB, que está lejos del tope de 9,5 MB por span OTLP, así que el problema no es el límite de tamaño: es el agregado.

Y dentro de la entrada hay un segundo multiplicador. El handler añade la definición de cada herramienta al campo de entrada de la generación, como mensajes con rol tool (langfuse/langchain/CallbackHandler.py:1190). Medido con herramientas de tres parámetros y una descripción de una línea:

Herramientas enlazadasBytes del campo de entrada de una generación
034
1398
51.854
103.674
207.334

Son 364 bytes por herramienta, en cada generación, siempre. Un agente con veinte herramientas y cuarenta pasos ingiere 7,3 KB × 41 generaciones = 300 KB solo en esquemas de herramientas que no cambian nunca a lo largo del turno.

Qué significa esto para ClickHouse

Aquí toca un descargo honesto. Comprimí el payload completo de esas ejecuciones con ZSTD nivel 3, que es lo que usa events_full, y salieron ratios de entre 29× y 86×, mejorando cuanto más largo el turno. Ese número está inflado por lo sintético del banco: mi herramienta devuelve siempre la misma cadena, así que la redundancia es mayor que en producción.

Lo que sí se sostiene es la forma. La repetición del historial es exactamente el patrón que la compresión columnar absorbe bien, así que el disco de ClickHouse no es donde duele. Donde duele es antes: en el ancho de banda hacia el endpoint OTLP, en la CPU del worker que descomprime y aplana, y en la cola de ingesta, que ven los bytes sin comprimir. El primer artículo dejaba anotado que la cola que satura primero es langfuse.queue.ingestion.depth, y esta es la razón por la que satura.

Cuenta rápida para dimensionar, con las cifras medidas y un turno medio de diez pasos:

  • 1.000 turnos al día son 54.000 observaciones y unos 170 MB sin comprimir al día.
  • 10.000 turnos al día son 540.000 observaciones y 1,7 GB sin comprimir al día.
  • Si el turno medio sube de diez a veinte pasos, las observaciones se duplican y los bytes se triplican.

La sensibilidad al número de pasos es el parámetro que hay que vigilar, más que el número de usuarios. Un cambio de prompt que haga al agente dar tres vueltas más multiplica la factura de observabilidad sin que nadie haya tocado la infraestructura. El artículo 5 de la serie vuelve sobre esto con las tablas de sistema de ClickHouse para medir los bytes reales por observación en tu propia instalación, que es lo único que sustituye a esta estimación.

Interrupciones: dos trazas donde debería haber una

Un agente en producción con aprobación humana usa interrupt(), devuelve el control, espera a una persona y reanuda con Command(resume=...). La pregunta operativa es si la traza sobrevive a esa pausa.

El SDK trae maquinaria explícita para el caso. Hay un almacén de contextos de traza pendientes indexado por thread_id, con un tope de 1.024 entradas y desalojo del más antiguo (MAX_PENDING_RESUME_TRACE_CONTEXTS), y una función que reconoce un Command con resume y recupera el contexto guardado.

Medido, no funciona en esta combinación de versiones:

Caso¿Misma traza?
Mismo objeto handler, invokeNo
Mismo objeto handler, streamNo
Interrupción dentro de un subgrafoNo
Handler nuevo para la reanudaciónNo

El motivo está en el código y es consistente. El contexto pendiente solo se guarda desde on_chain_error cuando el run que falla es el raíz y el nivel resultante es DEFAULT, que es lo que ocurre con las excepciones de control de flujo de LangGraph (CallbackHandler.py:866). En LangGraph 1.2.11 la interrupción no sale por excepción desde la raíz: el grafo devuelve normalmente, con la interrupción en el valor de retorno. El run raíz termina por on_chain_end y la rama que guarda el contexto nunca se ejecuta. La observación del nodo interrumpido sí queda bien: nivel DEFAULT y el Interrupt en el mensaje de estado, no marcado como error, que es el comportamiento correcto.

El arreglo está verificado y no depende de que el SDK cambie:

from langfuse import Langfuse
from langfuse.langchain import CallbackHandler

trace_id = Langfuse.create_trace_id(seed=thread_id)
handler = CallbackHandler(trace_context={"trace_id": trace_id})

Con eso, la interrupción y la reanudación caen en la misma traza aunque el handler sea otro objeto y aunque la reanudación la atienda otra réplica, porque el identificador se deriva del thread_id y no de un estado en memoria. Es la misma técnica que la documentación recomienda para unir varios agentes en una traza, aplicada a unir un agente consigo mismo.

Con un matiz que hay que decidir a conciencia. Sembrar con el thread_id a secas mete todos los turnos de esa conversación en una sola traza, que crece sin límite mientras la conversación siga viva. Si lo que quieres es una traza por turno con la reanudación pegada al turno correcto, siembra con thread_id más el número de turno, y deja la agrupación de la conversación al identificador de sesión, que para eso está. Como en v4 no hay tabla de trazas, una traza enorme no es una fila enorme: es un filtro que devuelve muchas filas, y lo que sufre es la interfaz al abrirla.

Hilo, sesión, usuario y lo que solo se lee en la raíz

El handler reconoce cinco claves de metadatos: langfuse_session_id, langfuse_user_id, langfuse_trace_name, langfuse_tags y langfuse_prompt. Las cuatro primeras se leen solo cuando parent_run_id es None, es decir, únicamente en el run raíz (CallbackHandler.py:587). Ponerlas en la configuración con la que invocas un subgrafo no hace nada.

La vía recomendada y la que funciona sin sorpresas es el gestor de contexto:

from langfuse import propagate_attributes

with propagate_attributes(
    trace_name="turno-agente",
    user_id=usuario,
    session_id=thread_id,
    tags=["produccion", "soporte-n1"],
):
    grafo.invoke(entrada, config=cfg)

Lo que LangGraph aporta por su cuenta llega igualmente. En la ejecución medida aparecen como metadatos de observación langgraph_node, langgraph_step, langgraph_triggers, langgraph_path, langgraph_checkpoint_ns, checkpoint_ns y thread_id, más ls_provider, ls_model_type y ls_integration. El thread_id sube además a metadato de traza.

Eso está bien, pero recordando lo del primer artículo: en events_core lo que está indexado es metadata_names, o sea los nombres de las claves, no los valores. Filtrar por langgraph_node = "planificador" es escaneo. Si vas a segmentar por nodo de forma habitual, el sitio correcto es el nombre de la observación, que es donde el nodo ya aparece, o una etiqueta, asumiendo que las etiquetas tampoco están indexadas. El campo barato para cortar por nodo en v4 es el propio nombre de la observación.

El identificador de sesión merece una decisión explícita. thread_id de LangGraph y session_id de Langfuse son el mismo concepto y no se conectan solos. Mapearlos uno a uno es lo razonable, y además es lo que hace que la conversación entera sea navegable en la interfaz aunque cada turno sea una traza distinta.

El filtro que tira tus propias trazas

Este es el hallazgo que más sorprende al montarlo. El procesador de spans de Langfuse no exporta todo lo que pasa por el TracerProvider. Exporta lo que cumple una de estas tres condiciones (langfuse/_client/span_filter.py:104):

  1. Lo creó el tracer del propio SDK de Langfuse.
  2. Tiene al menos un atributo que empieza por gen_ai.
  3. Su ámbito de instrumentación coincide con uno de los treinta y cinco prefijos de una lista cerrada.

La lista incluye lo esperable y un poco más: openinference, litellm, haystack, langsmith, strands-agents, pydantic-ai, autogen-core, vllm y una veintena de instrumentaciones opentelemetry.instrumentation.*.

La consecuencia para un agente es directa. Si instrumentas tus herramientas con OpenTelemetry a mano para ver la llamada al ERP o la consulta a Postgres dentro de la traza del agente, esos spans se descartan en silencio, sin log y sin error. Hay tres salidas, por orden de limpieza:

  • Crear esas observaciones con el SDK de Langfuse, con start_as_current_observation(as_type="tool"), que es lo que el resto del artículo asume.
  • Poner algún atributo gen_ai.* en el span, lo cual es abusar de la convención para colar el span, así que solo si el span describe de hecho una interacción con un modelo.
  • Mandar esos spans a tu colector de OpenTelemetry normal y no a Langfuse, y correlacionar por identificador de traza. Para una plataforma soberana con un colector propio, esta es la opción sensata: Langfuse guarda la parte de modelo, el colector guarda la parte de sistema, y el identificador de traza las une.

Que el comportamiento sea silencioso es lo que hace que se tarde una tarde en encontrarlo. Merece estar en el runbook.

El modelo y el coste con el gateway en medio

Un detalle que afecta a cualquiera que ponga LiteLLM delante, que es el montaje del que hablan el par operativo y la multi-tenencia de GPU.

El nombre de modelo que el handler pone en la generación sale primero del metadato ls_model_name y, si no está, de la serialización del componente (CallbackHandler.py:1297). Con un ChatOpenAI apuntando al gateway, ese nombre es el alias que hayas definido en LiteLLM, por ejemplo qwen-32b-interno. Langfuse calcula el coste con su propia tabla de modelos, que no conoce ese alias, así que la generación llega con tokens y sin coste.

Se arregla definiendo el modelo en Langfuse con ese mismo nombre y su precio por millón de tokens, que además es la única manera de que el coste refleje lo que cuesta tu GPU y no lo que cuesta la API de otro. Es trabajo de una tarde y evita que el panel de coste de la plataforma agéntica esté a cero para siempre.

Cómo queda esto en código

Lo mínimo que hay que tener puesto para que un agente LangGraph produzca trazas útiles en Langfuse v4:

import os
from typing import Annotated, TypedDict

from langfuse import Langfuse, get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, START
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition

# 1. Cliente. El enmascarado se aplica porque los datos pasan por el SDK.
def mask(*, data, **kwargs):
    return redactar_pii(data)

Langfuse(
    public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    host=os.environ["LANGFUSE_HOST"],
    mask=mask,
    environment="pro",          # minusculas, guiones, 40 caracteres, no empezar por "langfuse"
    sample_rate=float(os.getenv("LANGFUSE_SAMPLE_RATE", "1.0")),
)

# 2. Grafo. El nodo de decision lleva "agent" en el nombre a proposito.
class Estado(TypedDict):
    messages: Annotated[list, add_messages]

grafo = StateGraph(Estado)
grafo.add_node("agente_planificador", nodo_modelo)
grafo.add_node("tools", ToolNode(HERRAMIENTAS))
grafo.add_edge(START, "agente_planificador")
grafo.add_conditional_edges("agente_planificador", tools_condition)
grafo.add_edge("tools", "agente_planificador")
app = grafo.compile(checkpointer=checkpointer)

# 3. Por turno: traza determinista sembrada con hilo y turno, sesion = hilo.
def ejecutar_turno(thread_id: str, turno: int, texto: str, usuario: str):
    trace_id = Langfuse.create_trace_id(seed=f"{thread_id}:{turno}")
    handler = CallbackHandler(trace_context={"trace_id": trace_id})
    cfg = {
        "callbacks": [handler],
        "configurable": {"thread_id": thread_id},
    }
    with propagate_attributes(
        trace_name="turno-agente",
        user_id=usuario,
        session_id=thread_id,
        tags=["agente", "soporte"],
    ):
        salida = app.invoke({"messages": [HumanMessage(texto)]}, config=cfg)
    return trace_id, salida

# 4. Reanudacion tras aprobacion humana: mismo seed, misma traza.
def reanudar_turno(thread_id: str, turno: int, decision):
    from langgraph.types import Command

    trace_id = Langfuse.create_trace_id(seed=f"{thread_id}:{turno}")
    handler = CallbackHandler(trace_context={"trace_id": trace_id})
    cfg = {"callbacks": [handler], "configurable": {"thread_id": thread_id}}
    return app.invoke(Command(resume=decision), config=cfg)

# 5. Puntuar el turno. Los scores no viajan por OTLP: van por su propia ruta.
get_client().create_score(
    trace_id=trace_id,
    name="resuelto",
    value=1,
    data_type="BOOLEAN",
)

Dos notas sobre lo que no está en el ejemplo. sample_rate es muestreo de cabecera y por traza, así que muestrea turnos completos, que es lo que se quiere: media traza de agente no sirve para nada. Y si el proceso es un servidor web, el cliente se construye una vez al arrancar, no por petición, mientras que el handler puede ser por turno sin problema, porque con el identificador sembrado ya no guarda estado que haga falta conservar.

Checklist de instrumentación de un agente

  • Los nodos que representan una decisión del modelo llevan agent en el nombre. El resto, no.
  • Cada turno tiene un identificador de traza determinista sembrado con hilo y número de turno.
  • El thread_id de LangGraph va como session_id de Langfuse, siempre.
  • Los atributos de traza se ponen con propagate_attributes, no como metadatos en un subgrafo.
  • El alias de modelo del gateway está dado de alta en la tabla de modelos de Langfuse, con precio.
  • Las herramientas que hacen trabajo fuera de LangChain se instrumentan con el SDK de Langfuse, no con OpenTelemetry a pelo.
  • Hay una alerta sobre la profundidad de la cola de ingesta, y se conoce el número de pasos medio por turno.
  • Se ha medido, en la propia instalación, cuántas observaciones y cuántos bytes produce un turno real. Las cifras de este artículo son de un banco sintético.
  • El muestreo está puesto por variable de entorno, para poder bajarlo sin desplegar código.

Trampas

El tipo AGENT no significa que haya un agente. Significa que alguien escribió agent en el nombre. Y al revés: un grafo entero sin esa cadena no produce ni una observación de tipo agente.

Poner langfuse_session_id en los metadatos de un subgrafo no hace nada. Solo se leen en el run raíz.

Una interrupción parte la traza en dos. Salvo que siembres el identificador. Y si trabajas con varias réplicas, el estado en memoria del handler no te va a salvar nunca, porque la reanudación puede caer en otro pod.

Tus spans de OpenTelemetry no llegan. El filtro por defecto los descarta sin avisar si el ámbito no está en la lista y no hay atributos gen_ai.*.

El número que hay que vigilar no es el de usuarios, es el de pasos por turno. El volumen crece con el cuadrado de los pasos, y los pasos los decide el prompt, no la infraestructura.

Guardas megabytes y la interfaz te enseña mil caracteres. El truncado de la vista materializada a doscientos caracteres y el tope de lectura de mil siguen ahí. Pagas la ingesta completa y ves un recorte, salvo que abras la observación concreta.

El coste sale a cero con el gateway delante. Alias de modelo desconocido para la tabla de precios.

flush_at por encima del tamaño de cola revienta al construir el cliente. Con un ValueError de OpenTelemetry que no menciona Langfuse por ningún lado.

La serie: los nueve artículos

  1. Qué entra en una traza: modelo de datos de la versión 4, límites, precedencias, scores, enmascarado e índices.
  2. Poner LangGraph delante (este artículo): la instrumentación de una plataforma agéntica y el coste medido de un turno.
  3. Migrar de la versión 3 a la 4 sin ventana: los tres pasos del modo de escritura, las migraciones de fondo reanudables y dónde está el punto de no retorno del retroceso.
  4. Las colas del worker: el mapa de las treinta y nueve, qué pool dedicar a cada grupo, los interruptores por cola, el particionado y la concurrencia.
  5. Capacidad y coste real de ClickHouse: cómo medir los bytes por observación con las tablas del sistema, la diferencia entre la tabla completa y la de listados, y el coste de fusión de los índices de texto completo.
  6. Retención, borrado y protección de datos: por qué un borrado no libera disco, el limpiador de máscaras que viene desactivado, la cola de borrados pendientes y el ciclo de vida de S3 que hay que implementar a mano.
  7. Copias de seguridad y recuperación cruzada: orden de restauración entre Postgres, ClickHouse y el almacenamiento de objetos, qué rompe cada desajuste, y hasta dónde llega la reproducción de eventos.
  8. Runbook de saturación: qué alertar de las métricas de cola, las sondas de atasco, el drenado por el endpoint de preparación y la cola de mensajes fallidos.
  9. Sacar los datos fuera: la integración de almacenamiento de objetos a Parquet, las exportaciones por lotes y la API de métricas, para montar el lago de datos.

Ver también

Fuentes

  • Medición propia del 13 de septiembre de 2026: LangGraph 1.2.11, langchain-core 1.6.3 y SDK de Python de Langfuse 4.15.2, con InMemorySpanExporter de OpenTelemetry y GenericFakeChatModel, sin red.
  • Código del SDK de Langfuse 4.15.2: langfuse/langchain/CallbackHandler.py, langfuse/_client/span_filter.py, langfuse/_client/span_processor.py, langfuse/_client/span.py.
  • Descargas de PyPI de los últimos treinta días vía pypistats, consultadas el 13 de septiembre de 2026.
  • Estrellas de GitHub de LangGraph, CrewAI y OpenAI Agents SDK, consultadas el 13 de septiembre de 2026.
  • Integración de Langfuse con LangGraph y grafos de agente, consultados el 13 de septiembre de 2026.