リアルタイム音声AIを個人開発へ組み込むと、モデル選び以上に悩ましいのが失敗時の扱いです。
LLMが返らないたびに自動再試行すれば、一見すると可用性は上がります。しかし実際には、次の問題が同時に起こります。
- 同じ発話に対して複数のLLMリクエストが走る
- タイムアウト後の古い回答が、次の会話中に読み上げられる
- ユーザーが割り込んだ後も生成が続く
- 安価なモデルへ切り替えた結果、キャラクターや回答方針が変わる
- 何回呼び出したか分からず、コストを制御できない
LLMは自然な回答を生成できますが、「どの回答を採用するか」「何回まで試すか」「ユーザーが会話を止めたか」は判断してくれません。ここはモデルの能力ではなく、アプリケーション側の制御責任です。
この記事では、1つの確定発話に対するLLM呼び出しを最大2回に制限し、期限切れや割り込み後の回答をTTSへ渡さない回復ラダーをTypeScriptで実装します。
結論:再試行回数ではなく「採用できる回答」を1件に制限する
今回のルールは次のとおりです。
- 音声認識の途中結果ではLLMを呼ばない
- 確定したユーザー発話に
turnIdを割り当てる - 1回目は通常のLLMルートへ送る
- タイムアウトまたは通信失敗時だけ、2回目のルートを試す
- ターン全体の締切を超えたら固定の回復案内へ移る
- ユーザーが割り込んだターンの回答は、後から届いても読み上げない
- TTSへ渡せる回答は、各ターンにつき1件だけにする
重要なのは、Promise.race()でタイムアウトさせることだけではありません。クライアント側で処理を中断しても、上流では生成が完了する場合があります。そのため、中断要求と回答の採用判定を両方実装します。
前提:RTC、認識、LLM、読み上げを別の責任として扱う
Tencent Conversational AIは、リアルタイム音声対話と複数のLLMプロバイダーを組み合わせるシナリオを扱っています。
LLM設定ではOpenAI互換モデルやエージェントプラットフォームとの接続、要求識別子を使ったルーティング・観測が案内されています。設定項目は更新される可能性があるため、実際の接続時は上記の公式ドキュメントで現在の形式を確認してください。
アプリケーション内では、次の境界を維持します。
マイク入力
↓
RTC/メディア経路
↓
音声認識
↓ 確定発話だけ
TurnController ← 割り込み、締切、試行回数
↓
LLMアダプター(OpenAI互換モデルなど)
↓ 採用済み回答だけ
TTS
↓
RTC/端末再生
モデレーションが必要なサービスでは、LLM入力前とTTS再生前の適切な位置へ別途配置します。回復ラダーはモデレーションの代わりにはなりません。
先に決めるデータモデル
音声会話を文字列の往復だけで実装すると、失敗後に「いま届いた回答がどの発話のものか」を判定できません。最低限、次の情報をターン単位で保存します。
type TurnStatus =
| "running"
| "accepted"
| "cancelled"
| "exhausted";
type Attempt = {
requestId: string;
route: string;
startedAt: number;
finishedAt?: number;
result: "running" | "accepted" | "timeout" | "error" | "cancelled";
error?: string;
};
type TurnRecord = {
turnId: string;
input: string;
status: TurnStatus;
startedAt: number;
deadlineAt: number;
attempts: Attempt[];
acceptedRequestId?: string;
acceptedText?: string;
};
requestIdはLLMへの各試行、turnIdはユーザーの確定発話を表します。1ターン内でフォールバックしてもturnIdは変えません。
手順1:検証用プロジェクトを作る
Node.js 20以降を前提にします。
mkdir voice-turn-recovery
cd voice-turn-recovery
npm init -y
npm install -D typescript tsx @types/node
mkdir src
package.jsonへ実行コマンドを追加します。
{
"scripts": {
"demo": "tsx src/demo.ts"
}
}
手順2:1ターンの回復ラダーを実装する
以下をsrc/demo.tsとして保存します。
import assert from "node:assert/strict";
type TurnStatus = "running" | "accepted" | "cancelled" | "exhausted";
type AttemptResult =
| "running"
| "accepted"
| "timeout"
| "error"
| "cancelled";
type Attempt = {
requestId: string;
route: string;
startedAt: number;
finishedAt?: number;
result: AttemptResult;
error?: string;
};
type TurnRecord = {
turnId: string;
input: string;
status: TurnStatus;
startedAt: number;
deadlineAt: number;
attempts: Attempt[];
acceptedRequestId?: string;
acceptedText?: string;
};
type LlmRequest = {
requestId: string;
turnId: string;
input: string;
signal: AbortSignal;
};
interface LlmRoute {
name: string;
generate(request: LlmRequest): Promise<string>;
}
type ControllerOptions = {
turnDeadlineMs: number;
attemptTimeoutMs: number;
recoveryText: string;
onAcceptedText: (turnId: string, text: string) => Promise<void>;
onRecovery: (turnId: string, text: string) => Promise<void>;
};
class TimeoutError extends Error {}
class TurnController {
private readonly turns = new Map<string, TurnRecord>();
private readonly aborters = new Map<string, AbortController>();
constructor(
private readonly routes: LlmRoute[],
private readonly options: ControllerOptions,
) {}
getTurn(turnId: string): TurnRecord | undefined {
return this.turns.get(turnId);
}
interrupt(turnId: string): boolean {
const turn = this.turns.get(turnId);
if (!turn || turn.status !== "running") return false;
turn.status = "cancelled";
this.aborters.get(turnId)?.abort("user_interrupted");
return true;
}
async run(turnId: string, input: string): Promise<TurnRecord> {
const existing = this.turns.get(turnId);
if (existing) return existing;
const startedAt = Date.now();
const turn: TurnRecord = {
turnId,
input,
status: "running",
startedAt,
deadlineAt: startedAt + this.options.turnDeadlineMs,
attempts: [],
};
const turnAborter = new AbortController();
this.turns.set(turnId, turn);
this.aborters.set(turnId, turnAborter);
for (let index = 0; index < this.routes.length; index++) {
if (turn.status !== "running") break;
const remainingMs = turn.deadlineAt - Date.now();
if (remainingMs <= 0) break;
const route = this.routes[index];
const requestId = `${turnId}:attempt:${index + 1}`;
const attempt: Attempt = {
requestId,
route: route.name,
startedAt: Date.now(),
result: "running",
};
turn.attempts.push(attempt);
try {
const text = await withTimeout(
route.generate({
requestId,
turnId,
input,
signal: turnAborter.signal,
}),
Math.min(this.options.attemptTimeoutMs, remainingMs),
turnAborter,
);
attempt.finishedAt = Date.now();
// 中断後、期限後、別回答の採用後ならTTSへ渡さない。
if (
turn.status !== "running" ||
Date.now() > turn.deadlineAt ||
turn.acceptedRequestId
) {
attempt.result = "cancelled";
continue;
}
turn.acceptedRequestId = requestId;
turn.acceptedText = text;
turn.status = "accepted";
attempt.result = "accepted";
await this.options.onAcceptedText(turnId, text);
return turn;
} catch (error) {
attempt.finishedAt = Date.now();
if (turn.status === "cancelled" || turnAborter.signal.aborted) {
attempt.result = "cancelled";
break;
}
if (error instanceof TimeoutError) {
attempt.result = "timeout";
attempt.error = error.message;
} else {
attempt.result = "error";
attempt.error = error instanceof Error ? error.message : String(error);
}
}
}
this.aborters.delete(turnId);
if (turn.status === "cancelled") return turn;
turn.status = "exhausted";
await this.options.onRecovery(turnId, this.options.recoveryText);
return turn;
}
}
async function withTimeout<T>(
task: Promise<T>,
timeoutMs: number,
aborter: AbortController,
): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
aborter.abort("attempt_timeout");
reject(new TimeoutError(`timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
return await Promise.race([task, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"));
return;
}
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new Error("aborted"));
},
{ once: true },
);
});
}
class FakeLlm implements LlmRoute {
constructor(
readonly name: string,
private readonly delayMs: number,
private readonly behavior: "success" | "error",
) {}
async generate(request: LlmRequest): Promise<string> {
await sleep(this.delayMs, request.signal);
if (this.behavior === "error") {
throw new Error(`${this.name} failed`);
}
return `${this.name} response for ${request.input}`;
}
}
async function main() {
const played: string[] = [];
const recoveries: string[] = [];
const controller = new TurnController(
[
new FakeLlm("primary", 200, "error"),
new FakeLlm("fallback", 50, "success"),
],
{
// 製品仕様値ではなく、ローカル検証用の設定例。
turnDeadlineMs: 1_000,
attemptTimeoutMs: 300,
recoveryText: "うまく答えを作れませんでした。もう一度試しますか?",
onAcceptedText: async (turnId, text) => {
played.push(`${turnId}:${text}`);
},
onRecovery: async (turnId, text) => {
recoveries.push(`${turnId}:${text}`);
},
},
);
const result = await controller.run("turn-1", "今日の予定を整理して");
assert.equal(result.status, "accepted");
assert.equal(result.attempts.length, 2);
assert.equal(result.attempts[0].result, "error");
assert.equal(result.attempts[1].result, "accepted");
assert.equal(played.length, 1);
assert.equal(recoveries.length, 0);
console.dir(result, { depth: null });
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
実行します。
npm run demo
1つ目のルートが失敗し、2つ目のルートの回答だけがonAcceptedTextへ渡れば成功です。
なお、サンプル中のミリ秒値はTencent RTCやLLMの性能値ではありません。ローカルで状態遷移を短時間に再現するための設定例です。本番値はネットワーク、利用モデル、発話の長さ、許容する待ち時間を計測して決めます。
コード上の注意:試行ごとではなく、ターン全体で中断する
上の最小実装では、タイムアウト時にターン共通のAbortControllerを中断しています。そのままでは、タイムアウト後のフォールバックも同じシグナルを使うため開始できません。
本番実装では、次のようにコントローラーを分けます。
turnAborter
├── attempt-1 AbortController
└── attempt-2 AbortController
- ユーザーの割り込み:
turnAborterで全試行を止める - 1回のタイムアウト:その
attemptAborterだけ止める - フォールバック:新しい
attemptAborterで開始する
記事のコードを発展させる場合は、withTimeout()へターン共通のコントローラーを直接渡さず、試行ごとのコントローラーを作ってください。両者を連動させる例は次のとおりです。
function createAttemptAborter(turnSignal: AbortSignal): AbortController {
const attemptAborter = new AbortController();
if (turnSignal.aborted) {
attemptAborter.abort(turnSignal.reason);
} else {
turnSignal.addEventListener(
"abort",
() => attemptAborter.abort(turnSignal.reason),
{ once: true },
);
}
return attemptAborter;
}
この分離により、1回目のタイムアウトだけを止めつつ、ユーザーの割り込みでは全体を停止できます。
手順3:OpenAI互換モデルとTencent Conversational AIへ接続する
実システムでは、FakeLlmをLLM接続アダプターへ置き換えます。
Tencent Conversational AI側のLLM設定は、公式のLarge Language Model configurationに従います。OpenAI互換モデルを利用する場合も、エンドポイント、認証、モデル、要求識別子に関する現在の設定方法を公式ドキュメントで確認してください。
アプリケーションから渡す情報は、少なくとも次の対応を維持します。
| アプリ側 | 接続先での用途 |
|---|---|
turnId |
どの確定発話に属する処理かを追跡する |
requestId |
各LLM試行を区別する |
input |
音声認識で確定したユーザー発話 |
AbortSignal |
対応可能な接続で中断を伝える |
| route名 | 通常経路とフォールバック経路をログで区別する |
LLM接続ライブラリがAbortSignalに対応していなくても、acceptedRequestIdによる採用ゲートは残します。上流処理を止められない場合でも、遅れて届いた文章をTTSへ渡さないためです。
音声経路へ接続するときは、次のイベントだけをTurnControllerへ入れます。
// SDK固有のAPI名ではなく、接続位置を示す概念コード
async function onSpeechFinal(text: string) {
const turnId = crypto.randomUUID();
await controller.run(turnId, text);
}
function onUserSpeechStartedWhileAiSpeaking(activeTurnId: string) {
controller.interrupt(activeTurnId);
stopCurrentAudioPlayback();
}
音声認識の途中結果をrun()へ渡すと、認識更新のたびに新しいLLM要求が発生します。途中結果は字幕表示などに限定し、LLM呼び出しは確定イベントへ寄せます。
どのフォールバック方針を選ぶか
「常に安価なモデルから試す」が正解とは限りません。用途ごとに次のように判断できます。
| 会話の用途 | 推奨する方針 | 理由 |
|---|---|---|
| 雑談、簡単な案内 | 通常ルート+1回のフォールバック | 回答の多少の差を許容しやすい |
| キャラクター会話 | 同じ指示・人格を共有できる経路だけ使う | 切り替えによる口調変化が体験を壊しやすい |
| 予約、購入、設定変更 | 自動フォールバックせず確認へ戻す | 異なる判断を継続すると操作意図が変わり得る |
| 健康、法律、金融など高リスクの案内 | 回復文または有人導線へ移す | 流暢さより責任範囲の明示が重要 |
| 子どもを含むソーシャル体験 | 同意、モデレーション、停止操作を先に設計する | モデル切り替えだけでは安全性を担保できない |
AIコンパニオンやキャラクター対話は、Tencent RTCのSocial Entertainmentソリューションでも対象シナリオとして示されています。
ただし、キャラクターらしさや安全基準は製品が自動的に決めるものではありません。どのルートへ切り替えてよいか、どの話題では固定案内へ戻すかは、サービス運営者が決める必要があります。
確認方法:成功率ではなく、1ターン1回答を壊して試す
1. 1回目だけタイムアウトさせる
- 1回目のアダプターを意図的に遅延させる
- 2回目を成功させる
- TTSへ渡った回答が1件だけか確認する
-
requestIdが2件、acceptedRequestIdが1件か確認する
2. 割り込み後に回答を返す
- LLM生成中に
interrupt(turnId)を呼ぶ - アダプター側が中断を無視するケースも用意する
- 遅れて返った文章が
onAcceptedTextへ到達しないことを確認する - 再生中だった音声も停止する
3. 両方のルートを失敗させる
- ターン状態が
exhaustedになることを確認する - LLMを再帰的に呼ばず、固定の回復案内が1回だけ出ることを確認する
- 「再試行」操作はユーザーが選んだときだけ新しい
turnIdで開始する
4. 同じturnIdを二重送信する
- 通信再送を想定して同じ
turnIdでrun()を2回呼ぶ - 新しいLLM試行が増えず、既存レコードが返ることを確認する
5. ターン締切直前に回答させる
- 回答到着時刻が締切を越えた場合に採用されないことを確認する
- LLM完了時刻だけでなく、TTS開始前にもターン状態を再確認する
6. フォールバックで人格を変えてみる
- 1回目と2回目へ異なるシステム指示を与える
- 口調、禁止事項、回答範囲の差を記録する
- 差を許容できないなら、モデル切り替えではなく固定回復文を選ぶ
注意点
中断しても、呼び出しコストまで必ず消えるとは限らない
クライアントが接続を閉じても、プロバイダー側ですでに処理された分まで取り消せるとは限りません。したがって、コスト対策はキャンセルだけに依存せず、次も併用します。
- 1ターンの最大試行回数
- セッション全体の最大試行回数
- 入力文字数またはトークン量の上限
- 途中認識からLLMを呼ばない制御
-
turnId単位の重複排除
金額上限を実装する場合は、モデルの料金をコードへ直接埋め込まず、利用明細とモデル設定から別処理で集計する方が変更に追従しやすくなります。
回復文をLLMに作らせない
「失敗したので謝罪文を生成する」という実装は、障害中のLLMをもう一度呼ぶ構造になります。回復文は短い固定文、事前生成音声、または画面表示として用意します。
フォールバック先へ会話履歴を無条件に渡さない
別のプロバイダーへ切り替える場合、会話履歴や個人情報の送信先も変わる可能性があります。利用者への説明、同意、データ保持方針、地域要件、機密情報の除外を確認してください。
自動再試行とユーザーの再試行を区別する
内部フォールバックは同じturnId、ユーザーが「もう一度」を選んだ場合は新しいturnIdとします。この区別がないと、システムが勝手に続けたのか、利用者が再実行したのかを監査できません。
最後に
音声AIの信頼性は、最も賢いモデルを選ぶだけでは決まりません。失敗したときに無限再試行せず、割り込みを尊重し、どの回答を読み上げるかを1件に絞ることが、デモを運用可能な実装へ変える境界です。
まずは実モデルへ接続する前に、FakeLlmでタイムアウト、二重送信、割り込み、遅延回答を再生してください。その後、公式ドキュメントに沿ってOpenAI互換モデルを設定し、同じturnIdとrequestIdを観測ログへ通すと、障害時にも追跡しやすくなります。
関係性の開示: 筆者はTencent RTCに関係するコンテンツ制作に携わっています。本記事の実装方針を確認するため、Tencent RTCの公式ドキュメントを参照しました。