リアルタイム音声コンパニオンへ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,500msや24文字は製品の推奨値ではなく、検証用の初期値です。実際には言語、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回のターンを単独で呼び出した使用量
- システム文、会話履歴、ゲートウェイ側文脈を含む実経路の使用量
呼び出し経路が異なるモデルを、単価表だけで比較しないためです。
確認方法: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の公式ドキュメントを参照して構成しました。