🌊

Vercel AI SDK v5のuseChatでストリーミングが途中で止まる原因と解決法

に公開

これはなに?

Vercel AI SDK v5の useChat フックでツール呼び出しが多いチャットアプリを作っていたところ、ストリーミングが途中で止まる問題に遭遇しました。厄介なのは、HTTPステータスは200で終了し、エラーログも一切出ないことです。

この問題の調査と解決に6時間かかりました。同じ問題で困っている人や、将来の自分が忘れた時のために、原因と解決策をまとめておきます。

原因はバックプレッシャーが適切に制御されていなかったことでした。OpenAI APIから送られる大量のストリーミングイベントにReactの再レンダリングが追いつかず、ストリームが途中で終了してしまいます。

解決策は useChat フックに experimental_throttle: 100 オプションを追加するだけです。これでメッセージ更新がバッチ処理され、12回のツール呼び出しを含む長時間のストリーミングでも安定して動作するようになりました。

何が起きたか

ブラウザ上で動作するデータ分析アプリを作っていました。DuckDB Wasmを使ってクライアント側でSQLデータベースを動かし、AIがSQLを生成・実行する仕組みです。

ユーザーが「データを分析して」と要求すると、AIが複数のツール呼び出しを行いながら分析を進めるんですが、5〜12回目のツール呼び出しあたりで突然ストリーミングが止まってしまいました。

ブラウザの開発者ツールで確認してみると、APIリクエストはHTTPステータス200で正常終了しています。サイズは約23KB、処理時間は約6秒。ブラウザコンソールにもサーバーログにもエラーは一切出ていません。

EventStreamを見ると、最後のイベントは tool-input-delta で終わっていました。本来なら tool-calltool-result と続くはずなのに、そこでストリームが終了しています。

さらに困ったことに、この状態で続きのメッセージを送ると、OpenAI APIから400エラーが返ってきます。エラーメッセージは「Item 'rs_...' of type 'reasoning' was provided without its required following item.」でした。

なぜ大量のイベントが発生するのか

今回のケースでは、AIが複雑なSQL文を生成します。JOINを含む集計クエリやサブクエリなど、数百文字から1000文字を超えるSQL文になることもあります。

OpenAI Responses APIはツールの入力パラメータを文字単位でストリーミング送信するので、長いSQL文1つで数十から数百の tool-input-delta イベントが発生します。ユーザーの1回の要求で5〜12回のツール呼び出しがあると、合計で数百から千個を超えるイベントがストリーミングされることになります。

この大量のストリーミングイベントが、バックプレッシャー問題を引き起こしました。

発生しやすい条件

この問題は以下の条件で発生しやすくなります。

  • 複雑なReactアプリケーション(React Query、Toast通知、その他のプロバイダーなどを含む)
  • 複数のツール呼び出しを伴うAIタスク(5回以上)
  • 長いテキスト(SQL文など)をツール入力として生成
  • experimental_throttle オプションを設定していない

逆に、シンプルなプロトタイプや再レンダリングが高速なアプリケーションでは発生しにくいです。これが問題の診断を難しくしています。

原因はバックプレッシャー

根本原因はバックプレッシャーでした。バックプレッシャーとは、データストリームで受信側の処理速度が送信側に追いつかない時に発生する現象です。

メカニズム

Vercel AI SDKの useChat フックは、各イベントを受信するたびに状態を更新し、Reactの再レンダリングを実行します。複雑なアプリケーションでは、1回の再レンダリングに時間がかかります。

今回のケースでは、大量のイベント(1000個超)が高速で到着するのに対し、Reactの再レンダリングが追いつかないという状況が発生しました。JavaScriptのメインスレッドがブロックされ、新しいイベントを処理できなくなります。

Vercel AI SDK v5はServer-Sent Events (text/eventstream)を使っているので、処理が遅延するとイベントがキューに溜まっていきます。この状態が続くと、何らかのタイムアウトが発生してストリームが終了するようです。

