Startseite / Artikel / Kontrolliertes Agentengedächtnis in .NET 10: Quarantäne, TTLs und Recht auf Vergessen

Kontrolliertes Agentengedächtnis in .NET 10: Quarantäne, TTLs und Recht auf Vergessen

Persistentes Agentengedächtnis mit Herkunftsnachweis, Quarantäne, optimistischer Nebenläufigkeit, Ablösung, Ablaufzeiten, eingegrenztem Abruf und Event-Sourcing-Replay in ASP.NET Core entwerfen.

2258 Wörter

Für einen Agenten mit Gedächtnis ist es nicht der schlimme Fehler, eine Tatsache zu vergessen. Schlimm ist, sich mit voller Überzeugung und auf unbestimmte Zeit an eine falsche Tatsache zu erinnern.

Eine naive Gedächtnisfunktion wirkt einfach. Gesprächsstücke speichern, Embeddings berechnen, später ähnlichen Text abrufen und in den nächsten Prompt einfügen. Dieses Rezept verwandelt still und leise jeden Versprecher, jede veraltete Vorliebe, jedes kompromittierte Tool-Ergebnis und jede eingeschleuste Anweisung in einen dauerhaften Einfluss auf den Agenten. Persistentes Gedächtnis ist mehr als Abruf. Es ist ein Datenlebenszyklus mit Anforderungen an Autorität, Herkunft, Überarbeitung, Isolation, Aufbewahrung und Löschung.

Das folgende Design ist ein .NET-10-Service, in dem sich eine Erinnerung das Recht verdienen muss, das Verhalten eines Agenten zu prägen. Nicht vertrauenswürdige Einträge beginnen in Quarantäne. Schreibende verwenden optimistische Nebenläufigkeit. Korrekturen lösen alte Überzeugungen ab. Time-to-live-Werte räumen veraltetes Wissen weg. Was der Abruf zurückgeben darf, ist durch Mandant, Agent, Zweck, Subjekt und ein Token-Budget begrenzt. Das Vergessen eines Subjekts erzeugt ein dauerhaftes Lebenszyklus-Event.

Warum reine Ähnlichkeitssuche nicht reicht

Ein Artikel von Microsoft Security vom Juni 2026 zum Schutz von KI-Gedächtnis beschreibt eine ernste Bedrohung: Ein Angreifer kann Erinnerungen einpflanzen, die das Verhalten noch lange steuern, nachdem der Nutzer die ursprüngliche Interaktion vergessen hat. Die Abwehr wird dort in Schichten gedacht: Speicherung, Abruf, Modellinteraktion und Nutzerkontrolle.

PlugMem von Microsoft Research kommt zu einem ergänzenden Schluss über Zuverlässigkeit. Rohe Interaktionslogs werden lang, repetitiv und wenig wertvoll, deshalb sollte nützliches Gedächtnis Erfahrung in kompakte Fakten und wiederverwendbare Fähigkeiten umwandeln. Weitere Forschung aus 2026 untersucht aufgabenspezifische Gedächtnisstrukturen, Konsolidierung, Vergessen, Rekonsolidierung und Repräsentationen, die Abstraktion gegen konkrete Details abwägen. Alles zeigt in dieselbe Richtung: Mehr speichern und nach Ähnlichkeit abrufen ergibt keine ausreichende Architektur.

Gedächtnis und Kontext

Ein Kontext-Compiler entscheidet, welche Quellen in einen einzelnen Modellaufruf eingehen. Persistentes Gedächtnis sitzt früher in der Pipeline und lebt länger. Kontext fragt, was das Modell gerade jetzt sehen soll. Gedächtnis stellt andere Fragen:

  • Welcher Schreibende war befugt, es festzuhalten?
  • Zu welchem Mandanten, Agenten, Nutzer und Zweck gehört es?
  • Hat jemand es direkt gesehen, vermutet oder bestätigt?
  • Wie wird ein Konflikt mit einer neueren Tatsache aufgelöst?
  • Wann sollte es seine Gültigkeit verlieren?
  • Kann die beschriebene Person seine Entfernung verlangen?
  • Stellt sich nach einem Neustart derselbe Zustand wieder ein?

Diese Antworten gehören in Speicher- und Richtliniencode, niemals in einen Prompt.

Aufbau des Service

Das Beispiel ist ein ASP.NET-Core-Service auf einem frameworkunabhängigen Kern mit Event Sourcing, gegliedert in eine Kernbibliothek, ein API-Projekt und ein Testprojekt.

GovernedAgentMemory/
├── src/GovernedAgentMemory.Core/
├── src/GovernedAgentMemory.Api/
├── tests/GovernedAgentMemory.Tests/
├── docs/verification.md
├── Dockerfile
└── GovernedAgentMemory.slnx

