🔌

音声AIエージェントのWebSocket信頼性設計|切断・再接続・状態管理の戦い

に公開

はじめに

前編では、電話 × Twilio × Realtime APIのアーキテクチャ設計と、Function Calling・ガードレールという「エージェントの骨格」を扱いました。

しかし、どれだけ設計が良くても、繋がらなければ意味がない。本番運用を始めて数日で、この現実を思い知ることになります。

「繋がる」ことを前提に組んだプロトタイプが、実際の通話で牙を剥いた。この記事は、WebSocketストリーミングの信頼性と格闘した2ヶ月間の記録です。

作ってみて痛感したのは、 WebSocketは「繋がれば簡単、繋ぎ続けるのは地獄」 ということ。具体的には次の4つの問題に直面しました。

  • 切断検知〜再接続のタイムラグで音声が途切れる
  • 再接続時にカスタム挨拶文が重複する
  • 割り込みとフィラー音が競合して「二重に喋る」
  • 長時間通話でのメモリリークとリソース枯渇

どれも「ちょっとした不具合」では済まず、通話が成立しなくなる致命的な問題です。この記事では、それぞれの原因・実際の影響・そしてどう解決したかを、設計判断とともに書いていきます。


1. 切断との戦い — 検知・再接続・音途切れ

どちら側が切れるのか

まず最初に直面したのは、 「切断されていることに気づかない」 問題です。

通話中、突然相手が無言になる。こちらから話しかけても返事がない。ログを見ると、数秒前から音声ストリームが流れていない。原因を追うと、大きく2パターンありました。

切断パターン 原因 発生頻度
Twilio側の切断 発信者の電波状況悪化・キャリア経由の瞬断 モバイル発信で高頻度
OpenAI側の切断 Realtime APIのセッションタイムアウト・サーバ側の再接続要求 長時間通話で頻発

Twilio側切断の厄介なところは、WebSocketのクローズイベントが来ないケースがあることです。TCPレベルでは繋がっているように見えるが、音声パケットだけが届かない「サイレント切断」。これに気づかずにいると、AIはずっと「相手が黙っている」と解釈して待ち続けます。

OpenAI側は比較的素直で、session_expirederror イベントを飛ばしてきます。ただし突然来るので、その瞬間に音声が途切れるのは避けられません。

切断検知の設計 — ハートビートと音声無音監視の二段構え

TCPのクローズだけに頼れないので、アプリケーションレベルで死活監視することにしました。

// 切断検知の二段構え(要点抜粋)

class ConnectionHealthMonitor {
  private lastAudioReceived = Date.now();
  private lastPongReceived = Date.now();

  // ① 音声パケットの最終受信時刻を追跡(Twilio側)
  onTwilioAudio() {
    this.lastAudioReceived = Date.now();
  }

  // ② アプリケーションレベルの ping/pong(OpenAI側との相互監視)
  startHeartbeat(ws: WebSocket) {
    setInterval(() => {
      ws.send(JSON.stringify({ type: "ping" }));
      // pongが一定時間返らなければ切断と判定
      if (Date.now() - this.lastPongReceived > 10_000) {
        this.onDisconnect("openai_heartbeat_timeout");
      }
    }, 5_000);
  }

  // ③ 音声無音タイムアウト(サイレント切断の検知)
  checkSilence() {
    if (Date.now() - this.lastAudioReceived > 8_000) {
      this.onDisconnect("twilio_silence_timeout");
    }
  }
}

ポイントは ping/pong(OpenAI側)と音声パケット監視(Twilio側)を別の仕組みにしたことです。理由は単純で、両者の切断モードが根本的に違うから。ping/pongで検知できるのはOpenAIが明示的にセッションを切ったケースで、Twilioのサイレント切断は音声パケットの最終受信時刻を見なければわかりません。

再接続シーケンスの設計

切断を検知したら、即座に再接続を試みます。ただし、ただ繋ぎ直せばいいわけではありません。

再接続時にやるべきことは3つです。

  1. 保留音の挿入:切断から再接続までの無音を埋める
  2. 会話文脈の復元:直前までの会話履歴を session.update で流し込む
  3. 復帰メッセージ:「申し訳ございません」と自然に再開する

1の保留音は、サーバ側で短いBGMファイルをループ再生する方式にしました。AIに任せると「少々お待ちください」すら話せない(切断中なので)からです。

