Eine Antwort, die Wort für Wort ankommt
Eine Streaming-Antwort ist eine Antwort, die ein Sprachmodell in kleinen Stücken sendet, während es schreibt. Sie kommt nicht als ein Block am Ende. Chat-Anwendungen nutzen das, damit Worte sofort erscheinen.
Das ist nützlich, denn eine lange Antwort kann viele Sekunden dauern. Mit einem Strom sieht die lesende Person den Anfang fast sofort. Ihr Programm muss die Stücke lesen und zusammenfügen.
Am Ende dieser Lektion
Notiz
Ich kann eine Antwort lesen, die in Stücken ankommt, und sie zu einem Text zusammenfügen.
Schlüsselwörter
| Begriff | Was er bedeutet | Beispiel |
|---|---|---|
| Strom | eine Antwort, die in Stücken gesendet wird, während sie entsteht | |
| Server-Sent Events | eine Art, wie ein Server Zeilen an einen Client schickt | |
| Datenzeile | eine Stromzeile, die mit data: beginnt und JSON enthält | |
| Delta | das neue Textstück einer Datenzeile | |
| Endmarke | die letzte Datenzeile, die sagt, dass der Strom zu Ende ist | data: [DONE] |
Die Zellen, die Lernende ausfüllen, bleiben in dieser Vorschau leer.
Ein Strom ist eine Liste von Zeilen
Für einen Strom fügen Sie "stream": true zum Anfragekörper hinzu. Der Server antwortet dann mit Server-Sent Events. Das ist einfacher Text, eine Zeile nach der anderen, mit einer Leerzeile zwischen den Ereignissen.
Jede Zeile, die mit data: beginnt, trägt ein Stück JSON. Der Text steht in choices[0].delta.content. Die letzte Zeile ist data: [DONE].
Die Zeilen eines Stroms
| Die Zeile beginnt mit | Was sie enthält |
|---|---|
| data: {"choices":[{"delta":{"role":"assistant"}}]} | Die Eröffnung: eine Rolle, noch kein Text |
| data: {"choices":[{"delta":{"content":"Eins"}}]} | Ein Textstück zum Anfügen |
| data: {"choices":[{"delta":{},"finish_reason":"stop"}]} | Der Grund, warum die Antwort endete |
| data: [DONE] | Die Endmarke, die kein JSON ist |
| : keep-alive | Eine Kommentarzeile, die ignoriert wird |
Die Zellen, die Lernende ausfüllen, bleiben in dieser Vorschau leer.
Die ankommenden Byte-Pakete ansehen
docker exec -w /home/node/project agent node -e 'const labor = require("./labor.js"); const ereignis = (text) => "data: " + JSON.stringify({ choices: [{ delta: { content: text } }] }) + "\n\n"; const ganz = ereignis("Eins") + ereignis(", zwei") + ereignis(", drei.") + "data: [DONE]\n\n"; (async () => { let pakete = []; let etikett = "Strom"; try { const antwort = await labor.senden({ stream: true, max_tokens: 30, messages: [{ role: "user", content: "Zählen Sie von eins bis fünf." }] }); if (!antwort.ok) throw new Error("HTTP " + antwort.status); const dekoder = new TextDecoder(); for await (const bytes of antwort.body) pakete.push(dekoder.decode(bytes, { stream: true })); } catch (fehler) { etikett = "Aufgezeichnetes Beispiel"; pakete = ganz.match(/[^]{1,45}/g); } console.log(etikett + ": die ersten Pakete aus Bytes"); pakete.slice(0, 4).forEach((eins, i) => console.log((i + 1) + " " + JSON.stringify(eins))); console.log("... " + pakete.length + " Pakete insgesamt."); })();'Hier angezeigt, nicht ausgeführt. Erstellen Sie ein kostenloses Konto, um „Ausführen“ zu drücken und ihn im Terminal Ihres eigenen Labs einzugeben.
Ein Netzwerkpaket ist keine Zeile
Die Bytes erreichen Ihr Programm in Paketen, und ein Paket kann mitten in einer Zeile enden. Führen Sie darum einen Puffer. Hängen Sie jedes Paket an, schneiden Sie jede vollständige Zeile ab und behalten Sie den Rest für das nächste Paket.
Nur eine vollständige Zeile lässt sich sicher als JSON lesen. Fügen Sie dann jedes Textstück der Antwort hinzu.
strom.js schreiben, einen Leser für ganze Zeilen
docker exec -w /home/node/project agent node -e 'require("fs").writeFileSync("strom.js", ["// strom.js: liest eine Stromantwort als vollständige Zeilen, wie auch immer die Pakete geschnitten sind.", "async function* zeilenVon(antwort) {", " let behalten = \"\";", " const dekoder = new TextDecoder();", " for await (const bytes of antwort.body) {", " behalten += dekoder.decode(bytes, { stream: true });", " const teile = behalten.split(\"\\n\");", " behalten = teile.pop();", " yield* teile;", " }", " if (behalten !== \"\") yield behalten;", "}", "", "module.exports = { zeilenVon };"].join("\n") + "\n")'Hier angezeigt, nicht ausgeführt. Erstellen Sie ein kostenloses Konto, um „Ausführen“ zu drücken und ihn im Terminal Ihres eigenen Labs einzugeben.
stueckeVon ergänzen, das den Text liefert
docker exec -w /home/node/project agent node -e 'require("fs").appendFileSync("strom.js", ["", "// Teil 2: sendet einen Aufruf im Strom und liefert die Textstücke, eins nach dem anderen.", "async function* stueckeVon(anfrage) {", " const antwort = await require(\"./labor.js\").senden({ ...anfrage, stream: true });", " if (!antwort.ok) throw new Error(\"HTTP \" + antwort.status);", " for await (const zeile of zeilenVon(antwort)) {", " if (!zeile.startsWith(\"data: \") || zeile === \"data: [DONE]\") continue;", " const stueck = JSON.parse(zeile.slice(6)).choices[0].delta.content;", " if (stueck) yield stueck;", " }", "}", "", "module.exports = { zeilenVon, stueckeVon };"].join("\n") + "\n")'Hier angezeigt, nicht ausgeführt. Erstellen Sie ein kostenloses Konto, um „Ausführen“ zu drücken und ihn im Terminal Ihres eigenen Labs einzugeben.
Ein häufiger Fehler
Warnung
Viele glauben, jedes Paket sei ein Ereignis. Das ist falsch. Ein Paket kann drei Ereignisse enthalten oder die Hälfte von einem. Ihr Programm ist richtig, wenn es denselben Text liefert, wie auch immer die Bytes geschnitten sind. Erst puffern, dann Zeilen lesen.
Die Stücke ausgeben und zusammenfügen
docker exec -w /home/node/project agent node -e 'const { stueckeVon } = require("./strom.js"); const beispiel = ["Eins", ", zwei", ", drei", ", vier", ", fünf."]; (async () => { const start = Date.now(); let erstes = null, anzahl = 0, text = "", etikett = "Antwort des Modells"; try { const frage = { max_tokens: 40, messages: [{ role: "user", content: "Zählen Sie von eins bis fünf." }] }; for await (const stueck of stueckeVon(frage)) { if (erstes === null) erstes = Date.now() - start; text += stueck; anzahl++; process.stdout.write(stueck); } } catch (fehler) { etikett = "Aufgezeichnetes Beispiel"; text = beispiel.join(""); anzahl = beispiel.length; erstes = null; process.stdout.write(text); } console.log("\n" + etikett + ": " + anzahl + " Stücke, " + text.length + " Zeichen."); if (erstes !== null) console.log("Das erste Stück kam nach " + erstes + " ms, alles nach " + (Date.now() - start) + " ms."); })();'Hier angezeigt, nicht ausgeführt. Erstellen Sie ein kostenloses Konto, um „Ausführen“ zu drücken und ihn im Terminal Ihres eigenen Labs einzugeben.
Beispiel: von den Zeilen zum Text
| Datenzeilen der Reihe nach | Angefügtes Stück | Text bisher |
|---|---|---|
| Eröffnung, nur die Rolle | Keines | Leer |
| content "Eins" | Eins | Eins |
| content ", zwei" | , zwei | Eins, zwei |
| finish_reason "stop" | Keines | Eins, zwei |
| data: [DONE] | Keines | Eins, zwei |
Die Zellen, die Lernende ausfüllen, bleiben in dieser Vorschau leer.
Zum Weiterdenken: das erste Stück messen
Führen Sie das Zusammensetzen-Skript dreimal aus und notieren Sie die Zeit bis zum ersten Stück und bis zur ganzen Antwort.
Sagen Sie dann, welche der beiden Zeiten die lesende Person stärker bemerkt, und warum ein Strom bei langen Antworten am meisten hilft.