クリーム色の背景、色付き表示、高コントラスト、大きな文字のほうが読みやすいですか?配色と文字を選ぶと、すべてのページに一度で反映されます。

無料レッスン

ストリーミング応答の読み方

このレッスンのあとで: 断片で届く返答を読み、一つの文章につなげられる。

60分のレッスンコマンド4件AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

一語ずつ届く返答

ストリーミング応答とは、言語モデルが書きながら、小さな断片に分けて送ってくる返答です。最後にまとめて届くのではありません。チャットのアプリは、言葉がすぐ現れるように、これを使います。

長い返答は何秒もかかることがあるので、役に立ちます。ストリームなら、読む人は最初の部分をほぼすぐに見られます。プログラムは、断片を読んでつなげます。

この課のゴール

メモ

断片で届く返答を読み、一つの文章につなげられる。

キーワード

ことばいみれい
ストリーム書かれながら断片で送られる返答
サーバー送信イベントサーバーがクライアントへ行を送り続ける方法
データ行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]なしいち、に

学生が入力するセルは、このプレビューでは空白です。

さらに:最初の断片の時間を測る

組み立てのスクリプトを三回実行し、最初の断片までの時間と、返答全体までの時間を書き留めます。

そして、読む人がより強く感じるのはどちらかを言いましょう。ストリームが、長い返答で特に役立つ理由も言ってください。

このラボとソースについて: AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

授業で使いますか?このモジュールを学生に割り当てると、教員用ガイド、自動採点つきの課題、学生ごとのラボを使えます。