Features

Streaming & Tool-/Function-Calling

Inkrementelles Chat-Streaming im SSE-Stil und OpenAI-kompatibles Tool-Calling hinter einer provider-agnostischen Schnittstelle – mit Budget-, Nutzungs- und Telemetrie-Erfassung auf jedem Pfad.

Warum es existiert

Jede TYPO3-Extension, die gestreamte Ausgabe oder Function-Calling wollte, musste das pro Provider selbst umsetzen – die SSE-Frames jedes Anbieters und die Payload-Form jedes Tool-Calls parsen. nr-llm stellt beides als einzelne Methoden bereit (streamChat, chatWithTools), die den konfigurierten Provider auflösen und in das OpenAI-kompatible Tool-Format und zurück übersetzen (ADR-010). So schreibt eine Extension die Integration einmal und arbeitet mit OpenAI, Anthropic, Gemini, Ollama, OpenRouter und den übrigen.

Streaming umging die Middleware-Pipeline früher vollständig: Ein gestreamter Chat führte keine Budget-Vorprüfung durch, schrieb keine Nutzungszeile und erzeugte keine Telemetrie – eine offene Budget-Lücke, über die ein Nutzer mit überschrittenem Budget frei streamen konnte, während die Tokens unsichtbar blieben. ADR-062 schließt das mit einem eigenen Streaming-Lebenszyklus, sodass gestreamte Aufrufe wie jeder andere Aufruf erfasst werden.

Wann man es einsetzt

  • streamChat() einsetzen, wenn die wahrgenommene Latenz zählt – lange Completions erscheinen Token für Token statt erst nach mehreren Sekunden Wartezeit.
  • chatWithTools() einsetzen, wenn das Modell Daten abrufen, eine Aktion ausführen oder strukturierte Ausgabe erzeugen muss; das Modell entscheidet, wann ein Tool aufgerufen wird, und man führt es aus und gibt das Ergebnis zurück.
  • Nicht streamen, wenn Fallback-Resilienz über mehrere Provider hinweg nötig ist: Der Fallback greift nur bis zum ersten Chunk (sobald ein Chunk ausgeliefert ist, ist ein Provider-Wechsel unmöglich), und gestreamte Antworten werden nie gecacht.
  • Zuerst die Provider-Fähigkeiten prüfen – nur Provider, die StreamingCapableInterface implementieren, streamen, und nur solche, die ToolCapableInterface implementieren, akzeptieren Tools.
  • Für admin-gesteuerte Agent-Läufe (die begrenzte Tool-Schleife mit Schaltern pro Gruppe und einem Live-Trace) den Weg über das Playground / ToolLoopService nehmen statt eines einzelnen chatWithTools()-Aufrufs.

Wie es funktioniert

streamChat() gibt einen PHP-Generator zurück, der String-Chunks liefert (yield), sobald der Provider sie erzeugt. Da ein Generator lazy ist, lässt er sich nicht durch die Response-Middleware-Pipeline schieben (jede Middleware würde gegen einen noch nicht gestarteten Stream feuern). Stattdessen kapselt ein privater StreamingDispatcher den Provider-Generator und durchläuft einen vierstufigen Lebenszyklus: eine sofortige Budget-Vorprüfung, bevor überhaupt ein Generator zurückgegeben wird; die Provider-Auswahl mit flachem Fallback, die jeden Kandidaten bis zu seinem ersten yield vorbereitet; das lazy erneute Ausgeben (yield) jedes Chunks, während die Completion gepuffert wird; und die Verrechnung (Nutzung + Telemetrie) in einem finally-Block, sodass die Erfassung sowohl bei normalem Abschluss als auch bei einer Exception mitten im Stream und bei abgebrochenen bzw. früh beendeten Streams greift. Die Token-Zahlen eines Streams werden geschätzt (~4 Zeichen/Token), da die Streaming-Adapter nur Text-Deltas liefern.

