Startseite / Artikel

Wiederholungssichere Agenten: Vermeidung von LLM-Aufrufen im Workflow durch zeitliche Strukturen.

Warum ein Agent, der während des Laufs abstürzt, sein LLM nicht erneut aufrufen darf: Aktivitäten als Grenze zum Nichtdeterminismus, Wiederholungsmechaniken, Herzschläge, Wiederholungsversuche und zustandslose Graphen.

Verlag: REACTAPP.TOP16 Min. Lesezeit3685 Wörter

Quellen: Fixing Non-Deterministic Replay Bugs in AI Agent Loops

Stellen Sie sich einen Agenten in Schritt 7 von 10 vor, wenn sein Worker abstirbt: ein OOM-Kill, ein Wartungsevent oder ein Kernel-Panic. Wenn der LLM-Aufruf innerhalb des Workflows stattfindet, wird die Wiederherstellung zu einer stillen Überarbeitung der Vorgeschichte. Der Workflow ruft das Modell erneut auf, erhält eine andere Antwort, und der Agent landet an einem anderen Ort als dort, wo er gestorben ist. Dafür werden dieselben Tokens zweimal bezahlt, und die wiederhergestellte Ausführung unterscheidet sich von der ursprünglichen.

Es handelt sich dabei nicht um ein Gedankenexperiment. Eines Nachts um 02:14 schloss ein Support-Agent ein Kundenkonto, das eigentlich eine 30-tägige Frist erhalten hätte. Der Tool-Aufruf war erfolgreich. Als ein Ingenieur dieselbe Kundennachricht erneut abspielte, verhielt sich der Agent korrekt. Im Nachbesprechungsgespräch fehlte der Bereich, in dem die Ursache hätte stehen sollen, und wurde mit den Worten „kann nicht reproduziert werden“ gefüllt. Ein nicht reproduzierbarer Fehler ist nicht verschwunden – es handelt sich dabei um einen Fehler, der nicht mehr untersucht werden kann.

Die Regel, die dies verhindert, ist einfach: Der Code, der einen Agenten steuert, muss deterministisch sein – das LLM hingegen nicht – daher muss das LLM hinter einer Grenze stehen, die nicht unbedingt vorhanden sein muss. Temporal wird in den Beispielen verwendet, doch das Muster ist framework-unabhängig. Derselbe Ansatz gilt für AWS Step Functions, Prefect und Dagster, für einen Abfrager, der eine Datenbank ausliest, sowie für eine selbst erstellte Zustands-Tabelle, und in späteren Abschnitten wird diese Idee auf diese Frameworks übertragen.

Der Fehler vor dem Wortschatz

Nehmen wir einen Sourcing-Agenten mit fünf Schritten: Recherche, Bewertung, Entscheidung, Freigabe, Bestätigung. Der Entscheidungsschritt ruft ein LLM auf, und der natürliche, verlockende Ort für diesen Aufruf ist direkt in der Workflow-Klasse:

# ❌ What I almost wrote — LLM call INSIDE the workflow.
@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        suppliers = await workflow.execute_activity(research_activity, brief)
        scored = await workflow.execute_activity(score_activity, suppliers)

        # The LLM call — right here, in the workflow. THIS IS THE BUG.
        # If worker dies here, replay will re-call the LLM and mutate state/history.
        response = await llm_client.complete(
            messages=build_decide_prompt(scored),
            temperature=0.0,
        )
        selected = parse_llm_decision(response.content, scored)

        approval = await workflow.wait_condition(...)
        # ...create PO, initiate payment

Es liest sich in Ordnung. Ein erfahrener Ingenieur, der Temporal noch nie gesehen hat, würde es als gut bezeichnen. Das Problem tritt auf, wenn der Mitarbeiter abstürzt, sobald der Aufruf des LLM zurückgegeben wurde und die menschliche Freigabe noch aussteht.

Die Lösung führt den Aufruf in eine Aktivität um – eine Funktion, die vom Workflow geplant wird und deren Ergebnis aufgezeichnet wird:

# ✅ The fix — LLM call moved into an activity.
@activity.defn
async def decide_activity(scored: list[dict], brief: SourcingBriefInput) -> dict:
    """The LLM call lives here — in the activity, not the workflow."""
    from app.agentmesh.llm import get_llm_client

    client = get_llm_client()
    response = await client.complete(
        messages=build_decide_prompt(scored),
        temperature=0.0,
    )
    selected, rationale = parse_llm_decision(response.content, scored)
    return {"selected_supplier": selected, "decision_reason": rationale}


@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        suppliers = await workflow.execute_activity(research_activity, brief)
        scored = await workflow.execute_activity(score_activity, suppliers)

        # The LLM call is NOWHERE in the workflow.
        # The activity result is recorded. On replay, it's injected.
        decision = await workflow.execute_activity(
            decide_activity,
            args=(scored, brief),
        )

Ein Codeblock wurde verschoben. Beim Wiederspielen wird die Aktivität nicht erneut ausgeführt, da der Workflow das aufgezeichnete Ergebnis erhält und das Modell niemals ein zweites Mal aufgerufen wird. Warum das funktioniert, ist das eigentliche Thema.

Das Prinzip: Wiederspiel muss die Geschichte reproduzieren

Während ein Workflow läuft, schreibt Temporal jeden Schritt in eine Ereignisgeschichte – ein nur wachsendes Protokoll – mit Einträgen wie „Workflow gestartet“, „Aktivität geplant“, „Aktivität abgeschlossen“, „Signal empfangen“ und „Timer ausgelöst“. Ereignisse sind unveränderlich, und die Geschichte ist die Quelle der Wahrheit.

Nach einem Absturz spielt Temporal den Workflow erneut ab. Ein neuer Worker führt die Funktionslogik des Workflows von ihrer ersten Zeile aus aus. Bei jedem Aufruf wie workflow.execute_activity(...) oder workflow.wait_condition(...) prüft Temporal die Historie. Wenn ein passendes Ereignis vorhanden ist, injiziert es das aufgezeichnete Ergebnis anstelle einer Ausführung. Wenn die Funktion einen Aufruf durchführt, der sich von dem in der Historie gespeicherten unterscheidet, löst Temporal einen Fehler wegen Nichtbestimmtheit aus.

Das ist Event Sourcing. Die Historie ist das Protokoll, die Workflow-Funktion ist die Verarbeitung dieses Protokolls, und dieselben Ereignisse in derselben Reihenfolge durch dieselbe Funktion ergeben immer denselben Zustand. Temporals Dokumentation formuliert es klar: Workflow-Code muss deterministisch sein, um Wiedergabe zu ermöglichen, während nicht-deterministische Operationen wie API-Aufrufe, LLM-Aufrufe und Datenbankabfragen in Aktivitäten gehören. Die Aktivität wird einmal ausgeführt, ihr Ergebnis wird in die Historie aufgenommen, und ab dann lesen Wiedergaben dieses Ergebnis daraus.

Jeder, der bereits mit Postgres gearbeitet hat, kennt diese Struktur. Das Write-Ahead-Log ist die Aufzeichnung, die Tabellendateien sind eine materialisierte Ansicht, und die Wiederherstellung liest das Log aus. Ein Handbuch zu Event Sourcing formuliert dasselbe Prinzip: dieselben Ereignisse, dieselbe Reihenfolge, derselbe Endzustand – ohne Uhren, zufällige IDs oder zeitabhängige Abfragen innerhalb der Verarbeitung.

Wie eine Verletzung aussieht

Wenn das LLM im Workflow integriert ist, brechen gleichzeitig zwei Dinge zusammen.

Die Kosten verdoppeln sich. Ein Anruf, der beim ersten Mal drei Cent kostete, kostet beim Wiederabspielen erneut drei Cent. Ein Team stellte fest, dass unkontrollierte Wiederholungsversuche innerhalb von dreißig Sekunden für ein einziges Gespräch etwa zwei Dollar an Tokens verbrauchten, während das Fehlerbehandlungssystem diesen Wert auf einen Cent senkte – das ist ein Unterschied von 200-fach.

Die Ausführung weicht ab. Die ursprüngliche Ausführung wählte Lieferant B, das Wiederabspielen hingegen Lieferant A. Da die Historie nicht mehr übereinstimmt, gibt Temporal einen Fehler wegen Nichtbestimmtheit aus, und der Workflow ist funktionsunfähig.

