📈

PerplexityライクなQ&Aエージェントを個人開発アプリに組み込んだ——スケール時に実行モデルをどう変えるか

に公開

はじめに

外部のウェブ検索と、アプリケーション内で事前にAI分析した独自の内部記事を組み合わせ、出典付きの回答を返す——そんな「PerplexityライクなQ&Aエージェント」を、個人開発している海外テックニュース収集・分析アプリ『Vector』に組み込みました。

前編では、このエージェントを開発する中で工夫した、工程ごとの設計について書きました。

https://zenn.dev/yook/articles/qa-agent-six-stage-design

一方で、その実行モデルは現在の運用規模に合わせて、かなり割り切った設計にしています。
この記事では、ユーザーが増えてスケールしたときに、アーキテクチャをどのように変える必要があるのかを考察していきます。

ソースコードも公開しているので、見ていただけたら嬉しいです。

前提:現在のエージェントの構成

現在は招待制のアプリケーションとして運用しており、ユーザーごとに1日10回までの実行制限を設けています。

ユーザーからの質問はバックグラウンドの worker で非同期に処理され、以下の6つの工程を順に通ります。
その進捗(現在の工程や検索中のキーワード)や生成中の回答テキストは、Redis Streams(本番は ElastiCache Valkey)と SSE を経由してブラウザへ逐次ストリーミングされる仕組みです。

  1. セーフティチェック — 入力の安全性を判定する工程。ポリシー違反がないかを事前にチェックし、不正なリクエストを早期遮断します。
  2. コンテキスト整理 — 文脈を補足する工程。過去の会話履歴から「ユーザーが本当に知りたいこと(質問文)」と回答の条件を抽出・再構築します。
  3. プランニング — 調査方針を決める工程。検索の要否を判断し、必要な場合は検索クエリと調査ゴールを組み立てます。
  4. エビデンス収集 — 根拠情報を集める工程。内部データベースと外部Web検索を使い、回答に必要な情報を並列で収集します。
  5. エビデンスレビュー — 収集した情報を精査する工程。集めた情報から有効な根拠だけを選別し、不足している情報がないかを特定します。
  6. 回答生成 — 最終回答を作成する工程。精査された根拠をもとに、根拠リンク(引用)を明記したMarkdown形式の回答を生成します。

実行モデル

これら6つの工程は細かくキューに分割せず、すべて1つの worker タスク内の連続した関数呼び出しとして実装しています。
状態の受け渡しはメモリ上で完結させるシンプルな設計です。

現状のアーキテクチャの制約は、以下のとおりです。

  • 1 run = 1 worker タスク(キューに入るのは最初の起動トリガー1件のみ)
  • 中間結果はメモリ上のみ(各工程で生成したプランや採用エビデンスなどを保存・永続化する仕組みがない)
  • 途中で失敗したら「すべて最初からやり直し」(途中で失敗した場合、そこから再開できず run ごと失敗扱いになる)

途中で失敗したときの扱い(リトライ設計)

ユーザーが増えてまず直面するのが、エラーが起きた際のリトライ設計です。
ここで重要なのは、工程ごとに実行する処理が異なり、それに伴って失敗の性質も変わってくるという点です。

工程ごとに「何回やり直すか」だけでなく、「再試行しても失敗したときに、最終的にどのようにフォールバックするか」まで決める必要があります。

失敗パターン 対応方針(設計のポイント)
安全性の判定不能
(エラーやタイムアウト)
回数上限を決めて再試行。それでも判定できなければ安全側に倒して拒否(エラー終了)する
LLMの出力エラー
(JSON崩れ・内容の矛盾など)
エラーの種類(フォーマット違反か、ロジックの不備か)に応じてリトライ方法を変える
外部APIの通信・一時エラー
(タイムアウト・過負荷など)
短時間待って再試行するか、その検索をスキップして進めるか(フォールバック)を判断する

それぞれの失敗に対して、どのように対処すべきかを整理します。

