LLM-Agenten mit dem Symfony Workflow Component: Zustandsmaschinen, idempotente Schritte und wiederholbare Fehler

#Symfony Workflow LLM Agent
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Ein deutsches Logistik-SaaS-Unternehmen hat letzten Winter einen LLM-gestützten Dokumentenverarbeitungsagenten ausgeliefert. Der Agent extrahierte strukturierte Daten aus Frachtrechungen, validierte Felder gegen eine Regelmaschine und übertrug Datensätze in ihr ERP. Es funktionierte in Staging. In Produktion begann es am zweiten Tag zu scheitern - nicht weil das Modell falsche Extraktionen machte, sondern weil der HTTP-Aufruf zum ERP manchmal zeitüberschritt, nachdem der Agent bereits in eine Staging-Tabelle geschrieben hatte, was das System in einem halb eingereichten Zustand ohne saubere Möglichkeit zum Wiederholen hinterließ. Das Team hatte eine fähige Modell-Pipeline und eine fragile Orchestrierungsschicht gebaut. Das sind zwei separate Probleme, und nur eines davon ist die Schuld des Modells.

Der Symfony Workflow Component wurde genau für die Orchestrierungshälfte entworfen. Ursprünglich gebaut, um Geschäftsprozesse zu modellieren - Bestellzustandsmaschinen, Dokumentengenehmigungs-Flows, Abonnement-Lebenszyklen - erweist er sich als starke Wahl für LLM-Agent-Loops. Explizite Places und Transitionen geben dir wiederholbaren Zustand. Guards lassen dich Vorbedingungen und Richtlinien durchsetzen. Das Event-System verdoppelt sich als strukturierter Audit-Log. Symfony Messenger behandelt Wiederholungen, Rückdruck und Dead-Letter-Queues. Dieser Beitrag führt durch ein vollständiges Pattern zum Aufbau eines Symfony Workflow LLM Agent, der echte Produktionsfehler überstehen kann.

Warum Agent-Orchestrierung schwieriger ist als es aussieht

Die populäre Rahmung von LLM-Agenten behandelt sie als Loops: beobachte -> begründe -> handle -> beobachte. Diese Rahmung ist für den Happy Path korrekt. Sie verschleiert die Produktionsrealität.

Echte Agentenschritte haben Seiteneffekte - Schreibvorgänge in Datenbanken, Aufrufe externer APIs, Mutationen gemeinsamen Zustands. Wenn ein Schritt mitten in der Ausführung scheitert, brauchst du Antworten auf Fragen, die die Loop-Rahmung ignoriert: Hat der Seiteneffekt bereits stattgefunden? Ist es sicher, ihn zu wiederholen? Ist das Verständnis des Agenten vom aktuellen Zustand noch korrekt? Welchen Schritt wiederholst du, und von welchen Vorbedingungen?

Agentische Features in PHP-Anwendungen haben dieselben Fehlermuster wie jeder verteilte Workflow. Die Modellantwort ist nur ein I/O-Aufruf unter vielen. Den gesamten Loop als eine einzige synchrone Transaktion zu behandeln ist falsch; jeden Schritt als Fire-and-Forget zu behandeln ist ebenfalls falsch. Was du möchtest, ist expliziter Zustand, explizite Transitionen und deterministisches Recovery. Das ist es, was Zustandsmaschinen bieten, und deshalb passt der Symfony Workflow Component sauber auf Agent-Orchestrierung.

Die Kernisolation: Agentenschritt als Workflow-Transition

In Symfony Workflow bewegt sich eine Entität durch Places via Transitionen. Eine Transition feuert nur, wenn die Entität am richtigen Ort ist, ihre Guards bestehen und du sie explizit anwendest. Der Component verfolgt den Zustand auf der Entität und dispatcht Events vor, während und nach jeder Transition.

Mappe das auf einen Agent-Loop: Das Run-Objekt des Agenten ist die Entität. Jeder Tool-Aufruf oder Denkschritt ist eine Transition. Die Menge gültiger Places beschreibt, wo der Agent zu jedem Zeitpunkt legitim sein kann.

Hier ist eine vereinfachte YAML-Definition für einen Dokumentenverarbeitungsagenten:

framework:
  workflows:
    invoice_agent:
      type: state_machine
      marking_store:
        type: method
        property: status
      supports:
        - App\Agent\InvoiceAgentRun
      initial_marking: created
      places:
        - created
        - fetching_document
        - extracting_fields
        - validating_fields
        - writing_to_erp
        - completed
        - failed
      transitions:
        fetch:
          from: created
          to: fetching_document
        extract:
          from: fetching_document
          to: extracting_fields
        validate:
          from: extracting_fields
          to: validating_fields
        write:
          from: validating_fields
          to: writing_to_erp
        complete:
          from: writing_to_erp
          to: completed
        fail:
          from: [fetching_document, extracting_fields, validating_fields, writing_to_erp]
          to: failed

Die Entitätsklasse speichert den aktuellen Place, die Run-Eingaben und eventuelle Zwischenausgaben:

namespace App\Agent;

class InvoiceAgentRun
{
    public string $status = 'created';
    public array $extractedFields = [];
    public ?string $erpRecordId = null;
    public ?\DateTimeImmutable $startedAt = null;
    public ?\DateTimeImmutable $completedAt = null;
    public ?string $failureReason = null;

    public function __construct(
        public readonly string $id,
        public readonly string $documentUrl,
        public readonly string $tenantId,
    ) {}
}

Der Orchestrator wendet Transitionen nacheinander an und persistiert den Zustand nach jeder. Wenn der Prozess zwischen extracting_fields und validating_fields stirbt, weißt du genau, wo du warst, und kannst fortfahren - oder an einen menschlichen Prüfer übergeben - ohne zu raten.

Schritte idempotent machen

Zu wissen, wo du bist, ist nur die halbe Lösung. Die andere Hälfte ist sicherzustellen, dass die erneute Ausführung eines Schritts von einem bekannten Zustand das gleiche Ergebnis produziert, ohne duplizierte Seiteneffekte.

Für Read-only-Schritte - ein Dokument abrufen, das Modell aufrufen - ist Idempotenz meistens kostenlos. Ruf sie erneut auf, wenn nötig. Für Schreibschritte brauchst du einen expliziten Mechanismus, um Doppelschreibvorgänge bei Wiederholung zu verhindern.

Das Standardmuster ist eine Outbox-Tabelle. Bevor du eine Transition anwendest, die einen externen Seiteneffekt hat, füge einen Datensatz in die Outbox innerhalb derselben Datenbanktransaktion ein, die das Marking der Entität aktualisiert. Ein separater Worker liest die Outbox und führt den externen Aufruf durch, markiert den Datensatz als gesendet, sobald der Aufruf erfolgreich ist. Wenn der Worker mitten im Flug abstürzt, ist der Outbox-Datensatz beim Neustart noch da. Wenn das externe System denselben Idempotenz-Schlüssel zweimal erhält, ignoriert es das Duplikat.

// Innerhalb des Transition-Listeners für 'write'
public function onWrite(TransitionEvent $event): void
{
    $run = $event->getSubject();

    $this->entityManager->wrapInTransaction(function () use ($run) {
        // Persistiere das aktualisierte Marking (writing_to_erp)
        $this->entityManager->persist($run);

        // Stelle den ERP-Schreibvorgang via Outbox in die Warteschlange - gleiche Transaktion
        $outbox = new ErpOutboxEntry(
            runId: $run->id,
            idempotencyKey: "erp-write-{$run->id}",
            payload: $run->extractedFields,
        );
        $this->entityManager->persist($outbox);
    });
}

Der Outbox-Worker ist ein Symfony Messenger Consumer. Wenn der ERP-Aufruf fehlschlägt, wiederholt Messenger mit Backoff. Wenn Wiederholungen erschöpft sind, landet die Nachricht in der Dead-Letter-Queue, wo du sie inspizieren, das Payload korrigieren und manuell erneut abspielen kannst.

Der LLM-Aufruf als bewachte Transition

Modellaufrufe verdienen eine eigene Behandlung. Sie sind teuer, nicht-deterministisch und langsam. Du möchtest sie höchstens einmal pro Schritt ausführen und Ergebnisse cachen, sodass eine Wiederholung das Modell nicht erneut aufruft, wenn du bereits eine gültige Antwort hast.

Guards sind der richtige Ort, um zu prüfen, ob ein Schritt überhaupt wiederholt werden soll:

use Symfony\Component\Workflow\Event\GuardEvent;

public function guardExtract(GuardEvent $event): void
{
    $run = $event->getSubject();

    // Blockiere die Transition, wenn wir bereits eine gecachte Extraktion haben
    if (!empty($run->extractedFields)) {
        $event->setBlocked(true, 'Extraktion bereits abgeschlossen - wird übersprungen.');
    }
}

