一語ずつ届く返答
ストリーミング応答とは、言語モデルが書きながら、小さな断片に分けて送ってくる返答です。最後にまとめて届くのではありません。チャットのアプリは、言葉がすぐ現れるように、これを使います。
長い返答は何秒もかかることがあるので、役に立ちます。ストリームなら、読む人は最初の部分をほぼすぐに見られます。プログラムは、断片を読んでつなげます。
この課のゴール
メモ
断片で届く返答を読み、一つの文章につなげられる。
キーワード
| ことば | いみ | れい |
|---|---|---|
| ストリーム | 書かれながら断片で送られる返答 | |
| サーバー送信イベント | サーバーがクライアントへ行を送り続ける方法 | |
| データ行 | data: で始まり、JSON を持つストリームの行 | |
| デルタ | データ行にある新しい文字の断片 | |
| 終わりの印 | ストリームの終わりを示す最後のデータ行 | data: [DONE] |
学生が入力するセルは、このプレビューでは空白です。
ストリームはデータ行の並び
ストリームを頼むには、リクエスト本体に "stream": true を足します。するとサーバーは、サーバー送信イベントで返します。これは単純な文章で、一度に一行ずつ、イベントの間に空行が入ります。
data: で始まる各行は、JSON を一つ持ちます。文章は choices[0].delta.content にあります。最後の行は data: [DONE] です。
ストリームの行
| 行の始まり | 中身 |
|---|---|
| data: {"choices":[{"delta":{"role":"assistant"}}]} | 始まり:ロールだけで、まだ文字はない |
| data: {"choices":[{"delta":{"content":"いち"}}]} | 足す文字の断片 |
| data: {"choices":[{"delta":{},"finish_reason":"stop"}]} | 返答が終わった理由 |
| data: [DONE] | 終わりの印。JSON ではない |
| : keep-alive | 無視するコメント行 |
学生が入力するセルは、このプレビューでは空白です。
届くバイトのかたまりを見る
docker exec -w /home/node/project agent node -e 'const lab = require("./lab.js"); const event = (text) => "data: " + JSON.stringify({ choices: [{ delta: { content: text } }] }) + "\n\n"; const whole = event("いち") + event("、に") + event("、さん。") + "data: [DONE]\n\n"; (async () => { let chunks = []; let label = "ストリーム"; try { const response = await lab.send({ stream: true, max_tokens: 30, messages: [{ role: "user", content: "一から五まで数えてください。" }] }); if (!response.ok) throw new Error("HTTP " + response.status); const decoder = new TextDecoder(); for await (const bytes of response.body) chunks.push(decoder.decode(bytes, { stream: true })); } catch (error) { label = "記録済みのサンプル"; chunks = whole.match(/[^]{1,45}/g); } console.log(label + ":最初のバイトのかたまり"); chunks.slice(0, 4).forEach((one, i) => console.log((i + 1) + " " + JSON.stringify(one))); console.log("... 全部で " + chunks.length + " 個。"); })();'ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ネットワークのかたまりは行ではない
バイトは、かたまりになってプログラムに届き、かたまりは行の途中で終わることがあります。そこで、バッファを持ちます。かたまりを足し、完全な行を切り出し、残りを次のかたまりまで取っておきます。
完全な行だけが、安全に JSON として読めます。そのうえで、各断片を返答に足します。
stream.js を書く:完全な行の読み手
docker exec -w /home/node/project agent node -e 'require("fs").writeFileSync("stream.js", ["// stream.js:ストリームの返答を、かたまりの切れ目に関係なく、完全な行として読みます。", "async function* linesOf(response) {", " let held = \"\";", " const decoder = new TextDecoder();", " for await (const bytes of response.body) {", " held += decoder.decode(bytes, { stream: true });", " const parts = held.split(\"\\n\");", " held = parts.pop();", " yield* parts;", " }", " if (held !== \"\") yield held;", "}", "", "module.exports = { linesOf };"].join("\n") + "\n")'ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
文字を返す piecesOf を足す
docker exec -w /home/node/project agent node -e 'require("fs").appendFileSync("stream.js", ["", "// その2:ストリームで呼び出しを送り、文字の断片を一つずつ返します。", "async function* piecesOf(body) {", " const response = await require(\"./lab.js\").send({ ...body, stream: true });", " if (!response.ok) throw new Error(\"HTTP \" + response.status);", " for await (const line of linesOf(response)) {", " if (!line.startsWith(\"data: \") || line === \"data: [DONE]\") continue;", " const piece = JSON.parse(line.slice(6)).choices[0].delta.content;", " if (piece) yield piece;", " }", "}", "", "module.exports = { linesOf, piecesOf };"].join("\n") + "\n")'ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
よくある間違い
注意
かたまりは一つのイベントだと考える人が多くいます。そうではありません。かたまりには、イベントが三つ入ることも、半分だけ入ることもあります。バイトの切れ目が違っても同じ文章になれば、プログラムは正しいです。先にバッファし、それから行を読みます。
断片を表示してつなげる
docker exec -w /home/node/project agent node -e 'const { piecesOf } = require("./stream.js"); const sample = ["いち", "、に", "、さん", "、よん", "、ご。"]; (async () => { const started = Date.now(); let first = null, count = 0, text = "", label = "モデルの答え"; try { const asking = { max_tokens: 40, messages: [{ role: "user", content: "一から五まで数えてください。" }] }; for await (const piece of piecesOf(asking)) { if (first === null) first = Date.now() - started; text += piece; count++; process.stdout.write(piece); } } catch (error) { label = "記録済みのサンプル"; text = sample.join(""); count = sample.length; first = null; process.stdout.write(text); } console.log("\n" + label + ":" + count + " 個の断片、" + text.length + " 文字。"); if (first !== null) console.log("最初の断片は " + first + " ms 後、全部は " + (Date.now() - started) + " ms 後に届きました。"); })();'ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
例:行から文章まで
| データ行(順番) | 足した断片 | ここまでの文章 |
|---|---|---|
| 始まり、ロールだけ | なし | 空 |
| content "いち" | いち | いち |
| content "、に" | 、に | いち、に |
| finish_reason "stop" | なし | いち、に |
| data: [DONE] | なし | いち、に |
学生が入力するセルは、このプレビューでは空白です。
さらに:最初の断片の時間を測る
組み立てのスクリプトを三回実行し、最初の断片までの時間と、返答全体までの時間を書き留めます。
そして、読む人がより強く感じるのはどちらかを言いましょう。ストリームが、長い返答で特に役立つ理由も言ってください。