Tool-Calling folgt dem OpenAI-kompatiblen Schema: Man übergibt chatWithTools() eine Liste von Tool-Definitionen ({type: function, function: {name, description, parameters}}). Das toolCalls der Antwort ist eine nullbare Liste von ToolCall-Value-Objects, deren arguments bereits ein JSON-dekodiertes assoziatives Array sind. Man gibt den Assistant-Zug mit ChatMessage::assistantToolCalls() zurück, führt jedes angeforderte Tool aus, beantwortet jeden Aufruf per id mit ChatMessage::toolResult() und ruft chat() erneut auf, damit das Modell die Ergebnisse einbeziehen kann. nr-llm bringt 41 eingebaute, lesende Tools in 8 schaltbaren Gruppen mit; eine begrenzte Agent-Schleife (ToolLoopService::runLoop) steuert die Tool-Ausführung über mehrere Runden und wird durch eine fail-closed-Kaskade abgesichert – globaler Zustand pro Tool, Zustand pro Tool-Gruppe, erlaubte Gruppen pro Konfiguration und die Allow-List pro Lauf –, wobei jede Schicht nur einschränken, nie erweitern kann.

Die Ausgabe von Reasoning-Modellen wird von der Antwort getrennt: AbstractProvider::extractThinkingBlocks() zieht inline <think>…</think>-Blöcke (DeepSeek, Qwen, lokale Modelle) und Claudes native Thinking-Content-Blöcke in CompletionResponse::$thinking, sodass das Thinking über hasThinking() zum Debuggen verfügbar ist, ohne den eigentlichen Content zu verunreinigen.

LlmServiceManagerInterface

Öffentlicher Einstiegspunkt: streamChat() / streamChatWithConfiguration() geben einen Generator aus String-Chunks zurück; chatWithTools() / chatWithToolsForConfiguration() geben eine CompletionResponse zurück.

StreamingCapableInterface

Provider-Vertrag für Streaming – streamChatCompletion(array $messages, array $options = []): Generator und supportsStreaming(): bool.

ToolCapableInterface

Provider-Vertrag für Tools – chatCompletionWithTools(array $messages, array $tools, array $options = []): CompletionResponse und supportsTools(): bool.

StreamingDispatcher

Privater kapselnder Generator, dem der Streaming-Lebenszyklus gehört: sofortige Budget-Vorprüfung, Fallback-Vorbereitung vor dem ersten Chunk, lazy erneutes yield und Nutzungs-/Telemetrie-Verrechnung im finally (ADR-062).

ToolCall

Readonly-Value-Object für einen einzelnen Tool-Call – id, name und arguments als bereits JSON-dekodiertes assoziatives Array; wird in CompletionResponse::$toolCalls transportiert.

ToolLoopService

Begrenzte Agent-Schleife (runLoop), die mehrere Tool-Runden gegen eine Konfiguration ausführt und dabei jede Allow-List pro Lauf mit der global aktivierten Tool-Menge schneidet (ToolAvailabilityService).

Für Entwickler

Beide Funktionen sind einzelne Aufrufe auf dem injizierten LlmServiceManagerInterface. Streaming liefert Strings (yield); Tool-Calling gibt eine CompletionResponse zurück, die man auswertet und beantwortet.

Eine Chat-Antwort streamen

streamChat() gibt einen Generator aus String-Chunks zurück. Jeden Chunk ausgeben und flushen, sobald er eintrifft; Budget, Nutzung und Telemetrie werden automatisch verrechnet, wenn der Stream endet oder abgebrochen wird.

php
$stream = $this->llmManager->streamChat($messages);

foreach ($stream as $chunk) {
    echo $chunk;
    ob_flush();
    flush();
}

Tools im OpenAI-kompatiblen Format definieren

Jedes Tool ist ein function-Eintrag mit einem parameters-Objekt nach JSON-Schema. Das Modell entscheidet anhand des Gesprächs, wann eines aufgerufen wird.