Der eigentliche Modellaufruf lebt in einem Transition-Listener. Speichere die rohe Modellantwort auf der Entität neben den geparsten Feldern - du möchtest den Eval-Trace, nicht nur die strukturierte Ausgabe:

public function onExtract(TransitionEvent $event): void
{
    $run = $event->getSubject();

    $response = $this->llmClient->extract(
        document: $run->documentContent,
        schema: InvoiceFieldSchema::get(),
        model: 'claude-sonnet-4-6',
    );

    $run->rawLlmResponse = $response->raw;
    $run->extractedFields = $response->parsed;
    $run->extractionTokens = $response->usage;
}

Da die Entität nach jeder Transition persistiert wird, überleben rawLlmResponse und extractedFields einen Absturz. Eine Wiederholung startet von extracting_fields, der Guard sieht nicht-leere extractedFields, blockiert die Transition, und du gehst zur Validierung über, ohne das Modell erneut zu berühren - was sowohl für Kosten als auch für Konsistenz bei Wiederholungen wichtig ist. Dies ist einer der praktischsten Vorteile, LLM Tool Use auf Symfonys strukturierter Orchestrierungsschicht aufzubauen.

Messenger für den äußeren Loop verwenden

Der Agent-Loop selbst sollte asynchron sein. Das Dispatchen jedes Transition-Schritts als Messenger-Nachricht trennt die Ausführung des Agenten von HTTP-Request-Zyklen, gibt dir per-Schritt-Backoff und -Wiederholung, und bedeutet, dass der gesamte Run einen Anwendungsneustart überlebt.

namespace App\Agent\Message;

final class ApplyAgentTransition
{
    public function __construct(
        public readonly string $runId,
        public readonly string $transition,
    ) {}
}

Der Handler lädt den Run, prüft, ob die Transition noch anwendbar ist (der Workflow erzwingt dies via can()), wendet sie an, persistiert und dispatcht die nächste Transition:

public function __invoke(ApplyAgentTransition $message): void
{
    $run = $this->runRepository->find($message->runId);

    if (!$this->workflow->can($run, $message->transition)) {
        // Bereits fortgeschritten - idempotentes No-Op
        return;
    }

    $this->workflow->apply($run, $message->transition);
    $this->entityManager->flush();

    $nextTransition = $this->transitionResolver->next($run);
    if ($nextTransition !== null) {
        $this->bus->dispatch(new ApplyAgentTransition($run->id, $nextTransition));
    }
}

Die Retry-Konfiguration in messenger.yaml behandelt transiente Fehler - Modell-Timeouts, ERP-Rate-Limits, Netzwerkausfälle - ohne spezielle Behandlung im Handler:

framework:
  messenger:
    failure_transport: failed
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 5
          delay: 2000
          multiplier: 2
          max_delay: 30000

Fünf Wiederholungen mit exponentiellem Backoff behandeln die meisten transienten Fehler. Alles darüber hinaus landet im failed-Transport, wo du selektiv inspizieren, korrigieren und erneut abspielen kannst. Der Workflow-Zustand auf der Entität sagt dir genau, welcher Schritt fehlgeschlagen ist, sodass das erneute Abspielen einer toten Nachricht genau am richtigen Ort beginnt.

Der Audit-Log als Eval-Trace

Ein unterschätzter Vorteil dieses Patterns ist, dass das Event-System des Workflow Component kostenlos einen strukturierten Audit-Trail produziert. Jedes entered-, completed- und announce-Event trägt die Entität, den Transitionsnamen und einen Zeitstempel. Persistiere sie:

public function onEntered(EnteredEvent $event): void
{
    $run = $event->getSubject();
    if (!$run instanceof InvoiceAgentRun) {
        return;
    }

    $this->entityManager->persist(new AgentRunEvent(
        runId: $run->id,
        event: 'entered',
        place: current($event->getMarking()->getPlaces()),
        transition: $event->getTransition()?->getName(),
        occurredAt: new \DateTimeImmutable(),
        metadata: [
            'extractionTokens' => $run->extractionTokens ?? null,
        ],
    ));
}