Das sind keine seltenen Vorfälle. Etwa 0,8 % der Anrufe bei Anthropic und OpenAI scheitern innerhalb eines Zeitraums von dreißig Tagen, wodurch ein Agent, der pro Aufgabe 12 Modellaufrufe durchführt, mit einer Fehlerrate von fast 9,2 % pro Aufgabe konfrontiert ist. Wenn das LLM im Workflow vorhanden ist, wird es bei jedem Neversuch erneut aufgerufen. Wenn es in einer Aktivität ist, erhält der Neversuch das bereits aufgezeichnete Ergebnis. Temporals eigene Erläuterungen machen dies klar: Der Workflow ist die Orchestrierungsschicht und muss deterministisch sein, damit Agenten Prozessabstürze überstehen können, während die Aktivitäten der Ort sind, an dem die eigentliche Arbeit stattfindet – einschließlich LLM-Aufrufe, Toolaufrufe und API-Anfragen – und diese können so unvorhersehbar sein, wie es nötig ist.

Warum eine Temperatur von null kein Schutz ist

Immer wieder wird einwandt, dass die Temperatur auf 0 gesetzt wurde. Das hilft aus mehreren Gründen nicht:

  • Temperatur null bedeutet gieriges Dekodieren, nicht Determinismus. In einer Analyse ergaben tausend identische Anfragen 80 verschiedene Ergebnisse, verursacht durch batchabhängige Unterschiede bei Gleitkommazahlen in GPUs.
  • Der gleiche Modellname bedeutet nicht das gleiche Modell. Anbieter aktualisieren Modelle heimlich, sodass das am Dienstag genutzte Modell nicht unbedingt mit dem vom Mittwoch übereinstimmt.
  • Der Anbieter könnte während der Wiedergabe nicht verfügbar sein. In diesem Fall bleibt der Workflow stecken und wartet.
  • Sogar eine bytegenaue Antwort kostet Geld. Auch eine zweite Berechnung wurde bezahlt.

Ein Entwickler, der unter dem Namen Tisha schreibt, hat die Problematik gut beschrieben: Teams verbringen Wochen damit, ein Modell auf bitweisen Determinismus zu zwingen, scheitern und kommen zu dem Schluss, das System sei unvorhersehbar – obwohl der entscheidende Determinismus in einer höheren Schicht, in der Orchestrierung, liegt. Sie haben sich auf die falsche Schicht konzentriert.

Die Aktivität als Membran

Die Veränderung ist strukturell. Innerhalb einer Aktivität ist alles zulässig. Außerhalb davon muss alles deterministisch sein.

Ein Workflow kann Aktivitäten aufrufen, auf Signale warten, Timer einstellen und in Zweige abzweigen, sofern die Bedingungen für die Abzweigung nur von den Ergebnissen der Aktivitäten sowie den Eingaben des Workflows abhängen. Verboten sind Modellaufrufe, HTTP-Anfragen, das Lesen der Systemuhr, die Erzeugung zufälliger Zahlen sowie alles andere, was zu einem anderen Ergebnis auf einem anderen Rechner zu einem anderen Zeitpunkt führen könnte. All das fällt in die Aktivitäten.

Betrachten Sie es als Anatomie. Was der Workflow ausführt, ist nicht das Agent selbst, sondern die Koordination rund um es, während das Modell lediglich ein Service ist, den das Agent konsultiert. Das Gehirn ist unvorhersehbar, die Wirbelsäule muss stabil sein – und das Gehirn gelangt nicht in die Wirbelsäule.

Wie es in einer echten Codebasis aussieht

Die oben gezeigten vereinfachten Auszüge verbergen eine Einzelheit. In einer echten Codebasis befindet sich der Aufruf des LLM innerhalb von Knoten eines Graphen, und der Graph läuft wiederum innerhalb einer Aktivität – es gibt also zwei Grenzen, nicht eine:

Temporal Workflow (deterministic — replayed)
    └── Activity: run_graph_until_interrupt (non-deterministic — recorded once)
            └── LangGraph StateGraph
                    └── decide_node (async function)
                            └── get_llm_client().complete()  ← the LLM call

