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. Jeder Stream läuft außerdem live durch den verpflichtenden Secret-Redaction-Guardrail (ADR-088): Ein gleitendes Fenster hält die letzten 128 Bytes zurück, bis weiterer Text eintrifft. Die Chunks beim Aufrufer decken sich deshalb nicht mit den Deltas des Providers. Eine Prüfung der ersten 50.000 Bytes am Stream-Ende kann einen Guardrail-Treffer protokollieren, bereits gesendeten Text aber nicht zurückholen.

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. Bei den GPT-6-Modellen schickt der OpenAI-Adapter einen Tool-Request an die Responses API statt an chat/completions, wenn der Endpunkt der Host von OpenAI selbst ist; ein kompatibles Gateway bleibt bei chat/completions, außer die Optionen der Konfiguration setzen "openai_tools_transport": "responses" (ADR-203). Die eigenen Items des Providers für diesen Zug stehen in $response->metadata['nrllm_provider_items'] und gehen als drittes Argument an assistantToolCalls(); so behält das Modell sein Reasoning zwischen den Schritten. Die Thought-Signaturen von Gemini laufen über dasselbe Feld (ADR-222). nr-llm bringt 63 eingebaute Tools in 10 schaltbaren Gruppen mit — 45 nur lesend, dazu achtzehn schreibende, die deaktiviert ausgeliefert werden und auf eine menschliche Freigabe warten; eine begrenzte Agent-Schleife (ToolLoopService::runLoop) steuert die Tool-Ausführung über mehrere Runden und wird durch ToolCallPolicy abgesichert, ein zusammengesetztes fail-closed-Gate – globaler Zustand pro Tool, Zustand pro Tool-Gruppe, Tools nur für Admins, erlaubte Gruppen pro Konfiguration, die Allow-List pro Lauf und die Vertrauenszone des Providers der Konfiguration gegenüber der Datenklasse des Tools (ADR-094) –, 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 und andere Modelle, die sie ausgeben) aus dem Content. ClaudeProvider ergänzt Claudes native Thinking-Content-Blöcke, GeminiProvider seine Thought-Parts, und OllamaProvider liest das separate Feld message.thinking von Ollama. Alles landet in CompletionResponse::$thinking und ist über hasThinking() zum Debuggen verfügbar, ohne den eigentlichen Content zu verunreinigen. Wie viel ein Modell nachdenkt, ist eine Request-Option: ChatOptions::withReasoningEffort() nimmt einen ReasoningEffort-Case (ADR-204). Derzeit werten nur die GPT-6-Modelle von OpenAI ihn aus; der tatsächlich angewandte Effort steht in den Metadaten der Antwort.

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 nur die Tools anbietet, die das zusammengesetzte Gate ToolCallPolicy zulässt – die Allow-List pro Lauf geschnitten mit der global aktivierten Tool-Menge (ToolAvailabilityService) und den übrigen Prüfungen.

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 zusammen mit den Replay-Items des Providers 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,
        $response->metadata['nrllm_provider_items'] ?? null,
    );

    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). Ebenso zurückgehalten wird ein Tool, dessen Datenklasse über der Vertrauenszone des Providers der Konfiguration liegt; die Extension-Einstellung tools.dataClassEnforcement steht ab Werk auf enforce (ADR-094). 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.