Agente de cadena de suministro con LangGraph: incidencias con proveedores y reposición con plan-and-execute y map-reduce sobre miles de referencias
Índice
TL;DR
El modelo no ve treinta mil referencias. Ve las que un filtro en SQL declara en riesgo: cobertura por debajo del plazo del proveedor o incidencia abierta. Con un catálogo de 30.000 y una tasa de riesgo normal, son entre quinientas y dos mil. El primer ahorro del agente no es del modelo; es del WHERE.
El abanico se hace con Send desde una arista condicional, un nodo trabajador por lote de referencias, un reductor operator.add en el estado, y un nodo de consolidación con defer=True. Todo el abanico corre en un superstep. Verificado en un grafo de prueba con solo el abanico y la consolidación: 2.000 trabajadores dejan 4 checkpoints, no 2.000.
Lo que sí crece con el abanico son las escrituras y el tamaño del checkpoint. 2.000 envíos de una referencia cada uno son 6.003 filas en checkpoint_writes y un checkpoint de 287 KB con la lista de resultados entera. En lotes de 50, 123 filas. El tamaño del checkpoint no baja con los lotes, porque el estado consolidado es el mismo; baja acotando lo que cada trabajador devuelve.
En modo síncrono el abanico corre en un pool de hilos con el valor por defecto de Python, cinco en un pod de una CPU. En modo asíncrono corre sin límite: 2.000 tareas en vuelo a la vez. max_concurrency en la configuración lo acota en los dos modos, y hay que fijarlo al valor que el gateway y vLLM puedan absorber.
Un trabajador que falla tumba el invoke, pero no pierde a los demás: sus escrituras quedan como pendientes y la reanudación ejecuta solo el que falló. Con retry_policy en el nodo, el fallo transitorio contra el gateway ni siquiera sale del grafo.
La caché por nodo con cache_policy hace que la segunda pasada del día sobre las mismas referencias no llame al modelo: 0 llamadas reales en 200 referencias ya analizadas, con la clave por defecto sobre la entrada del nodo. Es lo que permite relanzar el análisis a media mañana sin pagar la mañana entera otra vez.
Estás aquí: del cliente al almacén
Este es el caso 3 de la serie de agentes con LangGraph para retail. Los dos anteriores, el asistente de sala y el agente de cliente, son conversaciones: una persona pregunta, el agente contesta, y el volumen lo marca la persona. Este no tiene persona delante. Lo lanza una tarea a las seis de la mañana, recorre el catálogo, y deja una propuesta de reposición para que compras la revise a las nueve.
Eso cambia el problema. Ya no importa la latencia de una respuesta, importa el coste total de la pasada, cuánto tarda, cuánto carga el servidor de inferencia, y qué pasa cuando se cae a la mitad. Y como el modelo se llama cientos o miles de veces con el mismo prompt y datos distintos, cada token del prompt cuenta cientos o miles de veces.
Es el primer caso de la serie que se escribe sobre StateGraph y no sobre create_agent. El agente del caso 1 vuelve a aparecer, como subgrafo, en el sitio donde hace falta razonar sobre una referencia con sus incidencias y su historial; el resto del grafo es determinista y no lleva modelo.
La analogía: la ronda del jefe de compras
Un jefe de compras con treinta mil referencias no las repasa una a una cada mañana. Mira un listado que el sistema le saca ordenado por riesgo, decide por dónde empieza (los proveedores con incidencias, las familias que rotan más), reparte el listado entre dos compradores, y al final junta lo que le traen en un pedido por proveedor. Si a media mañana llega un aviso de un proveedor, vuelve a mirar solo las referencias de ese proveedor.
El filtro es el listado del sistema. El planificador es el “por dónde empiezo”. El abanico son los compradores trabajando a la vez. La consolidación es el pedido por proveedor. Y la caché es no volver a mirar lo que ya se miró esta mañana.
El filtro determinista
La primera decisión de diseño es qué referencias llegan al modelo. La respuesta es un SELECT, no un prompt:
WITH cobertura AS (
SELECT s.sku,
sum(s.unidades - s.reservadas) AS unidades,
sum(s.unidades - s.reservadas) / nullif(v.media_diaria, 0) AS dias_cobertura
FROM stock s
JOIN ventas_media_30d v USING (sku)
GROUP BY s.sku, v.media_diaria
),
riesgo AS (
SELECT p.sku, p.proveedor_id, pr.plazo_dias, c.unidades, c.dias_cobertura,
i.id AS incidencia_id, i.tipo AS incidencia_tipo, i.abierta_ts
FROM productos p
JOIN proveedores pr USING (proveedor_id)
JOIN cobertura c USING (sku)
LEFT JOIN incidencias i ON i.sku = p.sku AND i.cerrada_ts IS NULL
WHERE c.dias_cobertura < pr.plazo_dias * 1.5
OR i.id IS NOT NULL
)
SELECT * FROM riesgo ORDER BY proveedor_id, dias_cobertura NULLS FIRST;
Con esto, la referencia entra en el análisis si su cobertura no llega a una vez y media el plazo del proveedor, o si tiene una incidencia abierta. El multiplicador 1,5 es un parámetro de compras, no del agente, y vive en una tabla de configuración. Sobre un catálogo generado de 30.000 referencias con una distribución de ventas realista, el filtro devuelve entre 500 y 2.000 filas según el día. Ese es el volumen que ve el modelo.
Todo lo demás, las referencias con cobertura holgada y sin incidencias, no genera tokens. Un agente que le pregunte al modelo “¿hay que reponer esto?” para cada una de las 30.000 está pagando 28.000 respuestas que un WHERE da gratis.
El estado
import operator
from typing import Annotated, TypedDict
class Referencia(TypedDict):
sku: str
proveedor_id: str
plazo_dias: int
unidades: int
dias_cobertura: float | None
incidencia: dict | None
class Analisis(TypedDict):
sku: str
proveedor_id: str
riesgo: str # "alto" | "medio" | "bajo"
accion: str # "pedir" | "esperar" | "sustituir_proveedor" | "reclamar"
unidades: int
motivo: str # una frase, acotada por el prompt
class EstadoCompras(TypedDict):
fecha: str
candidatas: list[Referencia]
plan: dict
analisis: Annotated[list[Analisis], operator.add]
propuesta: dict
pasada: int
Dos decisiones aquí. analisis es la única clave con reductor, y es la única en la que escriben los trabajadores. motivo está limitado a una frase en el prompt y a 200 caracteres en la validación, porque ese campo multiplicado por dos mil es lo que hace crecer el checkpoint. Un análisis de 180 bytes por referencia son 360 KB por checkpoint con dos mil; uno de 1 KB con explicaciones largas, 2 MB, y ese checkpoint se escribe en cada superstep posterior al abanico.
El planificador
El planificador es la única llamada al modelo que ve el conjunto entero, y no ve las referencias: ve un resumen por proveedor. Con salida estructurada:
from pydantic import BaseModel, Field
class PasoPlan(BaseModel):
proveedor_id: str
prioridad: int = Field(ge=1, le=3)
enfoque: str = Field(description="qué mirar primero en este proveedor, una frase")
class Plan(BaseModel):
pasos: list[PasoPlan]
proveedores_a_reclamar: list[str]
planificador = modelo.with_structured_output(Plan, method="json_schema")
def planificar(state: EstadoCompras) -> dict:
resumen = resumen_por_proveedor(state["candidatas"]) # SQL: n_refs, n_incidencias, plazo, sla
plan = planificador.invoke([
{"role": "system", "content": PROMPT_PLANIFICADOR},
{"role": "user", "content": json.dumps(resumen, ensure_ascii=False)},
])
return {"plan": plan.model_dump()}
method="json_schema" envía response_format con el esquema JSON al gateway, y vLLM lo aplica con decodificación guiada. Con un modelo de clase 30B es fiable para esquemas de este tamaño; con esquemas anidados profundos se ha visto que la decodificación guiada alarga la generación, y este esquema se mantiene plano a propósito.
El plan no decide qué referencias se analizan; eso lo decidió el filtro. Decide en qué orden van los proveedores, qué enfoque lleva cada trabajador en el prompt, y qué proveedores tienen tantas incidencias que lo que toca es reclamar y no pedir. Es una llamada al modelo por pasada, con un prompt de un par de miles de tokens. Su coste es despreciable frente al abanico; su valor es que el prompt de cada trabajador lleva una línea de contexto del proveedor que el trabajador no tendría.
El abanico
from langgraph.types import Send
LOTE = 50
def repartir(state: EstadoCompras) -> list[Send]:
plan = {p["proveedor_id"]: p for p in state["plan"]["pasos"]}
por_proveedor: dict[str, list[Referencia]] = {}
for r in state["candidatas"]:
por_proveedor.setdefault(r["proveedor_id"], []).append(r)
envios = []
for proveedor_id, refs in por_proveedor.items():
enfoque = plan.get(proveedor_id, {}).get("enfoque", "")
for i in range(0, len(refs), LOTE):
envios.append(Send("analizar_lote", {
"fecha": state["fecha"],
"proveedor_id": proveedor_id,
"enfoque": enfoque,
"referencias": refs[i:i + LOTE],
}))
return envios
repartir es la función de una arista condicional. Devuelve una lista de Send, y cada uno lanza una instancia de analizar_lote con su propio estado de entrada. Todas corren en el mismo superstep, y el grafo no avanza hasta que todas terminan.
El lote de 50 no es por el modelo. Cada referencia se analiza en su propia llamada al modelo dentro del trabajador; el lote existe por las escrituras del checkpoint. Medido con 2.000 referencias y un resultado de unos 180 bytes por referencia:
| Reparto | Checkpoints | Filas en checkpoint_writes | Bytes de escrituras | Tamaño del checkpoint mayor |
|---|---|---|---|---|
Un Send por referencia | 4 | 6.003 | 946 KB | 287 KB |
Un Send por lote de 50 | 4 | 123 | 319 KB | 287 KB |
Medido con InMemorySaver y el serializador JsonPlusSerializer, que es el mismo que usa el checkpointer de Postgres. Las filas de escrituras son las tareas pendientes del superstep: tres por tarea de una referencia. En Postgres son filas de checkpoint_writes, con su índice, y con treinta mil referencias serían noventa mil filas por pasada. El tamaño del checkpoint no cambia con el lote, porque es la lista de análisis consolidada, y se repite en cada checkpoint posterior al abanico.
El trabajador
from langgraph.types import CachePolicy, RetryPolicy
def analizar_lote(lote: dict) -> dict:
resultados = []
for ref in lote["referencias"]:
historial = historial_incidencias(ref["sku"], dias=90) # SQL, sin modelo
salida = analista.invoke({
"messages": [{"role": "user", "content": prompt_referencia(ref, historial, lote["enfoque"])}],
}, context=ContextoCompras(fecha=lote["fecha"], proveedor_id=lote["proveedor_id"]))
resultados.append(salida["structured_response"].model_dump())
return {"analisis": resultados}
grafo.add_node(
"analizar_lote",
analizar_lote,
cache_policy=CachePolicy(ttl=12 * 3600),
retry_policy=RetryPolicy(max_attempts=3, initial_interval=1.0, backoff_factor=2.0),
)
analista es un create_agent con response_format=ProviderStrategy(Analisis), una herramienta de consulta de incidencias por proveedor, el mismo ToolErrorMiddleware y límites del caso 1, y checkpointer=False. Lo último importa: un agente invocado dentro de un nodo hereda el checkpointer del grafo padre y escribe sus propios checkpoints bajo un espacio de nombres, tres por invocación; con dos mil referencias son seis mil filas que no sirven para reanudar nada. Se mide en el caso 4. Devuelve structured_response validado. El prompt por referencia lleva la referencia, su cobertura, su incidencia si la hay, el historial de 90 días en cinco líneas, y el enfoque del plan. Son unos 600 tokens de entrada y unos 120 de salida por referencia.
Dos mil referencias son 1,2 millones de tokens de entrada y 240.000 de salida por pasada. Con el prefijo del prompt del sistema compartido, y el gateway configurado para que las peticiones del mismo grupo de modelos caigan en la misma réplica, la mayor parte de esos 600 tokens de entrada acierta en la caché de prefijo de vLLM; la parte variable son las líneas de la referencia y el historial. Esto se mide con vllm:prefix_cache_hits frente a vllm:prefix_cache_queries durante la pasada, y es la primera cifra que hay que mirar si el coste no cuadra.
La concurrencia
En modo síncrono, LangGraph ejecuta las tareas del superstep en un pool de hilos con el valor por defecto de Python: el número de CPU más cuatro, con un tope de 32. En un pod con una CPU son cinco tareas en vuelo, medido. max_concurrency en la configuración lo cambia: con 32, se ven 32 en vuelo.
En modo asíncrono, con ainvoke, no hay pool y no hay límite: con 2.000 envíos se han medido 2.000 tareas en vuelo a la vez. Contra un gateway con un límite de peticiones por clave, eso es una tormenta de 429 en el primer segundo. max_concurrency también aplica en este modo, y es obligatorio:
config = {
"configurable": {"thread_id": f"compras:{fecha}"},
"max_concurrency": 24,
"metadata": {"caso": "compras", "fecha": fecha},
"callbacks": [langfuse_handler],
}
resultado = await grafo.ainvoke(estado_inicial, config=config, durability="sync")
El 24 sale del gateway: la clave virtual del agente de compras tiene max_parallel_requests a 24, y por debajo vLLM con --max-num-seqs a 64 reparte entre este agente y el tráfico de sala. Un max_concurrency por encima del límite de la clave solo produce 429 y reintentos; por debajo, deja capacidad sin usar. Con 24 en vuelo y unos dos segundos por referencia en un modelo de clase 30B, dos mil referencias son unos tres minutos de abanico. Las cifras de tiempo por referencia son las que hay que medir en cada despliegue; la aritmética es la que vale.
El fallo a medias
Con retry_policy en el nodo, un error de conexión con el gateway se reintenta dentro del grafo, con retroceso exponencial, y el trabajador termina sin que el invoke se entere. Verificado con un trabajador que falla la primera vez: el abanico de 20 acaba con 20 resultados.
Sin retry_policy, la excepción sale de invoke. Lo que no se pierde es el trabajo de los demás: verificado con un abanico de 20 en el que uno falla, el estado tras el fallo tiene 19 resultados guardados como escrituras pendientes, next apunta al nodo trabajador, y invoke(None, config) sobre el mismo hilo ejecuta solo la tarea que faltaba. Diecinueve llamadas al modelo que no se repiten.
Lo que sí se repite es un lote en vuelo cuando muere el pod: sus 50 referencias vuelven a analizarse. Aquí no hay dinero de por medio y el análisis es idempotente por naturaleza, así que la única consecuencia es el coste, y la caché lo cubre: si el lote ya había terminado alguna referencia, no, porque la caché es por nodo y el nodo no terminó. Es un argumento más para lotes pequeños.
La consolidación
def consolidar(state: EstadoCompras) -> dict:
por_proveedor: dict[str, list[Analisis]] = {}
for a in state["analisis"]:
por_proveedor.setdefault(a["proveedor_id"], []).append(a)
propuesta = {
"fecha": state["fecha"],
"pedidos": [
{
"proveedor_id": pid,
"lineas": [{"sku": a["sku"], "unidades": a["unidades"]} for a in items if a["accion"] == "pedir"],
"reclamaciones": [a["sku"] for a in items if a["accion"] == "reclamar"],
"n_alto": sum(1 for a in items if a["riesgo"] == "alto"),
}
for pid, items in sorted(por_proveedor.items())
],
"sin_analizar": faltan(state["candidatas"], state["analisis"]),
}
return {"propuesta": propuesta, "pasada": state["pasada"] + 1}
grafo.add_node("consolidar", consolidar, defer=True)
defer=True retrasa el nodo hasta que no quede ningún camino pendiente hacia él. Sin ese parámetro, consolidar se ejecutaría en cuanto terminara el primer lote, con una lista de análisis parcial. Con él, se ejecuta una vez, después de todo el abanico, y ve la lista completa. Verificado con stream_mode="updates": cinco lotes, cinco actualizaciones de analizar_lote y una de consolidar al final.
El orden de analisis es determinista. Los reductores se aplican al final del superstep en el orden de las tareas, que es el orden de los Send; verificado que la lista consolidada sigue el orden del reparto aunque los trabajadores terminen en otro. No hay que ordenar después, y un análisis que dependa del orden de llegada no tiene forma de depender de él.
sin_analizar es la lista de candidatas que no tienen análisis. En una pasada limpia es vacía; si un lote fue cortado por los límites del agente analista o devolvió un error, sus referencias aparecen ahí, y el nodo siguiente decide si se relanza.
Replanificar
def decidir(state: EstadoCompras) -> str:
if state["propuesta"]["sin_analizar"] and state["pasada"] < 3:
return "reintentar"
return "entregar"
def reintentar(state: EstadoCompras) -> dict:
pendientes = {r["sku"] for r in state["propuesta"]["sin_analizar"]}
return {"candidatas": [r for r in state["candidatas"] if r["sku"] in pendientes]}
grafo.add_edge(START, "cargar")
grafo.add_edge("cargar", "planificar")
grafo.add_conditional_edges("planificar", repartir, ["analizar_lote"])
grafo.add_edge("analizar_lote", "consolidar")
grafo.add_conditional_edges("consolidar", decidir, {"reintentar": "reintentar", "entregar": "entregar"})
grafo.add_conditional_edges("reintentar", repartir, ["analizar_lote"])
grafo.add_edge("entregar", END)
Es el plan-and-execute en su forma mínima: planificar, ejecutar en abanico, consolidar, y volver a ejecutar solo lo que falta, con un contador. El modelo no decide si se repite; lo decide decidir leyendo sin_analizar y pasada. Tres pasadas como máximo. Lo que quede sin analizar después de tres va en la propuesta marcado como tal, y compras lo mira a mano.
Una segunda pasada no vuelve a llamar al planificador. El plan de la primera sigue en el estado y repartir lo lee. Volver a planificar tendría sentido si el conjunto de candidatas cambiara, y en una pasada no cambia.
La caché por nodo
cache_policy=CachePolicy(ttl=12 * 3600) en analizar_lote hace que el grafo, antes de ejecutar el nodo, calcule una clave a partir de la entrada del nodo y busque en la caché. Con la clave por defecto, la entrada del lote entera es la clave: fecha, proveedor, enfoque y las 50 referencias con sus cifras.
Verificado: una pasada sobre 200 referencias hace 200 llamadas; una segunda pasada sobre las mismas 200, en otro hilo, hace 0 y termina en dos centésimas de segundo. En las actualizaciones del flujo, cada lote cacheado llega con __metadata__ y cached: True.
Para que la caché sirva en una tienda hay que decidir la clave. Con la clave por defecto, cualquier cambio en las unidades de una referencia invalida el lote entero, que es lo correcto si el stock cambió, y demasiado si cambió una unidad. La versión de producción usa una key_func propia que redondea la cobertura a días enteros y quita del lote los campos que no afectan al análisis:
def clave_lote(lote: dict) -> str:
base = [
(r["sku"], r["proveedor_id"], round(r["dias_cobertura"] or 0),
(r["incidencia"] or {}).get("id"))
for r in lote["referencias"]
]
return hashlib.sha256(json.dumps([lote["fecha"], lote["enfoque"], base]).encode()).hexdigest()
cache_policy=CachePolicy(key_func=clave_lote, ttl=12 * 3600)
La caché no es el checkpointer. Es un BaseCache que se pasa en compile(cache=...), y en un cluster con varias réplicas del agente tiene que ser compartida. El paquete trae InMemoryCache y RedisCache (langgraph.cache.redis, construido sobre un cliente de redis); la interfaz son seis métodos (get, set, clear y sus versiones asíncronas) si se quiere una sobre una tabla de Postgres. Con InMemoryCache, cada pod tiene la suya y la segunda pasada solo ahorra si cae en el mismo pod.
Lo que se mide
| Métrica | Dónde | Qué dice |
|---|---|---|
| Candidatas por pasada frente a catálogo | SELECT del filtro | Cuánto trabajo ve el modelo. Si sube del 10 por ciento, el multiplicador del filtro es alto o hay un problema de stock real |
| Tokens por referencia analizada | Langfuse, generaciones bajo caso=compras divididas por candidatas | Unos 700 con el prompt actual. Si sube, el historial se ha alargado o el motivo se ha desbordado |
| Aciertos de caché de prefijo durante la pasada | vllm:prefix_cache_hits / vllm:prefix_cache_queries | Por encima del 70 por ciento con el prompt del sistema compartido y afinidad en el gateway. Por debajo, las peticiones se reparten entre réplicas |
| Aciertos de caché de nodo | __metadata__.cached en el flujo, contados | Solo cuentan en pasadas repetidas. Cero en la primera del día es lo normal |
| Duración del abanico frente a candidatas | Traza del hilo, span de analizar_lote | Lineal con candidatas y max_concurrency. Si no es lineal, el gateway está encolando |
Filas en checkpoint_writes por pasada | Postgres | Candidatas dividido por lote, por tres. Si crece más, el lote se ha reducido o hay reintentos |
sin_analizar tras la tercera pasada | propuesta | Debe ser cero casi siempre. Cada referencia ahí es una llamada al modelo que no terminó |
| Líneas de pedido aceptadas por compras frente a propuestas | Tabla de pedidos de compra | La métrica de negocio. Un 70 por ciento de aceptación sin edición es una propuesta útil; un 30, un agente que hay que afinar |
La propuesta consolidada no se convierte en pedido sola. entregar la escribe en propuestas_reposicion y abre la aprobación de compras con el mismo patrón del caso 2: un hilo por propuesta, una interrupción con las líneas por proveedor, y un comprador que aprueba, edita cantidades o rechaza. Lo que cambia respecto al caso 2 es el tamaño del vale: son cientos de líneas, y la interfaz de aprobación es una tabla editable, no un botón.
Checklist
- El filtro de candidatas es SQL y su multiplicador vive en configuración.
Analisisacotamotivoa una frase y a 200 caracteres.- El planificador ve un resumen por proveedor, no las referencias, y usa
with_structured_output(..., method="json_schema"). repartiragrupa por proveedor y envía lotes de 50.analizar_lotetienecache_policyconkey_funcpropia yretry_policy.consolidarllevadefer=True.max_concurrencyestá en la configuración y coincide conmax_parallel_requestsde la clave del gateway.- La caché se pasa en
compile(cache=...)y es compartida entre réplicas (RedisCacheo propia). - Hay un contador de pasadas y un tope de tres.
- La propuesta pasa por aprobación de compras antes de ser pedido.
- Se ha medido
vllm:prefix_cache_hitsdurante una pasada completa.
Trampas y cosas que no son lo que parecen
El abanico asíncrono no tiene límite. 2.000 en vuelo, medido. Sin max_concurrency el primer efecto es una ráfaga de 429 del gateway y el segundo, los reintentos multiplicando la ráfaga.
El abanico síncrono tiene un límite que no es el suyo. Cinco en un pod de una CPU, por el pool de hilos de Python. Un agente que parece lento en producción y rápido en el portátil de ocho núcleos está pagando esta diferencia.
Los lotes reducen filas, no bytes de checkpoint. El checkpoint lleva la lista consolidada, y esa lista es la misma se envíe como se envíe. Lo que la reduce es acotar lo que cada análisis devuelve.
defer=True no es opcional. Sin él, la consolidación corre con el primer lote que termina. El grafo no avisa; la propuesta sale con una fracción de las referencias.
La caché por defecto es por entrada exacta. Una unidad de stock de diferencia invalida el lote. key_func propia, o la caché no acierta nunca fuera de las pruebas.
InMemoryCache es por pod. Con tres réplicas, dos tercios de las repeticiones no aciertan. RedisCache viene en el paquete; una sobre Postgres son seis métodos.
La re-ejecución tras un fallo es por tarea, no por superstep. Los que terminaron no se repiten. Pero el lote en vuelo cuando muere el pod sí, entero, y sus referencias ya analizadas dentro del lote también. Lotes pequeños.
El planificador no puede añadir referencias. Si el filtro no las sacó, el modelo no las ve. Un jefe de compras que eche en falta una referencia en la propuesta tiene que mirar el filtro, no el prompt.
Cierre
El agente de compras es determinista en todo menos en dos sitios: el plan por proveedor y el análisis por referencia. Todo lo demás, el filtro, el reparto, la consolidación, la decisión de repetir y el tope, es código que no llama al modelo. Es la proporción que hace que treinta mil referencias sean dos mil llamadas y no treinta mil, que una caída a media pasada cueste un lote y no la mañana, y que la segunda pasada del día cueste cero.
Las primitivas que lo permiten son cuatro y son de StateGraph: Send desde una arista condicional, un reductor en el estado, defer=True en la consolidación, y cache_policy con clave propia. Y un parámetro de configuración, max_concurrency, que no está en el grafo y sin el cual el grafo tumba el gateway.
El siguiente caso vuelve a una escala pequeña, una ficha de producto cada vez, y cambia el problema: no es cuántas veces se llama al modelo, es cuántas veces se le deja corregirse antes de decir que no.
Ver también
- Agentes con LangGraph para retail: el marco de la serie
- Agente de datos de producto con LangGraph: generador, evaluador y bucle acotado
- Servir agentes de LangGraph sin langgraph-api
- Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas
- Agente de atención al cliente con LangGraph: el límite de aprobación humana
- Dimensionar el gateway para una flota de agentes
- Enrutado por prefijo y caché KV: lo que LiteLLM no hace
- Límites, presupuestos y claves virtuales en LiteLLM
Fuentes
- API de grafos de LangGraph (
Send, reductores,defer,cache_policy,retry_policy): https://docs.langchain.com/oss/python/langgraph/graph-api - Persistencia y durabilidad en LangGraph: https://docs.langchain.com/oss/python/langgraph/persistence
- Salida estructurada en LangChain 1.x: https://docs.langchain.com/oss/python/langchain/structured-output
- Código de
langgraph.types(langgraph 1.2.11):Send,CachePolicy,RetryPolicy - Código de
langgraph.checkpoint.serde.jsonplus(langgraph-checkpoint 4.2.0), usado para medir los tamaños - Métricas de vLLM 0.29 (
vllm:prefix_cache_queries,vllm:prefix_cache_hits): https://docs.vllm.ai/en/latest/usage/metrics.html