Der Workflow selbst ist reine Orchestrierung – eine Abfolge von Aufrufen von execute_activity und wait_condition und nichts weiter. Die execute_activity-Zeile bildet die Grenze. Was innerhalb der Aktivität geschieht, wird aufgezeichnet, und was außerhalb geschieht, wird wiedergegeben:

@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        # ↓ This is the boundary. The activity runs once. Result is recorded.
        graph_result = await workflow.execute_activity(run_graph_until_interrupt, brief, ...)

        # Wait for human signal — a Temporal primitive, not an LLM call.
        # On replay, the signal is injected from history.
        await workflow.wait_condition(lambda: self._approval_received, timeout=timedelta(hours=24))

        # ↓ Another boundary. Resume activity runs once. Result is recorded.
        resume_result = await workflow.execute_activity(resume_graph, self._approval_data, ...)

        # ↓ Side effects — each is its own activity with its own retry policy.
        po_result = await workflow.execute_activity(create_po_activity, args=(...), ...)

Es gibt nichts darin, was einen LLM-Client aufruft, eine HTTP-Anfrage sendet, die aktuelle Zeit liest oder eine Zufallszahl erzeugt. Immer dieselben Eingaben, dieselbe Historie, derselbe Ausführungspfad.

Die Aktivität erstellt das Diagramm und ruft es auf – genau in dieser einzigen Zeile graph.ainvoke() liegt die Nichtdeterminiertheit: Aufrufe des LLM, Aufrufe von Tools sowie Abfragen des Speichers. Die Aktivität gibt ein Dictionary zurück, das aufgezeichnet wird; bei einer Wiedergabe wird der gesamte Vorgang übersprungen.

@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
    graph = build_sourcing_graph(checkpointer=await get_checkpointer())
    config = {"configurable": {"thread_id": activity.info().workflow_id}}

    # ↓ Everything inside this call is non-deterministic. It runs ONCE.
    #   The result dict is recorded in the event history. On replay, it's injected.
    final_state = await graph.ainvoke({"brief": brief, "suppliers": [], "attempts": 0, ...}, config)

    return {"paused": True, "selected_supplier": selected, "suppliers": final_state.get("suppliers", [])}

Am unteren Ende des Stacks befindet sich der Graphenknoten, an dem das Modell tatsächlich ausgelöst wird. Es erstellt die Anfrage anhand der bewerteten Anbieter und früherer Entscheidungen und führt anschließend den Aufruf durch:

async def decide_node(state: AgentState) -> dict:
    scored = state.get("scored_suppliers", [])
    messages = build_decide_prompt(brief.item, brief.quantity, brief.budget, scored, past_decisions)

    # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
    response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)

    selected, rationale = parse_llm_decision(response.content, scored)
    return {"selected_supplier": selected, "decision_reason": rationale, "cost_incurred": response.cost_usd}    # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
    response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)

Das sind drei Ebenen unter dem Workflow: Aktivität, Graph, Knoten und anschließend das LLM. Beim Wiederspielen läuft keines davon, da das Ergebnis der Aktivität injiziert wird. Der Workflow ist deterministisch. Die Aktivität, der Graph und das Modell hingegen nicht, doch die Nichtdeterminiertheit bleibt innerhalb der Grenzen der Aktivität begrenzt. Der Workflow sieht stets nur ein aufgezeichnetes Dictionary – dasselbe bei jedem Wiederspielen.

Wo die Grenzen unklar werden

Das Muster sieht auf dem Papier klar aus, wird in der Praxis aber an einigen Stellen unklar. Der Fall mit mehreren Tagen erfordert besondere Aufmerksamkeit, da sein Versagen subtil ist und zwei Persistenzschichten ohne Vorwarnung auseinanderdriften können.

Wenn der Graph durch LangGraphs interrupt() an einem Genehmigungsnode pausiert, wird die Aktivität abgeschlossen und ein Dictionary an den Workflow zurückgegeben. Der Workflow ruft anschließend workflow.wait_condition() auf, um auf ein menschliches Signal zu warten. Zu diesem Zeitpunkt existieren zwei Kopien des Zustands in zwei Systemen:

Temporal event history             LangGraph checkpoint (Postgres)
  └── ActivityCompleted(result)      └── graph state at interrupt()
        paused: True                       node: "approve"
        selected: SupplierB                 selected: SupplierB
        suppliers: [A, B, C]                suppliers: [A, B, C]

Sowohl Daten befinden sich in Postgres, doch es handelt sich um getrennte Tabellen, die zu unterschiedlichen Zeiten über separate Codepfade mit Daten versorgt werden. Der Checkpoint von LangGraph wird von der Aktivität erstellt, sobald eine Unterbrechung auftritt; sobald die Aktivität wieder fortsetzt, fügt Temporal sein Ereignis für die abgeschlossene Aktivität hinzu. Dadurch entstehen zwei Schreibvorgänge, zwei Systeme und ein logischer Zustand.

Probleme entstehen, wenn nur ein Schreibvorgang überlebt und der andere nicht. Eine Abhilfe ist operativ: Der thread_id muss der Temporal workflow_id sein, der im Gegensatz zu run_id bei Wiederholungen stabil bleibt; die Checkpoint-Tabelle muss die Daten so lange speichern wie Temporals Historie; und die Wiederaufnahmefunktion muss graph.ainvoke(None, config) aufrufen, da das Übergeben von None an LangGraph mitteilt, aus dem Checkpoint zu laden und nicht von vorne anzufangen. Fällt dabei etwas falsch, verschieben sich die Schichten. Der Workflow stürzt nicht ab – er setzt lediglich aus dem falschen Zustand fort.

Die bessere Lösung: den Graph zustandslos machen

Durch das intuitive Design kann der Postgres-Checkpointer von LangGraph den Zustand über die Agenten-Node hinweg zusammen mit der Historie von Temporal verfolgen. Das Nachverfolgen von Fehlerrichtungen während mehrtägiger Pause offenbart die verborgene Falle – ein klassisches Problem der doppelten Schreibvorgänge in zwei Datenbanken. Wenn ein Worker unmittelbar nachdem LangGraph seinen Checkpoint geschrieben hat, aber bevor Temporal die Abschlussmeldung speichert, abstürzt, sind sich die Zustandsverwaltungen nicht einig, und der Workflow setzt sich schweigend aus einem nicht übereinstimmenden Zustand fort.

Anstatt ständig weitere Synchronisierungsregeln für die Threadzuordnung, -speicherung und -Neuladung hinzuzufügen, ist die bessere Entscheidung, den zweiten Zustandsmotor zu entfernen. Ohne seinen Checkpointer wird LangGraph zu einer im Speicher liegenden, zustandslosen Reasoning-Einheit innerhalb der Aktivität. Temporal übernimmt durch workflow.wait_condition() den gesamten Langzeitzustand, die Ausführungshistorie sowie die Pausen durch menschliches Eingreifen. LangGraph arbeitet einfach durch seinen Reasoning-Zyklus, gibt ein Ergebnisdictionary an die Aktivität zurück und beendet seine Arbeit. Das Ergebnis: keine doppelten Schreibvorgänge, kein Risiko von Asynchronität und eine einfachere Bedienoberfläche.

Keines der Tools wird überflüssig. LangGraph bleibt stark bei schnellem Graphen-Routing im Arbeitsspeicher, beim Aufrufen von Tools sowie bei Rechenzyklen auf Knotenebene, während Temporal in Bezug auf Langlebigkeit über mehrere Tage, Wiederherstellung nach Ausfällen und verteilter Orchestrierung überlegen ist. Ein zustandsloser LangGraph innerhalb einer langlebigen Temporal-Aktivität verhindert, dass sie um die Persistenz konkurrieren, und bewahrt somit die Stärken beider Tools.

Eine Einschränkung in vielen Systemen

Orchestrierungsframeworks sind nur einer der Bereiche, in denen das Replay-Prinzip Anwendung findet. Jedes System, das durch Wiedergabe von Daten wiederhergestellt wird, weist dieselben drei Komponenten auf: eine Aufzeichnung der Ereignisse, eine reine Funktion, die diese in einen Zustand zusammenfasst, sowie alles Unvorhersehbare, das auf der anderen Seite einer aufgezeichneten Verbindung gespeichert wird.

