<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Retail on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/retail/</link><description>Recent content in Retail on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Thu, 17 Sep 2026 05:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/retail/index.xml" rel="self" type="application/rss+xml"/><item><title>Memoria entre hilos para los agentes de retail: PostgresStore, el cliente que vuelve y lo que no se debe recordar</title><link>https://blog.lo0.es/posts/memoria-agentes-langgraph-postgresstore-cliente-que-vuelve/</link><pubDate>Thu, 17 Sep 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/memoria-agentes-langgraph-postgresstore-cliente-que-vuelve/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El checkpointer es memoria de un hilo; el almacén es memoria entre hilos. Un cliente que vuelve mañana abre un hilo nuevo y el agente no sabe nada de ayer salvo lo que alguien haya puesto en el almacén. &lt;code>PostgresStore&lt;/code> es ese almacén: una tabla &lt;code>store&lt;/code> con espacio de nombres, clave y valor JSONB, y opcionalmente una tabla &lt;code>store_vectors&lt;/code> con embeddings para buscar por significado.&lt;/p>
&lt;p>El almacén llega al agente por &lt;code>store=&lt;/code> en &lt;code>create_agent&lt;/code> y se lee desde las herramientas y desde el prompt dinámico por &lt;code>runtime.store&lt;/code>. Verificado: un dato escrito en un hilo por el cliente &lt;code>C9&lt;/code> aparece en otro hilo del mismo cliente y no aparece en un hilo de &lt;code>C10&lt;/code>. El aislamiento es el espacio de nombres, &lt;code>(&amp;quot;clientes&amp;quot;, cliente_id)&lt;/code>, y el identificador lo pone el contexto de ejecución, no el modelo.&lt;/p>
&lt;p>Lo que se guarda lo decide la aplicación. Las preferencias que el cliente declara sí, por una herramienta con esquema cerrado. Las que el modelo deduce, no. Los hechos de negocio (última devolución, pedidos abiertos) no se duplican en el almacén: ya están en sus tablas y la herramienta los consulta ahí.&lt;/p>
&lt;p>El TTL existe y tiene tres partes: &lt;code>default_ttl&lt;/code> en minutos, &lt;code>refresh_on_read&lt;/code> que renueva la caducidad al leer, y un barredor que borra lo caducado. El barredor es un hilo dentro del proceso; con tres réplicas son tres barredores. Se desactiva y &lt;code>sweep_ttl()&lt;/code> se llama desde el CronJob de retención del artículo 5.&lt;/p>
&lt;p>No hay borrado por espacio de nombres. Para cumplir una solicitud de supresión hay que listar las claves del cliente con &lt;code>search&lt;/code> y borrarlas una a una con &lt;code>delete&lt;/code>. Es un bucle de tres líneas y una consulta SQL de comprobación, y tiene que existir antes de guardar el primer dato.&lt;/p>
&lt;p>&lt;code>InMemoryStore&lt;/code> no admite TTL: &lt;code>put(..., ttl=...)&lt;/code> lanza &lt;code>NotImplementedError&lt;/code>. Los tests del almacén con TTL van en el grupo de Postgres, no en el de memoria.&lt;/p>
&lt;h2 id="estás-aquí-lo-que-el-hilo-no-recuerda">Estás aquí: lo que el hilo no recuerda&lt;/h2>
&lt;p>Los cuatro agentes de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de retail&lt;/a> tienen persistencia por hilo. Un hilo es un turno de trabajo del dependiente, una conversación con un cliente sobre un pedido, una pasada de compras o una ficha. Cuando el hilo termina, lo que se aprendió en él se va con la retención.&lt;/p>
&lt;p>Hay cosas que deberían quedarse. Un cliente que ha dicho tres veces que prefiere recoger en la tienda de al lado no debería decirlo una cuarta. Un dependiente de la sección de menaje no debería tener que decir &amp;ldquo;menaje&amp;rdquo; en cada pregunta. Un proveedor que avisó de un retraso el lunes no debería sorprender al planificador del jueves.&lt;/p>
&lt;p>Eso es el almacén, y este artículo lo añade a los agentes 1, 2 y 3. Es el primero de una tanda de dos; el segundo pone un supervisor delante de los cuatro.&lt;/p>
&lt;h2 id="la-analogía-la-ficha-de-cliente-detrás-del-mostrador">La analogía: la ficha de cliente detrás del mostrador&lt;/h2>
&lt;p>Un buen dependiente lleva en la cabeza a los clientes habituales: la señora que recoge los jueves, el que siempre pide factura. Cuando el dependiente cambia de turno, eso se pierde, y por eso las tiendas que lo hacen bien tienen una ficha de cliente detrás del mostrador con dos o tres líneas escritas a mano. Lo que no tiene la ficha es lo que el sistema ya sabe: los pedidos, las devoluciones, la dirección. Eso se consulta.&lt;/p>
&lt;p>El almacén es la ficha. Las tablas de negocio son el sistema. La regla de qué va en cada sitio es la misma que en la tienda: en la ficha, lo que el cliente ha dicho y no está en ningún otro sitio; en el sistema, todo lo demás.&lt;/p>
&lt;h2 id="checkpointer-y-almacén">Checkpointer y almacén&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Checkpointer&lt;/th>
&lt;th>Almacén&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Unidad&lt;/td>
&lt;td>Hilo (&lt;code>thread_id&lt;/code>)&lt;/td>
&lt;td>Espacio de nombres (tupla de cadenas) y clave&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Contenido&lt;/td>
&lt;td>Estado completo del grafo por superstep&lt;/td>
&lt;td>Documentos JSONB pequeños&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quién escribe&lt;/td>
&lt;td>El motor, en cada superstep&lt;/td>
&lt;td>Herramientas y aplicación, cuando quieren&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Búsqueda&lt;/td>
&lt;td>Por &lt;code>thread_id&lt;/code>&lt;/td>
&lt;td>Por prefijo de espacio de nombres, filtro sobre el valor y, con índice, por significado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Caducidad&lt;/td>
&lt;td>Retención por hilo entero, externa&lt;/td>
&lt;td>TTL por documento, con barredor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tabla&lt;/td>
&lt;td>&lt;code>checkpoints&lt;/code> y compañía&lt;/td>
&lt;td>&lt;code>store&lt;/code> y &lt;code>store_vectors&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Los dos viven en la base &lt;code>agentes&lt;/code>, en tablas distintas, y los dos tienen &lt;code>setup()&lt;/code> propio que corre en el mismo Job de migraciones del &lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">artículo 5&lt;/a>.&lt;/p>
&lt;h2 id="qué-se-guarda-y-qué-no">Qué se guarda y qué no&lt;/h2>
&lt;p>La decisión más importante del artículo se toma antes de escribir código. Tres categorías:&lt;/p>
&lt;p>&lt;strong>Se guarda: lo que el cliente o el empleado declara y no está en ninguna tabla.&lt;/strong> Preferencia de recogida, canal de contacto preferido, idioma, &amp;ldquo;no me llaméis por la mañana&amp;rdquo;, la sección en la que trabaja el dependiente. Son datos que la persona ha dicho y que quiere que se recuerden.&lt;/p>
&lt;p>&lt;strong>No se guarda: lo que el modelo deduce.&lt;/strong> &amp;ldquo;Parece un cliente exigente&amp;rdquo;, &amp;ldquo;suele devolver&amp;rdquo;. Lo primero es una opinión del modelo sobre una persona y no tiene sitio en ningún almacén de una empresa que responda ante el RGPD. Lo segundo es un hecho que ya está en &lt;code>devoluciones&lt;/code> y que la herramienta puede contar cuando haga falta, con la fecha y el importe, en vez de como etiqueta.&lt;/p>
&lt;p>&lt;strong>No se duplica: lo que está en las tablas de negocio.&lt;/strong> Pedidos, devoluciones, stock, incidencias. El almacén no es una caché de la base de datos; es la ficha escrita a mano.&lt;/p>
&lt;p>Para el caso 3 hay una cuarta categoría: avisos de proveedores que no encajan en &lt;code>incidencias&lt;/code> porque no son incidencias todavía (&amp;ldquo;nos han dicho que la fábrica cierra en agosto&amp;rdquo;). Van al almacén con espacio de nombres &lt;code>(&amp;quot;proveedores&amp;quot;, proveedor_id)&lt;/code> y los escribe compras desde su aplicación, no el modelo.&lt;/p>
&lt;h2 id="el-almacén">El almacén&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">psycopg_pool&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ConnectionPool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.store.postgres&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PostgresStore&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.store.base&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">TTLConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">pool_agentes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ConnectionPool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;PG_AGENTES&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">min_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">max_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">kwargs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;autocommit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;prepare_threshold&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">almacen&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PostgresStore&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pool_agentes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">index&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;dims&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;embed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">emb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;fields&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;texto&amp;#34;&lt;/span>&lt;span class="p">]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">TTLConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">default_ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">60&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">24&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">365&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">refresh_on_read&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">omit_expired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sweep_interval_minutes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>emb&lt;/code> es el mismo &lt;code>OpenAIEmbeddings&lt;/code> del &lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">caso 1&lt;/a>, contra el modelo de embeddings servido en vLLM detrás de LiteLLM, con &lt;code>check_embedding_ctx_length=False&lt;/code>. &lt;code>dims&lt;/code> tiene que coincidir con la dimensión del modelo; el índice se crea con esa dimensión en &lt;code>setup()&lt;/code> y no se cambia después sin recrear la tabla.&lt;/p>
&lt;p>&lt;code>fields&lt;/code> dice qué campos del valor se indexan. Solo &lt;code>texto&lt;/code>. El resto del documento (origen, fecha, quién lo escribió) es metadato y se filtra, no se busca.&lt;/p>
&lt;p>El TTL de un año con renovación al leer es la política para preferencias: un cliente que vuelve las mantiene; uno que no vuelve en un año las pierde. &lt;code>sweep_interval_minutes=None&lt;/code> desactiva el barredor en proceso; se explica más abajo.&lt;/p>
&lt;p>&lt;code>setup()&lt;/code> con &lt;code>index&lt;/code> configurado aplica las migraciones de la tabla &lt;code>store&lt;/code> y además las de &lt;code>store_vectors&lt;/code>, empezando por &lt;code>CREATE EXTENSION IF NOT EXISTS vector&lt;/code> dentro de un bloque &lt;code>DO&lt;/code>. Eso exige que el rol del Job pueda crear extensiones, o que la extensión ya exista en la base. En un cluster de CloudNativePG lo segundo es lo habitual: la extensión se declara en el &lt;code>Cluster&lt;/code> y el rol del Job no necesita ser superusuario.&lt;/p>
&lt;h2 id="las-herramientas">Las herramientas&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">recordar_preferencia&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tipo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;recogida&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;contacto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;idioma&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;horario&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">valor&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">ContextoCliente&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Guarda una preferencia que el cliente ha declarado explícitamente en esta conversación.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ns&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;clientes&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ns&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;pref-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tipo&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;texto&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tipo&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">valor&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;tipo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tipo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;valor&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">valor&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;origen&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cliente&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;hilo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tipo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tipo&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">preferencias&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">ContextoCliente&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Preferencias declaradas por el cliente en conversaciones anteriores.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ns&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;clientes&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;tipo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;tipo&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;valor&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;valor&amp;#34;&lt;/span>&lt;span class="p">]}&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">it&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ns&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tres decisiones.&lt;/p>
&lt;p>&lt;strong>La clave es &lt;code>pref-{tipo}&lt;/code>, no un identificador nuevo por escritura.&lt;/strong> Una preferencia nueva del mismo tipo sobreescribe la anterior. Sin eso, &amp;ldquo;recogida: T002&amp;rdquo; y &amp;ldquo;recogida: T001&amp;rdquo; conviven y el modelo elige.&lt;/p>
&lt;p>&lt;strong>El tipo es un &lt;code>Literal&lt;/code> cerrado.&lt;/strong> El modelo no puede inventar categorías. Si el cliente dice algo que no encaja, no se guarda. Es la parte del esquema que hace que el almacén no acumule opiniones.&lt;/p>
&lt;p>&lt;strong>El espacio de nombres viene del contexto.&lt;/strong> &lt;code>runtime.context.cliente_id&lt;/code> lo puso la aplicación a partir de la sesión. El modelo no ve ese parámetro y no puede escribir en la ficha de otro cliente. Verificado: un dato escrito con contexto &lt;code>C9&lt;/code> no aparece en una búsqueda con contexto &lt;code>C10&lt;/code>, en otro hilo, con otro agente construido sobre el mismo almacén.&lt;/p>
&lt;p>La búsqueda de &lt;code>preferencias&lt;/code> no lleva &lt;code>query&lt;/code>: devuelve todo el espacio de nombres, que son como mucho cuatro documentos. La búsqueda semántica se usa en el caso 3, donde los avisos de un proveedor pueden ser decenas:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">avisos_proveedor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">consulta&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">ContextoCompras&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Avisos recientes de compras sobre el proveedor relacionados con la consulta.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ns&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;proveedores&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;texto&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;texto&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;similitud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">round&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">)}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">it&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ns&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">consulta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mf">0.4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con el índice configurado, &lt;code>search&lt;/code> con &lt;code>query&lt;/code> calcula el embedding de la consulta con &lt;code>emb&lt;/code>, lo compara por coseno contra &lt;code>store_vectors&lt;/code> dentro del prefijo, y devuelve &lt;code>SearchItem&lt;/code> con &lt;code>score&lt;/code>. Sin &lt;code>query&lt;/code>, es un listado por prefijo con filtro opcional sobre el valor.&lt;/p>
&lt;h2 id="el-prompt-que-lee-el-almacén">El prompt que lee el almacén&lt;/h2>
&lt;p>Para el caso 2 las preferencias no van por herramienta. Van en el prompt, porque el modelo tiene que saberlas antes de proponer nada y no debería tener que acordarse de llamar a &lt;code>preferencias&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dynamic_prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">prompt_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ModelRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="s2">&amp;#34;clientes&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lineas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;- &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;texto&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">p&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prefs&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;- (ninguna)&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Atiendes al cliente &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> de la tienda &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">.&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Preferencias que el cliente ha declarado en otras conversaciones:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">lineas&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Respétalas al proponer recogida, contacto o plazo. No las cites como si las hubieras deducido.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Verificado que &lt;code>request.runtime.store&lt;/code> está disponible en el prompt dinámico y devuelve lo mismo que desde una herramienta. Es una consulta a Postgres por cada llamada al modelo; con &lt;code>refresh_on_read=True&lt;/code>, además renueva la caducidad de esas preferencias en cada llamada, que es lo que se quiere para un cliente activo y lo que hay que tener en cuenta al estimar escrituras: cada lectura es también un &lt;code>UPDATE&lt;/code> de &lt;code>expires_at&lt;/code>.&lt;/p>
&lt;p>La última frase del prompt existe porque un modelo que recibe &amp;ldquo;prefiere recogida en T002&amp;rdquo; tiende a decir &amp;ldquo;veo que sueles recoger en T002&amp;rdquo;, y eso a un cliente le suena a vigilancia. La preferencia se aplica, no se comenta.&lt;/p>
&lt;h2 id="el-ttl-y-el-barredor">El TTL y el barredor&lt;/h2>
&lt;p>&lt;code>TTLConfig&lt;/code> tiene cuatro campos: &lt;code>default_ttl&lt;/code> en minutos para lo que se escriba sin &lt;code>ttl&lt;/code> explícito, &lt;code>refresh_on_read&lt;/code> para renovar al leer, &lt;code>omit_expired&lt;/code> para que las lecturas ignoren lo caducado aunque el barredor no haya pasado, y &lt;code>sweep_interval_minutes&lt;/code> para el barredor.&lt;/p>
&lt;p>El barredor es &lt;code>start_ttl_sweeper()&lt;/code>: un hilo dentro del proceso que cada N minutos ejecuta &lt;code>sweep_ttl()&lt;/code>, que es un &lt;code>DELETE FROM store WHERE expires_at IS NOT NULL AND expires_at &amp;lt; NOW()&lt;/code>. Con tres réplicas del servicio del caso 2, son tres hilos haciendo el mismo &lt;code>DELETE&lt;/code> sobre la misma tabla. No rompe nada; es trabajo repetido y una fuente de bloqueos breves que no hace falta tener.&lt;/p>
&lt;p>Se deja &lt;code>sweep_interval_minutes=None&lt;/code> y el barrido va en el CronJob de retención del artículo 5, una línea más:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">borrados&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">almacen&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sweep_ttl&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;store: &lt;/span>&lt;span class="si">%d&lt;/span>&lt;span class="s2"> documentos caducados borrados&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">borrados&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>omit_expired=True&lt;/code> es lo que hace que entre barrido y barrido lo caducado no se lea. Con &lt;code>False&lt;/code>, una preferencia caducada hace once meses seguiría apareciendo hasta la noche.&lt;/p>
&lt;p>Los plazos por espacio de nombres: un año con renovación para &lt;code>clientes&lt;/code>, 90 días sin renovación para &lt;code>proveedores&lt;/code> (un aviso de hace tres meses ya es una incidencia o no fue nada), y 180 días con renovación para &lt;code>empleados&lt;/code>. Se pasan como &lt;code>ttl=&lt;/code> en cada &lt;code>put&lt;/code>, en minutos, y &lt;code>default_ttl&lt;/code> es solo la red.&lt;/p>
&lt;h2 id="el-borrado-que-la-ley-pide">El borrado que la ley pide&lt;/h2>
&lt;p>Un cliente puede pedir que se borre lo que la empresa sabe de él, y la respuesta tiene que ser completa. El almacén no tiene &amp;ldquo;borra todo lo de este espacio de nombres&amp;rdquo;. &lt;code>BaseStore&lt;/code> tiene &lt;code>delete(namespace, key)&lt;/code> y &lt;code>list_namespaces&lt;/code>, y nada más.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">suprimir_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ns&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;clientes&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cliente_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">items&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">almacen&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ns&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">it&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">items&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">almacen&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ns&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">it&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y la comprobación, que es la que se guarda como evidencia de la solicitud:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">prefix&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;clientes.&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">store_vectors&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">prefix&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;clientes.&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El prefijo en la tabla es la tupla unida por puntos. Las dos consultas tienen que dar cero, y las dos van en el registro de la solicitud junto con la fecha.&lt;/p>
&lt;p>Esta función existe y está probada antes de que el agente guarde la primera preferencia. Y va acompañada de la del checkpointer: los hilos del cliente en &lt;code>hilos&lt;/code> se borran con &lt;code>delete_thread&lt;/code>, y las trazas de Langfuse con su API de borrado por &lt;code>user_id&lt;/code>, que es otro artículo. Lo que este artículo fija es que el almacén no puede ser el sitio donde un dato sobre una persona se queda sin que nadie sepa borrarlo.&lt;/p>
&lt;h2 id="lo-que-se-prueba-y-lo-que-no">Lo que se prueba y lo que no&lt;/h2>
&lt;p>Con &lt;code>InMemoryStore&lt;/code> se prueba todo el aislamiento y la lectura: el almacén admite &lt;code>index&lt;/code> con &lt;code>FakeEmbeddings&lt;/code> de &lt;code>langchain-core&lt;/code>, y los tests del &lt;a href="https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/">artículo de pruebas&lt;/a> se extienden con tres:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_preferencia_sobrevive_al_hilo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente_a&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;recordar_preferencia&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tipo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;recogida&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valor&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;T002&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)]),&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente_a&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">entrada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;me lo llevo a T002&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Ctx&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;C9&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">hilo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;h1&amp;#34;&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente_b&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;preferencias&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)]),&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_b&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">entrada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hola&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Ctx&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;C9&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;T003&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">hilo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;h2&amp;#34;&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msg&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;tipo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;recogida&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;valor&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;T002&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_otro_cliente_no_ve_nada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msg&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_sin_almacen_las_herramientas_no_rompen&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;preferencias&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)]),&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">entrada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hola&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Ctx&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;C9&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">hilo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;h1&amp;#34;&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msg&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El tercero fija un detalle verificado: sin &lt;code>store=&lt;/code> en &lt;code>create_agent&lt;/code>, &lt;code>runtime.store&lt;/code> es &lt;code>None&lt;/code> dentro de la herramienta, y la herramienta tiene que comprobarlo y devolver vacío en vez de lanzar. Es el comportamiento que se quiere si el almacén se despliega después del agente.&lt;/p>
&lt;p>Lo que no se prueba en memoria: el TTL. &lt;code>InMemoryStore.put(..., ttl=...)&lt;/code> lanza &lt;code>NotImplementedError: TTL is not supported by InMemoryStore&lt;/code>. Los tests de caducidad, &lt;code>refresh_on_read&lt;/code>, &lt;code>omit_expired&lt;/code> y &lt;code>sweep_ttl&lt;/code> van en el grupo de Postgres, con un TTL de un minuto y un &lt;code>sleep&lt;/code>.&lt;/p>
&lt;h2 id="lo-que-se-mide">Lo que se mide&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Métrica&lt;/th>
&lt;th>Dónde&lt;/th>
&lt;th>Qué dice&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Documentos por cliente&lt;/td>
&lt;td>&lt;code>SELECT prefix, count(*) FROM store GROUP BY prefix&lt;/code>&lt;/td>
&lt;td>Con el esquema cerrado, cuatro como máximo. Más, el &lt;code>Literal&lt;/code> se ha abierto&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Preferencias escritas por conversación&lt;/td>
&lt;td>Spans de &lt;code>recordar_preferencia&lt;/code> en Langfuse&lt;/td>
&lt;td>Por debajo de 0,1. Un modelo que guarda algo en cada conversación está guardando lo que no debe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Conversaciones del caso 2 en las que se aplicó una preferencia&lt;/td>
&lt;td>Comparar la propuesta con &lt;code>store&lt;/code>&lt;/td>
&lt;td>La utilidad. Si no cambia ninguna propuesta, la memoria no sirve&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Caducados borrados por barrido&lt;/td>
&lt;td>Log del CronJob&lt;/td>
&lt;td>Debe crecer a partir del mes doce y no antes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Solicitudes de supresión y tiempo hasta cero&lt;/td>
&lt;td>Registro de solicitudes&lt;/td>
&lt;td>La obligación. Tiene que ser el mismo día&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Hay una lista escrita de qué se guarda, qué no y qué no se duplica, y el &lt;code>Literal&lt;/code> de tipos la refleja.&lt;/li>
&lt;li>El espacio de nombres viene de &lt;code>runtime.context&lt;/code>, nunca de un argumento del modelo.&lt;/li>
&lt;li>Las claves son por tipo de preferencia y sobreescriben.&lt;/li>
&lt;li>&lt;code>index&lt;/code> usa el modelo de embeddings de la serie y &lt;code>dims&lt;/code> coincide.&lt;/li>
&lt;li>&lt;code>setup()&lt;/code> del almacén corre en el Job de migraciones y la extensión &lt;code>vector&lt;/code> existe antes.&lt;/li>
&lt;li>&lt;code>sweep_interval_minutes=None&lt;/code> y &lt;code>sweep_ttl()&lt;/code> en el CronJob de retención.&lt;/li>
&lt;li>&lt;code>omit_expired=True&lt;/code>.&lt;/li>
&lt;li>TTL por espacio de nombres pasado en cada &lt;code>put&lt;/code>.&lt;/li>
&lt;li>&lt;code>suprimir_cliente&lt;/code> existe, está probada y deja evidencia con las dos consultas a cero.&lt;/li>
&lt;li>Las herramientas comprueban &lt;code>runtime.store is None&lt;/code>.&lt;/li>
&lt;li>Los tests de TTL están en el grupo de Postgres.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El almacén como caché de la base de negocio.&lt;/strong> Se desincroniza y se convierte en la fuente que el modelo prefiere. Solo lo que no está en ninguna tabla.&lt;/p>
&lt;p>&lt;strong>El modelo escribiendo lo que deduce.&lt;/strong> Un &lt;code>put&lt;/code> con texto libre desde una herramienta sin esquema acaba con opiniones sobre personas en Postgres. &lt;code>Literal&lt;/code> cerrado y origen &lt;code>cliente&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Tres réplicas, tres barredores.&lt;/strong> El barredor es un hilo en proceso. Desactivar y barrer desde el CronJob.&lt;/p>
&lt;p>&lt;strong>&lt;code>refresh_on_read&lt;/code> escribe.&lt;/strong> Cada lectura desde el prompt renueva &lt;code>expires_at&lt;/code>. Es un &lt;code>UPDATE&lt;/code> por llamada al modelo; se cuenta.&lt;/p>
&lt;p>&lt;strong>No hay borrado por espacio de nombres.&lt;/strong> Listar y borrar, y comprobar en SQL. Antes de la primera escritura.&lt;/p>
&lt;p>&lt;strong>&lt;code>InMemoryStore&lt;/code> no tiene TTL.&lt;/strong> &lt;code>NotImplementedError&lt;/code>. Los tests de caducidad van contra Postgres.&lt;/p>
&lt;p>&lt;strong>&lt;code>setup()&lt;/code> crea la extensión.&lt;/strong> Necesita permiso o la extensión ya creada. En CNPG, se declara en el &lt;code>Cluster&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Veo que sueles…&amp;rdquo;&lt;/strong> El modelo comenta la preferencia y suena a vigilancia. El prompt dice que se aplique y no se cite.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>La memoria entre hilos es una tabla con espacio de nombres, un esquema cerrado de lo que se puede escribir, un TTL por tipo de dato y una función de borrado que existe antes que el primer &lt;code>put&lt;/code>. LangGraph pone la tabla, la búsqueda y la caducidad; la aplicación pone lo demás, y lo demás es lo que decide si la ficha de cliente es una ayuda o un problema.&lt;/p>
&lt;p>El siguiente artículo cierra la tanda con un supervisor: un punto de entrada que decide si una conversación es del asistente de sala, del agente de cliente o de compras, y la traspasa con &lt;code>Command(goto=...)&lt;/code> sin perder el hilo ni el almacén.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/">Probar agentes de LangGraph sin modelo: tests de grafo con pytest&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">Aislar agentes de IA por cliente en el cluster&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Memoria y almacén en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/memory">https://docs.langchain.com/oss/python/langgraph/memory&lt;/a>&lt;/li>
&lt;li>Persistencia en LangGraph (&lt;code>BaseStore&lt;/code>, &lt;code>PostgresStore&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langgraph.store.postgres&lt;/code> (langgraph-checkpoint-postgres 3.1.2): &lt;code>PostgresStore.__init__&lt;/code>, &lt;code>setup&lt;/code>, &lt;code>sweep_ttl&lt;/code>, &lt;code>start_ttl_sweeper&lt;/code>, &lt;code>MIGRATIONS&lt;/code>, &lt;code>VECTOR_MIGRATIONS&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.store.base&lt;/code> (langgraph-checkpoint 4.2.0): &lt;code>TTLConfig&lt;/code>, &lt;code>IndexConfig&lt;/code>, &lt;code>BaseStore.put&lt;/code>, &lt;code>search&lt;/code>, &lt;code>delete&lt;/code>, &lt;code>list_namespaces&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.store.memory&lt;/code> (langgraph-checkpoint 4.2.0): &lt;code>InMemoryStore.put&lt;/code> y el error de TTL&lt;/li>
&lt;li>pgvector: &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Evaluar agentes de LangGraph con datasets de Langfuse: casos dorados por agente, evaluadores en código, un juez calibrado y una puerta de regresión en CI</title><link>https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/</link><pubDate>Thu, 17 Sep 2026 04:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un caso dorado es una entrada, una salida esperada y metadatos, y la salida esperada casi nunca es un texto: es qué herramienta tenía que llamarse, con qué argumentos, si había que escalar, si había que parar a pedir aprobación, qué veredicto tenía que dar el evaluador. Comparar textos generados con textos esperados es lo que menos dice y lo que más cuesta.&lt;/p>
&lt;p>Los casos salen de tres sitios que ya existen: las trazas de producción, marcadas desde la aplicación con un pulgar; las decisiones de los encargados del caso 2, que son etiquetas gratis; y la muestra semanal de fichas que alguien de catálogo ya lee. &lt;code>create_dataset_item&lt;/code> con &lt;code>source_trace_id&lt;/code> convierte una traza en un caso con un clic y una línea.&lt;/p>
&lt;p>El SDK de Langfuse 4.15 trae &lt;code>run_experiment&lt;/code>: una función de tarea que ejecuta el agente por elemento, evaluadores por elemento que reciben entrada, salida y salida esperada y devuelven &lt;code>Evaluation&lt;/code>, evaluadores de ejecución que reciben todos los resultados, y un &lt;code>ExperimentResult&lt;/code> con las puntuaciones. Verificado: funciona sobre un dataset del servidor y sobre una lista local, y produce el resultado aunque el servidor no responda.&lt;/p>
&lt;p>Los evaluadores son código para todo lo que se puede decidir con código: herramienta correcta, argumentos, escalado, cita de sección, idioma, promesa de plazo. Un modelo juez solo para lo que exige leer, y solo después de medir su acuerdo con las personas sobre la misma muestra. Un juez sin calibrar es un segundo prompt opinando sobre el primero.&lt;/p>
&lt;p>&lt;code>run_experiment&lt;/code> tiene &lt;code>max_concurrency=50&lt;/code> por defecto. Contra un vLLM con una clave de gateway limitada a 24, son 26 peticiones en 429 desde el primer segundo. Se fija al valor de la clave, como en el caso 3.&lt;/p>
&lt;p>La puerta en CI es una línea: si la métrica que importa baja del umbral, &lt;code>RegressionError&lt;/code>. La acción oficial es de GitHub; con Forgejo se hace lo mismo con un script de veinte líneas que lee el &lt;code>ExperimentResult&lt;/code> y sale con código distinto de cero.&lt;/p>
&lt;h2 id="estás-aquí-lo-que-el-guion-no-prueba">Estás aquí: lo que el guion no prueba&lt;/h2>
&lt;p>El &lt;a href="https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/">artículo anterior&lt;/a> probó los cuatro agentes de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de retail&lt;/a> con un modelo guionizado. Esos tests dicen que el grafo hace lo que el código dice cuando el modelo decide lo que el guion decide. No dicen nada de lo que el modelo decide solo.&lt;/p>
&lt;p>Eso es lo que aquí se mide. Y se mide en Langfuse porque es donde ya están las trazas, donde ya se agrupan por &lt;code>caso&lt;/code> y &lt;code>thread_id&lt;/code>, y donde el &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">par operativo con LiteLLM&lt;/a> ya deja el coste por generación.&lt;/p>
&lt;h2 id="la-analogía-el-cliente-misterioso">La analogía: el cliente misterioso&lt;/h2>
&lt;p>Una cadena de tiendas no sabe si sus dependientes atienden bien por lo que dicen los dependientes. Manda a un cliente misterioso con un guion (pregunta esto, pide una devolución sin ticket, insiste) y una hoja de comprobación: ¿miró el stock?, ¿citó el procedimiento?, ¿llamó al encargado cuando tocaba?, ¿prometió algo que no podía? La hoja es objetiva en casi todo. Solo una casilla, &amp;ldquo;¿fue amable?&amp;rdquo;, la rellena una persona, y a esa persona se la entrena antes con casos en los que ya se sabe la respuesta.&lt;/p>
&lt;p>El dataset es el guion del cliente misterioso. Los evaluadores en código son la hoja. El juez con modelo es la casilla de &amp;ldquo;amable&amp;rdquo;, y la calibración es el entrenamiento de quien la rellena.&lt;/p>
&lt;h2 id="qué-es-un-caso-dorado-en-cada-agente">Qué es un caso dorado en cada agente&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Agente&lt;/th>
&lt;th>Entrada&lt;/th>
&lt;th>Salida esperada&lt;/th>
&lt;th>De dónde sale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tienda&lt;/td>
&lt;td>Pregunta, tienda, rol&lt;/td>
&lt;td>Herramienta esperada (o ninguna), si escala al encargado, id de sección si aplica&lt;/td>
&lt;td>Trazas de producción con pulgar; consultas escaladas revisadas&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cliente&lt;/td>
&lt;td>Mensaje, pedido, cliente, historial&lt;/td>
&lt;td>Herramienta y argumentos, si debe interrumpir, decisión que tomaría un encargado&lt;/td>
&lt;td>Decisiones de encargados (&lt;code>decisiones_encargado&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compras&lt;/td>
&lt;td>Resumen por proveedor (planificador); referencia con historial (analista)&lt;/td>
&lt;td>Orden de prioridad esperado; acción y rango de unidades&lt;/td>
&lt;td>Propuestas editadas por compras: lo que cambiaron es la etiqueta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fichas&lt;/td>
&lt;td>Ficha de proveedor, criterios&lt;/td>
&lt;td>Veredicto humano y criterios rechazados&lt;/td>
&lt;td>Muestra semanal de catálogo&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La columna del medio es lo que no es texto libre. En el caso 1 no se espera &amp;ldquo;En T001 no queda. T002 tiene 14&amp;rdquo;, se espera &lt;code>{&amp;quot;tool&amp;quot;: &amp;quot;stock_sku&amp;quot;, &amp;quot;args&amp;quot;: {&amp;quot;sku&amp;quot;: &amp;quot;SKU-0912&amp;quot;}, &amp;quot;escala&amp;quot;: false}&lt;/code>. La redacción cambia con cada versión del modelo y con la temperatura; la decisión no debería.&lt;/p>
&lt;p>Cuando sí hay texto esperado, como en el caso 4 con el veredicto, se compara lo estructurado: &lt;code>aprobada&lt;/code> y el conjunto de criterios rechazados, no el &lt;code>detalle&lt;/code>.&lt;/p>
&lt;h2 id="de-dónde-salen-los-casos">De dónde salen los casos&lt;/h2>
&lt;h3 id="trazas-con-pulgar">Trazas con pulgar&lt;/h3>
&lt;p>La aplicación de sala tiene dos botones debajo de cada respuesta. Cada pulsación es una puntuación sobre la traza:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_score&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;pulgar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">1.0&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">arriba&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="mf">0.0&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">data_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BOOLEAN&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">comment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">comentario_opcional&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Una vez a la semana, alguien filtra en Langfuse las trazas del caso &lt;code>tienda&lt;/code> con &lt;code>pulgar = 0&lt;/code>, lee la conversación, decide cuál era la respuesta correcta y crea el caso:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_dataset_item&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dataset_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tienda-dorado&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">input&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;pregunta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;rol&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;dependiente&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expected_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;procedimiento&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;seccion&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;PROC-4.3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;escala&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;origen&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;pulgar_abajo&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;semana&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-W38&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>source_trace_id&lt;/code> enlaza el caso con la traza de la que salió. En la interfaz, desde el caso se abre la conversación original, y desde cada ejecución del experimento se ve la nueva. Es lo que hace que el dataset no sea una hoja de cálculo.&lt;/p>
&lt;p>Los casos con pulgar arriba también entran, en menor número, para que el dataset no sea solo fallos.&lt;/p>
&lt;h3 id="las-decisiones-de-los-encargados">Las decisiones de los encargados&lt;/h3>
&lt;p>El caso 2 tiene etiquetas gratis. Cada fila de &lt;code>decisiones_encargado&lt;/code> es un caso: la entrada es la conversación hasta la interrupción, la salida esperada es la decisión. Si el encargado aprobó tal cual, el agente propuso bien. Si editó el importe, el agente propuso mal y la etiqueta dice cuánto. Si rechazó con motivo, el agente no debió proponer.&lt;/p>
&lt;p>Y una etiqueta sobre la propia política: si &lt;code>when&lt;/code> no interrumpió y el cliente reclamó después, ese caso entra con &lt;code>debia_interrumpir: true&lt;/code>. Esa es la evaluación de la función &lt;code>when&lt;/code>, que no tiene modelo y se ajusta con estos datos.&lt;/p>
&lt;h3 id="la-muestra-de-catálogo">La muestra de catálogo&lt;/h3>
&lt;p>El caso 4 ya tiene una muestra semanal que lee una persona. Cada ficha leída, con el veredicto de la persona y sus criterios, es un caso del dataset &lt;code>fichas-dorado&lt;/code>. Es el único dataset de los cuatro donde el juez con modelo tiene sentido, y también el único donde hay con qué calibrarlo.&lt;/p>
&lt;h2 id="ejecutar-un-experimento">Ejecutar un experimento&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.experiment&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Evaluation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">dataset&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tienda-dorado&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">tarea&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">item&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entrada&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">item&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">entrada&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">empleado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;EVAL&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rol&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">entrada&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;rol&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">thread_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;eval:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">item&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamadas&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">escalo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[],&lt;/span> &lt;span class="kc">False&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">update&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">entrada&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pregunta&amp;#34;&lt;/span>&lt;span class="p">]}]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="s2">&amp;#34;max_concurrency&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">nodo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cambio&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">nodo&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamadas&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">cambio&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">respuesta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_state&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">}})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;llamadas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">llamadas&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;respuesta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">respuesta&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">dataset&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run_experiment&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tienda-prompt-v7&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">modelo_nombre&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">fecha&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">task&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tarea&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">evaluators&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">herramienta_correcta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">escalado_correcto&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cita_seccion&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">castellano&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sin_plazos&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_evaluators&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">tasa_escalado&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">max_concurrency&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;modelo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">modelo_nombre&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;prompt&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;v7&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resultado&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">format&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Lo que importa de la tarea: cada elemento corre en un hilo nuevo con un identificador que no colisiona con producción, con el contexto del caso y no de un usuario real, y devuelve lo estructurado además del texto. El checkpointer del agente de evaluación es un &lt;code>InMemorySaver&lt;/code>; no se quiere el dataset en la tabla &lt;code>checkpoints&lt;/code> de producción.&lt;/p>
&lt;p>&lt;code>run_experiment&lt;/code> acepta &lt;code>data=&lt;/code> con una lista local cuando no hay dataset en el servidor, y es lo que se usa en desarrollo: los mismos evaluadores sobre diez casos escritos a mano en un fichero. Verificado en el SDK 4.15.4 que el &lt;code>ExperimentResult&lt;/code> se construye igual, con &lt;code>item_results&lt;/code>, &lt;code>run_evaluations&lt;/code> y las medias, y que si el servidor no responde el resultado se produce igual y solo fallan las exportaciones de trazas.&lt;/p>
&lt;p>Con el dataset del servidor, cada elemento deja una traza enlazada a la ejecución, y la ejecución aparece en la pestaña de datasets con sus puntuaciones. Dos ejecuciones con &lt;code>run_name&lt;/code> distinto sobre el mismo dataset son comparables lado a lado en la interfaz; es el uso principal.&lt;/p>
&lt;h3 id="cuando-el-agente-interrumpe">Cuando el agente interrumpe&lt;/h3>
&lt;p>La tarea del caso 2 tiene que reanudar. El caso dorado lleva la decisión esperada, y la tarea la aplica:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">tarea_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">item&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;eval:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">item&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">item&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mensaje&amp;#34;&lt;/span>&lt;span class="p">]}]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx_de&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">item&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">interrumpio&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">propuesta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;action_requests&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">interrumpio&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">interrumpio&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">propuesta&lt;/span>&lt;span class="p">)}),&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx_de&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">item&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;interrumpio&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">interrumpio&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;propuesta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">propuesta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;respuesta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se reanuda siempre con &lt;code>approve&lt;/code> para que la respuesta final exista y se pueda evaluar el texto. Lo que se compara con la etiqueta del encargado es &lt;code>propuesta&lt;/code>, no la decisión de reanudación.&lt;/p>
&lt;h2 id="los-evaluadores">Los evaluadores&lt;/h2>
&lt;p>El contrato es una función con argumentos nominales &lt;code>input&lt;/code>, &lt;code>output&lt;/code>, &lt;code>expected_output&lt;/code> y &lt;code>metadata&lt;/code>, que devuelve un &lt;code>Evaluation&lt;/code> o una lista. Puede ser síncrona o asíncrona.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">herramienta_correcta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">metadata&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">esperada&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamadas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">c&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llamadas&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">esperada&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llamadas&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">esperada&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">llamadas&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;herramienta_correcta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ok&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">comment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;esperada=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">esperada&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> llamadas=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">llamadas&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">escalado_correcto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">escalo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">any&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">c&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;permiso_denegado&amp;#34;&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llamadas&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;respuesta&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;escalado_correcto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">escalo&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;escala&amp;#34;&lt;/span>&lt;span class="p">]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">cita_seccion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;seccion&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cita_seccion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">1.0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">comment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;no aplica&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cita_seccion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">expected_output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;seccion&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;respuesta&amp;#34;&lt;/span>&lt;span class="p">]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">sin_plazos&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">promete&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">re&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">r&lt;/span>&lt;span class="s2">&amp;#34;\b(\d+\s*(días|horas)|mañana|hoy mismo)\b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;respuesta&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">re&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">I&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sin_plazos&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">promete&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">tasa_escalado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">item_results&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">escalos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llamadas&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="nb">any&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">c&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;permiso_denegado&amp;#34;&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llamadas&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">item_results&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tasa_escalado&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">escalos&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">item_results&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cinco evaluadores, cero modelos. &lt;code>herramienta_correcta&lt;/code> y &lt;code>escalado_correcto&lt;/code> son las dos métricas del caso 1 que un responsable de tienda entiende. &lt;code>cita_seccion&lt;/code> comprueba que el identificador de sección está en la respuesta, no que la respuesta sea buena. &lt;code>sin_plazos&lt;/code> es una expresión regular, y es suficiente para detectar que una versión del prompt ha empezado a prometer entregas. &lt;code>tasa_escalado&lt;/code> es de ejecución: no compara con nada, describe la distribución, y sirve para ver que una versión del prompt escala el doble sin que ningún caso individual falle.&lt;/p>
&lt;p>Un evaluador de idioma, &lt;code>castellano&lt;/code>, usa un detector de idioma sobre la respuesta. Existe porque el caso 2 devuelve el rechazo del encargado en inglés al modelo y se ha visto al modelo contestar en inglés.&lt;/p>
&lt;h3 id="el-juez-con-modelo-y-su-calibración">El juez con modelo, y su calibración&lt;/h3>
&lt;p>Para C04 (afirmaciones no respaldadas) y C06 (tono de la casa) del caso 4 no hay expresión regular. Hace falta leer. El juez es el propio evaluador del caso 4, con su prompt y &lt;code>ProviderStrategy(Veredicto)&lt;/code>, ejecutado como evaluador del experimento:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">juez_c04_c06&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">v&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">evaluador_fichas&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">mensaje_evaluador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;comprobaciones&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;origen&amp;#34;&lt;/span>&lt;span class="p">])}]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">veredicto&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">v&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;structured_response&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rechazados&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">criterio&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">p&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">veredicto&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">problemas&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">esperados&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">expected_output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;criterios_rechazados&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;juez_aprobada_coincide&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">veredicto&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">aprobada&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">expected_output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">])),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;juez_c04_coincide&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="s2">&amp;#34;C04&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">rechazados&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;C04&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">esperados&lt;/span>&lt;span class="p">))),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Evaluation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;juez_c06_coincide&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="s2">&amp;#34;C06&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">rechazados&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;C06&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">esperados&lt;/span>&lt;span class="p">))),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Aquí el experimento no evalúa al generador. Evalúa al evaluador: cuánto coincide con la persona de catálogo sobre las fichas que la persona ya leyó. Es la calibración. Mientras &lt;code>juez_aprobada_coincide&lt;/code> esté por debajo del 90 por ciento sobre la muestra, el juez no se usa para nada más que para medirse a sí mismo, y lo que se toca es su prompt y sus criterios.&lt;/p>
&lt;p>Cuando pasa del umbral, se usa como evaluador del generador: el experimento del generador lleva &lt;code>juez_c04_c06&lt;/code> en sus evaluadores, y su puntuación se lee sabiendo que el juez acierta nueve de cada diez veces frente a una persona. Un juez que no se ha medido así es un segundo prompt opinando sobre el primero, y sus puntuaciones son números sin unidad.&lt;/p>
&lt;h2 id="comparar-dos-versiones">Comparar dos versiones&lt;/h2>
&lt;p>Lo que un experimento sirve para decidir es si la versión nueva es mejor que la vieja en las métricas que importan y no peor en las demás. Dos ejecuciones sobre el mismo dataset:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>run_name&lt;/code>&lt;/th>
&lt;th>herramienta_correcta&lt;/th>
&lt;th>escalado_correcto&lt;/th>
&lt;th>cita_seccion&lt;/th>
&lt;th>sin_plazos&lt;/th>
&lt;th>tasa_escalado&lt;/th>
&lt;th>tokens/caso&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>qwen3-30b-prompt-v6&lt;/code>&lt;/td>
&lt;td>0,94&lt;/td>
&lt;td>0,91&lt;/td>
&lt;td>0,88&lt;/td>
&lt;td>1,00&lt;/td>
&lt;td>0,21&lt;/td>
&lt;td>1.840&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>qwen3-30b-prompt-v7&lt;/code>&lt;/td>
&lt;td>0,96&lt;/td>
&lt;td>0,97&lt;/td>
&lt;td>0,95&lt;/td>
&lt;td>0,98&lt;/td>
&lt;td>0,19&lt;/td>
&lt;td>2.110&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Las cifras de esta tabla son el formato, no una medida: dependen del modelo, del dataset y del prompt de cada despliegue. Lo que la tabla enseña es cómo se lee. La v7 acierta más y cita más, y ha empezado a prometer plazos en el 2 por ciento de los casos. Eso es un caso concreto en &lt;code>item_results&lt;/code> con &lt;code>sin_plazos = 0&lt;/code> y un &lt;code>comment&lt;/code> con la frase, y se mira antes de decidir. Y cuesta un 15 por ciento más de tokens, que es lo que hay que poner en la balanza con el 6 por ciento de mejora en escalado.&lt;/p>
&lt;p>El mismo cuadro sirve para cambiar de modelo con el mismo prompt, que es la comparación que más se hace en un cluster propio cuando sale una versión nueva del modelo abierto. El artículo de &lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">versionado de prompts&lt;/a> cubre cómo se etiqueta la versión que se despliega; aquí solo importa que &lt;code>run_name&lt;/code> y &lt;code>metadata&lt;/code> lleven prompt y modelo, para que dentro de tres meses la tabla se entienda.&lt;/p>
&lt;h2 id="la-puerta-en-ci">La puerta en CI&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.experiment&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">RegressionError&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">dataset&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run_experiment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">medias&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">e&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">resultado&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run_evaluations&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">media_escalado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">sum&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">e&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">resultado&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">item_results&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">e&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">evaluations&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;escalado_correcto&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resultado&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">item_results&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">media_escalado&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mf">0.90&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">RegressionError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">resultado&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">metric&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;escalado_correcto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">media_escalado&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.90&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>RegressionError&lt;/code> está pensada para la acción &lt;code>langfuse/experiment-action&lt;/code> de GitHub, que la captura, falla el flujo y escribe el comentario en el PR. Con Forgejo no hay acción, y hace lo mismo un paso del flujo que ejecuta el script y deja que la excepción salga: código distinto de cero, flujo rojo. El &lt;code>ExperimentResult&lt;/code> se guarda como artefacto del flujo con &lt;code>format(include_item_results=True)&lt;/code> para leer los casos que fallaron sin abrir Langfuse.&lt;/p>
&lt;p>Qué métricas son puerta y cuáles son informe es una decisión por agente:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Agente&lt;/th>
&lt;th>Puerta (para el despliegue)&lt;/th>
&lt;th>Informe (se mira, no para)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tienda&lt;/td>
&lt;td>&lt;code>escalado_correcto&lt;/code> ≥ 0,90; &lt;code>sin_plazos&lt;/code> = 1,00&lt;/td>
&lt;td>&lt;code>herramienta_correcta&lt;/code>, &lt;code>cita_seccion&lt;/code>, tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cliente&lt;/td>
&lt;td>&lt;code>propuesta_coincide&lt;/code> ≥ 0,85; &lt;code>interrumpio_cuando_debia&lt;/code> = 1,00&lt;/td>
&lt;td>&lt;code>castellano&lt;/code>, tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compras&lt;/td>
&lt;td>&lt;code>accion_coincide&lt;/code> ≥ 0,80&lt;/td>
&lt;td>&lt;code>unidades_en_rango&lt;/code>, orden del plan&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fichas&lt;/td>
&lt;td>&lt;code>juez_aprobada_coincide&lt;/code> ≥ 0,90 (calibración)&lt;/td>
&lt;td>aprobación por intento, tokens&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Las puertas con &lt;code>= 1,00&lt;/code> son las que no admiten un caso: una promesa de plazo o una operación con dinero sin interrupción no es una regresión del 2 por ciento, es un incidente.&lt;/p>
&lt;p>Un experimento contra vLLM con un dataset de doscientos casos y &lt;code>max_concurrency=16&lt;/code> tarda unos minutos y cuesta lo que doscientas conversaciones. Corre en cada cambio de prompt o de modelo, no en cada commit; los tests del artículo anterior sí corren en cada commit y son gratis.&lt;/p>
&lt;h2 id="concurrencia-y-coste-del-experimento">Concurrencia y coste del experimento&lt;/h2>
&lt;p>&lt;code>run_experiment&lt;/code> tiene &lt;code>max_concurrency=50&lt;/code> por defecto. Es el número de tareas en vuelo, y cada tarea es una conversación entera con sus llamadas al modelo. Contra un vLLM detrás de una clave de gateway con &lt;code>max_parallel_requests&lt;/code> a 24, el valor por defecto son 26 tareas recibiendo 429 y reintentando. El valor correcto es el de la clave del agente de evaluación, que es distinta de la de producción para que un experimento no consuma el presupuesto de la tienda.&lt;/p>
&lt;p>Dentro de cada tarea, &lt;code>max_concurrency&lt;/code> del grafo aplica al abanico interno, como en el caso 3. Las dos capas se multiplican: 16 tareas por 4 de abanico son 64 peticiones potenciales, y eso es lo que tiene que caber.&lt;/p>
&lt;p>El coste se lee en Langfuse por la traza de cada elemento, agrupando por el &lt;code>run_name&lt;/code>. Un experimento del caso 1 sobre doscientos casos son unos 400.000 tokens de entrada con el prompt v7 de la tabla, la mayoría en prefijo compartido, y cuestan lo que el gateway diga que cuesta ese modelo, que en un cluster propio es la cifra que el &lt;a href="https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/">artículo de FinOps&lt;/a> explica cómo asignar.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Cada agente tiene un dataset con salida esperada estructurada, no texto.&lt;/li>
&lt;li>La aplicación escribe &lt;code>pulgar&lt;/code> con &lt;code>create_score&lt;/code> y alguien convierte los pulgares abajo en casos cada semana.&lt;/li>
&lt;li>Las decisiones de encargados y las ediciones de compras alimentan sus datasets sin trabajo manual.&lt;/li>
&lt;li>La tarea corre en hilos &lt;code>eval:&lt;/code> con &lt;code>InMemorySaver&lt;/code> y contexto del caso.&lt;/li>
&lt;li>Los evaluadores en código cubren herramienta, argumentos, escalado, cita, idioma y plazos.&lt;/li>
&lt;li>El juez con modelo se calibra contra la muestra humana antes de puntuar al generador.&lt;/li>
&lt;li>&lt;code>max_concurrency&lt;/code> del experimento es el de la clave de evaluación del gateway.&lt;/li>
&lt;li>&lt;code>run_name&lt;/code> y &lt;code>metadata&lt;/code> llevan prompt y modelo.&lt;/li>
&lt;li>Hay una tabla de qué métricas son puerta y cuáles informe, con umbrales.&lt;/li>
&lt;li>La puerta lanza &lt;code>RegressionError&lt;/code> y el flujo de CI la deja salir.&lt;/li>
&lt;li>El experimento corre en cambios de prompt o modelo; los tests, en cada commit.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>Comparar texto con texto.&lt;/strong> Es la evaluación más fácil de escribir y la que menos dice. Se compara la decisión: herramienta, argumentos, escalado, veredicto.&lt;/p>
&lt;p>&lt;strong>Un juez sin calibrar.&lt;/strong> Es un prompt puntuando a otro prompt. Primero se mide contra personas sobre la misma muestra; después se usa, y se usa sabiendo su acuerdo.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_concurrency=50&lt;/code> por defecto.&lt;/strong> Contra una clave de gateway de 24, son 429 desde el primer segundo. Se fija al valor de la clave.&lt;/p>
&lt;p>&lt;strong>El dataset en la tabla de checkpoints de producción.&lt;/strong> La tarea usa su propio checkpointer en memoria. Doscientos hilos &lt;code>eval:&lt;/code> por experimento en Postgres es lo que la retención acabaría borrando, pero mientras tanto están ahí.&lt;/p>
&lt;p>&lt;strong>El experimento con la clave de producción.&lt;/strong> Consume el presupuesto de la tienda y sus métricas de coste. Clave propia para evaluación.&lt;/p>
&lt;p>&lt;strong>Un experimento que pasa en la media y falla en un caso de dinero.&lt;/strong> Las puertas de &lt;code>= 1,00&lt;/code> existen para eso. Una media del 98 por ciento en &lt;code>sin_plazos&lt;/code> son cuatro promesas en doscientos casos.&lt;/p>
&lt;p>&lt;strong>El resultado se produce aunque Langfuse esté caído.&lt;/strong> Verificado: &lt;code>run_experiment&lt;/code> devuelve el &lt;code>ExperimentResult&lt;/code> con las puntuaciones y solo fallan las exportaciones. Un flujo de CI que no compruebe que las trazas llegaron puede estar evaluando sin dejar rastro.&lt;/p>
&lt;p>&lt;strong>El dataset que no crece.&lt;/strong> Un dataset de cuarenta casos escritos el primer día mide el primer día. Los pulgares, las decisiones y la muestra semanal son lo que lo mantiene vivo.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>La evaluación de un agente es un dataset que crece solo, evaluadores que en su mayoría son código, un juez con modelo que se ha medido antes contra personas, y una puerta con umbrales por agente. Nada de eso es específico de LangGraph; lo específico es qué se guarda como salida de la tarea, y es lo que el grafo ya expone: las herramientas que llamó, con qué argumentos, si interrumpió, qué veredicto dio. Con los tests del artículo anterior fijando el grafo y esto midiendo al modelo, un cambio de prompt o de modelo deja de ser una opinión y pasa a ser una tabla con dos filas.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/">Probar agentes de LangGraph sin modelo: tests de grafo con pytest&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">Agente de datos de producto con LangGraph: generador, evaluador y bucle acotado&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse como par operativo&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">Versionado de prompts con Langfuse y MLflow&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/">FinOps y multi-tenancy de GPU con LiteLLM&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/">Arquitectura y ajuste de Langfuse self-hosted&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Langfuse, datasets y experimentos: &lt;a href="https://langfuse.com/docs/evaluation/experiments/experiments-via-sdk">https://langfuse.com/docs/evaluation/experiments/experiments-via-sdk&lt;/a>&lt;/li>
&lt;li>Langfuse, puntuaciones (&lt;code>create_score&lt;/code>): &lt;a href="https://langfuse.com/docs/evaluation/evaluation-methods/custom-scores">https://langfuse.com/docs/evaluation/evaluation-methods/custom-scores&lt;/a>&lt;/li>
&lt;li>Código del SDK &lt;code>langfuse&lt;/code> 4.15.4: &lt;code>Langfuse.run_experiment&lt;/code>, &lt;code>Langfuse.create_dataset_item&lt;/code>, &lt;code>Langfuse.create_score&lt;/code>, &lt;code>langfuse.experiment&lt;/code> (&lt;code>Evaluation&lt;/code>, &lt;code>TaskFunction&lt;/code>, &lt;code>EvaluatorFunction&lt;/code>, &lt;code>RunEvaluatorFunction&lt;/code>, &lt;code>ExperimentResult&lt;/code>, &lt;code>RegressionError&lt;/code>)&lt;/li>
&lt;li>Acción &lt;code>langfuse/experiment-action&lt;/code> para GitHub: &lt;a href="https://github.com/langfuse/experiment-action">https://github.com/langfuse/experiment-action&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Probar agentes de LangGraph sin modelo: tests de grafo con un modelo guionizado, pytest y el checkpointer en memoria</title><link>https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/</link><pubDate>Thu, 17 Sep 2026 04:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un agente de LangGraph es un grafo con nodos deterministas y uno que no lo es. Todo lo que no es el modelo se prueba sin modelo: la estructura, las herramientas, el contexto, los límites, la persistencia, las interrupciones y el abanico. Es la mayor parte del código y la parte que rompe en silencio.&lt;/p>
&lt;p>El modelo se sustituye por &lt;code>FakeMessagesListChatModel&lt;/code> de &lt;code>langchain-core&lt;/code>, que devuelve una lista de mensajes en orden. Le falta &lt;code>bind_tools&lt;/code>, y &lt;code>create_agent&lt;/code> lo llama; un subtipo con &lt;code>bind_tools&lt;/code> que devuelva &lt;code>self&lt;/code> y guarde las herramientas que vio lo arregla. Ese subtipo es un &lt;code>BaseModel&lt;/code> de pydantic, y guardar algo en un atributo no declarado lanza &lt;code>ValueError&lt;/code>: el atributo se declara en la clase.&lt;/p>
&lt;p>Los tests cuentan checkpoints. &lt;code>InMemorySaver.list(config)&lt;/code> devuelve uno por superstep, y el número es una propiedad del grafo que cambia cuando se añade middleware. Once por turno con una herramienta en el caso 1, trece con el resumen; si un cambio lo mueve, el test lo dice antes que la base de datos.&lt;/p>
&lt;p>Escribir los tests sacó tres cosas que no estaban en los artículos. Cuando &lt;code>ToolCallLimitMiddleware&lt;/code> corta con &lt;code>exit_behavior=&amp;quot;end&amp;quot;&lt;/code>, el último mensaje es un &lt;code>AIMessage&lt;/code> sintético en inglés, &lt;code>Tool call limit reached: run limit exceeded (7/6 calls)&lt;/code>, y va al usuario si la aplicación no lo intercepta. El corte se detecta en &lt;code>stream_mode=&amp;quot;updates&amp;quot;&lt;/code> porque la actualización del nodo del límite lleva &lt;code>jump_to: &amp;quot;end&amp;quot;&lt;/code>. Y los contadores por ejecución no quedan en el estado del hilo; solo los del hilo.&lt;/p>
&lt;p>Las interrupciones se prueban de principio a fin: se invoca, se comprueba &lt;code>__interrupt__&lt;/code>, se reanuda con &lt;code>Command(resume=...)&lt;/code> y se comprueba que la herramienta corrió una vez con los argumentos de la decisión. El fallo a medias también: una herramienta que lanza en la primera llamada y el estado que queda antes de reanudar.&lt;/p>
&lt;p>Lo que estos tests no prueban es si el modelo elige bien la herramienta o redacta bien. Eso es evaluación, no prueba, y es el siguiente artículo.&lt;/p>
&lt;h2 id="estás-aquí-después-de-construir-antes-de-medir">Estás aquí: después de construir, antes de medir&lt;/h2>
&lt;p>La &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a> construyó cuatro agentes y los sirvió. Los artículos verificaron cada afirmación ejecutando código contra un modelo falso, y ese código era una sesión de terminal. Este artículo lo convierte en una suite de pytest que corre en cada commit, sin GPU y en menos de un segundo.&lt;/p>
&lt;p>La distinción que ordena todo lo que sigue: un test comprueba que el grafo hace lo que el código dice; una evaluación comprueba que el modelo hace lo que el negocio quiere. Lo primero es determinista y se ejecuta en CI. Lo segundo es estadístico, cuesta tokens y va en el &lt;a href="https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/">artículo de evaluación&lt;/a>.&lt;/p>
&lt;h2 id="la-analogía-el-simulacro-de-caja">La analogía: el simulacro de caja&lt;/h2>
&lt;p>Para formar a un dependiente nuevo no hace falta un cliente. Se le pone un compañero que hace de cliente con un guion: pide esto, luego pregunta aquello, luego se enfada. Lo que se comprueba no es si el compañero actúa bien; es si el dependiente sigue el procedimiento, si sabe qué botón del TPV tocar y cuándo llamar al encargado.&lt;/p>
&lt;p>El modelo falso es el compañero con guion. Los tests comprueban el procedimiento: las herramientas, el orden, el rol, la firma del encargado. El modelo real se evalúa después, con clientes reales grabados.&lt;/p>
&lt;h2 id="el-modelo-guionizado">El modelo guionizado&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_core.language_models.fake_chat_models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">FakeMessagesListChatModel&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_core.messages&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AIMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FakeMessagesListChatModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Devuelve los mensajes de `responses` en orden y acepta herramientas.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools_vistas&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">bind_tools&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tools_vistas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tools&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="bp">self&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nombre&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tool_calls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">nombre&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;c-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">nombre&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">}])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tres cosas de estas doce líneas.&lt;/p>
&lt;p>&lt;code>FakeMessagesListChatModel&lt;/code> viene en &lt;code>langchain-core&lt;/code> y devuelve &lt;code>responses[i]&lt;/code> en la llamada &lt;code>i&lt;/code>. Con un &lt;code>AIMessage&lt;/code> que lleva &lt;code>tool_calls&lt;/code>, el nodo &lt;code>model&lt;/code> del agente lo trata como una petición de herramienta y el grafo va a &lt;code>tools&lt;/code>. Con uno que lleva solo &lt;code>content&lt;/code>, el grafo termina. Un guion es una lista de esos mensajes en el orden en que el modelo &amp;ldquo;decidiría&amp;rdquo;.&lt;/p>
&lt;p>&lt;code>bind_tools&lt;/code> no existe en el falso y &lt;code>create_agent&lt;/code> lo llama en cada vuelta del modelo para pasarle las herramientas. Sin él, &lt;code>NotImplementedError&lt;/code> en la primera invocación. El subtipo devuelve &lt;code>self&lt;/code> y de paso guarda los nombres de las herramientas, que es un dato que los tests quieren: qué vio el modelo.&lt;/p>
&lt;p>&lt;code>tools_vistas&lt;/code> está declarado como campo de clase porque el modelo falso es un modelo de pydantic. Asignar &lt;code>self.tools_vistas = ...&lt;/code> sin declararlo lanza &lt;code>ValueError: &amp;quot;ModeloGuion&amp;quot; object has no field &amp;quot;tools_vistas&amp;quot;&lt;/code>. Fue el primer fallo de la suite.&lt;/p>
&lt;p>Para el resumen y para &lt;code>profile&lt;/code>, el falso admite &lt;code>profile={&amp;quot;max_input_tokens&amp;quot;: 32768}&lt;/code> en el constructor como cualquier &lt;code>BaseChatModel&lt;/code>; se pasa cuando el agente lleva &lt;code>SummarizationMiddleware&lt;/code> con disparador por fracción, o el constructor del middleware lanza.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-1">Lo que se prueba del caso 1&lt;/h2>
&lt;p>El agente del &lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">asistente de sala&lt;/a> se construye con una función &lt;code>build(modelo, checkpointer)&lt;/code>, y las herramientas leen de un diccionario de stock en el módulo de pruebas en vez de Postgres. Lo que sigue es la suite entera; corre en medio segundo.&lt;/p>
&lt;h3 id="la-estructura">La estructura&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_estructura_del_grafo&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span>&lt;span class="p">)]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">nodos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_graph&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">nodes&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">nodos&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;__start__&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;__end__&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;ToolCallLimitMiddleware.after_model&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">nodos&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;ModelCallLimitMiddleware.before_model&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">nodos&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>get_graph()&lt;/code> no ejecuta nada. Devuelve el grafo compilado con los nodos que el middleware añadió. Un test así falla cuando alguien quita un límite &amp;ldquo;para probar&amp;rdquo; y lo deja quitado, y cuando una subida de &lt;code>langchain&lt;/code> cambia el nombre de un nodo, que es algo que la aplicación del artículo 5 usa para filtrar el flujo.&lt;/p>
&lt;p>&lt;code>get_graph().draw_mermaid()&lt;/code> devuelve el diagrama en texto. Guardado como fichero de referencia y comparado en un test, es un test de instantánea del grafo: cualquier cambio de topología aparece en el diff del commit.&lt;/p>
&lt;h3 id="las-herramientas-y-el-contexto">Las herramientas y el contexto&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@pytest.fixture&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">dependiente&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">empleado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;E017&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rol&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;dependiente&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_stock_usa_la_tienda_del_contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-0912&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;No queda.&amp;#34;&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;¿queda?&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tool_msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msgs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;SKU-0912&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;unidades&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cercanas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;T002&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">14&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">modelo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tools_vistas&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;procedimiento&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;registrar_rotura&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El guion pide &lt;code>stock_sku&lt;/code> sin decir tienda, y la herramienta responde con &lt;code>T001&lt;/code> porque lo leyó del contexto. Si alguien cambia la herramienta para que la tienda venga como argumento del modelo, el test falla porque el guion no lo pasa. Es la garantía de que el contexto no se puede suplantar desde el prompt.&lt;/p>
&lt;p>La última aserción fija la lista de herramientas que ve el modelo, en orden. Un test de contrato: si se añade una herramienta, el test la reclama.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_dependiente_no_registra_rotura&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registrar_rotura&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-4471&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">unidades&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">motivo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;caída&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Avisa al encargado.&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registra&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tool_msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tool_msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;permiso_denegado&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_encargado_si_registra&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">encargado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Contexto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;T001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">empleado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;E001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rol&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registrar_rotura&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-4471&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">unidades&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">motivo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;caída&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Hecho.&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;registra&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">encargado&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Son los dos tests que importan de todo el artículo. El guion hace que el modelo pida registrar la rotura en los dos casos; lo que cambia es el contexto. Si la comprobación de rol se moviera al prompt, los dos tests pasarían con el guion y el segundo fallaría con un modelo real que obedeciera al prompt, pero el primero pasaría también con un modelo real que no lo obedeciera. La comprobación tiene que estar donde el guion no puede saltarla.&lt;/p>
&lt;h3 id="los-límites">Los límites&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_limite_de_llamadas_corta_sin_excepcion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SKU-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;bucle&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tool_msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolMessage&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ejecutadas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tool_msgs&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ejecutadas&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">6&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;limit&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tool_msgs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Un guion de veinte llamadas seguidas es un modelo en bucle. Con &lt;code>run_limit=6&lt;/code>, la primera versión del test comprobaba que había como máximo seis &lt;code>ToolMessage&lt;/code>, y falló: hay siete. El middleware, al bloquear la séptima, añade un &lt;code>ToolMessage&lt;/code> sintético con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code> y el texto &lt;code>Tool call limit exceeded. Do not make additional tool calls.&lt;/code> para esa llamada, y después un &lt;code>AIMessage&lt;/code> sintético: &lt;code>Tool call limit reached: run limit exceeded (7/6 calls)&lt;/code>. El test correcto cuenta las ejecutadas, que son las que no tienen &lt;code>status=&amp;quot;error&amp;quot;&lt;/code>.&lt;/p>
&lt;p>Ese &lt;code>AIMessage&lt;/code> final es el que la aplicación del artículo 5 envía al usuario como respuesta del agente, y está en inglés y lo escribe el middleware. &lt;code>ModelCallLimitMiddleware&lt;/code> hace lo mismo: &lt;code>Model call limits exceeded: run limit (3/3)&lt;/code>. La aplicación tiene que detectar el corte y sustituir el texto. La forma fiable no es buscar ese texto, es mirar el flujo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_el_corte_se_ve_en_updates&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SKU-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cortes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">update&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;bucle&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}},&lt;/span> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">nodo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cambio&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cambio&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">cambio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;jump_to&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;end&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cortes&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nodo&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">cortes&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ToolCallLimitMiddleware.after_model&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La actualización del nodo del límite lleva &lt;code>jump_to: &amp;quot;end&amp;quot;&lt;/code>, &lt;code>run_tool_call_count: {&amp;quot;__all__&amp;quot;: 7}&lt;/code> y los dos mensajes sintéticos. Verificado en langgraph 1.2.11 y langchain 1.4.1. Es el mismo bucle de &lt;code>updates&lt;/code> que el servicio ya recorre para detectar interrupciones, y añade una rama para los cortes.&lt;/p>
&lt;p>Un detalle más que salió del test: tras el corte, &lt;code>get_state(config).values&lt;/code> tiene &lt;code>thread_tool_call_count&lt;/code> y &lt;code>thread_model_call_count&lt;/code>, pero no los contadores por ejecución. &lt;code>run_tool_call_count&lt;/code> vive en la actualización y no se persiste. Un test que quiera comprobar el contador por ejecución lo lee del flujo, no del estado.&lt;/p>
&lt;h3 id="los-checkpoints">Los checkpoints&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_checkpoints_por_turno&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;stock_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;SKU-0912&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;t&amp;#34;&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;q&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dependiente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">_&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">cp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">11&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Un número fijo que documenta el coste del middleware. Cuando alguien añada &lt;code>SummarizationMiddleware&lt;/code>, el test dirá 13 y el commit tendrá que cambiar el número y, con él, la estimación de filas por día del artículo 5. Es un test que existe para obligar a esa conversación.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-2">Lo que se prueba del caso 2&lt;/h2>
&lt;p>La &lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">interrupción de aprobación&lt;/a> tiene un contrato de datos y una secuencia, y las dos cosas se prueban sin modelo.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">efectos&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">tuple&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">reembolsar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">float&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Emite un reembolso.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">efectos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reembolso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;R-1&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_reembolso_grande_para_y_no_ejecuta&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">efectos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">pedido&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;P1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">120.0&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Reembolso emitido.&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;h1&amp;#34;&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;devuelve P1&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">efectos&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">intr&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">intr&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;action_requests&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">intr&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;review_configs&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;allowed_decisions&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;edit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reject&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r2&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;edit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;edited_action&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;pedido&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;P1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;importe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">90.0&lt;/span>&lt;span class="p">}}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]}),&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">efectos&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;P1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">90.0&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">r2&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;Reembolso emitido.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_reembolso_pequeno_no_para&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">efectos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">pedido&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;P2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">20.0&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">AIMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Hecho.&amp;#34;&lt;/span>&lt;span class="p">)])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">build_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">InMemorySaver&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;devuelve P2&amp;#34;&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;h2&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">efectos&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;P2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">20.0&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Los dos tests fijan la política (&lt;code>when&lt;/code> por importe), el contrato de la interrupción (&lt;code>action_requests&lt;/code> y &lt;code>review_configs&lt;/code>), la reanudación con &lt;code>edit&lt;/code>, y la propiedad central: la herramienta no corre antes de la decisión y corre una vez después, con los argumentos editados. La lista &lt;code>efectos&lt;/code> es el instrumento; en producción es la tabla &lt;code>reembolsos&lt;/code>.&lt;/p>
&lt;p>El tercer test del caso 2 es el de la re-ejecución, con un nodo propio que registra efectos antes y después de &lt;code>interrupt&lt;/code> y comprueba la secuencia &lt;code>antes, antes, después&lt;/code>. Existe para que nadie ponga una escritura delante de una interrupción sin que un test lo diga.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-3">Lo que se prueba del caso 3&lt;/h2>
&lt;p>Del &lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">abanico&lt;/a> se prueban cuatro propiedades, y ninguna necesita el modelo: el trabajador se sustituye por una función que devuelve un análisis fijo.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_consolidar_espera_a_todos&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pasos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">u&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">keys&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">u&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">pasos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">count&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">pasos&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pasos&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_orden_de_analisis_es_el_del_reparto&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SKU-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">05d&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_fallo_de_un_lote_conserva_los_demas&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">monkeypatch&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fallos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;n&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">analizar_con_fallo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;referencias&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;SKU-00050&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">fallos&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;n&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fallos&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;n&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="ne">ConnectionError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;gateway&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">analizar_lote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">analizar&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">analizar_con_fallo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">retry_policy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pytest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">raises&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="ne">ConnectionError&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">st&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_state&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">st&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">150&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">st&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">next&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">,)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">200&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_cache_evita_la_segunda_pasada&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llamadas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">analizar&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">lambda&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">llamadas&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">analizar_lote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">))[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">cache&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">InMemoryCache&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skus&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;b&amp;#34;&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">llamadas&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">4&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El primero es el test de &lt;code>defer=True&lt;/code>: si alguien lo quita, &lt;code>consolidar&lt;/code> aparece antes del último lote. El segundo fija el orden determinista del reductor. El tercero es el más valioso: con un lote que falla y sin &lt;code>retry_policy&lt;/code>, el estado conserva los 150 análisis de los tres lotes que terminaron, &lt;code>next&lt;/code> apunta al trabajador, y &lt;code>invoke(None)&lt;/code> completa solo el que faltaba. El cuarto cuenta llamadas reales con y sin caché: cuatro lotes de 50 en la primera pasada y cero en la segunda.&lt;/p>
&lt;p>Lo que no se prueba aquí es la concurrencia. Se puede medir el pico de tareas en vuelo con un contador y un candado, como se hizo para el artículo, pero depende del número de CPU de la máquina de CI, y un test que pasa en el portátil y falla en el runner no es un test. La concurrencia se fija con &lt;code>max_concurrency&lt;/code> y se mide en el despliegue.&lt;/p>
&lt;h2 id="lo-que-se-prueba-del-caso-4">Lo que se prueba del caso 4&lt;/h2>
&lt;p>El &lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">bucle de fichas&lt;/a> tiene dos agentes y un contador. Con el modelo guionizado los dos agentes se controlan por separado:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_tres_rechazos_van_a_revision_manual&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">generador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_generador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Ficha&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">titulo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Cafetera 1L&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">descripcion&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;v&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">atributos&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{})&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">evaluador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_evaluador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Veredicto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">aprobada&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">problemas&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;criterio&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;C04&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;detalle&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span>&lt;span class="p">}])&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">_&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">g&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">generador&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">evaluador&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">g&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado_inicial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SKU-1&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">h&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;historial&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">ultimo_nodo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">g&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_titulo_largo_se_rechaza_aunque_el_modelo_apruebe&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">generador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_generador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Ficha&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">titulo&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">90&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">descripcion&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">atributos&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{})]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">evaluador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente_evaluador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ModeloGuion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">responses&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">llamada&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Veredicto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">aprobada&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">problemas&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[])]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pytest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">raises&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">StructuredOutputValidationError&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">construir_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">generador&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">evaluador&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado_inicial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SKU-1&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con &lt;code>ToolStrategy&lt;/code> en los tests, la salida estructurada es una llamada a herramienta con el nombre del esquema, y el guion la produce con &lt;code>llamada(&amp;quot;Ficha&amp;quot;, ...)&lt;/code>. En producción los agentes llevan &lt;code>ProviderStrategy&lt;/code>; en los tests se usa &lt;code>ToolStrategy(esquema, handle_errors=False)&lt;/code> a propósito, porque el modelo falso no genera JSON con esquema y porque con &lt;code>handle_errors=True&lt;/code>, el valor por defecto, un fallo de validación se convierte en un &lt;code>ToolMessage&lt;/code> de error y el agente vuelve a llamar al modelo, que en un guion es el siguiente mensaje de la lista y no un reintento. Con &lt;code>handle_errors=False&lt;/code> la excepción es &lt;code>StructuredOutputValidationError&lt;/code>, la misma que lanza &lt;code>ProviderStrategy&lt;/code> en producción; verificado con un título de 90 caracteres y &lt;code>max_length=10&lt;/code>. Es una de las pocas diferencias entre el agente probado y el desplegado, y se documenta en la fixture.&lt;/p>
&lt;p>El segundo test fija que la validación de pydantic manda por encima del evaluador: un título de 90 caracteres no llega a evaluarse.&lt;/p>
&lt;h2 id="dónde-corren-y-qué-no-cubren">Dónde corren y qué no cubren&lt;/h2>
&lt;p>La suite de los cuatro casos corre con &lt;code>InMemorySaver&lt;/code> e &lt;code>InMemoryCache&lt;/code>, sin Postgres, sin vLLM y sin red; los seis tests del caso 1 de arriba tardan medio segundo. Va en el pre-commit y en CI.&lt;/p>
&lt;p>Un segundo grupo, marcado con &lt;code>@pytest.mark.postgres&lt;/code>, repite los tests de checkpoint y de retención contra un Postgres real levantado en un contenedor: &lt;code>setup()&lt;/code> en una base limpia, el número de filas en &lt;code>checkpoints&lt;/code> y &lt;code>checkpoint_writes&lt;/code> tras un turno, &lt;code>delete_thread&lt;/code> y la consulta de retención por &lt;code>checkpoint-&amp;gt;&amp;gt;'ts'&lt;/code>. Corre en CI y no en el pre-commit. Es el grupo que detecta una subida de &lt;code>langgraph-checkpoint-postgres&lt;/code> que añade una migración o cambia una tabla.&lt;/p>
&lt;p>Lo que ninguno de los dos grupos cubre:&lt;/p>
&lt;ul>
&lt;li>Si el modelo elige &lt;code>stock_sku&lt;/code> cuando el dependiente pregunta por stock. El guion lo decide.&lt;/li>
&lt;li>Si la respuesta al cliente es correcta, está en castellano y no promete plazos.&lt;/li>
&lt;li>Si el evaluador de fichas aprueba lo que catálogo aprobaría.&lt;/li>
&lt;li>Si el planificador de compras ordena bien los proveedores.&lt;/li>
&lt;li>La latencia, los tokens y el coste.&lt;/li>
&lt;/ul>
&lt;p>Todo eso depende del modelo y se mide con datos, no se afirma con aserciones. Es el siguiente artículo.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Hay un &lt;code>ModeloGuion&lt;/code> con &lt;code>bind_tools&lt;/code> y un campo declarado para las herramientas vistas.&lt;/li>
&lt;li>Cada agente se construye con una función &lt;code>build(modelo, checkpointer)&lt;/code> que los tests pueden llamar.&lt;/li>
&lt;li>Las herramientas leen de un origen sustituible (diccionario en tests, Postgres en producción).&lt;/li>
&lt;li>Hay un test de estructura por agente y una instantánea Mermaid del grafo.&lt;/li>
&lt;li>Cada herramienta con rol tiene dos tests: uno que pasa por el rol y otro que no.&lt;/li>
&lt;li>Los tests de límite cuentan ejecuciones, no &lt;code>ToolMessage&lt;/code>, y comprueban &lt;code>jump_to&lt;/code> en &lt;code>updates&lt;/code>.&lt;/li>
&lt;li>Hay un test de checkpoints por turno con el número exacto.&lt;/li>
&lt;li>Las interrupciones se prueban con &lt;code>invoke&lt;/code>, &lt;code>__interrupt__&lt;/code>, &lt;code>Command(resume)&lt;/code> y una lista de efectos.&lt;/li>
&lt;li>El abanico tiene tests de &lt;code>defer&lt;/code>, de orden, de fallo parcial y de caché.&lt;/li>
&lt;li>Los agentes de salida estructurada se prueban con &lt;code>ToolStrategy&lt;/code> y la diferencia está documentada.&lt;/li>
&lt;li>Los tests que necesitan Postgres van en un grupo aparte que corre en CI.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El modelo falso no tiene &lt;code>bind_tools&lt;/code>.&lt;/strong> &lt;code>NotImplementedError&lt;/code> en la primera invocación de &lt;code>create_agent&lt;/code>. Un subtipo de tres líneas.&lt;/p>
&lt;p>&lt;strong>El subtipo es pydantic.&lt;/strong> Un atributo no declarado lanza &lt;code>ValueError&lt;/code>. Declararlo en la clase.&lt;/p>
&lt;p>&lt;strong>El corte por límite deja siete &lt;code>ToolMessage&lt;/code> con &lt;code>run_limit=6&lt;/code>.&lt;/strong> El séptimo es sintético, con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code>. Contar las ejecutadas.&lt;/p>
&lt;p>&lt;strong>El mensaje final del corte es inglés fijo del middleware.&lt;/strong> &lt;code>Tool call limit reached: run limit exceeded (7/6 calls)&lt;/code>. Detectarlo por &lt;code>jump_to&lt;/code> en &lt;code>updates&lt;/code>, no por el texto, y sustituirlo antes de enviarlo.&lt;/p>
&lt;p>&lt;strong>Los contadores por ejecución no se persisten.&lt;/strong> &lt;code>run_tool_call_count&lt;/code> está en la actualización del nodo, no en &lt;code>get_state&lt;/code>. Los del hilo sí.&lt;/p>
&lt;p>&lt;strong>Un test de concurrencia depende de la máquina.&lt;/strong> El pool de hilos por defecto es cpu+4. No se afirma en CI; se mide en el despliegue.&lt;/p>
&lt;p>&lt;strong>&lt;code>ToolStrategy&lt;/code> en los tests, &lt;code>ProviderStrategy&lt;/code> en producción.&lt;/strong> Es una diferencia real y hay que escribirla en la fixture, no descubrirla en un incidente.&lt;/p>
&lt;p>&lt;strong>Un guion que pasa no dice que el modelo pasaría.&lt;/strong> Los tests prueban el grafo con las decisiones fijadas. Las decisiones se evalúan aparte.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>Cuatro agentes probados en segundos y sin modelo. Lo que se prueba es todo lo que un modelo no puede romper y un desarrollador sí: qué herramienta ve el modelo, qué hace cada una con cada rol, cuándo se para, cuántas filas deja, y qué queda cuando algo falla a medias. Lo que no se prueba es lo que solo el modelo decide, y para eso hacen falta datos reales, un evaluador y una forma de comparar dos versiones. Es lo que viene a continuación.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/">Evaluar agentes de LangGraph con datasets de Langfuse&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Modelos falsos de &lt;code>langchain-core&lt;/code> (&lt;code>FakeMessagesListChatModel&lt;/code>, &lt;code>GenericFakeChatModel&lt;/code>): &lt;a href="https://reference.langchain.com/python/langchain_core/language_models/fake_chat_models/">https://reference.langchain.com/python/langchain_core/language_models/fake_chat_models/&lt;/a>&lt;/li>
&lt;li>Middleware de LangChain 1.x (&lt;code>ToolCallLimitMiddleware&lt;/code>, &lt;code>ModelCallLimitMiddleware&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langchain/middleware">https://docs.langchain.com/oss/python/langchain/middleware&lt;/a>&lt;/li>
&lt;li>Persistencia en LangGraph (&lt;code>InMemorySaver&lt;/code>, &lt;code>get_state&lt;/code>, &lt;code>list&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langchain.agents.middleware.tool_call_limit&lt;/code> (langchain 1.4.1), método &lt;code>after_model&lt;/code>&lt;/li>
&lt;li>pytest: &lt;a href="https://docs.pytest.org/en/stable/">https://docs.pytest.org/en/stable/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Servir agentes de LangGraph sin langgraph-api: autorización por hilo, drenado, retención y sondas para los cuatro casos de retail</title><link>https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/</link><pubDate>Thu, 17 Sep 2026 03:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El servidor HTTP de LangGraph no se despliega. &lt;code>langgraph-api&lt;/code> tiene licencia Elastic 2.0, pide &lt;code>LANGGRAPH_CLOUD_LICENSE_KEY&lt;/code> en producción y envía metadatos a un endpoint de LangChain salvo en una variante contractual air-gapped. Lo documentó el artículo de deepagents con fichero y línea. El motor, los checkpointers y &lt;code>create_agent&lt;/code> son MIT y son lo único que hace falta.&lt;/p>
&lt;p>Lo que ese servidor daba y hay que escribir: la API de hilos y ejecuciones, la autorización, la cola, el drenado, la retención, las migraciones y las sondas. Son unas cuatrocientas líneas de FastAPI por caso más un CronJob y un Job. No es poco y no es un servidor de colas: es lo justo para cuatro agentes con perfiles de carga distintos.&lt;/p>
&lt;p>El checkpointer no comprueba propietarios. Cualquier &lt;code>thread_id&lt;/code> que llegue en la configuración se lee y se reanuda. Verificado además que un hilo inexistente devuelve estado vacío y &lt;code>next&lt;/code> vacío, sin error: un identificador mal formado no se distingue de uno ajeno. La autorización es una tabla &lt;code>hilos&lt;/code> con propietario, tienda y caso, y un identificador que genera el servidor.&lt;/p>
&lt;p>El drenado existe y funciona. &lt;code>RunControl.request_drain()&lt;/code> desde el manejador de SIGTERM hace que la ejecución en curso termine el superstep actual, guarde el checkpoint y lance &lt;code>GraphDrained&lt;/code>. Verificado: el estado queda en el límite del superstep y &lt;code>invoke(None, config)&lt;/code> lo reanuda desde ahí. La herramienta en vuelo en ese superstep se repite; por eso la idempotencia del caso 2.&lt;/p>
&lt;p>La retención es un CronJob que borra hilos enteros con &lt;code>delete_thread&lt;/code>. Es la única operación de borrado implementada en el checkpointer de Postgres 3.1.2, y la propia clase base avisa de que una poda parcial puede vaciar canales en silencio. La fecha para decidir qué borrar está dentro del JSONB de la fila de checkpoint, en &lt;code>checkpoint-&amp;gt;&amp;gt;'ts'&lt;/code>, no en una columna.&lt;/p>
&lt;p>Las migraciones corren en un Job con permiso de DDL y sin pooler en modo transacción, porque &lt;code>setup()&lt;/code> crea índices con &lt;code>CREATE INDEX CONCURRENTLY&lt;/code>. Los pods del agente conectan con un rol sin DDL y con &lt;code>prepare_threshold=0&lt;/code> si hay PgBouncer.&lt;/p>
&lt;h2 id="estás-aquí-el-último-de-la-serie-y-el-único-sin-agente">Estás aquí: el último de la serie, y el único sin agente&lt;/h2>
&lt;p>Este es el artículo 5 de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a>. Los cuatro anteriores construyen agentes y los invocan desde Python. Este los pone detrás de una URL en el cluster, con todo lo que un &lt;code>invoke&lt;/code> en un cuaderno no tiene: quién puede hablar con qué hilo, qué pasa cuando el pod se reinicia a medias, cuánto tiempo se guarda una conversación, y cómo sabe Kubernetes que el servicio está vivo.&lt;/p>
&lt;p>La decisión de fondo la tomó el &lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">artículo de deepagents&lt;/a>: el motor sí, el servidor no. Aquí se paga esa decisión.&lt;/p>
&lt;h2 id="la-analogía-el-mostrador-y-la-trastienda">La analogía: el mostrador y la trastienda&lt;/h2>
&lt;p>Los cuatro agentes son cuatro dependientes que ya saben hacer su trabajo. Lo que no tienen es mostrador: quién los llama, cómo se sabe que la persona al otro lado es quien dice, qué pasa con la conversación cuando el dependiente se va a comer, y quién tira los vales de hace un mes.&lt;/p>
&lt;p>El servidor de LangGraph es un mostrador de alquiler, con contrato. Lo que sigue es construir uno propio, más pequeño, con solo lo que estos cuatro necesitan.&lt;/p>
&lt;h2 id="lo-que-se-pierde-y-lo-que-se-escribe">Lo que se pierde y lo que se escribe&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lo que daba &lt;code>langgraph-api&lt;/code>&lt;/th>
&lt;th>Lo que se hace aquí&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>API de asistentes, hilos y ejecuciones&lt;/td>
&lt;td>FastAPI con cuatro rutas por caso: crear hilo, enviar mensaje, reanudar, leer estado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autenticación y autorización por hilo&lt;/td>
&lt;td>JWT de Keycloak en el gateway y tabla &lt;code>hilos&lt;/code> con propietario&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cola de ejecuciones y reintentos&lt;/td>
&lt;td>Semáforo de ejecuciones en vuelo por pod y 503 con &lt;code>Retry-After&lt;/code>; lo que necesita una cola con reintentos y visibilidad va a Temporal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cron&lt;/td>
&lt;td>&lt;code>CronJob&lt;/code> de Kubernetes que invoca el caso 3 a las seis&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Studio&lt;/td>
&lt;td>Trazas en Langfuse y &lt;code>get_state_history&lt;/code> por hilo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Streaming&lt;/td>
&lt;td>&lt;code>astream&lt;/code> sobre SSE&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drenado en despliegues&lt;/td>
&lt;td>&lt;code>RunControl&lt;/code> en el &lt;code>SIGTERM&lt;/code> y reanudación desde la aplicación&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retención&lt;/td>
&lt;td>&lt;code>CronJob&lt;/code> con &lt;code>delete_thread&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Migraciones&lt;/td>
&lt;td>&lt;code>Job&lt;/code> con &lt;code>setup()&lt;/code> y rol con DDL&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Lo que no se pierde: el checkpointer, el almacén, la caché, las interrupciones, los subgrafos y el middleware. Todo eso es del motor.&lt;/p>
&lt;h2 id="el-servicio">El servicio&lt;/h2>
&lt;p>Un &lt;code>Deployment&lt;/code> por caso, con la misma imagen y una variable de entorno que dice qué grafo carga. Los cuatro casos tienen perfiles distintos, y separarlos es lo que permite dimensionar y limitar cada uno con su clave del gateway: el asistente de sala es interactivo y de latencia baja; el de cliente es interactivo y con hilos que duran días; el de compras es una pasada diaria de minutos; el de fichas es un lote continuo.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">contextlib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">asynccontextmanager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">fastapi&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">FastAPI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">psycopg_pool&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AsyncConnectionPool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.checkpoint.postgres.aio&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AsyncPostgresSaver&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@asynccontextmanager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">lifespan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">FastAPI&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AsyncConnectionPool&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;PG_AGENTES&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">min_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">max_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">kwargs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;autocommit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;prepare_threshold&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">open&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">checkpointer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AsyncPostgresSaver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">grafo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cargar_grafo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;CASO&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">en_vuelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{}&lt;/span> &lt;span class="c1"># thread_id -&amp;gt; RunControl&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">semaforo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Semaphore&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">int&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;MAX_EJECUCIONES&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="p">)))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">drenando&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">False&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">FastAPI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lifespan&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">lifespan&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>AsyncPostgresSaver&lt;/code> se construye sobre el pool, no sobre una conexión. &lt;code>autocommit=True&lt;/code> es lo que el checkpointer espera, y &lt;code>prepare_threshold=0&lt;/code> desactiva las sentencias preparadas de psycopg, que PgBouncer en modo transacción no soporta. &lt;code>setup()&lt;/code> no se llama aquí.&lt;/p>
&lt;h3 id="crear-un-hilo">Crear un hilo&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@app.post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;/hilos&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">status_code&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">201&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">crear_hilo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cuerpo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">NuevoHilo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Usuario&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">usuario_actual&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">thread_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;INSERT INTO hilos (thread_id, caso, propietario, tienda, creado) VALUES (&lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, now())&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;CASO&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sub&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El identificador lo genera el servidor. El cliente no lo elige nunca, ni siquiera con un prefijo. Y la fila de &lt;code>hilos&lt;/code> se escribe antes de que exista ningún checkpoint, porque es la que autoriza todo lo demás.&lt;/p>
&lt;p>&lt;code>usuario_actual&lt;/code> lee el JWT que Keycloak emitió y que el gateway ya validó, y saca &lt;code>sub&lt;/code>, el rol y la tienda de sus claims. Cómo se configura ese realm y qué claims lleva está en el &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">artículo de Keycloak&lt;/a>; aquí solo se consume.&lt;/p>
&lt;h3 id="autorizar-un-hilo">Autorizar un hilo&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">hilo_autorizado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Usuario&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">usuario_actual&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">Hilo&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fila&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="k">await&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;SELECT thread_id, caso, propietario, tienda FROM hilos WHERE thread_id = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">))&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchone&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">fila&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">fila&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;CASO&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">404&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">hilo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Hilo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">fila&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">propietario&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sub&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rol&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">404&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">hilo&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dos detalles. Un hilo ajeno devuelve 404, no 403, para no confirmar que existe. Y la regla del encargado es la del caso 2: un encargado de la misma tienda puede leer y reanudar los hilos de sus clientes, que es lo que la cola de aprobaciones necesita.&lt;/p>
&lt;p>Esto es todo lo que el checkpointer no hace. Verificado en langgraph 1.2.11 con &lt;code>InMemorySaver&lt;/code> y en el código del de Postgres: &lt;code>get_state&lt;/code> con cualquier &lt;code>thread_id&lt;/code> devuelve lo que haya, y con uno que no existe devuelve &lt;code>values={}&lt;/code> y &lt;code>next=()&lt;/code> sin lanzar nada. Sin la tabla, un cliente que adivine o construya un identificador lee la conversación de otro.&lt;/p>
&lt;h3 id="enviar-un-mensaje-en-streaming">Enviar un mensaje, en streaming&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.runtime&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">RunControl&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.errors&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">GraphDrained&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@app.post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;/hilos/&lt;/span>&lt;span class="si">{thread_id}&lt;/span>&lt;span class="s2">/mensajes&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">enviar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Mensaje&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Hilo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hilo_autorizado&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Usuario&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">usuario_actual&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">drenando&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">503&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">headers&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;Retry-After&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">semaforo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">locked&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">503&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">headers&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;Retry-After&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">contexto_para&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;caso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">caso&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;langfuse_trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">langfuse_handler&lt;/span>&lt;span class="p">()],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;max_concurrency&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;MAX_CONCURRENCY&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">control&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">RunControl&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">flujo&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">semaforo&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">en_vuelo&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">control&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">modo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunk&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">astream&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">texto&lt;/span>&lt;span class="p">}]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">durability&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sync&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">control&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">control&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">modo&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;langgraph_node&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span> &lt;span class="n">evento&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;token&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">elif&lt;/span> &lt;span class="n">modo&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;updates&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">registrar_aprobacion_pendiente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span> &lt;span class="n">evento&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;interrupcion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;interrupt_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span> &lt;span class="n">evento&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="n">GraphDrained&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span> &lt;span class="n">evento&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;drenado&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;reanudable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">finally&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">en_vuelo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pop&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">EventSourceResponse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">flujo&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>stream_mode&lt;/code> como lista devuelve tuplas &lt;code>(modo, contenido)&lt;/code>; verificado que &lt;code>[&amp;quot;updates&amp;quot;, &amp;quot;values&amp;quot;]&lt;/code> intercala los dos modos por superstep. Con &lt;code>messages&lt;/code> se filtran los trozos del nodo &lt;code>model&lt;/code> para el cliente, y con &lt;code>updates&lt;/code> se detecta la interrupción en cuanto se produce, en vez de esperar al final para mirar &lt;code>__interrupt__&lt;/code> en el resultado. Es lo que permite escribir la cola de aprobaciones del caso 2 dentro del flujo.&lt;/p>
&lt;p>&lt;code>durability=&amp;quot;sync&amp;quot;&lt;/code> en todo lo interactivo. Cuesta una escritura síncrona por superstep, que en Postgres local son milisegundos, y es lo que hace que el drenado y la reanudación funcionen.&lt;/p>
&lt;h3 id="reanudar">Reanudar&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@app.post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;/hilos/&lt;/span>&lt;span class="si">{thread_id}&lt;/span>&lt;span class="s2">/reanudar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">reanudar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Reanudacion&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Hilo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hilo_autorizado&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Usuario&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">usuario_actual&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">estado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">aget_state&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">estado&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">next&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">409&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;el hilo no está esperando nada&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decisiones&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">usuario&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rol&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">403&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entrada&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decisiones&lt;/span>&lt;span class="p">})&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decisiones&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">contexto_desde_cola&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">cuerpo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decisiones&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">contexto_para&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span> &lt;span class="c1"># mismo flujo que enviar, con `entrada` en vez del mensaje&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dos reanudaciones distintas pasan por aquí. Con &lt;code>decisiones&lt;/code>, es la aprobación del caso 2: &lt;code>Command(resume=...)&lt;/code>, solo encargados, y el contexto se carga de la cola porque el &lt;code>runtime&lt;/code> no lo recuerda. Sin &lt;code>decisiones&lt;/code>, es la reanudación tras un drenado: &lt;code>invoke(None)&lt;/code> sobre el hilo, que continúa desde el último checkpoint. La comprobación de &lt;code>estado.next&lt;/code> distingue un hilo que espera de uno que terminó: reanudar un hilo terminado no falla, vuelve a ejecutar desde el final, y eso es un turno del modelo que nadie pidió.&lt;/p>
&lt;h2 id="el-drenado">El drenado&lt;/h2>
&lt;p>Un &lt;code>Deployment&lt;/code> con estrategia rolling mata pods con ejecuciones en curso. Sin nada más, la ejecución muere donde esté, el último checkpoint es el del superstep anterior, y el siguiente &lt;code>invoke&lt;/code> sobre el hilo repite desde ahí, herramienta en vuelo incluida.&lt;/p>
&lt;p>&lt;code>RunControl&lt;/code> es el mecanismo cooperativo. Se crea uno por ejecución, se pasa en &lt;code>control=&lt;/code>, y &lt;code>request_drain()&lt;/code> desde cualquier hilo hace que el motor termine el superstep actual, escriba el checkpoint y lance &lt;code>GraphDrained&lt;/code>. Verificado con un grafo de seis pasos y un drenado a mitad: &lt;code>invoke&lt;/code> lanza &lt;code>GraphDrained&lt;/code> con la razón, &lt;code>get_state&lt;/code> muestra tres pasos hechos y &lt;code>next&lt;/code> en el cuarto, y &lt;code>invoke(None, config)&lt;/code> lo termina.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">signal&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">manejar_sigterm&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">drenando&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">control&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">en_vuelo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">()):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">control&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">request_drain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;rolling&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">signal&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">signal&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">SIGTERM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">manejar_sigterm&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con &lt;code>terminationGracePeriodSeconds&lt;/code> por encima de la duración del superstep más largo (una llamada al modelo con su salida, unos 30 segundos en el peor caso de los casos interactivos; un lote entero de 50 referencias en el caso 3, que puede ser un minuto), el pod termina limpio. El cliente recibe el evento &lt;code>drenado&lt;/code> con &lt;code>reanudable: true&lt;/code> y vuelve a llamar a &lt;code>reanudar&lt;/code> sin decisiones, que cae en otro pod y continúa.&lt;/p>
&lt;p>Lo que el drenado no evita es que el superstep en curso se complete: si es una herramienta lenta, hay que esperarla. Y lo que no cubre es un &lt;code>SIGKILL&lt;/code> por límite de memoria, donde no hay cooperación posible: ahí vuelve la re-ejecución de la herramienta en vuelo y la idempotencia por &lt;code>tool_call_id&lt;/code> del caso 2.&lt;/p>
&lt;p>Para el caso 3, el drenado se combina con lotes pequeños: cuanto menos dura el superstep del abanico, antes termina el pod y menos trabajo se repite si no termina.&lt;/p>
&lt;h2 id="la-retención">La retención&lt;/h2>
&lt;p>El checkpointer de Postgres 3.1.2 tiene una operación de borrado, &lt;code>delete_thread&lt;/code>. &lt;code>prune&lt;/code>, &lt;code>copy_thread&lt;/code> y &lt;code>delete_for_runs&lt;/code> heredan de la clase base y lanzan &lt;code>NotImplementedError&lt;/code>, y el docstring de &lt;code>prune&lt;/code> en esa clase base explica por qué no hay que escribirse una poda parcial a mano: los canales delta guardan solo un centinela fuera de los puntos de instantánea, y quedarse con el último checkpoint de una cadena lo deja reconstruyéndose vacío sin error.&lt;/p>
&lt;p>Así que se borran hilos enteros, por antigüedad, y la antigüedad se saca del JSONB:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Hilos del caso &amp;#39;tienda&amp;#39; sin actividad en 7 días
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">checkpoints&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hilos&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">caso&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;tienda&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">checkpoint_ns&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">max&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">checkpoint&lt;/span>&lt;span class="o">-&amp;gt;&amp;gt;&lt;/span>&lt;span class="s1">&amp;#39;ts&amp;#39;&lt;/span>&lt;span class="p">)::&lt;/span>&lt;span class="n">timestamptz&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>checkpoints&lt;/code> no tiene columna de fecha. El &lt;code>checkpoint_id&lt;/code> es un UUID versión 6 ordenable por tiempo, y el propio checkpoint lleva &lt;code>ts&lt;/code> en ISO 8601; verificado en ambos casos. El &lt;code>HAVING&lt;/code> sobre &lt;code>ts&lt;/code> es más legible que ordenar por el identificador y da lo mismo.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">retencion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">caso&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">dias&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">200&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">filas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="k">await&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CONSULTA&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">caso&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">dias&lt;/span>&lt;span class="p">)))&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchall&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,)&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">filas&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">checkpointer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">adelete_thread&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;UPDATE hilos SET borrado = now() WHERE thread_id = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Corre como &lt;code>CronJob&lt;/code> cada noche, con un lote acotado para no bloquear las tablas durante minutos. &lt;code>delete_thread&lt;/code> borra &lt;code>checkpoints&lt;/code>, &lt;code>checkpoint_blobs&lt;/code> y &lt;code>checkpoint_writes&lt;/code> para todos los espacios de nombres del hilo, subgrafos incluidos. La fila de &lt;code>hilos&lt;/code> no se borra: se marca, porque las trazas de Langfuse y las filas de negocio siguen apuntando a ese &lt;code>thread_id&lt;/code>.&lt;/p>
&lt;p>Los plazos por caso: 7 días para el asistente de sala, 90 para el de cliente (una devolución puede reclamarse), 30 para compras (las propuestas ya están en su tabla), y 7 para fichas (el resultado ya está en &lt;code>fichas&lt;/code>). Son decisiones de negocio y se leen de una tabla, no del código.&lt;/p>
&lt;p>El almacén (&lt;code>PostgresStore&lt;/code>) es aparte y sí tiene TTL, con un barredor en proceso; en esta serie no se usa el almacén, así que no hay barredor que dimensionar.&lt;/p>
&lt;h2 id="las-migraciones">Las migraciones&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># job_migraciones.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.checkpoint.postgres&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PostgresSaver&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">PostgresSaver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_conn_string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;PG_AGENTES_DDL&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">saver&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">saver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">setup&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Un &lt;code>Job&lt;/code> de Kubernetes, con un rol que tiene &lt;code>CREATE&lt;/code> en el esquema, conectado directamente a Postgres y no al pooler. &lt;code>setup()&lt;/code> aplica diez migraciones idempotentes: cuatro tablas, un &lt;code>ALTER&lt;/code>, tres índices con &lt;code>CREATE INDEX CONCURRENTLY&lt;/code> y una columna añadida; lo lleva en &lt;code>checkpoint_migrations&lt;/code>. Los índices concurrentes no pueden ir dentro de una transacción, y PgBouncer en modo transacción envuelve cada sentencia en una: falla. Directo a Postgres, una vez, antes del primer despliegue y después de cada subida de &lt;code>langgraph-checkpoint-postgres&lt;/code>.&lt;/p>
&lt;p>Los pods conectan con un rol que solo tiene &lt;code>SELECT&lt;/code>, &lt;code>INSERT&lt;/code>, &lt;code>UPDATE&lt;/code> y &lt;code>DELETE&lt;/code> sobre las cuatro tablas. Si alguien despliega una versión nueva del paquete sin correr el Job, el arranque falla con un error de esquema, ruidoso, en la primera petición.&lt;/p>
&lt;h2 id="las-sondas">Las sondas&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@app.get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;/salud/vivo&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">vivo&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@app.get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;/salud/listo&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">listo&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">drenando&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">503&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SELECT 1 FROM checkpoint_migrations LIMIT 1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="ne">Exception&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">503&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;en_vuelo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">en_vuelo&lt;/span>&lt;span class="p">)}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La sonda de vida no toca nada externo; un pod cuya base de datos está caída no se reinicia, porque reiniciarlo no arregla la base. La de disponibilidad comprueba que hay conexión y que las migraciones existen, y devuelve 503 en cuanto empieza el drenado para que el &lt;code>Service&lt;/code> deje de enviarle tráfico nuevo mientras termina el que tiene.&lt;/p>
&lt;p>El gateway de inferencia no está en la sonda. Un LiteLLM caído se ve en la primera petición como un error del modelo, que &lt;code>ModelRetryMiddleware&lt;/code> reintenta y que acaba en un mensaje al usuario. Meterlo en la sonda de disponibilidad haría que los cuatro servicios se marcaran como no listos a la vez, sin que ninguno pudiera hacer nada al respecto.&lt;/p>
&lt;h2 id="capacidad-y-contrapresión">Capacidad y contrapresión&lt;/h2>
&lt;p>Cada pod corre un proceso de uvicorn con un bucle de eventos y un semáforo de ejecuciones en vuelo. El valor del semáforo y &lt;code>max_concurrency&lt;/code> se fijan por caso:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Caso&lt;/th>
&lt;th>&lt;code>MAX_EJECUCIONES&lt;/code> por pod&lt;/th>
&lt;th>&lt;code>MAX_CONCURRENCY&lt;/code>&lt;/th>
&lt;th>Réplicas&lt;/th>
&lt;th>Razón&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tienda&lt;/td>
&lt;td>32&lt;/td>
&lt;td>4&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Muchas conversaciones cortas; el abanico interno es pequeño&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cliente&lt;/td>
&lt;td>16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Menos tráfico, hilos largos con interrupciones&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compras&lt;/td>
&lt;td>1&lt;/td>
&lt;td>24&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Una pasada a la vez, con el abanico limitado a la clave del gateway&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fichas&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Lote continuo; el paralelismo lo da el número de fichas en vuelo&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Cuando el semáforo está lleno, 503 con &lt;code>Retry-After&lt;/code>. No hay cola en el servicio. Las ejecuciones que necesitan cola, reintentos con retroceso y visibilidad de qué está esperando (el caso 3 si se lanzara bajo demanda, o cualquier lote largo) van a un orquestador; el &lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">artículo sobre Temporal&lt;/a> explica cuándo compensa y cuánto cuesta.&lt;/p>
&lt;p>La suma de &lt;code>MAX_EJECUCIONES&lt;/code> por &lt;code>MAX_CONCURRENCY&lt;/code> por réplicas de los cuatro casos es el máximo de peticiones simultáneas que pueden llegar al gateway desde los agentes, y tiene que caber en lo que &lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">se dimensionó para la flota&lt;/a>. Con la tabla de arriba son 256 + 128 + 24 + 16.&lt;/p>
&lt;h2 id="observabilidad">Observabilidad&lt;/h2>
&lt;p>Cada petición crea un &lt;code>CallbackHandler&lt;/code> de Langfuse con el &lt;code>trace_id&lt;/code> que la aplicación generó, y ese &lt;code>trace_id&lt;/code> viaja en &lt;code>metadata&lt;/code> para que las herramientas lo escriban en las filas de negocio. Los nodos del grafo aparecen como spans anidados; con los subgrafos desactivados en los casos 3 y 4, la traza tiene la profundidad que el grafo del padre y no más.&lt;/p>
&lt;p>La alternativa OTel, con &lt;code>LANGSMITH_TRACING_MODE=otel&lt;/code> y el exportador OTLP del SDK de LangSmith hacia el endpoint OTel de Langfuse, correlaciona con los spans de FastAPI y del gateway. El artículo de deepagents documenta el camino y sus versiones; aquí se usa el callback por ser menos piezas, y se acepta que la traza del agente y la de infraestructura se cruzan por &lt;code>trace_id&lt;/code> en vez de anidarse.&lt;/p>
&lt;p>Tres métricas propias del servicio, en Prometheus: ejecuciones en vuelo por pod, 503 por contrapresión, y drenados. Las tres deberían estar cerca de cero la mayor parte del tiempo, y la primera es la que dice cuándo subir réplicas.&lt;/p>
&lt;h2 id="un-cliente-por-despliegue">Un cliente por despliegue&lt;/h2>
&lt;p>Los cuatro servicios son de una cadena de tiendas. Si la plataforma sirviera a varias cadenas, la tabla &lt;code>hilos&lt;/code> con la tienda no bastaría: el checkpointer, el almacén y la caché son compartidos por instancia, y el artículo de deepagents documenta que el backend de ficheros fija su raíz al construirse. La regla que sale de ahí es un despliegue por cliente, con su base &lt;code>agentes&lt;/code> y sus claves del gateway, y el &lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">artículo sobre aislar agentes por cliente&lt;/a> tiene el resto.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langgraph-api&lt;/code> no está en la imagen; &lt;code>langgraph&lt;/code>, los checkpointers y &lt;code>langchain&lt;/code> sí.&lt;/li>
&lt;li>Un &lt;code>Deployment&lt;/code> por caso, misma imagen, &lt;code>CASO&lt;/code> por entorno.&lt;/li>
&lt;li>El &lt;code>thread_id&lt;/code> lo genera el servidor y la fila de &lt;code>hilos&lt;/code> se escribe antes del primer checkpoint.&lt;/li>
&lt;li>Todo acceso a un hilo pasa por &lt;code>hilo_autorizado&lt;/code>; ajeno o inexistente es 404.&lt;/li>
&lt;li>&lt;code>astream&lt;/code> con &lt;code>[&amp;quot;messages&amp;quot;, &amp;quot;updates&amp;quot;]&lt;/code>, &lt;code>durability=&amp;quot;sync&amp;quot;&lt;/code> y un &lt;code>RunControl&lt;/code> por ejecución.&lt;/li>
&lt;li>&lt;code>SIGTERM&lt;/code> llama a &lt;code>request_drain&lt;/code> en las ejecuciones en vuelo y pone la sonda de disponibilidad en 503.&lt;/li>
&lt;li>&lt;code>terminationGracePeriodSeconds&lt;/code> cubre el superstep más largo del caso.&lt;/li>
&lt;li>&lt;code>reanudar&lt;/code> comprueba &lt;code>estado.next&lt;/code> y distingue aprobación de continuación.&lt;/li>
&lt;li>&lt;code>CronJob&lt;/code> de retención con &lt;code>delete_thread&lt;/code> por lotes y plazos por caso en tabla.&lt;/li>
&lt;li>&lt;code>Job&lt;/code> de migraciones con rol DDL, directo a Postgres, en cada subida del paquete.&lt;/li>
&lt;li>Rol de los pods sin DDL; &lt;code>prepare_threshold=0&lt;/code> si hay pooler.&lt;/li>
&lt;li>Sonda de vida sin dependencias; sonda de disponibilidad con &lt;code>SELECT&lt;/code> a &lt;code>checkpoint_migrations&lt;/code>.&lt;/li>
&lt;li>Semáforo por pod y 503 con &lt;code>Retry-After&lt;/code>; la suma de concurrencias cabe en el gateway.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>Un hilo inexistente no da error.&lt;/strong> &lt;code>get_state&lt;/code> devuelve &lt;code>values={}&lt;/code> y &lt;code>next=()&lt;/code>. Un endpoint que confíe en que el checkpointer distinga hilos válidos deja pasar cualquier identificador.&lt;/p>
&lt;p>&lt;strong>Reanudar un hilo terminado ejecuta un turno.&lt;/strong> &lt;code>invoke(None)&lt;/code> sobre un hilo sin &lt;code>next&lt;/code> no falla; vuelve a llamar al modelo. Comprobar &lt;code>estado.next&lt;/code> antes.&lt;/p>
&lt;p>&lt;strong>&lt;code>GraphDrained&lt;/code> se lanza donde se invoca.&lt;/strong> En un flujo SSE eso es dentro del generador, y hay que capturarla ahí para emitir el evento; si no, el cliente ve una conexión cortada sin saber que puede reanudar.&lt;/p>
&lt;p>&lt;strong>El drenado espera al superstep.&lt;/strong> Una herramienta que tarda dos minutos retrasa la terminación dos minutos, o se lleva un &lt;code>SIGKILL&lt;/code> si el período de gracia es menor. Supersteps cortos o período de gracia largo; no hay tercera opción.&lt;/p>
&lt;p>&lt;strong>La retención no tiene columna de fecha.&lt;/strong> &lt;code>checkpoint-&amp;gt;&amp;gt;'ts'&lt;/code> dentro del JSONB, o el orden del UUID v6. Ninguna de las dos está indexada; con millones de filas, la consulta de retención es un recorrido y se lanza de noche.&lt;/p>
&lt;p>&lt;strong>&lt;code>delete_thread&lt;/code> es lo único que hay.&lt;/strong> &lt;code>prune&lt;/code> lanza &lt;code>NotImplementedError&lt;/code> y una poda a mano puede vaciar canales sin error. Hilos enteros.&lt;/p>
&lt;p>&lt;strong>&lt;code>setup()&lt;/code> desde el pod o a través del pooler falla.&lt;/strong> Permisos o &lt;code>CREATE INDEX CONCURRENTLY&lt;/code> en transacción. Job directo con DDL.&lt;/p>
&lt;p>&lt;strong>La sonda de disponibilidad que mira al gateway tumba los cuatro servicios a la vez.&lt;/strong> El gateway se ve en la petición, no en la sonda.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>Servir los cuatro agentes sin el servidor de LangGraph es una tabla, cuatro rutas, un manejador de señal, un CronJob y un Job. La tabla es la autorización que el checkpointer no tiene. Las rutas son la API de hilos reducida a lo que estos agentes usan. El manejador de señal es el drenado, que el motor sí trae y que solo hay que conectar. El CronJob es la retención, que el motor trae a medias. El Job es lo que el motor pide y no dice dónde correr.&lt;/p>
&lt;p>Lo que no hay que escribir es lo que importa: el grafo, la persistencia, las interrupciones, el abanico y la salida estructurada son del motor, con licencia MIT, y corren en un cluster propio sin pedirle permiso a nadie. Eso era la tesis del artículo que abrió la vertical, y estos seis artículos son la comprobación con cuatro problemas de tienda que un responsable entiende.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">Agente de datos de producto con LangGraph: generador, evaluador y bucle acotado&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">deepagents en un cluster propio: el SDK es libre, el servidor no&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">Keycloak como identidad de la plataforma de IA&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">Aislar agentes de IA por cliente en el cluster&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">Ejecución durable con Temporal y el coste de los agentes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">Dimensionar el gateway para una flota de agentes&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Persistencia y checkpointers en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Documentación de self-hosting de LangGraph Platform (licencia y requisitos del servidor): &lt;a href="https://docs.langchain.com/langsmith/self-hosted">https://docs.langchain.com/langsmith/self-hosted&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langgraph.runtime&lt;/code> (langgraph 1.2.11): &lt;code>RunControl&lt;/code>; y de &lt;code>langgraph.errors&lt;/code>: &lt;code>GraphDrained&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.checkpoint.postgres&lt;/code> y &lt;code>.aio&lt;/code> (3.1.2): &lt;code>setup&lt;/code>, &lt;code>MIGRATIONS&lt;/code>, &lt;code>delete_thread&lt;/code>, &lt;code>adelete_thread&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.checkpoint.base&lt;/code> (4.2.0): docstrings de &lt;code>prune&lt;/code>, &lt;code>copy_thread&lt;/code>, &lt;code>delete_for_runs&lt;/code>&lt;/li>
&lt;li>psycopg 3, &lt;code>prepare_threshold&lt;/code> y poolers: &lt;a href="https://www.psycopg.org/psycopg3/docs/advanced/prepare.html">https://www.psycopg.org/psycopg3/docs/advanced/prepare.html&lt;/a>&lt;/li>
&lt;li>FastAPI, eventos de ciclo de vida: &lt;a href="https://fastapi.tiangolo.com/advanced/events/">https://fastapi.tiangolo.com/advanced/events/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Agente de datos de producto con LangGraph: enriquecer y controlar fichas con un generador, un evaluador y un bucle que sabe parar</title><link>https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/</link><pubDate>Thu, 17 Sep 2026 03:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El generador escribe la ficha y el evaluador la puntúa contra una lista de criterios que no cambia. Son dos agentes distintos, con dos prompts distintos y salida estructurada los dos. El evaluador no ve el prompt del generador ni sus intentos anteriores; ve la ficha y los criterios. Es la única forma de que el evaluador no apruebe lo que el generador quiso hacer en vez de lo que hizo.&lt;/p>
&lt;p>El bucle lo cierra una arista condicional que lee dos cosas del estado: el veredicto y un contador de intentos. Tres como máximo. El modelo no sabe cuántos intentos lleva ni decide cuándo parar. Verificado con un evaluador que rechaza dos veces y aprueba a la tercera: tres intentos, tres veredictos en el historial, y el grafo termina en &lt;code>publicar&lt;/code>.&lt;/p>
&lt;p>Con un modelo en vLLM, &lt;code>response_format&lt;/code> en &lt;code>create_agent&lt;/code> no hace lo que parece. Sin perfil en el modelo, LangChain no puede saber si el proveedor admite salida estructurada y recurre a una herramienta sintética con el nombre del esquema, que el modelo tiene que llamar. Con &lt;code>profile={&amp;quot;structured_output&amp;quot;: True}&lt;/code> usa &lt;code>response_format&lt;/code> con el esquema JSON y vLLM lo aplica con decodificación guiada. Verificado en &lt;code>_supports_provider_strategy&lt;/code> de langchain 1.4.1.&lt;/p>
&lt;p>Un agente de &lt;code>create_agent&lt;/code> invocado dentro de un nodo hereda el checkpointer del grafo padre y guarda sus propios checkpoints bajo un espacio de nombres. Una ficha con tres intentos deja 30 filas; con &lt;code>checkpointer=False&lt;/code> en los dos agentes, 12. El estado que importa está en el padre; los subgrafos no necesitan reanudarse.&lt;/p>
&lt;p>El resultado estructurado de un agente es un objeto pydantic, y ese objeto entra en el estado del subgrafo. Al deserializar el checkpoint, LangGraph 1.2.11 avisa de que el tipo no está registrado y de que lo bloqueará en una versión futura. Se guarda en el estado del padre como diccionario con &lt;code>model_dump()&lt;/code>, y se registra el módulo en &lt;code>allowed_msgpack_modules&lt;/code> o se desactiva el checkpointer del subgrafo.&lt;/p>
&lt;p>Lo que se mide es la tasa de aprobación por intento. Es la única métrica que dice si el bucle compensa: si a la tercera se aprueba lo mismo que a la segunda, el tercer intento sobra.&lt;/p>
&lt;h2 id="estás-aquí-el-cuarto-caso-el-bucle-que-corrige">Estás aquí: el cuarto caso, el bucle que corrige&lt;/h2>
&lt;p>Este es el caso 4 de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a>. Los tres anteriores tratan con personas o con un catálogo entero; este trata con un texto. Una ficha de producto tiene un título, una descripción, unos atributos, y unas reglas de la casa sobre lo que puede y no puede decir. Las fichas llegan del proveedor a medias, en otro idioma, o con los atributos en el título. Hoy las completa alguien de catálogo, una a una, y las publica sin que nadie las vuelva a leer.&lt;/p>
&lt;p>El agente hace lo mismo con dos diferencias: escribe y comprueba en dos pasos separados, y el segundo paso tiene derecho de veto. Es el patrón que la documentación llama reflexión, y la forma más simple de un evaluador: no puntúa de uno a diez, dice sí o no y por qué.&lt;/p>
&lt;h2 id="la-analogía-el-redactor-y-el-corrector">La analogía: el redactor y el corrector&lt;/h2>
&lt;p>En cualquier redacción hay dos personas. Una escribe y otra corrige, y la que corrige no ha visto el borrador anterior ni sabe lo que el redactor intentaba decir; solo tiene el texto y el libro de estilo. Si el corrector fuera el propio redactor releyendo, aprobaría lo que quiso escribir.&lt;/p>
&lt;p>El generador es el redactor. El evaluador es el corrector con el libro de estilo. El contador de intentos es el jefe de sección que a la tercera vuelta publica o descarta, sin que el redactor lo decida.&lt;/p>
&lt;h2 id="los-criterios">Los criterios&lt;/h2>
&lt;p>El evaluador comprueba una lista cerrada. Cada criterio tiene un identificador, una descripción para el evaluador y una descripción para el generador. Se guardan en una tabla, no en el prompt, y se versionan.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Id&lt;/th>
&lt;th>Criterio&lt;/th>
&lt;th>Comprobable sin modelo&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>C01&lt;/td>
&lt;td>El título tiene entre 30 y 80 caracteres y empieza por el tipo de producto&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C02&lt;/td>
&lt;td>La descripción no repite el título&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C03&lt;/td>
&lt;td>Todos los atributos obligatorios de la familia están presentes&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C04&lt;/td>
&lt;td>No hay afirmaciones de salud, seguridad o rendimiento no respaldadas por la ficha del proveedor&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C05&lt;/td>
&lt;td>La descripción no menciona precio, disponibilidad ni promociones&lt;/td>
&lt;td>Parcial&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C06&lt;/td>
&lt;td>El tono es el de la casa: segunda persona, sin superlativos, sin exclamaciones&lt;/td>
&lt;td>Parcial&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C07&lt;/td>
&lt;td>Las cifras (capacidad, medidas, potencia) coinciden con las del proveedor&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Los criterios de la tercera columna marcados con &amp;ldquo;Sí&amp;rdquo; se comprueban en código antes de llamar al evaluador, y el resultado se le pasa. Un modelo evaluando si un título tiene menos de 80 caracteres es un modelo haciendo mal lo que &lt;code>len()&lt;/code> hace bien. Lo que el evaluador aporta es C04, C06 y las partes no mecánicas de C05: lo que exige leer.&lt;/p>
&lt;h2 id="el-estado">El estado&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">operator&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">typing&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">TypedDict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">EstadoFicha&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sku&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">origen&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span> &lt;span class="c1"># ficha del proveedor, atributos de familia, cifras&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ficha&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span> &lt;span class="c1"># última versión generada&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">comprobaciones&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span> &lt;span class="c1"># C01, C02, C03, C07: resultado en código&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">veredicto&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span> &lt;span class="c1"># {&amp;#34;aprobada&amp;#34;: bool, &amp;#34;problemas&amp;#34;: [{&amp;#34;criterio&amp;#34;: ..., &amp;#34;detalle&amp;#34;: ...}]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">intentos&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">operator&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">historial&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">operator&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>intentos&lt;/code> con &lt;code>operator.add&lt;/code> es el contador: cada vez que &lt;code>generar&lt;/code> devuelve &lt;code>{&amp;quot;intentos&amp;quot;: 1}&lt;/code>, el reductor suma. &lt;code>historial&lt;/code> acumula un registro por intento con la ficha y el veredicto, y es lo que después permite medir. &lt;code>ficha&lt;/code> y &lt;code>veredicto&lt;/code> se sobreescriben.&lt;/p>
&lt;p>&lt;code>ficha&lt;/code> y &lt;code>veredicto&lt;/code> son diccionarios, no objetos pydantic. Los agentes devuelven pydantic en &lt;code>structured_response&lt;/code>, y el nodo lo convierte con &lt;code>model_dump()&lt;/code> antes de escribirlo en el estado. Más abajo se explica por qué no es una manía.&lt;/p>
&lt;h2 id="los-dos-agentes">Los dos agentes&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pydantic&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">BaseModel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Field&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">create_agent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents.structured_output&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ProviderStrategy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Ficha&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">titulo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">max_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">80&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">descripcion&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">max_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1200&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">atributos&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Problema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">criterio&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">detalle&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">max_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Veredicto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aprobada&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problemas&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Problema&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">default_factory&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">generador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_agent&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">PROMPT_GENERADOR&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># reglas de la casa, formato, y &amp;#34;corrige solo lo señalado&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response_format&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ProviderStrategy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Ficha&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">evaluador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_agent&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">PROMPT_EVALUADOR&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># criterios C04, C05, C06 y &amp;#34;responde solo con el veredicto&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response_format&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ProviderStrategy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Veredicto&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ninguno tiene herramientas. Todo lo que necesitan va en el mensaje: la ficha del proveedor, los atributos obligatorios de la familia, y para el generador, los problemas del intento anterior. Son dos llamadas al modelo por intento, sin ciclo de herramientas; &lt;code>create_agent&lt;/code> está aquí por la salida estructurada, el prompt y el middleware, no por el bucle.&lt;/p>
&lt;p>Dos parámetros que no son decorativos. &lt;code>ProviderStrategy&lt;/code> se explica en su propia sección. &lt;code>checkpointer=False&lt;/code> también.&lt;/p>
&lt;h3 id="los-prompts-y-lo-que-cada-uno-no-ve">Los prompts, y lo que cada uno no ve&lt;/h3>
&lt;p>El generador recibe la ficha del proveedor y, a partir del segundo intento, la lista de problemas del veredicto anterior con la instrucción de corregir solo eso. No recibe su ficha anterior entera. Se probó dárselo y el resultado fue peor: con la ficha anterior delante, el modelo reescribe frases que estaban bien y a veces reintroduce un problema ya corregido en otro sitio. Con la ficha del proveedor y la lista de problemas, regenera desde el origen con las restricciones acumuladas.&lt;/p>
&lt;p>El evaluador recibe la ficha generada, los resultados de las comprobaciones en código, y los criterios. No recibe el prompt del generador, ni sus intentos anteriores, ni cuántos lleva. Si supiera que es el tercer intento, aprobaría más; se ha visto en pruebas con el número de intento en el mensaje. La única información de contexto es el origen, para C04 y C07.&lt;/p>
&lt;h2 id="el-grafo">El grafo&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.graph&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">END&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">comprobar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;comprobaciones&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">comprobaciones_en_codigo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;origen&amp;#34;&lt;/span>&lt;span class="p">])}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">generar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problemas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;problemas&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">salida&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">generador&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">mensaje_generador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;origen&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">problemas&lt;/span>&lt;span class="p">)}]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">salida&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;structured_response&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">model_dump&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">evaluar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">salida&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">evaluador&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">mensaje_evaluador&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;comprobaciones&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;origen&amp;#34;&lt;/span>&lt;span class="p">])}]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">veredicto&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">salida&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;structured_response&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">model_dump&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fallos_codigo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">c&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">c&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ok&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;comprobaciones&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">ok&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">fallos_codigo&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">veredicto&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">False&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">veredicto&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;problemas&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;criterio&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">c&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;detalle&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;comprobación automática&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">fallos_codigo&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">veredicto&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;historial&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;intento&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">veredicto&lt;/span>&lt;span class="p">}]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">decidir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">publicar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">guardar_ficha&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">estado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;publicada&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">intentos&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">descartar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">guardar_ficha&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ficha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">estado&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;revision_manual&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">intentos&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problemas&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;problemas&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">EstadoFicha&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">generar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;comprobar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">comprobar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;evaluar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">evaluar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">publicar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">descartar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;comprobar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;comprobar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;evaluar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;evaluar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">decidir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">END&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;descartar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">END&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cinco nodos y una arista condicional con tres salidas. &lt;code>decidir&lt;/code> es el único sitio donde se lee el contador, y es una función sin modelo. El modelo no puede alargar el bucle ni acortarlo.&lt;/p>
&lt;p>Las comprobaciones en código van en su propio nodo entre generar y evaluar, y el evaluador las recibe. Además, &lt;code>evaluar&lt;/code> fuerza el rechazo si alguna falla: aunque el modelo apruebe, un título de 90 caracteres no se publica. El evaluador con modelo no tiene la última palabra sobre lo que se puede comprobar sin él.&lt;/p>
&lt;p>Verificado con un generador que devuelve una ficha por intento y un evaluador guionizado que rechaza dos veces y aprueba a la tercera: &lt;code>intentos&lt;/code> termina en 3, el historial tiene tres entradas con &lt;code>False, False, True&lt;/code>, y el grafo sale por &lt;code>publicar&lt;/code>. Con el evaluador rechazando siempre, sale por &lt;code>descartar&lt;/code> a la tercera.&lt;/p>
&lt;h2 id="la-salida-estructurada-por-debajo">La salida estructurada, por debajo&lt;/h2>
&lt;p>&lt;code>response_format&lt;/code> en &lt;code>create_agent&lt;/code> admite un esquema a secas, &lt;code>ToolStrategy(esquema)&lt;/code> o &lt;code>ProviderStrategy(esquema)&lt;/code>. Con el esquema a secas, LangChain elige por el modelo: en &lt;code>_supports_provider_strategy&lt;/code> mira &lt;code>model.profile&lt;/code>, y si el perfil dice &lt;code>structured_output: True&lt;/code> usa &lt;code>ProviderStrategy&lt;/code>; si no, busca el nombre del modelo en una lista de patrones de modelos conocidos, y si tampoco, &lt;code>ToolStrategy&lt;/code>.&lt;/p>
&lt;p>Con &lt;code>ChatOpenAI&lt;/code> apuntando a vLLM y un nombre como &lt;code>qwen3-30b-a3b&lt;/code>, el perfil es &lt;code>None&lt;/code> y el nombre no está en la lista. Resultado: &lt;code>ToolStrategy&lt;/code>. Verificado: &lt;code>_supports_provider_strategy(modelo, tools=[])&lt;/code> devuelve &lt;code>False&lt;/code> sin perfil y &lt;code>True&lt;/code> con &lt;code>profile={&amp;quot;structured_output&amp;quot;: True}&lt;/code>.&lt;/p>
&lt;p>Lo que cambia entre las dos:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;code>ToolStrategy&lt;/code>&lt;/th>
&lt;th>&lt;code>ProviderStrategy&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Qué se envía&lt;/td>
&lt;td>Una herramienta sintética con el nombre del esquema (&lt;code>Ficha&lt;/code>) en &lt;code>tools&lt;/code>&lt;/td>
&lt;td>&lt;code>response_format&lt;/code> con el esquema JSON&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qué hace el modelo&lt;/td>
&lt;td>Decide llamar a esa herramienta con los argumentos&lt;/td>
&lt;td>Genera el JSON con decodificación guiada&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qué puede salir mal&lt;/td>
&lt;td>El modelo responde en texto sin llamar a la herramienta, o llama con argumentos que no validan; LangChain reintenta con un mensaje de error (&lt;code>handle_errors=True&lt;/code> por defecto)&lt;/td>
&lt;td>El JSON es válido siempre; si no valida contra pydantic, no hay reintento: &lt;code>_handle_structured_output_error&lt;/code> solo reintenta con &lt;code>ToolStrategy&lt;/code>, con la otra lanza&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Coste&lt;/td>
&lt;td>Tokens de la definición de la herramienta en cada llamada, y a veces una vuelta más&lt;/td>
&lt;td>Nada extra en la entrada; la decodificación guiada añade algo de latencia en esquemas grandes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Para este agente se fija &lt;code>ProviderStrategy&lt;/code> a mano en los dos, y el modelo lleva &lt;code>profile={&amp;quot;max_input_tokens&amp;quot;: 32768, &amp;quot;structured_output&amp;quot;: True}&lt;/code> en el marco de la serie. El esquema se mantiene plano: &lt;code>Ficha&lt;/code> tiene tres campos y &lt;code>atributos&lt;/code> es un diccionario de cadenas, no un modelo anidado por familia. vLLM aplica bien esquemas de este tamaño; con esquemas de varios niveles y enumeraciones largas la decodificación guiada se nota en la latencia de la primera ficha.&lt;/p>
&lt;p>&lt;code>Field(max_length=80)&lt;/code> en el título es una restricción del esquema que vLLM no aplica en la generación; la aplica pydantic al validar. Con &lt;code>ProviderStrategy&lt;/code> un fallo de validación no se reintenta: el agente lanza y el nodo &lt;code>generar&lt;/code> lanza con él. Por eso &lt;code>generar&lt;/code> captura esa excepción y manda la ficha a revisión manual sin pasar por el evaluador, y por eso C01 se comprueba también en código: lo que está dentro del esquema es una segunda barrera, no la primera.&lt;/p>
&lt;h2 id="los-subgrafos-y-sus-checkpoints">Los subgrafos y sus checkpoints&lt;/h2>
&lt;p>&lt;code>generador&lt;/code> y &lt;code>evaluador&lt;/code> son grafos compilados. Cuando se invocan dentro de un nodo del grafo padre con &lt;code>.invoke(...)&lt;/code>, reciben la configuración del padre, y con ella el checkpointer. Sin hacer nada, cada uno guarda sus propios checkpoints bajo un espacio de nombres derivado del nodo y de la tarea.&lt;/p>
&lt;p>Medido con &lt;code>InMemorySaver&lt;/code>, una ficha con tres intentos:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Agentes compilados con&lt;/th>
&lt;th>Checkpoints del padre&lt;/th>
&lt;th>Checkpoints de subgrafos&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Valor por defecto&lt;/td>
&lt;td>12&lt;/td>
&lt;td>18&lt;/td>
&lt;td>30&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>checkpointer=False&lt;/code>&lt;/td>
&lt;td>12&lt;/td>
&lt;td>0&lt;/td>
&lt;td>12&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Los 18 de los subgrafos son tres por cada invocación de agente, seis invocaciones. Son el estado interno de cada llamada: los mensajes de esa llamada, la respuesta estructurada, y el resultado. Ninguno sirve para nada después: la ficha ya está en el estado del padre como diccionario, y un subgrafo sin herramientas ni interrupciones no tiene nada que reanudar.&lt;/p>
&lt;p>&lt;code>checkpointer=False&lt;/code> en &lt;code>create_agent&lt;/code> le dice al agente que no persista aunque el padre lo haga. Es distinto de &lt;code>checkpointer=None&lt;/code>, que es el valor por defecto y significa &amp;ldquo;hereda&amp;rdquo;. Con treinta mil fichas al año y tres intentos de media, la diferencia son 570.000 filas de checkpoint que no dicen nada, más sus blobs.&lt;/p>
&lt;p>Hay un caso en que se quiere lo contrario: si el generador tuviera herramientas con efecto o una interrupción de aprobación, sus checkpoints serían lo que permite reanudarlo. Aquí no es el caso, y tampoco lo es en el analista de referencias del caso 3, que por eso lleva el mismo parámetro.&lt;/p>
&lt;h2 id="el-objeto-pydantic-en-el-estado">El objeto pydantic en el estado&lt;/h2>
&lt;p>Al ejecutar el bucle con los agentes por defecto, LangGraph 1.2.11 escribe esto al deserializar el checkpoint del subgrafo:&lt;/p>
&lt;pre tabindex="0">&lt;code>Deserializing unregistered type __main__.Ficha from checkpoint. This will be blocked in a
future version. Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add to
allowed_msgpack_modules to allow explicitly: [(&amp;#39;__main__&amp;#39;, &amp;#39;Ficha&amp;#39;)]
&lt;/code>&lt;/pre>&lt;p>&lt;code>structured_response&lt;/code> en el estado del agente es un objeto &lt;code>Ficha&lt;/code>. El serializador lo guarda con su módulo y su clase, y al leerlo comprueba que el tipo esté permitido. Hoy es un aviso; el mensaje dice que será bloqueo. Cuando lo sea, un checkpoint escrito hoy con un objeto pydantic dentro no se podrá leer con la versión de mañana.&lt;/p>
&lt;p>Tres formas de no tener el problema, y aquí se usan las tres. El estado del padre solo lleva diccionarios: &lt;code>model_dump()&lt;/code> en cuanto el objeto sale del agente. Los subgrafos no persisten, así que no hay checkpoint con el objeto. Y el checkpointer del padre se construye con un serializador que conoce el módulo de los esquemas, por si alguna vez uno se cuela:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.checkpoint.serde.jsonplus&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">JsonPlusSerializer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">checkpointer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PostgresSaver&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pool_agentes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">serde&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">JsonPlusSerializer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">allowed_msgpack_modules&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;fichas.esquemas&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;Ficha&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fichas.esquemas&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;Veredicto&amp;#34;&lt;/span>&lt;span class="p">)]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Es la misma regla que en el caso 2 con el contexto y en el caso 3 con &lt;code>motivo&lt;/code>: lo que va al checkpoint se decide, no se deja caer.&lt;/p>
&lt;h2 id="lo-que-se-mide">Lo que se mide&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Métrica&lt;/th>
&lt;th>Dónde&lt;/th>
&lt;th>Qué dice&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tasa de aprobación al primer, segundo y tercer intento&lt;/td>
&lt;td>&lt;code>historial&lt;/code> en la tabla de fichas&lt;/td>
&lt;td>La curva del bucle. Si el tercer intento aprueba menos del 10 por ciento de lo que llegó a él, el tope debería ser dos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Criterios más rechazados por intento&lt;/td>
&lt;td>&lt;code>historial[*].veredicto.problemas&lt;/code>&lt;/td>
&lt;td>Un criterio que se rechaza siempre en el primer intento y nunca en el segundo es una instrucción que falta en el prompt del generador&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fichas a revisión manual&lt;/td>
&lt;td>&lt;code>estado = 'revision_manual'&lt;/code>&lt;/td>
&lt;td>Debe bajar con el tiempo. Si no baja, los criterios y el prompt están desalineados&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rechazos forzados por código con veredicto del modelo aprobado&lt;/td>
&lt;td>&lt;code>comprobaciones&lt;/code> frente a &lt;code>veredicto&lt;/code>&lt;/td>
&lt;td>Cuántas veces el modelo aprobó algo que &lt;code>len()&lt;/code> rechazó. Es la medida de cuánto no se puede fiar uno del evaluador en lo mecánico&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokens por ficha publicada&lt;/td>
&lt;td>Langfuse, &lt;code>caso=fichas&lt;/code>, dividido por publicadas&lt;/td>
&lt;td>Unos 2.500 por intento con los prompts actuales; el coste real es este número por la media de intentos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Devoluciones con motivo &amp;ldquo;no coincide con la descripción&amp;rdquo;&lt;/td>
&lt;td>Tabla de devoluciones, 90 días después&lt;/td>
&lt;td>La métrica de negocio. Tarda en llegar y es la que importa&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fichas revisadas al azar por catálogo que se rechazan&lt;/td>
&lt;td>Muestreo manual semanal&lt;/td>
&lt;td>La calibración del evaluador. Si catálogo rechaza más del 5 por ciento de lo que el evaluador aprobó, los criterios están mal descritos&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La última fila es la que hace que el sistema no se degrade sin que nadie lo vea. Un evaluador con modelo aprueba lo que su prompt le dice que apruebe; una muestra semanal leída por una persona es lo que mantiene el prompt honesto.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Los criterios viven en una tabla versionada, con la parte mecánica comprobada en código.&lt;/li>
&lt;li>El evaluador no ve el prompt del generador, sus intentos anteriores ni el número de intento.&lt;/li>
&lt;li>El generador recibe el origen y los problemas, no su ficha anterior.&lt;/li>
&lt;li>&lt;code>decidir&lt;/code> lee &lt;code>aprobada&lt;/code> e &lt;code>intentos&lt;/code> y no llama al modelo.&lt;/li>
&lt;li>&lt;code>evaluar&lt;/code> fuerza el rechazo si una comprobación en código falla.&lt;/li>
&lt;li>Los dos agentes llevan &lt;code>ProviderStrategy&lt;/code> y el modelo lleva &lt;code>structured_output: True&lt;/code> en el perfil.&lt;/li>
&lt;li>Los esquemas son planos y las restricciones de longitud están también en código.&lt;/li>
&lt;li>Los dos agentes llevan &lt;code>checkpointer=False&lt;/code>.&lt;/li>
&lt;li>El estado del padre solo tiene diccionarios; &lt;code>model_dump()&lt;/code> a la salida de cada agente.&lt;/li>
&lt;li>&lt;code>allowed_msgpack_modules&lt;/code> en el checkpointer registra el módulo de los esquemas.&lt;/li>
&lt;li>Hay una muestra semanal de fichas aprobadas que lee una persona.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>&lt;code>response_format=Ficha&lt;/code> con un modelo de vLLM es una herramienta, no un &lt;code>response_format&lt;/code>.&lt;/strong> Sin perfil, LangChain no sabe que el proveedor admite salida estructurada y usa &lt;code>ToolStrategy&lt;/code>. Funciona, pero paga la definición de la herramienta en cada llamada y falla de otra manera. &lt;code>ProviderStrategy&lt;/code> explícito y &lt;code>structured_output: True&lt;/code> en el perfil.&lt;/p>
&lt;p>&lt;strong>Un agente dentro de un nodo persiste solo.&lt;/strong> Hereda el checkpointer del padre y escribe bajo su espacio de nombres. 30 filas por ficha en vez de 12. &lt;code>checkpointer=False&lt;/code> cuando no hay nada que reanudar.&lt;/p>
&lt;p>&lt;strong>El objeto pydantic en el checkpoint es un aviso hoy y un error mañana.&lt;/strong> &lt;code>model_dump()&lt;/code> antes de escribir en el estado, y &lt;code>allowed_msgpack_modules&lt;/code> como red.&lt;/p>
&lt;p>&lt;strong>Las restricciones del esquema no las aplica el servidor.&lt;/strong> &lt;code>max_length&lt;/code> lo valida pydantic después de generar. La primera barrera para lo mecánico es código antes del evaluador.&lt;/p>
&lt;p>&lt;strong>El evaluador aprueba más si sabe que es el último intento.&lt;/strong> No se le dice. Tampoco se le dice qué intentó el generador. Solo la ficha, las comprobaciones, el origen y los criterios.&lt;/p>
&lt;p>&lt;strong>Darle al generador su ficha anterior empeora.&lt;/strong> Reescribe lo que estaba bien y reintroduce problemas. Origen más lista de problemas.&lt;/p>
&lt;p>&lt;strong>El tope de intentos no se calibra a ojo.&lt;/strong> Se mide la aprobación por intento. Si el tercero apenas aprueba, es coste sin resultado y el tope baja a dos.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>El agente de fichas es dos llamadas al modelo por intento y un contador. Lo que lo hace fiable no está en las llamadas: está en que el evaluador no ve lo que no debe, en que lo mecánico se comprueba sin modelo antes y después, en que el bucle lo cierra una función y no el modelo, y en que lo que entra en el checkpoint es lo que se ha decidido que entre. Y una muestra semanal leída por alguien de catálogo, que es lo que mantiene los criterios pegados a la realidad.&lt;/p>
&lt;p>Con esto los cuatro casos están escritos. El siguiente y último artículo de la serie no construye ningún agente: sirve los cuatro en el mismo cluster, sin el servidor de LangGraph, con la autorización por hilo, la retención y las sondas que ninguno de los cuatro tiene.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api: autorización por hilo, retención y sondas&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">deepagents en un cluster propio: el SDK es libre, el servidor no&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">Versionado de prompts con Langfuse y MLflow&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Salida estructurada en LangChain 1.x (&lt;code>ToolStrategy&lt;/code>, &lt;code>ProviderStrategy&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langchain/structured-output">https://docs.langchain.com/oss/python/langchain/structured-output&lt;/a>&lt;/li>
&lt;li>API de grafos de LangGraph (reductores, aristas condicionales, subgrafos): &lt;a href="https://docs.langchain.com/oss/python/langgraph/graph-api">https://docs.langchain.com/oss/python/langgraph/graph-api&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langchain.agents.factory&lt;/code> (langchain 1.4.1): &lt;code>_supports_provider_strategy&lt;/code> y la resolución de &lt;code>AutoStrategy&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.checkpoint.serde.jsonplus&lt;/code> (langgraph-checkpoint 4.2.0): aviso de tipos no registrados y &lt;code>allowed_msgpack_modules&lt;/code>&lt;/li>
&lt;li>vLLM, salida estructurada y decodificación guiada: &lt;a href="https://docs.vllm.ai/en/latest/features/structured_outputs.html">https://docs.vllm.ai/en/latest/features/structured_outputs.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Agente de cadena de suministro con LangGraph: incidencias con proveedores y reposición con plan-and-execute y map-reduce sobre miles de referencias</title><link>https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/</link><pubDate>Thu, 17 Sep 2026 02:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>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 &lt;code>WHERE&lt;/code>.&lt;/p>
&lt;p>El abanico se hace con &lt;code>Send&lt;/code> desde una arista condicional, un nodo trabajador por lote de referencias, un reductor &lt;code>operator.add&lt;/code> en el estado, y un nodo de consolidación con &lt;code>defer=True&lt;/code>. 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.&lt;/p>
&lt;p>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 &lt;code>checkpoint_writes&lt;/code> 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.&lt;/p>
&lt;p>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. &lt;code>max_concurrency&lt;/code> en la configuración lo acota en los dos modos, y hay que fijarlo al valor que el gateway y vLLM puedan absorber.&lt;/p>
&lt;p>Un trabajador que falla tumba el &lt;code>invoke&lt;/code>, pero no pierde a los demás: sus escrituras quedan como pendientes y la reanudación ejecuta solo el que falló. Con &lt;code>retry_policy&lt;/code> en el nodo, el fallo transitorio contra el gateway ni siquiera sale del grafo.&lt;/p>
&lt;p>La caché por nodo con &lt;code>cache_policy&lt;/code> 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.&lt;/p>
&lt;h2 id="estás-aquí-del-cliente-al-almacén">Estás aquí: del cliente al almacén&lt;/h2>
&lt;p>Este es el caso 3 de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a>. Los dos anteriores, el &lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">asistente de sala&lt;/a> y el &lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">agente de cliente&lt;/a>, 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>Es el primer caso de la serie que se escribe sobre &lt;code>StateGraph&lt;/code> y no sobre &lt;code>create_agent&lt;/code>. 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.&lt;/p>
&lt;h2 id="la-analogía-la-ronda-del-jefe-de-compras">La analogía: la ronda del jefe de compras&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>El filtro es el listado del sistema. El planificador es el &amp;ldquo;por dónde empiezo&amp;rdquo;. 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.&lt;/p>
&lt;h2 id="el-filtro-determinista">El filtro determinista&lt;/h2>
&lt;p>La primera decisión de diseño es qué referencias llegan al modelo. La respuesta es un &lt;code>SELECT&lt;/code>, no un prompt:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cobertura&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">unidades&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reservadas&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">unidades&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">unidades&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reservadas&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">nullif&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">media_diaria&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dias_cobertura&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">stock&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ventas_media_30d&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">media_diaria&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">riesgo&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pr&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">plazo_dias&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">unidades&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dias_cobertura&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">incidencia_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tipo&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">incidencia_tipo&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">abierta_ts&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">productos&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">proveedores&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pr&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cobertura&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">incidencias&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">cerrada_ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dias_cobertura&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pr&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">plazo_dias&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">OR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">riesgo&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dias_cobertura&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">NULLS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FIRST&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>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.&lt;/p>
&lt;p>Todo lo demás, las referencias con cobertura holgada y sin incidencias, no genera tokens. Un agente que le pregunte al modelo &amp;ldquo;¿hay que reponer esto?&amp;rdquo; para cada una de las 30.000 está pagando 28.000 respuestas que un &lt;code>WHERE&lt;/code> da gratis.&lt;/p>
&lt;h2 id="el-estado">El estado&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">operator&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">typing&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">TypedDict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Referencia&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sku&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">plazo_dias&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">unidades&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dias_cobertura&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">float&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">incidencia&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Analisis&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sku&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">riesgo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># &amp;#34;alto&amp;#34; | &amp;#34;medio&amp;#34; | &amp;#34;bajo&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">accion&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># &amp;#34;pedir&amp;#34; | &amp;#34;esperar&amp;#34; | &amp;#34;sustituir_proveedor&amp;#34; | &amp;#34;reclamar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">unidades&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">motivo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># una frase, acotada por el prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">EstadoCompras&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fecha&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">candidatas&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Referencia&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">plan&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">analisis&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Analisis&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">operator&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">propuesta&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pasada&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dos decisiones aquí. &lt;code>analisis&lt;/code> es la única clave con reductor, y es la única en la que escriben los trabajadores. &lt;code>motivo&lt;/code> 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.&lt;/p>
&lt;h2 id="el-planificador">El planificador&lt;/h2>
&lt;p>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:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pydantic&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">BaseModel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Field&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">PasoPlan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prioridad&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ge&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">le&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">enfoque&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;qué mirar primero en este proveedor, una frase&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Plan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pasos&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">PasoPlan&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">proveedores_a_reclamar&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">planificador&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">modelo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_structured_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Plan&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">method&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;json_schema&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">planificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoCompras&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resumen&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">resumen_por_proveedor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;candidatas&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="c1"># SQL: n_refs, n_incidencias, plazo, sla&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">plan&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">planificador&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;system&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">PROMPT_PLANIFICADOR&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resumen&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ensure_ascii&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;plan&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">plan&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">model_dump&lt;/span>&lt;span class="p">()}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>method=&amp;quot;json_schema&amp;quot;&lt;/code> envía &lt;code>response_format&lt;/code> 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="el-abanico">El abanico&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Send&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">LOTE&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">50&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">repartir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoCompras&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Send&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">plan&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span> &lt;span class="n">p&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">p&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;plan&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;pasos&amp;#34;&lt;/span>&lt;span class="p">]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">por_proveedor&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Referencia&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;candidatas&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">por_proveedor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">setdefault&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">[])&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">envios&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">refs&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">por_proveedor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">enfoque&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">plan&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;enfoque&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">refs&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">LOTE&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">envios&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">proveedor_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;enfoque&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">enfoque&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;referencias&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">refs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="n">i&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">LOTE&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">envios&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>repartir&lt;/code> es la función de una arista condicional. Devuelve una lista de &lt;code>Send&lt;/code>, y cada uno lanza una instancia de &lt;code>analizar_lote&lt;/code> con su propio estado de entrada. Todas corren en el mismo superstep, y el grafo no avanza hasta que todas terminan.&lt;/p>
&lt;p>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:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Reparto&lt;/th>
&lt;th>Checkpoints&lt;/th>
&lt;th>Filas en &lt;code>checkpoint_writes&lt;/code>&lt;/th>
&lt;th>Bytes de escrituras&lt;/th>
&lt;th>Tamaño del checkpoint mayor&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Un &lt;code>Send&lt;/code> por referencia&lt;/td>
&lt;td>4&lt;/td>
&lt;td>6.003&lt;/td>
&lt;td>946 KB&lt;/td>
&lt;td>287 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Un &lt;code>Send&lt;/code> por lote de 50&lt;/td>
&lt;td>4&lt;/td>
&lt;td>123&lt;/td>
&lt;td>319 KB&lt;/td>
&lt;td>287 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Medido con &lt;code>InMemorySaver&lt;/code> y el serializador &lt;code>JsonPlusSerializer&lt;/code>, 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 &lt;code>checkpoint_writes&lt;/code>, 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.&lt;/p>
&lt;h3 id="el-trabajador">El trabajador&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CachePolicy&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">RetryPolicy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">analizar_lote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resultados&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">ref&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;referencias&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">historial&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">historial_incidencias&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">dias&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># SQL, sin modelo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">salida&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">analista&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">prompt_referencia&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">historial&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;enfoque&amp;#34;&lt;/span>&lt;span class="p">])}],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ContextoCompras&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fecha&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">proveedor_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">]))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resultados&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">salida&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;structured_response&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">model_dump&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">resultados&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">analizar_lote&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cache_policy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">CachePolicy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">12&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">3600&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">retry_policy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RetryPolicy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">max_attempts&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">initial_interval&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">1.0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">backoff_factor&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">2.0&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>analista&lt;/code> es un &lt;code>create_agent&lt;/code> con &lt;code>response_format=ProviderStrategy(Analisis)&lt;/code>, una herramienta de consulta de incidencias por proveedor, el mismo &lt;code>ToolErrorMiddleware&lt;/code> y límites del caso 1, y &lt;code>checkpointer=False&lt;/code>. 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 &lt;code>structured_response&lt;/code> 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.&lt;/p>
&lt;p>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 &lt;code>vllm:prefix_cache_hits&lt;/code> frente a &lt;code>vllm:prefix_cache_queries&lt;/code> durante la pasada, y es la primera cifra que hay que mirar si el coste no cuadra.&lt;/p>
&lt;h3 id="la-concurrencia">La concurrencia&lt;/h3>
&lt;p>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. &lt;code>max_concurrency&lt;/code> en la configuración lo cambia: con 32, se ven 32 en vuelo.&lt;/p>
&lt;p>En modo asíncrono, con &lt;code>ainvoke&lt;/code>, 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. &lt;code>max_concurrency&lt;/code> también aplica en este modo, y es obligatorio:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">config&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;compras:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">fecha&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;max_concurrency&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">24&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;caso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;compras&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">fecha&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">langfuse_handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">estado_inicial&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">durability&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sync&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El 24 sale del gateway: la clave virtual del agente de compras tiene &lt;code>max_parallel_requests&lt;/code> a 24, y por debajo vLLM con &lt;code>--max-num-seqs&lt;/code> a 64 reparte entre este agente y el tráfico de sala. Un &lt;code>max_concurrency&lt;/code> 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.&lt;/p>
&lt;h3 id="el-fallo-a-medias">El fallo a medias&lt;/h3>
&lt;p>Con &lt;code>retry_policy&lt;/code> 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 &lt;code>invoke&lt;/code> se entere. Verificado con un trabajador que falla la primera vez: el abanico de 20 acaba con 20 resultados.&lt;/p>
&lt;p>Sin &lt;code>retry_policy&lt;/code>, la excepción sale de &lt;code>invoke&lt;/code>. 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, &lt;code>next&lt;/code> apunta al nodo trabajador, y &lt;code>invoke(None, config)&lt;/code> sobre el mismo hilo ejecuta solo la tarea que faltaba. Diecinueve llamadas al modelo que no se repiten.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="la-consolidación">La consolidación&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">consolidar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoCompras&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">por_proveedor&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Analisis&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">por_proveedor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">setdefault&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">[])&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">propuesta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;pedidos&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">pid&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;lineas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;unidades&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;unidades&amp;#34;&lt;/span>&lt;span class="p">]}&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">items&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;accion&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;pedir&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;reclamaciones&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">items&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;accion&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;reclamar&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;n_alto&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">items&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;riesgo&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;alto&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">pid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">items&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">sorted&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">por_proveedor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;sin_analizar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">faltan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;candidatas&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analisis&amp;#34;&lt;/span>&lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;propuesta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">propuesta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;pasada&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pasada&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">consolidar&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">defer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>defer=True&lt;/code> retrasa el nodo hasta que no quede ningún camino pendiente hacia él. Sin ese parámetro, &lt;code>consolidar&lt;/code> 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 &lt;code>stream_mode=&amp;quot;updates&amp;quot;&lt;/code>: cinco lotes, cinco actualizaciones de &lt;code>analizar_lote&lt;/code> y una de &lt;code>consolidar&lt;/code> al final.&lt;/p>
&lt;p>El orden de &lt;code>analisis&lt;/code> es determinista. Los reductores se aplican al final del superstep en el orden de las tareas, que es el orden de los &lt;code>Send&lt;/code>; 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.&lt;/p>
&lt;p>&lt;code>sin_analizar&lt;/code> 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.&lt;/p>
&lt;h2 id="replanificar">Replanificar&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">decidir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoCompras&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;propuesta&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;sin_analizar&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pasada&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;reintentar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;entregar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">reintentar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">EstadoCompras&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pendientes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;propuesta&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;sin_analizar&amp;#34;&lt;/span>&lt;span class="p">]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;candidatas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">r&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;candidatas&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">pendientes&lt;/span>&lt;span class="p">]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cargar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;cargar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;planificar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;planificar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">repartir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">decidir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;reintentar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;reintentar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;entregar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;entregar&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;reintentar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">repartir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analizar_lote&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;entregar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">END&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>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 &lt;code>decidir&lt;/code> leyendo &lt;code>sin_analizar&lt;/code> y &lt;code>pasada&lt;/code>. 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.&lt;/p>
&lt;p>Una segunda pasada no vuelve a llamar al planificador. El plan de la primera sigue en el estado y &lt;code>repartir&lt;/code> lo lee. Volver a planificar tendría sentido si el conjunto de candidatas cambiara, y en una pasada no cambia.&lt;/p>
&lt;h2 id="la-caché-por-nodo">La caché por nodo&lt;/h2>
&lt;p>&lt;code>cache_policy=CachePolicy(ttl=12 * 3600)&lt;/code> en &lt;code>analizar_lote&lt;/code> 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.&lt;/p>
&lt;p>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 &lt;code>__metadata__&lt;/code> y &lt;code>cached: True&lt;/code>.&lt;/p>
&lt;p>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 &lt;code>key_func&lt;/code> propia que redondea la cobertura a días enteros y quita del lote los campos que no afectan al análisis:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">clave_lote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;proveedor_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nb">round&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;dias_cobertura&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;incidencia&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">{})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;referencias&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">hashlib&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;fecha&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">lote&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;enfoque&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">base&lt;/span>&lt;span class="p">])&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hexdigest&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cache_policy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">CachePolicy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key_func&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">clave_lote&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">12&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">3600&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La caché no es el checkpointer. Es un &lt;code>BaseCache&lt;/code> que se pasa en &lt;code>compile(cache=...)&lt;/code>, y en un cluster con varias réplicas del agente tiene que ser compartida. El paquete trae &lt;code>InMemoryCache&lt;/code> y &lt;code>RedisCache&lt;/code> (&lt;code>langgraph.cache.redis&lt;/code>, construido sobre un cliente de &lt;code>redis&lt;/code>); la interfaz son seis métodos (&lt;code>get&lt;/code>, &lt;code>set&lt;/code>, &lt;code>clear&lt;/code> y sus versiones asíncronas) si se quiere una sobre una tabla de Postgres. Con &lt;code>InMemoryCache&lt;/code>, cada pod tiene la suya y la segunda pasada solo ahorra si cae en el mismo pod.&lt;/p>
&lt;h2 id="lo-que-se-mide">Lo que se mide&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Métrica&lt;/th>
&lt;th>Dónde&lt;/th>
&lt;th>Qué dice&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Candidatas por pasada frente a catálogo&lt;/td>
&lt;td>&lt;code>SELECT&lt;/code> del filtro&lt;/td>
&lt;td>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&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokens por referencia analizada&lt;/td>
&lt;td>Langfuse, generaciones bajo &lt;code>caso=compras&lt;/code> divididas por candidatas&lt;/td>
&lt;td>Unos 700 con el prompt actual. Si sube, el historial se ha alargado o el motivo se ha desbordado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Aciertos de caché de prefijo durante la pasada&lt;/td>
&lt;td>&lt;code>vllm:prefix_cache_hits&lt;/code> / &lt;code>vllm:prefix_cache_queries&lt;/code>&lt;/td>
&lt;td>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&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Aciertos de caché de nodo&lt;/td>
&lt;td>&lt;code>__metadata__.cached&lt;/code> en el flujo, contados&lt;/td>
&lt;td>Solo cuentan en pasadas repetidas. Cero en la primera del día es lo normal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Duración del abanico frente a candidatas&lt;/td>
&lt;td>Traza del hilo, span de &lt;code>analizar_lote&lt;/code>&lt;/td>
&lt;td>Lineal con candidatas y &lt;code>max_concurrency&lt;/code>. Si no es lineal, el gateway está encolando&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filas en &lt;code>checkpoint_writes&lt;/code> por pasada&lt;/td>
&lt;td>Postgres&lt;/td>
&lt;td>Candidatas dividido por lote, por tres. Si crece más, el lote se ha reducido o hay reintentos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>sin_analizar&lt;/code> tras la tercera pasada&lt;/td>
&lt;td>&lt;code>propuesta&lt;/code>&lt;/td>
&lt;td>Debe ser cero casi siempre. Cada referencia ahí es una llamada al modelo que no terminó&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Líneas de pedido aceptadas por compras frente a propuestas&lt;/td>
&lt;td>Tabla de pedidos de compra&lt;/td>
&lt;td>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&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La propuesta consolidada no se convierte en pedido sola. &lt;code>entregar&lt;/code> la escribe en &lt;code>propuestas_reposicion&lt;/code> 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.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>El filtro de candidatas es SQL y su multiplicador vive en configuración.&lt;/li>
&lt;li>&lt;code>Analisis&lt;/code> acota &lt;code>motivo&lt;/code> a una frase y a 200 caracteres.&lt;/li>
&lt;li>El planificador ve un resumen por proveedor, no las referencias, y usa &lt;code>with_structured_output(..., method=&amp;quot;json_schema&amp;quot;)&lt;/code>.&lt;/li>
&lt;li>&lt;code>repartir&lt;/code> agrupa por proveedor y envía lotes de 50.&lt;/li>
&lt;li>&lt;code>analizar_lote&lt;/code> tiene &lt;code>cache_policy&lt;/code> con &lt;code>key_func&lt;/code> propia y &lt;code>retry_policy&lt;/code>.&lt;/li>
&lt;li>&lt;code>consolidar&lt;/code> lleva &lt;code>defer=True&lt;/code>.&lt;/li>
&lt;li>&lt;code>max_concurrency&lt;/code> está en la configuración y coincide con &lt;code>max_parallel_requests&lt;/code> de la clave del gateway.&lt;/li>
&lt;li>La caché se pasa en &lt;code>compile(cache=...)&lt;/code> y es compartida entre réplicas (&lt;code>RedisCache&lt;/code> o propia).&lt;/li>
&lt;li>Hay un contador de pasadas y un tope de tres.&lt;/li>
&lt;li>La propuesta pasa por aprobación de compras antes de ser pedido.&lt;/li>
&lt;li>Se ha medido &lt;code>vllm:prefix_cache_hits&lt;/code> durante una pasada completa.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El abanico asíncrono no tiene límite.&lt;/strong> 2.000 en vuelo, medido. Sin &lt;code>max_concurrency&lt;/code> el primer efecto es una ráfaga de 429 del gateway y el segundo, los reintentos multiplicando la ráfaga.&lt;/p>
&lt;p>&lt;strong>El abanico síncrono tiene un límite que no es el suyo.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Los lotes reducen filas, no bytes de checkpoint.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>&lt;code>defer=True&lt;/code> no es opcional.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>La caché por defecto es por entrada exacta.&lt;/strong> Una unidad de stock de diferencia invalida el lote. &lt;code>key_func&lt;/code> propia, o la caché no acierta nunca fuera de las pruebas.&lt;/p>
&lt;p>&lt;strong>&lt;code>InMemoryCache&lt;/code> es por pod.&lt;/strong> Con tres réplicas, dos tercios de las repeticiones no aciertan. &lt;code>RedisCache&lt;/code> viene en el paquete; una sobre Postgres son seis métodos.&lt;/p>
&lt;p>&lt;strong>La re-ejecución tras un fallo es por tarea, no por superstep.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>El planificador no puede añadir referencias.&lt;/strong> 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.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>Las primitivas que lo permiten son cuatro y son de &lt;code>StateGraph&lt;/code>: &lt;code>Send&lt;/code> desde una arista condicional, un reductor en el estado, &lt;code>defer=True&lt;/code> en la consolidación, y &lt;code>cache_policy&lt;/code> con clave propia. Y un parámetro de configuración, &lt;code>max_concurrency&lt;/code>, que no está en el grafo y sin el cual el grafo tumba el gateway.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">Agente de datos de producto con LangGraph: generador, evaluador y bucle acotado&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">Dimensionar el gateway para una flota de agentes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/enrutado-prefijo-kv-cache-lo-que-litellm-no-hace/">Enrutado por prefijo y caché KV: lo que LiteLLM no hace&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-limites-presupuestos-claves-virtuales/">Límites, presupuestos y claves virtuales en LiteLLM&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>API de grafos de LangGraph (&lt;code>Send&lt;/code>, reductores, &lt;code>defer&lt;/code>, &lt;code>cache_policy&lt;/code>, &lt;code>retry_policy&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/langgraph/graph-api">https://docs.langchain.com/oss/python/langgraph/graph-api&lt;/a>&lt;/li>
&lt;li>Persistencia y durabilidad en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Salida estructurada en LangChain 1.x: &lt;a href="https://docs.langchain.com/oss/python/langchain/structured-output">https://docs.langchain.com/oss/python/langchain/structured-output&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langgraph.types&lt;/code> (langgraph 1.2.11): &lt;code>Send&lt;/code>, &lt;code>CachePolicy&lt;/code>, &lt;code>RetryPolicy&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.checkpoint.serde.jsonplus&lt;/code> (langgraph-checkpoint 4.2.0), usado para medir los tamaños&lt;/li>
&lt;li>Métricas de vLLM 0.29 (&lt;code>vllm:prefix_cache_queries&lt;/code>, &lt;code>vllm:prefix_cache_hits&lt;/code>): &lt;a href="https://docs.vllm.ai/en/latest/usage/metrics.html">https://docs.vllm.ai/en/latest/usage/metrics.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Agente de atención al cliente con LangGraph: pedidos, devoluciones y sustituciones con el límite de aprobación humana en el código</title><link>https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/</link><pubDate>Thu, 17 Sep 2026 02:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El límite de la aprobación humana no está en el prompt ni en la interfaz. Está en un middleware que intercepta la llamada a la herramienta con efecto, evalúa una condición sobre los argumentos, el estado y el contexto, y si se cumple para el grafo antes de que la herramienta corra. Verificado: la interrupción salta en un nodo posterior al modelo y anterior a &lt;code>tools&lt;/code>, y la herramienta no se ejecuta hasta que un humano decide.&lt;/p>
&lt;p>El humano puede aprobar, editar los argumentos, rechazar con motivo o responder en lugar de la herramienta. Son las cuatro decisiones que admite &lt;code>HumanInTheLoopMiddleware&lt;/code> en langchain 1.4.1, y las cuatro tienen un contrato de datos fijo que la aplicación de aprobaciones tiene que hablar. Editar el importe de un reembolso de 120 a 90 hace que la herramienta corra con 90; se ha comprobado con la herramienta instrumentada.&lt;/p>
&lt;p>&lt;code>interrupt()&lt;/code> sin checkpointer no falla al interrumpir. Devuelve el valor en &lt;code>__interrupt__&lt;/code> como si todo fuera bien, y falla al reanudar con &lt;code>Cannot use Command(resume=...) without checkpointer&lt;/code>. Un despliegue sin persistencia parece funcionar hasta que el primer encargado pulsa aprobar.&lt;/p>
&lt;p>Al reanudar, el nodo que interrumpió se ejecuta entero otra vez. Con un nodo propio, lo que haya antes de la interrupción corre dos veces. Con el middleware, la interrupción vive en su propio nodo y la herramienta corre después, una vez. Lo que sigue pudiendo correr dos veces es la herramienta cuando el pod muere con ella en vuelo, y por eso el reembolso lleva el identificador de la llamada como clave de idempotencia.&lt;/p>
&lt;p>El checkpointer no sabe listar hilos interrumpidos. La cola de aprobaciones es una tabla propia que la aplicación escribe cuando &lt;code>invoke&lt;/code> devuelve &lt;code>__interrupt__&lt;/code> y borra cuando se reanuda. Sin esa tabla, la única forma de saber qué está esperando es recordar los identificadores de hilo.&lt;/p>
&lt;p>El rechazo llega al modelo como un mensaje en inglés que empieza por &lt;code>User rejected the tool call&lt;/code>. El prompt le dice al modelo qué hacer con él. Sin esa línea, el modelo puede disculparse en inglés con un cliente de Talavera.&lt;/p>
&lt;h2 id="estás-aquí-el-segundo-caso-donde-el-ciclo-se-rompe">Estás aquí: el segundo caso, donde el ciclo se rompe&lt;/h2>
&lt;p>Este es el caso 2 de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a>. El &lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">caso 1&lt;/a> construyó un asistente de sala que cabe en un ciclo modelo-herramientas. Este caso usa el mismo &lt;code>create_agent&lt;/code>, el mismo contexto y el mismo checkpointer, y añade lo que el ciclo no tiene: parar, esperar a una persona que puede tardar un día, y seguir desde el mismo sitio.&lt;/p>
&lt;p>El agente atiende a un cliente por el canal web sobre un pedido concreto. Puede informar del estado, iniciar una devolución con reembolso, o proponer una sustitución por otra referencia con stock. Las dos últimas tienen efecto en el dinero o en el inventario. La política de la tienda dice cuáles se hacen solas y cuáles firma un encargado.&lt;/p>
&lt;h2 id="la-analogía-el-vale-de-devolución-con-casilla-de-firma">La analogía: el vale de devolución con casilla de firma&lt;/h2>
&lt;p>En caja, una devolución de 20 euros con ticket la hace el dependiente. Una de 200, o sin ticket, o de un cliente que ya devolvió tres cosas este mes, lleva un vale con una casilla que firma el encargado. El dependiente rellena el vale entero, lo deja en la bandeja y atiende al siguiente. El encargado pasa cuando puede, lee el vale, y firma, tacha el importe y firma otro, o lo devuelve con una nota.&lt;/p>
&lt;p>El agente es el dependiente que rellena el vale. El middleware es la casilla de firma. El checkpointer es la bandeja donde el vale espera. Y el contrato de la interrupción es el formato del vale: si el encargado no entiende lo que hay escrito, no firma.&lt;/p>
&lt;h2 id="la-política-en-una-función">La política, en una función&lt;/h2>
&lt;p>La política de aprobación es lo primero que se escribe, y se escribe como una función que devuelve verdadero cuando hace falta un humano. Recibe la llamada a herramienta que el modelo ha propuesto, el estado del hilo y el contexto de ejecución.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents.middleware&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ToolCallRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">necesita_encargado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolCallRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">bool&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">args&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cliente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;importe&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">60&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">devoluciones_ultimos_30_dias&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cliente&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">pedido_entregado_hace_menos_de&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pedido&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">dias&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">30&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;sustituir&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">diferencia_pvp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku_original&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sku_nuevo&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">False&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tres cosas de esta función. La primera: consulta la base de negocio, no el estado del agente. El historial de devoluciones del cliente está en &lt;code>devoluciones&lt;/code>, y es la fuente que un encargado aceptaría. La segunda: recibe &lt;code>req.state&lt;/code> y &lt;code>req.runtime&lt;/code>; verificado que &lt;code>state&lt;/code> es el estado completo del agente, con &lt;code>messages&lt;/code>, y que &lt;code>runtime.context&lt;/code> es el objeto de contexto que se pasó en &lt;code>invoke&lt;/code>. La tercera: se ejecuta después de que el modelo haya decidido llamar a la herramienta y antes de que la herramienta corra. Si devuelve falso, la herramienta corre sin parar. Si devuelve verdadero, el grafo para.&lt;/p>
&lt;h2 id="el-middleware-y-el-contrato-de-la-interrupción">El middleware y el contrato de la interrupción&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">create_agent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents.middleware&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">HumanInTheLoopMiddleware&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">aprobacion&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">HumanInTheLoopMiddleware&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">interrupt_on&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;allowed_decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;edit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reject&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;when&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">necesita_encargado&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;sustituir&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;allowed_decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reject&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;when&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">necesita_encargado&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;consultar_pedido&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;stock_alternativas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description_prefix&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Operación fuera de política. Requiere aprobación de un encargado.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_agent&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">consultar_pedido&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stock_alternativas&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">reembolsar&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sustituir&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">middleware&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">prompt_cliente&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">errores&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limites&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">aprobacion&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_schema&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ContextoCliente&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Compilado, el grafo tiene un nodo más que el del caso 1: &lt;code>HumanInTheLoopMiddleware.after_model&lt;/code>. Ahí es donde se evalúa &lt;code>when&lt;/code> y se llama a &lt;code>interrupt&lt;/code>. Es un nodo posterior al modelo y anterior a &lt;code>tools&lt;/code>; verificado con una herramienta instrumentada que la herramienta no corre antes de la decisión.&lt;/p>
&lt;p>Cuando la política dice que sí, &lt;code>invoke&lt;/code> devuelve el estado con una clave &lt;code>__interrupt__&lt;/code>. Es una lista de objetos &lt;code>Interrupt&lt;/code> con &lt;code>value&lt;/code> e &lt;code>id&lt;/code>. El &lt;code>value&lt;/code> tiene este formato exacto, obtenido de una ejecución con un reembolso de 120 euros:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;action_requests&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;pedido&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;P-20260917-0041&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;importe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">120.0&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Operación fuera de política. Requiere aprobación de un encargado.\n\nTool: reembolsar\nArgs: {&amp;#39;pedido&amp;#39;: &amp;#39;P-20260917-0041&amp;#39;, &amp;#39;importe&amp;#39;: 120.0}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;review_configs&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;action_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;reembolsar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;allowed_decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;edit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reject&amp;#34;&lt;/span>&lt;span class="p">]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>action_requests&lt;/code> lleva una entrada por herramienta interceptada en la misma vuelta del modelo; si el modelo pide un reembolso y una sustitución a la vez y las dos pasan la política, hay dos entradas y una sola interrupción. &lt;code>review_configs&lt;/code> le dice a la interfaz qué botones pintar. El campo &lt;code>description&lt;/code> es el prefijo más un volcado de la herramienta y los argumentos; para el encargado es mejor construir la descripción con una función, que &lt;code>InterruptOnConfig&lt;/code> admite en &lt;code>description&lt;/code>, y darle el nombre del cliente, la fecha del pedido y el motivo que el cliente dio.&lt;/p>
&lt;p>Con &lt;code>interrupt_on={&amp;quot;reembolsar&amp;quot;: True}&lt;/code>, sin diccionario, se permiten las cuatro decisiones y no hay condición: todo reembolso para. Es el valor para empezar y para las pruebas, no para la tienda.&lt;/p>
&lt;h2 id="las-cuatro-decisiones">Las cuatro decisiones&lt;/h2>
&lt;p>La reanudación entra con &lt;code>Command(resume=...)&lt;/code> y un diccionario con una lista &lt;code>decisions&lt;/code>, en el mismo orden que &lt;code>action_requests&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Command&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;approve&amp;#34;&lt;/span>&lt;span class="p">}]}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Las cuatro, verificadas con la herramienta instrumentada:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Decisión&lt;/th>
&lt;th>Formato&lt;/th>
&lt;th>Qué pasa&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>approve&lt;/code>&lt;/td>
&lt;td>&lt;code>{&amp;quot;type&amp;quot;: &amp;quot;approve&amp;quot;}&lt;/code>&lt;/td>
&lt;td>La herramienta corre con los argumentos que propuso el modelo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>edit&lt;/code>&lt;/td>
&lt;td>&lt;code>{&amp;quot;type&amp;quot;: &amp;quot;edit&amp;quot;, &amp;quot;edited_action&amp;quot;: {&amp;quot;name&amp;quot;: &amp;quot;reembolsar&amp;quot;, &amp;quot;args&amp;quot;: {...}}}&lt;/code>&lt;/td>
&lt;td>La herramienta corre con los argumentos editados. Con el importe cambiado de 120 a 90, la herramienta recibió 90&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>reject&lt;/code>&lt;/td>
&lt;td>&lt;code>{&amp;quot;type&amp;quot;: &amp;quot;reject&amp;quot;, &amp;quot;message&amp;quot;: &amp;quot;Fuera de plazo, el pedido tiene 45 días.&amp;quot;}&lt;/code>&lt;/td>
&lt;td>La herramienta no corre. El modelo recibe un &lt;code>ToolMessage&lt;/code> con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code> y el texto &lt;code>User rejected the tool call for reembolsar with reason: Fuera de plazo, el pedido tiene 45 días.&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>respond&lt;/code>&lt;/td>
&lt;td>&lt;code>{&amp;quot;type&amp;quot;: &amp;quot;respond&amp;quot;, &amp;quot;message&amp;quot;: &amp;quot;Ofrece un vale en vez de reembolso.&amp;quot;}&lt;/code>&lt;/td>
&lt;td>La herramienta no corre. El modelo recibe un &lt;code>ToolMessage&lt;/code> con &lt;code>status=&amp;quot;success&amp;quot;&lt;/code> y ese texto como si fuera el resultado&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dos consecuencias para el diseño.&lt;/p>
&lt;p>&lt;strong>El rechazo llega al modelo en inglés.&lt;/strong> El marco &lt;code>User rejected the tool call ... with reason:&lt;/code> lo pone el middleware y no se configura. El motivo del encargado va en castellano detrás. El prompt del agente lleva esta línea: &amp;ldquo;Si una operación es rechazada por un encargado, comunica al cliente el motivo tal cual, en castellano, y no propongas repetirla&amp;rdquo;. Sin ella, se han visto respuestas que mezclan idiomas o que reintentan el reembolso con otro importe.&lt;/p>
&lt;p>&lt;strong>&lt;code>respond&lt;/code> es la puerta para la resolución manual.&lt;/strong> El encargado no aprueba ni rechaza; le dice al agente qué hacer. Como la herramienta no corre, el vale o el reembolso parcial que el encargado proponga lo tiene que emitir el propio encargado en el TPV. Para este agente se deja fuera de &lt;code>allowed_decisions&lt;/code> en las dos herramientas: el encargado edita o rechaza, y lo que quiera hacer distinto lo hace fuera. Es una decisión de tienda, no de código, y el sitio donde se toma es esa lista.&lt;/p>
&lt;h2 id="lo-que-se-persiste-y-lo-que-se-repite">Lo que se persiste y lo que se repite&lt;/h2>
&lt;p>Una interrupción es un checkpoint más. Con el middleware de aprobación y sin los límites del caso 1, la ejecución que para en un reembolso deja 6 checkpoints; la reanudación con &lt;code>edit&lt;/code> añade 4 y acaba en 10. El estado del hilo entre los dos &lt;code>invoke&lt;/code> está entero en Postgres: mensajes, la llamada propuesta, y la interrupción pendiente. &lt;code>agente.get_state(config)&lt;/code> lo devuelve con &lt;code>next=('tools',)&lt;/code> y una tarea con la interrupción en &lt;code>tasks[0].interrupts&lt;/code>.&lt;/p>
&lt;p>Sobre qué se repite hay dos casos distintos que no hay que mezclar.&lt;/p>
&lt;p>&lt;strong>La re-ejecución del nodo al reanudar.&lt;/strong> Es diseño de LangGraph: el nodo que llamó a &lt;code>interrupt&lt;/code> se ejecuta desde el principio cuando llega &lt;code>Command(resume=...)&lt;/code>, y la llamada a &lt;code>interrupt&lt;/code> devuelve entonces el valor reanudado. Verificado con un nodo propio que registra efectos antes y después de la interrupción: la secuencia es &lt;code>antes, antes, después&lt;/code>. Con el middleware esto no afecta a la herramienta, porque la interrupción vive en &lt;code>after_model&lt;/code> y la herramienta corre en &lt;code>tools&lt;/code>, un nodo después. Afecta a cualquier nodo propio que se escriba con &lt;code>interrupt&lt;/code> dentro, y la regla es que la interrupción sea lo primero del nodo.&lt;/p>
&lt;p>&lt;strong>La re-ejecución de una herramienta en vuelo.&lt;/strong> Si el pod muere con &lt;code>reembolsar&lt;/code> a medias, al reanudar el hilo la tarea del nodo &lt;code>tools&lt;/code> se ejecuta otra vez. Las tareas que terminaron antes del fallo no: sus escrituras quedan guardadas como pendientes y se reaplican. Verificado con un abanico de 20 tareas en el que una falla: el estado tras el fallo tiene 19 resultados, y la reanudación con &lt;code>invoke(None, config)&lt;/code> ejecuta solo la que faltaba. Pero la que estaba en vuelo cuando murió el proceso no deja escritura, y corre de nuevo.&lt;/p>
&lt;p>Por eso &lt;code>reembolsar&lt;/code> es idempotente por identificador de llamada:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.tools&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">tool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">reembolsar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">float&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">ContextoCliente&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Emite un reembolso al medio de pago original del pedido.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">previo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;SELECT id, estado FROM reembolsos WHERE clave_idempotencia = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tool_call_id&lt;/span>&lt;span class="p">,),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchone&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">previo&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reembolso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">previo&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;repetido&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;INSERT INTO reembolsos (pedido_id, importe, clave_idempotencia, aprobado_por, trace_id) &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;VALUES (&lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">) RETURNING id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tool_call_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;aprobado_por&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;langfuse_trace_id&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchone&lt;/span>&lt;span class="p">()[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pasarela&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">reembolsar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pedido&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">importe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">referencia&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">rid&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reembolso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">rid&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>runtime.tool_call_id&lt;/code> es el identificador que el modelo puso en la llamada, y se conserva en el checkpoint. Una re-ejecución trae el mismo identificador, y la columna &lt;code>clave_idempotencia&lt;/code> con restricción única lo para. La llamada a la pasarela va fuera de la transacción y con la referencia del reembolso, para que la pasarela deduplique por su lado si el proceso muere entre el &lt;code>INSERT&lt;/code> y la llamada.&lt;/p>
&lt;p>La durabilidad se fija en &lt;code>invoke&lt;/code>. Con &lt;code>durability=&amp;quot;sync&amp;quot;&lt;/code>, cada checkpoint se escribe antes de seguir al siguiente superstep. Es el valor para los hilos con dinero. &lt;code>&amp;quot;async&amp;quot;&lt;/code> escribe en paralelo con la ejecución y puede perder el último paso si el pod muere. &lt;code>&amp;quot;exit&amp;quot;&lt;/code> escribe al terminar y no vale para nada que pueda interrumpirse.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">decisiones&lt;/span>&lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">durability&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sync&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="la-cola-de-aprobaciones">La cola de aprobaciones&lt;/h2>
&lt;p>El checkpointer guarda cada hilo, pero no tiene ninguna consulta que devuelva los hilos que están esperando a un humano. &lt;code>PostgresSaver.list&lt;/code> lista checkpoints de un hilo dado; no lista hilos. Y no hay columna de estado en &lt;code>checkpoints&lt;/code> que diga &amp;ldquo;interrumpido&amp;rdquo;.&lt;/p>
&lt;p>La cola es una tabla propia:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">aprobaciones_pendientes&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">interrupt_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">peticion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">jsonb&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- el value de la interrupción
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">creada&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">timestamptz&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">asignada_a&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La aplicación la escribe cuando &lt;code>invoke&lt;/code> devuelve &lt;code>__interrupt__&lt;/code> y la borra en la reanudación, en la misma transacción en la que registra quién decidió qué. Es también la tabla desde la que se mide el tiempo entre interrupción y decisión.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">entrada&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">durability&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sync&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">interrupciones&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="n">resultado&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;__interrupt__&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">intr&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">interrupciones&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;INSERT INTO aprobaciones_pendientes (thread_id, tienda, cliente_id, interrupt_id, peticion, trace_id) &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;VALUES (&lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">) ON CONFLICT (thread_id) DO UPDATE SET peticion = EXCLUDED.peticion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">intr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Json&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">intr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;langfuse_trace_id&amp;#34;&lt;/span>&lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;estado&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;esperando_aprobacion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;mensaje_cliente&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">mensaje_espera&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">intr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">)}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y del lado del encargado, la reanudación con el registro de quién decidió:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">decidir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">encargado&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">decisiones&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;aprobado_por&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">encargado&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">langfuse_handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decisions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">decisiones&lt;/span>&lt;span class="p">}),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">durability&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sync&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;DELETE FROM aprobaciones_pendientes WHERE thread_id = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;INSERT INTO decisiones_encargado (thread_id, encargado, decisiones) VALUES (&lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">encargado&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Json&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">decisiones&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">resultado&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El contexto del cliente no viaja en esta llamada. &lt;code>Command(resume=...)&lt;/code> se invoca sin &lt;code>context&lt;/code>, y el prompt dinámico y las herramientas lo leen del &lt;code>runtime&lt;/code>: en la reanudación, &lt;code>runtime.context&lt;/code> es &lt;code>None&lt;/code> si no se pasa. Verificado con un prompt dinámico que registra el contexto en cada llamada al modelo: &lt;code>C1&lt;/code> en la primera, &lt;code>None&lt;/code> tras &lt;code>Command(resume=...)&lt;/code> sin &lt;code>context&lt;/code>. Hay dos opciones: guardar el contexto del cliente en el estado del agente con un &lt;code>state_schema&lt;/code> propio, o pasarlo otra vez en la reanudación leyéndolo de la cola. Aquí se hace lo segundo, porque el contexto tiene el identificador del cliente y no se quiere en el checkpoint más de lo necesario. La función real de &lt;code>decidir&lt;/code> carga &lt;code>ContextoCliente&lt;/code> de &lt;code>aprobaciones_pendientes&lt;/code> y lo pasa en &lt;code>context=&lt;/code>.&lt;/p>
&lt;p>Quién puede reanudar es autorización de la capa HTTP: &lt;code>decidir&lt;/code> solo se llama desde un endpoint que exige rol de encargado y comprueba que la tienda del encargado es la de la fila. El checkpointer acepta cualquier &lt;code>Command(resume=...)&lt;/code> para cualquier &lt;code>thread_id&lt;/code>, y eso se documenta en el marco de la serie.&lt;/p>
&lt;h2 id="el-prompt">El prompt&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dynamic_prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">prompt_cliente&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ModelRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Atiendes al cliente &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cliente_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> de la tienda &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> sobre su pedido. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Consulta el pedido antes de proponer nada. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Para devolver, usa reembolsar con el importe de las líneas devueltas; para cambiar por otra referencia, &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;comprueba stock_alternativas y usa sustituir. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Algunas operaciones esperan la aprobación de un encargado; si es así, dile al cliente que la operación &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;queda pendiente y que recibirá confirmación, sin prometer un plazo. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Si un encargado rechaza una operación, comunica el motivo tal cual, en castellano, y no la repitas &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;con otros valores. Nunca inventes un número de reembolso: solo el que devuelva la herramienta.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La frase sobre la aprobación pendiente importa porque el modelo no ve la interrupción. Desde su punto de vista, pidió una herramienta y la conversación se detuvo. Cuando el hilo se reanuda con &lt;code>approve&lt;/code>, lo siguiente que ve es el resultado de la herramienta, como si no hubiera pasado tiempo. Es la aplicación la que le dice al cliente que espere, con &lt;code>mensaje_espera&lt;/code>, no el modelo. Y en la reanudación, el mensaje que el modelo genera tras el resultado es el que se le envía al cliente por el canal que corresponda, con el retraso que haya habido.&lt;/p>
&lt;h2 id="una-devolución-fuera-de-política-de-principio-a-fin">Una devolución fuera de política, de principio a fin&lt;/h2>
&lt;p>Con las herramientas reales sobre el catálogo de prueba y las respuestas del modelo guionizadas, como en el caso 1:&lt;/p>
&lt;pre tabindex="0">&lt;code>Cliente: Quiero devolver el pedido P-20260917-0041, la cafetera llegó con la jarra rota.
[tools] consultar_pedido(pedido=&amp;#34;P-20260917-0041&amp;#34;) -&amp;gt; {&amp;#34;pedido&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;importe&amp;#34;: 120.0, &amp;#34;entregado&amp;#34;: true, &amp;#34;dias_desde_entrega&amp;#34;: 4, &amp;#34;lineas&amp;#34;: [...]}
[after_model] reembolsar(pedido=&amp;#34;P-20260917-0041&amp;#34;, importe=120.0) -&amp;gt; when=True (importe &amp;gt; 60)
[__interrupt__] {&amp;#34;action_requests&amp;#34;: [{&amp;#34;name&amp;#34;: &amp;#34;reembolsar&amp;#34;, &amp;#34;args&amp;#34;: {&amp;#34;pedido&amp;#34;: &amp;#34;P-20260917-0041&amp;#34;, &amp;#34;importe&amp;#34;: 120.0}, ...}], ...}
Aplicación -&amp;gt; Cliente: Tu solicitud de devolución está registrada y pendiente de revisión. Te confirmaremos por este canal.
[encargado, 3 horas después] edit: importe 90.0
[tools] reembolsar(pedido=&amp;#34;P-20260917-0041&amp;#34;, importe=90.0) -&amp;gt; {&amp;#34;ok&amp;#34;: true, &amp;#34;reembolso&amp;#34;: 7712}
Agente -&amp;gt; Cliente: Hemos aprobado un reembolso de 90 euros por la cafetera, con referencia 7712. El importe llegará a tu medio de pago en 3 a 5 días.
&lt;/code>&lt;/pre>&lt;p>El modelo no sabe por qué el importe es 90 y no 120: la edición cambia los argumentos de la llamada, no le explica nada. Si la tienda quiere que el cliente reciba una explicación (jarra de repuesto sin coste, reembolso del resto), el encargado la escribe en la aplicación y la aplicación la añade al mensaje final. Meter esa explicación en el modelo exigiría una herramienta de notas con &lt;code>respond&lt;/code>, y en este agente &lt;code>respond&lt;/code> está fuera de &lt;code>allowed_decisions&lt;/code>.&lt;/p>
&lt;h2 id="lo-que-se-mide">Lo que se mide&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Métrica&lt;/th>
&lt;th>Dónde&lt;/th>
&lt;th>Qué dice&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Interrupciones por cada cien conversaciones&lt;/td>
&lt;td>&lt;code>aprobaciones_pendientes&lt;/code> (histórico)&lt;/td>
&lt;td>Si la política para demasiado o demasiado poco. Entre 10 y 25 es la zona de trabajo; por encima, el umbral de importe es bajo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tiempo entre interrupción y decisión, p50 y p95&lt;/td>
&lt;td>&lt;code>creada&lt;/code> frente a la fila de &lt;code>decisiones_encargado&lt;/code>&lt;/td>
&lt;td>Lo que el cliente espera. El p95 dice si hay un turno sin encargado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Decisiones por tipo&lt;/td>
&lt;td>&lt;code>decisiones_encargado&lt;/code>&lt;/td>
&lt;td>Muchos &lt;code>edit&lt;/code> con el mismo patrón son una regla que falta en la política; muchos &lt;code>reject&lt;/code> con el mismo motivo, una condición que falta en &lt;code>when&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reembolsos con &lt;code>repetido = true&lt;/code>&lt;/td>
&lt;td>&lt;code>reembolsos&lt;/code>&lt;/td>
&lt;td>Re-ejecuciones reales. Debe ser cercano a cero y distinto de cero alguna vez; si nunca pasa, la clave de idempotencia no se está probando&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Operaciones con efecto sin fila en &lt;code>decisiones_encargado&lt;/code> y con &lt;code>when&lt;/code> verdadero&lt;/td>
&lt;td>Cruce de &lt;code>reembolsos&lt;/code> con &lt;code>decisiones_encargado&lt;/code>&lt;/td>
&lt;td>Debe ser cero. Es la auditoría de que el límite funciona&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokens y coste por conversación, con y sin interrupción&lt;/td>
&lt;td>Langfuse, metadato &lt;code>caso=cliente&lt;/code>&lt;/td>
&lt;td>Una conversación interrumpida tiene dos trazas o una con hueco, según se configure el callback; las dos formas valen si se agrupan por &lt;code>thread_id&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La última fila de la tabla es la que se enseña al responsable. No dice cuánto ahorra el agente; dice que ningún euro ha salido sin la firma que la política exige. Es la condición para que el resto de métricas importen.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>La política es una función &lt;code>when&lt;/code> que consulta la base de negocio, no el prompt.&lt;/li>
&lt;li>&lt;code>allowed_decisions&lt;/code> está decidido por herramienta y &lt;code>respond&lt;/code> está fuera salvo en herramientas de notas.&lt;/li>
&lt;li>&lt;code>description&lt;/code> es una función que da al encargado el contexto que necesita, no el volcado por defecto.&lt;/li>
&lt;li>La aplicación escribe &lt;code>aprobaciones_pendientes&lt;/code> al recibir &lt;code>__interrupt__&lt;/code> y la borra al reanudar, en transacción con &lt;code>decisiones_encargado&lt;/code>.&lt;/li>
&lt;li>Las herramientas con efecto usan &lt;code>runtime.tool_call_id&lt;/code> como clave de idempotencia con restricción única.&lt;/li>
&lt;li>&lt;code>invoke&lt;/code> lleva &lt;code>durability=&amp;quot;sync&amp;quot;&lt;/code> en los hilos con dinero.&lt;/li>
&lt;li>La reanudación pasa &lt;code>context=&lt;/code> cargado de la cola; no se confía en que el &lt;code>runtime&lt;/code> lo recuerde.&lt;/li>
&lt;li>El endpoint de reanudación exige rol de encargado y comprueba la tienda.&lt;/li>
&lt;li>El prompt dice qué hacer con un rechazo y no promete plazos.&lt;/li>
&lt;li>Hay una consulta de auditoría que cruza efectos con decisiones y da cero.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>&lt;code>interrupt&lt;/code> sin checkpointer no falla al interrumpir.&lt;/strong> Devuelve &lt;code>__interrupt__&lt;/code> con normalidad y el estado se pierde. El error, &lt;code>Cannot use Command(resume=...) without checkpointer&lt;/code>, sale en la reanudación. Un entorno de pruebas sin persistencia da falsos positivos hasta que alguien intenta aprobar.&lt;/p>
&lt;p>&lt;strong>La herramienta no corre antes de la decisión, pero el modelo ya la ha pedido.&lt;/strong> Lo que se aprueba es una llamada con argumentos concretos. Si el encargado tarda un día y el stock ha cambiado, &lt;code>sustituir&lt;/code> puede fallar al ejecutarse con los argumentos aprobados. La herramienta tiene que volver a comprobar, y el mensaje de error tiene que decir que la aprobación caducó.&lt;/p>
&lt;p>&lt;strong>El nodo que interrumpe se ejecuta dos veces.&lt;/strong> Con el middleware no importa; con un nodo propio, todo lo anterior a &lt;code>interrupt&lt;/code> se repite. La interrupción va primero.&lt;/p>
&lt;p>&lt;strong>La herramienta en vuelo se repite si muere el pod.&lt;/strong> Las que terminaron no. La clave de idempotencia es el identificador de la llamada, y hay que probar la re-ejecución a propósito, matando el pod con una herramienta lenta, para ver la fila con &lt;code>repetido = true&lt;/code>.&lt;/p>
&lt;p>&lt;strong>El rechazo llega en inglés.&lt;/strong> &lt;code>User rejected the tool call for ... with reason:&lt;/code> es texto fijo del middleware. Sin instrucción en el prompt, el modelo puede contestar en inglés o reintentar.&lt;/p>
&lt;p>&lt;strong>No hay lista de hilos interrumpidos.&lt;/strong> Ni en el checkpointer ni en el estado. La cola es una tabla propia, y si la aplicación se cae entre el &lt;code>invoke&lt;/code> y el &lt;code>INSERT&lt;/code>, hay un hilo esperando que nadie ve. La escritura de la cola va lo más cerca posible del &lt;code>invoke&lt;/code>, y una tarea periódica cruza &lt;code>aprobaciones_pendientes&lt;/code> con los hilos que tienen &lt;code>next=('tools',)&lt;/code> y una interrupción en &lt;code>get_state&lt;/code>, recorriendo los hilos del día desde &lt;code>consultas_asistente&lt;/code>.&lt;/p>
&lt;p>&lt;strong>El contexto no se recuerda en la reanudación.&lt;/strong> &lt;code>runtime.context&lt;/code> es lo que se pase en esa llamada. Sin &lt;code>context=&lt;/code>, el prompt dinámico ve &lt;code>None&lt;/code> y falla al formatear.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>El límite de aprobación humana es una función, un middleware y una tabla. La función decide cuándo se pregunta; el middleware para el grafo antes de la herramienta y fija el contrato de la pregunta y de la respuesta; la tabla es la cola que el checkpointer no tiene. Alrededor, tres decisiones que no son de LangGraph pero que el código obliga a tomar: qué decisiones se permiten por herramienta, cómo se hace idempotente lo que tiene efecto, y quién puede reanudar.&lt;/p>
&lt;p>El siguiente caso deja al cliente y mira al almacén. Un agente de compras tiene que revisar miles de referencias contra las incidencias de sus proveedores y proponer una reposición. Ahí el problema no es parar, es abrir en abanico sin tirar el servidor de inferencia, agregar sin condiciones de carrera y no pagar dos veces por la misma referencia.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">deepagents en un cluster propio: el SDK es libre, el servidor no&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">Ejecución durable con Temporal y el coste de los agentes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">Aislar agentes de IA por cliente en el cluster&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Human-in-the-loop en LangChain 1.x: &lt;a href="https://docs.langchain.com/oss/python/langchain/human-in-the-loop">https://docs.langchain.com/oss/python/langchain/human-in-the-loop&lt;/a>&lt;/li>
&lt;li>Interrupciones en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/interrupts">https://docs.langchain.com/oss/python/langgraph/interrupts&lt;/a>&lt;/li>
&lt;li>Persistencia y durabilidad en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langchain.agents.middleware.human_in_the_loop&lt;/code> (langchain 1.4.1): &lt;code>InterruptOnConfig&lt;/code>, &lt;code>HITLRequest&lt;/code>, &lt;code>HITLResponse&lt;/code>, &lt;code>ApproveDecision&lt;/code>, &lt;code>EditDecision&lt;/code>, &lt;code>RejectDecision&lt;/code>, &lt;code>RespondDecision&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.types&lt;/code> (langgraph 1.2.11): &lt;code>interrupt&lt;/code>, &lt;code>Command&lt;/code>, &lt;code>Interrupt&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.pregel.main&lt;/code> (langgraph 1.2.11): firma de &lt;code>invoke&lt;/code>, parámetro &lt;code>durability&lt;/code>&lt;/li>
&lt;/ul></description></item><item><title>Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas con RAG, herramientas y middleware</title><link>https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/</link><pubDate>Thu, 17 Sep 2026 01:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El asistente de sala es un &lt;code>create_agent&lt;/code> 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>El RAG de procedimientos es una tabla en Postgres con pgvector, troceada por sección del manual, con una columna &lt;code>rol_minimo&lt;/code> que entra en el &lt;code>WHERE&lt;/code> 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.&lt;/p>
&lt;p>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 &lt;code>ToolErrorMiddleware&lt;/code> con un manejador propio o &lt;code>ToolRetryMiddleware&lt;/code>. Y el mensaje que deja &lt;code>ToolRetryMiddleware&lt;/code> al agotar reintentos termina en &amp;ldquo;Please try again&amp;rdquo;, lo que invita al modelo a volver a llamar a la herramienta rota.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="estás-aquí-el-primer-agente-sobre-el-marco-de-la-serie">Estás aquí: el primer agente sobre el marco de la serie&lt;/h2>
&lt;p>Este es el caso 1 de la &lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">serie de agentes con LangGraph para retail&lt;/a>. 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="la-analogía-la-carpeta-de-procedimientos-y-el-teléfono-del-encargado">La analogía: la carpeta de procedimientos y el teléfono del encargado&lt;/h2>
&lt;p>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á.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="el-problema-de-negocio-en-tres-preguntas">El problema de negocio en tres preguntas&lt;/h2>
&lt;p>Antes de escribir herramientas se listan las preguntas reales. Tres cubren la mayoría:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Stock.&lt;/strong> &amp;ldquo;¿Queda el SKU-0912?&amp;rdquo; y su continuación, &amp;ldquo;¿en qué tienda cercana lo hay?&amp;rdquo;. La respuesta es una consulta a la tabla &lt;code>stock&lt;/code> filtrada por la tienda del empleado y una segunda por las demás tiendas.&lt;/li>
&lt;li>&lt;strong>Procedimiento.&lt;/strong> &amp;ldquo;¿Qué hago con una devolución sin ticket?&amp;rdquo;. 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.&lt;/li>
&lt;li>&lt;strong>Rotura.&lt;/strong> &amp;ldquo;Se ha caído un lote de vasos, ¿cómo lo registro?&amp;rdquo;. La respuesta tiene dos partes: qué dice el procedimiento y, si el rol lo permite, el registro en el TPV.&lt;/li>
&lt;/ol>
&lt;p>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.&lt;/p>
&lt;h2 id="las-herramientas">Las herramientas&lt;/h2>
&lt;p>Tres principios que se mantienen en toda la serie, y que aquí aparecen por primera vez.&lt;/p>
&lt;p>&lt;strong>Devuelven datos estructurados.&lt;/strong> Un diccionario que LangChain serializa a JSON en el &lt;code>ToolMessage&lt;/code>. 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.&lt;/p>
&lt;p>&lt;strong>Leen el contexto de ejecución, no argumentos del modelo.&lt;/strong> La tienda y el rol viajan en &lt;code>runtime.context&lt;/code>, un objeto tipado que se pasa en cada &lt;code>invoke&lt;/code>. La herramienta lo recibe en un parámetro &lt;code>runtime: ToolRuntime[Contexto]&lt;/code> que el modelo no ve en el esquema y no puede rellenar.&lt;/p>
&lt;p>&lt;strong>Comprueban el rol dentro.&lt;/strong> 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.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">dataclasses&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">dataclass&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.tools&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">tool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dataclass&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Contexto&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tienda&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">empleado&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rol&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># &amp;#34;dependiente&amp;#34; | &amp;#34;encargado&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">stock_sku&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Contexto&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Unidades disponibles de un SKU en la tienda del empleado y en las tiendas cercanas.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">propia&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fila&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;SELECT unidades - reservadas FROM stock WHERE tienda = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2"> AND sku = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">propia&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchone&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cercanas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;SELECT tienda, unidades - reservadas FROM stock &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;WHERE sku = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2"> AND tienda &amp;lt;&amp;gt; &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2"> AND unidades - reservadas &amp;gt; 0 ORDER BY tienda&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propia&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchall&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">propia&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;unidades&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">fila&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">fila&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;cercanas&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">u&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">u&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">cercanas&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">registrar_rotura&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sku&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">unidades&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">motivo&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Contexto&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Registra una rotura en el TPV y descuenta stock. Solo encargados.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rol&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;permiso_denegado&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;detalle&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Solo un encargado puede registrar roturas.&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">asiento&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;INSERT INTO movimientos (tienda, sku, tipo, unidades, empleado, motivo, trace_id) &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;VALUES (&lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &amp;#39;rotura&amp;#39;, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">) RETURNING id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="n">unidades&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">empleado&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">motivo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;langfuse_trace_id&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchone&lt;/span>&lt;span class="p">()[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;UPDATE stock SET unidades = unidades - &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">, actualizado = now() &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;WHERE tienda = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2"> AND sku = &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">unidades&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sku&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;asiento&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">asiento&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dos detalles del código. &lt;code>pool&lt;/code> es un &lt;code>psycopg_pool.ConnectionPool&lt;/code> de proceso, creado al arrancar, contra la base &lt;code>retail&lt;/code>. Y &lt;code>registrar_rotura&lt;/code> escribe el &lt;code>trace_id&lt;/code> 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 &lt;code>metadata&lt;/code> en la configuración de la llamada; en el artículo 5 lo pone la capa HTTP.&lt;/p>
&lt;p>Un dependiente que le pide al agente registrar la rotura obtiene esto en el &lt;code>ToolMessage&lt;/code>: &lt;code>{&amp;quot;error&amp;quot;: &amp;quot;permiso_denegado&amp;quot;, &amp;quot;detalle&amp;quot;: &amp;quot;Solo un encargado puede registrar roturas.&amp;quot;}&lt;/code>. 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.&lt;/p>
&lt;h2 id="el-rag-de-procedimientos">El RAG de procedimientos&lt;/h2>
&lt;p>La carpeta de procedimientos es la tercera herramienta, y es la única con embeddings.&lt;/p>
&lt;h3 id="la-tabla">La tabla&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">procedimientos&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#34;PROC-7.2&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">seccion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#34;Roturas en sala&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">texto&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rol_minimo&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">smallint&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 0 dependiente, 1 encargado
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vigente&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">boolean&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">true&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1024&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">procedimientos&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hnsw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector_cosine_ops&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>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.&lt;/p>
&lt;p>&lt;code>rol_minimo&lt;/code> es la columna que hace que el filtro sea de la base de datos y no del prompt. &lt;code>vigente&lt;/code> permite retirar una sección sin borrarla, para que las trazas antiguas sigan resolviendo el identificador.&lt;/p>
&lt;h3 id="los-embeddings">Los embeddings&lt;/h3>
&lt;p>Se calculan con un modelo de embeddings servido en el mismo vLLM, con &lt;code>--task embed&lt;/code>, y expuesto en LiteLLM como cualquier otro modelo. Desde LangChain:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAIEmbeddings&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">emb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAIEmbeddings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://litellm.agentes.svc:4000/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LITELLM_KEY_TIENDA&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">check_embedding_ctx_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>check_embedding_ctx_length=False&lt;/code> importa. Con el valor por defecto, &lt;code>OpenAIEmbeddings&lt;/code> tokeniza el texto con &lt;code>tiktoken&lt;/code> 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 &lt;code>False&lt;/code>, envía el texto tal cual.&lt;/p>
&lt;h3 id="la-consulta">La consulta&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@tool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">procedimiento&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">consulta&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ToolRuntime&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Contexto&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Secciones del manual de procedimientos relevantes para la consulta (tres como máximo).&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rol&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;encargado&amp;#34;&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">vector&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">emb&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embed_query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">consulta&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connection&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">filas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">conn&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;SELECT id, seccion, texto, 1 - (embedding &amp;lt;=&amp;gt; &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">::vector) AS similitud &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;FROM procedimientos &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;WHERE vigente AND rol_minimo &amp;lt;= &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2"> &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;ORDER BY embedding &amp;lt;=&amp;gt; &lt;/span>&lt;span class="si">%s&lt;/span>&lt;span class="s2">::vector LIMIT 3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rol&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">vector&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetchall&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;seccion&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;texto&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;similitud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">round&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sim&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">)}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sim&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">filas&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">sim&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mf">0.45&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El filtro por rol va en el &lt;code>WHERE&lt;/code>, antes del &lt;code>ORDER BY&lt;/code>. 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="el-prompt-dinámico">El prompt dinámico&lt;/h2>
&lt;p>El sistema se construye en cada llamada al modelo a partir del contexto. No hay un prompt fijo con huecos rellenados a mano en &lt;code>invoke&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents.middleware&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">dynamic_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ModelRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dynamic_prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">prompt_tienda&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ModelRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Eres el asistente de sala de la tienda &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Hablas con el empleado &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">empleado&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">, con rol &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rol&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Responde solo con datos devueltos por las herramientas. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Si una herramienta devuelve un error, explícalo con sus palabras y no lo intentes de otra forma. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Cuando uses el manual, cita el identificador de sección entre paréntesis. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Si no hay sección relevante, dilo y remite al encargado. &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Tres frases como máximo.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>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.&lt;/p>
&lt;h2 id="el-agente-y-su-middleware">El agente y su middleware&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">create_agent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents.middleware&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ToolCallLimitMiddleware&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ModelCallLimitMiddleware&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ToolErrorMiddleware&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SummarizationMiddleware&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">error_herramienta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">exc&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="ne">Exception&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">type&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">exc&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="vm">__name__&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;detalle&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">exc&lt;/span>&lt;span class="p">)[:&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;herramienta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">construir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">checkpointer&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">create_agent&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">stock_sku&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">procedimiento&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">registrar_rotura&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">middleware&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prompt_tienda&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ToolErrorMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">on_error&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">error_herramienta&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ToolCallLimitMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">run_limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exit_behavior&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;end&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ModelCallLimitMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">run_limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exit_behavior&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;end&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">SummarizationMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">trigger&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fraction&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">0.75&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">keep&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fraction&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">0.25&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_schema&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Contexto&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cada middleware está por una razón que se ha visto en el código, no por costumbre.&lt;/p>
&lt;p>&lt;strong>&lt;code>ToolErrorMiddleware&lt;/code>.&lt;/strong> Sin él, una excepción en &lt;code>stock_sku&lt;/code> porque la base de datos no responde no llega al modelo. Se propaga por el nodo &lt;code>tools&lt;/code>, sale de &lt;code>invoke&lt;/code> y el turno acaba con una traza de error y sin respuesta. Verificado con una herramienta que lanza &lt;code>RuntimeError&lt;/code>: el manejador por defecto del nodo de herramientas de LangGraph 1.2.11 devuelve mensaje solo para &lt;code>ToolInvocationError&lt;/code>, que es el error de validación de argumentos, y re-lanza el resto. Con el manejador de arriba, el modelo recibe &lt;code>{&amp;quot;error&amp;quot;: &amp;quot;RuntimeError&amp;quot;, &amp;quot;detalle&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;herramienta&amp;quot;: &amp;quot;stock_sku&amp;quot;}&lt;/code> en un &lt;code>ToolMessage&lt;/code> con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code> 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 &lt;code>TypeError&lt;/code> en la primera excepción real.&lt;/p>
&lt;p>&lt;strong>&lt;code>ToolCallLimitMiddleware(run_limit=6)&lt;/code>.&lt;/strong> 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. &lt;code>exit_behavior=&amp;quot;end&amp;quot;&lt;/code> termina la ejecución sin excepción, con la última respuesta del modelo. El límite por hilo, &lt;code>thread_limit&lt;/code>, se deja sin fijar porque el hilo de un dependiente dura un turno de trabajo entero.&lt;/p>
&lt;p>&lt;strong>&lt;code>ModelCallLimitMiddleware(run_limit=8)&lt;/code>.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>&lt;code>SummarizationMiddleware&lt;/code>.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;code>ToolRetryMiddleware&lt;/code> no está. Se probó y se quitó. Con el valor por defecto de dos reintentos y &lt;code>on_failure=&amp;quot;continue&amp;quot;&lt;/code>, el mensaje que deja al agotar los reintentos es &lt;code>Tool 'stock_sku' failed after 3 attempts with RuntimeError: ... Please try again.&lt;/code> Ese &amp;ldquo;Please try again&amp;rdquo; 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 &lt;code>ToolCallLimitMiddleware&lt;/code> 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.&lt;/p>
&lt;h3 id="el-grafo-que-sale">El grafo que sale&lt;/h3>
&lt;p>Compilado con esos middlewares, &lt;code>agente.get_graph().nodes&lt;/code> devuelve ocho nodos: &lt;code>__start__&lt;/code>, &lt;code>model&lt;/code>, &lt;code>tools&lt;/code>, &lt;code>ToolCallLimitMiddleware.after_model&lt;/code>, &lt;code>ModelCallLimitMiddleware.before_model&lt;/code>, &lt;code>ModelCallLimitMiddleware.after_model&lt;/code>, &lt;code>SummarizationMiddleware.before_model&lt;/code> y &lt;code>__end__&lt;/code>. &lt;code>prompt_tienda&lt;/code> y &lt;code>ToolErrorMiddleware&lt;/code> 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 &lt;code>__end__&lt;/code>.&lt;/p>
&lt;p>Un turno con una llamada a herramienta recorre &lt;code>__start__&lt;/code>, los dos &lt;code>before_model&lt;/code>, &lt;code>model&lt;/code>, los dos &lt;code>after_model&lt;/code>, &lt;code>tools&lt;/code>, y otra vez los dos &lt;code>before_model&lt;/code>, &lt;code>model&lt;/code> y los dos &lt;code>after_model&lt;/code>. Trece supersteps, trece checkpoints. Solo con los dos límites, once. Sin middleware, cinco. Medido con un modelo falso que devuelve una llamada a &lt;code>stock_sku&lt;/code> y después una respuesta, sobre &lt;code>InMemorySaver&lt;/code>, contando con &lt;code>checkpointer.list(config)&lt;/code>. 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 &lt;code>tools&lt;/code>.&lt;/p>
&lt;h2 id="persistencia">Persistencia&lt;/h2>
&lt;p>El checkpointer es &lt;code>PostgresSaver&lt;/code> sobre la base &lt;code>agentes&lt;/code>, distinta de &lt;code>retail&lt;/code>. 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.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">psycopg_pool&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ConnectionPool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.checkpoint.postgres&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PostgresSaver&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">pool_agentes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ConnectionPool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;PG_AGENTES&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">min_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">max_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">kwargs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;autocommit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;prepare_threshold&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">checkpointer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PostgresSaver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pool_agentes&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">construir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">checkpointer&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>setup()&lt;/code> no se llama aquí. Crea cuatro tablas y tres índices, y los índices van con &lt;code>CREATE INDEX CONCURRENTLY&lt;/code>, 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.&lt;/p>
&lt;p>El &lt;code>thread_id&lt;/code> lo construye la aplicación: &lt;code>f&amp;quot;tienda:{ctx.tienda}:{ctx.empleado}:{turno}&amp;quot;&lt;/code>, con &lt;code>turno&lt;/code> 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 &lt;code>checkpointer.delete_thread(thread_id)&lt;/code> 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.&lt;/p>
&lt;p>Una consulta desde la aplicación:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">config&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;caso&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tienda&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;rol&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rol&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">langfuse_handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">pregunta&lt;/span>&lt;span class="p">}]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">respuesta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">resultado&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y la misma en streaming, que es lo que la aplicación de sala usa para pintar la respuesta según llega:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">meta&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">agente&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">pregunta&lt;/span>&lt;span class="p">}]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stream_mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">meta&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langgraph_node&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">emitir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>stream_mode=&amp;quot;messages&amp;quot;&lt;/code> entrega los trozos de cada mensaje con metadatos del nodo que los produce. Filtrar por &lt;code>langgraph_node == &amp;quot;model&amp;quot;&lt;/code> deja fuera los &lt;code>ToolMessage&lt;/code>, que no se muestran al dependiente.&lt;/p>
&lt;h2 id="una-conversación-completa">Una conversación completa&lt;/h2>
&lt;p>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.&lt;/p>
&lt;pre tabindex="0">&lt;code>Dependiente: ¿Queda el SKU-0912?
[tools] stock_sku(sku=&amp;#34;SKU-0912&amp;#34;) -&amp;gt; {&amp;#34;sku&amp;#34;: &amp;#34;SKU-0912&amp;#34;, &amp;#34;tienda&amp;#34;: &amp;#34;T001&amp;#34;, &amp;#34;unidades&amp;#34;: 0, &amp;#34;cercanas&amp;#34;: {&amp;#34;T002&amp;#34;: 14}}
Agente: En T001 no queda. T002 tiene 14 unidades disponibles.
Dependiente: Un cliente quiere devolver uno sin ticket, ¿qué hago?
[tools] procedimiento(consulta=&amp;#34;devolución sin ticket&amp;#34;) -&amp;gt; [{&amp;#34;id&amp;#34;: &amp;#34;PROC-4.3&amp;#34;, &amp;#34;seccion&amp;#34;: &amp;#34;Devoluciones sin justificante&amp;#34;, ...}]
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=&amp;#34;rotura en sala&amp;#34;) -&amp;gt; [{&amp;#34;id&amp;#34;: &amp;#34;PROC-7.2&amp;#34;, ...}]
[tools] registrar_rotura(sku=&amp;#34;SKU-4471&amp;#34;, unidades=6, motivo=&amp;#34;caída en sala&amp;#34;) -&amp;gt; {&amp;#34;error&amp;#34;: &amp;#34;permiso_denegado&amp;#34;, &amp;#34;detalle&amp;#34;: &amp;#34;Solo un encargado puede registrar roturas.&amp;#34;}
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.
&lt;/code>&lt;/pre>&lt;p>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 &lt;code>rol=&amp;quot;encargado&amp;quot;&lt;/code> en el contexto devuelve &lt;code>{&amp;quot;ok&amp;quot;: true, &amp;quot;asiento&amp;quot;: ...}&lt;/code> con el identificador del movimiento, y la respuesta lo cita.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="lo-que-se-mide-en-la-primera-semana">Lo que se mide en la primera semana&lt;/h2>
&lt;p>Dos fuentes. Langfuse dice qué hizo el agente; la tabla &lt;code>movimientos&lt;/code> y un registro de consultas dicen qué pasó en la tienda.&lt;/p>
&lt;p>&lt;strong>Langfuse&lt;/strong>, agrupando por los metadatos &lt;code>caso&lt;/code>, &lt;code>tienda&lt;/code> y &lt;code>rol&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Métrica&lt;/th>
&lt;th>Dónde&lt;/th>
&lt;th>Qué dice&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Consultas por dependiente y día&lt;/td>
&lt;td>Trazas por &lt;code>thread_id&lt;/code>&lt;/td>
&lt;td>Si lo usan. Por debajo de cinco al día, no lo usan&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Latencia p50 y p95 por consulta&lt;/td>
&lt;td>Duración de la traza&lt;/td>
&lt;td>Con vLLM saturado, el p95 se dispara antes que el p50&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokens de entrada y salida, coste&lt;/td>
&lt;td>Generaciones de la traza&lt;/td>
&lt;td>La entrada crece con el hilo; el resumen la acota&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llamadas a herramienta por turno&lt;/td>
&lt;td>Spans de &lt;code>tools&lt;/code>&lt;/td>
&lt;td>Mediana 1, p95 2. Más, el prompt o las docstrings están mal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cortes por límite&lt;/td>
&lt;td>Trazas que acaban en &lt;code>after_model&lt;/code> de un límite&lt;/td>
&lt;td>Debe ser cero. Cada corte es una conversación fallida&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Errores de herramienta&lt;/td>
&lt;td>&lt;code>ToolMessage&lt;/code> con &lt;code>status=&amp;quot;error&amp;quot;&lt;/code>&lt;/td>
&lt;td>Separar &lt;code>permiso_denegado&lt;/code> del resto: el primero es correcto, el resto es infra&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Postgres&lt;/strong>, con dos consultas:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Consultas que acabaron en el encargado
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">FILTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">escalada&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">al_encargado&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">consultas_asistente&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- Roturas registradas a través del agente y tiempo desde la consulta
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">roturas&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">mediana_hasta_registro&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">movimientos&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">consultas_asistente&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tipo&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;rotura&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>consultas_asistente&lt;/code> la escribe la aplicación en cada turno con el &lt;code>thread_id&lt;/code>, el &lt;code>trace_id&lt;/code> y un campo &lt;code>escalada&lt;/code> que se marca cuando la respuesta remite al encargado. Es la tabla que convierte trazas en minutos.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Las tres herramientas devuelven diccionarios y nunca prosa.&lt;/li>
&lt;li>&lt;code>registrar_rotura&lt;/code> comprueba el rol dentro y escribe el &lt;code>trace_id&lt;/code> en la fila.&lt;/li>
&lt;li>La consulta de &lt;code>procedimiento&lt;/code> lleva &lt;code>rol_minimo&lt;/code> y &lt;code>vigente&lt;/code> en el &lt;code>WHERE&lt;/code>.&lt;/li>
&lt;li>&lt;code>OpenAIEmbeddings&lt;/code> va con &lt;code>check_embedding_ctx_length=False&lt;/code>.&lt;/li>
&lt;li>&lt;code>ToolErrorMiddleware&lt;/code> tiene un manejador de dos argumentos que devuelve JSON.&lt;/li>
&lt;li>&lt;code>ToolRetryMiddleware&lt;/code> no está; los reintentos viven en el pool de Postgres.&lt;/li>
&lt;li>Los dos límites llevan &lt;code>exit_behavior=&amp;quot;end&amp;quot;&lt;/code> y se cuenta cuántas trazas acaban en ellos.&lt;/li>
&lt;li>&lt;code>SummarizationMiddleware&lt;/code> tiene &lt;code>trigger&lt;/code> y el modelo tiene &lt;code>profile&lt;/code>.&lt;/li>
&lt;li>&lt;code>setup()&lt;/code> del checkpointer corre en un Job con DDL, no en los pods.&lt;/li>
&lt;li>El &lt;code>thread_id&lt;/code> lo construye la aplicación y hay un proceso que borra hilos por antigüedad.&lt;/li>
&lt;li>La aplicación escribe &lt;code>consultas_asistente&lt;/code> con &lt;code>escalada&lt;/code> y &lt;code>trace_id&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>Una excepción en una herramienta es una excepción en &lt;code>invoke&lt;/code>.&lt;/strong> No hay manejo por defecto salvo para errores de validación de argumentos. Sin &lt;code>ToolErrorMiddleware&lt;/code>, una base de datos caída se ve como un agente caído.&lt;/p>
&lt;p>&lt;strong>El manejador de &lt;code>ToolErrorMiddleware&lt;/code> recibe dos argumentos.&lt;/strong> Excepción y petición. Con uno, &lt;code>TypeError&lt;/code> en la primera excepción real, que es cuando menos se quiere.&lt;/p>
&lt;p>&lt;strong>&lt;code>ToolRetryMiddleware&lt;/code> le pide al modelo que reintente.&lt;/strong> El mensaje al agotar los intentos termina en &amp;ldquo;Please try again&amp;rdquo;. Con una herramienta rota, eso es un bucle hasta el límite de llamadas.&lt;/p>
&lt;p>&lt;strong>Los límites y el resumen son nodos, y los nodos son checkpoints.&lt;/strong> Trece filas por turno con una herramienta y el middleware completo, contra cinco sin middleware. El middleware que envuelve (&lt;code>dynamic_prompt&lt;/code>, &lt;code>ToolErrorMiddleware&lt;/code>) no añade filas; el que se ejecuta como paso propio sí.&lt;/p>
&lt;p>&lt;strong>&lt;code>OpenAIEmbeddings&lt;/code> envía tokens, no texto, por defecto.&lt;/strong> Con un modelo de embeddings en vLLM eso es un error 400 o, peor, un embedding de un texto que no es el suyo. &lt;code>check_embedding_ctx_length=False&lt;/code>.&lt;/p>
&lt;p>&lt;strong>El filtro por rol en el prompt no es un filtro.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>&lt;code>setup()&lt;/code> desde el pod falla de dos formas.&lt;/strong> Sin DDL, error de permisos. A través de un pooler en modo transacción, el &lt;code>CREATE INDEX CONCURRENTLY&lt;/code> falla. Job aparte.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>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 &lt;code>interrupt()&lt;/code>, un checkpointer que sobrevive al pod, y un nodo que se ejecuta dos veces sin escribir dos veces.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/">Agentes con LangGraph para retail: el marco de la serie&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">deepagents en un cluster propio: el SDK es libre, el servidor no&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse como par operativo&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prioridad-herramientas-mcp-visibilidad-y-exclusion/">Prioridad de herramientas en el gateway MCP: visibilidad y exclusión&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">Dimensionar el gateway para una flota de agentes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/">Arquitectura y ajuste de Langfuse self-hosted&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Referencia de &lt;code>create_agent&lt;/code> en LangChain 1.x: &lt;a href="https://docs.langchain.com/oss/python/langchain/agents">https://docs.langchain.com/oss/python/langchain/agents&lt;/a>&lt;/li>
&lt;li>Middleware de LangChain 1.x (&lt;code>ToolErrorMiddleware&lt;/code>, &lt;code>ToolRetryMiddleware&lt;/code>, límites, resumen): &lt;a href="https://docs.langchain.com/oss/python/langchain/middleware">https://docs.langchain.com/oss/python/langchain/middleware&lt;/a>&lt;/li>
&lt;li>Persistencia y checkpointers en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence">https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langgraph.prebuilt.tool_node&lt;/code> (langgraph-prebuilt 1.1.0), función &lt;code>_default_handle_tool_errors&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langchain.agents.middleware.tool_error&lt;/code> y &lt;code>tool_retry&lt;/code> (langchain 1.4.1)&lt;/li>
&lt;li>Código de &lt;code>langgraph.checkpoint.postgres.base&lt;/code> (3.1.2), lista &lt;code>MIGRATIONS&lt;/code>&lt;/li>
&lt;li>pgvector, indexación HNSW y filtrado: &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>&lt;/li>
&lt;li>vLLM, modelos de embeddings (&lt;code>--task embed&lt;/code>): &lt;a href="https://docs.vllm.ai/en/latest/models/pooling_models.html">https://docs.vllm.ai/en/latest/models/pooling_models.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Agentes con LangGraph para retail: por qué un grafo, qué primitivas hacen falta y el stack que comparten los cuatro casos</title><link>https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/</link><pubDate>Thu, 17 Sep 2026 01:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/agentes-langgraph-retail-marco-serie/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un agente de LangChain construido con &lt;code>create_agent&lt;/code> ya es un grafo de LangGraph. Compilado sin middleware tiene cuatro nodos: entrada, modelo, herramientas y salida. Cada middleware que se añade mete nodos nuevos en ese grafo, y el checkpointer guarda una fila por paso del grafo, no por turno de conversación. Con dos middlewares de límite, un turno con una sola llamada a herramienta pasa de 5 checkpoints a 11. Esto se mide antes de dimensionar la base de datos, no después.&lt;/p>
&lt;p>El bucle de herramientas resuelve el primer caso de la serie y se queda corto en los otros tres. No sabe parar a pedir permiso y retomar desde ahí días después, no sabe abrir en abanico miles de elementos y agregar el resultado, y no sabe iterar contra un evaluador con un contador de intentos. Esas tres cosas son primitivas del grafo, no del bucle.&lt;/p>
&lt;p>La serie usa un único dominio de retail, con un catálogo ficticio y unas tablas fijas, para que los cuatro agentes compartan datos, contexto de ejecución y métricas. Cada artículo enseña una primitiva sobre ese dominio: el ciclo modelo-herramientas con middleware, la interrupción con reanudación, el map-reduce con &lt;code>Send&lt;/code> y reductores, y los subgrafos con bucle acotado.&lt;/p>
&lt;p>El stack es el mismo en los cuatro casos y corre en un cluster propio: un modelo abierto servido en vLLM, LiteLLM delante con una clave virtual por agente, Postgres con pgvector para el checkpointer, el almacén y el RAG, y Langfuse para las trazas. Ninguna pieza exige licencia comercial ni conexión con nadie. El servidor HTTP de LangGraph se queda fuera de la serie a propósito.&lt;/p>
&lt;p>Hay dos trampas de configuración que aparecen en el minuto uno y que no avisan. Un modelo servido en vLLM con nombre no reconocido por LangChain no tiene perfil, así que la ventana de contexto es desconocida para el agente. Y el middleware de resumen, construido sin disparador, no resume nunca. Ninguna de las dos lanza error.&lt;/p>
&lt;p>Casi todo lo que hay publicado sobre agentes con LangGraph sigue usando &lt;code>create_react_agent&lt;/code>, que está deprecado desde la 1.0. Esta serie se escribe contra langgraph 1.2.11 y langchain 1.4.1, y cada afirmación sobre el código se ha comprobado en el paquete instalado, no en la documentación.&lt;/p>
&lt;h2 id="estás-aquí-después-de-las-licencias-y-del-gateway-el-agente">Estás aquí: después de las licencias y del gateway, el agente&lt;/h2>
&lt;p>El blog ya ha cubierto lo que hay debajo. La &lt;a href="https://blog.lo0.es/posts/elegir-gateway-oss-inferencia-llm/">elección del gateway de inferencia&lt;/a>, &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">cómo se opera LiteLLM con Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">cómo se dimensiona ese gateway cuando lo que hay detrás son agentes y no personas&lt;/a>, y, en el artículo que abre la vertical agéntica, &lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">qué es libre y qué no en deepagents y qué hay que construir para servirlo sin depender de nadie&lt;/a>. Ese último artículo deja el motor del grafo como pieza MIT que corre en cualquier sitio y el servidor gestionado como pieza con licencia.&lt;/p>
&lt;p>Esta serie baja un nivel. Ya no pregunta qué armazón elegir. Pregunta cómo se construye un agente concreto sobre ese motor, con un problema de negocio delante y con números que un responsable de tienda entienda. Son seis artículos:&lt;/p>
&lt;ol start="0">
&lt;li>Este: el marco, las primitivas, el dominio compartido y el stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">Empleado de tienda&lt;/a>: asistente de sala sobre procedimientos, stock y roturas. Ciclo modelo-herramientas, RAG con pgvector, middleware. Es el que se despliega en una semana y el que tiene el retorno más fácil de medir.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Cliente&lt;/a>: pedidos, devoluciones y sustituciones. Aquí se pone el límite de la aprobación humana, con &lt;code>interrupt()&lt;/code> y reanudación sobre el checkpointer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Cadena de suministro&lt;/a>: análisis de incidencias con proveedores y planificación de reposición. Plan-and-execute con map-reduce sobre miles de referencias.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">Datos de producto&lt;/a>: enriquecimiento y control de calidad de fichas. Generador y evaluador en un bucle de reflexión acotado.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Cierre operativo&lt;/a>: los cuatro agentes servidos en el mismo cluster sin el servidor de LangGraph, con autorización por hilo, retención y sondas.&lt;/li>
&lt;/ol>
&lt;p>Los artículos 1 a 4 se leen sueltos. Este marco existe para no repetir en cada uno la parte de dominio, stack y método.&lt;/p>
&lt;p>Después de la serie hay una tanda de dos sobre pruebas y evaluación: &lt;a href="https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/">tests de grafo con un modelo guionizado&lt;/a> y &lt;a href="https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/">evaluación con datasets de Langfuse&lt;/a>. Y una tercera sobre memoria y enrutado, que empieza por la &lt;a href="https://blog.lo0.es/posts/memoria-agentes-langgraph-postgresstore-cliente-que-vuelve/">memoria entre hilos con PostgresStore&lt;/a>.&lt;/p>
&lt;h2 id="la-analogía-el-dependiente-con-iniciativa-y-el-manual-de-turno">La analogía: el dependiente con iniciativa y el manual de turno&lt;/h2>
&lt;p>Un bucle de herramientas es un dependiente con iniciativa y un teléfono. Le llega una pregunta, decide a quién llamar, llama, escucha, vuelve a decidir. Para consultas de sala funciona bien y es lo que se quiere: rapidez y criterio dentro de un margen estrecho.&lt;/p>
&lt;p>Un grafo es el manual de turno. Dice en qué orden se hacen las cosas, qué pasos se pueden hacer a la vez, dónde hay que parar a esperar la firma del encargado, y qué se hace si el proveedor no contesta. El dependiente sigue dentro, decidiendo en cada casilla, pero las casillas y las flechas las ha fijado alguien antes y no cambian por mucha iniciativa que tenga.&lt;/p>
&lt;p>LangGraph es el manual, y &lt;code>create_agent&lt;/code> es un manual de una sola casilla con el dependiente dentro. Los tres casos que vienen después del primero necesitan casillas nuevas.&lt;/p>
&lt;h2 id="por-qué-un-grafo-y-no-un-bucle">Por qué un grafo y no un bucle&lt;/h2>
&lt;p>Antes de decidirlo hay que ver qué es exactamente el bucle en la versión actual. En langchain 1.4.1, &lt;code>create_agent&lt;/code> devuelve un &lt;code>CompiledStateGraph&lt;/code> de LangGraph. Compilado con un modelo y tres herramientas, sin middleware, tiene esta estructura:&lt;/p>
&lt;pre tabindex="0">&lt;code>__start__ -&amp;gt; model -&amp;gt; (condicional) -&amp;gt; tools -&amp;gt; model
-&amp;gt; __end__
&lt;/code>&lt;/pre>&lt;p>Cuatro nodos. El estado por defecto es &lt;code>AgentState&lt;/code>, con un canal &lt;code>messages&lt;/code> que acumula mensajes con el reductor &lt;code>add_messages&lt;/code>, un canal privado &lt;code>jump_to&lt;/code> para que el middleware desvíe el flujo, y &lt;code>structured_response&lt;/code> para la salida tipada. Los middlewares se enganchan como nodos o como envoltorios. &lt;code>ModelCallLimitMiddleware&lt;/code> añade un nodo antes del modelo y otro después; &lt;code>ToolCallLimitMiddleware&lt;/code> añade uno después. Con esos dos, el grafo pasa a siete nodos, y el mismo turno con una llamada a herramienta deja 11 checkpoints en la base de datos en vez de 5. El checkpointer escribe una fila por superstep, y cada nodo de middleware es un superstep más. Es un coste pequeño por turno y grande por millón de turnos; se vuelve sobre él en el artículo 5.&lt;/p>
&lt;p>Lo que el bucle da, y da bien:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Un ciclo modelo-herramientas con parada por defecto.&lt;/strong> El modelo llama a herramientas hasta que devuelve una respuesta sin llamadas. Con &lt;code>ToolCallLimitMiddleware&lt;/code> y &lt;code>ModelCallLimitMiddleware&lt;/code> se acota el número de vueltas por ejecución y por hilo, y &lt;code>exit_behavior=&amp;quot;end&amp;quot;&lt;/code> corta sin lanzar excepción.&lt;/li>
&lt;li>&lt;strong>Un prompt que depende del contexto de ejecución.&lt;/strong> Con &lt;code>@dynamic_prompt&lt;/code> el sistema se construye en cada llamada al modelo a partir de &lt;code>runtime.context&lt;/code>, que es un objeto tipado que se pasa en &lt;code>invoke&lt;/code>. Tienda, empleado y rol viajan ahí, no en el prompt ni en el estado.&lt;/li>
&lt;li>&lt;strong>Herramientas con acceso al contexto.&lt;/strong> Una herramienta declarada con un parámetro &lt;code>runtime: ToolRuntime[Contexto]&lt;/code> recibe el mismo objeto. El modelo no ve ese parámetro y no puede inventárselo. Es la forma de que la consulta de stock sepa en qué tienda está sin que el modelo lo diga.&lt;/li>
&lt;li>&lt;strong>Persistencia por hilo.&lt;/strong> Cualquier checkpointer que se le pase guarda el estado completo después de cada superstep, y el hilo se identifica por &lt;code>thread_id&lt;/code> en la configuración.&lt;/li>
&lt;/ul>
&lt;p>Lo que el bucle no da:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Parar y retomar con intervención humana.&lt;/strong> Existe &lt;code>interrupt()&lt;/code>, y existe &lt;code>HumanInTheLoopMiddleware&lt;/code> que la usa por debajo para pedir aprobación antes de una herramienta. Pero el diseño del punto de aprobación, qué se muestra al humano, qué decisiones se le permiten, qué pasa si edita los argumentos y cómo se reanuda con &lt;code>Command(resume=...)&lt;/code>, es diseño de grafo. En el bucle es una casilla más entre el modelo y la herramienta. Cuando la aprobación depende del importe, del historial del cliente y de si el pedido ya salió del almacén, hace falta un nodo que decida si se pregunta o no, y eso ya no es el bucle.&lt;/li>
&lt;li>&lt;strong>Abrir en abanico y agregar.&lt;/strong> Analizar dos mil referencias con un modelo no es dos mil llamadas en secuencia dentro de un bucle. Es un nodo de planificación, un &lt;code>Send&lt;/code> por elemento hacia un nodo trabajador, un reductor en el estado que acumule resultados sin pisarse, y un nodo diferido que espere a que todos acaben. &lt;code>add_node(..., defer=True)&lt;/code> y &lt;code>cache_policy&lt;/code> son parámetros de &lt;code>StateGraph.add_node&lt;/code>, no del agente.&lt;/li>
&lt;li>&lt;strong>Iterar contra un evaluador con contador.&lt;/strong> Generar una ficha de producto, evaluarla contra unos criterios, corregir, volver a evaluar, y parar a la tercera. Es un ciclo con dos nodos y una arista condicional que lee un contador del estado. En el bucle se puede simular con una herramienta que evalúa, pero el modelo decide cuándo llamarla y cuándo dejar de llamarla, y esa decisión es justo la que no se le quiere dar.&lt;/li>
&lt;/ul>
&lt;p>La regla de la serie: se usa &lt;code>create_agent&lt;/code> mientras el problema quepa en un ciclo modelo-herramientas con middleware, y se baja a &lt;code>StateGraph&lt;/code> cuando hace falta una casilla que el modelo no debe controlar. El primer caso se queda en el agente. Los otros tres bajan al grafo, y en los tres el agente del caso 1 reaparece como subgrafo.&lt;/p>
&lt;h2 id="las-cuatro-primitivas-una-por-caso">Las cuatro primitivas, una por caso&lt;/h2>
&lt;p>Cada artículo de la serie enseña una primitiva del grafo sobre un problema de negocio. Aquí va la lista con la forma exacta que tiene en la 1.2.11, para que los artículos posteriores no tengan que presentarla desde cero.&lt;/p>
&lt;h3 id="caso-1-ciclo-modelo-herramientas-y-middleware">Caso 1: ciclo modelo-herramientas y middleware&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">create_agent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain.agents.middleware&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ToolCallLimitMiddleware&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">dynamic_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ModelRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dynamic_prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ModelRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">runtime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Eres el asistente de la tienda &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tienda&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. Hablas con &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">empleado&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">agente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_agent&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tools&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">stock_sku&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">procedimiento&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">middleware&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolCallLimitMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">run_limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exit_behavior&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;end&amp;#34;&lt;/span>&lt;span class="p">)],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_schema&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Contexto&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La firma completa de &lt;code>create_agent&lt;/code> admite además &lt;code>state_schema&lt;/code>, &lt;code>store&lt;/code>, &lt;code>response_format&lt;/code>, &lt;code>interrupt_before&lt;/code> e &lt;code>interrupt_after&lt;/code>, &lt;code>cache&lt;/code> y &lt;code>transformers&lt;/code>. Los que importan para esta serie son &lt;code>middleware&lt;/code>, &lt;code>context_schema&lt;/code>, &lt;code>checkpointer&lt;/code> y &lt;code>store&lt;/code>.&lt;/p>
&lt;h3 id="caso-2-interrupción-y-reanudación">Caso 2: interrupción y reanudación&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">interrupt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Command&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">aprobar_devolucion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">decision&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">interrupt&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;pedido&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pedido_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;importe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;importe&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;motivo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;motivo&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">decision&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;estado&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;estado&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rechazada&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;comentario&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">decision&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;comentario&amp;#34;&lt;/span>&lt;span class="p">)}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Días después, desde otro proceso:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">}),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">hilo&lt;/span>&lt;span class="p">}})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>interrupt(value)&lt;/code> es una función de un solo argumento. Cuando se ejecuta, el grafo guarda el estado en el checkpointer y devuelve el control con el valor en &lt;code>__interrupt__&lt;/code>. La reanudación entra con &lt;code>Command(resume=...)&lt;/code> y el nodo se ejecuta entero otra vez desde el principio; la llamada a &lt;code>interrupt&lt;/code> devuelve entonces el valor reanudado. Dos consecuencias que condicionan el diseño del caso 2: la interrupción va lo antes posible dentro del nodo, y nada con efecto externo se pone antes de ella en ese mismo nodo. Sin checkpointer, &lt;code>interrupt&lt;/code> lanza &lt;code>ValueError&lt;/code>.&lt;/p>
&lt;h3 id="caso-3-abanico-con-send-y-reductores">Caso 3: abanico con &lt;code>Send&lt;/code> y reductores&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">operator&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">typing&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">TypedDict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Send&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.graph&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">END&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Estado&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">skus&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">analisis&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">operator&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">planificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;sku&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">})&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">s&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;skus&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Estado&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;cargar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cargar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">analizar_sku&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">consolidar&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">defer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cargar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;cargar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">planificar&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analizar_sku&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;analizar_sku&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;consolidar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">END&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Send(nodo, estado)&lt;/code> lanza una instancia del nodo trabajador con un estado propio, y todas las instancias corren en el mismo superstep. El reductor &lt;code>operator.add&lt;/code> sobre &lt;code>analisis&lt;/code> es lo que permite que dos mil trabajadores escriban en la misma lista sin condiciones de carrera: LangGraph aplica las escrituras al final del superstep, en orden determinista. &lt;code>defer=True&lt;/code> en &lt;code>consolidar&lt;/code> lo retrasa hasta que no quede ningún camino pendiente hacia él. El caso 3 añade &lt;code>cache_policy&lt;/code> por nodo para no reanalizar la misma referencia dos veces en la misma jornada.&lt;/p>
&lt;h3 id="caso-4-subgrafos-y-bucle-acotado">Caso 4: subgrafos y bucle acotado&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">decidir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;veredicto&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;aprobada&amp;#34;&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;intentos&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">generar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;evaluar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">evaluar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">publicar&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;evaluar&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;evaluar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">decidir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;generar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;publicar&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El contador &lt;code>intentos&lt;/code> vive en el estado con un reductor que suma, y la arista condicional lo lee. El modelo no ve ese contador ni decide cuándo parar. &lt;code>generar&lt;/code> y &lt;code>evaluar&lt;/code> pueden ser cada uno un agente de &lt;code>create_agent&lt;/code> compilado y añadido como nodo; un grafo compilado es un nodo válido y comparte con el padre las claves de estado que tengan el mismo nombre.&lt;/p>
&lt;h2 id="el-dominio-compartido">El dominio compartido&lt;/h2>
&lt;p>Cuatro agentes sobre cuatro catálogos distintos no serían una serie. Se fija uno, ficticio, con el tamaño justo para que el caso 3 escale.&lt;/p>
&lt;p>Tres tiendas, &lt;code>T001&lt;/code> a &lt;code>T003&lt;/code>, un almacén central &lt;code>A001&lt;/code> y un catálogo generado de 2.000 referencias en el repositorio de ejemplo, con un generador para subir a 30.000 cuando el caso 3 lo necesite. Las tablas, en Postgres:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tabla&lt;/th>
&lt;th>Claves&lt;/th>
&lt;th>Quién la usa&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>productos&lt;/code>&lt;/td>
&lt;td>&lt;code>sku&lt;/code>, &lt;code>nombre&lt;/code>, &lt;code>familia&lt;/code>, &lt;code>proveedor_id&lt;/code>, &lt;code>pvp&lt;/code>&lt;/td>
&lt;td>Todos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fichas&lt;/code>&lt;/td>
&lt;td>&lt;code>sku&lt;/code>, &lt;code>titulo&lt;/code>, &lt;code>descripcion&lt;/code>, &lt;code>atributos&lt;/code> (jsonb), &lt;code>estado&lt;/code>&lt;/td>
&lt;td>Caso 4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>stock&lt;/code>&lt;/td>
&lt;td>&lt;code>(tienda, sku)&lt;/code>, &lt;code>unidades&lt;/code>, &lt;code>reservadas&lt;/code>, &lt;code>actualizado&lt;/code>&lt;/td>
&lt;td>Casos 1, 2, 3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>movimientos&lt;/code>&lt;/td>
&lt;td>&lt;code>id&lt;/code>, &lt;code>tienda&lt;/code>, &lt;code>sku&lt;/code>, &lt;code>tipo&lt;/code> (venta, rotura, merma, recepción), &lt;code>unidades&lt;/code>, &lt;code>empleado&lt;/code>, &lt;code>ts&lt;/code>&lt;/td>
&lt;td>Casos 1, 3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pedidos&lt;/code> / &lt;code>lineas_pedido&lt;/code>&lt;/td>
&lt;td>&lt;code>pedido_id&lt;/code>, &lt;code>cliente_id&lt;/code>, &lt;code>estado&lt;/code>, &lt;code>canal&lt;/code>; &lt;code>sku&lt;/code>, &lt;code>unidades&lt;/code>, &lt;code>precio&lt;/code>&lt;/td>
&lt;td>Caso 2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>devoluciones&lt;/code>&lt;/td>
&lt;td>&lt;code>id&lt;/code>, &lt;code>pedido_id&lt;/code>, &lt;code>motivo&lt;/code>, &lt;code>importe&lt;/code>, &lt;code>estado&lt;/code>, &lt;code>aprobada_por&lt;/code>&lt;/td>
&lt;td>Caso 2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>proveedores&lt;/code> / &lt;code>incidencias&lt;/code>&lt;/td>
&lt;td>&lt;code>proveedor_id&lt;/code>, &lt;code>plazo_dias&lt;/code>, &lt;code>sla&lt;/code>; &lt;code>id&lt;/code>, &lt;code>proveedor_id&lt;/code>, &lt;code>sku&lt;/code>, &lt;code>tipo&lt;/code>, &lt;code>abierta_ts&lt;/code>, &lt;code>cerrada_ts&lt;/code>&lt;/td>
&lt;td>Caso 3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>procedimientos&lt;/code>&lt;/td>
&lt;td>&lt;code>id&lt;/code>, &lt;code>seccion&lt;/code>, &lt;code>texto&lt;/code>, &lt;code>rol_minimo&lt;/code>, &lt;code>embedding&lt;/code> (vector)&lt;/td>
&lt;td>Caso 1&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>El contexto de ejecución también es común. Es una dataclass que se pasa en cada &lt;code>invoke&lt;/code> y que las herramientas y el prompt leen desde &lt;code>runtime.context&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dataclass&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">Contexto&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tienda&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># &amp;#34;T001&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">empleado&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># identificador, no nombre&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rol&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># &amp;#34;dependiente&amp;#34; | &amp;#34;encargado&amp;#34; | &amp;#34;compras&amp;#34; | &amp;#34;catalogo&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tres decisiones sobre este contexto que se mantienen en toda la serie. La primera: el rol se comprueba dentro de la herramienta, no en el prompt. Una herramienta que solo puede usar un encargado devuelve un error estructurado si el rol no es el suyo, y el prompt le dice al modelo que no insista. Un modelo no es una capa de autorización. La segunda: el contexto no se guarda en el estado ni en el checkpoint. Viaja en cada llamada, así que un hilo reanudado por otra persona lleva el contexto de esa persona. La tercera: el &lt;code>thread_id&lt;/code> lo construye la aplicación a partir del empleado y de un identificador de sesión, nunca lo elige el cliente. El checkpointer no comprueba propietarios; esa comprobación es de la capa HTTP que se escribe en el artículo 5.&lt;/p>
&lt;h2 id="el-stack-común">El stack común&lt;/h2>
&lt;p>Todo corre en un cluster de Kubernetes genérico. No hay ninguna pieza que exija salir a Internet ni clave de licencia.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Pieza&lt;/th>
&lt;th>Papel&lt;/th>
&lt;th>Nota&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>Sirve el modelo abierto&lt;/td>
&lt;td>Un modelo de clase 30B en FP8 con ventana de 32.768 tokens por defecto para los casos 1 y 2; 128K para el 3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>Gateway delante de vLLM&lt;/td>
&lt;td>Una clave virtual por agente, con presupuesto y límite de peticiones. Es lo que separa el coste de cada caso&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres 16 + pgvector&lt;/td>
&lt;td>Checkpointer, almacén y RAG&lt;/td>
&lt;td>Bases separadas en el mismo cluster: &lt;code>agentes&lt;/code> para checkpoints y store, &lt;code>retail&lt;/code> para las tablas de negocio y los embeddings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Trazas y coste&lt;/td>
&lt;td>Por el callback de LangChain en los casos 1 a 4; el artículo 5 discute la vía OTel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FastAPI&lt;/td>
&lt;td>Servir los grafos&lt;/td>
&lt;td>Se escribe en el artículo 5; hasta entonces los ejemplos se invocan desde Python&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dos detalles del modelo que hay que fijar antes de escribir una línea del agente.&lt;/p>
&lt;p>&lt;strong>Pasar la instancia del modelo, no la cadena.&lt;/strong> &lt;code>create_agent&lt;/code> acepta &lt;code>&amp;quot;openai:nombre&amp;quot;&lt;/code> como atajo, pero con un modelo servido en vLLM el nombre no está en la tabla de perfiles de LangChain, y la instancia construida a mano es la única forma de controlar &lt;code>base_url&lt;/code>, &lt;code>api_key&lt;/code> y el perfil:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">modelo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;qwen3-30b-a3b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://litellm.agentes.svc:4000/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LITELLM_KEY_TIENDA&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">temperature&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">profile&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;max_input_tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">32768&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sin &lt;code>profile&lt;/code>, &lt;code>modelo.profile&lt;/code> es &lt;code>None&lt;/code> para cualquier nombre que no sea un modelo de OpenAI. Verificado con &lt;code>langchain-openai&lt;/code> 1.6.2: &lt;code>ChatOpenAI(model=&amp;quot;qwen3-30b&amp;quot;, base_url=...)&lt;/code> devuelve perfil &lt;code>None&lt;/code> sin avisar. Con perfil, devuelve el diccionario que se le pasó. El valor tiene que quedar por debajo del &lt;code>--max-model-len&lt;/code> de vLLM, porque el agente cuenta los tokens de forma aproximada y el servidor los cuenta con su tokenizador.&lt;/p>
&lt;p>&lt;strong>El middleware de resumen no resume sin disparador.&lt;/strong> &lt;code>SummarizationMiddleware(modelo)&lt;/code> sin &lt;code>trigger&lt;/code> construye una lista de cláusulas vacía y &lt;code>_should_summarize&lt;/code> devuelve &lt;code>False&lt;/code> siempre. No hay error ni aviso. Con &lt;code>trigger=(&amp;quot;fraction&amp;quot;, 0.8)&lt;/code> sí hay comprobación en el constructor: sin perfil lanza una excepción con el mensaje de que hace falta &lt;code>profile={&amp;quot;max_input_tokens&amp;quot;: ...}&lt;/code>. Es el único sitio de todo el stack donde la ausencia de perfil se hace notar; en el resto, un modelo sin perfil es un modelo con ventana infinita hasta que vLLM devuelve un error de longitud de contexto.&lt;/p>
&lt;p>Con esto, el resumen se configura así en los cuatro casos:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">SummarizationMiddleware&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">modelo&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trigger&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fraction&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">0.75&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">keep&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fraction&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mf">0.25&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y el agente no llega nunca al límite del servidor. Con 32.768 de ventana, resume al pasar de 24.576 tokens aproximados y se queda con los últimos 8.192.&lt;/p>
&lt;h2 id="lo-que-se-mide-en-cada-caso">Lo que se mide en cada caso&lt;/h2>
&lt;p>Un responsable de tienda no compra un grafo. Compra minutos de dependiente, devoluciones bien resueltas o fichas publicadas. Cada artículo cierra con una tabla de métricas medidas, y las métricas salen de dos sitios: Langfuse, para lo que el agente hace, y la base de negocio, para lo que pasa después.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Caso&lt;/th>
&lt;th>Métrica de agente (Langfuse)&lt;/th>
&lt;th>Métrica de negocio (Postgres)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1 Empleado&lt;/td>
&lt;td>Latencia p50/p95 por consulta, tokens y coste por consulta, llamadas a herramienta por turno, tasa de cortes por límite&lt;/td>
&lt;td>Consultas resueltas sin escalar al encargado, tiempo hasta registro de una rotura&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2 Cliente&lt;/td>
&lt;td>Interrupciones por cada cien pedidos, tiempo entre interrupción y reanudación, decisiones editadas frente a aprobadas tal cual&lt;/td>
&lt;td>Devoluciones aprobadas fuera de política (debe ser cero), importe medio aprobado con y sin humano&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3 Suministro&lt;/td>
&lt;td>Tokens por referencia analizada, aciertos de caché de nodo, duración del abanico frente al número de referencias&lt;/td>
&lt;td>Referencias con rotura evitada, días de cobertura tras la reposición propuesta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4 Fichas&lt;/td>
&lt;td>Intentos por ficha hasta veredicto, tasa de aprobación a la primera, segunda y tercera, tokens por ficha publicada&lt;/td>
&lt;td>Fichas publicadas por hora, devoluciones por descripción incorrecta&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Las métricas de agente se sacan con dos valores en la configuración: &lt;code>metadata={&amp;quot;caso&amp;quot;: &amp;quot;tienda&amp;quot;, &amp;quot;tienda&amp;quot;: ctx.tienda}&lt;/code> y el &lt;code>thread_id&lt;/code>. Langfuse agrupa por ellos. Las métricas de negocio se sacan con SQL sobre las tablas del dominio, con el &lt;code>empleado&lt;/code> y el &lt;code>asiento&lt;/code> que las herramientas escriben en &lt;code>movimientos&lt;/code> y &lt;code>devoluciones&lt;/code>. Correlar las dos es cuestión de escribir el &lt;code>trace_id&lt;/code> de Langfuse en la fila de negocio; las herramientas de escritura de la serie lo hacen.&lt;/p>
&lt;h2 id="lo-que-la-serie-no-cubre">Lo que la serie no cubre&lt;/h2>
&lt;p>El servidor HTTP de LangGraph (&lt;code>langgraph-api&lt;/code>), su cola de ejecuciones, el cron, Studio y la API de asistentes, hilos y ejecuciones. Todo eso tiene licencia Elastic 2.0, pide clave de licencia comercial para producción y llama a casa; el artículo de deepagents lo documenta con fichero y línea. La serie sirve los grafos con FastAPI en el artículo 5 y asume que se pierde esa API. &lt;code>langgraph dev&lt;/code> aparece en los ejemplos como lo que es, un servidor en memoria para desarrollo.&lt;/p>
&lt;p>Tampoco cubre entrenamiento ni ajuste fino del modelo. Los cuatro casos son de inferencia sobre un modelo abierto tal cual sale del repositorio, y las decisiones de dimensionado son de inferencia: ventana, batching en vLLM, aciertos de caché de prefijo.&lt;/p>
&lt;p>Y no cubre la parte de interfaz. Cómo se muestra una interrupción a un encargado en una tablet es un problema de aplicación; el caso 2 entrega el contrato de datos de la interrupción y de la reanudación, no la pantalla.&lt;/p>
&lt;h2 id="método">Método&lt;/h2>
&lt;p>Cada afirmación sobre el comportamiento del código se ha verificado ejecutando el paquete instalado, con estas versiones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Paquete&lt;/th>
&lt;th>Versión&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>langgraph&lt;/td>
&lt;td>1.2.11&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>langgraph-checkpoint&lt;/td>
&lt;td>4.2.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>langgraph-checkpoint-postgres&lt;/td>
&lt;td>3.1.2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>langgraph-prebuilt&lt;/td>
&lt;td>1.1.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>langchain&lt;/td>
&lt;td>1.4.1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>langchain-core&lt;/td>
&lt;td>1.6.3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>langchain-openai&lt;/td>
&lt;td>1.6.2&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Cuando un artículo dice &amp;ldquo;hace X&amp;rdquo;, se ha ejecutado X. Cuando dice &amp;ldquo;el código sugiere X&amp;rdquo;, no se ha ejecutado y va marcado así. Los ejemplos de código de la serie se ejecutan contra un modelo falso de LangChain que devuelve mensajes con llamadas a herramientas fijas, para verificar la estructura del grafo, el orden de los nodos y el número de checkpoints; y contra vLLM para las cifras de latencia y tokens. Las dos cosas se distinguen en el texto.&lt;/p>
&lt;p>Un aviso de fechas. La 1.0 de LangGraph y de LangChain salió en octubre de 2025 y deprecó &lt;code>create_react_agent&lt;/code> a favor de &lt;code>create_agent&lt;/code>. La mayoría de guías, cursos y respuestas en foros siguen mostrando el prebuilt antiguo, con &lt;code>langgraph.prebuilt.create_react_agent&lt;/code> y un &lt;code>state_modifier&lt;/code> en vez de middleware. Funcionan todavía, con aviso de deprecación, y no tienen el sistema de middleware. Nada de esta serie se apoya en ellos.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;p>Antes de escribir el primer agente sobre este stack:&lt;/p>
&lt;ul>
&lt;li>El modelo se pasa como instancia de &lt;code>ChatOpenAI&lt;/code> con &lt;code>base_url&lt;/code> al gateway y &lt;code>profile={&amp;quot;max_input_tokens&amp;quot;: N}&lt;/code>, con N por debajo del &lt;code>--max-model-len&lt;/code> de vLLM.&lt;/li>
&lt;li>&lt;code>SummarizationMiddleware&lt;/code> lleva &lt;code>trigger&lt;/code> explícito; sin él no resume.&lt;/li>
&lt;li>El contexto de ejecución es una dataclass que se pasa en cada &lt;code>invoke&lt;/code> y no se guarda en el estado.&lt;/li>
&lt;li>El rol se comprueba dentro de las herramientas con efecto, no en el prompt.&lt;/li>
&lt;li>El &lt;code>thread_id&lt;/code> lo construye la aplicación; el cliente no lo elige.&lt;/li>
&lt;li>El checkpointer de Postgres se inicializa con &lt;code>setup()&lt;/code> una vez, desde un proceso con permiso de DDL, no desde los pods del agente.&lt;/li>
&lt;li>Cada agente tiene su clave virtual en LiteLLM con presupuesto, para que el coste salga separado por caso.&lt;/li>
&lt;li>Se ha contado cuántos checkpoints deja un turno típico con el middleware elegido, y se ha multiplicado por los turnos por día antes de dimensionar la base de datos.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El middleware no es gratis en la base de datos.&lt;/strong> Cada nodo de middleware es un superstep y cada superstep es una fila de checkpoint. Con los dos límites del caso 1, 11 filas por turno con una herramienta, contra 5 sin ellos. Con &lt;code>SummarizationMiddleware&lt;/code>, que también es un nodo, trece. El coste de cómputo es despreciable; el de almacenamiento y el de la limpieza que no existe, no.&lt;/p>
&lt;p>&lt;strong>&lt;code>ChatOpenAI&lt;/code> sin perfil no avisa.&lt;/strong> El agente asume ventana desconocida, &lt;code>SummarizationMiddleware&lt;/code> con disparador por fracción se niega a construirse, y con disparador por tokens funciona a ciegas respecto al servidor. La única defensa es el &lt;code>profile&lt;/code> explícito y una aserción al arrancar.&lt;/p>
&lt;p>&lt;strong>&lt;code>SummarizationMiddleware(modelo)&lt;/code> es un middleware que no hace nada.&lt;/strong> Sin &lt;code>trigger&lt;/code>, la lista de cláusulas está vacía y no resume nunca. Se ve el nodo en el grafo y en las trazas, y eso da una falsa sensación de que el contexto está controlado.&lt;/p>
&lt;p>&lt;strong>&lt;code>interrupt()&lt;/code> re-ejecuta el nodo entero.&lt;/strong> Lo que haya antes de la interrupción en ese nodo se ejecuta dos veces: al interrumpir y al reanudar. Si es una escritura en el TPV, se escribe dos veces. Esto condiciona todo el caso 2.&lt;/p>
&lt;p>&lt;strong>El checkpointer de Postgres solo sabe borrar hilos enteros.&lt;/strong> En &lt;code>langgraph-checkpoint-postgres&lt;/code> 3.1.2, &lt;code>delete_thread&lt;/code> está implementado; &lt;code>prune&lt;/code>, &lt;code>copy_thread&lt;/code> y &lt;code>delete_for_runs&lt;/code> heredan de la clase base y lanzan &lt;code>NotImplementedError&lt;/code>. Y la propia clase base avisa de que una poda parcial hecha a mano puede dejar los canales delta reconstruyéndose vacíos sin error. La retención es un trabajo de borrado por hilo que se escribe en el artículo 5.&lt;/p>
&lt;p>&lt;strong>&lt;code>langgraph dev&lt;/code> no es un servidor.&lt;/strong> Es hot reload y estado en memoria. En el artículo 5 los grafos se sirven con FastAPI y un checkpointer de Postgres, y esa es la única configuración que la serie llama producción.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>Lo que se lleva de este marco es una regla y un stack. La regla: &lt;code>create_agent&lt;/code> mientras el problema quepa en un ciclo modelo-herramientas con middleware, y &lt;code>StateGraph&lt;/code> cuando haga falta una casilla que el modelo no debe controlar, sea una firma, un abanico o un contador. El stack: vLLM, LiteLLM, Postgres con pgvector y Langfuse en un cluster propio, con el modelo pasado como instancia y con perfil, y con el contexto de ejecución fuera del estado.&lt;/p>
&lt;p>El siguiente artículo construye el primer agente sobre eso. Es el más sencillo de los cuatro y el que más se parece a lo que la mayoría de equipos ya tiene a medias: un asistente que responde a un dependiente sobre stock y procedimientos, con las herramientas comprobando el rol y con la tabla de métricas que un responsable de tienda puede leer al final de la primera semana.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/">deepagents en un cluster propio: el SDK es libre, el servidor no&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-empleado-tienda-langgraph-rag-tools/">Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cliente-devoluciones-langgraph-interrupt-hitl/">Agente de atención al cliente con LangGraph: el límite de aprobación humana&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-cadena-suministro-langgraph-send-map-reduce/">Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/agente-fichas-producto-langgraph-generador-evaluador/">Agente de datos de producto con LangGraph: generador, evaluador y bucle acotado&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/servir-agentes-langgraph-sin-langgraph-api/">Servir agentes de LangGraph sin langgraph-api&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/probar-agentes-langgraph-modelo-falso-pytest/">Probar agentes de LangGraph sin modelo: tests de grafo con pytest&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evaluar-agentes-langgraph-datasets-langfuse/">Evaluar agentes de LangGraph con datasets de Langfuse&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/memoria-agentes-langgraph-postgresstore-cliente-que-vuelve/">Memoria entre hilos para los agentes de retail: PostgresStore&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">Dimensionar el gateway para una flota de agentes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse como par operativo&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/elegir-gateway-oss-inferencia-llm/">Elegir gateway de inferencia de código abierto&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">Aislar agentes de IA por cliente en el cluster&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">Ejecución durable con Temporal y el coste de los agentes&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Releases de LangGraph, 1.2.11: &lt;a href="https://github.com/langchain-ai/langgraph/releases">https://github.com/langchain-ai/langgraph/releases&lt;/a>&lt;/li>
&lt;li>Guía de migración a LangGraph v1 (deprecación de &lt;code>create_react_agent&lt;/code>): &lt;a href="https://docs.langchain.com/oss/python/migrate/langgraph-v1">https://docs.langchain.com/oss/python/migrate/langgraph-v1&lt;/a>&lt;/li>
&lt;li>Referencia de &lt;code>create_agent&lt;/code> y middleware en LangChain 1.x: &lt;a href="https://docs.langchain.com/oss/python/langchain/agents">https://docs.langchain.com/oss/python/langchain/agents&lt;/a>&lt;/li>
&lt;li>Documentación de &lt;code>interrupt&lt;/code> y human-in-the-loop en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/interrupts">https://docs.langchain.com/oss/python/langgraph/interrupts&lt;/a>&lt;/li>
&lt;li>Documentación de &lt;code>Send&lt;/code> y map-reduce en LangGraph: &lt;a href="https://docs.langchain.com/oss/python/langgraph/graph-api">https://docs.langchain.com/oss/python/langgraph/graph-api&lt;/a>&lt;/li>
&lt;li>Código de &lt;code>langchain.agents.middleware.summarization&lt;/code> (langchain 1.4.1), &lt;code>_normalize_trigger&lt;/code> y &lt;code>_should_summarize&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langgraph.checkpoint.postgres&lt;/code> (3.1.2) y &lt;code>langgraph.checkpoint.base&lt;/code> (4.2.0), métodos &lt;code>delete_thread&lt;/code>, &lt;code>prune&lt;/code>, &lt;code>copy_thread&lt;/code>, &lt;code>delete_for_runs&lt;/code>&lt;/li>
&lt;li>Código de &lt;code>langchain_openai.chat_models.base&lt;/code> (1.6.2), resolución del perfil de modelo&lt;/li>
&lt;/ul></description></item></channel></rss>