Jeder Schreibvorgang folgt einer Pipeline: Zweck, Herkunft, TTL und Version validieren, dann nach Vertrauen verzweigen. Nicht vertrauenswürdige Schreibvorgänge landen in Quarantäne, vertrauenswürdige werden aktiv.

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

Die Referenzimplementierung speichert Events als JSON Lines, sodass sich der Lebenszyklus nach einem Neustart einsehen und wiedergeben lässt. Im echten Maßstab würde der Datei-Appender einem transaktionalen Event Store oder einer Datenbank weichen, die Zustandsübergänge blieben aber identisch.

Was ein Gedächtniseintrag enthält

Ein Eintrag enthält viel mehr als den Inhalt.

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 und Purpose sind getrennte Grenzen. Eine Support-Vorliebe sollte nicht automatisch zum Vertriebsgedächtnis werden, und eine Tatsache, die der Agent eines Mandanten gelernt hat, darf niemals bei einem anderen Mandanten auftauchen.

Provenance (Herkunft) hält fest, woher Wissen stammt, etwa aus einer CRM-Entität oder einem signierten Tool-Ergebnis. Ein URI-String allein beweist nichts, daher muss die produktive Datenaufnahme die Identität der Quelle prüfen und eine unveränderliche Quellversion speichern. Kind unterscheidet Arbeitsnotizen, Fakten, Fähigkeiten und Vorlieben, die sich keine gemeinsame Aufbewahrungs- oder Abrufrichtlinie teilen sollten. ContentHash verknüpft Lebenszyklusnachweise mit den ursprünglichen Bytes, ohne den Inhalt in jeden Betriebsdatensatz zu kopieren.

Quarantäne und Freigabe

Der Status ergibt sich aus dem Vertrauen zum Zeitpunkt des Schreibens.

var status = command.Trust == MemoryTrust.Untrusted
    ? MemoryStatus.Quarantined
    : MemoryStatus.Active;

Der vollständige Schreibvorgang baut das Element mit allen gesetzten Feldern.

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);

Einträge in Quarantäne werden gespeichert, aber vom Abruf ausgeschlossen. Das ist etwas anderes, als sie zu verwerfen: Ein autorisierter Prozess kann die Herkunft prüfen, bestätigende Belege abwägen und über die Freigabe entscheiden.

Die Freigabe ist ein expliziter Übergang. Sie benötigt die aktuelle Version und setzt das Vertrauen auf Verified (verifiziert). Zuerst wird die Version verglichen.

if (current.Version != expectedVersion)
    throw new MemoryConflictException(
        "Expected version does not match the current memory version.");

Dann wird der Status geprüft, denn nur Einträge in Quarantäne kommen infrage, und die freigegebene Kopie wird erzeugt.

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
};

In Produktion muss der Aufrufer, der die Freigabe vornimmt, selbst autorisiert sein. Ein Modell sollte sein eigenes Gedächtnis nicht freigeben, nur weil es Zuversicht behauptet.

Konkurrierende Schreibende

Das Gedächtnis ändert sich, während Agenten arbeiten. Zwei Workflows könnten beide Version 3 lesen und beide versuchen, sie zu aktualisieren. Beide stillschweigend zu akzeptieren, würde den Gewinner vom Netzwerk-Timing abhängig machen.

Jeder Schreibbefehl trägt deshalb eine ExpectedVersion, die der Store mit der aktuellen Projektion vergleicht. Zuerst kommt das Nachschlagen.

MemoryItem? existing;

Innerhalb einer Sperre prüft der Store dann, ob die gespeicherte Version der erwarteten entspricht, und meldet andernfalls einen Konflikt.

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.");
}

Ein veralteter Schreibender bekommt einen Konflikt und muss bewusst neu laden, neu überlegen und es erneut versuchen. Das zählt besonders, wenn die Aktualisierung eines Agenten auf einem überholten Bild der Aufgabe beruht.

Schreibvorgänge haben außerdem einen Idempotenzschlüssel. Läuft ein Client in einen Timeout, nachdem das Anhängen bereits geklappt hat, liefert das erneute Senden desselben Befehls das frühere Ergebnis, statt eine zweite Erinnerung anzulegen.

Deployments mit mehreren Instanzen brauchen Eindeutigkeit und Compare-and-Swap im transaktionalen Store, da eine prozessinterne Sperre sich nicht mit einem anderen Knoten abstimmen kann.

Korrekturen ohne Verlust der Historie

