Warum Result, Maybe und Validation das universelle Steuerungsprotokoll für KI-Agent-Workflows sind.
Im vorherigen Artikel haben wir Funktoren kennengelernt: Sie transformieren Werte innerhalb eines Kontexts. Aber was, wenn die Transformation selbst einen neuen Kontext erzeugt?
def parse_int(s: str) -> Maybe:
... # Gibt Just(42) oder Nothing() zurück
Just("42").map(parse_int) # → Just(Just(42)) – verschachtelt!
Das Problem: map verpackt das Ergebnis nochmal. Wir brauchen eine Operation, die den Kontext flachklopft: bind (auch flatMap oder >>= genannt). Und genau das macht eine Monade.
1. Q: Was ist eine Monade?
A: Ein Monoid in der Kategorie der Endofunktoren.
Diese Aussage sollte man zum späteren Gebrauch im Hinterkopf behalten, aber jetzt erstmal weiter mit der praktischen Definition:
Eine Monade ist ein Funktor mit einer zusätzlichen Operation, genannt bind oder flatMap:
map(Funktor): Wendet eine FunktionA → Binnerhalb des Kontexts anbind(Monade): Wendet eine FunktionA → M[B]an und macht das Ergebnis wieder flach
Dazu drei Gesetze:
- Linke Identität:
return(a).bind(f) == f(a) - Rechte Identität:
m.bind(return) == m - Assoziativität:
m.bind(f).bind(g) == m.bind(λx. f(x).bind(g))
Das klingt sehr abstrakt, aber die Intuition ist einfach: Eine Monade ist ein Wert im Kontext mit der Fähigkeit, Operationen zu verketten, wobei der Kontext automatisch verwaltet wird.
"Monads let us compose functions that have effects. The key insight is that the plumbing is hidden — you only see the happy path." — Bartosz Milewski: Category Theory for Programmers, S. 317
"A monad is just a monoid in the category of endofunctors." — Saunders Mac Lane: Categories for the Working Mathematician
2. Result als Agent-Protokoll
Die Result-Monade ist das zentrale Protokoll für Agent-Workflows:
Ok(value)= „Erfolg, weiter mit dem nächsten Schritt"Err(error)= „Fehler, Pipeline stoppt"
@dataclass(frozen=True)
class Ok:
value: any
def map(self, f): return Ok(f(self.value))
def bind(self, f): return f(self.value)
@dataclass(frozen=True)
class Err:
error: str
def map(self, f): return self # Fehler durchreichen
def bind(self, f): return self # Fehler durchreichen
Railway Oriented Programming
Das Geniale: Bei Err wird jeder weitere Schritt übersprungen. Das ist Railway Oriented Programming – der Fehler fließt automatisch durch:
def validate_input(data: dict) -> Result:
if "action" not in data:
return Err("Feld 'action' fehlt")
return Ok(data)
def normalize_data(data: dict) -> Result:
return Ok({k.lower().strip(): v for k, v in data.items()})
def enrich_with_metadata(data: dict) -> Result:
return Ok({**data, "processed": True})
# Pipeline: validate → normalize → enrich
pipeline = pipeline(validate_input, normalize_data, enrich_with_metadata)
pipeline({"action": "search"}) # → Ok({...})
pipeline({"no_action": True}) # → Err("Feld 'action' fehlt")
Für den KI-Agent ist das ideal: Er muss sich nicht um Fehlerbehandlung kümmern. Der erste Fehler stoppt die Pipeline, und er bekommt eine klare Fehlermeldung.
Monaden-Gesetze verifizieren
# Linke Identität: Ok(5).bind(f) == f(5)
f = lambda x: Ok(x * 2)
assert Ok(5).bind(f) == f(5) # Ok(10) == Ok(10) ✓
# Rechte Identität: m.bind(Ok) == m
assert Ok(5).bind(Ok) == Ok(5) # ✓
assert Err("e").bind(Ok) == Err("e") # ✓
# Assoziativität: m.bind(f).bind(g) == m.bind(λx. f(x).bind(g))
g = lambda x: Ok(x + 1)
assert Ok(5).bind(f).bind(g) == Ok(5).bind(lambda x: f(x).bind(g)) # ✓
Agent-Workflow-Orchestrierung
def run_workflow(steps, initial):
"""Führt benannte Schritte aus – stoppt beim ersten Fehler."""
result = Ok(initial)
log = []
for name, fn in steps:
result = result.bind(fn)
if result.is_ok:
log.append(f"✓ {name}")
else:
log.append(f"✗ {name}: {result.error}")
break
return WorkflowResult(steps_completed=..., final_result=result, log=tuple(log))
→ Vollständiger Code: src/agent_result.py
3. Maybe für optionale Daten
Die Maybe-Monade modelliert optionale Werte – ohne None/Null-Chaos:
Just(value)= Wert vorhandenNothing()= kein Wert
@dataclass(frozen=True)
class Just:
value: any
def bind(self, f): return f(self.value)
def or_else(self, default): return self.value
@dataclass(frozen=True)
class Nothing:
def bind(self, f): return self
def or_else(self, default): return default
Agent-Use-Cases
Sicherer Dictionary-Zugriff:
def from_dict(d: dict, key: str) -> Maybe:
if key in d:
return Just(d[key])
return Nothing()
# Kein KeyError, kein try/except
from_dict(config, "model").or_else("gpt-4")
Sicheres Parsing:
def safe_parse_int(s: str) -> Maybe:
try:
return Just(int(s))
except (ValueError, TypeError):
return Nothing()
safe_parse_int("42") # → Just(42)
safe_parse_int("abc") # → Nothing()
Fallback-Ketten:
def chain_lookups(*lookups):
"""Gibt das erste Just zurück."""
for lookup in lookups:
result = lookup()
if isinstance(result, Just):
return result
return Nothing()
# Agent sucht: erst Cache, dann DB, dann Default
chain_lookups(
lambda: from_dict(cache, "key"),
lambda: from_dict(database, "key"),
lambda: Just("default"),
)
Immutable Konfiguration:
@dataclass(frozen=True)
class AgentConfig:
settings: tuple[tuple[str, str], ...] = ()
def get(self, key: str) -> Maybe:
for k, v in self.settings:
if k == key: return Just(v)
return Nothing()
def with_setting(self, key: str, value: str) -> AgentConfig:
# Gibt neue Config zurück – Original bleibt unverändert
...
→ Vollständiger Code: src/agent_maybe.py
4. Validation für parallele Fehlersammlung
Result stoppt beim ersten Fehler. Aber manchmal will man alle Fehler auf einmal:
@dataclass(frozen=True)
class Valid:
value: any
@dataclass(frozen=True)
class Invalid:
errors: tuple[str, ...]
Applicative Kombination
def combine(*validations):
"""Alle Valid → Valid(tuple), mindestens ein Invalid → Invalid(alle Fehler)."""
errors, values = [], []
for v in validations:
match v:
case Valid(value): values.append(value)
case Invalid(errs): errors.extend(errs)
if errors:
return Invalid(tuple(errors))
return Valid(tuple(values))
Agent-Use-Case: Tool-Eingabe validieren
def validate_tool_input(data: dict) -> Validation:
action_v = validate_not_empty("action", data.get("action", ""))
target_v = validate_not_empty("target", data.get("target", ""))
return combine_with(
lambda a, t: ToolInput(action=a, target=t),
action_v, target_v,
)
# Alle Fehler auf einmal:
validate_tool_input({})
# → Invalid(("action darf nicht leer sein", "target darf nicht leer sein"))
Für den KI-Agent ist das besonders wertvoll: Statt nach jedem Fehler einzeln zu korrigieren, bekommt er alle Probleme auf einmal und kann sie in einem Schritt beheben.
→ Vollständiger Code: src/agent_validation.py
5. Monaden als universelles Agent-Interface
Alle drei Monaden folgen demselben Interface:
| Monade | map |
bind |
Kontext |
|---|---|---|---|
| Result | Transformiert Ok-Wert | Verkettet Schritte, Err stoppt | Erfolg/Fehler |
| Maybe | Transformiert Just-Wert | Verkettet Lookups, Nothing stoppt | Vorhanden/Nicht vorhanden |
| Validation | Transformiert Valid-Wert | — (Applicative statt Monade) | Gültig/Alle Fehler |
Der KI-Agent muss nur ein Pattern kennen:
step1(input).bind(step2).bind(step3)
Und drei Entscheidungen treffen:
- Result → wenn Fehler den Workflow stoppen sollen
- Maybe → wenn fehlende Werte ok sind (mit Fallback)
- Validation → wenn alle Fehler gesammelt werden sollen
Die zentrale Erkenntnis
Monade = Protokoll für „was passiert bei Erfolg/Fehler".
Die Semantik steckt in der Datenstruktur, nicht im Kontrollfluss. Der Agent braucht keine if/else-Ketten, keine try/except-Blöcke – die Monade regelt das automatisch.
Selbst ausprobieren
Genau das Railway-Oriented-Programming-Muster aus Abschnitt 2 lässt sich in wenigen Zeilen direkt ausprobieren: Ok/Err mit map/bind, drei winzige Pipeline-Schritte, und eine generische pipeline-Funktion, die sie verkettet. Beachte: Im Pipeline-Code gibt es keinen einzigen if/else-Fehlerzweig – Err reicht sich selbst durch.
from dataclasses import dataclass
@dataclass(frozen=True)
class Ok:
value: object
def map(self, f): return Ok(f(self.value))
def bind(self, f): return f(self.value)
@dataclass(frozen=True)
class Err:
error: str
def map(self, f): return self
def bind(self, f): return self
def validate_input(data):
if "action" not in data:
return Err("Feld 'action' fehlt")
return Ok(data)
def normalize_data(data):
return Ok({k.lower().strip(): v for k, v in data.items()})
def enrich_with_metadata(data):
return Ok({**data, "processed": True})
def pipeline(*steps):
"""Verkettet Schritte vom Typ A -> Result[B]."""
def run(initial):
result = Ok(initial)
for step in steps:
result = result.bind(step)
return result
return run
run = pipeline(validate_input, normalize_data, enrich_with_metadata)
for case in [
{"action": "Search", "Query": "Funktor"},
{"no_action": True},
{"action": "SUMMARIZE", "TARGET": "doc.pdf"},
]:
print(f"{case!r:55} → {run(case)}")
Erweiterungs-Idee: Ergänze einen Schritt log_result(name), der via map einen Eintrag in processed_log schreibt – map bleibt rein, weil der ganze State immutabel weitergereicht wird, und die Pipeline ändert sich nicht.
Ausblick
Jetzt haben wir die Bausteine: Pure Functions, Immutability, Funktoren, Monaden. Im nächsten Artikel setzen wir alles zusammen: Komposition, konfigurierbare Pipelines, Tool-Registry und automatische Code-Validierung.
Quellen
- Bartosz Milewski: Category Theory for Programmers, S. 317, 323
- Saunders Mac Lane: Categories for the Working Mathematician (Referenz)
- Scott Wlaschin: Domain Modeling Made Functional, S. 215, 225
- Eugenia Cheng: The Joy of Abstraction, S. 293
- FP in Kotlin by Tutorials, S. 277, 317
Code-Beispiele
src/agent_result.py– Result-Monade für Agent-Workflows mit Pipeline und Orchestrierungsrc/agent_maybe.py– Maybe-Monade für optionale Daten, Config und Fallback-Kettensrc/agent_validation.py– Validation für parallele Fehlersammlung bei Tool-Eingaben