Dieser Log ist dein Eval-Datensatz. Wenn die Output-Qualität nachlässt - die Extraktionsgenauigkeit sinkt, die Validierungs-False-Positive-Rate steigt - kannst du nach Place filtern, auf fehlgeschlagene Runs oder Antworten mit niedrigem Konfidenzwert eingrenzen und die rohe LLM-Antwort plus Ground Truth für jeden Fall exportieren. Evals, die auf Produktionsdaten basieren, erfordern diese Art von systematischem Trace. Ein Workflow Component, der jeden Schritt durch ein Event-System leitet, gibt dir das ohne zusätzlichen Instrumentierungsaufwand.

Verbindung zu echten Modellen

Nichts in dem obigen Pattern ist modellspezifisch. Das LlmClient-Interface nimmt einen Prompt und ein Schema und gibt strukturierte Ausgabe zurück. Verbinde es mit Claude, GPT-4o oder einer lokalen Mistral-Instanz - die Orchestrierungsschicht kümmert sich nicht darum.

Für Claude-Integration in PHP ist die Anthropic-API ein einfacher HTTP-Aufruf. Wir umhüllen ihn typischerweise in einen Service, der Wiederholungen auf HTTP-Ebene behandelt, getrennt von Messenger-Wiederholungen auf Schrittebene - zwei Wiederholungsschichten mit unterschiedlicher Semantik:

final class AnthropicLlmClient implements LlmClient
{
    public function extract(string $document, array $schema, string $model): LlmResponse
    {
        $response = $this->httpClient->request('POST', 'https://api.anthropic.com/v1/messages', [
            'headers' => [
                'x-api-key' => $this->apiKey,
                'anthropic-version' => '2023-06-01',
            ],
            'json' => [
                'model' => $model,
                'max_tokens' => 1024,
                'tools' => [$schema],
                'messages' => [
                    ['role' => 'user', 'content' => $document],
                ],
            ],
        ]);

        return LlmResponse::fromAnthropicToolUse($response->toArray());
    }
}

Für Teams, die mehrere Provider betreiben oder auf lokale Modelle zurückfallen, ist dieses Interface die einzige Naht, die sich ändert. Die Zustandsmaschine, das Outbox-Pattern, die Retry-Konfiguration - all das bleibt gleich, unabhängig davon, welches Modell hinter dem Extraktionsschritt steckt. Sieh dir unsere Leistungen an, wie wir Teams helfen, diese KI-Integrationsmuster in der Produktion zu entwerfen und zu implementieren.

Was dieses Pattern nicht abdeckt

Der Symfony Workflow Component ist exzellent für sequenzielle Agent-Loops mit klaren Schrittgrenzen. Er ist weniger natürlich für Agenten, die mitten im Loop dynamisch verzweigen müssen - zwischen fünf verschiedenen Tool-Pfaden basierend auf der Modellausgabe wählen - oder für Multi-Agenten-Orchestrierung, bei der Sub-Agenten gleichzeitig laufen und ihre Ergebnisse zusammengeführt werden müssen. Für diese Fälle ist eine flexiblere graph-basierte Orchestrierungsschicht die zusätzliche Komplexität wert.

Das Pattern behandelt auch nicht Prompt-Injection, Modellbewertung oder die Kostenabrechnung, die du benötigst, sobald Tokenausgaben zu einer Budgetlinie werden. Das sind echte Produktionsbedenken; die Orchestrierungsschicht ist eine Voraussetzung, keine vollständige Lösung.

Das zum Laufen bringen

Das minimale Setup für dieses Pattern auf einem Symfony 7-Projekt:

composer require symfony/workflow symfony/messenger doctrine/orm

Definiere deinen Workflow in config/packages/workflow.yaml, erstelle deine Entität und Outbox-Tabelle, schreibe die Transition-Listener, konfiguriere Messenger mit einem echten Transport (Doctrine oder Redis funktionieren beide gut), und deploye einen Worker neben deiner Anwendung. Der Workflow-Visualizer (bin/console workflow:dump invoice_agent | dot -Tpng > agent.png) gibt dir ein Diagramm, das du mit den nicht-technischen Stakeholdern teilen kannst, die ebenfalls Meinungen über das Verhalten des Agenten haben.

Wenn dein Team LLM-Agenten in einer PHP-Codebase in Betracht zieht und ein Pattern möchte, das den ersten Kontakt mit der Produktion übersteht, ist dies ein solider Ausgangspunkt. Melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io - wir helfen Teams dabei, agentische Features in Symfony- und Next.js-Anwendungen zu entwerfen und zu implementieren, die echter Last, echten Fehlern und echten Finanzabteilungen standhalten, die spitze Fragen zu KI-Kosten stellen.