Agenten machen die Sache noch schwieriger. In einem klassischen ereignisbasierten Buchhaltungssystem bleibt die apply-Funktion rein, da das Gutschreiben von zehn Dollar auf ein Konto immer zum gleichen Ergebnis führt. Die Geschäftslogik eines Agenten besteht darin, das LLM zu fragen, was zu tun ist – die Antwort unterscheidet sich jedoch jedes Mal. Wenn der „Verstand“ im Wiederabspielpfad liegt, ist ein Wiederabspielen unmöglich; daher muss der „Verstand“ außerhalb dieses Pfads liegen.

Temporal wurde für die Beispielplatform ausgewählt, weil es die Konvention durchsetzt: Sein SDK löst bei Verstößen einen Fehler wegen Nichtbestimmtheit aus, anstatt stillschweigend eine abweichende Ausführung zu erzeugen. Step Functions setzt dies kostenlos durch, da seine deklarative Zustandssprache es unmöglich macht, versehentlich einen Aufruf einer LLM in die Zustandsmaschine einzubauen. Mit einem Outbox-System oder benutzerdefiniertem Polling ist die Durchsetzung manuell. Nichts hindert einen Aufruf einer LLM innerhalb der Polling-Schleife und nichts warnt davor. Die Einschränkung ist in allen Fällen identisch. Die einzige Frage ist, wer ein Verstoß bemerkt: das Framework oder die Person, die um 3 Uhr morgens Dienst hat.

Dieselbe Aufteilung gilt auch für andere Agent-Stacks. Einige erzwingen sie, nämlich Temporal und das Microsoft Agent Framework. Andere stellen die Primitiven bereit und überlassen die Durchsetzung dem Ersteller, wie LangGraph und AutoGen es tun. Wieder andere ignorieren die Angelegenheit völlig, wie Celery und CrewAI. Die praktische Frage ist, wie viel vom Replay-Vertrag von Hand implementiert werden muss.

Zahlen aus externen Vergleichen deuten in dieselbe Richtung. Ein Bericht über die Migration von Celery zu Temporal wies auf ein hohes Risiko von doppelten Ausführungen bei Wiederholungsversuchen hin, insbesondere für Aufgaben, die viel GPU-Zeit verbrauchen. Ein Projekt mit Temporal und CrewAI kam zur oben beschriebenen Architektur, wobei der Orchestrierungsmechanismus als Quelle der Wahrheit für den Zustand und das Agentenframework als zustandsloser Reasoning-Unit genutzt wurde, der von innerhalb einer Aktivität aufgerufen wird. In einem Benchmark zwischen AutoGen und CrewAI schloss AutoGen Aufgaben bei einer p95-Latenz von 2,1 Sekunden ab, gegenüber 4,7 Sekunden bei CrewAI, und hatte zu 94 Prozent Erfolg, verglichen mit 83 Prozent bei CrewAI – was daran erinnert, dass sich die Art und Weise, wie ein Framework mit Fehlern umgeht, in Zahlen widerspiegelt. Das Microsoft Agent Framework bietet Dateibasierten Checkpoint-Speicher sowie fehlertolerante Supersteps, was seine Art und Weise ist, dieselbe Garantie zu bieten.

Was Determinismus kostet

Der Aktivitätsbereich ist nicht frei. Die Inliner-Simplizität verschwindet, da die falsche Version weniger Zeilen aufweist und leichter lesbar ist. Auch der direkte Zugriff auf den Zustand geht verloren, denn eine Aktivität sieht nur das, was ihr übergeben wird, nicht die Interna des Workflows. Zudem treten drei Randfälle auf, die bei einem inliner LLM nicht existieren. Es handelt sich dabei um echte ingenieurtechnische Entscheidungen, nicht um philosophische Kompromisse.

Randfall eins: lange Aktivitäten und Herzschläge

