02 / 10 · SYSTEM DESIGN

AIチャット:接続が切れても壊れない実行モデル

会話、生成処理、配信を分け、再接続とキャンセルの競合を設計する。

内容の確認日:

この章で考える問い

画面を閉じても生成結果を失わず、二重実行を防ぐには?

まず図全体の流れを見て、細部は「原寸で読む」を開いて確認してください。原寸の図は横にスクロールできます。正常な流れの次に、失敗時の分岐を追いましょう。

設計するものと守る条件

AIチャットを「入力をモデルへ送り、返答を表示する関数」と考えると、通信切断がそのまま会話の破損になります。この章では、会話の記録、生成を進める仕事、画面への配信を別々に扱う参照設計を作ります。Kumyuの実装済み構成を示すものではありません。

最初に決める条件は三つです。同じ送信の再試行でユーザーメッセージを増やさないこと、切断後に確定結果を回収できること、他人の会話を参照できないことです。「一度だけ生成」は外部モデルまで含めると簡単には保証できません。まず一度だけ受理し、一つの結果だけを採用する範囲を明確にします。

全体像:記録してから仕事を開始する

ブラウザーが API へ送信し、会話と実行レコードをDBに記録した後、ワーカーが生成してイベントを配信する
原寸で読む(横にスクロール)
ブラウザーが API へ送信し、会話と実行レコードをDBに記録した後、ワーカーが生成してイベントを配信する
図1:会話・実行・配信の分離 原寸の図を開く ↗

図1はこの章の独自の概念図です。矢印は要求、保存、配信の方向を表します。 API は認証済み利用者について会話の所有権を調べ、POST /runsのrequest_idと本文を受け取ります。一つのトランザクションで入力メッセージ、実行レコード、非同期配送用のoutboxを書き込みます。コミットが成功して初めて受理を返します。

UNIQUE(tenant_id, user_id, request_id)を設け、同じキーなら既存実行レコードを返します。キーはテナントと利用者の範囲で一意にします。比較するハッシュは、会話ID、正規化した本文、添付ファイルの識別子と版、生成結果に影響する設定を含む正規形から作ります。同じ本文でも会話が異なれば、同じキーの再利用は競合として拒否します。別内容を同じ送信と誤認しないためです。一意制約の仕組みはPostgreSQLの制約を参照できます。キーの保持期間も再試行契約の一部になります。

ワーカーは履歴のバージョンを固定し、モデルを呼びます。イベントは(run_id, seq)順で記録します。画面は実行レコードの購読を開始します。この分離により、ブラウザーの寿命と生成処理の寿命を一致させる必要がなくなります。

状態機械と配信カーソル

利用者、API、ワーカー、イベント保存先の間で、受理・生成・保存・再接続する順序
原寸で読む(横にスクロール)
利用者、API、ワーカー、イベント保存先の間で、受理・生成・保存・再接続する順序
図2:状態の確定と再接続 原寸の図を開く ↗

図2の横方向は担当する構成要素、上から下は処理の順序を示します。以下では実行状態も定義します。queued → running → completedが通常経路で、failedとcancelledも終端状態とします。終端から runningへ戻さず、やり直しは別実行レコードとして因果関係を残します。生成中の文字列は仮表示、完成したassistantメッセージは確定記録と区別します。

{
  "run_id": "run-example",
  "request_id": "send-example",
  "state": "running",
  "history_version": 12,
  "last_seq": 41
}

これは未実行の設計例です。history_versionはどの会話を材料にしたか、seqはどこまで画面が適用したかを示します。時刻で並べると時計のずれや同時刻のイベントで順序が曖昧になります。実行レコード内の連番を使い、クライアントは適用済み番号を重複排除します。

SSEを選ぶならHTML標準のイベントIDと再接続仕様が土台になります。ただし、再接続機能だけではサーバーの履歴保存や再送は実現しません。本設計では認可後にカーソル以降を再生し、その後のライブ配送へ隙間なく接続します。カーソルが保存期限を過ぎたら、確定スナップショットへ明示的に再同期します。

障害:完了とキャンセルは競合する

完了書き込みとキャンセル要求が同じ実行レコードを競合更新し、先に確定した終端状態だけを採用する
原寸で読む(横にスクロール)
完了書き込みとキャンセル要求が同じ実行レコードを競合更新し、先に確定した終端状態だけを採用する
図3:キャンセル競合と回復 原寸の図を開く ↗

図3ではネットワーク切断とキャンセルを別の出来事として読みます。切断だけなら実行レコードを続け、停止ボタンは独立した取消要求を送ります。完了側も取消側もWHERE state = 'running'等の条件付き更新で終端状態を確定し、更新件数を確認します。先に勝った終端状態を後続の応答が上書きしてはいけません。

この条件付き更新は比較交換(CAS)という考え方です。DB内の最終メッセージとcompleted状態は同じトランザクションで確定します。一方、プロバイダーへの停止要求やツールの副作用はそのトランザクションに含められません。キャンセル成功を「すでに送ったメールも取り消せた」という意味にしてはいけません。

生成途中でワーカーが落ちた場合、仮文字列を完成品に昇格させません。所有リースと世代番号で古いワーカーの完了を拒み、再開不能ならfailedとして再試行操作を示します。プロバイダー再実行が必要なら費用や内容の重複があり得ることも実行記録に残します。

選択肢を比較する

選択 得られること 引き受ける課題
一つのHTTP要求で生成 小さく始められる 切断と処理寿命が結びつく
永続実行レコードと別ストリーム 再接続・追跡・取消を設計できる 状態保存と回復処理が必要
全deltaを保存 途中から再生しやすい 書き込み量と保持管理が増える
定期スナップショット 保存回数を減らせる 再接続で一部表示を置き換える

設計例:履歴を編集できる相談チャット

仮に一つの会話で二つの端末が同時送信するとします。ここでは各入力を受理しつつ、生成実行レコードは会話ごとに直列化します。先の実行レコードが確定してから次の履歴バージョンを作るため、何への返答かが追えます。履歴編集は既存メッセージの黙った上書きではなく、新しい会話バージョンとして扱います。

監視するのは受理から最初のイベントまでの時間、受理から確定までの時間、再接続の回収成功率、終端状態に至らない実行レコードの年齢です。目標値は想定利用と計測から決めます。モデルの応答速度だけではユーザーが待つ時間を説明できません。

練習:応答が消えたとき何を再送するか

入力送信後に画面が真っ白になりました。利用者は更新し、同じ質問を再送しました。サーバーでは最初の実行レコードが完了しています。新しい実行レコードを作るべきでしょうか。

解答の考え方

更新をまたいで同じrequest_idを保持し、既存実行レコードを取得します。完成済みなら確定結果を表示します。異なる本文ならキー再利用エラーにします。ユーザーが明示的に「別の回答を生成」を選んだときだけ新しいキーを発行します。受理の冪等性、結果の永続性、配信の再開を分けて説明できれば、この設計の中心を理解できています。

押さえたいポイント

会話は記録、実行レコードは仕事、ストリームは配信手段。三つの寿命を分ける。

出典・さらに読む

  1. HTML Standard: Server-sent events内容の確認日: 2026-10-23
  2. PostgreSQL 18: Constraints内容の確認日: 2026-10-23

学習と設計検討のための概念的な構成例です。稼働中のKumyuの実装を表す図ではありません。

← 全章の一覧へ