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