0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gemini/OpenAIを音声AIで安全に比較する:シャドー応答と「話し始める前だけ」フェイルオーバーするTypeScript設計

0
Last updated at Posted at 2026-09-11

リアルタイム音声コンパニオンへGeminiやOpenAI系モデルを組み込むとき、悩ましいのは「どちらが賢いか」よりも、応答が遅い、途中で失敗する、モデルを切り替えたら口調まで変わるという運用上の問題です。

文書生成なら、同じ入力を複数モデルへ渡して完成物を比較できます。しかし音声会話では、先に返ってきたモデルの回答をユーザーへ読み上げた時点で、比較条件が変わります。失敗したモデルの続きを別モデルに話させれば、さらに不自然です。

本稿では、次の2経路を分けます。

  • 本番経路:選択した1モデルの応答だけをTTSへ渡す
  • シャドー経路:同じターンを別モデルへ送り、評価用に保存するが読み上げない

さらに、本番モデルから別モデルへのフェイルオーバーは、最初の発話をTTSへ確定する前だけ許可します。これにより、比較のためにユーザー体験を壊さず、モデル選定に必要な記録を集められます。

結論:モデルではなく「ターンの完了責任」を切り替える

採用モデルを決める前に、以下の境界を固定します。

状況 処理
本番モデルが発話開始前に失敗 代替モデルへ切り替える
本番モデルが一部でも発話した後に失敗 別モデルの続きを接続しない
ユーザーが割り込んだ 本番・シャドーの両方を中止する
シャドーモデルだけ成功 評価記録には残すが、ユーザーへ返さない
モデルを変更したい 次のターン境界で変更する

重要なのは、「速いモデルを自動的に勝者にする」ことではありません。どの程度の待ち時間や不完全回答を許容するかは、コンパニオンの用途と運営方針に依存する人間側の判断です。

LLMが流暢な候補文を生成できることは確認可能な能力です。一方、流暢さだけで自然なターンテイキング、安全な復旧、継続利用まで保証されるという期待は切り離す必要があります。

前提:音声経路を6つの責務に分ける

今回の構成では、各レイヤーを次のように分離します。

ユーザー音声
  ↓
RTCメディア転送
  ↓
STT(確定発話)
  ↓
会話オーケストレーター
  ├─ 本番LLM ─→ 発話確定ゲート ─→ TTS ─→ RTC
  └─ シャドーLLM ─→ 評価ストア(読み上げ禁止)

Tencent Conversational AIは、リアルタイム音声対話と複数LLMプロバイダーを組み合わせるシナリオを扱っています。LLM設定ではOpenAI互換モデルやエージェント基盤との接続、リクエスト識別子を用いたルーティング・観測の考え方を確認できます。

本稿のTypeScriptコードはLLMの製品固有SDKを直接呼びません。Gemini、OpenAI、OpenAI互換エンドポイントを同じModelAdapterへ正規化し、公式ドキュメントに沿った実際の接続設定はアダプターの外側へ閉じ込めます。

手順1:比較可能なデータモデルを作る

会話本文だけでなく、「どの時点でユーザー向け出力が確定したか」を記録します。

type ProviderName = "gemini" | "openai" | "other";

type TurnInput = {
  sessionId: string;
  turnId: string;
  requestId: string;
  text: string;
  committedHistory: Array<{
    role: "user" | "assistant";
    content: string;
  }>;
};

type TurnObservation = {
  turnId: string;
  requestId: string;
  provider: ProviderName;
  mode: "primary" | "fallback" | "shadow";
  startedAt: number;
  firstChunkAt?: number;
  speechCommittedAt?: number;
  completedAt?: number;
  outcome:
    | "completed"
    | "failed_before_speech"
    | "failed_after_speech"
    | "interrupted";
  output?: string;
  errorCode?: string;
};

interface ModelAdapter {
  readonly name: ProviderName;
  stream(
    input: TurnInput,
    signal: AbortSignal
  ): AsyncIterable<string>;
}

interface SpeechSink {
  enqueue(text: string): Promise<void>;
  notifyRecovery(reason: "incomplete_response"): Promise<void>;
}