1. 安全判定ができないとき(フェイルセーフ)

セーフティチェックは、判定できないまま後続へ進めてはいけない工程です。

他の工程では「エラーが起きても全体を止めずに進める」方針をとっていますが、ここだけは逆で、「判定が取れなければ後続へ通さずに処理を止める」必要があります。「再試行の上限に達したからとりあえず通す」では、無検査で通すのと同じになってしまうためです。

2. LLMの出力が不正なとき

LLMの出力が常に指定のスキーマ通りになるとは限らないため、想定外の応答に対するエラーハンドリングは必須です。
この際、エラーの「種類」によって適切なリトライ方針は変わります。

  • スキーマ(JSONなど)が壊れているだけの場合:
    フォーマットの不備は単純な再試行で復旧することも多いため、許容できるコストや待ち時間の範囲内でリトライを実行するのが有効です。
  • 構造は正しいが内容が矛盾している場合:
    (例:「検索不要」と判断したのに検索タスクが含まれている、存在しない出典番号を引用しているなど)
    この場合は、直前のエラー内容をプロンプトにフィードバックし、失敗理由を考慮させた上で再試行させるアプローチが考えられます。

また、単にその場でエラーをリトライするだけでなく、LLMの応答を監視・評価し、プロンプトの調整や継続的な改善につなげる仕組みも重要になると考えています。

3. 外部APIが一時的に失敗したとき(UXとのトレードオフ)

外部検索APIのエラーについても適切な切り分けが必要です。
タイムアウトや500番台のエラー、レート制限などは再試行で復旧する見込みがありますが、認証エラー(401/403)やリクエスト不正(400)は何度試しても解消しません。

外部検索がどうしても成功しない場合は、「内部のストック情報だけで回答を作成する」というフォールバックも選択肢となります。

ここで重要になるのが、「本アプリが対話型のチャットUIである」という制約です。
バックグラウンドでの再試行時間は、そのまま「ユーザーを待たせる時間」に直結します。

リトライ方針は機能単体で決めるのではなく、「システム全体としてユーザーをどこまで待たせてよいか(許容待ち時間)」という制約とセットで設計する必要があります。

完了した工程の成果を再利用する

現在の構成ではもう一つ大きな課題があります。

それは、「途中でエラーが起きた場合、それまでに完了していた工程の成果もすべて捨ててしまう」という現在の仕様です。

インメモリで工程間の状態を受け渡しているため、途中で失敗するとすべての工程が最初からやり直しになります。

ここでの一番の問題はコストの無駄です。
すでに成功していた工程のLLMや検索APIの料金を、もう一度支払い直すことになってしまいます。

そのため、失敗時には「途中まで進んだ状態から処理を再開できる」仕組みの重要性を強く感じています。

再開のために必要な3つの要素

途中から再開できるようにするには、以下の3つが必要です。

  1. 工程ごとの成果物を保存する
  2. 工程がどこまで完了したかを記録する
  3. 完了済みの工程を引き継いで再開する

一番の問題は1の「成果物」で、こちらは現状どこにも保存されていません。
工程間のデータ受け渡しをすべてメモリ上で行っているため、プロセスが落ちると途中の成果物はすべて消失してしまいます。

取りうるアプローチは、以下の2つです。

  1. メモリ層でのデータ保証を上げる — 中間結果を worker プロセスの外にあるインメモリのデータストアへ移し、レプリケーション(複製)やディスク書き出しの設定で保証レベルを上げるアプローチです。落ちたときに失われる可能性をゼロにはできませんが、どこまで許容するかを調整できます。ただし、レプリカを増やせばその分メモリを持つノードが増えますし、書き込みの完了を待つ設定にすれば待ち時間も伸びます。特にメモリはディスクに比べて単価が高いため、残すデータが増えるほど運用コストとして効いてきます。

  2. DB(Postgres)へ永続化する — プロセスとは完全に独立してデータを残すアプローチです。耐久性を確保しやすい反面、工程ごとにDBへの書き込みレイテンシ(遅延)を払うことになります。