php
$tools = [
    [
        'type' => 'function',
        'function' => [
            'name' => 'get_weather',
            'description' => 'Get current weather for a location',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'location' => [
                        'type' => 'string',
                        'description' => 'City name',
                    ],
                    'unit' => [
                        'type' => 'string',
                        'enum' => ['celsius', 'fahrenheit'],
                    ],
                ],
                'required' => ['location'],
            ],
        ],
    ],
];

Tool-Calls ausführen und Ergebnisse zurückgeben

toolCalls ist eine Liste von ToolCall-Value-Objects; arguments ist bereits ein dekodiertes Array. Den Assistant-Zug zurückgeben, jeden Aufruf per id beantworten und dann chat() erneut für die endgültige Antwort aufrufen.

php
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;

$response = $this->llmManager->chatWithTools($messages, $tools);

if ($response->hasToolCalls()) {
    $messages[] = ChatMessage::assistantToolCalls($response->toolCalls, $response->content);

    foreach ($response->toolCalls as $toolCall) {
        $result = match ($toolCall->name) {
            'get_weather' => $this->getWeather($toolCall->arguments['location']),
            default => throw new \RuntimeException("Unknown function: {$toolCall->name}"),
        };

        $messages[] = ChatMessage::toolResult($toolCall->id, json_encode($result, JSON_THROW_ON_ERROR));
    }

    $response = $this->llmManager->chat($messages);
}

Auf die Provider-Fähigkeit prüfen

Nicht jeder Provider streamt oder akzeptiert Tools. Das Capability-Interface prüfen, bevor die Funktion genutzt wird, um früh fehlzuschlagen statt erst beim API-Aufruf.

php
$provider = $this->llmManager->getProvider('openai');
if ($provider instanceof StreamingCapableInterface) {
    // Provider supports streaming
}

Fallstricke

Der Fallback endet am ersten Chunk

Der Streaming-Dispatcher läuft die Fallback-Kette der Konfiguration durch und bereitet Kandidaten nur bis zu ihrem ersten yield vor. Sobald ein Chunk an den Aufrufer übergeben wurde, ist ein Provider-Wechsel unmöglich – ein Fehler mitten im Stream lässt sich also nicht gegen einen anderen Provider erneut versuchen. Diese Asymmetrie zur nicht streamenden Pipeline ist dem Streaming inhärent (ADR-062).

Token-Zahlen beim Streaming werden geschätzt, nicht gemeldet

Die Streaming-Adapter liefern nur Text-Deltas und senden am Stream-Ende keinen usage-Frame, deshalb schätzt der Dispatcher die Tokens mit einer Heuristik von ~4 Zeichen/Token. Eine Schätzung ist für die Budget-Durchsetzung bewusst besser als die frühere stille Null, aber nicht der exakte Wert des Providers. Streaming cacht zudem nie und schreibt immer cache_hit = false.

Die Tool-Verfügbarkeit ist fail-closed und kaskadiert

Eine Allow-List pro Lauf kann nur einschränken, was global aktiviert ist. Ein global deaktiviertes Tool (ToolStateRepository) oder eine deaktivierte Tool-Gruppe lässt sich über eine Skill- oder Playground-Auswahl nie wieder aktivieren – ein Override pro Tool kann ein Tool innerhalb einer deaktivierten Gruppe nicht reaktivieren (ADR-039, ADR-043). Fremde ToolInterface-Implementierungen müssen getGroup() implementieren.

Keine automatische Tool-Ausführung

chatWithTools() gibt die vom Modell angeforderten Aufrufe zurück; es führt niemals selbst Funktionen aus. Man muss anhand von $toolCall->name verzweigen, ausführen und jedes Ergebnis über ChatMessage::toolResult() zurückgeben, bevor das Modell eine endgültige Antwort erzeugen kann. Jedes Tool-Ergebnis und jede Modellausgabe als nicht vertrauenswürdigen Content behandeln.