committedHistoryには、ユーザーへ実際に提示した発話だけを入れます。シャドー応答や、TTSへ渡す前に失敗した文章を履歴へ混ぜないことがポイントです。

手順2:「発話確定点」を実装する

LLMの最初の1トークンを、そのままTTSへ送るのは避けます。「え」「それ」だけが読み上げられた直後に失敗すると、別モデルへも切り替えられず、不完全な音声だけが残るためです。

次の実装では、句読点に到達するか一定文字数がたまるまで、最初の出力を短くバッファします。

const PRE_SPEECH_DEADLINE_MS = 1_500;
const MIN_SPEAKABLE_CHARS = 24;

class BeforeSpeechTimeout extends Error {}

const sleep = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

function isSpeakable(text: string): boolean {
  const normalized = text.replace(/\s/g, "");
  return (
    normalized.length >= MIN_SPEAKABLE_CHARS ||
    /[。!?!?]\s*$/.test(normalized)
  );
}

async function nextBeforeDeadline<T>(
  iterator: AsyncIterator<T>,
  remainingMs: number
): Promise<IteratorResult<T>> {
  if (remainingMs <= 0) throw new BeforeSpeechTimeout();

  return Promise.race([
    iterator.next(),
    sleep(remainingMs).then(() => {
      throw new BeforeSpeechTimeout();
    }),
  ]);
}

1,500ms24文字は製品の推奨値ではなく、検証用の初期値です。実際には言語、TTS、対象ユーザー、会話内容に合わせて計測し直します。

コード:発話前だけフェイルオーバーする

本番モデルが発話確定前に失敗した場合だけ、代替モデルを起動します。発話後の失敗では回答を継ぎ足さず、アプリが管理する復旧イベントへ変換します。

type RunResult = {
  observation: TurnObservation;
  spokenText: string;
};

async function runProvider(
  adapter: ModelAdapter,
  mode: "primary" | "fallback",
  input: TurnInput,
  sink: SpeechSink,
  parentSignal: AbortSignal
): Promise<RunResult> {
  const controller = new AbortController();
  const abort = () => controller.abort(parentSignal.reason);
  parentSignal.addEventListener("abort", abort, { once: true });

  const startedAt = performance.now();
  const observation: TurnObservation = {
    turnId: input.turnId,
    requestId: input.requestId,
    provider: adapter.name,
    mode,
    startedAt,
    outcome: "failed_before_speech",
  };

  let buffer = "";
  let spokenText = "";
  let committed = false;

  try {
    const iterator = adapter
      .stream(input, controller.signal)
      [Symbol.asyncIterator]();

    while (true) {
      const result = committed
        ? await iterator.next()
        : await nextBeforeDeadline(
            iterator,
            PRE_SPEECH_DEADLINE_MS - (performance.now() - startedAt)
          );

      if (result.done) break;

      const chunk = result.value;
      observation.firstChunkAt ??= performance.now();

      if (!committed) {
        buffer += chunk;

        if (!isSpeakable(buffer)) continue;

        await sink.enqueue(buffer);
        spokenText += buffer;
        buffer = "";
        committed = true;
        observation.speechCommittedAt = performance.now();
      } else {
        await sink.enqueue(chunk);
        spokenText += chunk;
      }
    }

    // 短い正常回答も、完了していればここで読み上げる
    if (!committed && buffer.trim()) {
      await sink.enqueue(buffer);
      spokenText += buffer;
      committed = true;
      observation.speechCommittedAt = performance.now();
    }

    observation.completedAt = performance.now();
    observation.outcome = "completed";
    observation.output = spokenText;

    return { observation, spokenText };
  } catch (error) {
    controller.abort();

    if (parentSignal.aborted) {
      observation.outcome = "interrupted";
    } else if (committed) {
      observation.outcome = "failed_after_speech";
      await sink.notifyRecovery("incomplete_response");
    } else {
      observation.outcome = "failed_before_speech";
    }

    observation.completedAt = performance.now();
    observation.output = spokenText;
    observation.errorCode =
      error instanceof BeforeSpeechTimeout
        ? "PRE_SPEECH_TIMEOUT"
        : "MODEL_STREAM_ERROR";

    return { observation, spokenText };
  } finally {
    parentSignal.removeEventListener("abort", abort);
  }
}

