Управляемая память агента в .NET 10: карантин, TTL и право на забвение
Как спроектировать постоянную память агента с происхождением данных, карантином, оптимистичной конкурентностью, заменой записей, сроком жизни, ограниченным поиском и воспроизведением событий в ASP.NET Core.
Для агента с памятью неприятный сбой — вовсе не забыть факт. Неприятный — помнить неверный факт с полной уверенностью и бессрочно.
Наивная функция памяти кажется простой. Сохраняем куски разговора, считаем эмбеддинги, потом достаём похожий текст и вставляем его в следующий промпт. Такой рецепт тихо превращает каждую оговорку, устаревшее предпочтение, скомпрометированный результат инструмента и внедрённую инструкцию в долгосрочное влияние на агента. Постоянная память — это больше, чем поиск. Это жизненный цикл данных с требованиями к полномочиям, происхождению, пересмотру, изоляции, хранению и удалению.
Ниже описан сервис на .NET 10, в котором память должна заслужить право влиять на поведение агента. Недоверенные записи начинают с карантина. Писатели используют оптимистичную конкурентность. Исправления заменяют старые убеждения. Значения времени жизни (TTL) вычищают устаревшие знания. То, что может вернуть поиск, ограничено тенантом, агентом, назначением, субъектом и бюджетом токенов. Забывание субъекта порождает долговечное событие жизненного цикла.
Почему простого поиска по сходству мало
В статье Microsoft Security от июня 2026 года о защите памяти ИИ описана серьёзная угроза: злоумышленник может подсадить воспоминания, которые управляют поведением ещё долго после того, как исходное взаимодействие выветрилось из головы пользователя. Защита там строится слоями — на уровне хранения, поиска, взаимодействия с моделью и пользовательского контроля.
PlugMem от Microsoft Research приходит к дополняющему выводу о надёжности. Сырые логи взаимодействий разрастаются, повторяются и мало что стоят, поэтому полезная память должна превращать опыт в компактные факты и переиспользуемые навыки. Другие исследования 2026 года изучают память под конкретные задачи, консолидацию, забывание, реконсолидацию и представления, балансирующие между абстракцией и конкретными деталями. Всё это указывает в одну сторону: хранить больше и искать по сходству — недостаточная архитектура.
Память и контекст
Компилятор контекста решает, какие источники попадут в один вызов модели. Постоянная память стоит раньше в пайплайне и живёт дольше. Контекст спрашивает, что модель должна видеть прямо сейчас. Память задаёт другие вопросы:
- У какого писателя были полномочия это записать?
- Какому тенанту, агенту, пользователю и назначению это принадлежит?
- Кто-то увидел это напрямую, предположил или подтвердил?
- Как разрешается конфликт с более свежим фактом?
- Когда это должно утратить силу?
- Может ли человек, которого это описывает, потребовать удаления?
- Вернётся ли то же состояние после перезапуска?
Ответы на эти вопросы место в хранилище и коде политик, а не в промпте.
Устройство сервиса
Пример — сервис на ASP.NET Core поверх независимого от фреймворка ядра с event sourcing, разложенный на библиотеку ядра, проект API и тестовый проект.
GovernedAgentMemory/
├── src/GovernedAgentMemory.Core/
├── src/GovernedAgentMemory.Api/
├── tests/GovernedAgentMemory.Tests/
├── docs/verification.md
├── Dockerfile
└── GovernedAgentMemory.slnx
Каждая запись проходит через пайплайн: проверка назначения, происхождения, TTL и версии, затем ветвление по доверию. Недоверенные записи уходят в карантин, доверенные становятся активными.
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
Эталонная реализация хранит события в формате JSON Lines, поэтому жизненный цикл можно изучить и воспроизвести после перезапуска. В реальном масштабе файловый аппендер уступил бы место транзакционному хранилищу событий или базе данных, но переходы состояний остались бы теми же.
Что несёт запись памяти
Запись содержит гораздо больше, чем сам контент.
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 и Purpose — отдельные границы. Предпочтение из поддержки не должно автоматически становиться памятью отдела продаж, а факт, который выучил агент одного тенанта, никогда не должен всплыть у другого.
Provenance (происхождение) фиксирует, откуда пришло знание, например из сущности CRM или подписанного результата инструмента. Одна строка URI ничего не доказывает, поэтому продакшен-приём данных должен проверять подлинность источника и хранить неизменяемую версию источника. Kind разделяет рабочие заметки, факты, навыки и предпочтения, которым не стоит иметь общую политику хранения или поиска. ContentHash связывает свидетельства жизненного цикла с исходными байтами, не копируя контент в каждую операционную запись.
Карантин и повышение
Статус определяется доверием в момент записи.
var status = command.Trust == MemoryTrust.Untrusted
? MemoryStatus.Quarantined
: MemoryStatus.Active;
Полная запись собирает элемент со всеми заполненными полями.
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);
Записи в карантине сохраняются, но не участвуют в поиске. Это не то же самое, что выбросить их: авторизованный процесс может изучить происхождение, взвесить подтверждающие доказательства и решить, повышать ли запись.
Повышение — явный переход. Для него нужна текущая версия, и оно переводит доверие в Verified (проверено). Сначала сравнивается версия.
if (current.Version != expectedVersion)
throw new MemoryConflictException(
"Expected version does not match the current memory version.");
Затем проверяется статус, поскольку подходят только записи в карантине, и создаётся повышенная копия.
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
};
В продакшене вызывающая сторона, выполняющая повышение, сама должна быть авторизована. Модель не должна повышать собственную память, просто заявив о своей уверенности.
Конкурирующие писатели
Память меняется, пока агенты работают. Два процесса могут оба прочитать версию 3 и оба попытаться её обновить. Молча принять оба — значит отдать победу на волю сетевых задержек.
Поэтому каждая команда записи несёт ExpectedVersion, которую хранилище сравнивает с текущей проекцией. Сначала идёт поиск записи.
MemoryItem? existing;
Затем под блокировкой хранилище проверяет, что сохранённая версия равна ожидаемой, и иначе сообщает о конфликте.
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.");
}
Устаревший писатель получает конфликт и должен осознанно перечитать данные, переосмыслить и повторить попытку. Особенно это важно, когда обновление агента опирается на устаревшую картину задачи.
У записей есть и ключ идемпотентности. Если клиент упал по таймауту уже после успешного добавления, повторная отправка той же команды вернёт прежний результат, а не создаст вторую запись.
Многоэкземплярным развёртываниям нужны уникальность и compare-and-swap в транзакционном хранилище, ведь блокировка внутри процесса не скоординируется с другим узлом.
Исправления без потери истории
Допустим, система помнит, что клиент предпочитает поддержку по телефону, а позже проверенные данные показывают, что он предпочитает email. Перезапись контента стирает историю решений. Оставить обе записи активными — значит получить противоречие.
Вместо этого новая запись указывает на старую через SupersedesId, а применение её события помечает прежнюю запись как Superseded (заменённая).
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
};
}
Поиск видит только исправленную запись, а воспроизведение по-прежнему показывает, почему более ранние действия могли опираться на старое убеждение. Сервис также проверяет, что у заменяемой записи те же тенант, агент и назначение. Сложные факты могут перерасти единственный указатель замены. Продакшен-системам могут понадобиться идентичность на уровне утверждений, интервалы действительности, группы противоречий и наборы доказательств.
Истечение срока и забывание
Память, которая никогда не истекает, становится устаревшей, дорогой и рискованной. Каждая запись обязана нести срок истечения в пределах настроенного максимального времени жизни, а поиск пропускает любую запись, чей ExpiresAt уже прошёл. Фоновый воркер к тому же по расписанию пишет явные события Expired (истекла), так что воспроизведённое состояние отражает жизненный цикл, а не зависит только от фильтрации по текущему времени.
Забывание субъекта записывает одно идемпотентное событие, и проекция переводит все соответствующие записи в Deleted (удалена).
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
};
}
Это логическое удаление. Поиск прекращается сразу, аудиторские свидетельства сохраняются, но старый контент по-прежнему лежит в более ранних событиях файла, поэтому называть это физическим стиранием нельзя. Регулируемое удаление может потребовать ключей шифрования на каждого субъекта с последующим уничтожением ключа, компактификации лога, истечения бэкапов, очистки реплик и задокументированного шага проверки. Аудируемость и стирание могут тянуть в разные стороны, поэтому архитектура должна прямо говорить, какие метаданные могут остаться.
Сначала жёсткие фильтры, потом поиск
Сходство никогда не должно быть первым фильтром авторизации. Сервис требует точного совпадения тенанта, агента, назначения, активного статуса, неистёкшего срока и, при необходимости, субъекта. Ранжирование происходит только после этого.
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)
});
Эталонный ранжировщик сочетает лексическое пересечение, уверенность и свежесть и намеренно остаётся объяснимым. Индекс эмбеддингов мог бы улучшить семантическую полноту, но ограничения по тенанту и назначению должны оставаться обязательными фильтрами, а не уборкой после глобального векторного поиска.
Выбранные записи также должны уложиться в бюджет токенов. Длинный элемент с низким рангом пропускается, чтобы не вытеснить остальной ответ. Оценка токенов грубая, и там, где важны точные лимиты, её стоит заменить токенизатором целевой модели.
Лог событий и воспроизведение
Каждое изменение дописывается одной строкой JSON, а затем применяется к проекции в памяти.
await File.AppendAllTextAsync(
path,
JsonSerializer.Serialize(memoryEvent, json)
+ Environment.NewLine,
cancellationToken);
Шаг применения выполняется под той же блокировкой.
lock (stateLock)
Apply(memoryEvent);
При запуске сервис читает события по порядку и восстанавливает текущее состояние. Один из тестов записывает память, открывает новое хранилище поверх того же файла и проверяет, что поиск работает после воспроизведения.
Файловый лог годится для обучения и небольших локальных сервисов, но это не распределённая база данных. Продакшен-хранилище должно обеспечивать атомарные добавления, уникальные ключи идемпотентности, принудительные оптимистичные версии, шифрование, эволюцию схемы, компактификацию, индексацию, бэкапы и проверенное аварийное восстановление.
API и эксплуатация
По HTTP доступны четыре операции: запись, повышение, поиск и забывание. Вокруг них — типизированная проверяемая конфигурация, ограничитель частоты с фиксированным окном, health checks, ошибки в формате Problem Details по RFC, преобразование enum в JSON, фоновый воркер для истечения сроков и ввод-вывод с поддержкой отмены.
Чтобы жизненный цикл был нагляден, пример принимает поля идентичности в теле запроса. Реальный код должен аутентифицировать вызывающих и выводить допустимый тенант и агента из учётных данных. Вызывающий, который передал знакомый id тенанта, не заслуживает доверия только поэтому.
Контент памяти не стоит логировать. Отслеживать стоит другое: число записей, карантинов, повышений, конфликтов, истечений, замен и вызовов забывания, а также длительность поиска и количество возвращённых элементов.
Тесты и наблюдаемые результаты
Шесть тестов покрывают основные утверждения:
- Недоверенная память попадает в карантин, отсутствует в поиске и становится доступной только после повышения.
- Устаревшая ожидаемая версия не может перезаписать текущую память.
- Исправление заменяет более раннюю запись.
- Забывание удаляет все записи, относящиеся к субъекту.
- Истёкшие и не влезающие в бюджет записи не возвращаются.
- Лог событий восстанавливает состояние после перезапуска.
Тест карантина проходит весь переход. Сначала он записывает недоверенный элемент.
var item = await store.WriteAsync(
Command("m1", MemoryTrust.Untrusted),
CancellationToken.None);
Затем проверяет карантин, пустой результат поиска, повышение и новый уровень доверия.
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);
Проверочный прогон на .NET 10 под Windows чисто восстановил зависимости, собрался в Release без предупреждений и ошибок и прошёл все шесть тестов. Запуск API показал, что доверенная запись становится активной, а недоверенная попадает в карантин. Первый поиск вернул один элемент, а после повышения — два. Забывание на уровне субъекта сообщило о двух затронутых записях, следующий поиск ничего не вернул, а health-эндпоинт ответил HTTP 200.
Правила, которых стоит держаться
- Не храните сырые разговоры по умолчанию.
- Не позволяйте контенту самому назначать себе происхождение или доверие.
- Не ищите глобально, чтобы потом фильтровать по тенанту.
- Не позволяйте модели одобрять собственные знания из карантина.
- Не перезаписывайте исправления без сохранения истории.
- Не считайте надгробную запись (tombstone) физическим стиранием.
- Не давайте всем типам памяти одинаковый TTL.
- Не принимайте уверенность, сгенерированную моделью, за проверенную истину.
И главное: поиск не даёт права на действие. Проверенная запись о том, что клиент предпочитает email, не разрешает отправлять конфиденциальные данные. Авторизация инструментов остаётся отдельной границей.
Как выглядит хорошая память агента
Полезная память избирательна, а не бесконечна. В управляемой архитектуре любой запомненный элемент можно отследить до автора, ограничить его областью, пересмотреть, дать ему истечь или удалить. Карантин отделяет хранение от влияния. Проверки версий защищают конкурирующие убеждения, замена сохраняет историю исправлений, воспроизведение объясняет, как возникло текущее состояние, а забывание снижает и риск, и шум.
Результат не будет похож на человеческую память. Это то, что продакшен-системам нужнее: жизненный цикл, о котором инженеры могут рассуждать, который могут тестировать, аудировать и контролировать.