LLM がレスポンスをストリーミングする仕組み

公開日: 2025 年 1 月 21 日

ストリーミングされた LLM レスポンスは、増分的に継続的に出力されるデータで構成されます。ストリーミング データは、サーバーとクライアントで異なるように見えます。

サーバーから

ストリーミング レスポンスがどのようなものかを確認するため、コマンドライン ツール curl を使用して、長いジョークを言うように Gemini に指示しました。Gemini API への次の呼び出しについて考えてみましょう。お試しになる場合は、URL の {GOOGLE_API_KEY} を Gemini API キーに置き換えてください。

$ curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key={GOOGLE_API_KEY}" \
      -H 'Content-Type: application/json' \
      --no-buffer \
      -d '{ "contents":[{"parts":[{"text": "Tell me a long T-rex joke, please."}]}]}'

このリクエストは、次の(切り捨てられた)出力をイベント ストリーム形式でロギングします。各行は data: で始まり、その後にメッセージ ペイロードが続きます。具体的な形式は実際には重要ではなく、重要なのはテキストのチャンクです。

//
data: {"candidates":[{"content": {"parts": [{"text": "A T-Rex"}],"role": "model"},
  "finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],
  "usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 4,"totalTokenCount": 15}}

data: {"candidates": [{"content": {"parts": [{ "text": " walks into a bar and orders a drink. As he sits there, he notices a" }], "role": "model"},
  "finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],
  "usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 21,"totalTokenCount": 32}}
コマンドを実行すると、結果チャンクがストリーミングされます。

最初のペイロードは JSON です。ハイライト表示された candidates[0].content.parts[0].text を詳しく見てみましょう。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "A T-Rex"
          }
        ],
        "role": "model"
      },
      "finishReason": "STOP",
      "index": 0,
      "safetyRatings": [
        {
          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
          "probability": "NEGLIGIBLE"
        },
        {
          "category": "HARM_CATEGORY_HATE_SPEECH",
          "probability": "NEGLIGIBLE"
        },
        {
          "category": "HARM_CATEGORY_HARASSMENT",
          "probability": "NEGLIGIBLE"
        },
        {
          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
          "probability": "NEGLIGIBLE"
        }
      ]
    }
  ],
  "usageMetadata": {
    "promptTokenCount": 11,
    "candidatesTokenCount": 4,
    "totalTokenCount": 15
  }
}

最初の text エントリは、Gemini の回答の始まりです。text エントリをさらに抽出すると、レスポンスは改行で区切られます。

次のスニペットは、複数の text エントリを示しています。これは、モデルからの最終的なレスポンスを示しています。

"A T-Rex"

" was walking through the prehistoric jungle when he came across a group of Triceratops. "

"\n\n\"Hey, Triceratops!\" the T-Rex roared. \"What are"

" you guys doing?\"\n\nThe Triceratops, a bit nervous, mumbled,
\"Just... just hanging out, you know? Relaxing.\"\n\n\"Well, you"

" guys look pretty relaxed,\" the T-Rex said, eyeing them with a sly grin.
\"Maybe you could give me a hand with something.\"\n\n\"A hand?\""

...

では、T-rex のジョークではなく、もう少し複雑なことをモデルに尋ねるとどうなるでしょうか。たとえば、Gemini に、数値が偶数か奇数かを判断する JavaScript 関数を作成するよう依頼します。text: チャンクの見た目が少し異なります。

出力には、JavaScript コードブロックで始まる Markdown 形式が含まれるようになりました。次のサンプルには、以前と同じ前処理ステップが含まれています。

"```javascript\nfunction"

" isEven(number) {\n  // Check if the number is an integer.\n"

"  if (Number.isInteger(number)) {\n  // Use the modulo operator"

" (%) to check if the remainder after dividing by 2 is 0.\n  return number % 2 === 0; \n  } else {\n  "
"// Return false if the number is not an integer.\n    return false;\n }\n}\n\n// Example usage:\nconsole.log(isEven("

"4)); // Output: true\nconsole.log(isEven(7)); // Output: false\nconsole.log(isEven(3.5)); // Output: false\n```\n\n**Explanation:**\n\n1. **`isEven("

"number)` function:**\n   - Takes a single argument `number` representing the number to be checked.\n   - Checks if the `number` is an integer using `Number.isInteger()`.\n   - If it's an"

...

さらに、マークアップされたアイテムの一部は、あるチャンクで始まり、別のチャンクで終わります。マークアップの一部がネストされています。次の例では、ハイライト表示された関数が 2 行(**isEven(number) function:**)に分割されています。組み合わせると、出力は **isEven("number) function:** になります。つまり、書式設定された Markdown を出力する場合は、Markdown パーサーで各チャンクを個別に処理することはできません。

クライアントから

MediaPipe LLM などのフレームワークを使用してクライアントで Gemma などのモデルを実行する場合、ストリーミング データはコールバック関数を介して取得されます。

次に例を示します。

llmInference.generateResponse(
  inputPrompt,
  (chunk, done) => {
     console.log(chunk);
});

Prompt API を使用すると、ReadableStream を反復処理して、ストリーミング データをチャンクとして取得できます。

const languageModel = await LanguageModel.create();
const stream = languageModel.promptStreaming(inputPrompt);
for await (const chunk of stream) {
  console.log(chunk);
}

次のステップ

ストリーミング データをパフォーマンスとセキュリティを確保しながらレンダリングする方法についてお悩みですか?LLM レスポンスをレンダリングするためのベスト プラクティスをご覧ください。