Zum Inhalt springen

Streaming-Responses

Bei längeren Antworten kann die Wartezeit bis zur kompletten Generierung spürbar sein. Streaming löst dieses Problem: Statt auf die fertige Antwort zu warten, liefert der Server die einzelnen Tokens unmittelbar nach ihrer Erzeugung über Server-Sent Events (SSE) an den Client. So entsteht der bekannte “Tippt-in-Echtzeit”-Effekt, und die ersten Wörter erscheinen schon nach Bruchteilen einer Sekunde.

Aktiviert wird das Streaming durch Setzen von "stream": true im Request-Body. Alle übrigen Parameter bleiben unverändert gegenüber einer normalen Chat-Completion-Anfrage.

Terminal-Fenster
curl -X POST https://ai.noris.de/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-N \
-d '{
"model": "vllm/release/gpt-oss-120b",
"messages": [{"role": "user", "content": "Erzähl mir eine Geschichte"}],
"stream": true
}'

Das Flag -N deaktiviert Pufferung, damit jedes Chunk sofort im Terminal erscheint.

Die Antwort besteht aus einer Folge von Zeilen, jeweils eingeleitet durch data: . Dahinter steht ein JSON-Objekt mit einem Teilfragment (delta) der Nachricht. Am Ende der Übertragung sendet der Server eine abschließende Zeile data: [DONE]. Typische Ausgabe:

data: {"id":"chatcmpl-xyz","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-xyz","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Es"},"finish_reason":null}]}
data: {"id":"chatcmpl-xyz","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" war"},"finish_reason":null}]}
...
data: [DONE]

Wichtig: Im Gegensatz zur Nicht-Streaming-Variante liegen die Inhalte nicht in message.content, sondern verteilt über mehrere Chunks in choices[0].delta.content. Konkatenieren Sie alle delta.content-Fragmente, um den vollständigen Text zu erhalten. Die Felder usage und finish_reason werden ausschließlich im letzten Daten-Chunk vor [DONE] geliefert, frühere Chunks enthalten dafür null.

Das openai-SDK kapselt das SSE-Parsing vollständig. Übergeben Sie stream=True und iterieren Sie über die zurückgegebene Stream-Instanz:

from openai import OpenAI
client = OpenAI(base_url="https://ai.noris.de/v1", api_key="YOUR_API_KEY")
stream = client.chat.completions.create(
model="vllm/release/gpt-oss-120b",
messages=[{"role": "user", "content": "Erzähl mir eine Geschichte"}],
stream=True
)
for chunk in stream:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")

Aufgrund von end="" wird jeder Teil sofort ausgegeben, ohne dass die Konsole auf einen Zeilenumbruch wartet. Falls Sie die Token-Metriken benötigen, greifen Sie im letzten Chunk auf chunk.usage zu (sofern der Provider stream_options={"include_usage": True} unterstützt).

Im Browser oder in Node.js bietet sich die ReadableStream-Schnittstelle von fetch an. Parsen Sie die data:-Zeilen manuell und brechen Sie bei [DONE] ab:

const response = await fetch("https://ai.noris.de/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
body: JSON.stringify({
model: "vllm/release/gpt-oss-120b",
messages: [{role: "user", content: "Erzähl mir eine Geschichte"}],
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") return;
const json = JSON.parse(payload);
const delta = json.choices[0]?.delta?.content;
if (delta) process.stdout.write(delta);
}
}
  • usage und finish_reason erscheinen ausschließlich im letzten Chunk vor [DONE]. Werten Sie diese Felder erst nach Beendigung des Streams aus.
  • Bei Netzwerkabbrüchen während des Streams muss der Client entscheiden, ob ein erneuter Aufruf gestartet oder der Teilverlauf weiterverwendet wird, ein automatischer Resume-Mechanismus ist nicht Bestandteil des Protokolls.
  • Leere delta.content-Felder können ignoriert werden; sie treten gelegentlich am Anfang (beim Rollen-Token) oder zwischen Segmenten auf.