2の文脈復元は、会話の要約をサーバ側に蓄積しておき、再接続時に conversation.item.create で過去のやり取りを注入します。全部の履歴を流すとトークンが嵩むので、直近の確定情報(日時・人数・名前など)だけを要約して渡す設計にしました。

ハマった点:再接続時の挨拶文が重複する

ここで最も悩まされたのが、再接続直後に「申し訳ございません」が2回流れる問題です。

原因はタイミングの競合でした。

  1. サーバが再接続成功を検知 → サーバ側で「申し訳ございません」音声を再生
  2. 同時に、OpenAIも会話の再開を検知 → AIも自発的に「お待たせしました」と発話
  3. 結果、両方の音声が重なって相手に届く

解決策はサーバ側の復帰メッセージとAIの自発発話を排他制御することでした。

// 再接続シーケンスの排他制御(要点抜粋)
let reconnectInProgress = false;

async function reconnect() {
  reconnectInProgress = true;

  // 1. 保留音を再生
  playHoldMusic();

  // 2. 新しいWebSocket接続
  const newWs = await connectToOpenAI();

  // 3. 会話文脈を復元
  await restoreSessionContext(newWs);

  // 4. サーバ側から復帰メッセージを送信
  await playReconnectMessage("申し訳ございません、回線が不安定でした。");

  // 5. AIの発話を抑制するため、最初の応答は明示的に指示してから解放
  await sendSystemMessage(newWs, "復帰メッセージを送信済みです。重複して挨拶しないでください。");

  // 6. 排他制御を解除
  reconnectInProgress = false;

  // 7. 通常の会話フローに復帰
  resumeNormalFlow(newWs);
}

reconnectInProgress フラグでAIからの自発発話をブロックし、サーバ側の復帰メッセージが完了してからAIへの指示を送り、その後にフラグを解除する。この順序を厳守することで、重複が完全に解消しました。


2. 割り込みとフィラー音の二重問題 — 状態管理で解決する

何が起きていたか

前編でも触れた「フィラーが出ない」問題は、実はもう一段深い問題を抱えていました。 フィラーが「出るべきでないタイミングで出る」 ケースです。

具体的にはこんな流れです。

  1. AIが応答を話し始める(「明日の19時で承ります…」)
  2. ユーザーが話しかぶせる(「あ、やっぱり20時で」)
  3. AIが割り込みを検知 → response.cancel で発話を中断
  4. しかし、直前に生成されていたフィラー音(相槌)がまだキューに残っている
  5. response.cancel の直後にフィラー音が再生されてしまう
  6. 結果:「承ります…あ、やっぱ…(フィラー音)…かしこまりました」というグチャグチャな音声になる

「二重に喋る」「途切れた音声の断片が再生される」——通話品質として最悪です。ユーザーからすれば「壊れている」としか思われません。

原因:イベント駆動の非同期性

根本原因は、OpenAI Realtime APIがイベント駆動の非同期ストリーミングであることです。response.audio.delta が逐次届き、それをサーバがTwilioに中継する。このパイプラインの途中で response.cancel が来ても、すでにキューに入った音声データは止められません。

解決策:発話状態の排他制御とキュー管理

対応は、サーバ側で発話の状態を厳密に管理し、割り込み時にはキューをフラッシュする設計にしました。

// 発話状態管理(要点抜粋)
class SpeechStateManager {
  private state: "idle" | "speaking" | "flushing" = "idle";
  private audioQueue: AudioChunk[] = [];
  private cancelRequested = false;

  // AIの応答が始まった
  onResponseStart() {
    if (this.state === "speaking") {
      // すでに発話中なら、前の発話をキャンセルしてキューをフラッシュ
      this.flushAudioQueue();
    }
    this.state = "speaking";
    this.cancelRequested = false;
  }

  // 音声デルタが届いた
  onAudioDelta(chunk: AudioChunk) {
    if (this.cancelRequested) {
      return; // キャンセル中なら破棄
    }
    // スピーキング中のみキューに入れて再生
    if (this.state === "speaking") {
      this.audioQueue.push(chunk);
      this.playNextInQueue();
    }
  }

  // 割り込み(response.cancel)を受け取った
  onInterrupt() {
    this.cancelRequested = true;
    this.flushAudioQueue(); // キューを空にする
    this.state = "flushing";

    // 小さなディレイの後、アイドルに戻す(flushの完了を待つ)
    setTimeout(() => {
      this.state = "idle";
      this.cancelRequested = false;
    }, 200); // 200msは実測値から調整
  }

