Головна / Статті / Керована пам'ять агента в .NET 10: карантин, TTL і право на забуття

Керована пам'ять агента в .NET 10: карантин, TTL і право на забуття

Як спроєктувати постійну пам'ять агента з походженням даних, карантином, оптимістичною конкурентністю, заміною записів, строком життя, обмеженим пошуком і відтворенням подій в ASP.NET Core.

2258 слів

Для агента з пам'яттю неприємний збій — зовсім не забути факт. Неприємний — пам'ятати хибний факт із повною впевненістю й безстроково.

Наївна функція пам'яті здається простою. Зберігаємо шматки розмови, рахуємо ембедінги, потім дістаємо схожий текст і вставляємо його в наступний промпт. Такий рецепт тихо перетворює кожне обмовлення, застаріле вподобання, скомпрометований результат інструмента й впроваджену інструкцію на довгостроковий вплив на агента. Постійна пам'ять — це більше, ніж пошук. Це життєвий цикл даних із вимогами до повноважень, походження, перегляду, ізоляції, зберігання й видалення.

Нижче описано сервіс на .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 тенанта, не заслуговує на довіру лише через це.

Контент пам'яті не варто логувати. Відстежувати варто інше: кількість записів, карантинів, підвищень, конфліктів, спливань, замін і викликів забуття, а також тривалість пошуку й кількість повернених елементів.

Тести та спостережені результати

Шість тестів покривають основні твердження:

  1. Недовірена пам'ять потрапляє в карантин, відсутня в пошуку й стає доступною лише після підвищення.
  2. Застаріла очікувана версія не може перезаписати поточну пам'ять.
  3. Виправлення замінює раніший запис.
  4. Забуття видаляє всі записи, що стосуються суб'єкта.
  5. Записи зі спливлим строком і ті, що не влазять у бюджет, не повертаються.
  6. Лог подій відновлює стан після перезапуску.

Тест карантину проходить увесь перехід. Спершу він записує недовірений елемент.

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, не дозволяє надсилати конфіденційні дані. Авторизація інструментів лишається окремою межею.

Яка вона, добра пам'ять агента

Корисна пам'ять вибіркова, а не безкінечна. У керованій архітектурі будь-який запам'ятований елемент можна простежити до автора, обмежити його областю, переглянути, дати йому спливти або видалити. Карантин відокремлює зберігання від впливу. Перевірки версій захищають конкурентні переконання, заміна зберігає історію виправлень, відтворення пояснює, як виник поточний стан, а забуття зменшує і ризик, і шум.

Результат не буде схожий на людську пам'ять. Це те, що продакшен-системам потрібніше: життєвий цикл, про який інженери можуть міркувати, який можуть тестувати, аудитувати й контролювати.