Langfuse v4, día 2 (3 de 10): dos agentes conocidos instrumentados, Open Deep Research y GPT Researcher

Índice

Tercer artículo de una serie de diez sobre operar Langfuse v4 en producción. El primero recorría qué entra en una traza; el segundo medía lo que cuesta un turno de agente sobre un grafo mínimo. Este aplica lo mismo a dos agentes que la gente despliega. Código leído el 14 de septiembre de 2026: Open Deep Research en el commit 1b7d2e8 y GPT Researcher en el 6f99857, con Langfuse 4.15.2 y LangGraph 1.2.11.

TL;DR

Una petición de Open Deep Research con la configuración por defecto son 1.416 observaciones y 8,1 MB. Medido sobre una réplica fiel de su topología con cinco unidades concurrentes, seis iteraciones del supervisor y diez llamadas react por investigador, que son sus valores por defecto. Todo cae en una sola traza. Y es un suelo, porque en el banco los resultados de búsqueda son de trescientos caracteres y los de verdad son mucho mayores.

Ninguna de esas 1.416 es de tipo AGENT. Sus nueve nodos se llaman clarify_with_user, write_research_brief, research_supervisor, final_report_generation, supervisor, supervisor_tools, researcher, researcher_tools y compress_research. Ninguno contiene la cadena que busca el handler.

El abanico paralelo no rompe la traza, y el miedo a que la rompa es infundado. Open Deep Research reenvía el config a los subgrafos y GPT Researcher pasa uno nuevo con solo etiquetas. Probé los dos patrones: una traza en ambos casos, porque LangChain recompone el config desde la variable de contexto.

GPT Researcher son dos productos en el mismo repositorio. El modo multiagente es LangGraph y se traza como cualquier grafo. El modo estándar no tiene grafo, y ahí cada llamada al modelo abre su propia traza: ocho llamadas, ocho trazas, medido.

Su nodo humano no usa interrupt(), espera por websocket. La observación del nodo se queda abierta mientras la persona piensa, de modo que su duración mide latencia humana. El percentil 95 de ese grafo no significa nada si no se excluye ese nodo.

Instrumentar los dos no requiere tocar sus repositorios. En Open Deep Research basta importar el grafo compilado y pasar el handler por config. En el modo estándar de GPT Researcher hay que envolver con el SDK, porque no hay sitio donde enganchar callbacks.

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

El artículo anterior sacó una fórmula sobre un grafo de juguete: 4 + 5n observaciones por turno, con el volumen en bytes creciendo con el cuadrado de los pasos. Una fórmula sobre un juguete sirve para entender el mecanismo y no sirve para dimensionar nada.

Este artículo la aplica a dos agentes reales, elegidos porque son conocidos y porque se rompen de maneras distintas. La serie pasa a diez artículos y el de migración se corre un puesto.

La analogía: dos fábricas, una auditoría

Una auditoría de trazabilidad en una fábrica con todo el proceso en cadenas propias es tediosa pero completa: cada puesto deja parte, y el auditor reconstruye la pieza de principio a fin. El volumen de papel es enorme y ese es el único problema.

La segunda fábrica subcontrata la mitad del proceso. El auditor ve entrar el material en una nave y salir el producto terminado, con horas y cantidades, y de lo que pasa dentro no tiene nada. El papel es poco. El problema tampoco es el papel.

Open Deep Research es la primera fábrica y el problema es de volumen. GPT Researcher, en su modo estándar, es la segunda, y el problema es que la traza no recoge lo que hace falta saber. Las dos se arreglan, pero con herramientas distintas, y confundirlas lleva a comprar disco cuando lo que falta es instrumentación.

Los dos agentes, y por qué estos

Open Deep Research (langchain-ai/open_deep_research, 12,7 k estrellas) es el agente de investigación de referencia de LangChain. Todo su trabajo pasa por LangChain, así que la instrumentación por callbacks lo ve entero.

