<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Langgraph on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/langgraph/</link><description>Recent content in Langgraph 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/langgraph/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><item><title>deepagents en un cluster propio: el SDK es libre, el servidor no, y lo que hay que construir en medio</title><link>https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/</link><pubDate>Tue, 15 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/deepagents-sdk-libre-servidor-con-licencia/</guid><description>&lt;blockquote>
&lt;p>Apertura de la vertical de plataforma agéntica. Los artículos anteriores trataron las piezas por separado: &lt;a href="https://blog.lo0.es/posts/elegir-gateway-oss-inferencia-llm/">el gateway de inferencia&lt;/a>, &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">su segunda puerta&lt;/a>, &lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">qué cuesta una flota de agentes&lt;/a> y &lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">el aislamiento&lt;/a>. Este trata lo que va encima. Verificado contra &lt;code>deepagents&lt;/code> 0.7.14 y el repositorio de LangGraph, commits del 14 de septiembre de 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>deepagents no es un runtime.&lt;/strong> Es una factoría que ensambla middleware y delega en la factoría de agentes de LangChain. Devuelve un grafo compilado de LangGraph y nada más. La documentación del propio repositorio lo dice sin adornos: no introduce un runtime nuevo. Eso es una buena noticia para quien despliega, porque corre donde corra Python.&lt;/p>
&lt;p>&lt;strong>La herramienta de planificación ya no existe.&lt;/strong> Desapareció en la 0.7.0 como cambio incompatible, junto con el canal de estado que la sostenía. Casi todo lo escrito sobre este armazón sigue describiéndola como una de sus tres patas. Hoy hay que pedirla explícitamente, y hay que pedirla también en cada subagente.&lt;/p>
&lt;p>&lt;strong>Con la configuración por defecto, los ficheros del agente viven dentro del checkpoint.&lt;/strong> El backend de estado guarda el contenido completo, los binarios en base64, en el mismo sitio donde se persiste la conversación. Cambiar eso es una línea y es la primera decisión de arquitectura del despliegue.&lt;/p>
&lt;p>&lt;strong>La base de datos crece por paso del bucle, no por turno.&lt;/strong> Cuatro tablas, una fila por superstep, una por canal y versión, otra por escritura de tarea. Y de las cuatro operaciones de limpieza que la clase base declara, en la implementación de Postgres solo hay una: borrar un hilo entero. Las otras tres lanzan excepción de no implementado.&lt;/p>
&lt;p>&lt;strong>Podar a mano puede vaciar el historial en silencio.&lt;/strong> El propio código avisa de que un borrado parcial rompe la cadena de deltas y deja los canales reconstruyéndose vacíos sin lanzar ningún error. La única poda segura es por hilo completo.&lt;/p>
&lt;p>&lt;strong>El aislamiento entre inquilinos es el identificador de hilo, y no hay ninguna comprobación de propietario.&lt;/strong> Quien pueda enviar un identificador de hilo lee ese hilo. El backend de ficheros en disco fija su raíz al construir el agente, así que no separa a nadie. Y el ejemplo de la documentación para calcular el espacio de nombres por usuario no funciona fuera de la plataforma gestionada.&lt;/p>
&lt;p>&lt;strong>Hay una trampa de configuración que deja un modelo de ventana corta sin compactar.&lt;/strong> Si el modelo no expone su ventana, el umbral de compactación cae a 170.000 tokens. Servido en un vLLM con ventana de 32.768, el agente revienta por longitud de contexto mucho antes de compactar. El fallo es silencioso y se arregla pasando el perfil a mano.&lt;/p>
&lt;p>&lt;strong>La observabilidad sin plataforma existe, y pasa por un paquete que no es evidente.&lt;/strong> El núcleo de LangChain no emite OpenTelemetry: cero referencias. El exportador vive dentro del SDK de LangSmith, y hay un modo que emite solo por OTLP sin hablar con el servicio. Es el camino a un Langfuse propio, y conviene saber por dónde pasa.&lt;/p>
&lt;p>&lt;strong>Y aquí se acaba lo libre: el servidor.&lt;/strong> El SDK, el motor del grafo y los checkpointers son MIT. El servidor que los publica por HTTP tiene licencia Elastic 2.0, pide una clave de licencia comercial para producción, añade Redis al stack y reporta metadatos a un endpoint externo salvo acuerdo de aislamiento. La herramienta de desarrollo es explícitamente de desarrollo.&lt;/p>
&lt;p>&lt;strong>Lo que queda es una lista de cosas que hay que escribir.&lt;/strong> Autorización por hilo, retención, cola de ejecuciones, cancelación, drenado limpio y migraciones. Ninguna es difícil. Todas son trabajo, y ninguna aparece en el ejemplo de veinte líneas de la portada.&lt;/p>
&lt;h2 id="estás-aquí-el-armazón-llega-después-del-modelo">Estás aquí: el armazón llega después del modelo&lt;/h2>
&lt;p>La secuencia se repite en todos los proyectos. Primero se monta la inferencia, que es lo visible. Luego el gateway, porque hacen falta claves y presupuestos. Luego la observabilidad, porque nadie sabe qué está pasando. Y cuando todo eso funciona, alguien pregunta por qué el asistente no puede hacer tareas de varios pasos, y entra en escena la palabra agente.&lt;/p>
&lt;p>En ese punto la tentación es escribir el bucle a mano, y durante dos semanas parece buena idea. El bucle es fácil. Lo que no es fácil es todo lo que se descubre después: qué pasa cuando el contexto se llena, dónde se guardan los ficheros intermedios, cómo se reanuda una tarea que se quedó a medias, cómo se pide confirmación a un humano sin perder el estado.&lt;/p>
&lt;p>Un armazón de agentes es exactamente eso: la respuesta empaquetada a esas preguntas. Y elegirlo parece una decisión de librería, del tamaño de elegir un cliente HTTP. No lo es. Decide dónde vive el estado de tus usuarios, qué base de datos crece, qué se puede aislar y qué no, y en el caso que nos ocupa, si hay una licencia comercial esperando al final del camino.&lt;/p>
&lt;p>Este artículo mira &lt;code>deepagents&lt;/code> con esos ojos. No es una guía de uso, y no evalúo si sus agentes son buenos. Miro qué obliga a construir.&lt;/p>
&lt;h2 id="la-analogía-el-andamio-y-la-grúa">La analogía: el andamio y la grúa&lt;/h2>
&lt;p>Una obra necesita andamio y necesita grúa, y no son lo mismo aunque las dos sean estructuras metálicas que rodean al edificio.&lt;/p>
&lt;p>El andamio lo monta uno con piezas compradas. Se adapta a la fachada, se puede ampliar, y el día que la obra termina se desmonta y se guarda. Nadie cobra por usarlo una vez comprado.&lt;/p>
&lt;p>La grúa es otra cosa. Se alquila, viene con operador, tiene número de serie y hay que avisar a alguien cuando se monta. Sin grúa la obra avanza, más despacio y con más gente, pero avanza.&lt;/p>
&lt;p>Aquí el andamio es el SDK y el motor del grafo, y es de quien lo compra. La grúa es el servidor que expone el agente por HTTP, y esa viene con contrato. El artículo trata de dónde está exactamente la línea entre las dos, porque en el folleto no se ve, y de cuánta obra se puede hacer sin grúa.&lt;/p>
&lt;h2 id="qué-es-deepagents-de-verdad">Qué es deepagents de verdad&lt;/h2>
&lt;p>Lo primero que hay que quitarse de la cabeza es que sea un sistema. Es una función.&lt;/p>
&lt;p>La factoría principal ensambla una lista de middleware y llama a la factoría de agentes de LangChain, que es quien construye el grafo. Lo que devuelve es un grafo compilado, con una configuración añadida encima que sube el límite de recursión a un número muy alto y añade una etiqueta de integración en los metadatos. El documento de arquitectura del propio repositorio lo declara: no se introduce un runtime nuevo.&lt;/p>
&lt;p>Compilado con las opciones por defecto, el grafo tiene los nodos que cabría esperar de un bucle de razonamiento y acción: inicio, un nodo previo que repara llamadas a herramienta incompletas, el nodo del modelo, el nodo de herramientas y el final. Las aristas condicionales van del modelo a herramientas, al propio modelo o al final. No hay más. Todo lo que parece un sistema es middleware que añade nodos solo cuando declara sus propios enganches.&lt;/p>
&lt;p>Las herramientas que aparecen de serie son las de sistema de ficheros, más la de delegación en subagentes: listar, leer, escribir, editar, borrar, buscar por patrón, buscar por contenido, ejecutar, y la de tarea.&lt;/p>
&lt;p>La consecuencia buena de esto es la portabilidad. Si algo es un grafo de LangGraph y nada más, corre en un contenedor de Python cualquiera, con el checkpointer que uno elija, contra el modelo que uno elija. No hay servicio escondido detrás.&lt;/p>
&lt;h3 id="lo-que-ya-no-está">Lo que ya no está&lt;/h3>
&lt;p>Merece un apartado porque contradice a casi todo lo publicado. La herramienta de planificación, la que escribía una lista de tareas y la mantenía en un canal del estado, &lt;strong>se eliminó en la 0.7.0&lt;/strong> como cambio incompatible declarado en el registro de cambios. No está la herramienta, no está el canal, y no está el fragmento de prompt que la acompañaba.&lt;/p>
&lt;p>Sobrevive como middleware de LangChain, y hay que pedirla a mano. Con un detalle que muerde: hay que pedirla también en cada subagente, porque cada subagente monta su propia pila. Lo único que queda de la etapa anterior son restos arqueológicos, como la clave de estado que sigue en la lista de claves excluidas al pasar contexto a un subagente.&lt;/p>
&lt;p>El plan, hoy, se apoya en ficheros. Que es coherente con el resto del diseño, y es otra razón para mirar con atención dónde viven esos ficheros.&lt;/p>
&lt;h2 id="dónde-vive-el-estado">Dónde vive el estado&lt;/h2>
&lt;p>Esta es la primera decisión de arquitectura y la que más caro sale equivocar.&lt;/p>
&lt;p>El estado del agente tiene dos canales grandes. Los mensajes, con un reductor propio que deduplica por identificador y trata los borrados como lápidas. Y los ficheros, con su propio reductor donde un valor nulo significa borrado. Los dos usan un tipo de canal que escribe deltas y guarda una instantánea completa cada cincuenta actualizaciones, y el comentario del código explica el motivo: pasar el crecimiento del checkpoint de cuadrático a lineal respecto al número de mensajes.&lt;/p>
&lt;p>Ese tipo de canal está marcado como beta en su propio código, con el aviso de que la representación en disco puede cambiar. Conviene saberlo antes de apoyar una plataforma encima.&lt;/p>
&lt;h3 id="los-backends">Los backends&lt;/h3>
&lt;p>Hay una interfaz común y varias implementaciones. Las que importan para decidir:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Dónde viven los ficheros&lt;/th>
&lt;th>Persistencia&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Estado&lt;/td>
&lt;td>Dentro del estado del grafo, o sea del checkpoint&lt;/td>
&lt;td>Por hilo, en la base de datos del checkpointer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Disco&lt;/td>
&lt;td>En un directorio real anclado a una raíz&lt;/td>
&lt;td>Donde monte el volumen&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Almacén&lt;/td>
&lt;td>En el almacén de LangGraph, con espacio de nombres&lt;/td>
&lt;td>Cruza hilos, con tiempo de vida&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compuesto&lt;/td>
&lt;td>Enrutado por prefijo de ruta entre los anteriores&lt;/td>
&lt;td>Mixta&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>El backend por defecto es el de estado. Es decir: &lt;strong>por defecto, todo lo que el agente escriba acaba dentro del checkpoint&lt;/strong>, y los binarios van codificados en base64 dentro del mismo JSON. Para una demostración es cómodo, porque no hay que montar nada. Para una plataforma con usuarios reales es una decisión de dimensionado tomada por omisión.&lt;/p>
&lt;p>Hay un detalle que redondea el asunto. El middleware de ficheros desaloja automáticamente los resultados de herramienta grandes a un fichero cuando superan un umbral de tokens, para que no ocupen la ventana de contexto. Es un buen mecanismo. Pero con el backend de estado, desalojar a fichero significa mover el contenido de un sitio del checkpoint a otro sitio del mismo checkpoint. Sale de la ventana del modelo y no sale de la base de datos.&lt;/p>
&lt;h3 id="lo-que-crece-en-la-base-de-datos">Lo que crece en la base de datos&lt;/h3>
&lt;p>El checkpointer de Postgres crea cuatro tablas: una de migraciones, una de checkpoints, una de blobs por canal y versión, y una de escrituras por tarea. Las claves primarias empiezan todas por el identificador de hilo, y hay un índice por tabla sobre esa columna.&lt;/p>
&lt;p>Lo que hay que interiorizar es el ritmo. Se escribe una fila de checkpoint &lt;strong>por superstep del motor&lt;/strong>, no por turno de conversación. Un turno con cuatro llamadas a herramienta son varios supersteps. Nada de eso se recicla solo.&lt;/p>
&lt;p>Y aquí llega la parte incómoda. La clase base del checkpointer declara cuatro operaciones de mantenimiento: borrar un hilo, borrar por ejecución, copiar un hilo y podar con estrategia. En la implementación de Postgres &lt;strong>solo está implementada la primera&lt;/strong>. Las otras tres heredan la excepción de no implementado de la clase base. Borrar un hilo son tres sentencias de borrado por identificador, y ya está.&lt;/p>
&lt;p>O sea: la retención te la montas tú. Y con un cuidado que el propio código documenta y que conviene citar entero, porque es el tipo de aviso que se lee después del incidente. Una poda ingenua que elimine checkpoints intermedios y sus escrituras puede cortar la cadena de deltas; el checkpoint superviviente rara vez es un punto de instantánea, así que sus canales se reconstruirían vacíos &lt;strong>sin que se lance ningún error&lt;/strong>.&lt;/p>
&lt;p>Traducido a operación: el barrido periódico borra hilos enteros y solo hilos enteros. Nunca filas sueltas. Y como la tabla de checkpoints no tiene columna de fecha, para saber qué hilo es viejo hay que llevar la cuenta por fuera o apoyarse en que el identificador de checkpoint es un UUID ordenable por tiempo.&lt;/p>
&lt;p>Existe una variante del checkpointer que guarda solo el último estado y no retiene historia, que acota el crecimiento de raíz. Con una reserva importante que hay que verificar antes de usarla: no está claro que sea compatible con los canales de deltas que este armazón usa por defecto en mensajes y ficheros. Combinar las dos cosas sin comprobarlo es pedir un problema silencioso.&lt;/p>
&lt;h3 id="el-almacén-que-sí-tiene-limpieza">El almacén, que sí tiene limpieza&lt;/h3>
&lt;p>Curiosamente, la pieza hermana está mejor resuelta. El almacén tiene tiempo de vida configurable, con refresco en lectura, omisión de expirados y un intervalo de barrido, y el barrido es un borrado por fecha de expiración.&lt;/p>
&lt;p>Dos avisos. El barredor es un hilo dentro del proceso, así que con varias réplicas hay varios barredores compitiendo por el mismo borrado; es idempotente, pero es contención gratuita y se evita apagándolo y poniendo el borrado en una tarea programada del cluster. Y la búsqueda semántica del almacén exige la extensión de vectores en la base de datos, lo que condiciona la imagen del Postgres gestionado, que normalmente no la trae.&lt;/p>
&lt;h2 id="el-aislamiento-que-no-existe">El aislamiento que no existe&lt;/h2>
&lt;p>Si la plataforma va a tener más de un inquilino, este es el apartado que decide la topología del despliegue.&lt;/p>
&lt;p>Lo único que separa un hilo de otro es el identificador de hilo. Es la primera columna de la clave primaria en las tres tablas. &lt;strong>No hay ninguna comprobación de propietario en ninguna parte del checkpointer.&lt;/strong> Quien consiga enviar un identificador de hilo en la configuración de la llamada, lee ese hilo. La autorización es responsabilidad de la capa que uno escriba por encima, y como esa capa también hay que escribirla, conviene anotarlo ya.&lt;/p>
&lt;p>Por backend, la situación es distinta:&lt;/p>
&lt;ul>
&lt;li>El de estado hereda el aislamiento del hilo, que es suficiente si la capa HTTP valida que el usuario es dueño del hilo.&lt;/li>
&lt;li>El de almacén calcula el espacio de nombres por llamada, mediante una función que recibe el contexto de ejecución. Es la opción correcta para multiinquilino.&lt;/li>
&lt;li>El de disco fija su raíz &lt;strong>en el constructor&lt;/strong>, cuando se construye el agente. No hay ningún camino en el código que la recalcule por petición, por hilo o por inquilino. Compartir un despliegue entre inquilinos con este backend significa que todos ven el mismo directorio.&lt;/li>
&lt;/ul>
&lt;p>Sobre ese último punto conviene no confundirse con la protección que sí existe. El modo virtual, que ahora viene activado por defecto, bloquea las rutas que se salen de la raíz. Eso protege del recorrido de directorios. No protege del vecino. El propio documento de amenazas del proyecto lo admite: ese modo existe para dar soporte al enrutado por prefijo del backend compuesto, no como frontera de seguridad.&lt;/p>
&lt;p>Hay además una trampa documental que vale la pena señalar porque cuesta una tarde. El ejemplo que la documentación propone para calcular el espacio de nombres por usuario lee la identidad de un campo de información de servidor del contexto de ejecución. Ese campo está anotado en el código de LangGraph como metadatos inyectados por el servidor gestionado, y &lt;strong>nulo cuando se ejecuta LangGraph de código abierto sin despliegues gestionados&lt;/strong>. En un cluster propio con una capa HTTP propia, ese ejemplo falla. Lo que hay que usar es el contexto que uno mismo rellena, con el identificador que venga validado del proveedor de identidad.&lt;/p>
&lt;p>Y una frontera más que cruza sin avisar: los middleware de memoria y de habilidades interpolan el contenido de ficheros en el prompt de sistema tal cual, sin sanear. Si dos inquilinos comparten un directorio que sirve de origen para eso, el primero escribe instrucciones que ejecuta el agente del segundo. Es inyección de prompt por almacenamiento compartido, y se evita con la misma medida que todo lo anterior: no compartir el backend de disco entre inquilinos.&lt;/p>
&lt;p>La conclusión de topología es corta. Multiinquilino de verdad con ficheros significa &lt;strong>un despliegue por inquilino&lt;/strong>, con su espacio de nombres, su volumen y su cuota, o bien el backend de almacén con espacio de nombres calculado por petición. No hay una tercera vía.&lt;/p>
&lt;h2 id="el-modelo-y-la-trampa-que-hay-que-conocer">El modelo, y la trampa que hay que conocer&lt;/h2>
&lt;p>El modelo por defecto es un proveedor comercial concreto, y está marcado como obsoleto: pasar el modelo vacío avisa y dejará de funcionar. Se puede inyectar cualquier modelo de chat de LangChain, que es lo que hará quien sirva sus propios pesos.&lt;/p>
&lt;p>Con dos detalles de integración que no son evidentes.&lt;/p>
&lt;p>El primero. Para apuntar a un endpoint compatible con OpenAI, que es como se ve un vLLM detrás de un gateway, hay que pasar &lt;strong>la instancia ya construida&lt;/strong>, no la cadena con prefijo de proveedor. Con la cadena se aplica un perfil de proveedor que fuerza el uso de la API de respuestas, que un vLLM normalmente no implementa. Pasando el objeto construido no se aplica ningún perfil y el agente queda limpio.&lt;/p>
&lt;p>El segundo es la trampa, y en mi opinión es el fallo de configuración más probable de todo el despliegue.&lt;/p>
&lt;p>El middleware de compactación calcula sus umbrales a partir del perfil del modelo. Si el modelo declara su ventana de entrada, compacta al llegar al 85 % de la ventana y conserva el 10 %. Si no la declara, cae a un valor fijo: &lt;strong>170.000 tokens&lt;/strong>.&lt;/p>
&lt;p>La cadena que lleva a no declararla es la habitual en un despliegue propio. El cliente de OpenAI resuelve el perfil buscando el nombre del modelo en una tabla estática de modelos del proveedor. Un modelo servido como &lt;code>qwen3-30b-a3b&lt;/code> no está en esa tabla, así que el perfil queda vacío, y el vacío se convierte en nulo. El método que lo asigna traga cualquier excepción, así que no hay aviso.&lt;/p>
&lt;p>Resultado: un vLLM con ventana de 32.768 tokens no compactará hasta los 170.000. El servidor devolverá error de longitud de contexto mucho antes, y lo hará de forma intermitente, solo cuando las conversaciones se alarguen. Hay una red de seguridad que captura el error y recorta, pero eso es recuperación reactiva después de una llamada fallida, no planificación.&lt;/p>
&lt;p>El arreglo es una línea, porque el perfil es un campo público y solo se rellena solo cuando está vacío: se pasa al construir el modelo, con un valor por debajo de la ventana real para dejar sitio a la generación. Y merece un &lt;code>assert&lt;/code> en el arranque, porque es el tipo de cosa que nadie mira hasta que falla.&lt;/p>
&lt;p>Un apunte de dimensionado relacionado. Con los umbrales por defecto y una ventana de 32.768, la compactación dispararía a 27.852 tokens, mientras que el desalojo de resultados de herramienta grandes está fijado en 20.000. Es decir, un solo resultado de herramienta puede ocupar casi todo el presupuesto antes de que nada lo desaloje. Con ventanas de 128.000 los defaults están proporcionados; por debajo de 64.000 hay que bajar esos dos umbrales a mano.&lt;/p>
&lt;h2 id="las-herramientas-y-el-mcp-que-no-está-donde-parece">Las herramientas, y el MCP que no está donde parece&lt;/h2>
&lt;p>Este armazón tiene sus herramientas de sistema de ficheros y su herramienta de delegación, y las demás se pasan como una lista normal de herramientas de LangChain.&lt;/p>
&lt;p>El dato que importa a quien tenga un gateway MCP montado: &lt;strong>el SDK no integra MCP&lt;/strong>. No hay ninguna referencia al protocolo en el paquete, ni dependencia de adaptadores. La integración vive en la herramienta de línea de comandos que acompaña al proyecto, que sí depende de los adaptadores de MCP para LangChain, resuelve configuraciones al estilo de los clientes de escritorio, y convierte cada herramienta remota en una herramienta de LangChain que luego pasa por la lista normal.&lt;/p>
&lt;p>O sea que el camino existe y está probado, pero hay que recorrerlo uno: cargar las herramientas del gateway con los adaptadores y pasarlas en la lista. Lo cual, dicho sea de paso, encaja bien con &lt;a href="https://blog.lo0.es/posts/prioridad-herramientas-mcp-visibilidad-y-exclusion/">lo que ya sabemos del catálogo&lt;/a>: el filtrado por credencial que hace el gateway se aplica en el listado, así que cada agente recibe el subconjunto que le toca sin que el armazón tenga que saber nada.&lt;/p>
&lt;h3 id="los-subagentes">Los subagentes&lt;/h3>
&lt;p>Se declaran de tres formas y se invocan con una herramienta de tarea que recibe una descripción y un tipo. Lo que hay que saber para diseñar con ellos:&lt;/p>
&lt;p>El subagente recibe el estado del padre &lt;strong>excepto&lt;/strong> los mensajes y unas cuantas claves privadas, y arranca con un único mensaje humano que es la descripción de la tarea. Los ficheros &lt;strong>sí&lt;/strong> se heredan, y comparte el mismo objeto de backend, así que padre e hijo trabajan sobre el mismo sistema de ficheros lógico. El resultado vuelve como un mensaje de herramienta con la respuesta estructurada o el último texto del hijo.&lt;/p>
&lt;p>La profundidad es efectivamente de un nivel. Verificado compilando el grafo: la pila que se monta para un subagente declarativo no incluye el middleware de subagentes, así que el hijo no tiene la herramienta de tarea y no puede delegar a su vez.&lt;/p>
&lt;p>Y no hay límite de concurrencia propio. El paralelismo es el de las llamadas paralelas a herramienta del motor, y el prompt de la herramienta de tarea invita explícitamente a lanzarlas en paralelo. Quien tenga cuota de GPU por inquilino querrá poner el freno en su sitio, porque aquí no está.&lt;/p>
&lt;h2 id="la-ejecución-de-código">La ejecución de código&lt;/h2>
&lt;p>El protocolo de sandbox está bien definido y las implementaciones empaquetadas son casi todas de servicios de pago: cuatro proveedores comerciales, más un intérprete de JavaScript embebido y un sandbox del propio proveedor de la plataforma.&lt;/p>
&lt;p>Queda un backend de shell local que conviene mirar de cerca antes de considerarlo. Ejecuta el comando con la shell del sistema, y su propio docstring enumera lo que no hace: nada de aislamiento, nada de separación de procesos, nada de límites de recursos, y lista los entornos de producción y multiinquilino como casos de uso inapropiados. Tiene además una opción de heredar el entorno del proceso que, activada, entrega al modelo todas las variables, incluidas las credenciales del gateway y la cadena de conexión de la base de datos. Viene desactivada, y es un pie de bala fácil de pegarse.&lt;/p>
&lt;p>La salida razonable en un cluster propio es implementar el protocolo uno mismo, y es más pequeño de lo que parece. La clase base de sandbox exige cuatro cosas: un identificador, ejecutar, subir ficheros y bajar ficheros. Todo lo demás, listar, leer, escribir, editar, borrar y buscar, lo construye la clase base encima de ejecutar. Mirando una de las implementaciones comerciales, son del orden de ciento cincuenta líneas.&lt;/p>
&lt;p>Un requisito de la imagen que condiciona el Dockerfile y que conviene saber antes: los ayudantes de la clase base inyectan pequeños scripts de Python codificados para resolver búsquedas y comprobaciones, así que la imagen del sandbox &lt;strong>necesita Python y una shell POSIX&lt;/strong> o la mitad de las llamadas fallan.&lt;/p>
&lt;p>Sobre cómo implementarlo, la forma que encaja con un Kubernetes propio es un contenedor efímero por sesión con tiempo de vida acotado, límites de recursos, sin token de cuenta de servicio montado y con una política de red que solo permita salir hacia el gateway de inferencia. Ejecutar se resuelve contra la API de ejecución del cluster, y subir y bajar ficheros por el mismo canal. La alternativa con menos código es una clase de runtime aislada para el pod del agente, un pod por inquilino, y el backend de shell dentro; se pierde el multiinquilino en un solo pod y no hay que escribir nada.&lt;/p>
&lt;h2 id="la-observabilidad-y-el-paquete-por-el-que-pasa">La observabilidad, y el paquete por el que pasa&lt;/h2>
&lt;p>Aquí hay un dato que sorprende y que conviene tener claro antes de diseñar el pipeline de trazas.&lt;/p>
&lt;p>&lt;strong>El núcleo de LangChain no emite OpenTelemetry.&lt;/strong> Cero referencias en todo el paquete. Su trazador nativo habla con el servicio de observabilidad del fabricante y punto.&lt;/p>
&lt;p>El exportador OTLP existe, pero vive dentro del SDK de ese servicio. Y ahí está la buena noticia: ese SDK tiene un modo de trazado que emite &lt;strong>solo&lt;/strong> por OTLP, sin hablar con el servicio, leyendo el destino y las cabeceras de las variables de entorno estándar de OpenTelemetry. El transporte es HTTP con protobuf, que es justo lo que acepta un Langfuse autoalojado en su ruta de ingesta.&lt;/p>
&lt;p>Es decir: para llevar las trazas del agente a la observabilidad propia hay que instalar el paquete del fabricante y pedirle que no hable con el fabricante. Funciona, es soportado y está documentado, pero es una dependencia que hay que declarar en el análisis de la cadena de suministro, no un detalle de configuración. Y si faltan los paquetes de OpenTelemetry, emite un aviso y deja de trazar en silencio, así que el arranque debería comprobarlo.&lt;/p>
&lt;p>Existe la alternativa del manejador de retrollamadas que publica el propio Langfuse, con menos piezas. La diferencia práctica es la correlación: por la vía OTLP los spans del agente comparten identificador de traza con los del gateway y los del motor de inferencia, y se ve una petición entera de punta a punta. Por la vía de retrollamadas, no. Para quien ya tenga un colector desplegado, la vía OTLP es la que paga.&lt;/p>
&lt;h2 id="el-servidor-aquí-se-acaba-lo-libre">El servidor: aquí se acaba lo libre&lt;/h2>
&lt;p>Todo lo anterior son decisiones de arquitectura. Esto es una decisión de licencia, y es la que da título al artículo.&lt;/p>
&lt;p>El SDK del armazón es MIT. El motor del grafo es MIT. Los checkpointers de memoria, SQLite y Postgres son MIT. Todo eso corre en cualquier sitio, sin clave, sin registro y sin llamar a nadie.&lt;/p>
&lt;p>El servidor que expone un grafo por HTTP es otra cosa. El paquete que lo implementa tiene &lt;strong>licencia Elastic 2.0&lt;/strong>, cuyas cláusulas relevantes prohíben ofrecer el software a terceros como servicio gestionado y prohíben modificar o eludir la funcionalidad de clave de licencia. El propio comando de la herramienta de línea imprime las condiciones: para desarrollo local pide una clave de API del servicio, y &lt;strong>para uso en producción pide una clave de licencia&lt;/strong> en una variable de entorno.&lt;/p>
&lt;p>Hay además reporte de uso. El código del servidor contiene un endpoint de baliza al que envía metadatos, con una cabecera que lleva la clave de licencia. La documentación de autoalojamiento lo confirma desde el otro lado, al listar entre los requisitos la salida de red hacia ese dominio para verificación de licencia y reporte de uso &lt;strong>si no se ejecuta en modo aislado&lt;/strong>. El modo aislado es una variante contractual, no una casilla que uno marca.&lt;/p>
&lt;p>Y arrastra infraestructura: además de Postgres, pide Redis.&lt;/p>
&lt;p>La herramienta de desarrollo, que es la que sale en todos los tutoriales, se describe a sí misma como modo de desarrollo con recarga en caliente y servidor en memoria. No es un servidor de producción y no pretende serlo.&lt;/p>
&lt;p>Nada de esto es un reproche. Es un modelo de negocio legítimo y bastante habitual: el núcleo permisivo y el plano de control comercial. Lo que no es legítimo es enterarse tarde. Quien esté montando una plataforma con requisito de soberanía, sin dependencias de servicios externos y con el argumento de que todo el stack es abierto, tiene que saber que &lt;strong>la pieza que convierte el grafo en un servicio no cumple ese requisito&lt;/strong>.&lt;/p>
&lt;h2 id="lo-que-hay-que-construir">Lo que hay que construir&lt;/h2>
&lt;p>La salida es escribir la capa HTTP uno mismo, y la buena noticia es que es aburrida. Un servicio que compile el grafo una vez con el checkpointer apuntando a la base de datos, y exponga un endpoint que valide el testigo del proveedor de identidad, derive el identificador de hilo y el contexto de inquilino, e invoque el grafo. Streaming por eventos si hace falta.&lt;/p>
&lt;p>Lo que hay que escribir además, y que el servidor comercial daba hecho:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Autorización por hilo.&lt;/strong> El motor no la tiene. Es la más importante y la más fácil de olvidar porque nada falla si no está.&lt;/li>
&lt;li>&lt;strong>Retención y borrado.&lt;/strong> Tarea programada que borre hilos enteros, nunca filas sueltas, por las razones del apartado del estado.&lt;/li>
&lt;li>&lt;strong>Migraciones.&lt;/strong> La preparación del esquema hay que llamarla explícitamente y necesita permisos de definición de datos, así que va en un trabajo aparte con un rol distinto del de ejecución. Y como crea índices de forma concurrente, no puede pasar por un agrupador de conexiones en modo transacción; conviene apuntar contra el servicio de escritura directo.&lt;/li>
&lt;li>&lt;strong>Sondas de salud y drenado.&lt;/strong> El motor tiene un mecanismo de drenado cooperativo, pero conectarlo a la señal de terminación del contenedor es trabajo propio.&lt;/li>
&lt;li>&lt;strong>Cola de ejecuciones y cancelación&lt;/strong>, si hacen falta ejecuciones largas en segundo plano. Aquí es donde más se nota la ausencia, y la respuesta razonable es una cola sobre la misma base de datos antes que otro sistema.&lt;/li>
&lt;li>&lt;strong>Durabilidad decidida a conciencia.&lt;/strong> Hay tres modos: persistir antes del paso siguiente, persistir en paralelo, o persistir solo al salir. El último no vale en Kubernetes: si el pod muere, se pierde el turno entero.&lt;/li>
&lt;/ul>
&lt;p>Sobre este último punto conviene ser explícito, porque afecta al diseño de las herramientas. El motor guarda las escrituras de cada tarea en cuanto termina, y al reanudar reaplica las que ya estaban y no las repite. Una herramienta que terminó no se vuelve a ejecutar. Una herramienta &lt;strong>que estaba en vuelo cuando el pod murió&lt;/strong> sí, porque su escritura nunca llegó. La semántica es de al menos una vez, así que toda herramienta con efecto externo tiene que ser idempotente o llevar clave de idempotencia. No hay exactamente una vez.&lt;/p>
&lt;p>Y lo mismo aplica a las interrupciones para confirmación humana: al reanudar, el nodo se &lt;strong>re-ejecuta entero&lt;/strong>, así que cualquier efecto lateral que estuviera antes de la interrupción dentro del mismo nodo se repite. La regla es que la interrupción vaya primero.&lt;/p>
&lt;h2 id="el-encaje-con-el-registro-de-actividad">El encaje con el registro de actividad&lt;/h2>
&lt;p>Dos cosas de este montaje tocan el cumplimiento de lleno, y las dos salen de apartados anteriores.&lt;/p>
&lt;p>La primera es que el checkpoint es, de facto, un repositorio de datos personales. Contiene la conversación entera y, con el backend por defecto, los ficheros que el agente haya escrito. Si la plataforma atiende a personas identificables, esa base de datos necesita cifrado, retención propia y control de acceso separado, exactamente igual que la tabla de gasto del gateway. La diferencia es que aquí no hay ningún interruptor de redacción que valga: el estado es el estado.&lt;/p>
&lt;p>La segunda es la trazabilidad. Un agente que delega en subagentes y ejecuta herramientas produce una cadena de acciones que hay que poder reconstruir, y el único sitio donde esa cadena queda completa es la traza. Que la traza salga por OTLP hacia la observabilidad propia deja de ser una preferencia técnica y pasa a ser el mecanismo de registro. Conviene diseñarlo así desde el principio, y no añadirlo después.&lt;/p>
&lt;p>Como siempre en esta serie, los códigos concretos del anexo hay que contrastarlos contra el texto vigente antes de llevarlos a un documento de cumplimiento; el planteamiento está desarrollado en &lt;a href="https://blog.lo0.es/posts/controles-tecnicos-ens-42001-eu-ai-act/">el artículo de controles técnicos&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decidir el backend de ficheros antes de la primera demostración, porque el de por defecto mete el contenido en la base de datos.&lt;/li>
&lt;li>Pasar el perfil del modelo a mano con la ventana real, y comprobarlo en el arranque.&lt;/li>
&lt;li>Bajar los umbrales de desalojo si la ventana del modelo está por debajo de 64.000 tokens.&lt;/li>
&lt;li>Construir el modelo como instancia, nunca como cadena con prefijo de proveedor, si detrás hay un endpoint compatible con OpenAI.&lt;/li>
&lt;li>Escribir la autorización por hilo antes de abrir el servicio a más de un usuario.&lt;/li>
&lt;li>Un despliegue por inquilino si se usa el backend de disco; espacio de nombres calculado por petición si se usa el de almacén.&lt;/li>
&lt;li>Calcular ese espacio de nombres desde el contexto propio, no desde el campo de información de servidor, que es nulo fuera de la plataforma gestionada.&lt;/li>
&lt;li>Tarea programada de retención que borre hilos enteros y solo hilos enteros.&lt;/li>
&lt;li>Migraciones del esquema en un trabajo aparte, con rol propio y contra el servicio de escritura directo.&lt;/li>
&lt;li>Durabilidad síncrona, y herramientas con efectos externos idempotentes.&lt;/li>
&lt;li>Trazas por OTLP con el modo que no habla con el servicio del fabricante, y comprobación en el arranque de que los paquetes están.&lt;/li>
&lt;li>Poner límite de concurrencia a los subagentes si hay cuota de GPU de por medio.&lt;/li>
&lt;li>Si hace falta ejecución de código, implementar el protocolo de sandbox contra un contenedor efímero; el backend de shell local está desaconsejado por su propia documentación.&lt;/li>
&lt;li>Anotar en el análisis de la cadena de suministro las dependencias duras de proveedores comerciales que el paquete arrastra aunque no se usen.&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;ul>
&lt;li>&lt;strong>La herramienta de planificación se eliminó en la 0.7.0.&lt;/strong> Hay que añadirla a mano, y también en cada subagente.&lt;/li>
&lt;li>&lt;strong>El backend por defecto guarda los ficheros dentro del checkpoint&lt;/strong>, binarios incluidos en base64.&lt;/li>
&lt;li>&lt;strong>Desalojar un resultado grande a fichero no lo saca de la base de datos&lt;/strong> con ese backend, solo de la ventana de contexto.&lt;/li>
&lt;li>&lt;strong>Se escribe un checkpoint por superstep&lt;/strong>, no por turno.&lt;/li>
&lt;li>&lt;strong>De las cuatro operaciones de limpieza declaradas, en Postgres solo existe borrar un hilo.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Una poda parcial puede vaciar el historial sin lanzar ningún error.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>El canal de deltas está en beta&lt;/strong> y su representación en disco puede cambiar.&lt;/li>
&lt;li>&lt;strong>No hay comprobación de propietario en el checkpointer.&lt;/strong> El identificador de hilo no es una credencial.&lt;/li>
&lt;li>&lt;strong>La raíz del backend de disco se fija al construir el agente&lt;/strong> y no separa inquilinos.&lt;/li>
&lt;li>&lt;strong>El ejemplo de la documentación para el espacio de nombres por usuario no funciona fuera de la plataforma gestionada.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>El middleware de ficheros y el de subagentes no se pueden quitar.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Si el modelo no declara su ventana, la compactación no dispara hasta los 170.000 tokens&lt;/strong>, en silencio.&lt;/li>
&lt;li>&lt;strong>Pasar el modelo como cadena con prefijo de proveedor fuerza una API que un vLLM no implementa.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>El SDK no integra MCP&lt;/strong>; la integración vive en la herramienta de línea de comandos.&lt;/li>
&lt;li>&lt;strong>Los subagentes no delegan&lt;/strong>: la profundidad es de un nivel.&lt;/li>
&lt;li>&lt;strong>El núcleo de LangChain no emite OpenTelemetry&lt;/strong>; el exportador vive en el SDK del servicio del fabricante.&lt;/li>
&lt;li>&lt;strong>El servidor HTTP tiene licencia Elastic 2.0&lt;/strong>, pide clave de licencia para producción y reporta uso salvo acuerdo de aislamiento.&lt;/li>
&lt;li>&lt;strong>La herramienta de desarrollo es de desarrollo&lt;/strong>, con servidor en memoria.&lt;/li>
&lt;li>&lt;strong>La reanudación es de al menos una vez&lt;/strong> para herramientas en vuelo.&lt;/li>
&lt;/ul>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>La pregunta con la que empezó este artículo era si &lt;code>deepagents&lt;/code> encaja en una plataforma propia. La respuesta es que sí, y que encaja mejor de lo que esperaba, precisamente porque es menos de lo que aparenta. Una pila de middleware sobre un grafo es fácil de alojar, fácil de entender y fácil de sustituir. Lo que no encaja es la pieza de al lado.&lt;/p>
&lt;p>Esa asimetría es el patrón que llevo viendo todo el año en el ecosistema, y merece nombrarlo. El núcleo se publica con licencia permisiva porque el núcleo es donde se compite por adopción. El plano de control se publica con licencia restrictiva porque el plano de control es donde se cobra. Pasó con los gateways, pasa con la observabilidad, y pasa aquí. Quien monte plataforma soberana no puede evaluar un proyecto entero por la licencia de su repositorio principal: hay que mirar paquete a paquete, y mirar cuál de ellos es el que uno va a necesitar el día que aquello tenga usuarios.&lt;/p>
&lt;p>La parte buena es que la factura, en este caso, se paga en trabajo y no en dinero. Lo que hay que construir para prescindir del servidor comercial es un servicio HTTP sin misterio, una tarea de retención, un trabajo de migración y una capa de autorización. Nada de eso es investigación. Todo eso es una semana de un ingeniero que sepa lo que hace, y a cambio queda un sistema que se despliega igual en un instituto que en un centro de datos clasificado, sin salida a internet y sin nadie contando ejecuciones al otro lado.&lt;/p>
&lt;p>Lo que sí recomiendo es hacer esa lista antes de la primera demostración y no después. Porque el ejemplo de la portada funciona en veinte líneas, y las veinte líneas tienen el backend por defecto, el modelo sin perfil y ninguna autorizació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/elegir-gateway-oss-inferencia-llm/">Elegir gateway de inferencia&lt;/a> — la pieza de debajo, y el mismo patrón de licencia mirado en otro sitio.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">El gateway MCP de LiteLLM&lt;/a> — de dónde salen las herramientas que este armazón consume.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prioridad-herramientas-mcp-visibilidad-y-exclusion/">Prioridad de herramientas MCP&lt;/a> — cómo se decide qué subconjunto del catálogo ve cada agente.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">Dimensionar para agentes&lt;/a> — la carga real que genera un bucle de herramientas sobre el gateway y el motor.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">El contratista con la llave maestra&lt;/a> — el aislamiento de red que este artículo da por necesario.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">El segundo vector de coste de los agentes&lt;/a> — ejecución durable y qué cuesta un bucle que falla a medias.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/controles-tecnicos-ens-42001-eu-ai-act/">Controles técnicos para ENS, 42001 y el AI Act&lt;/a> — el marco donde encajan el registro y la retención.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse: el par operativo&lt;/a> — el destino de las trazas OTLP de las que habla el artículo.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, código y documento de arquitectura: &lt;a href="https://github.com/langchain-ai/deepagents">https://github.com/langchain-ai/deepagents&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, registro de cambios de la rama del SDK (eliminación de la herramienta de planificación en la 0.7.0): &lt;a href="https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md">https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/langgraph&lt;/code>, motor, canales y checkpointers: &lt;a href="https://github.com/langchain-ai/langgraph">https://github.com/langchain-ai/langgraph&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint-postgres&lt;/code>, esquema de tablas y operaciones de mantenimiento: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint&lt;/code>, clase base del checkpointer y aviso sobre poda con canales de deltas: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint&lt;/a>.&lt;/li>
&lt;li>LangChain, despliegue de servidor autónomo (requisitos de licencia, Postgres, Redis y salida de red): &lt;a href="https://docs.langchain.com/langsmith/deploy-standalone-server">https://docs.langchain.com/langsmith/deploy-standalone-server&lt;/a>.&lt;/li>
&lt;li>LangChain, trazado con OpenTelemetry: &lt;a href="https://docs.langchain.com/langsmith/trace-with-opentelemetry">https://docs.langchain.com/langsmith/trace-with-opentelemetry&lt;/a>.&lt;/li>
&lt;li>Elastic License 2.0: &lt;a href="https://www.elastic.co/licensing/elastic-license">https://www.elastic.co/licensing/elastic-license&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración nativa OpenTelemetry (ruta de ingesta y autenticación): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración con LangChain por retrollamadas: &lt;a href="https://langfuse.com/integrations/frameworks/langchain">https://langfuse.com/integrations/frameworks/langchain&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/helm&lt;/code>, chart del servidor: &lt;a href="https://github.com/langchain-ai/helm">https://github.com/langchain-ai/helm&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, día 2 (3 de 10): dos agentes conocidos instrumentados, Open Deep Research y GPT Researcher</title><link>https://blog.lo0.es/posts/langfuse-v4-agentes-reales-odr-gptr/</link><pubDate>Mon, 14 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/langfuse-v4-agentes-reales-odr-gptr/</guid><description>&lt;blockquote>
&lt;p>Tercer artículo de una serie de diez sobre operar Langfuse v4 en producción. El primero recorría &lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">qué entra en una traza&lt;/a>; el segundo medía &lt;a href="https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/">lo que cuesta un turno de agente&lt;/a> sobre un grafo mínimo. Este aplica lo mismo a dos agentes que la gente despliega. Código leído el 14 de septiembre de 2026: Open Deep Research en el commit &lt;code>1b7d2e8&lt;/code> y GPT Researcher en el &lt;code>6f99857&lt;/code>, con Langfuse 4.15.2 y LangGraph 1.2.11.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Una petición de Open Deep Research con la configuración por defecto son 1.416 observaciones y 8,1 MB.&lt;/strong> Medido sobre una réplica fiel de su topología con cinco unidades concurrentes, seis iteraciones del supervisor y diez llamadas react por investigador, que son sus valores por defecto. Todo cae en una sola traza. Y es un suelo, porque en el banco los resultados de búsqueda son de trescientos caracteres y los de verdad son mucho mayores.&lt;/p>
&lt;p>&lt;strong>Ninguna de esas 1.416 es de tipo AGENT.&lt;/strong> Sus nueve nodos se llaman &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code>, &lt;code>final_report_generation&lt;/code>, &lt;code>supervisor&lt;/code>, &lt;code>supervisor_tools&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> y &lt;code>compress_research&lt;/code>. Ninguno contiene la cadena que busca el handler.&lt;/p>
&lt;p>&lt;strong>El abanico paralelo no rompe la traza, y el miedo a que la rompa es infundado.&lt;/strong> Open Deep Research reenvía el &lt;code>config&lt;/code> a los subgrafos y GPT Researcher pasa uno nuevo con solo etiquetas. Probé los dos patrones: una traza en ambos casos, porque LangChain recompone el config desde la variable de contexto.&lt;/p>
&lt;p>&lt;strong>GPT Researcher son dos productos en el mismo repositorio.&lt;/strong> El modo multiagente es LangGraph y se traza como cualquier grafo. El modo estándar no tiene grafo, y ahí cada llamada al modelo abre su propia traza: ocho llamadas, ocho trazas, medido.&lt;/p>
&lt;p>&lt;strong>Su nodo humano no usa &lt;code>interrupt()&lt;/code>, espera por websocket.&lt;/strong> La observación del nodo se queda abierta mientras la persona piensa, de modo que su duración mide latencia humana. El percentil 95 de ese grafo no significa nada si no se excluye ese nodo.&lt;/p>
&lt;p>&lt;strong>Instrumentar los dos no requiere tocar sus repositorios.&lt;/strong> En Open Deep Research basta importar el grafo compilado y pasar el handler por &lt;code>config&lt;/code>. En el modo estándar de GPT Researcher hay que envolver con el SDK, porque no hay sitio donde enganchar callbacks.&lt;/p>
&lt;h2 id="estás-aquí-observe-día-2">Estás aquí: OBSERVE, día 2&lt;/h2>
&lt;p>El artículo anterior sacó una fórmula sobre un grafo de juguete: &lt;code>4 + 5n&lt;/code> observaciones por turno, con el volumen en bytes creciendo con el cuadrado de los pasos. Una fórmula sobre un juguete sirve para entender el mecanismo y no sirve para dimensionar nada.&lt;/p>
&lt;p>Este artículo la aplica a dos agentes reales, elegidos porque son conocidos y porque se rompen de maneras distintas. La serie pasa a diez artículos y el de migración se corre un puesto.&lt;/p>
&lt;h2 id="la-analogía-dos-fábricas-una-auditoría">La analogía: dos fábricas, una auditoría&lt;/h2>
&lt;p>Una auditoría de trazabilidad en una fábrica con todo el proceso en cadenas propias es tediosa pero completa: cada puesto deja parte, y el auditor reconstruye la pieza de principio a fin. El volumen de papel es enorme y ese es el único problema.&lt;/p>
&lt;p>La segunda fábrica subcontrata la mitad del proceso. El auditor ve entrar el material en una nave y salir el producto terminado, con horas y cantidades, y de lo que pasa dentro no tiene nada. El papel es poco. El problema tampoco es el papel.&lt;/p>
&lt;p>Open Deep Research es la primera fábrica y el problema es de volumen. GPT Researcher, en su modo estándar, es la segunda, y el problema es que la traza no recoge lo que hace falta saber. Las dos se arreglan, pero con herramientas distintas, y confundirlas lleva a comprar disco cuando lo que falta es instrumentación.&lt;/p>
&lt;h2 id="los-dos-agentes-y-por-qué-estos">Los dos agentes, y por qué estos&lt;/h2>
&lt;p>&lt;strong>Open Deep Research&lt;/strong> (&lt;code>langchain-ai/open_deep_research&lt;/code>, 12,7 k estrellas) es el agente de investigación de referencia de LangChain. Todo su trabajo pasa por LangChain, así que la instrumentación por callbacks lo ve entero.&lt;/p>
&lt;p>&lt;strong>GPT Researcher&lt;/strong> (&lt;code>assafelovic/gpt-researcher&lt;/code>, 28,4 k estrellas) es el agente de investigación open source más conocido y no es de LangChain. Su modo multiagente usa LangGraph; su modo estándar, que es el que corren la API y la interfaz web, no.&lt;/p>
&lt;p>Uno es de la casa y el otro no, que para una comparación es lo que importa.&lt;/p>
&lt;h2 id="open-deep-research-la-forma-del-grafo">Open Deep Research: la forma del grafo&lt;/h2>
&lt;p>Tres grafos, dos de ellos anidados como subgrafos (&lt;code>src/open_deep_research/deep_researcher.py&lt;/code>):&lt;/p>
&lt;ul>
&lt;li>El grafo raíz, &lt;code>deep_researcher&lt;/code> (línea 701), con cuatro nodos: &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code> y &lt;code>final_report_generation&lt;/code>.&lt;/li>
&lt;li>El subgrafo del supervisor (línea 353), con &lt;code>supervisor&lt;/code> y &lt;code>supervisor_tools&lt;/code>, que iteran entre sí.&lt;/li>
&lt;li>El subgrafo del investigador (línea 589), con &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> y &lt;code>compress_research&lt;/code>, que es un bucle react clásico.&lt;/li>
&lt;/ul>
&lt;p>La multiplicación está en &lt;code>supervisor_tools&lt;/code>. Cuando el supervisor decide delegar, ese nodo lanza un subgrafo de investigador por cada unidad y los espera juntos (línea 305):&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">research_tasks&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">researcher_subgraph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&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;researcher_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="n">content&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 class="s2">&amp;#34;research_topic&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;research_topic&amp;#34;&lt;/span>&lt;span class="p">:&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 class="s2">&amp;#34;research_topic&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">tool_call&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">allowed_conduct_research_calls&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">tool_results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">gather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">research_tasks&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El detalle que decide si la traza sale entera es ese &lt;code>config&lt;/code> del final: el nodo recibe la configuración de LangGraph y la reenvía a los subgrafos, así que los callbacks viajan y cada investigador cuelga del sitio correcto.&lt;/p>
&lt;p>Los tres topes de la configuración por defecto (&lt;code>src/open_deep_research/configuration.py&lt;/code>) son &lt;code>max_concurrent_research_units = 5&lt;/code>, &lt;code>max_researcher_iterations = 6&lt;/code> y &lt;code>max_react_tool_calls = 10&lt;/code>. Se multiplican entre sí.&lt;/p>
&lt;h2 id="lo-que-cuesta-una-petición-medido">Lo que cuesta una petición, medido&lt;/h2>
&lt;p>Repliqué esa topología con modelos falsos, la misma técnica del artículo anterior: tres grafos, el mismo anidamiento, el mismo abanico con &lt;code>asyncio.gather&lt;/code>, resultados de búsqueda de trescientos caracteres y respuestas de modelo de doscientos. Sin red y sin GPU, así que reproduce en cualquier sitio.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Unidades&lt;/th>
&lt;th>Iteraciones&lt;/th>
&lt;th>Llamadas react&lt;/th>
&lt;th>Observaciones&lt;/th>
&lt;th>generation&lt;/th>
&lt;th>chain&lt;/th>
&lt;th>KB de atributos&lt;/th>
&lt;th>Mayor observación&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>1&lt;/td>
&lt;td>1&lt;/td>
&lt;td>26&lt;/td>
&lt;td>8&lt;/td>
&lt;td>18&lt;/td>
&lt;td>56,8 KB&lt;/td>
&lt;td>3,7 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>2&lt;/td>
&lt;td>3&lt;/td>
&lt;td>92&lt;/td>
&lt;td>26&lt;/td>
&lt;td>66&lt;/td>
&lt;td>257,3 KB&lt;/td>
&lt;td>4,6 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>6&lt;/td>
&lt;td>10&lt;/td>
&lt;td>&lt;strong>1.416&lt;/strong>&lt;/td>
&lt;td>370&lt;/td>
&lt;td>1.046&lt;/td>
&lt;td>&lt;strong>8.108 KB&lt;/strong>&lt;/td>
&lt;td>11,0 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La última fila es la configuración por defecto del repositorio. Una petición de investigación, una persona pulsando un botón una vez, deja &lt;strong>1.416 observaciones y ocho megabytes&lt;/strong> en el backend de observabilidad. En una sola traza, con un solo identificador.&lt;/p>
&lt;p>Esos ocho megabytes son un suelo, no un techo. En el banco, cada resultado de búsqueda ocupa trescientos caracteres; un resultado real de Tavily con el contenido de la página ocupa entre diez y cien veces más, y ese texto entra en el historial y se reserializa en cada observación posterior del mismo investigador, que es el crecimiento cuadrático del artículo anterior actuando sobre un texto mucho mayor.&lt;/p>
&lt;p>Puesto en cuentas de capacidad: cien peticiones de investigación al día, que para un equipo pequeño no es nada, son 141.600 observaciones y del orden de un gigabyte diario sin comprimir, con búsquedas de juguete. Con búsquedas reales, decenas de gigabytes. Es otro orden de magnitud respecto a un asistente de chat, y es la razón por la que este artículo va antes que el de capacidad.&lt;/p>
&lt;h2 id="cero-observaciones-de-tipo-agent">Cero observaciones de tipo AGENT&lt;/h2>
&lt;p>En las 1.416 observaciones no hay ni una de tipo &lt;code>AGENT&lt;/code>. El artículo anterior explicaba por qué: el handler decide ese tipo buscando la cadena &lt;code>agent&lt;/code> en la ruta de la clase o en el nombre del run. Los nueve nodos de Open Deep Research son &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code>, &lt;code>final_report_generation&lt;/code>, &lt;code>supervisor&lt;/code>, &lt;code>supervisor_tools&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> y &lt;code>compress_research&lt;/code>.&lt;/p>
&lt;p>Son nombres buenos. Describen lo que hace cada nodo y no incluyen una palabra que no aporta nada al que lee el código. El resultado es que el agente de investigación de referencia de LangChain, visto desde Langfuse, es un árbol de cadenas.&lt;/p>
&lt;p>Se arregla desde fuera, sin tocar el repositorio, poniendo nombre a la invocación del subgrafo o renombrando los dos nodos que representan una decisión del modelo, que son &lt;code>supervisor&lt;/code> y &lt;code>researcher&lt;/code>. Sin eso, cualquier panel que agrupe por tipo de observación no va a distinguir el trabajo de decisión del trabajo de fontanería.&lt;/p>
&lt;h2 id="instrumentarlo-sin-tocar-el-repositorio">Instrumentarlo sin tocar el repositorio&lt;/h2>
&lt;p>Open Deep Research se publica como grafo de LangGraph Server, con su &lt;code>langgraph.json&lt;/code> apuntando a &lt;code>deep_researcher&lt;/code>. Hay dos formas de meterle el handler según cómo lo ejecutes.&lt;/p>
&lt;p>Si lo invocas desde tu propio código, el grafo compilado es importable y basta el &lt;code>config&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">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&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.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">open_deep_research.deep_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">deep_researcher&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">trace_id&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">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&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">peticion_id&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">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&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_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;deep-research&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">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">peticion_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">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;odr&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;investigacion&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">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">deep_researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&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="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">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;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">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;configurable&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;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">peticion_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;max_concurrent_research_units&amp;#34;&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="s2">&amp;#34;max_researcher_iterations&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="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>Bajar esos dos topes de 5 y 6 a 3 y 4 no es una recomendación de observabilidad, es una decisión de producto que además divide el volumen de trazas por tres. Mejor tomarla a la vez.&lt;/p>
&lt;p>Si lo despliegas en LangGraph Server, no hay invocación tuya donde meter el &lt;code>config&lt;/code>, así que el handler se ata al compilar. El patrón es envolver el grafo exportado:&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="c1"># grafo_instrumentado.py, y en langgraph.json apuntar aqui&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.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">open_deep_research.deep_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">deep_researcher&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">graph&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">deep_researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_config&lt;/span>&lt;span class="p">({&lt;/span>&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">CallbackHandler&lt;/span>&lt;span class="p">()]})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con esa vía se pierde el identificador de traza sembrado por petición, porque el handler se crea una vez. Si el agente usa interrupciones, esa pérdida importa, por lo que vimos en el artículo anterior.&lt;/p>
&lt;h2 id="gpt-researcher-dos-productos-en-el-mismo-repositorio">GPT Researcher: dos productos en el mismo repositorio&lt;/h2>
&lt;p>Aquí está lo interesante, y es una trampa en la que se cae con cualquier agente que haya crecido por capas.&lt;/p>
&lt;p>El repositorio tiene el paquete &lt;code>gpt_researcher&lt;/code>, que es el motor, y el paquete &lt;code>multi_agents&lt;/code>, que es una orquestación encima. Solo el segundo usa LangGraph.&lt;/p>
&lt;p>El orquestador (&lt;code>multi_agents/agents/orchestrator.py:66&lt;/code>) tiene ocho nodos: &lt;code>browser&lt;/code>, &lt;code>planner&lt;/code>, &lt;code>human&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>writer&lt;/code>, &lt;code>fact_checker&lt;/code>, &lt;code>visualizer&lt;/code> y &lt;code>publisher&lt;/code>, con dos bucles condicionales, uno de revisión humana del plan y otro de verificación de hechos. El nodo &lt;code>researcher&lt;/code> lanza en paralelo un subgrafo de editor por sección (&lt;code>multi_agents/agents/editor.py:134&lt;/code>), y ese subgrafo tiene su propio bucle de revisión entre &lt;code>reviewer&lt;/code> y &lt;code>reviser&lt;/code>.&lt;/p>
&lt;p>Todo eso se traza como cualquier grafo. Y los modelos se llaman con &lt;code>self.llm.ainvoke(messages, **kwargs)&lt;/code> desde el proveedor genérico (&lt;code>gpt_researcher/llm_provider/generic/base.py:365&lt;/code>), que es un modelo de chat de LangChain sin config explícito, de modo que recoge la configuración ambiente mientras se ejecuta dentro de un nodo. Las generaciones aparecen.&lt;/p>
&lt;p>El modo estándar es otra cosa. La clase &lt;code>GPTResearcher&lt;/code> no tiene grafo, no tiene nodos y no pasa por LangGraph en ningún punto. Es el modo que usan la API y la interfaz web, o sea el que la mayoría ejecuta.&lt;/p>
&lt;h2 id="el-mito-del-config-nuevo">El mito del config nuevo&lt;/h2>
&lt;p>Antes de seguir hay que desmontar algo que parece un fallo y no lo es, porque se repite en muchos agentes.&lt;/p>
&lt;p>Cuando el nodo &lt;code>researcher&lt;/code> lanza los subgrafos de sección, lo hace así (&lt;code>multi_agents/agents/editor.py:74&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="n">final_drafts&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">chain&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_create_task_input&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">research_state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">title&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;tags&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;gpt-researcher&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">query&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">queries&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">research_results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;draft&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">gather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">final_drafts&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ese &lt;code>config&lt;/code> es nuevo y solo lleva etiquetas. El método ni siquiera recibe la configuración del nodo, así que no podría reenviarla. La conclusión intuitiva es que los subgrafos pierden los callbacks y cada sección acaba en su propia traza.&lt;/p>
&lt;p>No pasa. Monté los dos patrones lado a lado, el de Open Deep Research que reenvía el &lt;code>config&lt;/code> y el de GPT Researcher que pasa uno nuevo, y los dos dan &lt;strong>una sola traza con un solo span raíz&lt;/strong>. El motivo es que LangChain no toma el config que le pasas tal cual: parte de la configuración ambiente guardada en una variable de contexto y le superpone las claves que traes. Los callbacks siguen ahí, y las variables de contexto se copian a las tareas que crea &lt;code>asyncio.gather&lt;/code>.&lt;/p>
&lt;p>Lo que sí se pierde son las etiquetas del padre, que quedan sustituidas por la lista nueva. Es una molestia de filtrado, no una traza rota.&lt;/p>
&lt;h2 id="el-modo-estándar-ocho-llamadas-ocho-trazas">El modo estándar: ocho llamadas, ocho trazas&lt;/h2>
&lt;p>Sin grafo no hay run raíz, y sin run raíz cada llamada al modelo es su propio árbol. Medido con ocho llamadas sueltas a un modelo de chat con el handler pasado a mano en cada una:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Observaciones&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trazas distintas&lt;/td>
&lt;td>&lt;strong>8&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Spans raíz&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tipos&lt;/td>
&lt;td>8 generation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Ocho trazas de una observación cada una, sin relación entre ellas. Ninguna sabe que pertenece a la misma investigación. Y eso en el caso favorable, porque el modo estándar no ofrece ningún sitio público donde poner ese handler: los &lt;code>**kwargs&lt;/code> llegan hasta &lt;code>ainvoke&lt;/code> desde &lt;code>create_chat_completion&lt;/code> (&lt;code>gpt_researcher/utils/llm.py:120&lt;/code>), pero el camino atraviesa varias capas internas que no están pensadas como punto de extensión.&lt;/p>
&lt;p>La vía que funciona sin parchear el repositorio es envolver por fuera con el SDK y dejar que las llamadas de dentro cuelguen de ahí:&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">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">observe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&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">gpt_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">GPTResearcher&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">@observe&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;gpt-researcher&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent&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">investigar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&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">peticion_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="nb">str&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">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">peticion_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;gptr&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">researcher&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">GPTResearcher&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">pregunta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">report_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;research_report&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">await&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">conduct_research&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">informe&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">write_report&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">cliente&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&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">update_current_span&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;longitud&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">informe&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s2">&amp;#34;fuentes&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">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_source_urls&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;costes_gptr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_costs&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">informe&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con eso hay una traza por investigación, con una observación de tipo &lt;code>agent&lt;/code> en la raíz y las generaciones colgando dentro, en el mismo árbol. No aparecen las búsquedas ni el troceado de documentos, porque eso no pasa por LangChain. Si hacen falta, se añaden con &lt;code>start_as_current_observation(as_type=&amp;quot;tool&amp;quot;)&lt;/code> alrededor de las llamadas propias, que es la primera de las tres salidas del filtro de spans que describía el artículo anterior.&lt;/p>
&lt;p>Hay que fijarse en &lt;code>researcher.get_costs()&lt;/code>. GPT Researcher lleva su propia contabilidad de coste, y meterla como metadato es lo único que permite cuadrarla después con lo que calcula Langfuse. Cuando las dos cifras no coinciden, casi siempre es el alias del modelo del gateway.&lt;/p>
&lt;h2 id="el-nodo-humano-que-se-queda-abierto">El nodo humano que se queda abierto&lt;/h2>
&lt;p>El modo multiagente tiene un nodo &lt;code>human&lt;/code> que pide opinión sobre el plan antes de investigar. No usa &lt;code>interrupt()&lt;/code> de LangGraph. Espera dentro del nodo, leyendo del websocket (&lt;code>multi_agents/agents/human.py&lt;/code>).&lt;/p>
&lt;p>Las dos formas tienen consecuencias opuestas en la traza. Con &lt;code>interrupt()&lt;/code>, el grafo devuelve el control y la traza se parte en dos, que es lo que medía el artículo anterior. Esperando dentro del nodo, la traza es una sola pero la observación de ese nodo queda abierta todo el tiempo que tarde la persona, minutos u horas.&lt;/p>
&lt;p>Ninguna de las dos está mal. Lo que está mal es el panel que promedia duraciones sin saber cuál de las dos tiene delante. Con la espera dentro del nodo, el percentil 95 de duración del grafo mide cuánto tarda un humano en contestar un mensaje, no lo que tarda el agente. Si se alerta sobre esa métrica, la alerta salta a la hora de comer.&lt;/p>
&lt;p>La regla práctica es excluir por nombre los nodos de espera humana de cualquier métrica de latencia, y medir esa espera aparte, que además suele ser una métrica de producto interesante por sí sola.&lt;/p>
&lt;h2 id="qué-mirar-en-la-traza-cuando-llega">Qué mirar en la traza cuando llega&lt;/h2>
&lt;p>Con los dos agentes instrumentados, y antes de montar ningún panel, hay cuatro comprobaciones que se hacen una vez y ahorran semanas:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cuenta las observaciones de una petición típica.&lt;/strong> Si salen más de mil, el problema de esta plataforma va a ser de volumen y toca decidir muestreo antes que nada.&lt;/li>
&lt;li>&lt;strong>Mira si hay más de un span raíz.&lt;/strong> Más de uno significa que hay trabajo que se está trazando fuera del árbol, y hay que saber cuál antes de que sean miles.&lt;/li>
&lt;li>&lt;strong>Comprueba que las generaciones traen coste.&lt;/strong> Si vienen a cero, es el alias del modelo, y se arregla dando de alta ese nombre con su precio.&lt;/li>
&lt;li>&lt;strong>Ordena las observaciones por duración y quita las que esperan a una persona.&lt;/strong> Lo que queda arriba es donde está el tiempo de verdad.&lt;/li>
&lt;/ol>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>El grafo se invoca con un identificador de traza sembrado por petición, no con uno aleatorio.&lt;/li>
&lt;li>Los topes de concurrencia e iteraciones del agente están fijados a conciencia, sabiendo que multiplican el volumen de trazas.&lt;/li>
&lt;li>Los nodos que representan una decisión del modelo llevan &lt;code>agent&lt;/code> en el nombre, o se ha asumido por escrito que no habrá observaciones de ese tipo.&lt;/li>
&lt;li>El trabajo que no pasa por LangChain está envuelto con el SDK, o se ha decidido que no hace falta verlo.&lt;/li>
&lt;li>Los nodos de espera humana están identificados y excluidos de las métricas de latencia.&lt;/li>
&lt;li>La contabilidad de coste propia del agente, si la tiene, entra como metadato para poder cuadrarla.&lt;/li>
&lt;li>Hay una cifra medida de observaciones por petición, tomada de la instalación propia y no de este artículo.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas">Trampas&lt;/h2>
&lt;p>&lt;strong>Los valores por defecto de un agente de investigación son de demostración, no de producción.&lt;/strong> Cinco unidades por seis iteraciones por diez llamadas react multiplican, y el que los eligió pensaba en la calidad del informe, no en tu ClickHouse.&lt;/p>
&lt;p>&lt;strong>Un repositorio puede tener dos agentes dentro con dos historias de instrumentación distintas.&lt;/strong> Instrumentar el modo que documenta el README y no el que ejecuta la API es un error que no da ningún síntoma: las trazas llegan, solo que de otra cosa.&lt;/p>
&lt;p>&lt;strong>Pasar un config nuevo a un subgrafo no rompe la traza.&lt;/strong> Rompe las etiquetas. El árbol se mantiene porque los callbacks viajan por variable de contexto.&lt;/p>
&lt;p>&lt;strong>Un nodo que espera a una persona contamina cualquier estadística de duración.&lt;/strong> Y no lo hace de forma evidente, porque el nodo termina bien y no marca error.&lt;/p>
&lt;p>&lt;strong>Buenos nombres de nodo y buena clasificación de observaciones son objetivos en conflicto.&lt;/strong> Gana el que escribe el código del agente, que normalmente no eres tú.&lt;/p>
&lt;p>&lt;strong>Ocho megabytes por petición con búsquedas de trescientos caracteres.&lt;/strong> Con resultados reales, el número que salga de tu instalación no se parecerá al de este artículo, y será mayor.&lt;/p>
&lt;h2 id="la-serie-los-diez-artículos">La serie: los diez artículos&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">Qué entra en una traza&lt;/a>: modelo de datos de la versión 4, límites, precedencias, scores, enmascarado e índices.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/">Poner LangGraph delante&lt;/a>: la instrumentación de una plataforma agéntica y el coste medido de un turno.&lt;/li>
&lt;li>Dos agentes conocidos instrumentados (este artículo): Open Deep Research y GPT Researcher, con las cifras medidas de una petición real.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-migrar-sin-ventana/">Migrar de la versión 3 a la 4 sin ventana&lt;/a>: los tres pasos del modo de escritura, las migraciones de fondo reanudables y dónde está el punto de no retorno del retroceso.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-colas-del-worker/">Las colas del worker&lt;/a>: el mapa de las treinta y nueve, qué pool dedicar a cada grupo, los interruptores por cola, el particionado y la concurrencia.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-capacidad-clickhouse/">Capacidad y coste real de ClickHouse&lt;/a>: cómo medir los bytes por observación con las tablas del sistema, la diferencia entre la tabla completa y la de listados, y el coste de fusión de los índices de texto completo.&lt;/li>
&lt;li>Retención, borrado y protección de datos: por qué un borrado no libera disco, el limpiador de máscaras que viene desactivado, la cola de borrados pendientes y el ciclo de vida de S3 que hay que implementar a mano.&lt;/li>
&lt;li>Copias de seguridad y recuperación cruzada: orden de restauración entre Postgres, ClickHouse y el almacenamiento de objetos, qué rompe cada desajuste, y hasta dónde llega la reproducción de eventos.&lt;/li>
&lt;li>Runbook de saturación: qué alertar de las métricas de cola, las sondas de atasco, el drenado por el endpoint de preparación y la cola de mensajes fallidos.&lt;/li>
&lt;li>Sacar los datos fuera: la integración de almacenamiento de objetos a Parquet, las exportaciones por lotes y la API de métricas, para montar el lago de datos.&lt;/li>
&lt;/ol>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/">Poner LangGraph delante&lt;/a>: la fórmula y el crecimiento cuadrático que aquí se aplican a dos agentes reales.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">Langfuse v4: qué entra de verdad en una traza&lt;/a>: el modelo de datos que explica el coste del volumen.&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>: el mismo problema visto desde el gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/">FinOps y multi-tenencia de GPU con LiteLLM&lt;/a>: por qué el coste sale a cero con un alias del gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">Ejecución duradera y coste de los agentes&lt;/a>: qué pasa cuando el agente sobrevive al proceso.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">Aislar agentes de IA por cliente&lt;/a>: el aislamiento de un agente que navega y descarga.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Código de &lt;a href="https://github.com/langchain-ai/open_deep_research">Open Deep Research&lt;/a>, commit &lt;code>1b7d2e8&lt;/code>, leído el 14 de septiembre de 2026: &lt;code>src/open_deep_research/deep_researcher.py&lt;/code> y &lt;code>configuration.py&lt;/code>.&lt;/li>
&lt;li>Código de &lt;a href="https://github.com/assafelovic/gpt-researcher">GPT Researcher&lt;/a>, commit &lt;code>6f99857&lt;/code>, leído el 14 de septiembre de 2026: &lt;code>multi_agents/agents/orchestrator.py&lt;/code>, &lt;code>multi_agents/agents/editor.py&lt;/code>, &lt;code>multi_agents/agents/human.py&lt;/code>, &lt;code>gpt_researcher/llm_provider/generic/base.py&lt;/code> y &lt;code>gpt_researcher/utils/llm.py&lt;/code>.&lt;/li>
&lt;li>Medición propia del 14 de septiembre de 2026 sobre réplicas de las dos topologías, con LangGraph 1.2.11, langchain-core 1.6.3, SDK de Langfuse 4.15.2, &lt;code>InMemorySpanExporter&lt;/code> y modelos falsos, sin red.&lt;/li>
&lt;li>Estrellas de GitHub de los dos repositorios, consultadas el 14 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, día 2 (2 de 10): poner LangGraph delante, y lo que cuesta un turno de agente en observaciones</title><link>https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/</link><pubDate>Sun, 13 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/</guid><description>&lt;blockquote>
&lt;p>Segundo artículo de una serie de diez sobre operar Langfuse v4 en producción. El primero, &lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">qué entra de verdad en una traza&lt;/a>, recorría el modelo de datos leyendo el código del servidor. Este mide el otro lado. Verificado contra Langfuse 4.15.2 (SDK de Python), LangGraph 1.2.11 y langchain-core 1.6.3, con las cifras tomadas el 13 de septiembre de 2026 en un banco de pruebas sin red.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Un turno de agente son cuatro observaciones más cinco por cada llamada a herramienta.&lt;/strong> Medido con un exportador en memoria sobre un grafo ReAct mínimo: cero herramientas, 4 observaciones; una, 9; cinco, 29; diez, 54; cuarenta, 204. La fórmula se cumple exactamente en todo el rango.&lt;/p>
&lt;p>&lt;strong>El volumen en bytes crece de forma cuadrática, no lineal.&lt;/strong> El mismo turno pasa de 12,2 KB con una llamada a herramienta a 1,87 MB con cuarenta. El 88 % de esos bytes es el campo de entrada, porque cada observación reserializa el historial de mensajes completo tal y como está en ese momento.&lt;/p>
&lt;p>&lt;strong>Las definiciones de herramientas viajan pegadas a cada llamada al modelo.&lt;/strong> El handler las añade al campo de entrada como mensajes con rol &lt;code>tool&lt;/code>. Medido: 364 bytes por herramienta. Con veinte herramientas son 7,3 KB en cada uno de los pasos del bucle.&lt;/p>
&lt;p>&lt;strong>El tipo de observación AGENT se asigna por coincidencia de cadena en el nombre.&lt;/strong> Un nodo llamado &lt;code>planificador&lt;/code> sale como &lt;code>CHAIN&lt;/code>; el mismo nodo llamado &lt;code>agente_planificador&lt;/code> sale como &lt;code>AGENT&lt;/code>. La lógica es literalmente buscar &lt;code>agent&lt;/code> en la ruta de la clase o en el nombre del nodo (&lt;code>langfuse/langchain/CallbackHandler.py:397&lt;/code>).&lt;/p>
&lt;p>&lt;strong>Una interrupción con reanudación produce dos trazas separadas.&lt;/strong> Verificado con &lt;code>invoke&lt;/code>, con &lt;code>stream&lt;/code> y con subgrafos, y también reutilizando el mismo objeto handler. El SDK trae maquinaria para reenganchar la traza, pero solo se dispara si el run raíz termina con una excepción de control de flujo, y en LangGraph 1.2.11 la interrupción vuelve por el valor de retorno.&lt;/p>
&lt;p>&lt;strong>El arreglo cabe en una línea y está medido.&lt;/strong> Pasar &lt;code>trace_context={&amp;quot;trace_id&amp;quot;: Langfuse.create_trace_id(seed=thread_id)}&lt;/code> al construir el handler deja la interrupción y la reanudación en la misma traza, incluso con handlers distintos y, por tanto, con réplicas distintas.&lt;/p>
&lt;p>&lt;strong>El procesador de spans descarta por defecto lo que no reconoce.&lt;/strong> Solo exporta spans del propio SDK, spans con algún atributo &lt;code>gen_ai.*&lt;/code>, o spans de una lista cerrada de treinta y cinco prefijos de instrumentación (&lt;code>langfuse/_client/span_filter.py:11&lt;/code>). Las trazas de tu propio código no llegan salvo que entren por una de esas tres puertas.&lt;/p>
&lt;h2 id="estás-aquí-observe-día-2">Estás aquí: OBSERVE, día 2&lt;/h2>
&lt;p>La serie se planteó con ocho artículos y ha crecido a diez. El motivo es de orden: los dos siguientes hablan de migrar y de dimensionar ClickHouse, y las dos cosas necesitan una cifra de entrada que nadie publica, que es cuántas observaciones y cuántos bytes produce una unidad de trabajo real. Un chat produce una traza con dos observaciones. Un agente produce doscientas. Dimensionar con la cifra del chat lleva a un cluster que se cae el día que alguien conecta el agente.&lt;/p>
&lt;p>Así que antes de migrar, medir. Y para medir hace falta poner delante lo que la gente despliega.&lt;/p>
&lt;h2 id="la-analogía-el-expediente-que-se-fotocopia-entero-en-cada-firma">La analogía: el expediente que se fotocopia entero en cada firma&lt;/h2>
&lt;p>Un expediente de contratación pasa por seis mesas. En cada mesa alguien lee lo que hay, añade una hoja y lo pasa a la siguiente.&lt;/p>
&lt;p>Hay dos maneras de dejar rastro de ese recorrido. La primera es anotar en un registro qué mesa lo tocó, cuándo y qué hoja añadió. Seis anotaciones cortas. La segunda es fotocopiar el expediente completo al entrar en cada mesa y al salir, y archivar las doce fotocopias. El registro ocupa una hoja. Las fotocopias ocupan, en la sexta mesa, seis veces lo que ocupaban en la primera, y en total ocupan del orden del cuadrado del número de mesas.&lt;/p>
&lt;p>La instrumentación por callbacks de LangChain hace lo segundo. No registra el delta de cada paso, registra el estado completo a la entrada y a la salida de cada paso. Con un chat eso da igual, porque el estado es corto y hay un paso. Con un agente de cuarenta pasos, el archivo pesa casi dos megabytes por expediente tramitado.&lt;/p>
&lt;p>Esto no es un defecto que haya que arreglar. Es lo que permite abrir una traza y ver exactamente qué vio el modelo en el paso 17 sin reconstruir nada. Pero condiciona el dimensionado, y el número hay que saberlo antes de firmar el pliego.&lt;/p>
&lt;h2 id="cuál-es-la-plataforma-agéntica-más-usada">Cuál es la plataforma agéntica más usada&lt;/h2>
&lt;p>La pregunta tiene dos respuestas según la métrica, y las dos son defendibles.&lt;/p>
&lt;p>Por descargas de PyPI en los últimos treinta días, consultadas el 13 de septiembre de 2026:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Paquete&lt;/th>
&lt;th>Descargas / 30 días&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>langgraph&lt;/code>&lt;/td>
&lt;td>52,3 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>strands-agents&lt;/code>&lt;/td>
&lt;td>35,2 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>openai-agents&lt;/code>&lt;/td>
&lt;td>22,9 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>crewai&lt;/code>&lt;/td>
&lt;td>19,1 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>google-adk&lt;/code>&lt;/td>
&lt;td>14,5 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pydantic-ai&lt;/code>&lt;/td>
&lt;td>8,0 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama-index&lt;/code>&lt;/td>
&lt;td>5,0 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agno&lt;/code>&lt;/td>
&lt;td>1,9 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>autogen-agentchat&lt;/code>&lt;/td>
&lt;td>0,6 M&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Por estrellas de GitHub el orden se invierte en cabeza: CrewAI va por 58,4 k, LangGraph por 38,1 k y el SDK de agentes de OpenAI por 28,6 k.&lt;/p>
&lt;p>Las descargas de PyPI están infladas por integración continua y por imágenes que reinstalan en cada build, así que son un indicador de presencia en pipelines, no de agentes en producción. Las estrellas miden interés, no despliegue. Con las dos cosas a la vez, LangGraph es la que aparece en más pipelines y la que tiene el camino de instrumentación más trabajado en Langfuse, así que es la que se instrumenta aquí. Lo que sigue sobre volumen aplica igual a cualquier plataforma que instrumente por callbacks de LangChain, porque el coste viene del modelo de callbacks, no del grafo.&lt;/p>
&lt;h2 id="cómo-entra-langgraph-en-langfuse">Cómo entra LangGraph en Langfuse&lt;/h2>
&lt;p>Conviene aclarar la ruta, porque el artículo anterior dejaba dos vías abiertas y aquí solo se usa una.&lt;/p>
&lt;p>&lt;code>langchain-core&lt;/code> 1.6.3 no importa OpenTelemetry en ningún sitio. La comprobación es directa sobre el wheel instalado y no devuelve ni un fichero. LangGraph tampoco emite spans OTLP por su cuenta. Lo que hay es el sistema de callbacks de LangChain, y el &lt;code>CallbackHandler&lt;/code> de Langfuse se engancha ahí.&lt;/p>
&lt;p>Ese handler no habla con la API de ingesta clásica. Crea observaciones con el SDK v4, que por dentro es OpenTelemetry: el &lt;code>LangfuseSpanProcessor&lt;/code> monta un &lt;code>OTLPSpanExporter&lt;/code> contra &lt;code>{base_url}/api/public/otel/v1/traces&lt;/code> (&lt;code>langfuse/_client/span_processor.py:123&lt;/code>). O sea que sí es OTLP, pero generado por el SDK, no por una instrumentación automática de terceros.&lt;/p>
&lt;p>La diferencia importa por una razón concreta que quedó pendiente en el artículo anterior. Como los datos pasan por las llamadas del SDK, la función de enmascarado clásica sí se aplica: entrada, salida y metadatos pasan por &lt;code>_process_media_and_apply_mask&lt;/code> antes de convertirse en atributos (&lt;code>langfuse/_client/span.py:534&lt;/code>). Con una instrumentación automática del tipo OpenInference u OpenLLMetry, los mensajes viajan en eventos de span y el enmascarado no los toca. Para un agente que maneja datos de cliente en las herramientas, esa diferencia decide la ruta.&lt;/p>
&lt;p>La contrapartida es que un handler de callbacks solo ve lo que pasa por LangChain. Las llamadas HTTP que haga una herramienta por su cuenta, la consulta a la base de datos, el &lt;code>subprocess&lt;/code>, nada de eso aparece. Y si instrumentas tu código con OpenTelemetry para que aparezca, te topas con el filtro del que habla el apartado de más abajo.&lt;/p>
&lt;h2 id="el-tipo-de-observación-se-decide-por-el-nombre-del-nodo">El tipo de observación se decide por el nombre del nodo&lt;/h2>
&lt;p>El primer artículo insistía en que v4 tiene diez tipos de observación y en que usar el correcto cambia lo que la interfaz sabe agrupar. El handler de LangChain emite seis de esos diez: &lt;code>tool&lt;/code>, &lt;code>retriever&lt;/code>, &lt;code>generation&lt;/code>, &lt;code>agent&lt;/code>, &lt;code>chain&lt;/code> y &lt;code>span&lt;/code>. Nunca emite &lt;code>event&lt;/code>, &lt;code>embedding&lt;/code>, &lt;code>evaluator&lt;/code> ni &lt;code>guardrail&lt;/code>.&lt;/p>
&lt;p>La asignación es directa para cuatro de ellos: callback de herramienta, tipo &lt;code>tool&lt;/code>; callback de recuperador, tipo &lt;code>retriever&lt;/code>; callback de modelo, tipo &lt;code>generation&lt;/code>. Para los callbacks de cadena, que es lo que son todos los nodos de un grafo de LangGraph, el criterio es este (&lt;code>langfuse/langchain/CallbackHandler.py:393&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="k">elif&lt;/span> &lt;span class="n">callback_type&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;chain&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="c1"># Detect if it&amp;#39;s an agent by examining class path or name&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">serialized&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">serialized&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">class_path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">serialized&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">if&lt;/span> &lt;span class="nb">any&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">part&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">part&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">class_path&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;agent&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">name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_langchain_run_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">serialized&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="s2">&amp;#34;agent&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">name&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="s2">&amp;#34;agent&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">return&lt;/span> &lt;span class="s2">&amp;#34;chain&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Medido sobre el mismo grafo con el nodo renombrado, y con el resto idéntico:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Nombre del nodo&lt;/th>
&lt;th>Llamadas a herramienta&lt;/th>
&lt;th>Observaciones&lt;/th>
&lt;th>Tipos&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>planificador&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11 generation, 33 chain, 10 tool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agente_planificador&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11 generation, 22 chain, 11 agent, 10 tool&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Once observaciones cambian de tipo por once caracteres en el nombre de una función. Esto tiene dos consecuencias prácticas. La primera es que cualquier cuadro de mando que cuente observaciones de tipo &lt;code>AGENT&lt;/code> está contando convenciones de nombres. La segunda es que el grafo de agente de la interfaz se dibuja cuando la traza contiene alguna observación de tipo distinto de &lt;code>span&lt;/code>, &lt;code>event&lt;/code> o &lt;code>generation&lt;/code>, y &lt;code>chain&lt;/code> cuenta, así que el dibujo sale igual. Lo que cambia es lo que puedes filtrar y agregar después.&lt;/p>
&lt;p>La recomendación operativa es aburrida y funciona: nombrar los nodos que representan una decisión del modelo con un nombre que contenga &lt;code>agent&lt;/code>, y dejar el resto como cadenas. Es una convención de nombres elevada a esquema de datos, que no es bonito, pero es lo que hay y es estable.&lt;/p>
&lt;h2 id="lo-que-cuesta-un-turno-medido">Lo que cuesta un turno, medido&lt;/h2>
&lt;p>El montaje es un grafo ReAct mínimo: un nodo que llama al modelo, un &lt;code>ToolNode&lt;/code> con una herramienta, una arista condicional con &lt;code>tools_condition&lt;/code> y un checkpointer en memoria. El modelo es &lt;code>GenericFakeChatModel&lt;/code> de langchain-core, que devuelve una lista fija de mensajes, así que no hace falta ni red ni GPU y el experimento es reproducible en cualquier portátil. El exportador es &lt;code>InMemorySpanExporter&lt;/code> de OpenTelemetry, pasado al cliente por el parámetro &lt;code>span_exporter&lt;/code>, de modo que las observaciones se cuentan sin servidor Langfuse delante.&lt;/p>
&lt;p>Resultado, con el nodo llamado &lt;code>planificador&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Llamadas a herramienta&lt;/th>
&lt;th>Observaciones&lt;/th>
&lt;th>generation&lt;/th>
&lt;th>chain&lt;/th>
&lt;th>tool&lt;/th>
&lt;th>Bytes de atributos&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3&lt;/td>
&lt;td>0&lt;/td>
&lt;td>4,2 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>9&lt;/td>
&lt;td>2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>1&lt;/td>
&lt;td>12,2 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>29&lt;/td>
&lt;td>6&lt;/td>
&lt;td>18&lt;/td>
&lt;td>5&lt;/td>
&lt;td>64,1 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11&lt;/td>
&lt;td>33&lt;/td>
&lt;td>10&lt;/td>
&lt;td>173,5 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20&lt;/td>
&lt;td>104&lt;/td>
&lt;td>21&lt;/td>
&lt;td>63&lt;/td>
&lt;td>20&lt;/td>
&lt;td>541,4 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>30&lt;/td>
&lt;td>154&lt;/td>
&lt;td>31&lt;/td>
&lt;td>93&lt;/td>
&lt;td>30&lt;/td>
&lt;td>1.108 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>40&lt;/td>
&lt;td>204&lt;/td>
&lt;td>41&lt;/td>
&lt;td>123&lt;/td>
&lt;td>40&lt;/td>
&lt;td>1.874 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La cuenta de observaciones es exactamente &lt;code>4 + 5n&lt;/code>. Las cinco por vuelta del bucle son: el nodo del modelo, la llamada al modelo, la arista condicional, el nodo de herramientas y la herramienta. Las cuatro fijas son el grafo raíz, la primera vuelta parcial y el cierre.&lt;/p>
&lt;p>Los bytes no siguen esa línea. De 10 a 20 pasos el volumen se multiplica por 3,1; de 20 a 40, por 3,5. Es el crecimiento cuadrático que se espera cuando cada uno de los &lt;code>n&lt;/code> pasos arrastra un historial de longitud proporcional a &lt;code>n&lt;/code>.&lt;/p>
&lt;p>El desglose por campo lo confirma. Con cuarenta pasos, de 1.709 KB medidos en esa ejecución, 1.502 KB están en &lt;code>langfuse.observation.input&lt;/code> y solo 56 KB en la salida. El 88 % de lo que se ingiere es el mismo historial repetido en distinto grado de avance. La observación individual más grande de ese turno son 22,2 KB, que está lejos del tope de 9,5 MB por span OTLP, así que el problema no es el límite de tamaño: es el agregado.&lt;/p>
&lt;p>Y dentro de la entrada hay un segundo multiplicador. El handler añade la definición de cada herramienta al campo de entrada de la generación, como mensajes con rol &lt;code>tool&lt;/code> (&lt;code>langfuse/langchain/CallbackHandler.py:1190&lt;/code>). Medido con herramientas de tres parámetros y una descripción de una línea:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Herramientas enlazadas&lt;/th>
&lt;th>Bytes del campo de entrada de una generación&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0&lt;/td>
&lt;td>34&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>398&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>1.854&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>3.674&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20&lt;/td>
&lt;td>7.334&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Son 364 bytes por herramienta, en cada generación, siempre. Un agente con veinte herramientas y cuarenta pasos ingiere 7,3 KB × 41 generaciones = 300 KB solo en esquemas de herramientas que no cambian nunca a lo largo del turno.&lt;/p>
&lt;h2 id="qué-significa-esto-para-clickhouse">Qué significa esto para ClickHouse&lt;/h2>
&lt;p>Aquí toca un descargo honesto. Comprimí el payload completo de esas ejecuciones con ZSTD nivel 3, que es lo que usa &lt;code>events_full&lt;/code>, y salieron ratios de entre 29× y 86×, mejorando cuanto más largo el turno. Ese número está inflado por lo sintético del banco: mi herramienta devuelve siempre la misma cadena, así que la redundancia es mayor que en producción.&lt;/p>
&lt;p>Lo que sí se sostiene es la forma. La repetición del historial es exactamente el patrón que la compresión columnar absorbe bien, así que el disco de ClickHouse no es donde duele. Donde duele es antes: en el ancho de banda hacia el endpoint OTLP, en la CPU del worker que descomprime y aplana, y en la cola de ingesta, que ven los bytes sin comprimir. El primer artículo dejaba anotado que la cola que satura primero es &lt;code>langfuse.queue.ingestion.depth&lt;/code>, y esta es la razón por la que satura.&lt;/p>
&lt;p>Cuenta rápida para dimensionar, con las cifras medidas y un turno medio de diez pasos:&lt;/p>
&lt;ul>
&lt;li>1.000 turnos al día son 54.000 observaciones y unos 170 MB sin comprimir al día.&lt;/li>
&lt;li>10.000 turnos al día son 540.000 observaciones y 1,7 GB sin comprimir al día.&lt;/li>
&lt;li>Si el turno medio sube de diez a veinte pasos, las observaciones se duplican y los bytes se triplican.&lt;/li>
&lt;/ul>
&lt;p>La sensibilidad al número de pasos es el parámetro que hay que vigilar, más que el número de usuarios. Un cambio de prompt que haga al agente dar tres vueltas más multiplica la factura de observabilidad sin que nadie haya tocado la infraestructura. El artículo 6 de la serie vuelve sobre esto con las tablas de sistema de ClickHouse para medir los bytes reales por observación en tu propia instalación, que es lo único que sustituye a esta estimación.&lt;/p>
&lt;h2 id="interrupciones-dos-trazas-donde-debería-haber-una">Interrupciones: dos trazas donde debería haber una&lt;/h2>
&lt;p>Un agente en producción con aprobación humana usa &lt;code>interrupt()&lt;/code>, devuelve el control, espera a una persona y reanuda con &lt;code>Command(resume=...)&lt;/code>. La pregunta operativa es si la traza sobrevive a esa pausa.&lt;/p>
&lt;p>El SDK trae maquinaria explícita para el caso. Hay un almacén de contextos de traza pendientes indexado por &lt;code>thread_id&lt;/code>, con un tope de 1.024 entradas y desalojo del más antiguo (&lt;code>MAX_PENDING_RESUME_TRACE_CONTEXTS&lt;/code>), y una función que reconoce un &lt;code>Command&lt;/code> con &lt;code>resume&lt;/code> y recupera el contexto guardado.&lt;/p>
&lt;p>Medido, no funciona en esta combinación de versiones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Caso&lt;/th>
&lt;th>¿Misma traza?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mismo objeto handler, &lt;code>invoke&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mismo objeto handler, &lt;code>stream&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Interrupción dentro de un subgrafo&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Handler nuevo para la reanudación&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>El motivo está en el código y es consistente. El contexto pendiente solo se guarda desde &lt;code>on_chain_error&lt;/code> cuando el run que falla es el raíz y el nivel resultante es &lt;code>DEFAULT&lt;/code>, que es lo que ocurre con las excepciones de control de flujo de LangGraph (&lt;code>CallbackHandler.py:866&lt;/code>). En LangGraph 1.2.11 la interrupción no sale por excepción desde la raíz: el grafo devuelve normalmente, con la interrupción en el valor de retorno. El run raíz termina por &lt;code>on_chain_end&lt;/code> y la rama que guarda el contexto nunca se ejecuta. La observación del nodo interrumpido sí queda bien: nivel &lt;code>DEFAULT&lt;/code> y el &lt;code>Interrupt&lt;/code> en el mensaje de estado, no marcado como error, que es el comportamiento correcto.&lt;/p>
&lt;p>El arreglo está verificado y no depende de que el SDK cambie:&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">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.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">trace_id&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">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&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">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&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;/code>&lt;/pre>&lt;/div>&lt;p>Con eso, la interrupción y la reanudación caen en la misma traza aunque el handler sea otro objeto y aunque la reanudación la atienda otra réplica, porque el identificador se deriva del &lt;code>thread_id&lt;/code> y no de un estado en memoria. Es la misma técnica que la documentación recomienda para unir varios agentes en una traza, aplicada a unir un agente consigo mismo.&lt;/p>
&lt;p>Con un matiz que hay que decidir a conciencia. Sembrar con el &lt;code>thread_id&lt;/code> a secas mete todos los turnos de esa conversación en una sola traza, que crece sin límite mientras la conversación siga viva. Si lo que quieres es una traza por turno con la reanudación pegada al turno correcto, siembra con &lt;code>thread_id&lt;/code> más el número de turno, y deja la agrupación de la conversación al identificador de sesión, que para eso está. Como en v4 no hay tabla de trazas, una traza enorme no es una fila enorme: es un filtro que devuelve muchas filas, y lo que sufre es la interfaz al abrirla.&lt;/p>
&lt;h2 id="hilo-sesión-usuario-y-lo-que-solo-se-lee-en-la-raíz">Hilo, sesión, usuario y lo que solo se lee en la raíz&lt;/h2>
&lt;p>El handler reconoce cinco claves de metadatos: &lt;code>langfuse_session_id&lt;/code>, &lt;code>langfuse_user_id&lt;/code>, &lt;code>langfuse_trace_name&lt;/code>, &lt;code>langfuse_tags&lt;/code> y &lt;code>langfuse_prompt&lt;/code>. Las cuatro primeras se leen solo cuando &lt;code>parent_run_id&lt;/code> es &lt;code>None&lt;/code>, es decir, únicamente en el run raíz (&lt;code>CallbackHandler.py:587&lt;/code>). Ponerlas en la configuración con la que invocas un subgrafo no hace nada.&lt;/p>
&lt;p>La vía recomendada y la que funciona sin sorpresas es el gestor de contexto:&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">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">propagate_attributes&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">propagate_attributes&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_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;turno-agente&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">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&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">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;produccion&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;soporte-n1&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">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">entrada&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>Lo que LangGraph aporta por su cuenta llega igualmente. En la ejecución medida aparecen como metadatos de observación &lt;code>langgraph_node&lt;/code>, &lt;code>langgraph_step&lt;/code>, &lt;code>langgraph_triggers&lt;/code>, &lt;code>langgraph_path&lt;/code>, &lt;code>langgraph_checkpoint_ns&lt;/code>, &lt;code>checkpoint_ns&lt;/code> y &lt;code>thread_id&lt;/code>, más &lt;code>ls_provider&lt;/code>, &lt;code>ls_model_type&lt;/code> y &lt;code>ls_integration&lt;/code>. El &lt;code>thread_id&lt;/code> sube además a metadato de traza.&lt;/p>
&lt;p>Eso está bien, pero recordando lo del primer artículo: en &lt;code>events_core&lt;/code> lo que está indexado es &lt;code>metadata_names&lt;/code>, o sea los nombres de las claves, no los valores. Filtrar por &lt;code>langgraph_node = &amp;quot;planificador&amp;quot;&lt;/code> es escaneo. Si vas a segmentar por nodo de forma habitual, el sitio correcto es el nombre de la observación, que es donde el nodo ya aparece, o una etiqueta, asumiendo que las etiquetas tampoco están indexadas. El campo barato para cortar por nodo en v4 es el propio nombre de la observación.&lt;/p>
&lt;p>El identificador de sesión merece una decisión explícita. &lt;code>thread_id&lt;/code> de LangGraph y &lt;code>session_id&lt;/code> de Langfuse son el mismo concepto y no se conectan solos. Mapearlos uno a uno es lo razonable, y además es lo que hace que la conversación entera sea navegable en la interfaz aunque cada turno sea una traza distinta.&lt;/p>
&lt;h2 id="el-filtro-que-tira-tus-propias-trazas">El filtro que tira tus propias trazas&lt;/h2>
&lt;p>Este es el hallazgo que más sorprende al montarlo. El procesador de spans de Langfuse no exporta todo lo que pasa por el &lt;code>TracerProvider&lt;/code>. Exporta lo que cumple una de estas tres condiciones (&lt;code>langfuse/_client/span_filter.py:104&lt;/code>):&lt;/p>
&lt;ol>
&lt;li>Lo creó el tracer del propio SDK de Langfuse.&lt;/li>
&lt;li>Tiene al menos un atributo que empieza por &lt;code>gen_ai&lt;/code>.&lt;/li>
&lt;li>Su ámbito de instrumentación coincide con uno de los treinta y cinco prefijos de una lista cerrada.&lt;/li>
&lt;/ol>
&lt;p>La lista incluye lo esperable y un poco más: &lt;code>openinference&lt;/code>, &lt;code>litellm&lt;/code>, &lt;code>haystack&lt;/code>, &lt;code>langsmith&lt;/code>, &lt;code>strands-agents&lt;/code>, &lt;code>pydantic-ai&lt;/code>, &lt;code>autogen-core&lt;/code>, &lt;code>vllm&lt;/code> y una veintena de instrumentaciones &lt;code>opentelemetry.instrumentation.*&lt;/code>.&lt;/p>
&lt;p>La consecuencia para un agente es directa. Si instrumentas tus herramientas con OpenTelemetry a mano para ver la llamada al ERP o la consulta a Postgres dentro de la traza del agente, esos spans se descartan en silencio, sin log y sin error. Hay tres salidas, por orden de limpieza:&lt;/p>
&lt;ul>
&lt;li>Crear esas observaciones con el SDK de Langfuse, con &lt;code>start_as_current_observation(as_type=&amp;quot;tool&amp;quot;)&lt;/code>, que es lo que el resto del artículo asume.&lt;/li>
&lt;li>Poner algún atributo &lt;code>gen_ai.*&lt;/code> en el span, lo cual es abusar de la convención para colar el span, así que solo si el span describe de hecho una interacción con un modelo.&lt;/li>
&lt;li>Mandar esos spans a tu colector de OpenTelemetry normal y no a Langfuse, y correlacionar por identificador de traza. Para una plataforma soberana con un colector propio, esta es la opción sensata: Langfuse guarda la parte de modelo, el colector guarda la parte de sistema, y el identificador de traza las une.&lt;/li>
&lt;/ul>
&lt;p>Que el comportamiento sea silencioso es lo que hace que se tarde una tarde en encontrarlo. Merece estar en el runbook.&lt;/p>
&lt;h2 id="el-modelo-y-el-coste-con-el-gateway-en-medio">El modelo y el coste con el gateway en medio&lt;/h2>
&lt;p>Un detalle que afecta a cualquiera que ponga LiteLLM delante, que es el montaje del que hablan &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">el par operativo&lt;/a> y la &lt;a href="https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/">multi-tenencia de GPU&lt;/a>.&lt;/p>
&lt;p>El nombre de modelo que el handler pone en la generación sale primero del metadato &lt;code>ls_model_name&lt;/code> y, si no está, de la serialización del componente (&lt;code>CallbackHandler.py:1297&lt;/code>). Con un &lt;code>ChatOpenAI&lt;/code> apuntando al gateway, ese nombre es el alias que hayas definido en LiteLLM, por ejemplo &lt;code>qwen-32b-interno&lt;/code>. Langfuse calcula el coste con su propia tabla de modelos, que no conoce ese alias, así que la generación llega con tokens y sin coste.&lt;/p>
&lt;p>Se arregla definiendo el modelo en Langfuse con ese mismo nombre y su precio por millón de tokens, que además es la única manera de que el coste refleje lo que cuesta tu GPU y no lo que cuesta la API de otro. Es trabajo de una tarde y evita que el panel de coste de la plataforma agéntica esté a cero para siempre.&lt;/p>
&lt;h2 id="cómo-queda-esto-en-código">Cómo queda esto en código&lt;/h2>
&lt;p>Lo mínimo que hay que tener puesto para que un agente LangGraph produzca trazas útiles en Langfuse v4:&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">os&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="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 class="p">,&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&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.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&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">HumanMessage&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.graph.message&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">add_messages&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.prebuilt&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ToolNode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools_condition&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"># 1. Cliente. El enmascarado se aplica porque los datos pasan por el SDK.&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">mask&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data&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">return&lt;/span> &lt;span class="n">redactar_pii&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&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">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">public_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;LANGFUSE_PUBLIC_KEY&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">secret_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;LANGFUSE_SECRET_KEY&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">host&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;LANGFUSE_HOST&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">mask&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">mask&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">environment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;pro&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># minusculas, guiones, 40 caracteres, no empezar por &amp;#34;langfuse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sample_rate&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">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_SAMPLE_RATE&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;1.0&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="c1"># 2. Grafo. El nodo de decision lleva &amp;#34;agent&amp;#34; en el nombre a proposito.&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">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">messages&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">add_messages&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;agente_planificador&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">nodo_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">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;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolNode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HERRAMIENTAS&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;agente_planificador&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;agente_planificador&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools_condition&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;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;agente_planificador&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">grafo&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">compile&lt;/span>&lt;span class="p">(&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3. Por turno: traza determinista sembrada con hilo y turno, sesion = hilo.&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">ejecutar_turno&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">turno&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">texto&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="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">trace_id&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">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&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">thread_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">turno&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">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&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="n">cfg&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;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">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;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="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">propagate_attributes&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_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;turno-agente&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">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">usuario&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&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">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;agente&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;soporte&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">salida&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">app&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="n">texto&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="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">salida&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"># 4. Reanudacion tras aprobacion humana: mismo seed, misma traza.&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">reanudar_turno&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">turno&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">decision&lt;/span>&lt;span class="p">):&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">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">trace_id&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">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&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">thread_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">turno&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">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&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="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&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">handler&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">return&lt;/span> &lt;span class="n">app&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="n">decision&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 5. Puntuar el turno. Los scores no viajan por OTLP: van por su propia ruta.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">get_client&lt;/span>&lt;span class="p">()&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;resuelto&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="mi">1&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dos notas sobre lo que no está en el ejemplo. &lt;code>sample_rate&lt;/code> es muestreo de cabecera y por traza, así que muestrea turnos completos, que es lo que se quiere: media traza de agente no sirve para nada. Y si el proceso es un servidor web, el cliente se construye una vez al arrancar, no por petición, mientras que el handler puede ser por turno sin problema, porque con el identificador sembrado ya no guarda estado que haga falta conservar.&lt;/p>
&lt;h2 id="checklist-de-instrumentación-de-un-agente">Checklist de instrumentación de un agente&lt;/h2>
&lt;ul>
&lt;li>Los nodos que representan una decisión del modelo llevan &lt;code>agent&lt;/code> en el nombre. El resto, no.&lt;/li>
&lt;li>Cada turno tiene un identificador de traza determinista sembrado con hilo y número de turno.&lt;/li>
&lt;li>El &lt;code>thread_id&lt;/code> de LangGraph va como &lt;code>session_id&lt;/code> de Langfuse, siempre.&lt;/li>
&lt;li>Los atributos de traza se ponen con &lt;code>propagate_attributes&lt;/code>, no como metadatos en un subgrafo.&lt;/li>
&lt;li>El alias de modelo del gateway está dado de alta en la tabla de modelos de Langfuse, con precio.&lt;/li>
&lt;li>Las herramientas que hacen trabajo fuera de LangChain se instrumentan con el SDK de Langfuse, no con OpenTelemetry a pelo.&lt;/li>
&lt;li>Hay una alerta sobre la profundidad de la cola de ingesta, y se conoce el número de pasos medio por turno.&lt;/li>
&lt;li>Se ha medido, en la propia instalación, cuántas observaciones y cuántos bytes produce un turno real. Las cifras de este artículo son de un banco sintético.&lt;/li>
&lt;li>El muestreo está puesto por variable de entorno, para poder bajarlo sin desplegar código.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas">Trampas&lt;/h2>
&lt;p>&lt;strong>El tipo AGENT no significa que haya un agente.&lt;/strong> Significa que alguien escribió &lt;code>agent&lt;/code> en el nombre. Y al revés: un grafo entero sin esa cadena no produce ni una observación de tipo agente.&lt;/p>
&lt;p>&lt;strong>Poner &lt;code>langfuse_session_id&lt;/code> en los metadatos de un subgrafo no hace nada.&lt;/strong> Solo se leen en el run raíz.&lt;/p>
&lt;p>&lt;strong>Una interrupción parte la traza en dos.&lt;/strong> Salvo que siembres el identificador. Y si trabajas con varias réplicas, el estado en memoria del handler no te va a salvar nunca, porque la reanudación puede caer en otro pod.&lt;/p>
&lt;p>&lt;strong>Tus spans de OpenTelemetry no llegan.&lt;/strong> El filtro por defecto los descarta sin avisar si el ámbito no está en la lista y no hay atributos &lt;code>gen_ai.*&lt;/code>.&lt;/p>
&lt;p>&lt;strong>El número que hay que vigilar no es el de usuarios, es el de pasos por turno.&lt;/strong> El volumen crece con el cuadrado de los pasos, y los pasos los decide el prompt, no la infraestructura.&lt;/p>
&lt;p>&lt;strong>Guardas megabytes y la interfaz te enseña mil caracteres.&lt;/strong> El truncado de la vista materializada a doscientos caracteres y el tope de lectura de mil siguen ahí. Pagas la ingesta completa y ves un recorte, salvo que abras la observación concreta.&lt;/p>
&lt;p>&lt;strong>El coste sale a cero con el gateway delante.&lt;/strong> Alias de modelo desconocido para la tabla de precios.&lt;/p>
&lt;p>&lt;strong>&lt;code>flush_at&lt;/code> por encima del tamaño de cola revienta al construir el cliente.&lt;/strong> Con un &lt;code>ValueError&lt;/code> de OpenTelemetry que no menciona Langfuse por ningún lado.&lt;/p>
&lt;h2 id="la-serie-los-diez-artículos">La serie: los diez artículos&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">Qué entra en una traza&lt;/a>: modelo de datos de la versión 4, límites, precedencias, scores, enmascarado e índices.&lt;/li>
&lt;li>Poner LangGraph delante (este artículo): la instrumentación de una plataforma agéntica y el coste medido de un turno.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-agentes-reales-odr-gptr/">Dos agentes conocidos instrumentados&lt;/a>: Open Deep Research y GPT Researcher, con las cifras medidas de una petición real.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-migrar-sin-ventana/">Migrar de la versión 3 a la 4 sin ventana&lt;/a>: los tres pasos del modo de escritura, las migraciones de fondo reanudables y dónde está el punto de no retorno del retroceso.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-colas-del-worker/">Las colas del worker&lt;/a>: el mapa de las treinta y nueve, qué pool dedicar a cada grupo, los interruptores por cola, el particionado y la concurrencia.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-capacidad-clickhouse/">Capacidad y coste real de ClickHouse&lt;/a>: cómo medir los bytes por observación con las tablas del sistema, la diferencia entre la tabla completa y la de listados, y el coste de fusión de los índices de texto completo.&lt;/li>
&lt;li>Retención, borrado y protección de datos: por qué un borrado no libera disco, el limpiador de máscaras que viene desactivado, la cola de borrados pendientes y el ciclo de vida de S3 que hay que implementar a mano.&lt;/li>
&lt;li>Copias de seguridad y recuperación cruzada: orden de restauración entre Postgres, ClickHouse y el almacenamiento de objetos, qué rompe cada desajuste, y hasta dónde llega la reproducción de eventos.&lt;/li>
&lt;li>Runbook de saturación: qué alertar de las métricas de cola, las sondas de atasco, el drenado por el endpoint de preparación y la cola de mensajes fallidos.&lt;/li>
&lt;li>Sacar los datos fuera: la integración de almacenamiento de objetos a Parquet, las exportaciones por lotes y la API de métricas, para montar el lago de datos.&lt;/li>
&lt;/ol>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">Langfuse v4: qué entra de verdad en una traza&lt;/a>: el modelo de datos que explica por qué el volumen importa tanto.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse: el par operativo&lt;/a>: las cuatro colas donde se pierde la traza entre el gateway y el backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/">El gateway no vive solo&lt;/a>: por qué el usuario de la traza no es el de Keycloak.&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>: el otro lado del mismo problema de volumen.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/temporal-durable-execution-coste-agentes/">Ejecución duradera y coste de los agentes&lt;/a>: qué pasa cuando el agente sobrevive al proceso que lo lanzó.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">Aislar agentes de IA por cliente&lt;/a>: el aislamiento que hay debajo de todo esto.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">Evals: la capa después del tracing&lt;/a>: dónde encajan los scores de un turno de agente.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Medición propia del 13 de septiembre de 2026: LangGraph 1.2.11, langchain-core 1.6.3 y SDK de Python de Langfuse 4.15.2, con &lt;code>InMemorySpanExporter&lt;/code> de OpenTelemetry y &lt;code>GenericFakeChatModel&lt;/code>, sin red.&lt;/li>
&lt;li>Código del SDK de Langfuse 4.15.2: &lt;code>langfuse/langchain/CallbackHandler.py&lt;/code>, &lt;code>langfuse/_client/span_filter.py&lt;/code>, &lt;code>langfuse/_client/span_processor.py&lt;/code>, &lt;code>langfuse/_client/span.py&lt;/code>.&lt;/li>
&lt;li>Descargas de PyPI de los últimos treinta días vía &lt;a href="https://pypistats.org/">pypistats&lt;/a>, consultadas el 13 de septiembre de 2026.&lt;/li>
&lt;li>Estrellas de GitHub de &lt;a href="https://github.com/langchain-ai/langgraph">LangGraph&lt;/a>, &lt;a href="https://github.com/crewAIInc/crewAI">CrewAI&lt;/a> y &lt;a href="https://github.com/openai/openai-agents-python">OpenAI Agents SDK&lt;/a>, consultadas el 13 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/frameworks/langgraph">Integración de Langfuse con LangGraph&lt;/a> y &lt;a href="https://langfuse.com/docs/observability/features/agent-graphs">grafos de agente&lt;/a>, consultados el 13 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item></channel></rss>