  private flushAudioQueue() {
    this.audioQueue = [];
  }

  private playNextInQueue() {
    // Twilioへの送信処理(省略)
  }
}

設計の肝は3つです。

  1. 状態を3値(idle / speaking / flushing)で管理:単純なON/OFFではなく、キャンセルからアイドルに戻る中間状態を持つ
  2. cancelRequested フラグで音声デルタを受け付けない:キャンセル後に届いた残差データを遮断
  3. 200msのフラッシュディレイ:Twilio側のバッファにも残っている音声が完全に排出されるのを待つ。この値は実機での試行錯誤で決めた

特に3の「フラッシュディレイ」は地味ですが重要です。ここが短すぎると音声の切れ端が残り、長すぎるとAIの応答再開が遅れて間延びします。200msは「ギリギリ違和感がない」値として、実際の通話テストを繰り返して見つけました。


3. 長時間通話のリソース管理

30分を超えたあたりから起きる現象

テスト通話の時間を延ばしていくと、30分〜1時間のあたりでメモリ使用量がじわじわ上がり、最終的にプロセスがOOMキルされる問題が発生しました。

ヒープダンプを取って追跡した結果、主な原因は次の2つでした。

原因 詳細
オーディオバッファの蓄積 通話中ずっと音声データの参照が残り、GCの対象外になっていた
イベントリスナーの増殖 再接続のたびに ws.on("message") が重複登録され、リスナー数が指数増加

特に後者は再現条件がわかりにくく、短いテスト通話では発覚しませんでした。本番で1時間近い通話が発生したときに初めて表面化した問題です。

オーディオバッファの蓄積対策

音声データはTwilioから届いた μ-law のbase64文字列を逐次処理していますが、デバッグ用にメモリ上に保持していた参照がGCを妨げていました。

// Before: すべての音声チャンクを配列に保持(メモリリークの原因)
const audioHistory: AudioChunk[] = [];
twilioWs.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  if (msg.event === "media") {
    audioHistory.push(msg.media.payload); // ← これが蓄積し続ける
    openaiWs.send(JSON.stringify({
      type: "input_audio_buffer.append",
      audio: msg.media.payload,
    }));
  }
});

// After: リングバッファで直近N秒だけ保持
class RingBuffer {
  private buffer: AudioChunk[] = [];
  private maxSize = 50; // 約5秒分

  push(chunk: AudioChunk) {
    this.buffer.push(chunk);
    if (this.buffer.length > this.maxSize) {
      this.buffer.shift(); // 古いものを捨てる
    }
  }
}

デバッグ用の保持はリングバッファ方式に切り替え、直近5秒分だけを残すようにしました。ログ目的の全量保存は、音声ではなく OpenAI のテキストトランスクリプトだけを別途保存 する方式で十分でした。

セッションの定期的なリセット

イベントリスナーの増殖は、WebSocket接続のライフサイクル管理の甘さが原因でした。再接続ロジックが毎回新しいリスナーを ws.on(...) で登録しており、古い接続のリスナーが解除されていませんでした。

根本対策として、長時間通話では15分ごとにセッションを計画リセットする設計を導入しました。

// セッションリセットの計画実行(要点抜粋)
const SESSION_MAX_DURATION_MS = 15 * 60 * 1000; // 15分

function scheduleSessionReset(sessionId: string) {
  setTimeout(async () => {
    // 1. 会話の要約を取得
    const summary = await summarizeConversation(sessionId);

    // 2. 旧セッションの全リスナーを解除
    removeAllListeners(sessionId);

    // 3. 旧WebSocketをクローズ
    await closeSession(sessionId);

    // 4. 新セッションを確立
    const newSession = await createNewSession();

    // 5. 要約を注入
    await injectSummary(newSession, summary);

    // 6. 次のリセットを予約
    scheduleSessionReset(newSession.id);
  }, SESSION_MAX_DURATION_MS);
}

15分という閾値は、OpenAIのRealtime APIセッションが自然タイムアウトする前に、こちらから計画切断する意図です。突然切られるより、制御された再接続の方が体験が良いからです。

リソースモニタリング

OOMキルの再発防止として、プロセスレベルのリソース監視も入れました。