Angenommen, das System merkt sich, dass ein Kunde telefonischen Support bevorzugt, und verifizierte Belege zeigen später, dass er E-Mail vorzieht. Den Inhalt zu überschreiben, löscht die Entscheidungshistorie. Beide aktiv zu lassen, erzeugt einen Widerspruch.

Stattdessen verweist die neue Erinnerung über SupersedesId auf die alte, und das Anwenden ihres Events markiert den vorherigen Eintrag als Superseded (abgelöst).

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
    };
}

Die Suche sieht nur die korrigierte Erinnerung, während das Replay weiterhin zeigt, warum frühere Aktionen sich auf die alte Überzeugung gestützt haben könnten. Der Service prüft außerdem, dass der abgelöste Eintrag denselben Mandanten, Agenten und Zweck hat. Komplexe Fakten können einem einzelnen Ablösungszeiger entwachsen. Produktivsysteme brauchen womöglich Identität auf Aussageebene, Gültigkeitszeiträume, Widerspruchsgruppen und Belegmengen.

Ablauf und Vergessen

Ein Gedächtnis, das nie abläuft, wird veraltet, teuer und riskant. Jeder Schreibvorgang muss ein Ablaufdatum innerhalb der konfigurierten maximalen Lebensdauer tragen, und die Suche überspringt jeden Eintrag, dessen ExpiresAt verstrichen ist. Ein gehosteter Worker schreibt zudem planmäßig explizite Expired-Events (abgelaufen), sodass der wiedergegebene Zustand den Lebenszyklus abbildet, statt nur von einer Filterung nach der aktuellen Uhrzeit abzuhängen.

Das Vergessen eines Subjekts schreibt ein einziges idempotentes Event, und die Projektion setzt jede passende Erinnerung auf Deleted (gelöscht).

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
    };
}

Das ist eine logische Löschung. Der Abruf endet sofort und Audit-Nachweise bleiben erhalten, doch der alte Inhalt steckt noch in früheren Datei-Events, also sollte man es nicht als physische Löschung bezeichnen. Regulierte Löschung kann subjektspezifische Verschlüsselungsschlüssel mit anschließender Schlüsselvernichtung, Log-Kompaktierung, Ablauf von Backups, Bereinigung von Replikas und einen dokumentierten Prüfschritt erfordern. Auditierbarkeit und Löschung können gegeneinander arbeiten, daher muss die Architektur festlegen, welche Metadaten bleiben dürfen.

Abruf mit harten Filtern zuerst

Ähnlichkeit darf nie der erste Autorisierungsfilter sein. Der Service verlangt eine exakte Übereinstimmung bei Mandant, Agent, Zweck, aktivem Status, nicht abgelaufener Lebensdauer und optional beim Subjekt. Das Ranking kommt erst danach.

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)
    });

Der Referenz-Ranker kombiniert lexikalische Überschneidung, Konfidenz und Aktualität und ist absichtlich erklärbar. Ein Embedding-Index könnte den semantischen Recall verbessern, doch Mandanten- und Zweckbeschränkungen müssen Pflichtfilter bleiben und dürfen keine Aufräumarbeit nach einer globalen Vektorsuche sein.

Ausgewählte Einträge müssen außerdem in ein Token-Budget passen. Ein langes, niedrig gerangtes Element wird übersprungen, damit es den Rest der Antwort nicht verdrängt. Die Token-Schätzung ist grob und sollte dort, wo exakte Grenzen zählen, durch den Tokenizer des Zielmodells ersetzt werden.

Event-Log und Replay

Jede Änderung wird als einzelne JSON-Zeile angehängt und dann auf die In-Memory-Projektion angewendet.

await File.AppendAllTextAsync(
    path,
    JsonSerializer.Serialize(memoryEvent, json)
        + Environment.NewLine,
    cancellationToken);

Der Anwendungsschritt läuft unter derselben Sperre.

lock (stateLock)
    Apply(memoryEvent);

Beim Start liest der Service die Events der Reihe nach und baut den aktuellen Zustand neu auf. Ein Test schreibt eine Erinnerung, öffnet einen neuen Store über derselben Datei und prüft, dass der Abruf nach dem Replay funktioniert.

Ein Datei-Log eignet sich zum Lernen und für kleine lokale Services, ist aber keine verteilte Datenbank. Ein produktiver Store müsste atomare Anhänge, eindeutige Idempotenzschlüssel, erzwungene optimistische Versionen, Verschlüsselung, Schema-Evolution, Kompaktierung, Indizierung, Backups und getestete Notfallwiederherstellung bieten.

API und Betrieb

