Tutorial LangGraph: tu primer agente multi-paso para automatizar soporte SaaS

Tutorial LangGraph: tu primer agente multi-paso para automatizar soporte SaaS

16 de Mayo del 20267 minIA, LangGraph, Agentes, Tutorial, Python

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:

  1. Recibe un ticket de soporte vía webhook.
  2. Lo clasifica con Claude Haiku (categoría + urgencia).
  3. Decide si auto-responde o escala a humano.
  4. Si auto-responde, redacta con Claude Sonnet usando docs internos como contexto.
  5. Si escala, abre un task con resumen en Notion/Linear.
  6. 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:

ComponenteCosto 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
TotalUSD 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

  1. 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.
  2. Loops infinitos cuando agregás re-trying nodes — siempre usá recursion_limit y un contador en state.
  3. El checkpointer crece sin freno — agregá un job que limpie threads completados después de N días.
  4. 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:

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.