Zwei Welten, eine Plattform
ORCONIC orchestriert zwei sehr unterschiedliche Arten von Schritten:
- Deterministische Operationen: "Schicke diese E-Mail", "Lege diesen Datensatz in Pipedrive an", "Buche diese Position in DATEV". Sie sind seiteneffekt-behaftet, idempotenzpflichtig und müssen exakt einmal ausgeführt werden.
- Reasoning-Schritte: "Klassifiziere diese E-Mail in eine von 12 Kategorien", "Extrahiere die Felder aus diesem PDF", "Plane eine Antwort auf diese Beschwerde". Sie sind nicht-deterministisch, müssen aber jederzeit reproduzierbar protokolliert sein.
Die Versuchung ist groß, alles über einen Hammer zu lösen — entweder einen Agent-Loop, der "alles selbst entscheidet", oder einen starren Workflow-DAG mit LLM-Calls als isolierten Tasks. Wir haben beides ausprobiert. Beides ist falsch.
Unsere Architektur
Unsere Lösung kombiniert beide Welten in einer klar abgegrenzten Verantwortungsteilung:
@langchain/langgraphverwaltet alle Reasoning-Sequenzen. Ein Agent in LangGraph hat einen klar definierten State-Graphen, Memory-Lookups, Tool-Auswahl, Reflexionsschritte. Hier passiert alles, was Modelloutput beinhaltet.- Eigener
workflow-engine(DAG-basiert, BullMQ-backed) orchestriert die deterministischen Schritte. Ein DAG-Node weiß, wie er einen LangGraph-Subgraphen aufruft, das Resultat in seinen State integriert und den nächsten Node aktiviert.
Auf der Konsumenten-Seite sieht das so aus:
const workflow = defineWorkflow({
nodes: [
{ id: "ingest", type: "trigger", source: "webhook:lead" },
{ id: "enrich", type: "tool", tool: "clearbit.enrich" },
{ id: "classify", type: "agent", graph: "lead-classifier" },
{ id: "approve", type: "approval-gate", roles: ["sales-lead"] },
{ id: "create-deal", type: "tool", tool: "pipedrive.deal.create" },
{ id: "notify", type: "tool", tool: "slack.message.send" }
],
edges: [
{ from: "ingest", to: "enrich" },
{ from: "enrich", to: "classify" },
{ from: "classify", to: "approve", condition: "result.confidence < 0.8" },
{ from: "classify", to: "create-deal", condition: "result.confidence >= 0.8" },
{ from: "approve", to: "create-deal", on: "approved" },
{ from: "create-deal", to: "notify" }
]
});
Das ist lesbar, testbar und überlebt einen Server-Crash mitten in der Ausführung.
Approval-Gates richtig gemacht
Ein Approval-Gate ist mehr als eine "warte auf Klick"-Pause. In einem produktiven System muss er Folgendes leisten:
- Persistenz über Restarts: Der Workflow-State muss in einer dauerhaften Stelle abgelegt sein, nicht im Prozess-Speicher.
- Mehrfache Approver-Rollen mit Quorum: "2 von 3 Sales-Leads müssen freigeben."
- Begründungspflicht bei Ablehnung, gespeichert im Audit-Log.
- Timeout: Wenn niemand binnen 24 Stunden reagiert, wird der Workflow eskaliert oder beendet.
- Idempotenz: Wer zweimal auf "Freigeben" klickt, löst nicht zweimal die nachfolgenden Schritte aus.
Unsere Implementierung speichert den Gate-Zustand in der workflow_runs.state-Spalte als JSON-Blob mit einer Version, einer Lock-Sequenz und einer Liste pending Approver. Bei einem Klick auf "Freigeben" wird der State unter Lock geupdatet, der DAG-Executor wird via BullMQ-Job neu eingereiht und der Workflow läuft am letzten erfolgreichen Schritt weiter.
Crash-Resume: was wir gelernt haben
Crash-Resume bedeutet: wenn der Worker-Prozess mitten in einem Workflow stirbt — durch OOM, Deployment, Hardware-Ausfall —, dann läuft der Workflow nach einem Neustart genau dort weiter, wo er war. Nicht ein Schritt zurück, nicht ein Schritt zu viel.
Das klingt einfach. Es ist nicht einfach. Drei Lektionen aus zwei Quartalen produktivem Betrieb:
Lektion 1: Idempotenzschlüssel sind Pflicht
Jeder Tool-Call, jeder Agent-Schritt, jeder externe Side-Effect muss einen deterministischen Idempotenzschlüssel haben. Wir verwenden ${workflow_run_id}:${node_id}:${attempt}. Wenn der Worker stirbt, nachdem er Stripe einen Refund ausgelöst hat, aber bevor er das Ergebnis in der DB festgeschrieben hat, dann führt der Re-Try gegen Stripe nicht zu einem zweiten Refund — Stripe deduplisiert über den Idempotenzschlüssel.
Lektion 2: Trennt "ich habe es versucht" von "ich habe es geschafft"
Wir schreiben für jeden Node zwei Audit-Einträge: attempted vor dem Side-Effect, succeeded danach. Beim Resume schauen wir auf den letzten Zustand. Wenn nur attempted da steht, fragen wir den Provider, was tatsächlich passiert ist (Stripe-Charge-Status, E-Mail-Versand-Status), bevor wir entscheiden, ob wir retryen oder weiterspringen.
Lektion 3: LangGraph-State muss serialisierbar sein
LangGraph erlaubt es, Funktionen im State zu speichern. Tut das niemals. Wir halten den gesamten State als pures JSON. Closures, RegExps oder Date-Objekte in Subgraphen sind verboten — sonst kommt beim Resume eine kaputte Variante zurück, die zu schwer reproduzierbaren Bugs führt.
Loops, Parallelität und Timeouts
Manche Workflows brauchen Schleifen ("für jeden Lead ..."), manche parallele Branches ("benachrichtige Slack und schicke E-Mail gleichzeitig"), manche Timeouts ("warte maximal 60 Sekunden auf das LLM"). Unser Engine kann alles drei nativ.
- Loops über
loopNodemititems,concurrencyundaggregator. - Parallel über
parallelNodemitbranches. Ergebnisse werden zusammengeführt. - Timeouts über
pTimeout. Bei Überschreitung Failover auf einen Fallback-Branch oder Eskalation.
Spannender Effekt: durch die Trennung von DAG und LangGraph konnten wir die LangGraph-Subgraphen einzeln testen — als reine Funktion State -> State —, ohne den Workflow-Engine hochfahren zu müssen. Das hat unsere Test-Pyramide signifikant gesund gehalten.
Was als nächstes kommt
Der DAG-Executor läuft heute in BullMQ. Wir evaluieren ein Plugin-System für Distributed Sagas, bei dem ein einzelner Workflow über mehrere Workspaces hinweg koordinieren kann — Beispiel: ein Konzern mit fünf Töchtern und einer zentralen Compliance-Pipeline.
Bis dahin gilt: keine Magie. Zwei Engines, klare Verantwortung, ein gemeinsames Audit-Log. Das ist die Architektur, die uns nachts schlafen lässt.