Pinned Post
Jido in der Praxis: Agenten in Elixir als komponierbare Actions
- Link abrufen
- X
- Andere Apps
Bei Fiatbitcoin laufen rund zwei Dutzend kleine Agenten im Hintergrund. Sie holen Kurse, lesen die Mempool, bewerten Nachrichten, rechnen Steuerfristen aus, schicken Alerts. Am Anfang waren das alles einfach GenServer und Oban-Jobs, jeder ein bisschen anders gebaut. Das funktioniert, wird aber schnell unübersichtlich: Jeder Job hat seinen eigenen Ein- und Ausgang, seine eigene Art, Fehler zu melden, seine eigene Vorstellung davon, was “fertig” heißt.
Jido ist ein Framework für genau diese Art von Arbeit. Es gibt einem eine Handvoll klar umrissener Bausteine, mit denen man Agenten aus kleinen, testbaren Einheiten zusammensetzt. Ich benutze es in Version 2.1. Dieser Artikel ist eine Tour durch echten Code aus dem Projekt, und die Frage dahinter ist die ganze Zeit dieselbe: Wo trägt Jido sein Gewicht, und wo nicht.
Falls du kein Elixir schreibst, ein paar Vokabeln vorab. Ein Modul ist eine Sammlung von Funktionen. {:ok, value} und {:error, reason} sind die übliche Art, Erfolg oder Fehler zurückzugeben, ein getaggtes Paar. Ein GenServer ist ein leichtgewichtiger, langlebiger Prozess mit eigenem Zustand, und ein Supervisor ist der Prozess, der ihn startet und bei einem Absturz neu hochfährt. Mehr braucht es nicht, um hier mitzulesen.
Die kleinste Einheit: eine Action
Der zentrale Baustein in Jido ist nicht der Agent, sondern die Action. Eine Action ist eine einzelne Arbeitseinheit mit einem Namen, einer Beschreibung und einem Parameter-Schema. So sieht die Action aus, die eine Portfolio-Zusammenfassung erzeugt:
defmodule Backend.Agents.Actions.GenerateSummary do
use Jido.Action,
name: "generate_summary",
description:
"Generates comprehensive portfolio and tax summary with German natural language text",
schema: [
user_id: [
type: :integer,
required: true,
doc: "The ID of the user to generate summary for"
]
]
@impl true
def run(%{user_id: user_id}, context) do
# ...
end
end
Das schema beschreibt die Eingaben (unter der Haube NimbleOptions), inklusive Typ, Pflichtfeldern und einer Doku-Zeile pro Feld. Der eigentliche Code steckt in run/2: erstes Argument sind die Parameter, zweites ein Kontext-Map für Dinge, die von außen reinkommen. Rückgabe ist immer {:ok, map} oder {:error, reason}. Diese eine Konvention zieht sich durch alle Actions, und sie ist der Grund, warum sich am Ende alles miteinander verbinden lässt.
Actions komponieren
Eine Action darf andere Actions aufrufen. GenerateSummary rechnet selbst gar nichts aus, sie holt sich Performance- und Steuerdaten von zwei anderen Actions und webt daraus einen Text:
def run(%{user_id: user_id}, context) do
with {:ok, performance} <- GetPerformanceSummary.run(%{user_id: user_id}, context),
{:ok, tax} <- GetTaxReport.run(%{user_id: user_id}, context) do
summary_text = build_summary_text(performance, tax, cross_ctx, prev_value)
{:ok,
%{
user_id: user_id,
performance: Map.drop(performance, [:user_id]),
tax: Map.drop(tax, [:user_id]),
summary_text: summary_text
}}
else
{:error, reason} -> {:error, reason}
end
end
Das with ist Elixirs Weg, mehrere Schritte hintereinanderzuhängen, die alle klappen müssen. Solange jeder Schritt {:ok, ...} liefert, geht es weiter. Sobald einer {:error, ...} zurückgibt, springt die Ausführung in den else-Zweig und reicht den Fehler unverändert nach oben. Weil GetPerformanceSummary und GetTaxReport denselben Vertrag erfüllen wie GenerateSummary, lassen sie sich stapeln, ohne dass eine von der anderen etwas Besonderes wissen muss.
![]() |
| Anatomie eines Jido-Agenten: Agent, Actions und Tool |
Der Agent als Bündel
Der Agent selbst ist erstaunlich dünn. Er deklariert im Wesentlichen, welche Actions er kann und welchen Zustand er hält:
defmodule Backend.Agents.PerformanceTaxAgent do
use Jido.Agent,
name: "performance_tax_agent",
description: "Analyzes Bitcoin portfolio performance and tax status following German tax law",
actions: [
GetPerformanceSummary,
GetTaxReport,
GenerateSummary,
GenerateSummaryAI
],
schema: [
last_user_id: [type: :integer, default: nil],
last_result: [type: :map, default: nil]
]
end
Es gibt zwei Wege, damit zu arbeiten. Für den einfachen Fall ruft man die Action direkt auf, ohne dass irgendein Prozess laufen muss. So macht es die REST-Schnittstelle und die LiveView:
def generate_summary(user_id) do
{time_us, result} =
:timer.tc(fn -> GenerateSummary.run(%{user_id: user_id}, %{}) end)
TelemetryHandler.record_metric(
"performance_tax_agent",
"execution_duration",
time_us / 1000.0,
%{action: "generate_summary", user_id: user_id}
)
result
end
Der zweite Weg ist der zustandsbehaftete Agent-Prozess, für Workflows, bei denen man Schritte plant und einen Zustand über mehrere Aufrufe hält:
{:ok, agent} = PerformanceTaxAgent.new()
{:ok, agent} = PerformanceTaxAgent.plan(agent, GenerateSummary, %{user_id: 1})
{:ok, agent} = PerformanceTaxAgent.run(agent)
result = agent.result
Als Prozess lebt der Agent unter einem Jido.AgentServer, und der ist ein ganz normales Kind im Supervision-Baum:
{Jido.AgentServer,
agent: Backend.Agents.PerformanceTaxAgent, id: "performance_tax_agent", jido: Backend.Jido},
Das ist ein Punkt, den ich an Jido mag: Der Agent-Prozess bricht nicht aus OTP aus, er fügt sich ein. Supervision, Restart-Strategien, Telemetry, PubSub, all das bleibt genau so, wie man es aus normalem Elixir kennt. Jido ersetzt die Werkzeugkiste nicht, es legt eine Struktur darüber.
Vier Türen, eine Action
Hier zahlt sich die Disziplin mit dem einheitlichen Action-Vertrag richtig aus. Dieselbe GenerateSummary wird bei Fiatbitcoin aus vier völlig verschiedenen Richtungen aufgerufen:
- Ein Oban-Cron-Job läuft täglich um 06:15 UTC und schreibt die Zusammenfassung ins Gedächtnis des Systems.
- Der REST-Endpunkt
GET /api/ai/performance_tax/:user_idliefert sie als JSON an externe Aufrufer. - Die LiveView unter
/ai_insightszeigt sie im Browser an. - Ein LLM ruft sie über Tool-Calling als Werkzeug auf (dazu gleich mehr).
Vier Eintrittspunkte, ein Stück Logik. Ich musste die Berechnung nicht viermal schreiben und halte sie auch nicht an vier Stellen synchron. Das ist kein Jido-spezifisches Kunststück, saubere Funktionen erreichen dasselbe. Aber Jido drückt einen von Anfang an in diese Form, weil die Action die Standardeinheit ist und der {:ok, map}-Vertrag nicht verhandelbar ist. Man rutscht in die gute Struktur hinein, statt sich zu ihr durchzuringen.
Von der Action zum LLM-Tool
Der Weg, auf dem sich das am deutlichsten auszahlt, ist Tool-Calling. Eine Action hat bereits alles, was ein Sprachmodell über ein Werkzeug wissen muss: einen Namen, eine Beschreibung, ein Parameter-Schema. Um aus einer Action ein Tool zu machen, muss ich sie kaum noch übersetzen:
def tools(user_id) do
[
ReqLLM.Tool.new!(
name: "get_tax_report",
description:
"Liefert den Steuerstatus des aktuellen Nutzers nach deutschem Recht: " <>
"steuerfreie und steuerpflichtige Positionen sowie das nächste steuerfreie Datum.",
parameter_schema: [],
callback: fn _args -> run_action(GetTaxReport, user_id) end
)
]
end
defp run_action(action, user_id) do
case action.run(%{user_id: user_id}, %{}) do
{:ok, result} -> {:ok, Jason.encode!(result)}
{:error, reason} -> {:error, inspect(reason)}
end
end
Ein Detail daran finde ich hübsch. Die user_id ist fest in den Callback eingebunden, das Tool selbst nimmt keine Parameter. Das Modell entscheidet also, welche Daten es holt, aber nie, wessen. Es kann nicht auf ein fremdes Portfolio zeigen, weil ihm der Hebel dafür fehlt. Die Autorisierung liegt im Closure, nicht in der Hand des Modells.
Die dazugehörige Action GenerateSummaryAI delegiert die eigentliche Datenbeschaffung an das Modell und ersetzt am Ende nur den Zusammenfassungs-Text durch die vom Modell geschriebene Variante. Fällt das Modell aus, greift der Template-Text aus GenerateSummary. Der Rückgabewert bleibt in beiden Fällen gleich geformt, also ist die KI-Variante ein sauberer Drop-in-Ersatz und kein zweiter Codepfad, den man getrennt pflegen müsste.
Was Jido sonst noch mitbringt
Was ich bisher gezeigt habe, ist der Kern: Action, Agent, Tool. Jido bringt darüber hinaus einiges mit, das ich hier nur anreiße. Manches nutze ich im Projekt leicht, manches liegt noch ungenutzt bereit. Der Vollständigkeit halber ein Überblick.
- Jido.Exec, die Ausführungs-Engine. Statt
Action.run/2direkt aufzurufen, kann man eine Action überJido.Exec.run/3laufen lassen. Dann bekommt man Parameter- und Ausgabe-Validierung, automatische Retries mit exponentiellem Backoff, Timeouts, asynchrone Ausführung (run_async/await) und Kompensation, also ein sauberes Zurückrollen, wenn ein Schritt scheitert. Das ist genau der validierende Pfad, den ich in den Lessons Learned noch nachziehen will. - Signals und Direktiven. Ein Agent führt Seiteneffekte nicht selbst aus, sondern gibt typisierte Direktiven wie
%Emit{}zurück. Die Runtime macht daraus Signale im CloudEvents-Format und verschickt sie über PubSub, HTTP oder einen Bus. So reden Agenten miteinander, ohne sich direkt zu kennen. - Sensors. Ein
Jido.Sensorverwandelt externe Ereignisse in Signale. Genau nach diesem Muster hängt bei Fiatbitcoin der Fehler-Sensor an dererror_tracker-Tabelle und speist die Self-Healing-Pipeline. - Plugins. Ein Plugin bündelt Actions, eigenen Zustand und Routing-Regeln zu einer wiederverwendbaren Fähigkeit, die man per
plugins:an mehrere Agenten hängt. Nützlich, sobald sich Fähigkeiten über Agenten hinweg wiederholen. - Per-Agent-Cron. Jido bringt einen eigenen, prozess-lokalen Scheduler mit (
Jido.Scheduler), sodass ein Agent seine Läufe selbst planen kann. Ich benutze dafür weiterhin Oban, weil das schon im Projekt steckt, aber die Option liegt bereit. - Memory, Thread und Persistenz. Ein Agent hat ein eingebautes Gedächtnis und ein append-only Ereignis-Log und kann sich per
Jido.Persistin einen Checkpoint schreiben und später wieder auftauen. Für langlaufende, zustandsbehaftete Agenten interessant. Wir haben ein eigenes Gedächtnis, deshalb liegt das bei uns brach. - Tool-Konvertierung fertig eingebaut. Das Umhüllen einer Action als LLM-Tool, das ich weiter oben von Hand gemacht habe, gibt es auch fertig:
Jido.Action.Tool.to_tool/2erzeugt aus dem Action-Schema das Tool-Map inklusive JSON-Schema. Ich habe es von Hand gebaut, weil ich dieuser_idins Closure binden und die Ausgabe selbst als JSON kodieren wollte. - Observability.
Jido.Observelegt Telemetry-Spans um Action- und Agent-Aufrufe und reichert sie mit Korrelations-IDs an. Wer schon eine Telemetry-Pipeline hat, hängt sich einfach dran.
Der rote Faden bleibt derselbe: All das baut auf der Action und ihrem Vertrag auf. Man kann klein anfangen und die schwereren Werkzeuge dazunehmen, wenn ein Agent sie wirklich braucht.
Wo Jido nicht hilft, und das ist in Ordnung
Nicht jeder Agent im Projekt ist ein Jido-Agent, und das ist Absicht. Der BlockEventAgent zum Beispiel hört auf ZMQ-Events vom lokalen Bitcoin-Node und schreibt pro neuem Block einen Eintrag. Er ist ein schlichter GenServer:
defmodule Backend.Agents.BlockEventAgent do
use GenServer
@impl true
def init(_opts) do
Phoenix.PubSub.subscribe(Backend.PubSub, "bitcoin_zmq")
{:ok, %{}}
end
@impl true
def handle_info({:zmq_raw_block, hash, raw_hex}, state) when is_binary(raw_hex) do
Task.start(fn -> safe_record(hash, raw_hex) end)
{:noreply, state}
end
end
Reines Reagieren auf einen PubSub-Topic, ein bisschen Zustand, kein Bedarf für ein Action-Schema oder einen Plan. Hier würde Jido nur Zeremonie hinzufügen, ohne etwas zu vereinfachen. Die Faustregel, die sich für mich herausgeschält hat: Sobald ein Agent mehrere benannte Fähigkeiten hat, die auch von außen (Cron, API, LLM) einzeln aufrufbar sein sollen, lohnt sich Jido. Bei einem einzelnen reaktiven Handler ist ein GenServer ehrlicher.
Und der zustandsbehaftete Agent-Modus mit plan/run? Den benutze ich im Projekt fast nie. Die meiste Arbeit ist ein Aufruf rein, ein Ergebnis raus, ohne dass zwischen den Aufrufen etwas gehalten werden muss. Jido zwingt einen nicht in den Prozess-Modus, und das ist gut so. Es wäre unehrlich zu behaupten, wir würden das ganze Framework ausreizen.
Lessons Learned
Drei Dinge, die sich bewährt haben, und eins, bei dem ich nachschärfen würde.
1. Der einheitliche Vertrag ist das eigentliche Feature.
Nicht die Agent-DSL, nicht der Prozess-Modus. Dass jede Action run/2 heißt und {:ok, map} oder {:error, reason} liefert, ist das, was Komposition, Wiederverwendung und Tool-Calling überhaupt erst mühelos macht. Der Rest von Jido baut nur darauf auf.
2. Actions sind von Natur aus leicht zu testen.
Eine Action ist eine Funktion mit klarer Ein- und Ausgabe. Kein Prozess, kein Mock-Framework, kein Setup. GenerateSummaryAI nimmt sogar die LLM-Funktion über den Kontext entgegen, sodass man im Test ein Stück Prosa zurückgeben kann, ohne je ein echtes Modell anzufassen. Das ist kein Zufall, sondern fällt direkt aus dem Action-Design.
3. Nicht alles muss ein Agent sein.
Der Reflex, jeden Hintergrundprozess in die neue Abstraktion zu pressen, kostet mehr, als er bringt. Ein GenServer, der auf ein PubSub-Event reagiert, darf ein GenServer bleiben. Jido dort einzusetzen, wo es passt, und dort wegzulassen, wo es nicht passt, hat die Codebasis eher entlastet als vereinheitlicht.
4. Was ich nachschärfen würde.
Die Schema-Validierung nehme ich noch nicht konsequent mit. Die Bequemlichkeitsfunktionen rufen Action.run/2 direkt auf und umgehen damit die Prüfung, die der Executor mitbringen würde. Bei internen, typsicheren Aufrufen fällt das nicht auf, aber am REST-Rand, wo Nutzereingaben ankommen, würde ich die Aufrufe künftig über den validierenden Pfad schicken, statt mich auf die Controller-Schicht zu verlassen. Der Vertrag ist nur so viel wert, wie er auch durchgesetzt wird.
Wenn euch eine Stelle genauer interessiert, das Tool-Calling, die Komposition über with oder die Frage, wann ein GenServer die ehrlichere Wahl ist, schreibt es in die Kommentare. Ich gehe gerne tiefer.
- Link abrufen
- X
- Andere Apps



Kommentare