Ein Aufruf eines LLM kann 30 Sekunden dauern. Ein mehrstufiger Agentengraph mit Tool-Aufrufen kann fünf Minuten in Anspruch nehmen. Temporals Standardwert für start_to_close_timeout beträgt 10 Sekunden. Wenn eine Aktivität diese Zeit überschreitet, markiert Temporal sie als fehlgeschlagen und versucht es erneut – wodurch der Modellaufruf wiederholt wird, die Tokens erneut bezahlt werden müssen und die sorgfältig isolierte nicht-deterministische Arbeit erneut ausgeführt wird.

Die Lösung besteht in einem Heartbeat-Timer. Jede Aktivität, die länger als ein paar Sekunden andauert, sollte in regelmäßigen Abständen activity.heartbeat() aufrufen, um Temporal mitzuteilen, dass sie noch aktiv ist. Wenn der Worker abstirbt, verwendet Temporal den letzten Heartbeat, um festzustellen, wie weit die Aktivität bereits fortgeschritten ist. Dadurch kann eine checkpointbare Aktivität wieder aufgenommen werden, während alle anderen Aktivitäten von Null an neu gestartet werden müssen:

@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
    # Long-running: graph may run 5+ minutes with multiple LLM calls
    for node in graph.stream(initial_state, config):
        activity.heartbeat()  # ← "I'm alive, don't timeout me"
        # ... process node output

Es gibt einen Haken: Ein Herzschlag bewahrt den internen Fortschritt der Aktivität nicht auf; er verhindert lediglich den Zeitablauf. Sollte der Worker mitten in der Ausführung abbrechen, beginnt die Wiederholung die Aktivität erneut von vorne und der LLM wird erneut ausgelöst, da das Ergebnis nur dann aufgezeichnet wird, wenn die Aktivität abgeschlossen ist. Bei einer fünfminütigen Aktivität führt ein Abbruch nach vier Minuten dazu, dass vier Minuten ungeschützter Arbeit verloren gehen.

Deshalb teilt das Beispiel die Aktivität in zwei Teile auf – einen, der bis zur Unterbrechung läuft, und einen, der danach fortgesetzt wird. Jeder Teil ist kurz genug, um innerhalb des Zeitlimits abgeschlossen zu werden, und jedes Ergebnis wird unabhängig aufgezeichnet. Die Aufteilung dient der besseren Nachvollziehbarkeit, nicht der Organisation des Codes.

Edge Case zwei: Streaming von Tokens

Wenn das LLM in einem Workflow inline verwendet wird, können Tokens direkt in die HTTP-Antwort des Benutzers übertragen werden. Wenn es in einer Aktivität verwendet wird, gelangen die Tokens in die Ausgabe dieser Aktivität, ein einziger Rückgabewert, der in der Historie aufgezeichnet wird. Bis die Aktivität abgeschlossen ist, erreicht der Benutzer nichts.

Das Streaming ist der am häufigsten vorgebrachte Einwand gegen dieses Muster – und er ist berechtigt. Es gibt drei Ansätze mit zunehmender Komplexität. Der erste akzeptiert, dass die Ausgabe eine Entscheidung und kein Chat-Stream ist, was dem Quellen-Agenten entspricht. Der zweite fügt einen Nebenkanal für mehrstufige Interaktionen wie Interviews hinzu, bei denen der Benutzer jede Antwort sehen muss. Der Nebenkanal kann ein von einem Server gesendeter Events-Endpunkt sein, der von Redis unterstützt wird: Die Aktivität veröffentlicht Tokens in Redis, der Endpunkt streamt sie an den Benutzer, während Temporal nur das endgültige Ergebnis speichert. Ein Benutzer, der die Interaktion unterbricht, kann später zurückkehren und das Workflow-System nach dem Ergebnis fragen. Die Tokens im Stream sind einmalig verwendbar; das gespeicherte Ergebnis hingegen bleibt bestehen.

Edge Case drei: Versuchspolitiken unterscheiden sich

Temporäre Wiederholungsversuche scheitern bei Aktivitäten automatisch, doch ein Scheitern bedeutet für verschiedene Aktivitäten unterschiedliche Dinge. Eine ausschließlich zum Lesen dienende Abfrage und ein Nebeneffekt sollten nicht auf dieselbe Weise wiederholt werden. Bei einem Nebeneffekt wie der Erstellung einer Bestellbestätigung gilt eine strenge Richtlinie, die keine Wiederholung zulässt; dabei sorgt eine Idempotenzschlüssel für Sicherheit:

# Side effect — strict, non-retryable. Idempotency key handles safety.
po_result = await workflow.execute_activity(
    create_po_activity,
    args=(supplier, item, quantity, price),
    retry_policy=workflow.RetryPolicy(
        initial_interval=timedelta(seconds=1),
        maximum_attempts=1,  # ← don't retry. Idempotency key prevents duplicates.
        non_retryable_error_types=["DuplicatePOError"],
    ),
)

# Verification — aggressive retry, but with backoff for eventual consistency
po_verification = await workflow.execute_activity(
    verify_po_exists,
    args=(po_result["po_id"],),
    retry_policy=workflow.RetryPolicy(
        initial_interval=timedelta(seconds=2),  # ← give the DB time to sync
        maximum_attempts=5,
    ),
)

Die Wiederholungspolitik ist Teil des Replay-Vertrags. Wenn eine Aktion fehlschlägt und wiederholt wird, werden die Wiederholungsversuche in der Historie aufgezeichnet, und beim Replay injiziert Temporal das endgültige Ergebnis, ohne die fehlgeschlagenen Versuche erneut auszuführen. Eine Aktion, die eine Bestellbestätigung mit maximum_attempts=3 erstellt, erhält drei Versuche dazu, und ohne eine Idempotenzschlüssel bedeutet das drei Bestellungen. Die beiden Mechanismen arbeiten zusammen: Die Politik entscheidet, wann wiederholt werden soll, und der Schlüssel bestimmt, was dann geschieht.

Das sind die Kosten der Langlebigkeit. Es geht nicht um deterministische versus nicht-deterministische Verfahren. Es geht vielmehr darum, ob etwas mit gewisser Indirektheit langlebig ist oder einfach und nicht wiederherstellbar – ein Produktionsagent sollte auf Langlebigkeit setzen, weil Produktionsausfälle auftreten können. Die Diskussionen über Zuverlässigkeit von Agenten bleiben oft auf der falschen Ebene und konzentrieren sich auf das Modell: bessere Anweisungen, umfassendere Bewertungen, ein intelligenterer Checkpoint im nächsten Quartal. All das stößt an eine harte Obergrenze.

Die einzige Regel, die man beibehalten sollte

Von allem im System widersteht nur das LLM der Wiederholung, und das ist der Grund, warum es dorthin gehört, wo die Wiederholung es niemals erreicht. Temporals FAQ fasst dies noch präziser zusammen: Umgebe alle Aufrufe des LLM stets mit einer Aktivität. Immer – nicht nur in der Regel oder wenn Zeit vorhanden ist –, denn das einzige Mal, bei dem dies ausgelassen wird, ist beim Absturz um 3 Uhr morgens, bei dem die Wiederholung einen anderen Ablauf ergibt und jemand, der im Dienst ist, für einen Agenten verantwortlich sein muss, der über Nacht in einem fehlerhaften Zustand bleibt.

Ohne Vertragsmechanismus für die Wiederausführung sind die drei Schichten – Harness, Loop und Graph – lediglich organisatorischer Natur. Sie helfen dabei, über ein System nachzudenken, tragen aber nicht dazu bei, dass es überlebt. Mit dem Vertragsmechanismus werden sie strukturell: Der Loop setzt von dem letzten abgeschlossenen Schritt fort, wobei Aktivitäten aus der Historie eingefügt werden; der Zustand des Graphen wird an einem Ort gespeichert und von der Wiederaufnahmeeinheit geladen; Tools werden nicht erneut ausgeführt, da die Ergebnisse aufgezeichnet werden und der Idempotenzschlüssel eine zweite Absicherung darstellt. Bei Fehlern bleiben die Schichten getrennt – und das nicht nur aus Grundsatz.

Es bleibt eine Einschränkung: Es gibt keine zeitlichen Garantien dafür, dass ein Workflow nach einem Absturz korrekt wiederausgeführt wird. Es wird nicht garantiert, dass die Geschäftslogik sicher wiederausgeführt werden kann – diese Aufgabe bleibt beim Entwickler. Idempotenz ist das nächste Problem, das gelöst werden muss.