Saltar al contenido

Capítulo 8 de 18

Compaction y continuidad sin perder trabajo

Reduce el contexto con una frontera explícita, conserva el historial original y distingue resumir de restaurar.

#Por qué un resumen puede romper un agente

Tras muchas vueltas, enviar toda la trayectoria deja de ser viable. Compaction sustituye una parte por una representación más breve para futuras inferencias. Es una transformación con pérdida: puede omitir un requisito, convertir una hipótesis en hecho o olvidar que una operación ya ocurrió.

Por eso el resumen no reemplaza el historial durable ni la tabla de operaciones. Ayuda al modelo a orientarse; el runtime sigue consultando hechos operativos. Un texto que dice «el correo se envió» no debe autorizar que una operación pendiente se marque como completada.

#Diseñar el resumen por la tarea que deberá continuar

Un resumen genérico como «hemos investigado la incidencia y seguimos trabajando» consume poco y conserva poco. Necesitas objetivo vigente, restricciones, decisiones y sus razones, evidencia localizada, incertidumbres y siguientes comprobaciones.

{
  "schema_version": 1,
  "covers_through": 80,
  "goal": "Diagnosticar el fallo de conexión sin modificar producción",
  "constraints": ["solo lectura", "no incluir secretos en logs"],
  "verified": [{"claim": "api resuelve db", "source_seq": 42}],
  "hypotheses": [{"claim": "puerto incorrecto", "source_seq": 61}],
  "pending": ["comparar puerto configurado con el listener de db"],
  "artifact_refs": ["logs/api@version-9"]
}

source_seq permite volver a la evidencia. verified no significa «lo afirmó el modelo con seguridad»: debe contener hechos respaldados por observaciones. Si el resumen lo genera otro modelo, valida estructura y referencias y evita que él mismo eleve una hipótesis a hecho sin evidencia.

#La frontera evita perder mensajes nuevos

Supón que comienzas a resumir hasta la secuencia 80. Mientras el modelo redacta, llegan los eventos 81–85. Si sustituyes «todo lo anterior» por el resumen, borrarás cinco novedades. La cobertura debe ser explícita:

contexto nuevo = reglas + objetivo + resumen hasta 80 + trayectoria desde 81

La instalación del resumen guarda su cobertura y no borra la cola posterior. Si hay compactions concurrentes, una actualización condicional comprueba qué resumen anterior sigue activo. Un resultado más antiguo no debe sobrescribir uno que ya cubre un tramo mayor.

El corte también debe respetar las unidades del protocolo. Si la secuencia 80 contiene una llamada y su resultado está en 81, corta antes de la llamada o después del resultado. Resumir la llamada y enviar el resultado aislado puede romper el adaptador o perder su significado.

#Un caso completo: dos resúmenes y una novedad

Vamos a reproducir una intercalación pequeña, sin llamar a un modelo. El resumen activo cubre hasta 2. Dos trabajadores lo leen: A prepara un resumen hasta 4 y B, otro hasta 3. Antes de instalar ninguno llega el mensaje 5. A termina primero; B debe ser rechazado porque partió de una base que ya cambió.

activo: resumen 1, cobertura 2
           ├─ A prepara hasta 4 ── instala resumen 2
           └─ B prepara hasta 3 ─────────────────── rechazar base obsoleta
historial: 1  2  3  4  [5 llega durante la preparación]
contexto final: resumen 2 + mensaje 5

Los tres bloques siguientes forman un programa completo de Python 3.12 o posterior. Usan SQLite y un directorio temporal; no requieren paquetes, servicios ni archivos descargables. Aquí cada fila del historial es un hecho completo. En una conversación con llamadas de herramienta elegirías una frontera que incluya todos los resultados, como acabamos de explicar.

#Guardar originales y versiones por separado

Conservaremos todos los originales en history, las versiones del resumen en summaries y un único puntero al resumen activo en runs. Cambiar ese puntero no borra mensajes. previous_id permite explicar de qué resumen partió cada versión.

import json
import sqlite3
import tempfile
from contextlib import contextmanager
from pathlib import Path


@contextmanager
def transaction(db):
    db.execute("BEGIN IMMEDIATE")
    try:
        yield
        db.execute("COMMIT")
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise


def open_db(path):
    db = sqlite3.connect(path, autocommit=True)
    db.execute("PRAGMA foreign_keys=ON")
    return db