async function runPrimaryWithFallback(
  primary: ModelAdapter,
  fallback: ModelAdapter,
  input: TurnInput,
  sink: SpeechSink,
  signal: AbortSignal
): Promise<TurnObservation[]> {
  const records: TurnObservation[] = [];

  const first = await runProvider(
    primary,
    "primary",
    input,
    sink,
    signal
  );
  records.push(first.observation);

  if (
    first.observation.outcome === "failed_before_speech" &&
    !signal.aborted
  ) {
    const second = await runProvider(
      fallback,
      "fallback",
      input,
      sink,
      signal
    );
    records.push(second.observation);
  }

  return records;
}

failed_after_speechでは、フォールバックを呼びません。別モデルが同じ会話履歴を受け取っても、先ほどの文章をどこまでユーザーが聞いたかは完全には把握できないためです。

復旧音声はLLMに再生成させず、たとえば「途中で応答を続けられませんでした。もう一度聞き直してください」のような、アプリ側で管理する短い定型文にします。

手順3:シャドー応答をユーザー経路から隔離する

シャドーモデルの結果は、TTSへ接続しない専用関数で取得します。

async function runShadow(
  adapter: ModelAdapter,
  input: TurnInput,
  signal: AbortSignal
): Promise<TurnObservation> {
  const startedAt = performance.now();
  const record: TurnObservation = {
    turnId: input.turnId,
    requestId: input.requestId,
    provider: adapter.name,
    mode: "shadow",
    startedAt,
    outcome: "failed_before_speech",
  };

  let output = "";

  try {
    for await (const chunk of adapter.stream(input, signal)) {
      record.firstChunkAt ??= performance.now();
      output += chunk;
    }

    record.completedAt = performance.now();
    record.output = output;
    record.outcome = "completed";
  } catch {
    record.completedAt = performance.now();
    record.output = output;
    record.outcome = signal.aborted
      ? "interrupted"
      : "failed_before_speech";
  }

  return record;
}

呼び出し側では、本番処理と並行して起動できます。ただし全ターンを二重送信すると、コストだけでなくプライバシー上の送信先も増えます。まずは匿名化できる検証会話、または明示的に同意を得たサンプルだけに限定します。

async function handleTurn(
  input: TurnInput,
  primary: ModelAdapter,
  fallback: ModelAdapter,
  shadow: ModelAdapter,
  sink: SpeechSink,
  signal: AbortSignal,
  enableShadow: boolean
) {
  const shadowPromise = enableShadow
    ? runShadow(shadow, input, signal)
    : Promise.resolve(undefined);

  const productionRecords = await runPrimaryWithFallback(
    primary,
    fallback,
    input,
    sink,
    signal
  );

  const shadowRecord = await shadowPromise;

  return {
    productionRecords,
    shadowRecord,
  };
}

ユーザーが発話を始めたら同じAbortControllerを中止し、本番・フォールバック・シャドーをまとめてキャンセルします。キャンセル後に到着したシャドー結果を、次のターンの履歴へ追加してはいけません。

手順4:文章順位ではなく会話タスクで判定する

モデル選定用の評価表は、次のように機械判定と人間判定を分けます。

機械的に集計する項目

  • 発話確定までの時間
  • 発話前の失敗率
  • 発話後に途切れた回数
  • 割り込み後に生成が継続した回数
  • ターン完了までの時間
  • リクエスト単位の入力・出力使用量
  • フォールバックが発生した回数

人が対比較する項目

  • 最初の一文だけで質問へ応答できているか
  • 長い説明の前に確認質問を置くべきだったか
  • 音声で聞き取りやすい文の長さか
  • 断定してはいけない内容を断定していないか
  • キャラクター設定を維持できているか
  • 次の発話をユーザーへ返せる終わり方か

シャドー応答をLLMだけで自動採点すると、採点モデルの好みを測る結果になりがちです。構造要件はコードで検査し、会話としての自然さや許容可能性は、モデル名を隠した対比較で人が判断するのが安全です。

