DelphiでLLMレスポンスをストリーミングする: Server-Sent Eventsによるトークン単位の出力

· コンポーネント

手っ取り早い答え: sgcWebSocketsを使ってDelphiでLLMレスポンスをストリーミングするには、コンポーネントの OnHTTPAPISSE イベントを割り当ててから、ストリーミングメソッドを呼び出します — OpenAIには Stream := True 付きの _CreateChatCompletion、Anthropic ClaudeとOllamaには _CreateMessageStream、Geminiには _CreateContentStream です。各デルタはServer-Sent Eventとしてハンドラーに届きます。aDataMemo に追加すれば、モデルが生成するにつれて回答が自動的に打ち出されていきます。

ストリーミングしないLLM呼び出しは、回答全体が用意できるまでブロックします。1段落の返答ならそれで構いませんが、長い補完では、ユーザーは何のフィードバックもないまま、フリーズしたウィンドウを数秒間見つめることになります。ストリーミングはそれを解決します。モデルは出力を1本のHTTP接続上で小さなチャンクの連続として返し、あなたは各チャンクが届いた瞬間にそれを描画します。その結果、ChatGPTで見られるおなじみの「タイピング」効果が得られ、回答全体に時間がかかる場合でも応答性の高いUIになります。

ストリーミングの仕組み: OnHTTPAPISSE

内部では、すべてのsgcWebSockets AIコンポーネントがServer-Sent Events(SSE)を使ってストリーミングします。プロバイダーはレスポンスを開いたまま保ち、トークンが生成されるたびにイベントをプッシュします。コンポーネントはそれらのイベントを解析し、すべてのコンポーネントで同じシグネチャを持つ単一のハンドラーを通じて、チャンクごとに1つのイベントを発生させます。

procedure TForm1.HandleSSE(Sender: TObject;
  const aEvent, aData: string; var Cancel: Boolean);
begin
  // aEvent  -> the SSE event name (provider-specific)
  // aData   -> the payload for this chunk (the token delta)
  // Cancel  -> set True to abort the stream early
  Memo1.Lines.Add(aData);
end;

これが契約のすべてです。aData は増分のペイロードを運び、aEvent はそれがどの種類のイベントかを教え(複数のイベントタイプを発するプロバイダーで便利です)、Cancel := True を設定するとストリームが停止します。たとえばユーザーが停止ボタンをクリックしたときなどです。同じハンドラーの形がOpenAI、Anthropic、Gemini、Ollamaで機能するので、一度書けばプロバイダー間で再利用できます。

OpenAI: Stream := True

TsgcHTTP_API_OpenAI では、リクエストでストリーミングを有効にし、イベントをフックします。OpenAIOptions.ApiKey を設定し、OnHTTPAPISSE を割り当てれば、チャット補完が1ブロックではなくチャンクごとに配信されます。

uses
  sgcHTTP_API_OpenAI;

var
  OpenAI: TsgcHTTP_API_OpenAI;
begin
  OpenAI := TsgcHTTP_API_OpenAI.Create(nil);
  OpenAI.OpenAIOptions.ApiKey := 'sk-...';

  // Each token delta arrives in HandleSSE
  OpenAI.OnHTTPAPISSE := HandleSSE;

  // Build a typed request, set Stream := True, then call
  OpenAI._CreateChatCompletion('gpt-4o-mini', 'Explain WebSockets in detail.');
end;

型付きリクエストは Stream プロパティを公開しているので、リクエストオブジェクトを自分で構築する場合は、送信する前に Stream := True を設定します。トークンは生成されるにつれて OnHTTPAPISSE を通じて表面化し、あなたはそれぞれを Memo に追加します。

Anthropic Claude: _CreateMessageStream

Claudeは専用のストリーミングヘルパーを公開しているので、切り替える別個のフラグはありません。TsgcHTTP_API_Anthropic_CreateMessageStream を呼び出すと、そのリクエストでSSEが有効になります。APIキーとバージョンを設定し、ハンドラーを割り当てて呼び出します。

