Beschleunige deine AI & LLM-Integration mit diesem einfachen Trick

LLMs sind der neueste Trend und belegen wahrscheinlich mindestens ein Ticket im Backlog eines jeden Produkts. Ihre Integration über gängige APIs wie OpenAI ist immer einfacher geworden. In diesem Artikel wollen wir darstellen, wie die Verwendung von Datenströmen die wahrgenommene Leistung deiner KI-Anwendungen drastisch verbessern kann.

Wir haben alle gelernt, dass es wichtig ist, einen Gedanken zu Ende zu bringen, bevor man ihn ausspricht. Bei der Integration von KI ist jedoch genau das Gegenteil effektiver. LLMs können anfangen zu "sprechen", ohne zu wissen, wie ihr Satz enden wird – und genau das können wir nutzen!

Wir sehen eine kleine App, die Kurzgeschichten generiert. Mit einem Klick auf einen Button werden zwei Geschichten mit denselben Stichworten generiert – eine links und eine rechts – aber über zwei verschiedene Endpunkte:

  • Die linke Spalte verwendet einen Endpunkt, der OpenAIs Completion API aufruft und mit async/await die gesamte Antwort auf einmal abruft.
  • Die rechte Spalte hingegen nutzt HTTP-Streaming.

Am Ende sind beide Geschichten ungefähr gleichzeitig vollständig, aber in der rechten Spalte können die Nutzer bereits nach einer halben Sekunde anfangen zu lesen. Das sorgt für eine wesentlich flüssigere Nutzererfahrung.

Wie wird das gemacht?

Dieses Problem lösen wir von zwei Seiten:

  1. Der Server muss die KI-Antwort direkt weiterleiten, sobald sie eintrifft.
  2. Das Frontend muss den gestreamten Inhalt verarbeiten und den Text dynamisch in die HTML-Seite einfügen.

HTTP-Streams im Browser konsumieren

Zum Glück machen async iterables und die fetch-API das wirklich einfach. Zunächst führst du einen normalen fetch-Aufruf aus und prüfst auf Netzwerkfehler:

 const response = await fetch(
  `/api/stream?q=${encodeURIComponent(keywords)}`
);

if (!response.ok) {
  output.textContent = `Error: ${response.statusText}`;
  return;
}

Die eigentlichen Datenbytes, die über die Verbindung gesendet werden, müssen in Strings umgewandelt werden. Das ist ziemlich tricky, wenn man das manuell machen möchte, da ein einzelnes UTF-8 Zeichen auch auf mehrere Bytes aufgeteilt werden kann (Emojis zum Beispiel), und damit auch auf zwei Netzwerkpakete aufgeteilt werden kann. Aber zum Glück hilft uns der im Browser eingebaute TextDecoderStream:

Mit dem dekodierten Stream kannst du dann die AsyncIterable-Schnittstelle nutzen, um in einer Schleife den Text dynamisch hinzuzufügen im HTML einzufügen:

for await (const chunk of textStream) {
  output.textContent += chunk;
}

HTTP-Streams mit Node.js erzeugen

Was jetzt noch fehlt, ist das Produzieren des HTTP-Streams auf unserem Node.js Server. Zunächst definieren wir unseren Request-Handler. Das können wir mit einem beliebigen Web-Framework machen, wir nutzen dafür fastify.

app.get("/stream", async (req, res) => {
  const keywords =
    (req.query as { q: string }).q || "Hansel and Gretel";
  // Prepare content type to send headers early.
  res.header("Content-Type", "text/plain; charset=utf-8");

Als nächstes erzeugen wir einen Datenstream, anstatt auf die vollständige Antwort zu warten. Mit der OpenAI-Schnittstelle geht das recht einfach, indem man das stream Flag auf true setzt.

const stream = await openai.chat.completions.create({
  model: "gpt-4o-mini-2024-07-18",
  messages: getPrompt(keywords),
  stream: true,
});

Und jetzt kommt die Magie. Wir nutzen die pipeline Funktion von Node.js, um den OpenAI-Stream (der AsyncIterable implementiert) durch eine Funktion, die den reinen Text aus der OpenAI Antwort extrahiert, leitet, um ihn schließlich in den WritableStream des response-Objektes zu streamen und damit direkt zum Client.

await pipeline(stream, extractText, res.raw);

Die extractText-Funktion nutzt dabei einen Async Generator und ist auch ziemlich klein:

async function* extractText(
  source: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
) {
  for await (const data of source) {
    const next = data.choices[0].delta.content;
    if (next) yield next;
  }
}

Jetzt können wir alles zusammensetzen:

Vollständiger Code

Frontend

async function streamStory(
  keywords: string,
  output: HTMLDivElement
) {
  const response = await fetch(
    `/api/stream?q=${encodeURIComponent(keywords)}`
  );

  if (!response.ok) {
    output.textContent = `Error: ${response.statusText}`;
    return;
  }

  output.textContent = "";

  const sourceStream = response.body!;

  const textStream = sourceStream.pipeThrough(
    new TextDecoderStream()
  );

  for await (const chunk of textStream) {
    output.textContent += chunk;
  }
}

Backend

import fastify from "fastify";
import OpenAI from "openai";
import { pipeline } from "stream/promises";

const app = fastify();

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

app.get("/stream", async (req, res) => {
  const keywords =
    (req.query as { q: string }).q || "Hansel and Gretel";

  res.header("Content-Type", "text/plain; charset=utf-8");

  const stream = await openai.chat.completions.create({
    model: "gpt-4o-mini-2024-07-18",
    messages: getPrompt(keywords),
    stream: true,
  });

  await pipeline(stream, extractText, res.raw);
});

await app.listen({ port: 3000 });

async function* extractText(
  source: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
) {
  for await (const data of source) {
    const next = data.choices[0].delta.content;
    if (next) yield next;
  }
}

function getPrompt(
  keywords: string
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
  return [
    {
      role: "system",
      content:
        "Du bist ein Profi-Märchenautor! " +
        "Deine Geschichten enthalten keinerlei böse Wörter, " +
        "die nicht geeignet für sind für Kinder. Nutzer geben " +
        "dir Stichworte und du antwortest mit einer ca. 100 Wörter-langen Geschichte. " +
        "Schreibe die Geschichte auf Englisch:",
    },
    {
      role: "user",
      content: keywords,
    },
  ];
}