Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas con RAG, herramientas y middleware
Índice
TL;DR
El asistente de sala es un create_agent con tres herramientas, un prompt dinámico y cuatro middlewares. No hay grafo escrito a mano. Es el caso en el que el ciclo modelo-herramientas basta, y por eso es el primero de la serie.
Las herramientas devuelven datos, no prosa, y comprueban el rol dentro. Un dependiente que pide registrar una rotura recibe un error estructurado de la herramienta, no una negativa del modelo. El prompt le dice al modelo que no insista. El modelo no autoriza nada.
El RAG de procedimientos es una tabla en Postgres con pgvector, troceada por sección del manual, con una columna rol_minimo que entra en el WHERE de la consulta vectorial. El dependiente no recupera las secciones del encargado ni por casualidad. La herramienta devuelve el identificador de sección y el prompt obliga a citarlo.
Por defecto, una excepción en una herramienta mata la ejecución entera. Verificado en langchain 1.4.1: el manejador por defecto solo convierte en mensaje los errores de validación de argumentos; cualquier otra excepción se propaga y el turno acaba con traza de error. Hace falta ToolErrorMiddleware con un manejador propio o ToolRetryMiddleware. Y el mensaje que deja ToolRetryMiddleware al agotar reintentos termina en “Please try again”, lo que invita al modelo a volver a llamar a la herramienta rota.
Con el middleware completo, un turno con una llamada a herramienta deja 13 checkpoints en Postgres. Solo con los dos límites, 11. Sin middleware, 5. Los límites hacen falta; el coste en filas hay que contarlo.
Lo que se mide en la primera semana sale de Langfuse y de una tabla de movimientos: consultas por dependiente, latencia, tokens, llamadas a herramienta por turno, y consultas que acaban en el encargado. Con eso, el retorno se explica en minutos de encargado.
Estás aquí: el primer agente sobre el marco de la serie
Este es el caso 1 de la serie de agentes con LangGraph para retail. El marco fija el dominio, las tablas, el contexto de ejecución y el stack: vLLM detrás de LiteLLM, Postgres con pgvector, Langfuse. Aquí no se repite nada de eso; se construye el agente.
Lo que se resuelve es lo que un dependiente pregunta en sala con el cliente delante: si queda una referencia, dónde la hay, qué dice el manual sobre una devolución sin ticket, y cómo se registra un producto que se ha roto en el pasillo. Hoy esas preguntas van al encargado, que las contesta desde la trastienda entre otras cinco cosas. El retorno del agente está en ese tiempo.
La analogía: la carpeta de procedimientos y el teléfono del encargado
Toda tienda tiene una carpeta de procedimientos que nadie abre y un encargado al que todos llaman. La carpeta está bien escrita y desactualizada en tres secciones. El encargado está actualizado y no está.
El agente es la carpeta con el teléfono del encargado dentro. Responde con la carpeta cuando la pregunta está en la carpeta, mira el stock cuando la pregunta es de stock, y devuelve al dependiente al encargado cuando la respuesta exige una decisión que la carpeta reserva al encargado. Lo que no hace es tomar esa decisión, y eso se garantiza en el código, no en el prompt.
El problema de negocio en tres preguntas
Antes de escribir herramientas se listan las preguntas reales. Tres cubren la mayoría:
- Stock. “¿Queda el SKU-0912?” y su continuación, “¿en qué tienda cercana lo hay?”. La respuesta es una consulta a la tabla
stockfiltrada por la tienda del empleado y una segunda por las demás tiendas. - Procedimiento. “¿Qué hago con una devolución sin ticket?”. La respuesta está en el manual, en una sección concreta, con condiciones. El dependiente quiere la sección y la condición, no una paráfrasis.
- Rotura. “Se ha caído un lote de vasos, ¿cómo lo registro?”. La respuesta tiene dos partes: qué dice el procedimiento y, si el rol lo permite, el registro en el TPV.
La tercera es la que fija el límite del agente. Un dependiente puede preguntar cómo se registra. Solo un encargado puede registrar. El agente hace las dos cosas, pero la segunda comprueba quién pregunta.
Las herramientas
Tres principios que se mantienen en toda la serie, y que aquí aparecen por primera vez.
Devuelven datos estructurados. Un diccionario que LangChain serializa a JSON en el ToolMessage. Nunca prosa. El modelo redacta; la herramienta informa. Esto hace que el resultado sea comparable en las trazas y que el mismo dato valga para el caso 3, que no tiene modelo en medio para la mayoría de las referencias.
Leen el contexto de ejecución, no argumentos del modelo. La tienda y el rol viajan en runtime.context, un objeto tipado que se pasa en cada invoke. La herramienta lo recibe en un parámetro runtime: ToolRuntime[Contexto] que el modelo no ve en el esquema y no puede rellenar.
Comprueban el rol dentro. La herramienta con efecto devuelve un error estructurado si el rol no es el suyo. Es una línea y es la única barrera que cuenta.
from dataclasses import dataclass
from langchain.tools import tool, ToolRuntime
@dataclass
class Contexto:
tienda: str
empleado: str
rol: str # "dependiente" | "encargado"
@tool
def stock_sku(sku: str, runtime: ToolRuntime[Contexto]) -> dict:
"""Unidades disponibles de un SKU en la tienda del empleado y en las tiendas cercanas."""
propia = runtime.context.tienda
with pool.connection() as conn:
fila = conn.execute(
"SELECT unidades - reservadas FROM stock WHERE tienda = %s AND sku = %s",
(propia, sku),
).fetchone()
cercanas = conn.execute(
"SELECT tienda, unidades - reservadas FROM stock "
"WHERE sku = %s AND tienda <> %s AND unidades - reservadas > 0 ORDER BY tienda",
(sku, propia),
).fetchall()
return {
"sku": sku,
"tienda": propia,
"unidades": fila[0] if fila else 0,
"cercanas": {t: u for t, u in cercanas},
}
@tool
def registrar_rotura(sku: str, unidades: int, motivo: str, runtime: ToolRuntime[Contexto]) -> dict:
"""Registra una rotura en el TPV y descuenta stock. Solo encargados."""
if runtime.context.rol != "encargado":
return {"error": "permiso_denegado", "detalle": "Solo un encargado puede registrar roturas."}
with pool.connection() as conn, conn.transaction():
asiento = conn.execute(
"INSERT INTO movimientos (tienda, sku, tipo, unidades, empleado, motivo, trace_id) "
"VALUES (%s, %s, 'rotura', %s, %s, %s, %s) RETURNING id",
(runtime.context.tienda, sku, -unidades, runtime.context.empleado, motivo,
runtime.config["metadata"].get("langfuse_trace_id")),
).fetchone()[0]
conn.execute(
"UPDATE stock SET unidades = unidades - %s, actualizado = now() "
"WHERE tienda = %s AND sku = %s",
(unidades, runtime.context.tienda, sku),
)
return {"ok": True, "asiento": asiento}
Dos detalles del código. pool es un psycopg_pool.ConnectionPool de proceso, creado al arrancar, contra la base retail. Y registrar_rotura escribe el trace_id en la fila: es lo que después permite unir la traza de Langfuse con el movimiento de negocio. En este caso el identificador entra por metadata en la configuración de la llamada; en el artículo 5 lo pone la capa HTTP.
Un dependiente que le pide al agente registrar la rotura obtiene esto en el ToolMessage: {"error": "permiso_denegado", "detalle": "Solo un encargado puede registrar roturas."}. El modelo lo lee y responde que tiene que avisar al encargado. Si el modelo intentara otra cosa, la herramienta devolvería lo mismo. Si un dependiente escribiera en el prompt que es encargado, el contexto seguiría diciendo que no, porque el contexto lo pone la aplicación a partir de la sesión, no el usuario.
El RAG de procedimientos
La carpeta de procedimientos es la tercera herramienta, y es la única con embeddings.
La tabla
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE procedimientos (
id text PRIMARY KEY, -- "PROC-7.2"
seccion text NOT NULL, -- "Roturas en sala"
texto text NOT NULL,
rol_minimo smallint NOT NULL DEFAULT 0, -- 0 dependiente, 1 encargado
vigente boolean NOT NULL DEFAULT true,
embedding vector(1024) NOT NULL
);
CREATE INDEX ON procedimientos USING hnsw (embedding vector_cosine_ops);
Cada fila es una sección del manual, no un trozo de tamaño fijo. Los manuales de tienda ya vienen numerados por sección, y la sección es la unidad que el dependiente reconoce y que el encargado corrige. Un trozo de 500 tokens que empieza a media frase no tiene identificador y no se puede citar. Las secciones largas se parten por subsección, con el identificador del padre como prefijo.
rol_minimo es la columna que hace que el filtro sea de la base de datos y no del prompt. vigente permite retirar una sección sin borrarla, para que las trazas antiguas sigan resolviendo el identificador.
Los embeddings
Se calculan con un modelo de embeddings servido en el mismo vLLM, con --task embed, y expuesto en LiteLLM como cualquier otro modelo. Desde LangChain:
from langchain_openai import OpenAIEmbeddings
emb = OpenAIEmbeddings(
model="bge-m3",
base_url="http://litellm.agentes.svc:4000/v1",
api_key=os.environ["LITELLM_KEY_TIENDA"],
check_embedding_ctx_length=False,
)
check_embedding_ctx_length=False importa. Con el valor por defecto, OpenAIEmbeddings tokeniza el texto con tiktoken para partirlo según el límite de los modelos de OpenAI, y el resultado se envía como lista de identificadores de token que vLLM no entiende. Con False, envía el texto tal cual.
La consulta
@tool
def procedimiento(consulta: str, runtime: ToolRuntime[Contexto]) -> list[dict]:
"""Secciones del manual de procedimientos relevantes para la consulta (tres como máximo)."""
rol = 1 if runtime.context.rol == "encargado" else 0
vector = emb.embed_query(consulta)
with pool.connection() as conn:
filas = conn.execute(
"SELECT id, seccion, texto, 1 - (embedding <=> %s::vector) AS similitud "
"FROM procedimientos "
"WHERE vigente AND rol_minimo <= %s "
"ORDER BY embedding <=> %s::vector LIMIT 3",
(vector, rol, vector),
).fetchall()
return [
{"id": i, "seccion": s, "texto": t, "similitud": round(sim, 3)}
for i, s, t, sim in filas
if sim >= 0.45
]
El filtro por rol va en el WHERE, antes del ORDER BY. Con pgvector 0.8 y un índice HNSW, un filtro restrictivo puede dejar menos de tres resultados tras el recorrido del índice; los recorridos iterativos de esa versión lo corrigen, y en una tabla de unos cientos de secciones el índice ni siquiera es necesario. El umbral de similitud se fija mirando las trazas de la primera semana; 0,45 es un punto de partida para un modelo multilingüe, no una cifra medida.
Tres secciones como máximo. El prompt del agente pide respuestas de tres frases y una cita; con más contexto el modelo tiende a resumir varias secciones en una respuesta que no cita ninguna. Si la pregunta abarca dos procedimientos, el modelo llama a la herramienta dos veces con dos consultas, y eso se ve en la traza.
El prompt dinámico
El sistema se construye en cada llamada al modelo a partir del contexto. No hay un prompt fijo con huecos rellenados a mano en invoke.
from langchain.agents.middleware import dynamic_prompt, ModelRequest
@dynamic_prompt
def prompt_tienda(request: ModelRequest) -> str:
ctx = request.runtime.context
return (
f"Eres el asistente de sala de la tienda {ctx.tienda}. "
f"Hablas con el empleado {ctx.empleado}, con rol {ctx.rol}. "
"Responde solo con datos devueltos por las herramientas. "
"Si una herramienta devuelve un error, explícalo con sus palabras y no lo intentes de otra forma. "
"Cuando uses el manual, cita el identificador de sección entre paréntesis. "
"Si no hay sección relevante, dilo y remite al encargado. "
"Tres frases como máximo."
)
Lo que el prompt no hace: no lista permisos, no dice qué puede hacer cada rol, no describe las herramientas. Los permisos están en las herramientas. Las descripciones de las herramientas están en sus docstrings, y son lo que el modelo ve en el esquema. Un prompt que repite lo que las herramientas ya dicen es contexto gastado en cada llamada y una segunda fuente de verdad que se desactualiza.
El agente y su middleware
from langchain.agents import create_agent
from langchain.agents.middleware import (
ToolCallLimitMiddleware, ModelCallLimitMiddleware,
ToolErrorMiddleware, SummarizationMiddleware,
)
def error_herramienta(exc: Exception, request) -> str:
return json.dumps({
"error": type(exc).__name__,
"detalle": str(exc)[:200],
"herramienta": request.tool_call["name"],
})
def construir(modelo, checkpointer):
return create_agent(
modelo,
tools=[stock_sku, procedimiento, registrar_rotura],
middleware=[
prompt_tienda,
ToolErrorMiddleware(on_error=error_herramienta),
ToolCallLimitMiddleware(run_limit=6, exit_behavior="end"),
ModelCallLimitMiddleware(run_limit=8, exit_behavior="end"),
SummarizationMiddleware(modelo, trigger=("fraction", 0.75), keep=("fraction", 0.25)),
],
context_schema=Contexto,
checkpointer=checkpointer,
)
Cada middleware está por una razón que se ha visto en el código, no por costumbre.
ToolErrorMiddleware. Sin él, una excepción en stock_sku porque la base de datos no responde no llega al modelo. Se propaga por el nodo tools, sale de invoke y el turno acaba con una traza de error y sin respuesta. Verificado con una herramienta que lanza RuntimeError: el manejador por defecto del nodo de herramientas de LangGraph 1.2.11 devuelve mensaje solo para ToolInvocationError, que es el error de validación de argumentos, y re-lanza el resto. Con el manejador de arriba, el modelo recibe {"error": "RuntimeError", "detalle": "...", "herramienta": "stock_sku"} en un ToolMessage con status="error" y responde que no puede consultar el stock ahora. El manejador recibe la excepción y la petición, en ese orden; un manejador de un solo argumento falla con TypeError en la primera excepción real.
ToolCallLimitMiddleware(run_limit=6). Seis llamadas a herramienta por ejecución. Una consulta de sala normal usa una o dos. Seis es el techo que separa un modelo que está afinando la consulta de uno que está dando vueltas. exit_behavior="end" termina la ejecución sin excepción, con la última respuesta del modelo. El límite por hilo, thread_limit, se deja sin fijar porque el hilo de un dependiente dura un turno de trabajo entero.
ModelCallLimitMiddleware(run_limit=8). Ocho llamadas al modelo por ejecución. Con seis herramientas como máximo, el modelo se llama siete veces en el peor caso; la octava es margen. Este límite es el que cuenta para el coste: cada llamada al modelo lleva el historial entero.
SummarizationMiddleware. Con disparador explícito por fracción y perfil en el modelo; el marco de la serie documenta que sin disparador el middleware no resume nunca. Un turno de trabajo de ocho horas con cuarenta consultas cabe en 32.768 tokens sin resumir, así que en la práctica no salta. Está para el día en que un dependiente hace ciento veinte.
ToolRetryMiddleware no está. Se probó y se quitó. Con el valor por defecto de dos reintentos y on_failure="continue", el mensaje que deja al agotar los reintentos es Tool 'stock_sku' failed after 3 attempts with RuntimeError: ... Please try again. Ese “Please try again” lo lee el modelo, y con una base de datos caída el modelo vuelve a llamar a la herramienta, que vuelve a fallar tres veces, hasta que ToolCallLimitMiddleware corta. Los reintentos contra Postgres se hacen en el pool de conexiones, con tiempo de espera corto, y el agente recibe un error una sola vez.
El grafo que sale
Compilado con esos middlewares, agente.get_graph().nodes devuelve ocho nodos: __start__, model, tools, ToolCallLimitMiddleware.after_model, ModelCallLimitMiddleware.before_model, ModelCallLimitMiddleware.after_model, SummarizationMiddleware.before_model y __end__. prompt_tienda y ToolErrorMiddleware no añaden nodos: envuelven la llamada al modelo y a la herramienta. Los dos límites y el resumen sí son nodos, porque se ejecutan como paso propio antes o después del modelo, y los límites además tienen que poder desviar el flujo a __end__.
Un turno con una llamada a herramienta recorre __start__, los dos before_model, model, los dos after_model, tools, y otra vez los dos before_model, model y los dos after_model. Trece supersteps, trece checkpoints. Solo con los dos límites, once. Sin middleware, cinco. Medido con un modelo falso que devuelve una llamada a stock_sku y después una respuesta, sobre InMemorySaver, contando con checkpointer.list(config). Un turno en el que el modelo pide dos herramientas a la vez deja las mismas trece filas: las dos corren en el mismo superstep del nodo tools.
Persistencia
El checkpointer es PostgresSaver sobre la base agentes, distinta de retail. Las tablas de checkpoint crecen por superstep y se borran por hilo; las de negocio crecen por movimiento y no se borran. No se mezclan.
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver
pool_agentes = ConnectionPool(os.environ["PG_AGENTES"], min_size=2, max_size=10,
kwargs={"autocommit": True, "prepare_threshold": 0})
checkpointer = PostgresSaver(pool_agentes)
agente = construir(modelo, checkpointer)
setup() no se llama aquí. Crea cuatro tablas y tres índices, y los índices van con CREATE INDEX CONCURRENTLY, que no puede ejecutarse dentro de una transacción ni a través de un pooler en modo transacción. Se llama una vez, desde un Job de Kubernetes con un rol que tenga permiso de DDL, antes del primer despliegue. Los pods del agente conectan con un rol sin DDL. Si se olvida, la primera invocación falla con una excepción de tabla inexistente, que es el fallo bueno: ruidoso y en el arranque.
El thread_id lo construye la aplicación: f"tienda:{ctx.tienda}:{ctx.empleado}:{turno}", con turno la fecha y el turno de trabajo. Un hilo por dependiente y turno. El dependiente no elige el identificador ni lo ve. Al cerrar el turno, un proceso aparte llama a checkpointer.delete_thread(thread_id) para los hilos de más de siete días. Es la única operación de borrado que existe en el checkpointer de Postgres 3.1.2, y se documenta en el marco de la serie por qué no hay otra.
Una consulta desde la aplicación:
config = {
"configurable": {"thread_id": thread_id},
"metadata": {"caso": "tienda", "tienda": ctx.tienda, "rol": ctx.rol},
"callbacks": [langfuse_handler],
}
resultado = agente.invoke(
{"messages": [{"role": "user", "content": pregunta}]},
context=ctx,
config=config,
)
respuesta = resultado["messages"][-1].content
Y la misma en streaming, que es lo que la aplicación de sala usa para pintar la respuesta según llega:
for chunk, meta in agente.stream(
{"messages": [{"role": "user", "content": pregunta}]},
context=ctx, config=config, stream_mode="messages",
):
if meta["langgraph_node"] == "model" and chunk.content:
emitir(chunk.content)
stream_mode="messages" entrega los trozos de cada mensaje con metadatos del nodo que los produce. Filtrar por langgraph_node == "model" deja fuera los ToolMessage, que no se muestran al dependiente.
Una conversación completa
Una conversación de sala de tres turnos, en el mismo hilo, reproducida con las herramientas reales sobre el catálogo de prueba. Las llamadas, los argumentos y los resultados entre corchetes son los que devuelve el código. Las frases del agente están guionizadas con el modelo falso de la serie para fijar el comportamiento esperado; la redacción exacta la pone el modelo que se sirva.
Dependiente: ¿Queda el SKU-0912?
[tools] stock_sku(sku="SKU-0912") -> {"sku": "SKU-0912", "tienda": "T001", "unidades": 0, "cercanas": {"T002": 14}}
Agente: En T001 no queda. T002 tiene 14 unidades disponibles.
Dependiente: Un cliente quiere devolver uno sin ticket, ¿qué hago?
[tools] procedimiento(consulta="devolución sin ticket") -> [{"id": "PROC-4.3", "seccion": "Devoluciones sin justificante", ...}]
Agente: Se admite solo como vale, con DNI y en los 30 días desde la fecha de compra que figure en la etiqueta (PROC-4.3). Si no hay etiqueta, se remite al encargado.
Dependiente: Se me ha caído una caja de seis del SKU-4471, regístralo como rotura.
[tools] procedimiento(consulta="rotura en sala") -> [{"id": "PROC-7.2", ...}]
[tools] registrar_rotura(sku="SKU-4471", unidades=6, motivo="caída en sala") -> {"error": "permiso_denegado", "detalle": "Solo un encargado puede registrar roturas."}
Agente: Retira el producto y fotografíalo (PROC-7.2). El registro en el TPV lo tiene que hacer un encargado; avísale con la foto.
En el tercer turno el modelo pidió dos herramientas en la misma vuelta y la segunda devolvió el error de permiso. La respuesta lo integra sin inventar una alternativa. El mismo tercer turno con rol="encargado" en el contexto devuelve {"ok": true, "asiento": ...} con el identificador del movimiento, y la respuesta lo cita.
Las llamadas, los argumentos y los resultados de arriba son los que aparecen en la traza de Langfuse. La aplicación no los muestra; el encargado que revisa la traza al final de la semana sí los ve.
Lo que se mide en la primera semana
Dos fuentes. Langfuse dice qué hizo el agente; la tabla movimientos y un registro de consultas dicen qué pasó en la tienda.
Langfuse, agrupando por los metadatos caso, tienda y rol:
| Métrica | Dónde | Qué dice |
|---|---|---|
| Consultas por dependiente y día | Trazas por thread_id | Si lo usan. Por debajo de cinco al día, no lo usan |
| Latencia p50 y p95 por consulta | Duración de la traza | Con vLLM saturado, el p95 se dispara antes que el p50 |
| Tokens de entrada y salida, coste | Generaciones de la traza | La entrada crece con el hilo; el resumen la acota |
| Llamadas a herramienta por turno | Spans de tools | Mediana 1, p95 2. Más, el prompt o las docstrings están mal |
| Cortes por límite | Trazas que acaban en after_model de un límite | Debe ser cero. Cada corte es una conversación fallida |
| Errores de herramienta | ToolMessage con status="error" | Separar permiso_denegado del resto: el primero es correcto, el resto es infra |
Postgres, con dos consultas:
-- Consultas que acabaron en el encargado
SELECT tienda, count(*) FILTER (WHERE escalada) AS al_encargado, count(*) AS total
FROM consultas_asistente WHERE ts >= now() - interval '7 days'
GROUP BY tienda;
-- Roturas registradas a través del agente y tiempo desde la consulta
SELECT m.tienda, count(*) AS roturas,
percentile_cont(0.5) WITHIN GROUP (ORDER BY m.ts - c.ts) AS mediana_hasta_registro
FROM movimientos m JOIN consultas_asistente c ON c.trace_id = m.trace_id
WHERE m.tipo = 'rotura' AND m.ts >= now() - interval '7 days'
GROUP BY m.tienda;
consultas_asistente la escribe la aplicación en cada turno con el thread_id, el trace_id y un campo escalada que se marca cuando la respuesta remite al encargado. Es la tabla que convierte trazas en minutos.
El retorno se explica así: cada consulta resuelta sin escalar son entre dos y cinco minutos de encargado que no se interrumpen, según la tienda. Con cuarenta consultas al día por tienda y una tasa de escalado del 20 por ciento, son treinta y dos consultas resueltas y entre una y dos horas y media de encargado al día. Las cifras de consultas y de escalado son las que hay que medir; los minutos por consulta los pone cada tienda. Lo que este artículo no da es una cifra de latencia o de coste de su despliegue: dependen del modelo, de la GPU y de la carga, y se miden en el suyo con la tabla de arriba.
Checklist
- Las tres herramientas devuelven diccionarios y nunca prosa.
registrar_roturacomprueba el rol dentro y escribe eltrace_iden la fila.- La consulta de
procedimientollevarol_minimoyvigenteen elWHERE. OpenAIEmbeddingsva concheck_embedding_ctx_length=False.ToolErrorMiddlewaretiene un manejador de dos argumentos que devuelve JSON.ToolRetryMiddlewareno está; los reintentos viven en el pool de Postgres.- Los dos límites llevan
exit_behavior="end"y se cuenta cuántas trazas acaban en ellos. SummarizationMiddlewaretienetriggery el modelo tieneprofile.setup()del checkpointer corre en un Job con DDL, no en los pods.- El
thread_idlo construye la aplicación y hay un proceso que borra hilos por antigüedad. - La aplicación escribe
consultas_asistenteconescaladaytrace_id.
Trampas y cosas que no son lo que parecen
Una excepción en una herramienta es una excepción en invoke. No hay manejo por defecto salvo para errores de validación de argumentos. Sin ToolErrorMiddleware, una base de datos caída se ve como un agente caído.
El manejador de ToolErrorMiddleware recibe dos argumentos. Excepción y petición. Con uno, TypeError en la primera excepción real, que es cuando menos se quiere.
ToolRetryMiddleware le pide al modelo que reintente. El mensaje al agotar los intentos termina en “Please try again”. Con una herramienta rota, eso es un bucle hasta el límite de llamadas.
Los límites y el resumen son nodos, y los nodos son checkpoints. Trece filas por turno con una herramienta y el middleware completo, contra cinco sin middleware. El middleware que envuelve (dynamic_prompt, ToolErrorMiddleware) no añade filas; el que se ejecuta como paso propio sí.
OpenAIEmbeddings envía tokens, no texto, por defecto. Con un modelo de embeddings en vLLM eso es un error 400 o, peor, un embedding de un texto que no es el suyo. check_embedding_ctx_length=False.
El filtro por rol en el prompt no es un filtro. Está en la consulta SQL. Si se quita de ahí y se deja en el prompt, un dependiente recupera las secciones del encargado en cuanto la pregunta se parece lo bastante.
setup() desde el pod falla de dos formas. Sin DDL, error de permisos. A través de un pooler en modo transacción, el CREATE INDEX CONCURRENTLY falla. Job aparte.
Cierre
El asistente de sala es el agente más pequeño de la serie y el que más enseña sobre dónde poner cada cosa. Los permisos, en las herramientas. El filtro de documentos, en la consulta. El contexto, fuera del estado. Los errores, convertidos en datos antes de que lleguen al modelo. Y los límites, contados en filas de checkpoint antes de dimensionar la base.
El siguiente caso rompe el ciclo. Un cliente pide una devolución, el agente prepara la resolución y, según el importe y el historial, para y espera a un encargado que puede estar en otra tienda y contestar mañana. Eso es interrupt(), un checkpointer que sobrevive al pod, y un nodo que se ejecuta dos veces sin escribir dos veces.
Ver también
- Agentes con LangGraph para retail: el marco de la serie
- Servir agentes de LangGraph sin langgraph-api
- Agente de atención al cliente con LangGraph: el límite de aprobación humana
- deepagents en un cluster propio: el SDK es libre, el servidor no
- LiteLLM y Langfuse como par operativo
- Prioridad de herramientas en el gateway MCP: visibilidad y exclusión
- Dimensionar el gateway para una flota de agentes
- Arquitectura y ajuste de Langfuse self-hosted
Fuentes
- Referencia de
create_agenten LangChain 1.x: https://docs.langchain.com/oss/python/langchain/agents - Middleware de LangChain 1.x (
ToolErrorMiddleware,ToolRetryMiddleware, límites, resumen): https://docs.langchain.com/oss/python/langchain/middleware - Persistencia y checkpointers en LangGraph: https://docs.langchain.com/oss/python/langgraph/persistence
- Código de
langgraph.prebuilt.tool_node(langgraph-prebuilt 1.1.0), función_default_handle_tool_errors - Código de
langchain.agents.middleware.tool_errorytool_retry(langchain 1.4.1) - Código de
langgraph.checkpoint.postgres.base(3.1.2), listaMIGRATIONS - pgvector, indexación HNSW y filtrado: https://github.com/pgvector/pgvector
- vLLM, modelos de embeddings (
--task embed): https://docs.vllm.ai/en/latest/models/pooling_models.html