SCHEMA = """
CREATE TABLE runs (
    id TEXT PRIMARY KEY,
    active_summary_id INTEGER REFERENCES summaries(id)
);
CREATE TABLE history (
    run_id TEXT NOT NULL REFERENCES runs(id),
    seq INTEGER NOT NULL,
    text TEXT NOT NULL,
    PRIMARY KEY (run_id, seq)
);
CREATE TABLE summaries (
    id INTEGER PRIMARY KEY,
    run_id TEXT NOT NULL REFERENCES runs(id),
    coverage INTEGER NOT NULL,
    body TEXT NOT NULL,
    previous_id INTEGER REFERENCES summaries(id)
);
"""

Reutilizamos la transacción del capítulo de persistencia: yield cede la ejecución al cuerpo del with; después continúa en COMMIT si terminó bien o recibe la excepción para ejecutar ROLLBACK. BEGIN IMMEDIATE reserva el turno de escritura de SQLite. La preparación del resumen ocurre fuera de ese turno.

#Validar referencias heredadas y cambiar el puntero

El candidato puede conservar una referencia al mensaje 1 del resumen anterior, aunque el material nuevo solo incluya 3 y 4. Por eso no validamos sus referencias únicamente contra el tramo nuevo. Consultamos originales del mismo run con secuencia no posterior al corte. Encontrar una fila demuestra que la referencia existe; no demuestra que la frase redactada esté respaldada por ella. Esa revisión semántica sigue siendo necesaria.

def install(db, run_id, base_id, cut, candidate):
    if not isinstance(candidate, list):
        raise ValueError("El resumen debe ser una lista de afirmaciones")
    with transaction(db):
        base = db.execute(
            "SELECT coverage FROM summaries WHERE id=? AND run_id=?",
            (base_id, run_id),
        ).fetchone()
        boundary = db.execute(
            "SELECT 1 FROM history WHERE run_id=? AND seq=?",
            (run_id, cut),
        ).fetchone()
        if base is None or cut <= base[0] or boundary is None:
            raise ValueError("La cobertura no extiende una base válida")
        for claim in candidate:
            if (not isinstance(claim, dict)
                    or set(claim) != {"text", "source_seq"}
                    or not isinstance(claim["text"], str)
                    or not claim["text"].strip()
                    or type(claim["source_seq"]) is not int):
                raise ValueError("Afirmación mal formada")
            source = db.execute(
                "SELECT 1 FROM history WHERE run_id=? AND seq=? AND seq<=?",
                (run_id, claim["source_seq"], cut),
            ).fetchone()
            if source is None:
                raise ValueError("Referencia ajena o fuera de cobertura")
        created = db.execute(
            "INSERT INTO summaries(run_id, coverage, body, previous_id) "
            "VALUES (?, ?, ?, ?)",
            (run_id, cut, json.dumps(candidate), base_id),
        )
        changed = db.execute(
            "UPDATE runs SET active_summary_id=? "
            "WHERE id=? AND active_summary_id=?",
            (created.lastrowid, run_id, base_id),
        )
        if changed.rowcount != 1:
            raise RuntimeError("La base cambió; prepara otro resumen")

fetchone() devuelve una fila como tupla o None si no hay coincidencia. base[0] lee su cobertura. lastrowid recupera la identidad de la fila recién insertada; rowcount dice cuántas filas cambió el UPDATE. Los ? son parámetros, no texto SQL construido con datos del modelo.

La comparación active_summary_id=base_id implementa compare-and-swap o CAS: «cambia el valor solo si sigue siendo el que leí». Si falla, la excepción revierte también la inserción del candidato; no dejamos una versión publicada a medias. El corte lo decide el runtime a partir de unidades completas, no un campo que el modelo pueda inventar.

#Observar aceptación, rechazo y reconstrucción

En este ejemplo el resumen inicial contiene la restricción del mensaje 1. A y B se representan con listas redactadas a mano: así aislamos la corrección del almacenamiento de la calidad lingüística. Las llamadas en el orden indicado reproducen una carrera de manera determinista, sin depender de la velocidad de dos hilos.

