Triaje de tickets de soporte multiagente con LangGraph y Ollama
Construir un grafo de triaje de tickets con tres agentes usando LangGraph: un clasificador, un respondiente y un control humano, ejecutándose en Ollama local o mediante una API con una sola línea modificada.
Fuentes: Building a Multi-Agent Support Ticket Triage With LangGraph
Cualquiera que haya manejado un buzón de soporte conoce la rutina matutina: alguien lee cada ticket nuevo, clasifica las solicitudes de restablecimiento de contraseñas y las preguntas de facturación en grupos separados, y busca los pocos mensajes que son realmente urgentes. Es un proceso puramente mecánico, y es exactamente el tipo de trabajo que puede asumir un pequeño equipo de agentes que colaboran entre sí.
Lo que sigue muestra cómo construir ese equipo con LangGraph: un agente clasifica cada ticket, un enrutador decide qué hacer a continuación y otro agente redacta la respuesta. Los casos urgentes omiten la automatización y van directamente a una persona. El grafo se crea una sola vez y se ejecuta ya sea localmente en Ollama o mediante una API en la nube, sin necesidad de modificar a los agentes ni el sistema de enrutamiento.
¿Por qué más de un agente?
Un agente solitario es como un trabajador que nunca habla con nadie, y en una empresa real eso impide que crezca rápidamente. Al añadir más tareas, la instrucción única se vuelve larga, frágil y difícil de depurar, ya que se le pide a un único modelo que clasifique, decida, escriba y juzgue el riesgo al mismo tiempo. Dividir la tarea permite que cada parte permanezca pequeña, verificable y reemplazable. Un sistema de múltiples agentes se asemeja a un equipo de especialistas, cada uno con una función definida, que coordinan el siguiente paso.
La triaje de tickets se ajusta a esta idea porque el trabajo se divide naturalmente, y un proceso que trate cada mensaje de manera idéntica sería inadecuado al menos en uno de los siguientes casos:
- Una pregunta rutinaria sobre facturación puede responderse automáticamente.
- Una solicitud vaga de funcionalidad recibe una respuesta preliminar que un humano refina posteriormente.
LangGraph gestiona exactamente estos casos en los que el siguiente paso depende del estado actual. El equipo está formado por tres miembros:
- Agente clasificador, que lee un ticket y asigna una categoría y una prioridad.
- Agente respondedor, que redacta respuestas para los tickets que pueden procesarse automáticamente.
- Control humano, un nodo que envía los tickets urgentes a una persona en lugar de responder por sí mismo.
Entre ellos se encuentra la lógica de enrutamiento, que funciona como un supervisor. Analiza la prioridad y elige el camino a seguir.
Cómo piensa LangGraph
Imagínese un diagrama de flujo en una pizarra. LangGraph consta de cuatro partes:
- Estado es un portapapeles que se transmite de una estación a otra. Contiene todo lo que se sabe hasta el momento sobre la tarea: el texto original, la categoría, la prioridad, la respuesta preliminar si la hay, y el estado actual.
- Nodo es una estación, una función que toma el estado, realiza una acción y devuelve actualizaciones.
- Arco es una flecha fija que conecta una estación con otra.
- Arco condicional es una flecha elegida en tiempo de ejecución. Una pequeña función de enrutador lee el estado y decide hacia dónde dirigirse, en lugar de ir siempre al mismo lugar.
Así, el estado fluye a través de los nodos, los arcos los conectan, y los arcos condicionales permiten que el camino se divida según lo indique el estado actual. Si eres nuevo en esto, el estado es simplemente un diccionario de Python con una estructura definida. Un TypedDict describe qué claves tiene el diccionario.
La tecnología utilizada
LangGraph es la capa de orquestación. Define los nodos, el estado y la ruta entre los agentes.
Ollama con Llama 3 ejecuta todo localmente, de forma gratuita, en su propia máquina. Los tickets de soporte a menudo contienen datos personales del cliente, por lo que en un entorno empresarial es una gran ventaja mantener esos datos en su propia infraestructura en lugar de enviarlos a una API externa.
Un modelo API, como aquí gpt-4o-mini, es la alternativa cuando no se dispone de hardware adicional o se necesita más rendimiento del que ofrece un modelo local pequeño. Dado que el grafo se escribe una sola vez, se puede comenzar a usarlo localmente y pasar posteriormente a una API sin tener que reescribir la lógica.
Construyéndolo paso a paso
1. Configuración
Instale tres paquetes: langgraph para el grafo, langchain-ollama como puente hacia Ollama y langchain-openai como puente hacia el modelo de la API.
# Install LangGraph and the two model integrations
pip install langgraph langchain-ollama langchain-openai
Un entorno virtual nuevo mantiene los paquetes de este proyecto separados de los demás. En Windows, créelo con python -m venv yourname y actívalo a través de la carpeta Scripts que se encuentra dentro. En Mac o Linux, utiliza python3 -m venv venv y luego ejecuta source venv/bin/activate. Una vez activado, la orden de instalación anterior solo afectará a este proyecto.
Si Ollama aún no está instalado, consíguelo desde ollama.com/download. Luego, descarga el modelo local desde la terminal:
# Download the Llama 3 model so we can run it locally
ollama pull llama3
2. Los agentes
A partir de aquí, todo se coloca en un único archivo llamado triage.py, añadiéndose de arriba hacia abajo en el orden indicado a continuación.
Una fábrica para el modelo. Comience con la pieza que hace posibles ambos backends: una pequeña función de fábrica, get_llm, que devuelve el modelo adecuado según una configuración específica. La única función de una fábrica es entregar un objeto ya preparado. Imagine una máquina de café con un botón marcado como “local” y otro como “API”: presione uno y obtendrá la taza correspondiente, sin necesidad de saber qué ocurre en su interior.
La función acepta un parámetro backend cuyo valor por defecto es “local”; por lo tanto, al llamarla sin argumentos se utiliza el modelo local.
# A factory that returns the model we want, so the rest of the code
# never has to care which backend we are using.
def get_llm(backend: str = "local"):
if backend == "local":
from langchain_ollama import ChatOllama
# temperature=0 keeps classification stable and repeatable
return ChatOllama(model="llama3", temperature=0)
elif backend == "api":
from langchain_openai import ChatOpenAI
return ChatOpenAI(model="gpt-4o-mini", temperature=0)
else:
raise ValueError("backend must be 'local' or 'api'")
# Choose your backend here: "local" or "api"
BACKEND = "local"
llm = get_llm(BACKEND)
Una cadena if/elif/else verifica el parámetro y devuelve llama3, gpt-4o-mini o un error. Las importaciones se colocan intencionadamente dentro de las ramas. Quien trabaje únicamente localmente nunca necesitará langchain-openai, y viceversa.
temperature=0 controla qué tan aleatorias son las respuestas del modelo. Valores más altos añaden variedad, mientras que 0 hace que las respuestas sean lo más estables y predecibles posible. Para la clasificación esto es exactamente lo adecuado, ya que el mismo ticket debería recibir siempre la misma categoría. BACKEND = "local" establece esta opción, y la línea siguiente llama a la función de creación.
El clasificador. Este agente lee un ticket y devuelve una categoría y una prioridad. Para procesar el resultado de forma programática, se le pide al modelo que responda en formato JSON.
La función auxiliar parse_json convierte la respuesta en un verdadero diccionario de Python. Utiliza find para localizar primero los corchetes de apertura y cierre, ya que los modelos locales no siempre devuelven JSON limpio. Suelen envolverlo en texto adicional como “Claro, aquí está el JSON: ... Espero que esto ayude.”
La función classify_ticket es el propio agente. Recibe el estado actual y devuelve actualizaciones. Su prompt le asigna al modelo un papel claro como asistente de triaje de soporte, restringe la respuesta a cuatro categorías y tres prioridades, y exige JSON puro sin comentarios adicionales. El prompt es deliberadamente estricto para que la respuesta sea más limpia y parse_json tenga menos trabajo. Llamar a llm.invoke(prompt) envía la solicitud al modelo.
Fíjese en lo que se devuelve: solo los dos campos que este agente completa, categoría y prioridad. Esa es una característica fundamental de LangGraph. Un nodo no devuelve todo el estado, sino solo las partes que desea modificar, y LangGraph las fusiona en el estado compartido. Esto mantiene los nodos pequeños, facilitando su prueba por separado, y evita que un agente sobrescriba accidentalmente un campo que pertenece a otro.
import json
def parse_json(text: str) -> dict:
# Local models sometimes wrap their JSON in extra text,
# so we grab everything between the first { and the last }.
start = text.find("{")
end = text.rfind("}") + 1
return json.loads(text[start:end])
def classify_ticket(state: dict) -> dict:
prompt = f"""You are a support triage assistant.
Classify the following support ticket.
Respond with ONLY a JSON object with two keys:
"category": one of ["billing", "technical", "account", "general"]
"priority": one of ["low", "medium", "high"]
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
result = parse_json(response.content)
# We return only the keys we want to update in the shared state
return {"category": result["category"], "priority": result["priority"]}
El respondiente. El segundo agente redacta respuestas para los tickets que pueden automatizarse sin problemas. draft_reply recibe el estado del ticket, crea una instrucción que presenta al modelo como un agente de soporte amable y conciso, e incluye una restricción: no se hacen promesas que no puedan cumplirse. Devuelve dos campos: el texto redactado en draft_reply y el estado “auto-draftado”, para que se pueda ver posteriormente que el ticket fue manejado automáticamente y no escalado. La instrucción es intencionalmente sencilla. Una respuesta de soporte escrita por un modelo debe ser breve y honesta, y las promesas sobre el plazo de reembolso o la fecha de lanzamiento de una función son cosas que debería decidir un humano.
def draft_reply(state: dict) -> dict:
prompt = f"""You are a friendly and concise support agent.
Write a short, helpful reply to this {state['category']} ticket.
Do not make promises you cannot keep.
Ticket:
{state['ticket']}"""
response = llm.invoke(prompt)
return {"draft_reply": response.content, "status": "auto-drafted"}
La puerta humana. El tercer agente nunca llama al modelo. Lo único que hace es marcar el ticket como escalado para que lo maneje una persona. Devuelve un draft_reply vacío, ya que no debe existir ninguna respuesta automática, y el estado “escalado a revisión humana”. Esto deja claro en el estado que un humano es quien se encarga del ticket. Un nodo no necesita utilizar un LLM para considerarse un agente en el grafo. Cualquier cosa que tome el estado, realice una tarea y devuelva actualizaciones puede formar parte del mismo sistema, ya sea que llame a un modelo, a una base de datos o a nada en absoluto.
def escalate_to_human(state: dict) -> dict:
# No auto-reply for urgent tickets. We flag them for a person.
return {
"draft_reply": "",
"status": "escalated to human review",
}
3. Conexión del grafo
Cada uno de los tres agentes tiene una tarea. Ahora necesitan conectarse, y eso comienza con la descripción de la forma del estado compartido. La clase TicketState es similar a la de la pestaña de pegar anteriormente, ya que indica qué campos existen y sus tipos.
Esos cinco campos también rastrean el recorrido de un ticket a través del sistema. ticket almacena el texto original. El clasificador completa los campos category y priority. El responsable de responder completa draft_reply, o lo deja vacío después de una escalada. status registra dónde terminó finalmente el ticket.
from typing import TypedDict
class TicketState(TypedDict):
ticket: str # the original ticket text
category: str # filled in by the classifier
priority: str # filled in by the classifier
draft_reply: str # filled in by the responder (or cleared on escalation)
status: str # where the ticket ended up
A continuación está el enrutador, el supervisor. route_by_priority lee la prioridad asignada por el clasificador y devuelve una cadena que indica a LangGraph qué ruta seguir. Si la prioridad es “alta”, el ticket pasa al equipo humano. De lo contrario, la función devuelve “responder” y el ticket recibe una respuesta automática.
def route_by_priority(state: TicketState) -> str:
# Urgent tickets skip the automatic reply and go to a human.
if state["priority"] == "high":
return "human_review"
return "responder"
Ahora, arme el grafo:
- Cree un grafo vacío con
StateGraph(TicketState), pasando la estructura del estado para que el grafo sepa qué campos se encuentran en el portapapeles. - Registre los tres agentes con
add_node. Cada llamada relaciona un nombre breve con una función. El nombre “classifier” pertenece aclassify_ticket, “responder” pertenece adraft_reply, y “human_review” pertenece aescalate_to_human. Cada paso posterior hace referencia a los nodos por estos nombres, por lo que selecciónelos con cuidado y manténgalos consistentes. - Agregue
add_edge(START, “classifier”)para que cada ticket comience en el clasificador.STARTes un punto de entrada integrado.
add_conditional_edges, que recibe el nodo donde ocurre la ramificación (“classifier”), la función de enrutamiento (route_by_priority) y una tabla de consulta.END, ya que una ejecución se considera completa independientemente de si la solicitud fue respondida o escalada.graph.compile(), que convierte la definición en algo ejecutable. El resultado se almacena en app, y es este el que procesa las solicitudes.from langgraph.graph import StateGraph, START, END
# Create the graph and tell it the shape of our state
graph = StateGraph(TicketState)
# Register each agent as a node
graph.add_node("classifier", classify_ticket)
graph.add_node("responder", draft_reply)
graph.add_node("human_review", escalate_to_human)
# Every ticket starts at the classifier
graph.add_edge(START, "classifier")
# After classifying, the router decides where to go next
graph.add_conditional_edges(
"classifier",
route_by_priority,
{
"responder": "responder", # normal tickets get an auto-draft
"human_review": "human_review", # urgent tickets get escalated
},
)
# Both paths finish the run
graph.add_edge("responder", END)
graph.add_edge("human_review", END)
# Compile turns our definition into something runnable
app = graph.compile()
El diccionario que se pasa a add_conditional_edges es la tabla de consulta. Sus claves son las cadenas de texto que el router puede devolver, y sus valores son los nodos a los que apuntan esas cadenas. Cuando el router devuelve “human_review”, LangGraph accede al nodo human_review. Esa asociación constituye la base de cómo se ramifican los grafos. Si más adelante se desea añadir una nueva categoría de manejo, como un especialista en facturación, el cambio consistirá en un nuevo nodo, una nueva clave en la tabla y una rama adicional en el router.
4. Ejecutarlo, local o mediante API
Primero envíe un ticket urgente a través del grafo:
# A clearly urgent billing issue
urgent_ticket = (
"I was charged twice for my subscription this month "
"and I need a refund today. This is unacceptable."
)
result = app.invoke({"ticket": urgent_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])
Ejecuta el script con python triage.py. Es de esperar que la redacción exacta de la respuesta impresa varíe ligeramente entre ejecuciones y entre modelos, pero la categoría, la prioridad y el estado deberían ser consistentes, y ese es el motivo por el cual se utiliza temperature=0. El ticket tiene alta prioridad, por lo que el router lo envía al equipo humano. El estado que se devuelve es “escalated to human review” y no se genera ninguna respuesta automática.
Prueba con un segundo ticket rutinario:
# A routine, low-stakes question
routine_ticket = "How do I change the email address on my account?"
result = app.invoke({"ticket": routine_ticket})
print("Category:", result["category"])
print("Priority:", result["priority"])
print("Status: ", result["status"])
print("Reply: ", result["draft_reply"])
Este proceso se dirige al encargado de responder y devuelve una respuesta preliminar que una persona puede revisar antes de enviarla. Ejecutar ambos casos paralelamente hace que la divergencia sea tangible: mismo gráfico, mismo código, dos rutas diferentes determinadas únicamente por la prioridad asignada por el clasificador.
Cambiar a un modelo API
Ejecutar lo mismo a través de una API en lugar de localmente requiere una clave API y una sola línea modificada, gracias a la función factory.
Inicie sesión en la plataforma API de OpenAI, cree una nueva clave y cópiela de inmediato, ya que solo se muestra una vez. Por seguridad, no pegue la clave en el script; guárdela como variable de entorno y deje que el código la lea desde allí. Ejecute la orden correspondiente a su sistema en una terminal, reemplazando your-key-here por la clave:
# On Windows (PowerShell)
setx OPENAI_API_KEY "your-key-here"
# On Mac/Linux
export OPENAI_API_KEY="your-key-here"
Ejecútelos en la terminal, no en el script, y preferiblemente en la misma terminal donde está activo el entorno virtual. La variable se establece a nivel de la sesión de la terminal, no dentro de venv. Después de eso, la clave permanece en un lugar fuera de su código, nunca aparece en el script y no puede terminar en GitHub por accidente.
Luego cambie una línea en el script y ejecute nuevamente python triage.py:
# Switch from local to API without touching the graph or the agents
BACKEND = "api"
llm = get_llm(BACKEND)
Qué muestra y qué hacer a continuación
El ejemplo refleja un flujo de trabajo diario real. El objetivo no es reemplazar al equipo de soporte, sino asumir la clasificación automática mientras los casos importantes y urgentes son atendidos por un humano. Dado que el gráfico, los agentes y la redirección funcionan tanto con Ollama como con una API, se puede crear un prototipo con Ollama y pasar a un modelo basado en API cuando se necesite mayor rendimiento.
Hay mucho espacio para su desarrollo. Los agentes podrían contar con memoria, de modo que un ticket posterior del mismo cliente mantendría el contexto. Podrían unirse más especialistas, como un agente de facturación con acceso a los datos reales de la cuenta. El gráfico podría conectarse a un sistema real de gestión de tickets a través de su API, convirtiendo el script en parte de un proceso real. Además, se podría incluir un paso adecuado con intervención humana para colocar los tickets escalados en una cola de revisión ya con la clasificación asignada.
Para leer más, la documentación de LangGraph y LangChain en https://docs.langchain.com/oss/python/langgraph/overview, así como la guía de inicio rápido de Ollama en https://docs.ollama.com/quickstart, explican las herramientas utilizadas aquí.