<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Fastapi on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/fastapi/</link><description>Recent content in Fastapi on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Thu, 17 Sep 2026 03:30:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/fastapi/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>