また、コストはモデル単価だけでなく、次の2つに分けて記録します。

  1. 1回のターンを単独で呼び出した使用量
  2. システム文、会話履歴、ゲートウェイ側文脈を含む実経路の使用量

呼び出し経路が異なるモデルを、単価表だけで比較しないためです。

確認方法:5つの故障を注入する

実プロバイダーへ接続する前に、遅延や例外を返す偽アダプターで確認します。

class FakeAdapter implements ModelAdapter {
  constructor(
    readonly name: ProviderName,
    private readonly chunks: string[],
    private readonly delayMs: number,
    private readonly failAfterChunks?: number
  ) {}

  async *stream(
    _input: TurnInput,
    signal: AbortSignal
  ): AsyncIterable<string> {
    for (let i = 0; i < this.chunks.length; i++) {
      await sleep(this.delayMs);
      if (signal.aborted) throw new Error("aborted");
      if (this.failAfterChunks === i) {
        throw new Error("injected failure");
      }
      yield this.chunks[i];
    }
  }
}

最低限、以下を検証します。

  • 本番モデルが最初のチャンク前に停止すると、代替モデルだけが読み上げられる
  • 一文を読み上げた後の停止では、別モデルの文章が接続されない
  • ユーザー割り込み後、本番とシャドーの両方がinterruptedになる
  • シャドーモデルが先に完了しても、TTSへ1文字も渡らない
  • 次ターンの履歴には、実際に読み上げた回答だけが残る
  • requestIdから本番、フォールバック、シャドーの記録を関連付けられる
  • シャドー機能を無効化しても本番会話が成立する

RTC、STT、TTSを結合した後は、テキストログだけでなく、実際の再生停止時刻も確認します。AbortControllerが中止されても、すでにTTSや再生キューへ入った音声が自動的に消えるとは限らないためです。

注意点とトレードオフ

1. フェイルオーバーは可用性を無条件に上げない

代替モデルの起動にも時間がかかります。発話前期限を長くすると切り替え成功率は上がりますが、無言時間も延びます。短くすると素早く切り替えられる一方、一時的に遅いだけの本番モデルを頻繁に捨てます。

用途ごとに「待つ」「聞き直す」「別モデルへ切り替える」の優先順位を決めてください。

2. 発話後の自動継続は行わない

途中回答を別モデルへ渡して続きを生成させる方法もありますが、事実関係、口調、結論が変わる可能性があります。最初は不完全回答として明示し、次のターンでユーザーに再開の意思を確認するほうが制御しやすくなります。

3. シャドー実行は別のデータ送信である

同じ会話を複数プロバイダーへ送るため、同意、データ最小化、保持期間、削除方法を先に決めます。個人情報や機微情報を含む可能性がある会話では、シャドーを既定で無効にする判断も必要です。

4. ユーザーが止められる経路をLLMへ依存させない

音声による停止に加えて、画面上の停止ボタンやセッション終了操作を用意します。停止処理はLLMの意味理解を待たず、RTC再生、TTSキュー、モデル生成をアプリ側から中止できる構成にします。

まとめ

リアルタイム音声AIにおけるGeminiとOpenAI系モデルの比較は、完成した回答文だけでは決められません。

  • 本番応答とシャドー応答を分ける
  • シャドー応答には発話権を与えない
  • フェイルオーバーは最初の発話確定前だけにする
  • 発話後の失敗は別モデルで継ぎ足さない
  • 実際に提示した文章だけを会話履歴へ確定する
  • 遅延・可用性は呼び出し経路込みで記録する
  • 最終判断には、人間による会話タスクの対比較を残す

モデル比較への不安は、「正解のモデルを一度で選ばなければならない」と考えると大きくなります。実際には、ユーザー経路を固定したまま候補をシャドー評価し、ターン単位で採用判断を更新できる仕組みに変えるほうが現実的です。


関係性の開示:筆者はTencent RTCの技術コンテンツ制作に関わっています。本稿の実装方針は、Tencent RTCの公式ドキュメントを参照して構成しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?