// 通話ごとのリソース追跡(要点抜粋)
setInterval(() => {
  const usage = process.memoryUsage();
  metrics.record("call.memory.heap_used_mb", usage.heapUsed / 1024 / 1024);
  metrics.record("call.memory.rss_mb", usage.rss / 1024 / 1024);

  // ヒープが500MBを超えたら警告
  if (usage.heapUsed > 500 * 1024 * 1024) {
    logger.warn("heap_high", {
      heapUsedMB: Math.round(usage.heapUsed / 1024 / 1024),
      sessionId: currentSessionId,
    });
  }
}, 10_000);

閾値超過時はオペレータに通知し、緊急時は通話を安全に切断して新規セッションに誘導するフローにしました。幸い、リングバッファと定期リセットの導入後は500MBに達するケースはなくなりました。


4. 本番運用の監視設計

何を監視すべきか

WebSocketの信頼性を上げるには、「直感」や「たぶん大丈夫」ではなく数字で見える化することが不可欠でした。特に以下のメトリクスを常時追跡しています。

メトリクス 正常閾値 アラート条件
切断率 5%未満 10%超過で警告 / 20%超過で緊急通知
再接続成功率 95%以上 80%未満で警告
平均復旧時間 3秒以内 5秒超過で警告
セッション継続時間 15分を超えたら計画リセットの発火を確認

特にこだわったのが 「平均復旧時間」 です。切断は不可避です。問題は「切れたあと何秒で戻れるか」。この数字が長いほど通話離脱率が跳ね上がるからです。実際、平均復旧時間が5秒を超えた週は、通話途中離脱率が通常の2倍にまで悪化しました。

アラート設計

アラートは「鳴りすぎると無視される」ので、段階的に設計しました。

// アラート設計(要点抜粋)
function evaluateHealth(metrics: CallMetrics) {
  // Level 1: 記録のみ(軽微な変動)
  if (metrics.disconnectRate > 0.05) {
    logger.info("disconnect_rate_elevated", metrics);
  }

  // Level 2: 通知(対応が必要なレベル)
  if (metrics.disconnectRate > 0.10) {
    notifyChannel("ops", `切断率が${(metrics.disconnectRate * 100).toFixed(1)}%に上昇`);
  }

  // Level 3: 緊急(即時対応)
  if (metrics.reconnectSuccessRate < 0.80) {
    notifyChannel("oncall", `再接続成功率が${(metrics.reconnectSuccessRate * 100).toFixed(1)}%に低下。要確認。`);
  }
}

ログ戦略 — 「ツール実行されない」をどう見つけたか

前編で触れた「ツールを実行しないことがある」問題も、実はWebSocket監視の延長で見つけました。具体的には 「音声ストリームが正常でも、ツール呼び出しイベントが発生していない」 というパターンをログで追跡し、通話終了時にツール未実行のまま会話が終わっていないかをチェックしていました。

このように、「繋がっていること」と「正しく動いていること」は別問題です。WebSocketが生きていても中身が機能していなければ意味がない。接続の監視と、その上のアプリケーションレベルの監視を、別レイヤーで追う設計が重要でした。


5. まとめ

WebSocketストリーミングの信頼性と2ヶ月間格闘して得た学びは、次の4つに集約されます。

「繋がる」の次は「繋ぎ続ける」。そして「切れたらどう戻すか」までが設計。

  • 切断は不可避。前提として設計する:Twilioのサイレント切断もOpenAIのセッションタイムアウトも、起きる前提で検知と復旧を組む
  • 再接続はただ繋ぎ直せばいいわけではない:保留音・文脈復元・復帰メッセージ・挨拶の重複防止まで含めて1つの「シーケンス」として設計する
  • 非同期イベントの排他制御は状態管理で:割り込みとフィラーの競合は、フラグとキュー管理とフラッシュディレイの3点で解決
  • 長時間通話はリソースの「出口」を必ず作る:リングバッファ・定期セッションリセット・リスナー管理。放置するとOOMキルが待っている

前編で扱った「アーキテクチャ」「Function Calling」「ガードレール」は、いわばエージェントの設計図です。今回扱った「切断・再接続・状態管理・リソース管理」は、その設計図を実際に動かし続けるための運用基盤です。どちらか一方では本番に耐えられない——両方が揃って初めて「電話で予約を取れるAIエージェント」は成立します。

前後編ともに、より詳細な実装解説(コード全文・トラブルシューティングガイド・監視ダッシュボードの設定例)については、Shineos Tech Blogで公開する予定です。
https://blog.shineos.com/


💡 音声AIエージェントの導入や開発について、ご関心やご相談がございましたら、以下よりお気軽にお問い合わせください。

株式会社シャイオス お問い合わせ窓口


参考

Discussion