GPT Researcher (assafelovic/gpt-researcher, 28,4 k estrellas) es el agente de investigación open source más conocido y no es de LangChain. Su modo multiagente usa LangGraph; su modo estándar, que es el que corren la API y la interfaz web, no.

Uno es de la casa y el otro no, que para una comparación es lo que importa.

Open Deep Research: la forma del grafo

Tres grafos, dos de ellos anidados como subgrafos (src/open_deep_research/deep_researcher.py):

  • El grafo raíz, deep_researcher (línea 701), con cuatro nodos: clarify_with_user, write_research_brief, research_supervisor y final_report_generation.
  • El subgrafo del supervisor (línea 353), con supervisor y supervisor_tools, que iteran entre sí.
  • El subgrafo del investigador (línea 589), con researcher, researcher_tools y compress_research, que es un bucle react clásico.

La multiplicación está en supervisor_tools. Cuando el supervisor decide delegar, ese nodo lanza un subgrafo de investigador por cada unidad y los espera juntos (línea 305):

research_tasks = [
    researcher_subgraph.ainvoke({
        "researcher_messages": [HumanMessage(content=tool_call["args"]["research_topic"])],
        "research_topic": tool_call["args"]["research_topic"]
    }, config)
    for tool_call in allowed_conduct_research_calls
]

tool_results = await asyncio.gather(*research_tasks)

El detalle que decide si la traza sale entera es ese config del final: el nodo recibe la configuración de LangGraph y la reenvía a los subgrafos, así que los callbacks viajan y cada investigador cuelga del sitio correcto.

Los tres topes de la configuración por defecto (src/open_deep_research/configuration.py) son max_concurrent_research_units = 5, max_researcher_iterations = 6 y max_react_tool_calls = 10. Se multiplican entre sí.

Lo que cuesta una petición, medido

Repliqué esa topología con modelos falsos, la misma técnica del artículo anterior: tres grafos, el mismo anidamiento, el mismo abanico con asyncio.gather, resultados de búsqueda de trescientos caracteres y respuestas de modelo de doscientos. Sin red y sin GPU, así que reproduce en cualquier sitio.

UnidadesIteracionesLlamadas reactObservacionesgenerationchainKB de atributosMayor observación
1112681856,8 KB3,7 KB
223922666257,3 KB4,6 KB
56101.4163701.0468.108 KB11,0 KB

La última fila es la configuración por defecto del repositorio. Una petición de investigación, una persona pulsando un botón una vez, deja 1.416 observaciones y ocho megabytes en el backend de observabilidad. En una sola traza, con un solo identificador.

Esos ocho megabytes son un suelo, no un techo. En el banco, cada resultado de búsqueda ocupa trescientos caracteres; un resultado real de Tavily con el contenido de la página ocupa entre diez y cien veces más, y ese texto entra en el historial y se reserializa en cada observación posterior del mismo investigador, que es el crecimiento cuadrático del artículo anterior actuando sobre un texto mucho mayor.

Puesto en cuentas de capacidad: cien peticiones de investigación al día, que para un equipo pequeño no es nada, son 141.600 observaciones y del orden de un gigabyte diario sin comprimir, con búsquedas de juguete. Con búsquedas reales, decenas de gigabytes. Es otro orden de magnitud respecto a un asistente de chat, y es la razón por la que este artículo va antes que el de capacidad.

Cero observaciones de tipo AGENT

En las 1.416 observaciones no hay ni una de tipo AGENT. El artículo anterior explicaba por qué: el handler decide ese tipo buscando la cadena agent en la ruta de la clase o en el nombre del run. Los nueve nodos de Open Deep Research son clarify_with_user, write_research_brief, research_supervisor, final_report_generation, supervisor, supervisor_tools, researcher, researcher_tools y compress_research.

Son nombres buenos. Describen lo que hace cada nodo y no incluyen una palabra que no aporta nada al que lee el código. El resultado es que el agente de investigación de referencia de LangChain, visto desde Langfuse, es un árbol de cadenas.

Se arregla desde fuera, sin tocar el repositorio, poniendo nombre a la invocación del subgrafo o renombrando los dos nodos que representan una decisión del modelo, que son supervisor y researcher. Sin eso, cualquier panel que agrupe por tipo de observación no va a distinguir el trabajo de decisión del trabajo de fontanería.

Instrumentarlo sin tocar el repositorio

Open Deep Research se publica como grafo de LangGraph Server, con su langgraph.json apuntando a deep_researcher. Hay dos formas de meterle el handler según cómo lo ejecutes.

Si lo invocas desde tu propio código, el grafo compilado es importable y basta el config:

from langfuse import Langfuse, propagate_attributes
from langfuse.langchain import CallbackHandler
from open_deep_research.deep_researcher import deep_researcher

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

with propagate_attributes(
    trace_name="deep-research",
    user_id=usuario,
    session_id=peticion_id,
    tags=["odr", "investigacion"],
):
    resultado = await deep_researcher.ainvoke(
        {"messages": [HumanMessage(pregunta)]},
        config={
            "callbacks": [handler],
            "configurable": {
                "thread_id": peticion_id,
                "max_concurrent_research_units": 3,
                "max_researcher_iterations": 4,
            },
        },
    )

Bajar esos dos topes de 5 y 6 a 3 y 4 no es una recomendación de observabilidad, es una decisión de producto que además divide el volumen de trazas por tres. Mejor tomarla a la vez.

Si lo despliegas en LangGraph Server, no hay invocación tuya donde meter el config, así que el handler se ata al compilar. El patrón es envolver el grafo exportado:

# grafo_instrumentado.py, y en langgraph.json apuntar aqui
from langfuse.langchain import CallbackHandler
from open_deep_research.deep_researcher import deep_researcher

graph = deep_researcher.with_config({"callbacks": [CallbackHandler()]})

Con esa vía se pierde el identificador de traza sembrado por petición, porque el handler se crea una vez. Si el agente usa interrupciones, esa pérdida importa, por lo que vimos en el artículo anterior.

GPT Researcher: dos productos en el mismo repositorio

Aquí está lo interesante, y es una trampa en la que se cae con cualquier agente que haya crecido por capas.

El repositorio tiene el paquete gpt_researcher, que es el motor, y el paquete multi_agents, que es una orquestación encima. Solo el segundo usa LangGraph.

El orquestador (multi_agents/agents/orchestrator.py:66) tiene ocho nodos: browser, planner, human, researcher, writer, fact_checker, visualizer y publisher, con dos bucles condicionales, uno de revisión humana del plan y otro de verificación de hechos. El nodo researcher lanza en paralelo un subgrafo de editor por sección (multi_agents/agents/editor.py:134), y ese subgrafo tiene su propio bucle de revisión entre reviewer y reviser.

Todo eso se traza como cualquier grafo. Y los modelos se llaman con self.llm.ainvoke(messages, **kwargs) desde el proveedor genérico (gpt_researcher/llm_provider/generic/base.py:365), que es un modelo de chat de LangChain sin config explícito, de modo que recoge la configuración ambiente mientras se ejecuta dentro de un nodo. Las generaciones aparecen.

El modo estándar es otra cosa. La clase GPTResearcher no tiene grafo, no tiene nodos y no pasa por LangGraph en ningún punto. Es el modo que usan la API y la interfaz web, o sea el que la mayoría ejecuta.

El mito del config nuevo

Antes de seguir hay que desmontar algo que parece un fallo y no lo es, porque se repite en muchos agentes.

Cuando el nodo researcher lanza los subgrafos de sección, lo hace así (multi_agents/agents/editor.py:74):

final_drafts = [
    chain.ainvoke(self._create_task_input(research_state, query, title),
                  config={"tags": ["gpt-researcher"]})
    for query in queries
]
research_results = [result["draft"] for result in await asyncio.gather(*final_drafts)]

