【ITニュース解説】Move Slow LLM Calls Off the Request Path with BullMQ in Node.js
2026年10月10日に「Dev.to」が公開したITニュース「Move Slow LLM Calls Off the Request Path with BullMQ in Node.js」について初心者にもわかりやすく解説しています。
ITニュース概要
LLMなどの時間のかかる処理は、直接HTTPリクエストで実行せず、BullMQキューとRedisで非同期処理する。これによりAPIは高速に応答し、タイムアウトや重複リトライ、プロバイダ制限を防ぎ安定稼働する。クライアントはジョブIDを受け取り、進捗をリアルタイムで確認できる。
ITニュース解説
大規模言語モデル(LLM)の利用が広がる中で、Node.jsアプリケーションにLLMを組み込む際、その処理にかかる時間が問題となる場合がある。LLMへの問い合わせは数秒から数十秒、場合によってはそれ以上かかることがあり、このような時間のかかる処理をWebアプリケーションの標準的なリクエスト処理の経路(リクエストパス)に直接組み込むと、様々な課題が生じる。
まず、リクエストパスにLLM呼び出しを直接置くことの具体的な問題点を考えてみよう。一つ目は「タイムアウト」である。ユーザーからのリクエストを受け付けるサーバーや、その手前にあるロードバランサー、リバースプロキシ、さらにはクライアント自身にも、通信が途絶えたと判断するまでの時間、つまり「アイドルタイムアウト」が設定されている。LLMの生成処理がこのタイムアウトを超えてしまうと、ユーザーはエラー画面を目にするが、実は裏側ではLLMの処理は正常に完了しているという不整合が発生する可能性がある。
二つ目は「リトライによる重複」だ。タイムアウトによってユーザーがエラーと判断した場合、ブラウザやユーザーはもう一度リクエストを試すことが多い。これにより、完了しているかもしれないLLMへの呼び出しが再度行われ、結果として同じ処理に対して二重の費用が発生したり、データベースに同じデータが二重に書き込まれたりするリスクがある。
三つ目は「プロバイダー制限」だ。多くのLLMサービスには、一定時間内に処理できるリクエスト数に上限(レートリミット)が設けられている。トラフィックが急増し、LLMへの呼び出しが一斉に行われると、この制限に抵触し、「429 Too Many Requests」のようなエラーがユーザーに返されてしまう。
そして四つ目は「デプロイ時の問題」だ。Webサーバーは、新しいバージョンのアプリケーションを公開する際に再起動されることが多い。リクエストパス上でLLM呼び出しが実行中にサーバーが再起動されると、その処理は中断されてしまい、結果的にユーザーの作業が失われることになる。
これらの問題を解決するために有効なのが、時間のかかるLLM呼び出しをリクエストパスから「切り離し」、非同期に処理する仕組みである。これは、Web APIはすぐにクライアントに応答を返し、実際の重い処理は裏側で別のプロセスが行うという考え方だ。このために「キュー」(待ち行列)と「ワーカー」(作業者)というシステムを用いる。本記事では、Node.js環境でBullMQというキューライブラリとRedisというデータストアを組み合わせてこの仕組みを実現する方法を解説している。
具体的なシステム構成は以下のようになる。まず、クライアントは通常通りWeb APIにリクエスト(例えば、ドキュメントの要約を依頼するPOSTリクエスト)を送る。APIサーバーは、LLMへの実際の呼び出しは行わず、受け取ったリクエスト内容を元に「ジョブ」(作業指示)を生成し、そのジョブをBullMQが管理するキューに追加する。ジョブをキューに追加したら、APIサーバーは即座に「202 Accepted」というHTTPステータスコードと共に、そのジョブを一意に識別する「jobId」をクライアントに返す。これにより、APIサーバーは迅速に応答でき、クライアントを待たせることなく次の処理に進める。
キューに追加されたジョブは、専用の「ワーカープロセス」によって処理される。ワーカーはキューを常時監視しており、新しいジョブが追加されるとそれをキューから取り出し、LLMへの呼び出しなどの時間のかかる実際の処理を実行する。処理が完了したら、結果をデータベースなどに保存する。
クライアントは、APIサーバーから受け取ったjobIdを使って、別のエンドポイントに接続することで、そのジョブの進捗状況をリアルタイムで受け取ることができる。この進捗通知には、Server-Sent Events(SSE)という技術が用いられる。これは、サーバーからクライアントへ一方的にデータをプッシュする仕組みで、進捗状況の更新や処理の完了、失敗といったイベントをクライアントに送信するために適している。
BullMQを使うと、ジョブの管理が非常に柔軟になる。例えば、ジョブを追加する際に同じjobIdを指定すれば、まだ処理中のジョブがあれば新しいジョブを追加せずに既存のジョブの完了を待つ、といった重複排除が可能になる。また、ジョブが失敗した場合に何回リトライするか(attempts)や、リトライの間隔(backoff)を指数関数的に長くするといった設定も簡単に行える。これにより、一時的なネットワーク障害やLLMプロバイダーの過負荷などで失敗しても、自動的に再試行されるため、システムの堅牢性が高まる。
ワーカープロセス側では、同時にいくつのジョブを処理するか(concurrency)や、キュー全体で1分間にいくつのジョブを処理するか(limiter)といった設定が可能だ。これにより、LLMプロバイダーのレートリミットを超えないように調整したり、サーバーリソースを効率的に利用したりできる。
エラーハンドリングも重要だ。エラーには、入力データが不正であるなど、何度リトライしても成功しない「回復不能なエラー」と、一時的な問題でリトライすれば成功する可能性がある「回復可能なエラー」がある。回復不能なエラーに対しては、BullMQのUnrecoverableErrorを使って、無駄なリトライをスキップし、すぐにジョブを失敗させるように設定できる。また、LLM呼び出し自体にもタイムアウトを設定し、もしLLMサービスが応答しない場合にワーカーが永久に待機しないようにすることが重要である。
最終的に、指定されたリトライ回数を使い切っても失敗したり、回復不能なエラーで即座に失敗したジョブは、「デッドレターキュー」と呼ばれる専用のキューに送られる。これにより、通常のキューと分けて失敗したジョブを一元的に管理し、後で人間が原因を調査したり、修正後に再実行したりするフローを組み込むことができる。
クライアントへの進捗通知にはServer-Sent Eventsが用いられるが、ここでも考慮すべき点がある。クライアントが接続するよりも早くジョブが完了している可能性があるため、接続時にジョブの現在の状態(完了済み、失敗済みなど)を確認し、適切なイベントを送信することが必要だ。また、LLMからの生成結果のような大きなデータは、SSEのイベントペイロードに直接含めず、代わりに結果を取得するためのURLを送信することで、Redisへの負荷を軽減し、セキュリティ面でも有利になる。
本番環境でこのシステムを運用する際には、いくつかの重要な考慮事項がある。例えば、サーバーが停止する際に、実行中のジョブが中断されないように、ワーカープロセスを適切に終了させる(graceful shutdown)。また、APIサーバーとワーカープロセスは、それぞれ異なる目的を持つため、別々のプロセスやコンテナとして実行し、それぞれ独立してスケーリングできるようにするべきだ。キューのデータを永続化するために、Redisの永続化機能を有効にすることも不可欠である。さらに、デッドレターキューに溜まったジョブの数や、最も古いジョブがキューで待っている時間などを監視し、問題の兆候を早期に検知できるようにすることも重要となる。LLMからの結果をデータベースに保存する際には、リトライされても同じ結果が重複して保存されないよう、「冪等性」(何度実行しても同じ結果になる性質)を考慮した実装が求められる。
もちろん、全てのLLM呼び出しにキューが必要というわけではない。もしLLMからの応答が通常1、2秒で返ってきて、ユーザーもその間画面で待つことが許容されるような場合は、シンプルな同期的な処理の方が開発が容易である。キューシステムは、LLM呼び出しが遅い、突発的なアクセス増加に対応する必要がある、処理の再試行にコストがかかる、またはサーバーデプロイ時にも処理が中断されては困る、といった場合に特にその真価を発揮する。これらの詳細を理解し、適切にシステムを設計・実装することで、堅牢でスケーラブルなNode.jsアプリケーションを構築できるだろう。