with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / "compaction.db"
    db = open_db(path)
    db.executescript(SCHEMA)
    db.execute("INSERT INTO runs VALUES ('r-1', NULL)")
    originals = [
        (1, "La ejecución es de solo lectura."),
        (2, "Empieza la investigación de red."),
        (3, "La API resuelve el nombre db."),
        (4, "Falta comprobar el puerto configurado."),
    ]
    db.executemany("INSERT INTO history VALUES ('r-1', ?, ?)", originals)
    initial = [{"text": "Solo lectura", "source_seq": 1}]
    cursor = db.execute(
        "INSERT INTO summaries(run_id, coverage, body) VALUES ('r-1', 2, ?)",
        (json.dumps(initial),),
    )
    base_id = cursor.lastrowid
    db.execute("UPDATE runs SET active_summary_id=? WHERE id='r-1'", (base_id,))
    a = initial + [{"text": "El nombre db se resuelve", "source_seq": 3}]
    b = initial + [{"text": "Continuar diagnóstico DNS", "source_seq": 2}]

    # Llega mientras A y B preparan sus candidatos, después del corte de A.
    db.execute("INSERT INTO history VALUES ('r-1', 5, 'El usuario pide revisar TLS.')")
    install(db, "r-1", base_id, 4, a)
    try:
        install(db, "r-1", base_id, 3, b)
    except RuntimeError:
        print("B rechazado: su base ya no es la activa")
    else:
        raise AssertionError("Se aceptó un candidato obsoleto")
    db.close()

    # Reabrir comprueba qué conservamos, no solo nuestras variables en memoria.
    db = open_db(path)
    active_id, coverage, body = db.execute(
        "SELECT s.id, s.coverage, s.body FROM runs r "
        "JOIN summaries s ON s.id=r.active_summary_id WHERE r.id='r-1'"
    ).fetchone()
    tail = db.execute(
        "SELECT seq, text FROM history WHERE run_id=? AND seq>? ORDER BY seq",
        ("r-1", coverage),
    ).fetchall()
    assert coverage == 4 and json.loads(body)[0]["source_seq"] == 1
    assert [seq for seq, text in tail] == [5]
    assert db.execute("SELECT COUNT(*) FROM history").fetchone()[0] == 5
    assert db.execute("SELECT COUNT(*) FROM summaries").fetchone()[0] == 2
    print("Referencia heredada 1 válida; cobertura 4; cola conservada:", tail[0][0])
    db.close()

El resultado muestra el rechazo de B y la conservación del mensaje 5. Hay dos resúmenes —original y A— y cinco hechos originales. El contexto siguiente se forma con el resumen activo y esa cola; no con history cortado según la hora a la que terminó la inferencia.

El caso prueba instalación y recuperación de la selección. No prueba la calidad de un resumen generado ni una caída durante una escritura del motor. En un servicio real añade el estado del run a las condiciones de activación: conservar un resumen de una ejecución cancelada puede ser útil para consulta, pero esta operación no debe reactivarla.

#Los resúmenes de resúmenes acumulan errores

Resumir repetidamente solo el resumen anterior puede degradar los detalles. Conserva referencias a originales y refresca información crítica desde datos estructurados: permisos vigentes, objetivo actual, operaciones pendientes y requisitos de entrega. Esas piezas no deberían depender exclusivamente de la memoria lingüística.

También puedes mantener una nota de trabajo estructurada que se actualice al confirmar hitos. Esto reduce el coste de compaction, pero no elimina la necesidad de comprobarla: si el agente escribe «test aprobado» sin registrar comando, versión y resultado, la nota pierde valor.

#Snapshot, checkpoint y resumen

Pieza Sirve para No contiene necesariamente
Resumen Reducir la entrada al modelo Archivos ni estado íntegro
Checkpoint Establecer una frontera recuperable Todo el historial en un solo objeto
Snapshot Capturar una representación del estado en un punto Procesos vivos, sockets o efectos externos

Puedes guardar un snapshot del estado de un run hasta el evento 80 y después aplicar eventos 81–85. Eso acelera consultas. Un resumen hasta 80 es una interpretación para el modelo y no necesariamente permite reconstruir ese mismo estado.

#Comprobar continuidad con información fácil de perder

Diseña un caso donde al principio se diga «solo lectura» y muchos pasos después aparezca una herramienta de escritura. Tras compactar, la restricción debe seguir en la entrada y en la política del ejecutor. Añade otro donde una hipótesis se refute: el resumen debe conservar la conclusión actual y la referencia que la cambió.

Para comprobar calidad compara decisiones sobre el contexto original y el compactado en tareas controladas. No exijas texto idéntico; exige que se conserven restricciones, operaciones conocidas y evidencia necesaria. Mide también tamaño y latencia: un resumen que casi ocupa lo mismo no resuelve el problema.

#Qué hacer cuando el resumen no es fiable

Si falta una referencia, no inventes su contenido. Recupera el tramo original o marca la incertidumbre. Si la herramienta necesaria ya no existe, la continuidad puede requerir intervención o una nueva estrategia; «continuar» no significa fingir que nada cambió.

La compaction debe ser reversible respecto al acceso a evidencia: el agente puede volver a consultar originales aunque no quepan en la entrada actual. Esa propiedad transforma una pérdida de detalle en una consulta adicional, en lugar de una pérdida definitiva.

#Referencias

  • sqlite3, Python: transacciones utilizadas para instalar una versión de resumen de forma consistente.
  • Building effective agents, Anthropic: interacción iterativa con evidencia. El formato y algoritmo de compaction aquí descritos son decisiones de diseño del curso, no una API del proveedor.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar