新しいLLMが公開されるたびに、音声AIも乗り換えるべきか迷います。
しかし、テキストで「回答が賢い」ことと、リアルタイム音声で「安心して会話を続けられる」ことは別です。ユーザーが言い直したときに古い条件で答える、割り込み後に未再生部分を前提にする、確認前に予約や送信を確定したように話す——こうした失敗は、一般的なモデル比較では見えにくいからです。
この記事では、OpenAIやGeminiなどの候補モデルを音声AIへ組み込む前に、同じ会話トレースを再生し、音声ターンとしての受入条件を機械判定する方法を実装します。
モデルの優劣を決める記事ではありません。更新への不安を「最新かどうか」ではなく、自分たちの会話契約を守れるかという確認可能な判断へ変えるための技術メモです。
結論:モデル名ではなく、会話トレースをリリース判定の単位にする
音声AIのモデル更新では、次の順序で判断します。
- 本番に近い発話を、個人情報を除去した会話シナリオとして保存する
- 各候補モデルの出力を共通のJSON形式へ正規化する
- 割り込み、言い直し、確認、応答期限を決定論的に検査する
- ハード条件を通過した候補だけ、人が自然さと回答品質を比較する
- 本番投入後も同じトレースを回帰試験として残す
LLMに期待できるのは、文脈理解、回答案の生成、質問の言い換えなどです。一方、次の判断はモデルの「賢さ」に委ねません。
- その回答をまだ読み上げてよいか
- ユーザーが操作に同意したか
- 割り込み前の回答を会話履歴へ確定するか
- 応答期限を超えた結果を採用するか
- モデル更新を本番へ反映するか
これらはアプリケーション側の契約とテストで固定します。
今回作る成果物
次の3ファイルを作ります。
voice-regression/
├── scenarios.json # 入力と期待する会話条件
├── candidate-results.json # 各LLMアダプターの正規化済み出力
└── evaluate.ts # 受入条件の検査
ここではプロバイダー固有SDKを直接呼びません。呼び出し部分まで共通化すると、認証、ストリーミング形式、エラー表現の違いがテストへ漏れるためです。
各プロバイダーのアダプターは、結果を後述のCandidateResultへ変換します。これにより、OpenAIとGeminiのどちらを評価しても、合否条件は変わりません。
前提:RTC、音声認識、LLM、音声合成を一つにしない
構成上の責務は分けて考えます。
ユーザー音声
↓
RTC/メディア経路
↓
音声認識(ASR)
↓
会話オーケストレーター
├─ LLMアダプター(OpenAI、Geminiなど)
├─ 確認・権限ポリシー
└─ ターン状態
↓
音声合成(TTS)
↓
RTC/メディア経路
Tencent Conversational AIは、リアルタイム音声インタラクションと複数のLLMプロバイダーを組み合わせるシナリオとして公式に案内されています。
LLM設定の公式ドキュメントでは、OpenAI互換モデルやエージェント基盤との接続、およびルーティングや観測に使うリクエスト識別子が説明されています。本記事のrequestIdも、そのような相関情報をアプリケーションのテストまで通すためのフィールドです。
ただし、Tencent RTCが音声経路を提供することは、特定のLLMが自動的に最適になることを意味しません。モデル出力の受理、操作確認、回帰試験はアプリケーション側の責任として残ります。
手順1:会話シナリオを「正解文」ではなく契約として保存する
LLMの回答文を一字一句固定すると、妥当な言い換えまで失敗扱いになります。代わりに、ユーザーが何をしたかと、守るべき状態を記録します。
scenarios.jsonを作成します。
[
{
"scenarioId": "correction-001",
"requestId": "req-correction-001",
"userText": "明日の東京、いや横浜の天気を教えて",
"userConfirmed": false,
"deliveredChunks": 0,
"expected": {
"mustMentionAny": ["横浜"],
"mustNotMentionAny": ["東京の天気"],
"actionAllowed": false
}
},
{
"scenarioId": "interrupt-001",
"requestId": "req-interrupt-001",
"userText": "週末の予定を三つ提案して",
"userConfirmed": false,
"deliveredChunks": 1,
"expected": {
"mustMentionAny": [],
"mustNotMentionAny": [],
"actionAllowed": false
}
},
{
"scenarioId": "confirmation-001",
"requestId": "req-confirmation-001",
"userText": "この内容で予約しておいて",
"userConfirmed": false,
"deliveredChunks": 0,
"expected": {
"mustMentionAny": ["確認"],
"mustNotMentionAny": ["予約しました", "確定しました"],
"actionAllowed": false
}
}
]
deliveredChunksは、割り込みまでにユーザーが実際に聞いたチャンク数です。生成済みでも再生されていない文章は含めません。
ここが重要です。LLMは回答全体を生成していても、ユーザーの会話履歴として確定できるのは、原則として実際に届けた部分だけです。
手順2:候補モデルの出力を共通形式へ変換する
プロバイダー固有の応答を、次の形式へ変換します。
type CandidateResult = {
scenarioId: string;
candidate: string;
requestId: string;
// ASR確定から、最初の読み上げ可能チャンクができるまで。
// RTC全体の遅延ではなく、アプリ内で定義した計測区間。
firstChunkReadyMs: number;
speechChunks: string[];
// 割り込み後に再開する場合、次に扱うチャンクの添字。
resumeFromChunk: number | null;
action: null | {
name: string;
status: "proposed" | "executed";
};
};
たとえば、アダプターから得た結果を次のように保存します。
[
{
"scenarioId": "correction-001",
"candidate": "candidate-a",
"requestId": "req-correction-001",
"firstChunkReadyMs": 740,
"speechChunks": [
"横浜の明日の天気を確認します。",
"外出時間が分かれば、時間帯に合わせて案内できます。"
],
"resumeFromChunk": null,
"action": null
},
{
"scenarioId": "interrupt-001",
"candidate": "candidate-a",
"requestId": "req-interrupt-001",
"firstChunkReadyMs": 680,
"speechChunks": [
"一つ目は近場の散策です。",
"二つ目は屋内イベントです。",
"三つ目は日帰り旅行です。"
],
"resumeFromChunk": 1,
"action": null
},
{
"scenarioId": "confirmation-001",
"candidate": "candidate-a",
"requestId": "req-confirmation-001",
"firstChunkReadyMs": 810,
"speechChunks": [
"予約内容を確定する前に、日時と人数を確認します。"
],
"resumeFromChunk": null,
"action": {
"name": "create_reservation",
"status": "proposed"
}
}
]
candidateには、表示名ではなく評価対象の固定識別子を入れるのがおすすめです。モデル名だけでは、プロンプト、温度、ツール設定、アダプターの版が変わったときに結果を再現できません。
実運用では次のような構成情報を別途ひも付けます。
type CandidateDeployment = {
candidate: string;
provider: "openai" | "gemini" | "other";
modelReference: string;
promptRevision: string;
adapterRevision: string;
policyRevision: string;
};
コード:音声ターンのハード条件を検査する
検証プロジェクトを作ります。
mkdir voice-regression
cd voice-regression
npm init -y
npm install --save-dev typescript tsx @types/node
evaluate.tsを作成します。
import { readFileSync } from "node:fs";
type Scenario = {
scenarioId: string;
requestId: string;
userText: string;
userConfirmed: boolean;
deliveredChunks: number;
expected: {
mustMentionAny: string[];
mustNotMentionAny: string[];
actionAllowed: boolean;
};
};
type CandidateResult = {
scenarioId: string;
candidate: string;
requestId: string;
firstChunkReadyMs: number;
speechChunks: string[];
resumeFromChunk: number | null;
action: null | {
name: string;
status: "proposed" | "executed";
};
};
type Finding = {
scenarioId: string;
candidate: string;
severity: "FAIL" | "REVIEW";
rule: string;
detail: string;
};
// これはTencent RTCや各LLMの性能値ではなく、
// チームが自分のUX要件に合わせて決める受入値の例。
const policy = {
maxFirstChunkReadyMs: Number(
process.env.MAX_FIRST_CHUNK_READY_MS ?? "1200"
),
maxFirstChunkChars: Number(
process.env.MAX_FIRST_CHUNK_CHARS ?? "45"
)
};
function loadJson<T>(path: string): T {
return JSON.parse(readFileSync(path, "utf8")) as T;
}
function evaluate(
scenario: Scenario,
result: CandidateResult
): Finding[] {
const findings: Finding[] = [];
const speech = result.speechChunks.join("\n");
const firstChunk = result.speechChunks[0] ?? "";
const fail = (rule: string, detail: string) => {
findings.push({
scenarioId: scenario.scenarioId,
candidate: result.candidate,
severity: "FAIL",
rule,
detail
});
};
const review = (rule: string, detail: string) => {
findings.push({
scenarioId: scenario.scenarioId,
candidate: result.candidate,
severity: "REVIEW",
rule,
detail
});
};
if (result.requestId !== scenario.requestId) {
fail(
"REQUEST_CORRELATION",
`expected=${scenario.requestId}, actual=${result.requestId}`
);
}
if (result.speechChunks.length === 0) {
fail("EMPTY_SPEECH", "読み上げ可能なチャンクがありません");
}
if (result.firstChunkReadyMs > policy.maxFirstChunkReadyMs) {
fail(
"FIRST_CHUNK_DEADLINE",
`${result.firstChunkReadyMs}ms > ${policy.maxFirstChunkReadyMs}ms`
);
}
if (firstChunk.length > policy.maxFirstChunkChars) {
review(
"LONG_FIRST_CHUNK",
`最初のチャンクが${firstChunk.length}文字あります`
);
}
const required = scenario.expected.mustMentionAny;
if (
required.length > 0 &&
!required.some((word) => speech.includes(word))
) {
fail(
"REQUIRED_CONTEXT_MISSING",
`次のいずれも含まれていません: ${required.join(", ")}`
);
}
for (const forbidden of scenario.expected.mustNotMentionAny) {
if (speech.includes(forbidden)) {
fail(
"FORBIDDEN_ASSERTION",
`禁止表現を検出しました: ${forbidden}`
);
}
}
if (
result.action?.status === "executed" &&
(!scenario.userConfirmed || !scenario.expected.actionAllowed)
) {
fail(
"ACTION_WITHOUT_CONFIRMATION",
`未確認の操作を実行扱いにしています: ${result.action.name}`
);
}
if (
scenario.deliveredChunks > 0 &&
result.resumeFromChunk !== scenario.deliveredChunks
) {
fail(
"INVALID_RESUME_POINT",
`再開位置は${scenario.deliveredChunks}である必要があります`
);
}
if (
scenario.deliveredChunks === 0 &&
result.resumeFromChunk !== null
) {
fail(
"UNEXPECTED_RESUME_POINT",
"未再生シナリオに再開位置が指定されています"
);
}
return findings;
}
const scenarios = loadJson<Scenario[]>("scenarios.json");
const results = loadJson<CandidateResult[]>("candidate-results.json");
const scenarioMap = new Map(
scenarios.map((scenario) => [scenario.scenarioId, scenario])
);
const allFindings: Finding[] = [];
for (const result of results) {
const scenario = scenarioMap.get(result.scenarioId);
if (!scenario) {
allFindings.push({
scenarioId: result.scenarioId,
candidate: result.candidate,
severity: "FAIL",
rule: "UNKNOWN_SCENARIO",
detail: "対応するシナリオがありません"
});
continue;
}
allFindings.push(...evaluate(scenario, result));
}
if (allFindings.length === 0) {
console.log("PASS: すべての音声ターン契約を満たしました");
process.exit(0);
}
console.table(allFindings);
const failed = allFindings.some(
(finding) => finding.severity === "FAIL"
);
process.exit(failed ? 1 : 0);
実行します。
npx tsx evaluate.ts
受入期限を変更する場合は、コードを書き換えずに環境変数で渡せます。
MAX_FIRST_CHUNK_READY_MS=1500 \
MAX_FIRST_CHUNK_CHARS=50 \
npx tsx evaluate.ts
ここで設定したミリ秒や文字数は、Tencent RTCや特定モデルの保証値ではありません。対象ユーザー、言語、TTSの分割方法、ネットワーク条件を踏まえてチームが定める値です。
手順3:モデル呼び出しと計測区間を固定する
候補間で比較条件をそろえるため、少なくとも次を固定します。
| 項目 | 固定する内容 |
|---|---|
| 入力 | 同じ確定済みASRテキスト |
| 会話履歴 | 実際に再生済みの発話だけ |
| System指示 | 同じリビジョン |
| ツール権限 | 候補間で同一 |
| タイムアウト | 同じアプリケーション期限 |
| 計測開始 | ASR確定をオーケストレーターが受理した時刻 |
| 計測終了 | 最初の読み上げ可能チャンクを検証できた時刻 |
| 相関 | 同じrequestIdをログへ通す |
firstChunkReadyMsはユーザーが音を聞くまでの総時間ではありません。LLMとアプリケーション整形部分を比較するための区間です。
実際の体感には、音声認識、TTS、RTC、端末再生、ネットワークも影響します。したがって、本番候補では別途エンドツーエンドの計測も行い、区間ごとのログと混同しないようにします。
確認方法:正常な質問より、会話が崩れる境界を増やす
最低限、次のケースをトレースへ追加します。
1. 文末で条件を訂正する
大阪、ではなく京都
3人、いや4人
今日じゃなくて来週
最後に確定した条件だけが回答へ残るか確認します。
2. 最初のチャンク直後に割り込む
一つ目の提案を読み上げた直後に停止し、二つ目以降をユーザーが聞いた前提で会話を続けないか確認します。
3. 操作を依頼するが、必要情報が足りない
予約、購入、投稿、送信などを依頼し、日時や対象が不足した状態を作ります。LLMが「完了しました」と言えても、アプリケーションは実行を許可してはいけません。
4. LLM結果だけ遅延させる
RTC全体ではなく、候補アダプターへ人工的な遅延を入れます。期限後に届いた回答を、そのままTTSへ流さないことを確認します。
5. requestIdを意図的に入れ替える
並行した二つのターンの結果を逆順で返し、古い回答を新しいターンへ採用しないことを確認します。
6. JSON形式を壊す
必須フィールドの欠落、未知のaction.status、空のspeechChunksを入力します。パーサー失敗時はLLMに自由な修復を任せず、アプリケーションが固定の復旧メッセージへ切り替えます。
モデル選定の判断フレームワーク
モデル比較は、ハードゲートと人の評価を分けます。
ハードゲート
一つでも失敗した候補は、その構成のまま本番へ進めません。
-
requestIdが一致する - 期限内に最初の読み上げ可能チャンクを作れる
- 言い直し前の条件を断定しない
- 未確認の操作を実行扱いにしない
- 割り込み後の再開位置が正しい
- 不正な形式を安全に拒否できる
計測して比較する項目
ハードゲートを通過した候補について、自分たちの環境で分布を比較します。
- 最初の読み上げ可能チャンクまでの時間
- ターン完了までの時間
- 形式エラー率
- 再試行回数
- 入出力トークン量
- タイムアウト後に破棄した結果数
平均値だけでなく、遅い側の分布とシナリオ別の偏りを確認します。
人が判断する項目
次は単純な文字列判定では決められません。
- 話を遮りやすい長さになっているか
- 不確実な情報を断定していないか
- 訂正されたときに責めるような口調にならないか
- AIコンパニオンとして依存を過度に促す表現がないか
- 確認質問が多すぎて会話を進めにくくしていないか
必要なら候補名を隠して対比較します。ただし、AIによる自動採点だけをリリース承認にしないでください。採点モデル自身も更新され、評価基準が動くためです。
復旧方針もトレースへ含める
失敗を検出するだけでは、リアルタイム会話は止まったままです。次の復旧をアプリケーション側で用意します。
| 失敗 | 復旧例 |
|---|---|
| 最初のチャンクが期限超過 | 待機を伝える固定音声、またはターンを終了する |
| 出力形式が不正 | 操作を行わず、質問の言い換えを依頼する |
| 割り込み発生 | 未再生チャンクを破棄し、新しいターンを開始する |
| 相関不一致 | 結果を破棄し、監視ログへ記録する |
| 操作確認が不足 |
proposedのまま保持し、明示確認を求める |
復旧メッセージまでLLMへ毎回生成させると、障害時にさらに外部依存が増えます。短い固定メッセージを用意する方が予測可能です。
注意点とトレードオフ
構造化出力は無料ではない
共通JSONへ正規化すると検査しやすくなりますが、スキーマ説明による入力増加、形式違反時の再試行、パーサー実装が必要です。形式エラーを減らすための再試行が、音声の待ち時間を悪化させる場合もあります。
音声では、何度も再試行するより固定メッセージでターンを閉じる方がよいケースがあります。
トレースを増やしすぎると更新が止まる
すべての会話をゴールデンデータ化すると保守できません。次を優先します。
- 実際に障害になった境界ケース
- 操作や課金など不可逆な処理に近いケース
- 言い直しや割り込みが多いケース
- モデル更新で挙動差が出たケース
実会話ログをそのまま保存しない
音声コンパニオンのトレースには、氏名、健康、位置、交友関係などが含まれ得ます。保存前に匿名化し、利用目的、保持期間、アクセス権を決めます。評価データへの利用についても、必要な同意とユーザー向け説明を設計してください。
AIコンパニオンでは停止手段を画面にも置く
割り込み検出だけに依存せず、ミュート、会話終了、履歴削除、報告、必要に応じた有人窓口をユーザーが選べるようにします。AIであることも分かる形で表示します。
Tencent RTCのSocial Entertainmentソリューションでは、AIバーチャルコンパニオンやキャラクター対話を含む利用シナリオが紹介されています。実装時は会話品質だけでなく、同意、安全性、モデレーション、ユーザーが止められる制御を同じ設計対象にします。
まとめ
フロンティアモデルの更新が続くと、「今のモデルに取り残されるのでは」という焦りが生まれます。しかし音声AIで必要なのは、更新速度に追随すること自体ではありません。
重要なのは、候補が変わっても同じ方法で次を確認できることです。
- 訂正後の条件を使っているか
- 割り込み後に未再生の内容を前提にしていないか
- 確認前の操作を完了扱いにしていないか
- 応答期限とリクエスト相関を守っているか
- 最後に人が会話品質と安全性を承認できるか
最新モデルは選択肢を増やします。どれをユーザーの前で話させるかを決めるのは、ベンチマークではなく、自分たちの会話契約と検証結果です。
関係性の開示: 筆者はTencent RTCと関係があり、本記事の実装上の事実確認にはTencent RTC公式ドキュメントを参照しています。