現時点では、2つ目の「DBへ書き込む」アプローチが最もシンプルだと考えています。
ただし、実際に採用できるかは以下の2点を計測・検証して判断する必要があります。

  • 処理速度への影響: これまでメモリ上で受け渡していた中間データをDB書き込みに置き換えることで、1 run 全体の処理時間にどの程度影響が出るかを確かめる必要があります。
  • コネクションプールとの兼ね合い: 工程の境界ごとに書き込みを挟むため、DBへの接続頻度が増加します。ユーザーが増えて同時に走る run が増えた際、コネクションプールの上限に余裕を保てるか、またDB負荷やコスト増が許容範囲に収まるかを確認する必要があります。

隠れた対価:同じ run が2回走る可能性

もうひとつ、忘れてはいけないトレードオフがあります。途中から再開できるようにするということは、「同じ run が2回走る」可能性を引き受けるということです。

プロセスが落ちた run を再開するには、その run をもう一度実行する必要があります。
アプローチとしては、メッセージキュー側のリトライを利用するか、停止している run をアプリ側で検出して再投入する、といった方法が考えられます。

ただし、どの方法を選んでも同じ壁に突き当たります。
「本当に落ちた run」と「生きているが応答が遅れているだけの run」を、確実に区別する方法がないということです。

そうなると、以下のような設計が新たに必要になります。

  • 外部APIの二重呼び出し: 2本の run が同時に動けば、LLMや検索APIもその分余計に呼び出されます。これはAPIコストの二重発生だけでなく、プロバイダーのレート制限にも影響します。
  • SSEの二重配信: 2本が同じ run のストリームへ書き込むと、別々の生成結果が画面に届き、テキストが混ざって表示されてしまいます。
  • 中間結果の二重書き込み: どちらの run による書き込みを正とするかを定め、古い世代からの遅れて届いた書き込みを弾く仕組みが必要になります。

つまり、「再開できる構成」を選ぶということは、「同じ run は実質1本しか走らない」という今の単純さを手放すことでもあります。

現状でもメッセージキューの配送自体は At-least-once(最低1回配送)ですが、再配送までの猶予(10分)がタスクの最大タイムアウト(3分)に対して十分長く設定されています。そのため、仮に同じメッセージが再配送されても、すでに完了している run は実行せずにスキップされる仕組みです。

そのため、2本が同時に走る可能性は極端に低くなっています。
ただし、これは状態による排他ではなく時間的な余裕で成り立っているだけで、再開を前提にするとこの前提そのものが崩れます。

今後スケールに合わせて「途中再開できる構成」へ移行するのであれば、「二重実行に伴うAPIコストの二重化」への対策と、「状態管理や排他制御」といった複雑な実装の導入に取り組む必要があります。

実行単位の分割:タスクの性質ごとにキューを分けるか

現在は、6つの工程すべてを1つのワーカータスクの中で順番に実行しています。
これは、性質の異なる工程が、同一のキューと実行環境で動いていることを意味します。

すべての工程に「同一のタイムアウト」「同一のネットワーク環境」「同じワーカーの実行枠」が一律で割り当てられるため、以下の問題が生じます。

  • 特定のタスクの滞留が他を巻き込む: 外部APIの遅延などで処理が長引くと、その run はワーカーの同時実行枠を占有し続けます。枠がすべて埋まると、後から届いた run は枠が空くまで待たされます。
  • 必要な箇所だけスケールできない: 「外部検索が追いつかないから、その工程だけワーカーを増やす」ということができません。スケーリングの単位が run 全体になってしまいます。
  • 信頼境界とリソースを共有してしまう: run 全体が1つのワーカープロセスの中で動くため、APIキーやDBへの接続情報、ネットワークの到達範囲を、そのプロセスが一括して持つことになります。すべてのタスクが同じ権限で動くため、問題が起きたときに影響範囲を工程単位で閉じ込めることができません。

