Trazar un agente RAG+tools: la jerarquía que sí sirve
instrumentarás el agente de Aurora con spans anidados correctos (retrieval → LLM → tool) y sesiones multi-turno, sin spans huérfanos. Importa porque una traza con la jerarquía rota no te deja reconstruir la causalidad de un fallo — y reconstruir la causalidad es para lo que existe la traza.
5.1El problema
Instrumentaste cada función de Aurora. consultar_politica, buscar_pedido, la llamada al LLM: todas tienen su @observe. Corres una conversación, abres Langfuse y ves los spans. Existen, sí. Pero todos cuelgan de la raíz, uno al lado del otro, como una lista plana.
No ves que el tool-call vino DESPUÉS del retrieval. No ves que la segunda llamada al LLM ocurrió porque la primera pidió una herramienta. La traza te dice qué pasos hubo, pero no quién provocó a quién. La jerarquía está rota.
Eso te deja sin lo único que necesitabas. Cuando Aurora promete un reembolso que no existe, quieres seguir el hilo: ¿recuperó la política equivocada y el LLM la creyó? ¿O recuperó bien y el LLM la ignoró? Con spans aplanados, las dos historias se ven idénticas. La traza ha registrado los eventos pero ha perdido la causa.
Esta lección arregla eso. Vas a anidar los spans del agente entero para que la traza cuente la historia real: request → agente → (retrieval, LLM, tool). En el orden y con la dependencia que de verdad ocurrieron.
5.2Qué vas a poder hacer
Al terminar serás capaz de:
- Construir una traza del agente Aurora con 3-4 niveles anidados, donde cada span declara su padre correcto.
- Localizar un span huérfano en una traza rota y corregir su parentesco.
- Agrupar los turnos de una misma conversación en una sesión multi-turno.
Necesitas saber antes:
- De N0·L2 (Anatomía de una traza): qué es un trace, un span, una observation y una session, y qué campo define el padre de un span. Lo recuperamos en 5.3.
- De N0·L3 (Instrumentar con Langfuse): el decorador
@observe()del SDK Python v4 y cómo apareció tu primer trace en la UI. - De N0·L4 (Qué capturar): qué campos enriquecen un span (tokens, coste,
finish_reason, errores, metadata). - Externo: Python y haber llamado a una API de LLM con bucle de tool-use.
5.3Recupera
Antes de seguir, responde de memoria. Esto reactiva lo de L2 y hace que lo nuevo se enganche.
- En el modelo de datos de una traza, ¿qué campo de un span determina cuál es su padre? (Pista: lo usaste en L2 para dibujar la jerarquía de una request.)
- Si un span no declara padre, ¿de quién cuelga en la UI?
- ¿Qué entidad agrupa los traces de varios turnos de una misma conversación?
Comprueba tu respuesta
- El
parent_span_id(en términos OpenTelemetry; en la API de Langfuse,parentObservationId). Es el puntero que enlaza cada span con el que lo originó. Sin él, no hay árbol, solo una lista. · fuente: docs.datadoghq.com/llm_observability/terms; langfuse.com/docs/observability/data-model. - De la raíz del trace. Un span sin padre se adjunta al span raíz por defecto. Esto es exactamente lo que rompe la jerarquía — lo veremos en 5.4.
- La session (
session_id). Sessions > Traces > Spans: la sesión agrupa los traces de una conversación multi-turno. · fuente: getmaxim.ai; matiz OTel: en OTel puro la sesión es el atributogen_ai.conversation.id, no una entidad propia.
5.4El concepto
El árbol de un agente
Un agente RAG+tools no ejecuta una secuencia plana. Ejecuta un árbol. El corpus lo describe así: una traza típica tiene 3-4 niveles. Van de root (la request) → agente/orquestador → retrieval + llamada al LLM + tool call → y, si hace falta, sub-spans (consultar el vectorDB, rerank, formatear). · fuente: getmaxim.ai + agenta.ai.
Para Aurora, una request real anida así:
trace: "soporte-aurora" (root — la request del cliente)
└─ agente "responder" (el orquestador: dirige el bucle)
├─ retrieval "consultar_politica" (paso RAG: query + docs + scores)
├─ generation "claude / turno 1" (el LLM decide: ¿llamo a una tool?)
├─ tool "buscar_pedido" (ejecuta la herramienta que pidió el LLM)
└─ generation "claude / turno 2" (el LLM redacta la respuesta final)
La forma del árbol es la causalidad. Que buscar_pedido sea hijo del agente, después del generation del turno 1, dice algo verdadero: el LLM pidió esa herramienta y luego el agente la ejecutó. Aplana ese árbol y borras esa frase.
El span retrieval: por qué merece su propio nivel
El span de retrieval no es un span cualquiera. Captura la query, los documentos recuperados con sus scores, la versión del índice y los metadatos del chunk. Sin ese span, un "contexto incorrecto" es invisible: ves una respuesta mala, pero no si nació de recuperar el chunk equivocado. · fuente: dev.to/kuldeep_paul/the-rag-debugging-playbook (confianza media; consistente con los atributos retrieval de OTel).
Por eso consultar_politica —el paso RAG de Aurora— necesita ser un span propio, anidado bajo el agente, no fundido con la llamada al LLM.
El bug del span huérfano
Aquí está el fallo que viste en 5.1, con nombre. Un span huérfano es una observation que se crea sin declarar padre: su parentObservationId queda en null. La consecuencia es mecánica: se adjunta a la raíz del trace, rompiendo la jerarquía y, con ella, el error analysis. · fuente: github.com/orgs/langfuse/discussions/9116.
Normalízalo: es el fallo nº1 al instrumentar un agente a mano. No es descuido tuyo ni señal de que esto se te dé mal. Pasa porque cada función, vista en aislado, parece correcta — tiene su @observe, captura su input y su output. El defecto no vive en ninguna función; vive en la relación entre ellas, que es justo lo invisible cuando miras una a una. Lo verás aparecer una y otra vez; aprender a reconocerlo de un vistazo es media batalla.
La analogía: es como una nota a pie de página sin número de referencia. La nota existe, el texto existe, pero nadie sabe a qué frase pertenece. (Dónde falla la analogía: una nota huérfana es inofensiva; un span huérfano hace que el resto del árbol mienta sobre la secuencia de causas.)
Lo que NO es un span huérfano
No confundas dos cosas:
- Un span huérfano declara
parentObservationId: null→ se va a la raíz. Está mal anidado. - Un span raíz legítimamente no tiene padre — es el techo del árbol. Está bien así.
La diferencia clave: un huérfano debería tener padre y no lo tiene; la raíz no debe tenerlo. Mismo "sin padre", significados opuestos.
Sesiones multi-turno
Una conversación con Aurora tiene varios turnos. Cada turno es un trace; el conjunto es una session. Asignas el mismo session_id a todos los traces de la conversación y la UI los agrupa, en orden, bajo un mismo hilo. Sin sesión, ves requests sueltas y pierdes el contexto: que el cliente preguntó por el pedido después de que le negaras el reembolso. · fuente: getmaxim.ai (Sessions > Traces > Spans).
5.5Míralo funcionar
Vamos a instrumentar responder() entero con el anidamiento correcto. Es código denso: un bucle de tool-use con observabilidad incrustada.
Antes de leer línea a línea, lee el bloque entero una vez de corrido. Lo difícil no es ninguna línea suelta. Es ver cómo el contexto de Langfuse se propaga del padre a los hijos sin que pases el padre a mano. Primero hazte una idea de la forma; luego desmenuzamos las piezas.
Antes: la versión que produce huérfanos
Esto es lo que tenías en 5.1. Cada función decorada por separado, sin que nada las anide:
1# aurora_agent.py — versión que PRODUCE huérfanos (el antipatrón)
2from langfuse import observe
3
4@observe()
5def consultar_politica(tema: str) -> str: # paso RAG
6 ...
7
8@observe()
9def buscar_pedido(order_id: str) -> dict:
10 ...
11
12# responder() llama a las tools, pero NO abre un span que las contenga.
13# Cada @observe arranca su propio span de raíz → todos huérfanos, en fila.
14def responder(mensaje: str, historial: list[dict]) -> str:
15 ...El problema no se ve aquí: cada función está bien. El problema es la ausencia de un span padre que envuelva el bucle del agente. Sin ese contenedor, no hay a quién colgar los hijos.
Después: la versión jerárquica
Ahora la versión que cuenta la historia real. Léela entera primero.
1# aurora_agent.py — versión con jerarquía CORRECTA (Langfuse SDK Python v4)
2from langfuse import observe, get_client
3from anthropic import Anthropic
4
5client = Anthropic()
6langfuse = get_client()
7
8@observe(as_type="retrieval") # span de tipo retrieval (el paso RAG)
9def consultar_politica(tema: str) -> str:
10 docs = kb.buscar(tema) # recupera de la base de conocimiento
11 # Enriquece el span con lo que hace depurable un fallo de retrieval:
12 langfuse.update_current_observation(
13 input={"tema": tema},
14 output={"docs": [d.id for d in docs], "scores": [d.score for d in docs]},
15 metadata={"indice_version": kb.version},
16 )
17 return "\n".join(d.texto for d in docs)
18
19@observe(as_type="tool")
20def buscar_pedido(order_id: str) -> dict:
21 return crm.estado(order_id)
22
23@observe(as_type="tool")
24def escalar_a_humano(motivo: str) -> str:
25 return tickets.abrir(motivo)
26
27@observe() # ← este @observe abre el span PADRE del agente
28def responder(mensaje: str, historial: list[dict]) -> str:
29 mensajes = historial + [{"role": "user", "content": mensaje}]
30
31 while True: # bucle de tool-use multi-turno
32 with langfuse.start_as_current_observation( # span hijo: una llamada al LLM
33 name="claude-turno", as_type="generation"
34 ) as gen:
35 resp = client.messages.create(
36 model="claude-sonnet-4-6", max_tokens=1024,
37 tools=TOOLS, messages=mensajes,
38 )
39 gen.update(
40 input=mensajes, output=resp.content,
41 usage_details={"input_tokens": resp.usage.input_tokens,
42 "output_tokens": resp.usage.output_tokens},
43 metadata={"finish_reason": resp.stop_reason},
44 )
45
46 if resp.stop_reason != "tool_use": # el LLM ya no pide tools
47 return resp.content[0].text # → respuesta final
48
49 mensajes.append({"role": "assistant", "content": resp.content})
50 for bloque in resp.content: # ejecuta cada tool pedida
51 if bloque.type == "tool_use":
52 fn = {"buscar_pedido": buscar_pedido,
53 "consultar_politica": consultar_politica,
54 "escalar_a_humano": escalar_a_humano}[bloque.name]
55 resultado = fn(**bloque.input) # ← el span de la tool cuelga del agente
56 mensajes.append({"role": "user", "content": [
57 {"type": "tool_result", "tool_use_id": bloque.id,
58 "content": str(resultado)}]})Y el punto de entrada, que abre la sesión multi-turno:
1# Cada turno = un trace; todos comparten session_id → la UI los agrupa.
2from langfuse import propagate_attributes
3
4def turno(mensaje: str, historial: list[dict], session_id: str, user_id: str) -> str:
5 with langfuse.start_as_current_observation(
6 as_type="span", name="soporte-aurora", input=mensaje
7 ) as root:
8 with propagate_attributes(user_id=user_id, session_id=session_id):
9 salida = responder(mensaje, historial)
10 root.update(output=salida)
11 return salidaPor qué funciona: el context manager
Aquí está la pieza que pediste mirar de cerca. Self-explanation — antes de leer la respuesta, intenta tú: ¿por qué buscar_pedido() se cuelga del agente sin que le pasemos el padre por argumento?
La respuesta es la propagación de contexto de OpenTelemetry, la misma que viste en L3. Cuando entras en el with langfuse.start_as_current_observation(...), ese span se vuelve el span actual del contexto de ejecución. Cualquier @observe o start_as_current_observation que se abra dentro de ese bloque lee el contexto, encuentra al span actual y lo toma como padre — automáticamente. · fuente: langfuse.com/docs/sdk/python/decorators (el @observe usa context propagation OTel para el parentesco).
Dos métodos que aparecen en el código, presentados antes de usarlos:
- El objeto que devuelve el
with(aquírootygen) expone.update(...): con él añadesinput,output,usage_detailsometadataa ese span después de abrirlo. Lo usas cuando los valores solo existen tras ejecutar el cuerpo (p. ej. eloutputdel LLM). propagate_attributes(user_id=..., session_id=...)es un context manager que fija esos atributos en la traza completa mientras dura su bloque. Así todos los spans que se creen dentro quedan etiquetados con eseuser_idysession_id, y la UI los agrupa en una sesión.
Por eso el orden importa de verdad:
@observe()sobreresponder()abre el span del agente y lo pone como actual.- Dentro, cada llamada a
consultar_politica/buscar_pedidose ejecuta con ese span en contexto → se anidan bajo él. Sin huérfanos. - En la versión "antes",
responder()no estaba decorado: no había span actual cuando se llamaba a las tools → cada una arrancaba de raíz → huérfanas.
La diferencia entre las dos versiones es una línea: el @observe() sobre responder(). Esa línea es la que crea el padre del que cuelga todo lo demás.
5.6Hazlo tú
Andamiaje decreciente: primero analizas una traza para decidir si hay huérfano; luego instrumentas un flujo nuevo desde cero.
Ejercicio A — ¿Sale huérfano escalar_a_humano?
Este código instrumenta el flujo de escalado de Aurora. La tool escalar_a_humano se ejecuta fuera del with gen, después de cerrarlo. Analiza si saldrá huérfana en la UI y por qué. Si no hay problema, explica qué lo previene (pista: ¿lleva escalar_a_humano su @observe?, ¿qué span está activo cuando se la llama?). Si lo hubiera, corrígelo.
1@observe()
2def gestionar_queja(mensaje: str) -> str:
3 with langfuse.start_as_current_observation(
4 name="claude-turno", as_type="generation"
5 ) as gen:
6 resp = client.messages.create(model="claude-sonnet-4-6",
7 max_tokens=512, tools=TOOLS,
8 messages=[{"role": "user", "content": mensaje}])
9 # El LLM pide escalar. Ejecutamos la tool FUERA, después del with:
10 ticket = escalar_a_humano(motivo=mensaje) # ← ¿sale huérfano? ¿por qué?
11 return ticketElaborative interrogation — antes de mirar la solución: ¿qué span está actual en el contexto cuando se ejecuta escalar_a_humano, dado que gestionar_queja sí lleva @observe()?
Solución
No sale huérfano. El código es correcto. Lo que lo previene son dos cosas que ya están en su sitio:
escalar_a_humanolleva su propio@observe(as_type="tool")(definido en 5.5), así que sí se crea un span para ella.- La llamada ocurre dentro del cuerpo de
gestionar_queja, cuyo@observe()mantiene el span del agente como actual en el contexto. Estar fuera delwith genda igual: elwithsolo acota el span del LLM, no el del agente. El span del agente sigue activo después delwith.
Resultado: el span de la tool lee el contexto, encuentra al agente como span actual y se anida bajo él. Jerarquía correcta.
¿Cuándo sí habría huérfano? Si escalar_a_humano se llamara sin ningún @observe/start_as_current_observation activo en el contexto — por ejemplo, si gestionar_queja no estuviera decorada. Entonces la tool arrancaría su propio span de raíz. Corrección en ese caso: decorar la función contenedora con @observe() (o abrir un span con start_as_current_observation) para que exista un padre vivo cuando se ejecuta la tool. (Aparte: si quitaras el @observe de escalar_a_humano, el problema sería el contrario — no aparecería span alguno para la tool.)
Feedback: si tu primer instinto fue "el problema es que está fuera del with", buen reflejo de causalidad — pero el with gen solo es el padre del span de generación, no del agente. El padre del agente es el @observe() de la función entera, y sigue activo después del with. La diferencia que importa: qué span está actual en el contexto cuando se ejecuta la tool, no en qué bloque esté escrita la línea.
Ejercicio B — Instrumenta un flujo nuevo (desde cero)
Aurora añade un flujo de devoluciones. El agente hace cuatro pasos. Primero consulta la política de devoluciones (RAG). Luego llama al LLM. Si procede, busca el pedido. Y vuelve a llamar al LLM para redactar. Instrumenta gestionar_devolucion(mensaje, session_id, user_id) con la jerarquía correcta: root → agente → retrieval + 2× generation + tool, dentro de una sesión.
No hay solución cerrada: reutiliza el patrón de 5.5. Verifica tu trabajo contra esta checklist.
- La función del agente lleva
@observe()(o abre un span constart_as_current_observation) → existe el padre. - El paso RAG es un span propio con
as_type="retrieval"que captura query, docs y scores. - Cada llamada al LLM es un
generationcon tokens (usage_details) yfinish_reason. - El root fija
session_idyuser_idconpropagate_attributes(...)envolviendo el cuerpo. - En la UI, cero spans cuelgan de la raíz salvo el span del agente. Si ves otro a nivel raíz, es huérfano.
5.7Comprueba
Sin pistas. Gate de maestría: localizar y corregir el huérfano.
Esta es la representación (no la UI, pero equivalente) de una traza del flujo de pedidos de Aurora:
trace: soporte-aurora
├─ agente "responder"
│ ├─ generation "claude-turno-1" (finish_reason: tool_use)
│ └─ generation "claude-turno-2" (finish_reason: end_turn)
└─ tool "buscar_pedido" (parentObservationId: null)
- Identifica el span huérfano y di cómo lo reconociste.
- Corrige su parentesco: ¿quién debería ser su padre y por qué?
- Explica qué pregunta de error analysis no podrías responder con la traza tal como está.
Criterio de corrección + feedback
- El huérfano es
tool "buscar_pedido": cuelga del trace, al mismo nivel que el agente, conparentObservationId: null. Lo reconoces porque una tool del agente nunca debería ser hermana del agente — debería estar dentro de él. - Su padre debe ser el agente
responder. El LLM la pidió (turno 1 cerró confinish_reason: tool_use), así que el agente la ejecutó: causalmente, la tool es hija del agente. La corrección de instrumentación: ejecutarbuscar_pedidomientras el span del agente está activo en el contexto, para que se anide bajo él en lugar de arrancar de raíz. - No podrías responder: "¿el
buscar_pedidoocurrió a causa de este turno del LLM, o fue otra cosa?" Aplanada, la tool flota sin causa. Anidada, queda claro que el turno 1 la provocó y el turno 2 usó su resultado.
Feedback formativo: si acertaste 1 y 2 pero te costó el 3, dominas la mecánica (reconocer y reparar) pero aún no el porqué — y el porqué es lo que hace que estas trazas sirvan para el análisis de N1. Vuelve a 5.4 "El árbol de un agente": la forma del árbol es la causalidad. Si fallaste el 1, repasa la distinción huérfano-vs-raíz en 5.4: ambos "sin padre", significados opuestos.
Gate: necesitas el 1 y el 2 correctos para considerar superado este punto.
5.8Conecta
Ya produces trazas explotables del agente real. Eso no es un ejercicio de juguete: estas trazas SON el insumo del checkpoint C0.
Recuerda la rúbrica de C0 (de la arquitectura del nivel). Su dimensión 2 es literal: jerarquía parent-child correcta, cero spans huérfanos en raíz. Lo que acabas de practicar es exactamente lo que se evalúa ahí. Su dimensión 4 es la sesión multi-turno reconstruible — el session_id de 5.5.
Y más allá del checkpoint: en N1 harás error analysis sobre estas trazas. Leerás 50-100 conversaciones reales y anotarás el primer fallo de cada una. Ese trabajo es imposible sobre spans aplanados. Necesitas el árbol para ver dónde nació el fallo: en el retrieval, en el LLM o en la orquestación. La jerarquía que arreglas hoy es la que hace legible el análisis de mañana.
¿Dónde aplicarías esto en tu trabajo? En cualquier agente con tool-calling: copilotos internos, pipelines RAG, asistentes de soporte. El patrón es siempre el mismo — un span padre que envuelve el bucle, hijos para retrieval/LLM/tool, sesión para el multi-turno. El span huérfano te acechará en todos; ahora lo reconoces.
Y cierra el arco que abrimos en L1: cuando Aurora prometió un reembolso inexistente, no tenías datos, tenías vibes. Con la traza jerárquica de hoy, podrías abrir esa conversación y leer la cadena de causas paso a paso.
5.9Reflexiona
Tómate un minuto. Responder esto por escrito consolida lo aprendido mejor que releer.
- ¿Qué aprendiste? Resume en una frase por qué un span huérfano hace que una traza "completa" siga sin servir.
- ¿Qué sigue sin estar claro? ¿Tienes claro cuándo usar
@observevsstart_as_current_observation? ¿Y cómo se propaga el contexto entre funciones? Si no, vuelve a 5.5. - ¿Qué harías distinto? La próxima vez que instrumentes un agente, ¿empezarías por las funciones sueltas o por el span padre que las envuelve? ¿Por qué?
Esto requiere práctica. La intuición de "dónde está el padre" llega instrumentando, no leyendo. En L6 generarás ≥100 trazas reales y verás el patrón —y los huérfanos— a escala.
5.10Referencia rápida
| Quieres… | Usa… | Resultado |
|---|---|---|
| Abrir el span padre del agente | @observe() sobre responder() | span del agente, actual en el contexto |
| Anidar un hijo automáticamente | llamar a una función @observe dentro del padre | hijo cuelga del padre (context propagation) |
| Anidar un hijo explícito | with langfuse.start_as_current_observation(as_type="span", ...) | bloque acotado, hijo del span actual |
| Marcar el paso RAG | @observe(as_type="retrieval") | span retrieval (query, docs, scores) |
| Marcar una tool | @observe(as_type="tool") | span tool (nombre, args, resultado) |
| Marcar una llamada al LLM | start_as_current_observation(as_type="generation") | span generation (tokens, finish_reason) |
| Añadir datos a un span ya abierto | obj.update(output=..., usage_details=..., metadata=...) | enriquece ese span con valores tardíos |
| Agrupar turnos (sesión + usuario) | with propagate_attributes(user_id=..., session_id=...) en el root | sesión multi-turno en la UI |
El reflejo que te llevas: si un span cuelga de la raíz y no es el agente, es huérfano → busca qué span debería haber estado activo en el contexto cuando se creó. El padre no se pasa por argumento; se hereda del contexto.