Ese config es nuevo y solo lleva etiquetas. El método ni siquiera recibe la configuración del nodo, así que no podría reenviarla. La conclusión intuitiva es que los subgrafos pierden los callbacks y cada sección acaba en su propia traza.

No pasa. Monté los dos patrones lado a lado, el de Open Deep Research que reenvía el config y el de GPT Researcher que pasa uno nuevo, y los dos dan una sola traza con un solo span raíz. El motivo es que LangChain no toma el config que le pasas tal cual: parte de la configuración ambiente guardada en una variable de contexto y le superpone las claves que traes. Los callbacks siguen ahí, y las variables de contexto se copian a las tareas que crea asyncio.gather.

Lo que sí se pierde son las etiquetas del padre, que quedan sustituidas por la lista nueva. Es una molestia de filtrado, no una traza rota.

El modo estándar: ocho llamadas, ocho trazas

Sin grafo no hay run raíz, y sin run raíz cada llamada al modelo es su propio árbol. Medido con ocho llamadas sueltas a un modelo de chat con el handler pasado a mano en cada una:

Observaciones8
Trazas distintas8
Spans raíz8
Tipos8 generation

Ocho trazas de una observación cada una, sin relación entre ellas. Ninguna sabe que pertenece a la misma investigación. Y eso en el caso favorable, porque el modo estándar no ofrece ningún sitio público donde poner ese handler: los **kwargs llegan hasta ainvoke desde create_chat_completion (gpt_researcher/utils/llm.py:120), pero el camino atraviesa varias capas internas que no están pensadas como punto de extensión.

La vía que funciona sin parchear el repositorio es envolver por fuera con el SDK y dejar que las llamadas de dentro cuelguen de ahí:

from langfuse import get_client, observe, propagate_attributes
from gpt_researcher import GPTResearcher

@observe(name="gpt-researcher", as_type="agent")
async def investigar(pregunta: str, peticion_id: str, usuario: str) -> str:
    with propagate_attributes(user_id=usuario, session_id=peticion_id, tags=["gptr"]):
        researcher = GPTResearcher(query=pregunta, report_type="research_report")
        await researcher.conduct_research()
        informe = await researcher.write_report()

    cliente = get_client()
    cliente.update_current_span(
        output={"longitud": len(informe), "fuentes": len(researcher.get_source_urls())},
        metadata={"costes_gptr": researcher.get_costs()},
    )
    return informe

Con eso hay una traza por investigación, con una observación de tipo agent en la raíz y las generaciones colgando dentro, en el mismo árbol. No aparecen las búsquedas ni el troceado de documentos, porque eso no pasa por LangChain. Si hacen falta, se añaden con start_as_current_observation(as_type="tool") alrededor de las llamadas propias, que es la primera de las tres salidas del filtro de spans que describía el artículo anterior.

Hay que fijarse en researcher.get_costs(). GPT Researcher lleva su propia contabilidad de coste, y meterla como metadato es lo único que permite cuadrarla después con lo que calcula Langfuse. Cuando las dos cifras no coinciden, casi siempre es el alias del modelo del gateway.

El nodo humano que se queda abierto

El modo multiagente tiene un nodo human que pide opinión sobre el plan antes de investigar. No usa interrupt() de LangGraph. Espera dentro del nodo, leyendo del websocket (multi_agents/agents/human.py).

Las dos formas tienen consecuencias opuestas en la traza. Con interrupt(), el grafo devuelve el control y la traza se parte en dos, que es lo que medía el artículo anterior. Esperando dentro del nodo, la traza es una sola pero la observación de ese nodo queda abierta todo el tiempo que tarde la persona, minutos u horas.

Ninguna de las dos está mal. Lo que está mal es el panel que promedia duraciones sin saber cuál de las dos tiene delante. Con la espera dentro del nodo, el percentil 95 de duración del grafo mide cuánto tarda un humano en contestar un mensaje, no lo que tarda el agente. Si se alerta sobre esa métrica, la alerta salta a la hora de comer.

La regla práctica es excluir por nombre los nodos de espera humana de cualquier métrica de latencia, y medir esa espera aparte, que además suele ser una métrica de producto interesante por sí sola.

Qué mirar en la traza cuando llega

Con los dos agentes instrumentados, y antes de montar ningún panel, hay cuatro comprobaciones que se hacen una vez y ahorran semanas:

  1. Cuenta las observaciones de una petición típica. Si salen más de mil, el problema de esta plataforma va a ser de volumen y toca decidir muestreo antes que nada.
  2. Mira si hay más de un span raíz. Más de uno significa que hay trabajo que se está trazando fuera del árbol, y hay que saber cuál antes de que sean miles.
  3. Comprueba que las generaciones traen coste. Si vienen a cero, es el alias del modelo, y se arregla dando de alta ese nombre con su precio.
  4. Ordena las observaciones por duración y quita las que esperan a una persona. Lo que queda arriba es donde está el tiempo de verdad.

Checklist

  • El grafo se invoca con un identificador de traza sembrado por petición, no con uno aleatorio.
  • Los topes de concurrencia e iteraciones del agente están fijados a conciencia, sabiendo que multiplican el volumen de trazas.
  • Los nodos que representan una decisión del modelo llevan agent en el nombre, o se ha asumido por escrito que no habrá observaciones de ese tipo.
  • El trabajo que no pasa por LangChain está envuelto con el SDK, o se ha decidido que no hace falta verlo.
  • Los nodos de espera humana están identificados y excluidos de las métricas de latencia.
  • La contabilidad de coste propia del agente, si la tiene, entra como metadato para poder cuadrarla.
  • Hay una cifra medida de observaciones por petición, tomada de la instalación propia y no de este artículo.

Trampas

Los valores por defecto de un agente de investigación son de demostración, no de producción. Cinco unidades por seis iteraciones por diez llamadas react multiplican, y el que los eligió pensaba en la calidad del informe, no en tu ClickHouse.

Un repositorio puede tener dos agentes dentro con dos historias de instrumentación distintas. Instrumentar el modo que documenta el README y no el que ejecuta la API es un error que no da ningún síntoma: las trazas llegan, solo que de otra cosa.

Pasar un config nuevo a un subgrafo no rompe la traza. Rompe las etiquetas. El árbol se mantiene porque los callbacks viajan por variable de contexto.

Un nodo que espera a una persona contamina cualquier estadística de duración. Y no lo hace de forma evidente, porque el nodo termina bien y no marca error.

Buenos nombres de nodo y buena clasificación de observaciones son objetivos en conflicto. Gana el que escribe el código del agente, que normalmente no eres tú.

Ocho megabytes por petición con búsquedas de trescientos caracteres. Con resultados reales, el número que salga de tu instalación no se parecerá al de este artículo, y será mayor.

La serie: los diez 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: la instrumentación de una plataforma agéntica y el coste medido de un turno.
  3. Dos agentes conocidos instrumentados (este artículo): Open Deep Research y GPT Researcher, con las cifras medidas de una petición real.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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

  • Código de Open Deep Research, commit 1b7d2e8, leído el 14 de septiembre de 2026: src/open_deep_research/deep_researcher.py y configuration.py.
  • Código de GPT Researcher, commit 6f99857, leído el 14 de septiembre de 2026: multi_agents/agents/orchestrator.py, multi_agents/agents/editor.py, multi_agents/agents/human.py, gpt_researcher/llm_provider/generic/base.py y gpt_researcher/utils/llm.py.
  • Medición propia del 14 de septiembre de 2026 sobre réplicas de las dos topologías, con LangGraph 1.2.11, langchain-core 1.6.3, SDK de Langfuse 4.15.2, InMemorySpanExporter y modelos falsos, sin red.
  • Estrellas de GitHub de los dos repositorios, consultadas el 14 de septiembre de 2026.