Detener las herramientas de forma condicional: los artefactos superan a return_direct en LangGraph.
Detener/continuar por llamada a partir de artefactos de la herramienta: utilizar middleware ReAct y create_agent desarrollados manualmente, cuando return_direct estático no pueda tomar una decisión.
Cuando return_direct estático es la herramienta incorrecta
Cualquiera que haya lanzado un agente de llamada a herramientas de LangGraph se ha encontrado con return_direct=True: se omite enviar el resultado de la herramienta a través del modelo y se finaliza el bucle. Parece perfecto hasta que la decisión de detenerse debe depender del resultado de esta invocación, y no de qué herramienta esté registrada.
Este tutorial choca contra ese problema, construye manualmente un bucle ReAct mínimo y luego recrea el mismo comportamiento en create_agent utilizando middleware. La respuesta breve es: sí, funciona; pero el primer enfoque con middleware puede fallar por razones sutiles relacionadas con el orden de los mensajes, y no porque el framework descarte silenciosamente las actualizaciones.
Referencias de versión para mayor claridad: “legacy” se refiere a langgraph==0.6.6 (la última línea antes de que create_react_agent fuera reemplazado por create_agent); “current” significa langchain==1.4.2 que utiliza langgraph==1.2.11. Cada agente ReAct es un ciclo modelo ↔ herramientas; aquí el enfoque está en el enlace desde las herramientas hacia el modelo, y en cuándo ese enlace debería desaparecer para una llamada específica.
El requisito que rompió el ciclo por defecto
La integración de una herramienta de búsqueda parecía algo normal: el modelo llama a search(query), lee los resultados, responde o continúa. Dos características hicieron que el ciclo estándar no fuera adecuado.
Cuando se encontraba un resultado, la herramienta devolvía una gran página en formato JSON. Volver a incluir ese contenido en el contexto para otro procesamiento por parte del modelo es costoso y, por lo general, inútil; si la búsqueda ya había respondido a la pregunta, una segunda llamada suele limitarse a reformularla con un costo elevado.
Cuando falla el intento, los errores se dividen en opuestos: un verdadero callejón sin salida (no hay nada con lo que comparar; intentarlo de nuevo es un desperdicio) frente a un tiempo de espera transitorio o error 503 (en este caso tiene sentido intentarlo de nuevo). Por lo tanto, la regla varía según cada llamada: éxito → detenerse; fallo reintentable → continuar; fallo fatal → detenerse; la decisión se toma a partir del contenido de la llamada, no del tipo estático de la herramienta.
Por qué return_direct no puede indicar eso
En langgraph.prebuilt.chat_agent_executor de la versión heredada fijada, el enrutamiento es el siguiente:
should_return_direct = {t.name for t in tool_classes if t.return_direct}
...
def route_tool_responses(state):
for m in reversed(_get_state_value(state, "messages")):
if not isinstance(m, ToolMessage):
break
if m.name in should_return_direct:
return END
...
return entrypoint
should_return_direct se calcula una sola vez a partir del atributo .return_direct de la herramienta durante la creación del grafo. Esta marca indica que “esta herramienta siempre termina el bucle”. No cuenta con un modo específico para cada llamada. Se trata de una incompatibilidad de categoría, no de un defecto en la marca misma.
Los hilos del foro reflejan el mismo problema: las herramientas voluminosas obligan a realizar llamadas adicionales innecesarias al modelo, y los mantenedores suelen sugerir conectar manualmente tool_node → END. Existen discusiones separadas sobre las actualizaciones de Command y return_direct (incluyendo langgraph#5496) que aportan contexto; el argumento principal no requiere un error, ya que las banderas estáticas simplemente no pueden representar resultados dinámicos.
La estructura del flujo de control
Dicho de forma sencilla:
Tool call
├── success or unfixable failure → stop, use the tool's result
└── fixable failure → let the model decide
Hay dos destinos, elegidos cada vez que se realiza una llamada. El resto de este texto implementa esa estructura en dos ocasiones: una de forma manual y otra con middleware.
Canales separados: content y artifact
El @tool de LangChain ya separa lo que ve el modelo de lo que recibe el código de la aplicación a través de response_format="content_and_artifact". La herramienta devuelve (content, artifact). ToolMessage.content va al modelo; artifact permanece en el mensaje para la orquestación y nunca llega al camino del LLM.
@tool(response_format="content_and_artifact")
def search(query: str):
return "the content the LLM sees", {"stop": True, "debug": "extra stuff"}
node = ToolNode([search])
result = node.invoke(state)
msg = result["messages"][0]
# msg.content -> "the content the LLM sees"
# msg.artifact -> {"stop": True, "debug": "extra stuff"}
El patrón deseado:
tool result
│
┌──────────┴──────────┐
↓ ↓
content artifact
│ │
↓ ↓
model router
│
continue / stop
frente a lo que return_direct reduce a una única respuesta estática:
return_direct content_and_artifact
│ │
└── tool content → model
definition artifact → routing metadata
→ routing
Un flag fijo que intenta responder tanto a “¿qué ve el usuario?” como a “¿debe terminar el bucle?”, o bien dos canales, cada uno respondiendo a una pregunta.
ReAct tradicional construido manualmente
En lugar de inventar un bucle, reduzca create_react_agent al esqueleto: mantenga los nombres de los nodos y el ciclo, elimine los ganchos de prompt, los formatos de respuesta estructurados, la resolución dinámica del modelo, el control de pasos restantes, los puntos de verificación, las interrupciones y el envío paralelo mediante Send.
Quedan tres nodos:
agent— llama al modelo; si existentool_calls, continúa, de lo contrario termina.tools— un simpleToolNode; agrega los resultados deToolMessage.finalize— no hay llamada al modelo; envuelve literalmente el texto final elegido por la herramienta como unAIMessage.
Dos enrutadores:
should_continuedespués deagent: llamadas a herramientas →tools, de lo contrarioEND.
route_after_tools después de tools: se inspecciona el artefacto y se vuelve al agent o se pasa a finalize (sustituyendo la verificación original del conjunto return_direct estático).finalize representa un compromiso intencional: omite la llamada al LLM y muestra exactamente lo que produjo la herramienta, pero esta debe generar texto presentable y el modelo no puede fusionar este resultado con otras pruebas. En los casos en que “la salida de la herramienta ya es la respuesta”, este compromiso resulta ventajoso.
Resultados de búsqueda como metadatos, no comandos del grafo
La herramienta de búsqueda establece artifact["stop"] basándose en lo que ocurrió en esta llamada específica. stop es metadato de la aplicación, no un campo reservado por LangChain. Lo crucial es que la herramienta informa un resultado; la orquestación lo interpreta. Esto permite que el enrutamiento sea modular con políticas que la herramienta nunca ve.
@tool(response_format="content_and_artifact")
def search(query: str) -> tuple[str, dict]:
"""Search a knowledge base for information about the query."""
outcome = force_outcome or rng.choices(
list(resolved_weights), weights=list(resolved_weights.values())
)[0]
if outcome == "retryable":
return rng.choice(_RETRYABLE_MESSAGES), {"stop": False} if outcome == "fatal":
return rng.choice(_FATAL_MESSAGES), {"stop": True} query_lower = query.lower()
for topic, page in _INDEX.items():
if topic in query_lower or query_lower in topic:
return page, {"stop": True}
return "Nothing in the index overlaps with this query.", {"stop": True}
Tres casos:
- Exito con contenido real →
stop=True(otra pasada del modelo solo reformularía el texto). - Fallo que se puede intentar de nuevo →
stop=False(se le da al modelo otra oportunidad). - Fallo fatal →
stop=True(el bucle desperdicia tokens intentando lo mismo sin obtener respuesta).
stop=False no significa “intentar de nuevo ahora”; solo evita un cierre inmediato. El modelo sigue podiendo decidir volver a realizar la búsqueda, probar algo distinto o responder. El enrutador se reduce a una verificación de artefactos en una sola línea:
def route_after_tools(self, state: AgentState) -> str:
last_message = state["messages"][-1]
if (
isinstance(last_message, ToolMessage)
and isinstance(last_message.artifact, dict)
and last_message.artifact.get("stop")
):
return "finalize"
return "agent"
Un mecanismo que exige "success" | "retryable" | "fatal" hace que los caminos sean deterministas en un modelo Groq real: los casos de éxito y fatal van a tools → finalize → END sin realizar otra llamada al modelo; los casos que se pueden intentar de nuevo regresan a agent.
Límite: cuando la ruta también depende de los pasos restantes, las cuentas de intentos anteriores o las banderas de autenticación, el artefacto por sí solo es insuficiente; el enrutador debe leer el estado del grafo más amplio. content_and_artifact resulta útil cuando el resultado de esta herramienta determina el siguiente salto.
Más allá de la búsqueda
Cualquier herramienta cuyo resultado sea más detallado que simplemente “ok/fail” es adecuada: una herramienta write_record puede establecer already_applied; un monitor puede definir progress para una interfaz de usuario que el modelo nunca describe. El artefacto es solo datos, utilizable desde un nodo condicional, middleware o una interfaz de usuario que nunca interactúa con el grafo. return_direct es una decisión de enrutamiento integrada en la definición; no cuenta con un modo de “transportar información y decidir más tarde”.
La misma idea en create_agent
Pins: Python 3.12, langchain==1.4.2 / langgraph==1.2.11, langchain-groq==1.1.3. create_agent reemplaza el grafo manual por conexiones declarativas además de middleware.
Primer instinto: wrap_tool_call, devolver Command(goto=END) cuando se establece stop.
class StopOnArtifact(AgentMiddleware):
def wrap_tool_call(self, request, handler):
result = handler(request)
if isinstance(result, ToolMessage):
stop = isinstance(result.artifact, dict) and result.artifact.get("stop")
if stop:
relay = AIMessage(content=str(result.content))
return Command(goto=END, update={"messages": [result, relay]})
return Command(goto="model", update={"messages": [result]})
return result
En la versión probada, ese camino solo activa el atajo cuando END ya es accesible de la forma en que lo conecta return_direct. El middleware puede calcular stop=True mientras el bucle sigue devolviendo la solicitud al modelo hasta que este finalmente responde sin necesidad de herramientas. Eso parecía ser el caso en #5496 con las versiones actuales, hasta que dos variantes programadas demostraron lo contrario:
A: update={"messages": [result]} -> stops correctly
B: update={"messages": [result, relay]} -> loops back to the model
La versión A funciona. La versión B agrega un relé AIMessage sin tool_calls en la misma actualización. La verificación de salida recorre hacia atrás hasta el último AIMessage para evaluar return_direct; encuentra el relé, no ve llamadas a herramientas y sigue en bucle. Se aplicó el Command: el orden de los mensajes ocultó al mensaje original con llamadas a herramientas de la verificación de salida. No se trata de una actualización descartada, ni es #5496.
Incluso después de solucionar eso, el enfoque utilizado incluye before_model en su lugar: este no requiere en absoluto return_direct.
class StopOnArtifact(AgentMiddleware):
@hook_config(can_jump_to=["end"])
def before_model(self, state, runtime):
last = state["messages"][-1]
if isinstance(last, ToolMessage) and isinstance(last.artifact, dict) and last.artifact.get("stop"):
relay = AIMessage(content=str(last.content))
return {"jump_to": "end", "messages": [relay]}
return None
before_model se ejecuta justo antes de cada llamada al modelo; en iteraciones posteriores, lo hace inmediatamente después de las herramientas. @hook_config(can_jump_to=["end"]) permite saltar a END independientemente de cualquier indicador de herramienta. Devolver {"jump_to": "end", ...} representa una simple actualización de estado que se lee en los bordes del grafo. Un gancho detecta tanto el artefacto como construye el relé AIMessage; la tarea se divide entre route_after_tools y finalize.
Los resultados forzados coinciden con el grafo construido manualmente: éxito y cortocircuito fatal con el contenido exacto de la herramienta; los casos reintentables vuelven a iniciar la fase del modelo.
Conclusión
content_and_artifact no fue diseñado como una primitiva de enrutamiento. Separa a los públicos destinatarios: el contenido visible para el modelo frente a la metadatos exclusivos de la aplicación, y esa misma separación permite determinar de manera clara si “deberíamos detenernos?” sin pedirle al modelo que analice el flujo de control. return_direct mezcla la presentación y la terminación en una sola bandera estática, lo que provoca errores precisamente cuando esas respuestas deben ser diferentes en cada llamada.
Si un caso de uso requiere una detención condicional, mantenga separados el resultado de la herramienta y la decisión de enrutamiento: exponga los metadatos junto con la respuesta y deje que la orquestación tome la decisión. content_and_artifact ya proporciona ese canal.
Notas de diseño que los equipos olvidan después de la primera prueba exitosa
La detención condicional parece resuelta una vez que se superan los tres resultados forzados. La producción añade concurrencia: dos llamadas a la herramienta en una sola iteración del modelo, o un lote de búsquedas donde solo una debería finalizarse. Hay que decidir si cualquier factor de detención interrumpe todo el proceso, si todos deben coincidir, o si aplica un orden de prioridad. Se debe codificar esa política en el router, no en conocimientos informales.
La observabilidad debe mostrar el artefacto junto a ToolMessage sin registrar secretos del content. Cuando se activa la detención, hay que registrar qué regla se aplicó: éxito, error fatal o anulación de la política, para que el soporte técnico pueda explicar por qué el asistente no “pensó más tiempo”. Esto debe combinarse con un control de tokens: el objetivo principal de finalizar al tener éxito es reducir las llamadas al modelo; los paneles de control deben demostrar esos ahorros.
Tenga cuidado al trasladar patrones entre las versiones menores de LangGraph. Los nombres de los ganchos del middleware, la accesibilidad de Command y las verificaciones de salida directa han cambiado en la transición de la versión 0.6 a 1.x. Mantenga una prueba de caracterización que obligue a un resultado de éxito, reintentable o fatal en cada actualización. Si un gancho entra repentinamente en bucle infinito, sospeche de la estructura de la lista de mensajes antes de reportar errores en el framework: los mensajes retransmitidos son un problema recurrente.
Finalmente, evite insertar banderas de control en el content “solo esta vez”. En cuanto el modelo detecte stop=true en el texto, podría describir el flujo de control o mostrar códigos internos a los usuarios. El artefacto existe para que la orquestación pueda tomar decisiones de manera decisiva, mientras el canal orientado al usuario permanece limpio.
Asociar el patrón a frameworks vecinos
Esa misma división entre contenido y control también se observa fuera de LangGraph. Cualquier entorno de ejecución de agentes que integre la salida estándar de las herramientas en el único canal de mensajes acaba creando marcadores ad hoc, envoltorios JSON o metadatos adicionales. Prefiera un canal lateral oficial cuando la plataforma lo ofrezca; cree un envoltorio documentado cuando no lo haya; nunca confíe en que el modelo ignore los tokens de control ocultos en el texto.
Si un equipo debe soportar tanto los gráficos heredados create_react_agent como las nuevas aplicaciones create_agent, mantenga idéntico el contrato de artefactos de la herramienta y solo cambie la implementación del enrutador. De esta manera, los cambios de versión se limitan a las pruebas de orquestación. Cuando el middleware crece —verificaciones de autenticación, límites de gasto, eliminación de PII— ejecute esos complementos antes de interpretar stop, para que un rechazo de política no se confunda con un cortocircuito exitoso. El orden de los complementos forma parte del comportamiento público del agente, aunque parezca algo técnico interno.
Documento para futuros lectores que explica por qué existe finalize (o el salto before_model): se trata de una elección explícita del producto que permite que el texto generado por la herramienta sea visible para el usuario sin pasar por una fase de pulido adicional. Si más adelante el producto desea un estilo de resumen oral, basta con reintroducir un nodo de modelo en la ruta de finalización en lugar de sobrecargar la herramienta para que genere dos tonos al mismo tiempo. Separar “calcular resultado” de “narrar resultado” permite que las herramientas se reutilicen en clientes de voz, chat y API.
Intuición práctica sobre detenerse versus continuar
Imagínese una herramienta de pago que a veces devuelve un recibo completo, otras veces un mensaje de “tiempo de espera agotado del procesador de pagos” y en otras ocasiones “tarjeta rechazada permanentemente”. Estos tres casos se corresponden perfectamente con el estado de éxito, el estado que permite reintentar y el estado fatal. El content del recibo puede ser HTML listo para el cliente; el archivo resultante contiene { "stop": true, "reason": "completed" }. En caso de tiempo de espera agotado, se deja una breve explicación en content para el modelo y { "stop": false, "reason": "transient" } en el archivo. El rechazo permanente detiene el bucle con un mensaje seguro para el usuario y { "stop": true, "reason": "fatal" } para que el agente no siga intentando procesar la transacción. El mismo esquema se puede aplicar a búsquedas, creación de tickets o exportación de documentos sin tener que reescribir el enrutador, solo la asignación de funciones de la herramienta.
Hábitos de verificación complementarios
Mantenga el arnés de resultado forzado en CI con un modelo de chat falso que emita llamadas a herramientas predeterminadas. Las ejecuciones reales de Groq sirven para verificar la confiabilidad punto a punto ocasionalmente, no en cada commit. Asegúrese de las secuencias exactas de rutas: qué nodos se ejecutaron, si ocurrió una segunda llamada al modelo, y de que el contenido final sea igual al contenido de la herramienta en las rutas de detención. Cuando alguien “simplifica” el middleware e introduce nuevamente return_direct, el arnés debería fallar de manera evidente. Almacene transcripciones de referencia junto al arnés para poder comparar las diferencias en caso de fallos. La detención condicional es un contrato de comportamiento; las pruebas son la forma en que dicho contrato se mantiene a pesar de los refactores en las versiones de LangGraph y entre ingenieros que solo revisan brevemente las notas originales del diseño.
Si más adelante el producto necesita que el modelo combine los resultados de las herramientas con las iteraciones anteriores, incluso en caso de éxito, agregue un nodo opcional de pulido después de “finalize” en lugar de eliminar el circuito cortocircuitado. Las banderas de funcionalidad son mejores que las reescrituras: stop_mode=hard|polish|never permite que los experimentos continúen sin perder el contrato del artefacto. Mida el consumo de tokens en cada modo con el mismo conjunto de consultas antes de elegir uno por defecto.
Contrato del lector para adoptar este patrón
Copie el esquema del artefacto y las pruebas del enrutador antes de copiar el texto principal. El valor del ensayo radica en la separación de responsabilidades, no en la anécdota sobre la herramienta de búsqueda. Si su dominio utiliza etiquetas diferentes para los fallos, asómbelas a los mismos tres categorías y mantenga el enrutador simple. Resista la tentación de añadir una cuarta categoría hasta que un incidente real lo exija. En caso de duda, prefiera seguir con el modelo en lugar de detenerse bruscamente ante errores ambiguos; los cortocircuitos silenciosos que ocultan fallos parciales son peores que una llamada adicional al modelo, aunque sea económica, que explique la incertidumbre al usuario.
Envíe el conjunto de herramientas junto con el artículo para que los lectores puedan probar los casos límite en su propio entorno antes de confiar en este patrón en el tráfico real.
Lecturas relacionadas
- Herramientas Gate Agent por Usuario con LangChain Wrap Middleware — Un agente calculador, tres niveles de acceso: el middleware de tipo wrap sobrescribe la lista de herramientas según el user_type en tiempo de ejecución en lugar de clonar tres agentes.
- FastMCP Voice Stack Parte 2: Orquestación con LangGraph para Agentes Verbales — Añade estado de LangGraph, bordes condicionales, herramientas y verificadores para hacer que el servidor MCP de la Parte 1 funcione de una conversación a otra.
- LangChain @dynamic_prompt para instrucciones de sistema conscientes de la tarea — Crea un agente de recursos humanos que cambia las instrucciones del sistema para permisos, beneficios y administración según el contexto en tiempo de ejecución, manteniendo la autorización en el código de la aplicación.