Memoria gobernada para agentes en .NET 10: cuarentena, TTL y derecho al olvido
Cómo diseñar memoria persistente para agentes con procedencia, cuarentena, concurrencia optimista, sustitución, caducidad, recuperación acotada y reproducción de eventos en ASP.NET Core.
Para un agente con memoria, el fallo grave no es olvidar un dato. El grave es recordar un dato falso con total seguridad y de forma indefinida.
Una función de memoria ingenua parece sencilla. Guardas trozos de conversación, calculas embeddings, recuperas más tarde texto parecido y lo pegas en el siguiente prompt. Esa receta convierte sin hacer ruido cada lapsus, cada preferencia desfasada, cada resultado de herramienta comprometido y cada instrucción inyectada en una influencia a largo plazo sobre el agente. La memoria persistente es más que recuperación. Es un ciclo de vida de datos con requisitos de autoridad, procedencia, revisión, aislamiento, retención y borrado.
El diseño que sigue es un servicio en .NET 10 en el que un recuerdo tiene que ganarse el derecho a influir en el comportamiento del agente. Las entradas no fiables empiezan en cuarentena. Quienes escriben usan concurrencia optimista. Las correcciones sustituyen a las creencias antiguas. Los valores de tiempo de vida (TTL) barren el conocimiento obsoleto. Lo que puede devolver la recuperación está limitado por tenant, agente, propósito, sujeto y un presupuesto de tokens. Olvidar a un sujeto genera un evento de ciclo de vida duradero.
Por qué la búsqueda por similitud no basta
Un artículo de Microsoft Security de junio de 2026 sobre cómo proteger la memoria de la IA describe una amenaza seria: un atacante puede plantar recuerdos que dirigen el comportamiento mucho después de que el usuario haya olvidado la interacción original. Plantea la defensa por capas: almacenamiento, recuperación, interacción con el modelo y control del usuario.
PlugMem, de Microsoft Research, llega a una conclusión complementaria sobre la fiabilidad. Los registros de interacción en bruto se vuelven largos, repetitivos y de poco valor, así que una memoria útil debería convertir la experiencia en hechos compactos y habilidades reutilizables. Otras investigaciones de 2026 estudian estructuras de memoria específicas por tarea, consolidación, olvido, reconsolidación y representaciones que equilibran abstracción y detalle concreto. Todo apunta en la misma dirección: guardar más y recuperar por similitud no constituye una arquitectura suficiente.
Memoria frente a contexto
Un compilador de contexto decide qué fuentes entran en una llamada al modelo. La memoria persistente está antes en el pipeline y vive más tiempo. El contexto pregunta qué debe ver el modelo ahora mismo. La memoria plantea otras preguntas:
- ¿Qué escritor tenía autoridad para registrarlo?
- ¿A qué tenant, agente, usuario y propósito pertenece?
- ¿Alguien lo vio directamente, lo supuso o lo confirmó?
- ¿Cómo se resuelve un choque con un dato más reciente?
- ¿En qué momento debería caducar?
- ¿Puede la persona a la que describe exigir que se elimine?
- ¿Reaparecerá el mismo estado tras un reinicio?
Esas respuestas pertenecen al almacenamiento y al código de políticas, nunca a un prompt.
Estructura del servicio
El ejemplo es un servicio ASP.NET Core construido sobre un núcleo con event sourcing independiente del framework, organizado en una biblioteca de núcleo, un proyecto de API y un proyecto de pruebas.
GovernedAgentMemory/
├── src/GovernedAgentMemory.Core/
├── src/GovernedAgentMemory.Api/
├── tests/GovernedAgentMemory.Tests/
├── docs/verification.md
├── Dockerfile
└── GovernedAgentMemory.slnx
Cada escritura sigue un pipeline: validar propósito, procedencia, TTL y versión, y luego bifurcar según la confianza. Las escrituras no fiables van a cuarentena y las fiables pasan a estar activas.
flowchart LR
I[Memory write] --> V[Validate purpose, provenance, TTL, version]
V --> Q{Trust}
Q -->|Untrusted| X[Quarantine]
Q -->|Approved| A[Active memory]
X --> P[Explicit promotion]
P --> A
A --> E[(Append-only event log)]
E --> R[Deterministic projection]
R --> S[Scoped retrieval]
F[Forget, expire, supersede] --> E
La implementación de referencia guarda los eventos como JSON Lines, de modo que el ciclo de vida se puede inspeccionar y reproducir tras un reinicio. A escala real, el escritor de archivos daría paso a un almacén de eventos transaccional o a una base de datos, pero las transiciones de estado serían idénticas.
Qué contiene una entrada de memoria
Una entrada guarda mucho más que el contenido.
public sealed record MemoryItem(
string Id,
string TenantId,
string AgentId,
string SubjectId,
string Purpose,
MemoryKind Kind,
string Content,
string ContentHash,
string Provenance,
MemoryTrust Trust,
double Confidence,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
long Version,
MemoryStatus Status,
string? SupersedesId);
TenantId, AgentId, SubjectId y Purpose son límites separados. Una preferencia de soporte no debería convertirse automáticamente en memoria de ventas, y un dato aprendido por el agente de un tenant nunca debe aparecer en otro.
Provenance (procedencia) anota de dónde vino el conocimiento, por ejemplo de una entidad del CRM o de un resultado de herramienta firmado. Una cadena URI por sí sola no demuestra nada, así que la ingesta en producción tiene que verificar la identidad de la fuente y conservar una versión inmutable de ella. Kind distingue notas de trabajo, hechos, habilidades y preferencias, que no deberían compartir una misma política de retención o recuperación. ContentHash vincula las evidencias del ciclo de vida con los bytes originales sin copiar el contenido en cada registro operativo.
Cuarentena y promoción
El estado se deriva de la confianza en el momento de la escritura.
var status = command.Trust == MemoryTrust.Untrusted
? MemoryStatus.Quarantined
: MemoryStatus.Active;
La escritura completa construye el elemento con todos los campos rellenados.
var item = new MemoryItem(
command.Id,
command.TenantId,
command.AgentId,
command.SubjectId,
command.Purpose,
command.Kind,
command.Content,
Hash(command.Content),
command.Provenance,
command.Trust,
command.Confidence,
existing?.CreatedAt ?? now,
command.ExpiresAt,
(existing?.Version ?? 0) + 1,
status,
command.SupersedesId);
Las entradas en cuarentena se almacenan, pero quedan fuera de la recuperación. No es lo mismo que descartarlas: un proceso autorizado puede revisar la procedencia, sopesar evidencias que la corroboren y decidir si promoverlas.
La promoción es una transición explícita. Necesita la versión actual y cambia la confianza a Verified (verificada). Primero se compara la versión.
if (current.Version != expectedVersion)
throw new MemoryConflictException(
"Expected version does not match the current memory version.");
Luego se comprueba el estado, porque solo las entradas en cuarentena pueden promoverse, y se genera la copia promovida.
if (current.Status != MemoryStatus.Quarantined)
throw new InvalidOperationException(
"Only quarantined memory can be promoted.");var promoted = current with
{
Status = MemoryStatus.Active,
Trust = MemoryTrust.Verified,
Version = current.Version + 1
};
En producción, quien hace la promoción debe estar autorizado. Un modelo no debería promover su propia memoria solo por afirmar que está seguro.
Escritores concurrentes
La memoria cambia mientras los agentes trabajan. Dos flujos podrían leer la versión 3 y tratar ambos de actualizarla. Aceptar las dos en silencio haría que el ganador dependiera de la latencia de la red.
Por eso cada comando de escritura lleva un ExpectedVersion, que el almacén compara con la proyección actual. Primero va la búsqueda.
MemoryItem? existing;
Después, dentro de un lock, el almacén comprueba que la versión guardada coincide con la esperada y, si no, lanza un conflicto.
lock (stateLock)
items.TryGetValue(command.Id, out existing);if ((existing?.Version ?? 0) != command.ExpectedVersion)
{
throw new MemoryConflictException(
"Expected version does not match the current memory version.");
}
Un escritor desfasado recibe un conflicto y tiene que recargar, replantearse y reintentar a conciencia. Esto importa sobre todo cuando la actualización de un agente se basa en una visión anticuada de la tarea.
Las escrituras tienen además una clave de idempotencia. Si un cliente agota el tiempo de espera después de que el append haya tenido éxito, reenviar el mismo comando devuelve el resultado anterior en lugar de crear un segundo recuerdo.
Los despliegues con varias instancias necesitan unicidad y compare-and-swap en el almacén transaccional, porque un lock dentro del proceso no puede coordinarse con otro nodo.
Correcciones sin perder el historial
Supongamos que el sistema recuerda que un cliente prefiere el soporte por teléfono y que más adelante evidencias verificadas muestran que prefiere el email. Sobrescribir el contenido borra el historial de decisiones. Mantener ambos activos produce una contradicción.
En su lugar, el nuevo recuerdo apunta al antiguo mediante SupersedesId, y al aplicar su evento la entrada anterior queda marcada como Superseded (sustituida).
if (memoryEvent.Type == "upsert" &&
memoryEvent.Item.SupersedesId is not null &&
items.TryGetValue(memoryEvent.Item.SupersedesId, out var previous))
{
items[previous.Id] = previous with
{
Status = MemoryStatus.Superseded,
Version = previous.Version + 1
};
}
La búsqueda solo ve el recuerdo corregido, mientras que la reproducción sigue mostrando por qué acciones anteriores pudieron basarse en la creencia antigua. El servicio también confirma que la entrada sustituida comparte tenant, agente y propósito. Los hechos complejos pueden quedarse grandes para un único puntero de sustitución. Los sistemas en producción podrían necesitar identidad a nivel de afirmación, rangos de validez temporal, grupos de contradicciones y conjuntos de evidencias.
Caducidad y olvido
Una memoria que nunca caduca se vuelve obsoleta, cara y arriesgada. Cada escritura debe llevar una caducidad dentro del tiempo de vida máximo configurado, y la búsqueda omite cualquier entrada cuyo ExpiresAt ya haya pasado. Además, un worker en segundo plano escribe periódicamente eventos Expired (caducada) explícitos, de modo que el estado reproducido refleja el ciclo de vida en lugar de depender solo de filtrar por la hora actual.
Olvidar a un sujeto escribe un único evento idempotente, y la proyección pasa a Deleted (eliminada) todos los recuerdos que coinciden.
foreach (var item in items.Values
.Where(item =>
item.TenantId == tenantId &&
item.SubjectId == subjectId)
.ToArray())
{
items[item.Id] = item with
{
Status = MemoryStatus.Deleted,
Version = item.Version + 1
};
}
Eso es un borrado lógico. La recuperación se detiene de inmediato y las evidencias de auditoría se conservan, pero el contenido antiguo sigue en eventos anteriores del archivo, así que no debería describirse como borrado físico. Un borrado regulado puede requerir claves de cifrado por sujeto seguidas de su destrucción, compactación del log, caducidad de las copias de seguridad, limpieza de réplicas y un paso de verificación documentado. La auditabilidad y el borrado pueden tirar en direcciones opuestas, así que la arquitectura debe indicar qué metadatos pueden permanecer.
Recuperación con filtros estrictos primero
La similitud nunca debe ser el primer filtro de autorización. El servicio exige coincidencia exacta de tenant, agente, propósito, estado activo, vida útil no caducada y, opcionalmente, sujeto. El ranking viene solo después.
var ranked = snapshot
.Where(item =>
item.TenantId == query.TenantId &&
item.AgentId == query.AgentId &&
item.Purpose == query.Purpose &&
item.Status == MemoryStatus.Active &&
item.ExpiresAt > now &&
(query.SubjectId is null ||
item.SubjectId == query.SubjectId))
.Select(item => new
{
Item = item,
Score = TermMatches(item, terms) * 10
+ item.Confidence
+ Recency(item, now)
});
El ranker de referencia combina solapamiento léxico, confianza y recencia, y es explicable a propósito. Un índice de embeddings podría mejorar el recall semántico, pero las restricciones de tenant y propósito deben seguir siendo filtros obligatorios y no una limpieza posterior a una búsqueda vectorial global.
Las entradas elegidas también deben caber en un presupuesto de tokens. Un elemento largo y con poca puntuación se omite para que no desplace al resto de la respuesta. La estimación de tokens es aproximada y debería sustituirse por el tokenizador del modelo de destino allí donde importen los límites exactos.
Log de eventos y reproducción
Cada mutación se añade como una única línea JSON y después se aplica a la proyección en memoria.
await File.AppendAllTextAsync(
path,
JsonSerializer.Serialize(memoryEvent, json)
+ Environment.NewLine,
cancellationToken);
El paso de aplicación se ejecuta bajo el mismo lock.
lock (stateLock)
Apply(memoryEvent);
Al arrancar, el servicio lee los eventos en orden y reconstruye el estado actual. Una prueba escribe un recuerdo, abre un almacén nuevo sobre el mismo archivo y comprueba que la recuperación funciona tras la reproducción.
Un log en archivo sirve para aprender y para servicios locales pequeños, pero no es una base de datos distribuida. Un almacén de producción tendría que ofrecer appends atómicos, claves de idempotencia únicas, versiones optimistas obligatorias, cifrado, evolución del esquema, compactación, indexación, copias de seguridad y recuperación ante desastres probada.
Superficie de la API y operación
Por HTTP se exponen cuatro operaciones: escribir, promover, buscar y olvidar. A su alrededor hay configuración tipada y validada, un limitador de tasa de ventana fija, health checks, errores en formato Problem Details según el RFC, conversión de enums a JSON, un worker en segundo plano para la caducidad y E/S que respeta la cancelación.
Para que el ciclo de vida quede a la vista, el ejemplo acepta los campos de identidad en el cuerpo de la petición. El código real debe autenticar a quien llama y derivar de sus credenciales el tenant y el agente permitidos. Que alguien envíe un id de tenant conocido no merece confianza por sí solo.
El contenido de la memoria no debería registrarse en logs. Lo que sí conviene seguir es el número de escrituras, cuarentenas, promociones, conflictos, caducidades, sustituciones y llamadas de olvido, además de cuánto tardan las búsquedas y cuántos elementos devuelven.
Pruebas y resultados observados
Seis pruebas cubren las afirmaciones principales:
- La memoria no fiable queda en cuarentena, no aparece en las búsquedas y solo se puede recuperar tras la promoción.
- Una versión esperada desfasada no puede sobrescribir la memoria actual.
- Una corrección sustituye a la entrada anterior.
- Olvidar elimina todos los recuerdos que coinciden con el sujeto.
- No se devuelven entradas caducadas ni las que exceden el presupuesto.
- El log de eventos reconstruye el estado tras un reinicio.
La prueba de cuarentena recorre la transición completa. Primero escribe un elemento no fiable.
var item = await store.WriteAsync(
Command("m1", MemoryTrust.Untrusted),
CancellationToken.None);
Después comprueba la cuarentena, los resultados de búsqueda vacíos, la promoción y el nuevo nivel de confianza.
Assert.Equal(MemoryStatus.Quarantined, item.Status);
Assert.Empty(store.Search(Query()).Items);var promoted = await store.PromoteAsync(
"m1", 1, "promote-1", CancellationToken.None);Assert.Equal(MemoryTrust.Verified, promoted.Trust);
Assert.Single(store.Search(Query()).Items);
Una ejecución de verificación con .NET 10 en Windows restauró las dependencias sin problemas, compiló en Release con cero avisos y cero errores y superó las seis pruebas. Al ejecutar la API, un recuerdo fiable pasó a estar activo y uno no fiable acabó en cuarentena. La primera búsqueda devolvió un elemento y, tras la promoción, dos. El olvido por sujeto informó de dos recuerdos afectados, la siguiente búsqueda no devolvió ninguno y el endpoint de salud respondió HTTP 200.
Reglas que conviene mantener
- No guardes conversaciones en bruto por defecto.
- No dejes que el contenido se asigne su propia procedencia o confianza.
- No busques de forma global para filtrar después por tenant.
- No dejes que el modelo apruebe su propio conocimiento en cuarentena.
- No sobrescribas correcciones sin conservar el historial.
- No trates un tombstone como un borrado físico.
- No des el mismo TTL a todos los tipos de memoria.
- No trates la confianza generada por el modelo como verdad verificada.
Sobre todo, recuperar no autoriza a actuar. Un recuerdo verificado de que un cliente prefiere el email no da permiso para enviar datos confidenciales. La autorización de herramientas sigue siendo un límite aparte.
Cómo es una buena memoria de agente
Una memoria útil es selectiva, no infinita. En un diseño gobernado, cualquier elemento recordado puede rastrearse hasta su autor, confinarse a su ámbito, revisarse, caducar o eliminarse. La cuarentena separa almacenar de influir. Las comprobaciones de versión protegen las creencias en competencia, la sustitución conserva la historia de las correcciones, la reproducción explica cómo se llegó al estado actual y el olvido recorta tanto el riesgo como el ruido.
El resultado no se parecerá a la memoria humana. Es algo que los sistemas en producción necesitan con más urgencia: un ciclo de vida sobre el que los ingenieros puedan razonar y que puedan probar, auditar y controlar.