Pinned Post
Wie wir unseren AI-Agenten ein gemeinsames Gedächtnis gegeben haben
- Link abrufen
- X
- Andere Apps
Bei Fiatbitcoin laufen mittlerweile rund ein Dutzend Jido-Agenten parallel: Portfolio-Auswertung, Steuer, On-Chain-Beobachtung, Anomalie-Erkennung, Tages-Briefing. Irgendwann fällt einem dieses Muster auf:
Agent A weiß etwas. Agent B könnte es gebrauchen. Aber B fragt nie nach, weil A das Wissen nur in seinem eigenen kleinen State hält.
Kein Edge-Case, sondern der Default-Zustand jedes Multi-Agent-Systems, solange man nicht aktiv dagegen baut. Deshalb haben wir das Brain gebaut: einen zentralen Wissensspeicher, in den alle Agenten schreiben und aus dem alle lesen. Seit kurzem auch Claude Code, über einen MCP-Server.
Ein paar Architekturentscheidungen haben sich in der Umsetzung bewährt. Und eine, die ich heute anders treffen würde.
Das Problem: Agent-Silos
Bis vor kurzem hatte jeder Agent sein eigenes kleines Gedächtnis. Oder gar keins, je nach Implementierung. In der Praxis hieß das:
- Der PerformanceTaxAgent berechnete jede Nacht eine Portfolio-Summary, hatte aber keinen Bezug zur Summary von gestern.
- Der WhaleSensor loggte verdächtige On-Chain-Bewegungen in den Logger. Der AnomalyDetectionAgent hätte das Signal gerne gehabt, musste es aber selbst neu erkennen.
- Wenn Claude Code im Repo half, war es blind gegenüber den Beobachtungen, die die Production-Agenten gesammelt hatten.
Cross-Agent-Zusammenhänge mussten wir von Hand verdrahten. Agent A ruft Agent B, der wiederum C. Das skaliert weder kognitiv noch im Code.
Die Inspiration kam von eToros Company Brain. Wir haben uns die Kernideen abgeschaut (semantische Memory-Typen, Enrichment beim Schreiben, Visibility-Scopes, hybride Suche, Knowledge Graph mit Entity-Extraktion) und pragmatisch auf unseren Stack zugeschnitten: Phoenix-Umbrella, Elixir, Postgres mit pgvector, Jido für die Agenten.
Drei Schichten
┌─────────────────────────────────────────────────┐
Claude │ ACCESS │
Code │ POST /mcp (tools + resources) │
│ GET /mcp/health │
Jido │ Backend.Brain.Recorder.record/3 │
Agents │ Backend.Brain.{write, recall, relate, ...} │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ CORE │
│ Write-Pipeline (auto-embed, telemetry) │
│ Recall: vector | FTS | hybrid | entity-only │
│ Trust-Tier-Enforcement │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ PERSISTENCE (PostgreSQL + pgvector) │
│ brain_memories brain_entities ... │
└─────────────────────────────────────────────────┘
Oben sitzt der Access-Layer mit zwei Eingängen: ein In-Cluster-Pfad als direkter Elixir-Funktionsaufruf für die Jido-Agenten, und ein Out-of-Cluster-Pfad über HTTP/JSON-RPC für MCP-Clients wie Claude Code. Beide landen im selben Storage. Das ist im Kern die ganze Idee.
Memory-Typen: semantisch, nicht strukturlos
Ein zentraler Speicher wird schnell zur Müllhalde, wenn alles als "Text mit Tags" reinfliegt. Wir haben uns früh auf ein kleines, semantisch klar abgegrenztes Set festgelegt:
| Typ | Verwendung | Beispiel |
|---|---|---|
:fact | Statische, prüfbare Aussage | "Avg buy price user 1 = 32.450 EUR" |
:episode | Zeitgebundenes Ereignis | "Whale-Bewegung von 1.500 BTC in Block 800001" |
:decision | Bewusst getroffene Entscheidung | "User-Limit auf 5.000 EUR/Monat gesetzt" |
:rule | Gelernte Regel / Constraint | "Bei Volatility > 0.05 keine Auto-Käufe" |
:plan | Geplante zukünftige Aktion | "Steuerfreier Verkauf am 2026-09-15" |
:preference | User- / Agent-Präferenz | "User bevorzugt EUR-Reports" |
:action / :outcome | Aktion und ihr Ergebnis | "Webhook X gefeuert → 200 OK, 1.2s" |
Pro Memory kommen noch ein paar weitere Felder dazu: ein Scope (:agent_private, :team oder :org), ein Trust-Tier (:verified, :observed, :asserted), ein Lifecycle-State (:active, :stale, :superseded, :archived), eine Confidence und eine source_agent_id für die Provenance.
Klingt nach viel Metadata. Ist es auch. Aber jede Spalte hat ihren Job. Scope macht die Sichtbarkeit explizit. Trust-Tier verhindert, dass MCP-Clients sich selbst :verified zuweisen können (das wird serverseitig erzwungen). Lifecycle erlaubt sanftes Altern statt harter Löschung.
Lesen: vier Recall-Modi unter einem API
Backend.Brain.recall/1 ist polymorph in der Eingabe:
# Volltextsuche
Brain.recall(query: "tax-free positions", limit: 5)
# "Was wissen wir über User 1?"
Brain.recall(entity: {"user", "u-1"})
# Volltextsuche, aber nur Wissen über User 1
Brain.recall(query: "Verlust", entity: {"user", "u-1"})
# Hybrid: Vektor + FTS, dedupe per memory_id, max-Score
Brain.recall(query: "Volatilität", limit: 10)
Unter der Haube: pgvector mit HNSW-Index für die semantische Suche, Postgres-FTS mit tsvector-Generated-Column und GIN-Index für Volltext. Beide lassen sich kombinieren. Wenn der Embedder aktiv ist, wird ein Text-Query automatisch eingebettet und in den Hybrid-Modus geschaltet.
Der Embedder läuft direkt in der App, kein externer Service und keine API-Calls nach außen. Wir nutzen Bumblebee und laden das Modell sentence-transformers/all-MiniLM-L6-v2 von Hugging Face (384 Dimensionen, L2-normalisiert, einmaliger Download von rund 90 MB nach ~/.bumblebee/ beim ersten Start). Die Inferenz läuft über Nx.Serving mit dem EXLA-Compiler.
Der Vorteil einer einzigen Funktion mit polymorpher Eingabe ist, dass Aufrufer sich nicht entscheiden müssen, welche Suche sie brauchen. Sie beschreiben, was sie haben (Query? Embedding? Entity? Kombination?), und das Brain wählt den passenden Pfad.
Der Knowledge Graph
Memories sind nicht nur ein flacher Pool. Zwei Verknüpfungsarten machen daraus einen navigierbaren Graphen:
- Memory zu Entity über
brain_memory_entities. Jedes Memory kann beliebig viele kanonisierte Entitäten referenzieren (User, BTC-Adresse, Symbol, Datum, Error-Fingerprint), jeweils mit einer Rolle (:subject,:objectoder:mentioned). - Memory zu Memory über
brain_memory_edges. Typisierte Kanten mit fixem Enum:supersedes,supports,contradicts,derives_from,related_to,observed_with.
Ein Beispiel aus der Produktion: unsere GenerateSummary-Action verlinkt jede neue Portfolio-Summary mit ihrer Vorgängerin über newer --derives_from--> older. Die Historie der Tages-Auswertungen eines Users wird damit als Pfad im Graphen sichtbar, und an dem Pfad hängen automatisch die Entities (User, Symbole, Zeitfenster), die jede Summary referenziert hat.
Für die Visualisierung im Admin-LiveView haben wir einen edge-zentrischen Snapshot. Zusätzlich zum Primärset (letzte N Memories) holen wir die jüngsten K Edges aus der gesamten DB und laden fehlende Endpunkt-Memories als external: true nach. Ohne diesen Trick verschwinden Edges von Agenten mit langem Horizont aus der Ansicht. Der AgentPerformanceTrackerAgent zum Beispiel schreibt eine Edge pro Woche, und der wäre in einer reinen Zeitfenster-Heuristik sofort raus.
Lessons Learned
Drei Punkte, die ich anderen empfehlen würde. Und einer, bei dem ich heute anders einsteigen würde.
1. Keinen generischen "alle Action-Results in den Brain"-Hook bauen.
Die Versuchung war groß: ein Wrapper, der jedes Agent-Outcome automatisch persistiert. Das Ergebnis wäre eine Halde aus halbstrukturierten, semantisch wertlosen Memories gewesen. Stattdessen haben wir uns auf bewusste Schreibstellen pro Agent festgelegt, im Recorder-Pattern, mit explizit gewähltem Typ. Brain-Müll ist schwerer zu räumen als Brain-Lücken.
2. Trust-Tier serverseitig erzwingen.
Bei MCP-Writes mappen wir die agent_id über eine Allow-List auf einen Trust-Tier. Ein externer Client kann sich nicht selbst als :verified ausgeben, egal was im Payload steht. Fühlt sich anfangs nach Overhead an, ist aber die Eigenschaft, die einen geteilten Speicher von einer geteilten Mülltonne unterscheidet.
3. Knowledge Graph mit fixem Relation-Enum, nicht Freitext.
Sechs Relationstypen. Ecto-validate_inclusion plus DB-CHECK-Constraint. Hört sich restriktiv an, ist aber genau das, was den Graphen abfragbar und visualisierbar macht. User-defined Relations sind die Art von Flexibilität, die man nie braucht und immer bereut.
4. Was ich heute anders machen würde: Async-Writes von Anfang an.
Brain.write/1 läuft synchron. Ohne Embedding sind das ein paar Millisekunden, mit Embedding 30 bis 80. Bei der aktuellen Schreibrate kein Problem. Aber jeder Agent, der mal eben ein Memory schreiben will, zahlt im Hot Path die Embedding-Latenz. Ein synchroner Insert plus async Embed-Backfill über einen Oban-Job wäre robuster gewesen. Heute haben wir einen EmbedBackfillWorker als Fallback, aber das ist Kosmetik für ein Architekturproblem.
Wenn euch ein Detail interessiert (Embedder-Pipeline, MCP-Tool-Schema, das derives_from-Pattern, der edge-zentrische Graph-Snapshot), schreibt es in die Kommentare. Ich gehe gerne tiefer rein.

Kommentare