Über HTTP sind vier Operationen verfügbar: Schreiben, Freigeben, Suchen und Vergessen. Drumherum gibt es typisierte, validierte Konfiguration, einen Rate Limiter mit festem Zeitfenster, Health Checks, Fehler als Problem Details nach RFC, JSON-Enum-Konvertierung, einen gehosteten Worker für Abläufe und I/O, das Abbruchsignale respektiert.

Damit der Lebenszyklus sichtbar bleibt, nimmt das Beispiel Identitätsfelder im Request-Body entgegen. Echter Code muss Aufrufer authentifizieren und den erlaubten Mandanten- und Agentenbereich aus den Anmeldedaten ableiten. Ein Aufrufer, der eine bekannte Mandanten-ID mitschickt, verdient allein deshalb kein Vertrauen.

Gedächtnisinhalte sollten nicht geloggt werden. Stattdessen lohnt es sich zu verfolgen: die Anzahl von Schreibvorgängen, Quarantänen, Freigaben, Konflikten, Abläufen, Ablösungen und Vergessen-Aufrufen sowie die Dauer von Suchen und die Zahl zurückgegebener Elemente.

Tests und beobachtete Ergebnisse

Sechs Tests decken die wichtigsten Aussagen ab:

  1. Nicht vertrauenswürdige Erinnerungen landen in Quarantäne, fehlen in der Suche und sind erst nach der Freigabe abrufbar.
  2. Eine veraltete erwartete Version kann die aktuelle Erinnerung nicht überschreiben.
  3. Eine Korrektur löst den früheren Eintrag ab.
  4. Vergessen entfernt jede Erinnerung, die zum Subjekt passt.
  5. Abgelaufene und budgetüberschreitende Einträge werden nicht zurückgegeben.
  6. Das Event-Log stellt den Zustand nach einem Neustart wieder her.

Der Quarantänetest durchläuft den gesamten Übergang. Zuerst schreibt er ein nicht vertrauenswürdiges Element.

var item = await store.WriteAsync(
    Command("m1", MemoryTrust.Untrusted),
    CancellationToken.None);

Dann prüft er Quarantäne, leere Suchergebnisse, Freigabe und die neue Vertrauensstufe.

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);

Ein Prüflauf mit .NET 10 unter Windows stellte die Abhängigkeiten sauber wieder her, baute in Release ohne Warnungen und Fehler und bestand alle sechs Tests. Beim Ausführen der API wurde eine vertrauenswürdige Erinnerung aktiv, während eine nicht vertrauenswürdige in Quarantäne landete. Die erste Suche lieferte ein Element, nach der Freigabe zwei. Das Vergessen auf Subjektebene meldete zwei betroffene Erinnerungen, die nächste Suche lieferte keine, und der Health-Endpunkt antwortete mit HTTP 200.

Regeln, die man einhalten sollte

  • Speichern Sie Rohgespräche nicht standardmäßig.
  • Lassen Sie Inhalte nicht selbst ihre Herkunft oder ihr Vertrauen festlegen.
  • Suchen Sie nicht global, um danach nach Mandant zu filtern.
  • Lassen Sie das Modell nicht sein eigenes Wissen aus der Quarantäne freigeben.
  • Überschreiben Sie Korrekturen nicht, ohne die Historie zu bewahren.
  • Behandeln Sie einen Tombstone nicht als physische Löschung.
  • Geben Sie nicht allen Gedächtnistypen dieselbe TTL.
  • Behandeln Sie vom Modell erzeugte Konfidenz nicht als verifizierte Wahrheit.

Vor allem autorisiert ein Abruf keine Aktion. Eine verifizierte Erinnerung, dass ein Kunde E-Mail bevorzugt, ist keine Erlaubnis, vertrauliche Daten zu versenden. Die Autorisierung von Tools bleibt eine eigene Grenze.

Wie gutes Agentengedächtnis aussieht

Nützliches Gedächtnis ist selektiv, nicht endlos. In einem kontrollierten Design lässt sich jeder gespeicherte Eintrag zu seinem Urheber zurückverfolgen, auf seinen Bereich beschränken, überarbeiten, ablaufen lassen oder entfernen. Die Quarantäne trennt das Speichern vom Beeinflussen. Versionsprüfungen schützen konkurrierende Überzeugungen, Ablösung bewahrt die Geschichte der Korrekturen, Replay erklärt, wie der aktuelle Zustand entstanden ist, und Vergessen reduziert sowohl Risiko als auch Rauschen.

Das Ergebnis wird nicht wie menschliches Gedächtnis aussehen. Es ist etwas, das Produktivsysteme dringender brauchen: ein Lebenszyklus, über den Ingenieure nachdenken und den sie testen, prüfen und kontrollieren können.