生成AIの出力をストリーミング表示する画面で、結果が1行も表示されないままエラー画面に遷移するという不具合に遭遇しました。コンソールに出ていたのは Minified React error #185(= Maximum update depth exceeded)。
調べていくと、原因は「SSEのメッセージが1接続内にまとめて届いたとき、クライアントがメッセージ件数分だけ無制限にReduxへdispatchし、Reactのネスト更新上限を超える」という構造的な問題でした。この記事では、再現が難しいこの不具合をどう再現し、どう原因を特定し、どう直したかを紹介します。
背景
私たちが開発している「ELYZA Works」は、LLMを使った業務アプリケーションを作成・実行できるプラットフォームです。ユーザーは「議事録の要約」「メール文面の生成」のようなAIアプリを画面から実行し、生成結果をリアルタイムに閲覧できます。
LLMの生成には時間がかかるため、実行結果画面では SSE(Server-Sent Events) を使って生成中のテキストを逐次ストリーミング表示しています。構成はざっくり以下のとおりです。
LLM → サーバー → SSE(fetch + ReadableStream)→ クライアント → Redux store → React
クライアント側では、SSEの1メッセージを「差分更新(delta update)」として受け取ります。各メッセージには連番の id が振られており、init(初期化)/ append(文字列追記) / replace(置換)のいずれかのアクションを持ちます。
data: {"id": 0, "action": "init", "data": {"output": {"text": ""}}}
data: {"id": 1, "action": "append", "data": {"output": {"text": "Hello"}}}
data: {"id": 2, "action": "append", "data": {"output": {"text": " world!"}}}
クライアントはメッセージを1件受け取るたびに、現在のデータへ差分を適用してReduxへ dispatch します。Reactコンポーネントは useSelector でstoreを購読しているので、dispatchのたびに再レンダリングされ、テキストが1文字ずつ伸びていくように見える——という仕組みです。
受信処理の中心は、ReadableStream から \n\n 区切りでメッセージを1件ずつ取り出して再帰的に処理する関数でした(簡略化しています)。
async function readStreamData(reader, dispatch, action, currentData, currentSequence) {
// バッファに完全なメッセージが残っていればそれを、なければ reader.read() で読む
const { done, value } = await buildChunkData(reader);
if (done) return;
const deltaUpdate = parseChunkData(value);
if (deltaUpdate.id !== currentSequence) {
throw new StreamError('Sequence is not match');
}
const updatedData = applyDeltaUpdate(currentData, deltaUpdate);
dispatch(action(updatedData)); // ← メッセージ1件につき1 dispatch
await readStreamData(reader, dispatch, action, updatedData, currentSequence + 1);
}
普段は何の問題もなく動いていました。メッセージが1件ずつネットワークから届く限りは。
問題
ある日、次のような不具合報告が届きました。
- AIアプリを実行して結果画面を開くと、実行中のスピナーが出たまま結果が1行も表示されない
- 実行からしばらく経つと、アプリ全体がエラー画面に遷移する
- ブラウザのコンソールには
Error: Minified React error #185が出ている
React error #185は、本番ビルド(minify済み)で Maximum update depth exceeded が発生したときのエラーです。useEffect の中でのsetStateが無限ループしているときなどに出る、あの「更新回数が上限を超えました」ですね。Reactはネストした更新(更新処理中にさらに発生した更新)が50回を超えるとこのエラーを投げて処理を打ち切ります。
厄介だったのは、この不具合が一部のユーザーでしか起きないことでした。開発環境では普通に再現せず、同じアプリを実行しても手元では正常にストリーミング表示されます。また、報告者の環境の開発者ツールを見ると、streamのリクエストは1本だけで、リトライやエラーレスポンスもありません。サーバーは正常にデータを返しているのに、クライアントだけが死んでいる状態です。
再現
「一部のユーザーでしか起きない」「手元で再現しない」——この時点で疑ったのは、ネットワーク経路の違いでした。
SSEは本来メッセージが1件ずつ届くことを期待するプロトコルですが、経路上にバッファリングを行うプロキシやセキュリティ製品が挟まっていると、サーバーが逐次送信したメッセージが経路上で溜め込まれ、クライアントには最後にまとめてドカッと届くことがあります。ユーザーの環境(社内プロキシなど)に依存するので、「一部のユーザーだけ」という症状とも整合します。
この仮説は、実行結果画面のコンポーネントと不具合発生時の状況(録画・開発者ツールのスクショ)をAIアシスタント「Fable 5」に共有し、状態更新周りで#185が起きうる箇所を推測させたことで得られたものです(詳しい経緯は記事末尾の付録「調査段階でのFable 5の活用」で紹介します)。この仮説を検証するため、Fable 5に既存のPlaywright E2Eテスト資産を使った再現コードを作成してもらいました。ポイントは、ネットワークレスポンスを差し替えて「一括到達」を人工的に作ることです。
// Playwrightのrouteインターセプトで、SSEエンドポイントのレスポンスを
// 「init + append N件を \n\n で連結した単一ボディ」に差し替える
await page.route('**/api/records/*/stream', async (route) => {
const messages = [
`data: ${JSON.stringify({ id: 0, action: 'init', data: initialData })}`,
];
for (let i = 1; i <= APPEND_COUNT; i++) {
messages.push(
`data: ${JSON.stringify({ id: i, action: 'append', data: { output: { text: `chunk${i} ` } } })}`,
);
}
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: messages.join('\n\n') + '\n\n',
});
});
この再現コードで APPEND_COUNT(一括で届くappendメッセージの件数)を変えながら発生条件を確認してもらったところ、結果は明確でした(この境界値の調整もFable 5が自動で行ってくれました)。
| 1回のflushで届くappendメッセージ数 | 結果 |
|---|---|
| 10 / 15 / 20件 | 正常に表示 |
| 25件以上 | React error #185 → エラー画面(報告と同一の文言・同一の画面) |
再現成功です。Reactの上限は50回のはずなのに約半分の25件で発症している点は伏線で、これは後述する「増幅要因」によるものでした。
原因特定
再現環境ができたので、あとはFable 5が示した推測を実際のコードで裏付けるだけです。クラッシュに至る連鎖は次のとおりでした。
SSEメッセージ数十件が1接続にまとめて到達
→ readStreamData がメッセージ1件ごとに連続dispatch(停止点なし)
→ dispatchごとにeffectが発火してさらにdispatch(増幅要因)
→ ネスト更新50回超 → React error #185 → エラー画面
主因: バッファ済みメッセージの「待ちなし」連続dispatch
先ほどの readStreamData をもう一度見てみます。
const { done, value } = await buildChunkData(reader);
// ...
dispatch(action(updatedData));
await readStreamData(reader, dispatch, action, updatedData, currentSequence + 1);
buildChunkData は、1回の reader.read() に複数メッセージが連結されて届いた場合、残りを内部バッファに保持し、次回呼び出しでバッファから1件ずつ返す実装になっていました。
メッセージが1件ずつネットワークから届く場合、各dispatchの間には reader.read() のネットワーク待ちが挟まります。この待ち時間の間にReactはレンダリングを完了できるので、何も問題は起きません。
しかし一括到達した場合、2件目以降はすべてバッファから同期的に取り出せてしまうため、await はmicrotaskの消化だけで即座に解決します。つまり、Reactに描画の隙を与えないままdispatchがメッセージ件数分連打される。dispatch回数に上限を設ける仕組み(スロットリングやバッチング)はどこにもありませんでした。
増幅要因: effectのOR条件バグ
とはいえ、Reduxのdispatch連打だけならReact 18以降の自動バッチングである程度は吸収されます。上限50に対して25件で発症したのは、dispatchのたびにeffectが発火してさらにdispatchを重ねる経路があったからです。
その1つが、出力フォームコンポーネントにあったこのeffectです。
React.useEffect(() => {
if (
(editor.output_type_id !== selectedUsecase?.output_type_id ||
editor.output_type_id !== selectedTemplate?.output_type_id) &&
editor.resetCount === 0
) {
// 出力欄を初期化するアクションをdispatch
dispatch(setInitOutputAction({ /* ... */ }));
}
}, [/* records.record を含む依存配列 */]);
条件式に注目してください。
editor.output_type_id !== A || editor.output_type_id !== B
この形の条件は、A !== B である限り必ずtrueになります(値がAならBと不一致、値がBならAと不一致)。今回の実行結果画面では selectedTemplate が undefined になるため A !== B が常に成立し、条件は恒真でした。
さらに依存配列に records.record(オブジェクト参照)が含まれていたため、ストリームのチャンクを受信してrecordが更新されるたびにこのeffectが発火し、毎回 setInitOutputAction をdispatchしていました。つまり「1チャンク受信 → dispatch → effect発火 → もう1発dispatch」という増幅が起きていたのです。
本来の意図は「表示中のアプリから解決したoutput_type_idとeditorの値がズレていたら初期化する」だったはずで、次のように書くべきものでした。
const resolvedOutputTypeId =
selectedUsecase?.output_type_id ?? selectedTemplate?.output_type_id;
if (editor.output_type_id !== resolvedOutputTypeId && ...) {
「フォールバック付きで解決した1つの値」と比較したいのに、フォールバックの各候補とOR比較してしまう——レビューでも見落としやすいバグだと思います。
修正方法
修正は2段構えです。
1. 根本対応: バッファ済みメッセージをマージして1回のdispatchにまとめる
修正前後の構造
コードに入る前に、修正前の処理の流れを整理します。登場人物は3つです。
-
reader —
ReadableStreamの読み取り口。reader.read()でネットワークから届いたデータの塊を受け取る -
バッファ — 1回の
read()に複数メッセージが連結されて届いたとき、余った分を一時的に貯めておく文字列。reader ごとに1つ持つ - readStreamData — 「メッセージを1件取り出す → 差分を適用 → dispatch」を繰り返す再帰関数
修正前は、バッファに何件溜まっていようと1件取り出すたびにdispatchしていました。25件が一括到達した場合はこうなります。
【修正前】25件が一括到達した場合
reader.read() ──→ バッファに25件たまる
│
├─ 1件目を取り出す → 差分適用 → dispatch ①
├─ 2件目を取り出す → 差分適用 → dispatch ②
├─ 3件目を取り出す → 差分適用 → dispatch ③
│ …(間にネットワーク待ちがないので、
│ Reactに描画させる隙がないまま連打)
└─ 25件目を取り出す → 差分適用 → dispatch ㉕ → 💥 #185
修正の方針はシンプルで、「dispatchする前に、バッファに溜まっている分を全部差分適用してしまい、dispatchは1回だけにする」です。
【修正後】25件が一括到達した場合
reader.read() ──→ バッファに25件たまる
│
├─ 1件目を取り出す → 差分適用
├─ 2件目を取り出す → 差分適用 ┐
├─ 3件目を取り出す → 差分適用 │ バッファが空になるまで
│ … │ マージし続ける(dispatchしない)
├─ 25件目を取り出す → 差分適用 ┘
│
└─ dispatch ①(25件分をまとめて1回)✅
一方、メッセージが1件ずつ届く通常ケースでは、1件処理した時点でバッファは空なので「溜まっている分をマージ」の工程は何もせずに素通りし、従来どおり1件ごとにdispatchされます。正常系の挙動は変えずに、異常系(一括到達)だけ潰すのがこの構造のポイントです。
これを実装するには、「ネットワークを待たずに、バッファに溜まっている完全なメッセージだけを取り出す」操作が必要になります。既存の buildChunkData(メッセージ1件を返す関数)は「バッファにあればそれを返し、なければ reader.read() でネットワークを待つ」という2つの仕事を1つでやっていたので、前半の「バッファから取り出す」部分だけを関数として切り出しました。
コード
まず、切り出したヘルパー関数 takeBufferedChunkData です。バッファ(readerBuffers)から区切り文字 \n\n までの完全なメッセージ1件を取り出して返し、完全なメッセージがなければ undefined を返します。reader.read() は決して呼ばない(= ネットワークを待たない)のがミソです。
/**
* バッファ済みの完全なメッセージがあれば同期的に取り出す。
* なければ undefined を返す(reader.read() は呼ばない)。
*/
export function takeBufferedChunkData(
reader: ReadableStreamDefaultReader<string>,
): string | undefined {
const data = trimLeadingDelimiters(readerBuffers.get(reader) ?? '');
const delimIdx = data.indexOf(MESSAGE_DELIMITER);
if (delimIdx > 0) {
readerBuffers.set(reader, data.slice(delimIdx + MESSAGE_DELIMITER.length));
return data.slice(0, delimIdx);
}
readerBuffers.set(reader, data);
return undefined;
}
次に呼び出し側の readStreamData です。図で示したとおり、dispatchの前に takeBufferedChunkData が undefined を返す(= バッファが空になる)までループで吸い上げ、差分を mergedData にマージし続けます。そしてループを抜けたところで1回だけdispatchします。
const updatedData = applyDeltaUpdate(currentData, deltaUpdate);
// バッファに溜まっている分はdispatchせずマージして1回のdispatchにまとめる。
// SSEメッセージが1接続内に一括到達した場合、メッセージ件数分の連続dispatchで
// Reactのネスト更新上限を超えてクラッシュする(React error #185)ため。
let mergedData = updatedData;
let nextSequence = currentSequence + 1;
for (
let buffered = takeBufferedChunkData(reader);
buffered !== undefined;
buffered = takeBufferedChunkData(reader)
) {
const bufferedDelta = parseChunkData<StreamDeltaUpdate<StreamingData>>(buffered);
if (bufferedDelta.id !== nextSequence) {
throw new StreamError('Sequence is not match');
}
mergedData = applyDeltaUpdate(mergedData, bufferedDelta);
nextSequence++;
}
dispatch(action(mergedData));
await readStreamData(reader, dispatch, controller, action, mergedData, nextSequence);
この変更で、dispatch回数は「メッセージ数」から「reader.read() の回数」に落ちます。80件が一括到達しても read() が1回ならdispatchも1回。つまり「何件まとめて届いたら壊れるか」という閾値の問題が構造的に消えます。
スロットリングやdebounceで対処する案もありましたが、それらは正常系の表示タイミングにも影響が及びます。この方式なら通常ケースではループが1回も回らないため、正常系の表示品質に一切影響を与えずに異常系だけを潰せます。
なお、ループ内でも id の連番チェック(シーケンス番号の検証)は1件ずつ行っているので、メッセージの取りこぼし検知は従来どおり機能します。
2. 増幅要因の除去: effectのOR条件を修正
前述のOR条件バグは、フォールバックで解決した単一の値と比較する形に修正しました。こちらは根本原因ではないですが、ついでに修正します。
React.useEffect(() => {
const resolvedOutputTypeId =
selectedUsecase?.output_type_id ?? selectedTemplate?.output_type_id;
if (
editor.output_type_id !== resolvedOutputTypeId &&
editor.resetCount === 0
) {
const initValue = initOutput(records.record, resolvedOutputTypeId);
dispatch(
setInitOutputAction({
output_type_id: resolvedOutputTypeId,
output: initValue,
modifiedOutput: initValue,
}),
);
}
}, [/* deps */]);
これで実行結果画面ではチャンク受信ごとの無駄なdispatchがなくなり、effect連鎖の増幅が止まります。1の修正だけでもクラッシュは防げますが、潜在的な「無駄な更新」も一緒に取り除きました。
検証
-
takeBufferedChunkDataのユニットテストを追加(空バッファではread()を呼ばない、先頭の連続区切りをトリムする、不完全なメッセージは残留させる、などの境界ケース) - 再現スクリプトで append 80件の一括到達を流し込み、エラーが出ずに結果がそのまま表示されることを確認
- 通常の逐次ストリーミング表示が従来どおり滑らかであることを実機確認
まとめ
- SSEはメッセージが1件ずつ届くとは限らない。 経路上のプロキシ等でバッファされ、一括到達することがある。「一部のユーザーだけ壊れる」ときはネットワーク経路の違いを疑う
- ストリーム受信処理では、受信レートがそのままUI更新レートに直結しない設計にする。 「1メッセージ = 1 dispatch」は逐次到達の前提が崩れた瞬間に破綻する。手元に溜まっている分はマージしてからdispatchすれば、正常系の挙動を変えずに異常系だけを潰せる
-
x !== A || x !== Bは(A ≠ B なら)恒真になる。フォールバック付きの値と比較したいなら、先にA ?? Bで解決してから比較する - 再現しない不具合は、疑わしい入力を人工的に作る。 Playwrightのrouteインターセプトでレスポンスを差し替えれば、「経路上でバッファされたSSE」のような環境依存の事象も決定的に再現できる
同じようにLLMのストリーミング表示を実装している方の参考になれば幸いです。
調査段階でのFable 5の活用
ところで、本不具合の調査では輸出規制前のFable 5を偶然使うことができましたので1、どのように活用したのかを簡単にお話しします。
事前準備
一部のユーザーでのみ発生することはすでに述べた通りですが、幸いなことにこのユーザー様とは良好な関係を構築できていたため、不具合の様子を録画していただけることになりました。
事前にブラウザ開発者ツールの設定を共有していたこともあり、不具合の様子が収められた動画と開発者ツールのスクショをいただけました。
まず、動画とスクショを穴が開くまで観察し、以下の気づきを得ました。
- LLMアプリケーションを実行後、20秒前後スピナーが回ってから、結果が1文字も表示されることなく即エラー画面に遷移
- 開発者ツールのネットワークタブではstreamの通信が1つだけ
一見すると#185エラーが起きる原因はわかりませんが、これはこれで特異な状況です。
Fable 5での調査
発生する画面(tsxファイル)はわかっていたので、前記の気づきと一緒にFable 5 (high)に共有し、状態更新で#185が起きうる箇所の推測をさせました。
その結果、本記事で紹介した原因の推測まで辿り着くことができました。
#185エラー時は、短時間に大量の更新イベントが発生するはずですが、実行後ずいぶん時間が経ってからいきなりエラーになることをヒントに、SSEメッセージの処理にたどり着いたようです。
原因の確定
この時すでに修正方針も一緒に回答してきた気もしますが、一旦無視して、再現コードを作成可能か聞いて作ってもらうことにしました。
調査結果はあくまで推測に過ぎず、本当にそれが原因でエラー画面に遷移するのか、修正によりエラーが起きなくなるのか確認させるためです。
幸いにもPlaywriteでE2Eテストを書いていたため、その資産を活用して再現コードを作って実際に不具合が起きることを確認してくれました。
ちなみに不具合の確認中、送信するメッセージ数を調整しながら発生する境界値チェックも自動で行ってくれました。かしこいですね。
これで今まで私たちが見ることができなかった不具合をいつでも再現でき、修正後の動作確認もできるようになりました。
振り返り
結果として厄介な問題をFableで解決することができました。
適切なコンテキストと指示を与えることで高い成果が得られるかしこいモデルであることが実感できましたので、今後も上手く付き合っていきたいところです。
-
再現・修正コードが完成した翌日に輸出規制の影響で停止されました ↩