エラーとして検出されない

詳細なメカニズムは完全には把握できていませんが、HTTPステータスは200のままで、エラーではなく正常なクローズとして扱われました。ブラウザのストリームには done: true が返されるため、JavaScriptのエラーハンドリングには引っかかりません。

しかし実際には、ストリームは完全なデータを送信し終える前に途中で終了しており、reasoning partが不完全な状態でメッセージ履歴に残ります。次回のメッセージ送信時に、この不完全なメッセージを含む履歴がOpenAI APIに送られると、400エラーが返されます。

このエラーメッセージはGitHub Issue #7099で報告されているものと同じなので、当初は同じ問題だと思いました。しかし実際には、issue #7099はAI SDKのバグで、今回のケースはバックプレッシャーによる間接的な問題でした。

解決方法

解決策はシンプルです。useChat フックに experimental_throttle オプションを追加するだけです。

const { messages, sendMessage, status, addToolResult } = useChat({
  experimental_throttle: 100, // 100ミリ秒ごとにバッチ処理
  // その他のオプション...
});

このオプションでメッセージ更新の頻度を制限できます。値は100ミリ秒を設定しましたが、これは試行錯誤の結果です。アプリケーションの複雑さによって適切な値は変わると思います。

スロットリングなしの場合、イベントが到着するたびに再レンダリングが発生します。大量のイベントが高速で到着すると、再レンダリングの処理が追いつかなくなります。

一方、スロットリングを適用すると、一定時間内に到着した複数のイベントを1回の更新にまとめます。これで再レンダリング回数が大幅に削減され、処理が追いつくようになります。

実際にこの修正を適用したところ、12回のツール呼び出しを含む長時間のストリーミングでも最後まで安定して動作するようになりました。次のメッセージ送信時の400エラーも発生しなくなりました。

根本的な解決策はレンダリングの最適化

ただし、experimental_throttle は対症療法です。根本的な解決策は、Reactのレンダリングパフォーマンスを改善することです。

再レンダリングが高速であれば、そもそもこの問題は発生しません。React.memo、useMemo、useCallbackなどを使って、不要な再レンダリングを減らすことで、throttleなしでも動作する可能性があります。

とはいえ、現実的には複雑なアプリケーションで完璧にレンダリングを最適化するのは難しいです。experimental_throttle を設定するだけで確実に解決できるので、まずはこちらを試すことをおすすめします。レンダリングの最適化は、パフォーマンス改善の一環として別途取り組むのが良いと思います。

なぜシンプルなプロジェクトでは問題が起きないのか

興味深いことに、同じコードをシンプルな再現プロジェクトに移植したところ、問題が発生しませんでした。これは処理速度の違いが原因です。

元のプロジェクトでは、rootコンポーネントにReact Query、Toast、その他多数のプロバイダーが含まれており、1回の再レンダリングに時間がかかっていました。対して再現プロジェクトは最小限のコンポーネント構成で、再レンダリングが高速に完了します。

この処理速度の差で、イベントの処理が追いつくかどうかが変わります。つまり、この問題はアプリケーションの複雑さに依存し、シンプルなプロトタイプでは発生せず、本番環境の複雑なアプリケーションで顕在化する典型的な問題です。

まとめ

Vercel AI SDK v5で大量のツール呼び出しを伴うストリーミングを行う場合、バックプレッシャーでストリームが途中で止まる可能性があります。Reactの再レンダリング速度がイベント到着速度に追いつかないことで発生し、HTTPステータス200で終了するため、エラーとして検出されません。

解決策は useChat フックの experimental_throttle オプションです。これでメッセージ更新の頻度を制限でき、再レンダリング回数が大幅に削減されて、安定したストリーミングが実現できます。

アプリケーションの複雑さが増すほど、このような制御の必要性は高まります。特に本番環境の複雑なアプリケーションでは、早めに設定しておくことをおすすめします。

GitHubで編集を提案

Discussion