uses
  sgcHTTP_API_Anthropic;

var
  Anthropic: TsgcHTTP_API_Anthropic;
begin
  Anthropic := TsgcHTTP_API_Anthropic.Create(nil);
  Anthropic.AnthropicOptions.ApiKey := 'sk-ant-...';
  Anthropic.AnthropicOptions.AnthropicVersion := '2023-06-01';

  Anthropic.OnHTTPAPISSE := HandleSSE;
  Anthropic._CreateMessageStream(
    'claude-3-5-sonnet-latest',
    'Summarise RFC 6455',
    1024);
end;

Claudeはストリーミング中にいくつかのSSEイベントタイプを発します(コンテンツブロックの開始、デルタ、そして停止)。必要であれば aEvent 引数でそれらを区別できます。「届いたテキストを表示する」だけのシンプルなUIなら、aData を追加するだけで十分です。

GeminiとOllama: 同じ形

Google Geminiは、独自のストリーミングメソッド TsgcHTTP_API_Gemini_CreateContentStream で、まったく同じパターンに従います。

Gemini.OnHTTPAPISSE := HandleSSE;
Gemini._CreateContentStream(
  'gemini-2.0-flash',
  'Explain quantum entanglement',
  1024);

ローカルモデルもまったく同じように動作します。TsgcHTTP_API_Ollama はAPIキーを必要としません — OllamaOptions.BaseUrlhttp://localhost:11434/api に向けて _CreateMessageStream を呼び出せば、あなた自身のハードウェア上のオープンモデルが、同じ OnHTTPAPISSE ハンドラーを通じてストリーミングで返ってきます。

Ollama.OllamaOptions.BaseUrl := 'http://localhost:11434/api';
Ollama.OnHTTPAPISSE := HandleSSE;
Ollama._CreateMessageStream('llama3', 'Summarise RFC 6455');

4つのプロバイダー、1つのイベント、それぞれ1回のメソッド呼び出し。ストリーミングバックエンドの切り替えは、書き直しではなく局所的な編集です。

UIを安全に更新する

ハンドラーに関する実践的な注記をいくつか。まず、OnHTTPAPISSE の中での作業は小さく保ちましょう — デルタを追加して戻るだけです。トークンごとの重い処理はストリームを途切れがちに感じさせるので、テキストを蓄積し、ストリームが終了してから高コストなフォーマット処理を一度だけ行います。次に、スレッドのコンテキストに注意してください。バックグラウンドスレッドからリクエストを開始すると、SSEイベントはそのスレッドで発生し、メインスレッド以外でVCLやFMXのコントロールに触れるのは安全ではありません。その場合は TThread.Synchronize(またはブロックしない追加なら TThread.Queue)で更新を戻します。

procedure TForm1.HandleSSE(Sender: TObject;
  const aEvent, aData: string; var Cancel: Boolean);
begin
  TThread.Queue(nil,
    procedure
    begin
      Memo1.SelStart := Length(Memo1.Text);
      Memo1.SelText := aData; // append at the caret, no full repaint
    end);
end;

Lines.Add ではなく SelText で追加すると、トークンごとに Memo 全体を再フローさせずに済み、長いストリームをスムーズに保てます。メインスレッドからAPIを呼び出す場合は、TThread.Queue のラッパーを外して、コントロールを直接更新できます。

はじめに

これらのコンポーネントはすべてsgcWebSocketsに付属しています。無料トライアルを入手し、使いたいプロバイダーのコンポーネントを配置し、OnHTTPAPISSE を割り当ててストリーミングメソッドを呼び出します — 数行でトークン単位のUIが手に入ります。メソッドの完全なリファレンスについてはOpenAIコンポーネントのページAnthropicコンポーネントのページをご覧いただくか、AI & LLM コンポーネントハブですべてのモデルを閲覧してください。

ご質問やフィードバックは? お問い合わせください — コードを書いた本人たちから返信が届きます。

関連