Tool Calling und Streaming verlangen von dir Gegensätzliches.
Streaming will Tokens an den Browser weitergeben, sobald sie ankommen. Tool Calling will die Completion prüfen, entscheiden, ob das Modell ein Tool angefordert hat, es ausführen und die Schleife wiederholen. Das Unangenehme daran: Welche Art von Turn du vor dir hast, erfährst du erst, indem du ihn generierst. Im Request gibt es kein Feld, das sagt: „Das hier wird eine Antwort.“
Also gibt es einen naheliegenden Weg, die Schleife zu schreiben, und fast jeder schreibt sie mindestens einmal so.
Das Muster
loop:
completion = create(messages, tools) // nicht gestreamt
if completion has tool calls:
run them, append results, continue
else:
break
// jetzt ist klar, dass es eine Antwort ist: also noch einmal, gestreamt
stream = create(messages, tools) // gestreamt
forward every delta to the browser
Beim Lesen wirkt das vernünftig. Der nicht gestreamte Aufruf ist ein Probelauf: leicht zu durchschauen, leicht auszuwerten, und er hält den Streaming-Pfad einfach, weil beim Start des Streams feststeht, dass nichts anderes mehr passieren kann.
Er generiert aber auch jede einfache Antwort zweimal.
Der erste Aufruf erzeugt eine vollständige Antwort, denn das Modell weiß nicht, dass es ein Probelauf ist. Du wirfst diese Antwort weg, fragst noch einmal und streamst die zweite. Der Nutzer wartet auf beide.
Genau das habe ich ausgeliefert, und es hat mehrere Releases überlebt, weil nichts daran falsch aussieht. Es gibt keinen Fehler. Die Latenz ist „etwas langsam, ist halt ein LLM“. Die Rechnung ist „ungefähr das, was man bei KI erwartet“. Die Tests laufen durch, weil sie den finalen Text prüfen, und der finale Text stimmt.
Was es wirklich kostet
Zwei Dinge, und das zweite wiegt schwerer als das erste.
Tokens. Bei einem Turn ohne Tool Calls bezahlst du die ganze Antwort zweimal, Output wie Input – und der Input ist die teure Hälfte, sobald du einen System-Prompt, eine Übersicht des Hilfecenters und den Gesprächsverlauf mitschickst. Antworten sind der Normalfall: Begrüßungen, Anschlussfragen, Klarstellungen, alles, was das Modell mit dem beantworten kann, was es schon hat.
Latenz bis zum ersten Zeichen. Diese Kosten spüren die Nutzer. Der Probelauf muss komplett fertig sein, bevor der gestreamte Aufruf startet. Der Besucher wartet also eine ganze Generierung lang vor einer leeren Sprechblase, und erst dann erscheinen die ersten Zeichen. Du hast Streaming eingebaut, um genau dieses Warten abzuschaffen, und dann das Warten wieder davorgesetzt.
Die Lösung: keine Probeläufe mehr. Streame jede Runde und behandle „Text ist angekommen“ und „ein Tool wurde angefordert“ als zwei Dinge, die aus demselben Aufruf kommen können, denn das tun sie.
Eine Runde streamen, die du vielleicht verwerfen musst
Die Denkblockade: Eine gestreamte Runde fühlt sich unsicher an. Was, wenn sie sich als Tool Call herausstellt, nachdem du schon Text an den Browser geschickt hast?
Das spielt keine Rolle. Eine Runde, die ein Tool aufruft, gibt meist keinen Text aus. Und wenn doch, ist es eine Ansage, die der Nutzer ohnehin sehen sollte – „Ich sehe kurz in der Dokumentation nach“ – und danach kommt der Aufruf. Leite die Deltas weiter, sobald sie ankommen. Tauchen in derselben Runde Tool Calls auf, führe sie aus und setze die Schleife fort; der Text der nächsten Runde schreibt einfach dieselbe Nachricht weiter.
Was sich ändert: Deine Schleife setzt eine Runde jetzt aus Fragmenten zusammen, statt ein fertiges Objekt zu lesen. Auf vier Details kommt es an:
Tool Calls kommen in Stücken, mit dem Index als Schlüssel. Der erste Chunk eines Aufrufs enthält index, id und den Funktionsnamen; jeder weitere Chunk nur index und das nächste Stück des Argument-JSON. Du sammelst alles in einer Map mit dem Index als Schlüssel und hängst die Argument-Strings aneinander. Sonst ist nichts stabil – weder die Reihenfolge noch die Position im Array.
Der Stopp-Grund kommt in einem eigenen Chunk. finish_reason steht in einem Chunk mit leerem Delta. Wenn du dir nur Chunks mit Inhalt ansiehst, bekommst du ihn nie zu sehen und erfährst nie, dass die Antwort am Token-Limit abgeschnitten wurde.
Die Verbrauchsdaten kommen ganz zum Schluss. Fordere sie mit stream_options: { include_usage: true } an, dann kommen sie in einem letzten Chunk mit leerem choices-Array. Code, der chunk.choices[0].delta liest, ohne vorher zu prüfen, wirft eine Exception genau bei dem einen Chunk, der dir sagt, was der Turn gekostet hat.
Eine Runde kann erst etwas ansagen und dann ein Tool aufrufen. Wenn in dieser Runde Text kam und die vorige Runde auch Text erzeugt hat, brauchst du einen Absatzumbruch dazwischen. Sonst laufen zwei Sätze, die im Abstand von einer Sekunde generiert wurden, in einer Zeile zusammen.
Nichts davon ist schwer. Aber jeder dieser Fehler bleibt stumm, wenn du ihn machst, und deshalb wirkt die Version mit Probelauf sicherer, als sie ist.
Was du gratis dazubekommst: Abbrechen
Und das ist das Argument, das mich neben dem Geld überzeugt hat.
Solange du Probeläufe machst, kannst du keinen Stopp-Button anbieten. Die Anfrage des Nutzers hängt an nichts, was sich abbrechen lässt: Die Generierung, die gerade läuft, wirfst du ohnehin weg, und die, auf die es ankommt, hat noch nicht begonnen. Abbrechen wird erst möglich, wenn es pro Turn genau eine laufende Generierung gibt und genau diese weitergeleitet wird.
Wenn die Struktur stimmt, braucht das Abbrechen nur ein paar Zeilen: ein AbortSignal vom Button zum Fetch und eine Prüfung auf dem Server, die das Schreiben beendet, wenn der Client weg ist. Das Verlassen der Schleife beendet auch den Upstream-Request, sodass das Modell wirklich aufhört zu generieren, statt eine Antwort fertigzuschreiben, die niemand liest.
Aber rechne dabei richtig ab, denn genau hier verliert eine naive Implementierung unbemerkt Geld, nur in die andere Richtung. Wenn du den Token-Verbrauch am Ende eines erfolgreichen Turns erfasst, erfasst ein abgebrochener Turn nichts – und du hast jedes Token verschenkt, das das Modell verbraucht hat, bevor der Nutzer auf Stopp gedrückt hat. Erfasse den Verbrauch in einem finally. Dann läuft derselbe Pfad, egal ob der Turn fertig wurde, fehlgeschlagen ist oder mitten im Satz abgebrochen wurde:
try {
// ... Runden streamen, Deltas per yield ausgeben
} finally {
billOnce(tokensSpentSoFar);
}
Wenn du in JavaScript ein for await über einen Async-Generator verlässt, ruft das return() am Generator auf. Der Generator wird an seinem letzten yield fortgesetzt und führt sein finally aus. Abbrechen ist kein Sonderfall, an den du denken musst – es ist der ganz normale Rückweg, sofern die Abrechnung dort steht, wo dieser Rückweg vorbeiführt.
Speichere dann, was der Nutzer gesehen hat. Eine abgebrochene Antwort ist trotzdem eine Antwort: ein halber Absatz auf seinem Bildschirm und, wenn du Transkripte speicherst, ein halber Absatz in deinem Dashboard, als abgebrochen markiert. Speicherst du nichts, widersprechen deine eigenen Logs dem Gespräch, das die Person tatsächlich geführt hat.
Woran du erkennst, dass du diesen Bug hast
Zähl die Completions, nicht die Tokens. Logge pro Upstream-Aufruf eine Zeile mit der Turn-ID, stell deinem Assistenten eine Frage, die gar keine Tools braucht – „Hallo“ oder „Was kannst du?“ –, und sieh nach, wie viele Completions dieser eine Turn erzeugt hat.
Sind es zwei, bezahlst du eine davon für nichts.
Der KI-Chat im Website-Widget von Kookee streamt jetzt eine Completion pro Runde, und der Senden-Button wird zum Stopp-Button, solange eine Antwort ankommt. Zum Widget siehe KI-Chat-Widget auf deiner Website einbinden, zur zweiten Falle in diesem Teil des Stacks Warum die Quellenliste deines KI-Chatbots wahrscheinlich falsch ist.