---
title: "Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas con RAG, herramientas y middleware"
url: https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/
markdown: https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/index.md
lang: es
date: 2026-09-17
lastmod: 2026-09-17
description: "Primer caso de la serie de agentes con LangGraph para retail. Un asistente de sala que responde a un dependiente sobre stock, procedimientos y roturas, construido con create_agent sobre langchain 1.4.1 y langgraph 1.2.11. El artículo recorre las herramientas con comprobación de rol dentro, el RAG de procedimientos sobre pgvector con filtro por rol en la consulta, el prompt dinámico que lee el contexto de ejecución, el middleware de límites y errores, la persistencia en Postgres, y lo que se mide en la primera semana. Con tres comportamientos del código que no están en la documentación: una excepción en una herramienta tumba la ejecución entera por defecto, el mensaje del middleware de reintentos invita al modelo a reintentar, y un turno con una llamada a herramienta deja trece checkpoints con el middleware completo."
tags: ["langgraph","langchain","agentes","retail","rag","pgvector","vllm","litellm","langfuse","postgres"]
---

# Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas con RAG, herramientas y middleware


## 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](https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/index.md). 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:

1. **Stock.** "¿Queda el SKU-0912?" y su continuación, "¿en qué tienda cercana lo hay?". La respuesta es una consulta a la tabla `stock` filtrada por la tienda del empleado y una segunda por las demás tiendas.
2. **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.
3. **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.

```python
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

```sql
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:

```python
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

```python
@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`.

```python
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

```python
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.

```python
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:

```python
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:

```python
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:

```sql
-- 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_rotura` comprueba el rol dentro y escribe el `trace_id` en la fila.
- La consulta de `procedimiento` lleva `rol_minimo` y `vigente` en el `WHERE`.
- `OpenAIEmbeddings` va con `check_embedding_ctx_length=False`.
- `ToolErrorMiddleware` tiene un manejador de dos argumentos que devuelve JSON.
- `ToolRetryMiddleware` no 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.
- `SummarizationMiddleware` tiene `trigger` y el modelo tiene `profile`.
- `setup()` del checkpointer corre en un Job con DDL, no en los pods.
- El `thread_id` lo construye la aplicación y hay un proceso que borra hilos por antigüedad.
- La aplicación escribe `consultas_asistente` con `escalada` y `trace_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](https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/index.md)
- [Servir agentes de LangGraph sin langgraph-api](https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/index.md)
- [Agente de atención al cliente con LangGraph: el límite de aprobación humana](https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/index.md)
- [deepagents en un cluster propio: el SDK es libre, el servidor no](https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/index.md)
- [LiteLLM y Langfuse como par operativo](https://blog.lo0.es/posts/litellm-langfuse-par-operativo/index.md)
- [Prioridad de herramientas en el gateway MCP: visibilidad y exclusión](https://blog.lo0.es/posts/prioridad-herramientas-mcp-visibilidad-y-exclusion/index.md)
- [Dimensionar el gateway para una flota de agentes](https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/index.md)
- [Arquitectura y ajuste de Langfuse self-hosted](https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/index.md)

## Fuentes

- Referencia de `create_agent` en 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_error` y `tool_retry` (langchain 1.4.1)
- Código de `langgraph.checkpoint.postgres.base` (3.1.2), lista `MIGRATIONS`
- 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>