いつ分割に踏み切るべきか?

ここで、分割した場合のメリットとデメリットを整理した上で、「では、どのタイミングで分割に踏み切るべきなのか?」について考えてみます。

分割するメリット(得られるもの)

  • 単位ごとの独立した制御: タスクの性質に合わせ、タイムアウトやリトライ戦略、並列度を最適化できる。
  • 滞留したときの影響範囲を抑えられる: 特定のタスクが詰まっても、専用のワーカーを持たせておけば、他のタスクを巻き込まない。
  • 必要な権限だけを持たせられる: 工程ごとに、その処理に必要なAPIキーや接続先だけを渡せる。第三者のコンテンツを収集するタスクを切り離しておけば、そこで問題が起きても他の工程への影響を抑えられる。

分割するデメリット(失うもの)

  • レイテンシの増加: キューを跨ぐたび、メッセージのエンキューと次のワーカーへの受け渡しが発生します。この往復処理自体はLLM呼び出しの時間に比べれば無視できる程度ですが、問題となるのは「次のワーカーに空きがない場合の順番待ち時間」です。
    現状は1本の run が一度実行枠を確保すれば最初から最後まで一気に完了できますが、分割すると工程の境界ごとに毎回ワーカーの空き待ちが発生する可能性があります。
  • 制御の複雑化: いまは1つのタスク内で守られている「実行のキャンセル」や「二重実行の防止(排他制御)」を、分散した各タスクで協調して守る必要が出ます。
  • タスク喪失のリスク: 前のタスクが完了し、次のタスクをキューに入れる一瞬の隙間でプロセスが停止すると、処理がどのキューにも存在しない「ロスト状態」になります。これを検知・回復する仕組みが新たに必要です。

ここまでの整理から、分割に踏み切るのは「特定のタスクの問題が、run 全体を巻き込んでいる」と確認できた段階だと考えています。

  • 特定のタスクだけ時間がかかる: ワーカーを占有し、他の run に順番待ちを発生させている
  • 特定のタスクだけ詰まる: その工程のみを独立してスケールさせたい状態になっている
  • 特定のタスクだけ失敗が多い: リトライの連鎖で、他の正常な工程まで巻き込んでいる

このような実害がない状態で分割しても、デメリットの方が大きくなってしまうと考えています。

どこで分割するべきか?

分割するとしても、単に工程ごとに切り分ければよいわけではありません
タスクの特性を見極めたうえで、どこに境界を引くのかを決める必要があると考えています。

このアプリケーションでは、タスクの性質の違いがどこにあるのかを見ていきます。

唯一はっきりと性質が異なるのは、外部のWeb検索APIを呼び出す「エビデンス収集」です。
他の工程が「プロンプトをモデルへ送り、決められた形式の応答を受け取る」のに対し、この工程は「検索APIを呼び出し、第三者が書いたコンテンツを取り込む」処理です。

失敗の理由も、LLMの出力不備ではなく「相手のAPIが応答しない」というネットワーク越しの障害が中心になります。

このアプリケーションで分割するとしたら、エビデンス収集と、それ以外のLLM工程との間に境界を引き、性質の異なるエビデンス収集を切り出す形になると考えています。

おわりに

ここまで読んでいただき、ありがとうございました。

記事の執筆を通じて思考を整理したことで、自分自身にとっても多くの気づきがありました。
リトライの設計、中間成果物の保持、実行単位(ワーカーの境界)の切り方など、向き合うべき課題はまだまだ多く残っています。

今後は、根本的なデータ構造などについても改めて学んでいく必要があると感じています。

今後は、根本的なデータ構造や分散システムの基礎についても、あらためて学びを深めていく必要性を実感しています。

今回見えてきた課題や知見は、今後のアプリ開発にもしっかりと活かしていくつもりです。引き続き手を動かしながら試行錯誤を続けていきます。

ご意見やフィードバック、アドバイスなどいただけますと大変励みになります!

Discussion