
Tutorial LangGraph: tu primer agente multi-paso para automatizar soporte SaaS
Respuesta corta (60 segundos): LangGraph permite construir agentes con estado persistente y flujos condicionales en Python. Lo construís como un grafo: nodos son funciones (que típicamente llaman a un LLM), edges son transiciones (que pueden ser condicionales). En este tutorial armás un agente de soporte que clasifica un ticket entrante, decide si auto-responder o escalar, redacta la respuesta con Claude Sonnet, y permite human-in-the-loop para tickets sensibles. ~3 horas de implementación, USD 30-60/mes operando 5K tickets.
LangGraph es el framework que más rápido creció en 2025-2026 para construir agentes de IA en Python. La razón es práctica: combina lo bueno de LangChain (integraciones) con un modelo de ejecución más predecible (grafos explícitos vs. cadenas implícitas).
Este tutorial construye algo real: un agente de soporte SaaS que reemplaza el primer contacto con clientes. La estructura se traduce a casi cualquier caso de uso multi-paso (procesamiento de documentos, generación de reportes, onboarding automatizado).
Lo que vas a tener al final
Un proceso Python que:
- Recibe un ticket de soporte vía webhook.
- Lo clasifica con Claude Haiku (categoría + urgencia).
- Decide si auto-responde o escala a humano.
- Si auto-responde, redacta con Claude Sonnet usando docs internos como contexto.
- Si escala, abre un task con resumen en Notion/Linear.
- Persiste cada paso para que el flujo sobreviva crashes.
Stack: Python 3.11+, LangGraph, Anthropic SDK, Postgres para checkpointing.
Setup
~# crear venv y dependencias python -m venv .venv source .venv/bin/activate pip install langgraph langchain-anthropic psycopg python-dotenv # variables de entorno cat > .env <<'EOF' ANTHROPIC_API_KEY=sk-ant-... DATABASE_URL=postgresql://localhost/agent_dev EOF
Paso 1 · Definir el State
El State es lo que el grafo va acumulando mientras ejecuta. En LangGraph se define como un TypedDict:
~# agent/state.py from typing import TypedDict, Annotated, Sequence from langchain_core.messages import BaseMessage import operator class TicketCategory(TypedDict): category: str # "billing" | "technical" | "general" | "urgent" confidence: float reasoning: str class SupportAgentState(TypedDict): # Input ticket_id: str ticket_text: str customer_email: str # Computado por el agente classification: TicketCategory | None action: str | None # "auto_respond" | "escalate_human" | "ask_clarification" response_draft: str | None response_sent: bool # Historial messages: Annotated[Sequence[BaseMessage], operator.add] # Safety iterations: int
Por qué TypedDict y no Pydantic: LangGraph espera dicts. Pydantic agrega overhead innecesario aquí. Si querés validación más fuerte, usá Pydantic en los handlers que reciben el ticket inicial, no en el state interno.
Paso 2 · Nodo de clasificación
Cada nodo es una función que recibe el state actual y devuelve los campos que actualizó.
~# agent/nodes/classify.py from langchain_anthropic import ChatAnthropic from langchain_core.messages import HumanMessage, SystemMessage import json from agent.state import SupportAgentState, TicketCategory CLASSIFY_LLM = ChatAnthropic( model="claude-3-5-haiku-20241022", temperature=0, max_tokens=300, ) SYSTEM = """Sos un clasificador de tickets de soporte. Dado el texto del ticket, devolvé JSON con: - category: una de "billing", "technical", "general", "urgent" - confidence: 0.0 a 1.0 - reasoning: explicación corta (max 30 palabras) Solo JSON, sin texto adicional.""" def classify_ticket(state: SupportAgentState) -> dict: msg = CLASSIFY_LLM.invoke([ SystemMessage(content=SYSTEM), HumanMessage(content=state["ticket_text"]), ]) parsed: TicketCategory = json.loads(msg.content) return { "classification": parsed, "iterations": state.get("iterations", 0) + 1, }
Detalle clave: la función retorna solo los campos que cambia, no todo el state. LangGraph hace merge automáticamente.
Paso 3 · Routing condicional
Acá entra la magia de LangGraph. Definimos una función que decide la siguiente transición:
~# agent/routing.py from agent.state import SupportAgentState def route_after_classification(state: SupportAgentState) -> str: classification = state["classification"] cat = classification["category"] confidence = classification["confidence"] # Tickets urgentes siempre a humano if cat == "urgent": return "escalate" # Baja confianza en clasificación → escalar if confidence < 0.7: return "escalate" # Billing complejo → humano (compliance) if cat == "billing": return "escalate" # Resto: auto-respond return "respond"
Paso 4 · Nodo de generación de respuesta
~# agent/nodes/respond.py from langchain_anthropic import ChatAnthropic from langchain_core.messages import HumanMessage, SystemMessage from agent.state import SupportAgentState from agent.knowledge_base import retrieve_relevant_docs RESPONSE_LLM = ChatAnthropic( model="claude-3-5-sonnet-20241022", temperature=0.3, max_tokens=600, ) SYSTEM_TEMPLATE = """Sos un agente de soporte de {company}. Tono: amable, claro, conciso. Usá ÚNICAMENTE la información del contexto siguiente para responder. Si no podés responder con lo que tenés, decí que vas a escalarlo a un humano y NO inventes información. Contexto: {context}""" def generate_response(state: SupportAgentState) -> dict: docs = retrieve_relevant_docs(state["ticket_text"], top_k=5) context = "\n\n".join([d.content for d in docs]) system = SYSTEM_TEMPLATE.format(company="TuSaaS", context=context) msg = RESPONSE_LLM.invoke([ SystemMessage(content=system), HumanMessage(content=state["ticket_text"]), ]) return { "response_draft": msg.content, "action": "auto_respond", }
Paso 5 · Armar el grafo
Acá ensamblás todos los nodos en un StateGraph:
~# agent/graph.py from langgraph.graph import StateGraph, END from langgraph.checkpoint.postgres import PostgresSaver from agent.state import SupportAgentState from agent.nodes.classify import classify_ticket from agent.nodes.respond import generate_response from agent.nodes.escalate import escalate_to_human from agent.nodes.send import send_response from agent.routing import route_after_classification def build_graph(checkpointer=None): builder = StateGraph(SupportAgentState) # Registrar nodos builder.add_node("classify", classify_ticket) builder.add_node("respond", generate_response) builder.add_node("escalate", escalate_to_human) builder.add_node("send", send_response) # Entry point builder.set_entry_point("classify") # Edges builder.add_conditional_edges( "classify", route_after_classification, {"respond": "respond", "escalate": "escalate"}, ) builder.add_edge("respond", "send") builder.add_edge("escalate", END) # humano se hará cargo builder.add_edge("send", END) return builder.compile(checkpointer=checkpointer)
Paso 6 · Human-in-the-loop con interrupt
Para tickets sensibles, queremos que un humano apruebe la respuesta antes de enviarla. LangGraph soporta esto nativamente con interrupt_before:
~# agent/graph.py (modificado) def build_graph(checkpointer=None): # ... mismo código de arriba ... return builder.compile( checkpointer=checkpointer, interrupt_before=["send"], # pausar antes de enviar )
Ahora el grafo se pausa antes del nodo send. Tu API expone dos endpoints:
~# api/main.py from fastapi import FastAPI from langgraph.checkpoint.postgres import PostgresSaver from agent.graph import build_graph app = FastAPI() checkpointer = PostgresSaver.from_conn_string(os.environ["DATABASE_URL"]) graph = build_graph(checkpointer=checkpointer) @app.post("/tickets/{ticket_id}/process") async def process_ticket(ticket_id: str, body: dict): config = {"configurable": {"thread_id": ticket_id}} # Correr hasta el interrupt result = await graph.ainvoke( {"ticket_id": ticket_id, "ticket_text": body["text"], ...}, config=config, ) if result.get("response_draft"): return {"status": "pending_approval", "draft": result["response_draft"]} return {"status": "escalated"} @app.post("/tickets/{ticket_id}/approve") async def approve_response(ticket_id: str, body: dict): config = {"configurable": {"thread_id": ticket_id}} # Si el humano edita la respuesta, actualizamos el state if body.get("edited_draft"): await graph.aupdate_state(config, {"response_draft": body["edited_draft"]}) # Continuar desde el interrupt result = await graph.ainvoke(None, config=config) return {"status": "sent"}
Paso 7 · Observabilidad
Tres logs mínimos por ejecución:
~# agent/observability.py import structlog logger = structlog.get_logger() def log_transition(state, node_name, decision=None): logger.info( "agent_transition", ticket_id=state["ticket_id"], node=node_name, decision=decision, iterations=state.get("iterations", 0), classification=state.get("classification"), )
Y configurar LangSmith para tracing completo (free hasta 5K trazas/mes):
~export LANGCHAIN_TRACING_V2=true export LANGCHAIN_API_KEY=ls__... export LANGCHAIN_PROJECT=support-agent
Cada ejecución te queda con un timeline visual de cada nodo, su input, su output, su latencia y tokens consumidos. Esencial para debugging.
Costos en producción
Para 5,000 tickets/mes con este pipeline:
| Componente | Costo mensual estimado |
|---|---|
| Claude Haiku (clasificación, 5K calls) | USD 5-10 |
| Claude Sonnet (generación, ~3K calls — 60% auto-respond) | USD 25-50 |
| Hosting Python (Modal/Railway) | USD 10-30 |
| Postgres (Supabase free tier suele alcanzar) | USD 0-25 |
| Total | USD 40-115/mes |
A 50K tickets/mes, escalá lineal hasta ~USD 400-1,000/mes. Si llegás a ese volumen, vale la pena empezar a cachear clasificaciones y reducir output de Sonnet con max_tokens.
Pitfalls que vas a encontrar
- El agente decide "respond" cuando debería "escalate" — ajustá el threshold de confidence en routing. Empezá conservador (0.8) y bajalo solo si ves muchas escalaciones innecesarias.
- Loops infinitos cuando agregás re-trying nodes — siempre usá
recursion_limity un contador en state. - El checkpointer crece sin freno — agregá un job que limpie threads completados después de N días.
- LangSmith en producción sin sampling — si tu volumen es alto, sampling al 10% mantiene visibilidad sin volar el free tier.
Hablemos de tu caso
Si estás considerando construir un agente de IA para tu SaaS y querés revisar arquitectura antes de comprometer 3-4 semanas de un dev, reservá una llamada de 30 minutos sin costo. En 30 minutos suele aclararse si LangGraph es la herramienta correcta o si tu caso se resuelve mejor con un script lineal o un workflow de n8n.
Leer también:
- Integrar OpenAI sin reventar costos — disciplina de costos aplicable a cualquier stack, incluido LangGraph.
- RAG paso a paso para tu SaaS — si tu agente necesita consultar tu propia documentación.
- Más artículos sobre IA — guías y comparativas.
- Volver al blog — todos los artículos.
Preguntas frecuentes
¿LangGraph vs CrewAI vs construir mi propio orquestador?
LangGraph para agentes con flujos definidos (clasificar → decidir → actuar). CrewAI para múltiples agentes colaborando con roles. Custom code cuando el flujo es simple (3-5 pasos lineales) o muy específico. Para automatizar soporte SaaS, LangGraph es el sweet spot — state management y human-in-the-loop vienen built-in.
¿Por qué Python y no TypeScript con LangChain.js?
LangGraph tiene paridad funcional entre Python y JS pero la comunidad y el ecosistema de plugins están más maduros en Python. Si tu SaaS es Next.js, exponé el agente como un servicio Python aparte (FastAPI/Modal) y llamalo desde tu API. La separación también te ayuda a escalar el agente independientemente.
¿Cuánto cuesta correr este agente en producción?
Para 5K tickets/mes con clasificación + respuesta: USD 30-60/mes en API costs (Claude Haiku clasificación + Sonnet generación). Para 50K tickets: USD 200-500. El hosting del proceso Python: USD 5-30/mes en Modal/Railway/Fly.io. Postgres puede ser el que ya tenés.
¿Cómo manejo el estado entre invocaciones del agente?
Checkpointer de LangGraph (Postgres o SQLite). Cada vez que el grafo se ejecuta, persiste el state en una tabla con un thread_id. Si el proceso cae, podés resumir desde el último checkpoint. Esencial para human-in-the-loop donde el agente espera input humano por horas o días.
¿Cuándo NO es buena idea usar agentes?
Cuando una llamada a LLM única ya resuelve el problema (clasificación simple, extracción). Cuando los pasos del flujo son siempre los mismos (ahí un script lineal es más simple y debuggeable). Cuando necesitás respuesta sincrónica < 500ms (los agentes multi-paso tardan 2-10 segundos). Agentes brillan cuando el flujo se ramifica condicionalmente.
¿Cómo evito que el agente quede en loop infinito?
Tres salvaguardas: (1) recursion_limit en la config del graph (default 25, suele ser suficiente); (2) un contador de iteraciones en el state que el agente revisa antes de continuar; (3) timeout total del thread (15-30 min máximo). Loggear cuándo se gatilla cada uno para detectar prompts que se rompen.