音声AIの待ち時間を短くしたいと考えると、途中の文字起こしをすぐLLMや外部サービスへ渡したくなります。
しかし、利用者が本当に困るのは数百ミリ秒の差だけではありません。
「落ち着いたジャズを……ではなく、アップテンポの曲を探して」
この発話で最初の検索結果をそのまま使えば、AIは速くても間違った方向へ進みます。かといって、すべての処理を発話確定後に始めれば、検索、LLM、音声合成の待ち時間が直列に積み上がります。
必要なのは、早く動くか、正しく動くかの二択ではなく、取り消せる処理だけを早く始める境界です。
この記事では、AI音声コンパニオンの商品・楽曲検索を例に、途中認識から読み取り専用検索を先行させ、確定発話と一致した結果だけを採用する仕組みをTypeScriptで実装します。
結論:途中認識では「準備」し、確定発話で「採用」する
今回のルールは次の4つです。
- 途中認識から開始できるのは、破棄可能な読み取り処理だけ
- 同じ検索キーが連続して観測された場合だけ先行検索する
- 確定発話から作ったキーと完全一致した結果だけ採用する
- LLMへの回答生成依頼は、確定発話と採用済み検索結果を使う
途中認識で実行してよい処理と、待つべき処理を分けると次のようになります。
| 処理 | 途中認識で開始 | 理由 |
|---|---|---|
| 公開カタログの検索 | 可 | 読み取り専用で破棄できる |
| 検索用インデックスの照会 | 可 | 副作用がなく中断できる |
| LLM入力の静的テンプレート準備 | 可 | 外部状態を変更しない |
| ユーザー履歴への確定保存 | 不可 | 誤った内容が記録される |
| 購入、予約、送信 | 不可 | 取り消せない可能性がある |
| LLM回答の読み上げ開始 | 不可 | 暫定内容を利用者へ確定情報として見せる |
重要なのは、途中認識が利用できることと、完成した返答が必ず速くなることを混同しない点です。ネットワーク、検索、LLM、音声合成のどこが支配的かによって効果は変わります。実装後に区間ごとの時刻を計測して判断します。
前提:RTC、音声認識、検索、LLM、TTSを分ける
Tencent Conversational AIは、ユーザーとLLMを接続するリアルタイム音声インタラクションの構成を提供しています。全体像は公式ドキュメントで確認できます。
ただし、本稿のpartialとfinalは、採用する音声認識レイヤーからアプリケーションへ渡す抽象イベントです。特定のTencent RTC API名として扱わないでください。
責務は次のように分離します。
ユーザー音声
↓
RTC/メディア伝送
↓
音声認識 ── partial / final
↓
投機検索コーディネーター
├─ partial: 読み取り専用検索を準備
└─ final: 結果の採用可否を決定
↓
LLM
↓
音声合成
↓
RTC/メディア伝送
途中認識の精度はLLMの賢さとは別問題です。また、検索結果の採用は確率的なLLM判断にせず、アプリケーションコードで再現可能にします。
手順1:検証プロジェクトを作る
Node.js 20以降を前提に、依存関係の少ない検証環境を作ります。
mkdir voice-prefetch-demo
cd voice-prefetch-demo
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
package.jsonへ実行スクリプトを追加します。
{
"scripts": {
"demo": "tsx src/demo.ts"
}
}
今回のデモでは外部のLLMや検索APIを呼びません。遅延を持つ偽のカタログ検索を使い、先行検索の採用と破棄を再現します。
手順2:検索条件を自由文のまま比較しない
途中認識と確定発話の文字列は、句読点や言い直しによって変化します。文字列全体を比較するのではなく、検索に必要な条件だけを正規化します。
今回の検索キーは次の形です。
type SearchQuery = {
mood: "calm" | "upbeat" | "any";
genre: "jazz" | "rock" | "any";
};
「ではなく」「じゃなくて」が含まれる場合は、その後ろを現在の希望として扱います。
これは説明用の決定論的パーサーです。本番でLLMに検索条件を抽出させる場合も、次の境界は残してください。
- LLM出力をスキーマ検証する
- 未知の値を検索へ通さない
- 暫定抽出結果で副作用を起こさない
- 確定発話から再度条件を作る
コード:連続した候補だけ検索し、確定キーで採用する
src/demo.tsを作成します。
import assert from "node:assert/strict";
type TranscriptEvent = {
turnId: string;
revision: number;
kind: "partial" | "final";
text: string;
};
type SearchQuery = {
mood: "calm" | "upbeat" | "any";
genre: "jazz" | "rock" | "any";
};
type Song = {
id: string;
title: string;
};
type LookupResult =
| { status: "completed"; songs: Song[] }
| { status: "aborted"; songs: [] };
type CommitResult = {
source: "prefetched" | "final-search";
query: SearchQuery;
songs: Song[];
};
function parseQuery(text: string): SearchQuery | null {
// 言い直しがあれば、最後の訂正部分を優先する
const focus = text.split(/ではなく|じゃなくて/).at(-1)?.trim() ?? "";
const mood: SearchQuery["mood"] =
/アップテンポ|元気|速い/.test(focus)
? "upbeat"
: /落ち着|静か|ゆっくり/.test(focus)
? "calm"
: "any";
const genre: SearchQuery["genre"] = /ジャズ/.test(focus)
? "jazz"
: /ロック/.test(focus)
? "rock"
: "any";
if (mood === "any" && genre === "any") {
return null;
}
return { mood, genre };
}
function queryKey(query: SearchQuery): string {
return `${query.mood}:${query.genre}`;
}
interface Catalog {
search(query: SearchQuery, signal: AbortSignal): Promise<LookupResult>;
}
class FakeCatalog implements Catalog {
public startedKeys: string[] = [];
public abortedKeys: string[] = [];
async search(
query: SearchQuery,
signal: AbortSignal,
): Promise<LookupResult> {
const key = queryKey(query);
this.startedKeys.push(key);
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve({
status: "completed",
songs: [{ id: `song-${key}`, title: `候補曲 ${key}` }],
});
}, 120);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
this.abortedKeys.push(key);
resolve({ status: "aborted", songs: [] });
},
{ once: true },
);
});
}
}
class SpeculativeLookup {
private lastCandidateKey?: string;
private candidateStreak = 0;
private inFlight?: {
key: string;
controller: AbortController;
promise: Promise<LookupResult>;
};
constructor(
private readonly catalog: Catalog,
private readonly requiredStreak = 2,
) {}
observePartial(event: TranscriptEvent): void {
assert.equal(event.kind, "partial");
const query = parseQuery(event.text);
if (!query) return;
const key = queryKey(query);
if (key === this.lastCandidateKey) {
this.candidateStreak += 1;
} else {
this.lastCandidateKey = key;
this.candidateStreak = 1;
}
// 一度だけ現れた不安定な候補では検索しない
if (this.candidateStreak < this.requiredStreak) return;
// 同じ検索がすでに進行中なら再実行しない
if (this.inFlight?.key === key) return;
// 別の候補へ変わったら古い検索を中断する
this.inFlight?.controller.abort();
const controller = new AbortController();
this.inFlight = {
key,
controller,
promise: this.catalog.search(query, controller.signal),
};
}
async commitFinal(event: TranscriptEvent): Promise<CommitResult> {
assert.equal(event.kind, "final");
const finalQuery = parseQuery(event.text);
if (!finalQuery) {
this.inFlight?.controller.abort();
throw new Error("確定発話から検索条件を作れませんでした");
}
const finalKey = queryKey(finalQuery);
const prefetched = this.inFlight;
// 確定条件と一致しない投機検索は採用しない
if (!prefetched || prefetched.key !== finalKey) {
prefetched?.controller.abort();
const controller = new AbortController();
const result = await this.catalog.search(finalQuery, controller.signal);
if (result.status !== "completed") {
throw new Error("確定後の検索が完了しませんでした");
}
return {
source: "final-search",
query: finalQuery,
songs: result.songs,
};
}
const result = await prefetched.promise;
// 中断と確定が競合した場合は、確定条件で取り直す
if (result.status === "aborted") {
const controller = new AbortController();
const retried = await this.catalog.search(finalQuery, controller.signal);
if (retried.status !== "completed") {
throw new Error("再検索が完了しませんでした");
}
return {
source: "final-search",
query: finalQuery,
songs: retried.songs,
};
}
return {
source: "prefetched",
query: finalQuery,
songs: result.songs,
};
}
}
async function stableScenario(): Promise<void> {
const catalog = new FakeCatalog();
const lookup = new SpeculativeLookup(catalog);
lookup.observePartial({
turnId: "turn-1",
revision: 1,
kind: "partial",
text: "落ち着いたジャズ",
});
lookup.observePartial({
turnId: "turn-1",
revision: 2,
kind: "partial",
text: "作業用に落ち着いたジャズ",
});
const result = await lookup.commitFinal({
turnId: "turn-1",
revision: 3,
kind: "final",
text: "作業用に落ち着いたジャズを探して",
});
assert.equal(result.source, "prefetched");
assert.deepEqual(result.query, { mood: "calm", genre: "jazz" });
console.log("stable:", result);
}
async function correctionScenario(): Promise<void> {
const catalog = new FakeCatalog();
const lookup = new SpeculativeLookup(catalog);
lookup.observePartial({
turnId: "turn-2",
revision: 1,
kind: "partial",
text: "落ち着いたジャズ",
});
lookup.observePartial({
turnId: "turn-2",
revision: 2,
kind: "partial",
text: "落ち着いたジャズを探して",
});
const result = await lookup.commitFinal({
turnId: "turn-2",
revision: 3,
kind: "final",
text: "落ち着いたジャズではなく、アップテンポの曲を探して",
});
assert.equal(result.source, "final-search");
assert.deepEqual(result.query, { mood: "upbeat", genre: "any" });
assert.ok(catalog.abortedKeys.includes("calm:jazz"));
console.log("correction:", result);
console.log("aborted:", catalog.abortedKeys);
}
await stableScenario();
await correctionScenario();
実行します。
npm run demo
1つ目のシナリオでは、途中認識と確定発話の検索キーが一致するため、先行結果が採用されます。
2つ目では「ジャズではなく、アップテンポ」と訂正されています。calm:jazzの検索は中断され、確定したupbeat:anyで検索し直されます。
手順3:LLMには確定発話と採用済みデータだけを渡す
LLM接続境界では、途中認識の自由文を会話履歴へ混ぜないようにします。
type GenerateRequest = {
requestId: string;
finalTranscript: string;
catalogItems: Song[];
};
interface VoiceAgentModel {
generate(request: GenerateRequest): AsyncIterable<string>;
}
async function buildModelRequest(
event: TranscriptEvent,
lookup: SpeculativeLookup,
): Promise<GenerateRequest> {
if (event.kind !== "final") {
throw new Error("LLM回答は確定発話からだけ生成します");
}
const committed = await lookup.commitFinal(event);
return {
requestId: `${event.turnId}:${event.revision}`,
finalTranscript: event.text,
catalogItems: committed.songs,
};
}
ここでのrequestIdは、同じターンの検索、LLM要求、音声生成をアプリケーション側で関連付けるための値です。個人情報や発話本文をIDへ埋め込まないでください。
Tencent Conversational AIのLLM設定では、OpenAI互換モデルやDify、Cozeなどのエージェントプラットフォームとの接続、およびリクエスト識別子を使ったルーティングや観測について公式資料を参照できます。
実際の設定項目や接続方式は公式ドキュメントに従い、このサンプルのVoiceAgentModelをアダプターとして実装します。ここでは未確認のSDKメソッド名を仮定しません。
手順4:先行検索の効果を区間で測る
「会話が速くなった」と感覚だけで判定すると、誤採用や中断増加を見落とします。最低限、次の時刻とカウンターを記録します。
type TurnTiming = {
turnId: string;
firstPartialAt?: number;
speculativeLookupStartedAt?: number;
finalTranscriptAt?: number;
lookupReadyAt?: number;
llmFirstChunkAt?: number;
firstAudioAt?: number;
speculativeResultAdopted: boolean;
speculativeLookupCanceled: boolean;
};
確認したいのは、単一の平均値ではなく次の関係です。
-
finalTranscriptAt時点で検索が完了していた割合 - 先行検索が確定条件と一致して採用された割合
- 訂正によって中断された割合
- 先行検索を入れる前後の
firstAudioAt - finalTranscriptAt - 検索負荷、エラー率、外部API利用量の変化
途中認識が早くても、候補が頻繁に変わって検索が大量発生するなら改善とは言えません。利用者の待ち時間と、システム側の追加負荷を一緒に見ます。
確認方法:正常系より「言い直し」を増やす
以下を自動テストまたは音声認識イベントのリプレイで確認します。
1. 候補が1回しか現れない
partial: 落ち着いた
final: 落ち着いたジャズを探して
期待結果:連続回数が不足するため投機検索せず、確定後に1回だけ検索する。
2. 条件が安定する
partial: 落ち着いたジャズ
partial: 作業用に落ち着いたジャズ
final: 作業用に落ち着いたジャズを探して
期待結果:calm:jazzの先行結果を採用する。
3. 否定で希望が変わる
partial: 落ち着いたジャズ
partial: 落ち着いたジャズを探して
final: ジャズではなくアップテンポの曲を探して
期待結果:calm:jazzを破棄し、upbeat:anyで検索する。
4. 途中認識が揺れる
partial: ジャズ
partial: ロック
partial: ジャズ
final: ジャズを探して
期待結果:同じキーが連続しないため、途中では検索しない。
5. 確定と中断が競合する
検索完了とfinal到着を同時に近づけます。
期待結果:中断済み結果を採用せず、必要なら確定条件で再検索する。未処理のPromise rejectionを発生させない。
6. ターンが変わる
前のターンの検索完了後に、次のターンのfinalを送ります。
期待結果:turnIdの異なる結果を再利用しない。本番実装ではコーディネーターをターン単位に生成するか、内部状態を明示的にリセットします。
判断フレームワーク:何を途中認識から始めてよいか
処理ごとに次の4問へ答えると、AIへ任せる範囲を決めやすくなります。
-
中断可能か
AbortSignalや同等の仕組みで止められるか。 -
結果を捨てられるか
誤った検索結果が履歴、課金、通知、外部状態へ残らないか。 -
確定後に機械的に照合できるか
LLMの自己評価ではなく、正規化キーやバージョンで一致を確認できるか。 -
余分な実行コストを許容できるか
待ち時間が縮んでも、外部API呼び出しや検索負荷が増えすぎないか。
1つでも満たさない場合は、確定発話まで待つのが安全です。
音声AIで人が残すべき判断は、毎回の検索語を手作業で確認することではありません。どの処理なら誤って先行しても安全か、その境界を決めることです。LLMの性能が上がっても、このプロダクト判断は自動では決まりません。
注意点
投機検索へ個人情報を広げすぎない
途中認識は後から訂正されるだけでなく、利用者が送るつもりのなかった情報を含む可能性があります。外部検索へ渡す値は、許可したスロットへ限定してください。生の発話全文を検索ログへ保存する設計は避けます。
同じ候補の連続回数は固定の正解ではない
この記事では再現しやすさのため2回としました。実際には音声認識イベントの頻度によって意味が変わります。回数だけでなく、候補が維持された時間や検索コストも含めて調整します。
キャッシュの再利用範囲を広げすぎない
価格、在庫、権限など、短時間でも変わる情報は確定時に再検証が必要です。検索キーが一致していても、結果の有効期限や利用者スコープが違えば採用してはいけません。
先行LLM生成は次の段階として扱う
読み取り検索より、LLM生成は誤った文脈を引きずりやすく、計算量も増えます。まず検索のような構造化された処理で採用率と中断率を測り、それから先行生成を検討する方が原因を切り分けやすくなります。
中断できない外部APIでは同時実行数を制限する
クライアント側で結果を無視できても、サーバー側の処理は継続している場合があります。ターンごとの最大投機回数、同時実行数、タイムアウトを設けてください。
まとめ
リアルタイム音声AIで短くしたいのは、単なる処理時間ではなく、利用者が「会話が止まった」と感じる区間です。ただし、途中認識をそのまま実行命令へ変えると、言い直しに弱いエージェントになります。
そこで、途中認識から始める処理を次の条件へ限定します。
- 読み取り専用
- 中断可能
- 結果を破棄可能
- 確定発話と機械的に照合可能
この境界があれば、AIは発話終了前から準備を進められます。一方、回答内容と外部状態を確定する権限は、最後まで確定発話とアプリケーション側のルールに残せます。
関係開示: 筆者はTencent RTCのコンテンツ制作に関係しており、本稿の実装上の参照資料としてTencent RTC公式ドキュメントを使用しました。