音声AIに「照明を消して」と頼んだ直後、「やっぱり待って」と言い直したのに操作だけ実行される——。これはLLMの回答品質より、誰がいつまで実行権を持つかの問題です。
GeminiなどのLLMは、発話から操作候補を構造化する用途には使えます。しかし、生成結果が自然だからといって、ユーザーの意思が確定したとは限りません。特にリアルタイム音声では、LLMの処理中、確認音声の再生中、外部APIの呼び出し直前にユーザーが割り込めます。
この記事では、Tencent Conversational AIを音声経路として利用する構成を前提に、次の制御をTypeScriptで再現します。
- LLMは操作を実行せず、候補だけを返す
- 状態変更を伴う操作は明示確認を要求する
- 確認後に短命な実行許可を発行する
- 新しい発話が始まったら、未使用の実行許可を失効させる
- 同じ操作が再送されても一度だけ実行する
結論:LLMの提案と外部操作の間に「短命な実行許可」を置く
音声コンパニオンを安全にする境界は、プロンプトだけでは作れません。次の4段階を別々に扱います。
ユーザー音声
↓
音声認識結果
↓
LLMが操作候補を生成
↓
アプリが確認・期限・割り込みを判定
↓
外部デバイスAPIを実行
LLMに任せるのは、発話を次のような候補へ変換するところまでです。
{
"action": "set_light_power",
"args": { "room": "living", "power": "off" },
"explanation": "リビングの照明を消す"
}
一方、以下はアプリ側の決定事項にします。
- その操作が許可リストに含まれるか
- 確認が必要か
- 確認の有効期限内か
- 確認後に別の発話が始まっていないか
- 同じ操作をすでに実行していないか
「LLMが意図を理解した」ことと「ユーザーが今も実行を望んでいる」ことを分けるのがポイントです。
前提:RTC、音声処理、LLM、実行制御を分離する
Tencent Conversational AIは、リアルタイム音声対話とLLMを組み合わせるための構成を提供しています。全体像は公式ドキュメントで確認できます。
公式のLLM設定ドキュメントでは、OpenAI互換モデルやエージェントプラットフォームとの接続、リクエスト識別子を利用したルーティングや観測の考え方が説明されています。
Geminiを利用する場合は、アプリのバックエンドからGeminiへ接続する方法と、OpenAI互換のアダプターを用意して接続する方法を選べます。本記事の制御ロジックは、どちらの接続方法でもLLMと外部操作の間に配置できます。
責任分界は次のようにします。
| レイヤー | 責任 |
|---|---|
| RTC/音声経路 | 音声の送受信 |
| 音声認識 | 発話開始、途中結果、確定テキストの通知 |
| Gemini | 確定テキストから操作候補を生成 |
| 会話コントローラー | 確認、割り込み、期限、実行許可の管理 |
| TTS | 確認文や結果の読み上げ |
| デバイスゲートウェイ | 認可済み操作を一度だけ外部APIへ送る |
Tencent RTCのSDK固有イベント名は利用環境によって異なるため、以下のコードではonSpeechStartとonFinalTranscriptというアプリ共通イベントへ変換して扱います。
先に決める操作ポリシー
すべての発話を同じように確認すると会話が遅くなります。一方、すべてを即時実行すると取り消しが間に合いません。
今回は次の基準にします。
| 操作 | 例 | 確認 |
|---|---|---|
| 読み取り | 部屋の状態を教えて | 不要 |
| 状態変更 | 照明を消して | 必須 |
| 未対応・高リスク | 解錠、購入など | 実行しない |
確認語もLLMに自由判定させません。検証用実装では、実行してまたは実行してくださいだけを承認として扱います。単独の「はい」は、相槌や別の質問への返答と区別しづらいため承認に使いません。
手順1:検証環境を作る
Node.js 20以降を前提にします。
mkdir voice-command-lease
cd voice-command-lease
npm init -y
npm install -D typescript tsx @types/node
mkdir src test
実行には次のコマンドを使います。
npx tsx src/demo.ts
npx tsx --test test/controller.test.ts
手順2:操作候補と実行許可を別のデータにする
重要なのは、LLMの出力をそのまま実行可能なオブジェクトにしないことです。
// src/controller.ts
import { randomUUID } from 'node:crypto';
export type Candidate = {
action: string;
args: Record<string, unknown>;
explanation: string;
};
export type Proposal = {
proposalId: string;
requestId: string;
revision: number;
action: 'get_room_status' | 'set_light_power';
args: Record<string, unknown>;
explanation: string;
expiresAt: number;
status: 'awaiting_confirmation' | 'approved' | 'cancelled' | 'executed';
};
export type Permit = {
permitId: string;
proposalId: string;
revision: number;
expiresAt: number;
revoked: boolean;
used: boolean;
};
export interface Planner {
propose(text: string, requestId: string): Promise<Candidate>;
}
export interface Player {
speak(text: string): Promise<void>;
stop(): Promise<void>;
}
export interface DeviceGateway {
dispatch(
proposal: Proposal,
idempotencyKey: string,
canDispatch: () => boolean
): Promise<void>;
}
Proposalはあくまで候補です。外部APIを呼べるのは、アプリが発行したPermitが有効な場合だけです。
また、requestIdはLLM呼び出しと操作候補を対応付けるために保持します。Tencent Conversational AI側の観測情報と関連付ける場合も、音声セッション全体のIDだけでなく、個々の要求を区別できるIDを残します。
コード:割り込みで実行許可を失効させる
次が会話制御の本体です。
// src/controller.ts の続き
const CONFIRM_WORDS = new Set(['実行して', '実行してください']);
const CANCEL_WORDS = new Set(['やめて', 'キャンセル', '取り消して', '待って']);
function normalize(text: string): string {
return text.trim().replace(/[。、!!??\s]/g, '');
}
function toProposal(
candidate: Candidate,
requestId: string,
revision: number,
leaseMs: number
): Proposal | null {
const expiresAt = Date.now() + leaseMs;
if (candidate.action === 'get_room_status') {
if (typeof candidate.args.room !== 'string') return null;
return {
proposalId: randomUUID(),
requestId,
revision,
action: 'get_room_status',
args: { room: candidate.args.room },
explanation: candidate.explanation,
expiresAt,
status: 'approved'
};
}
if (candidate.action === 'set_light_power') {
const room = candidate.args.room;
const power = candidate.args.power;
if (typeof room !== 'string') return null;
if (power !== 'on' && power !== 'off') return null;
return {
proposalId: randomUUID(),
requestId,
revision,
action: 'set_light_power',
args: { room, power },
explanation: candidate.explanation,
expiresAt,
status: 'awaiting_confirmation'
};
}
return null;
}
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
export class VoiceCommandController {
private revision = 0;
private pending: Proposal | null = null;
private permit: Permit | null = null;
constructor(
private readonly planner: Planner,
private readonly player: Player,
private readonly gateway: DeviceGateway,
private readonly leaseMs = 15_000,
private readonly commitWindowMs = 400
) {}
snapshot() {
return {
revision: this.revision,
pending: this.pending ? { ...this.pending } : null,
permit: this.permit ? { ...this.permit } : null
};
}
async onSpeechStart(): Promise<void> {
this.revision += 1;
await this.player.stop();
// 確認済みでも、外部送信前なら新しい発話で失効させる
if (this.permit && !this.permit.used) {
this.permit.revoked = true;
}
}
async onFinalTranscript(text: string): Promise<void> {
const normalized = normalize(text);
if (this.pending?.status === 'awaiting_confirmation') {
if (CANCEL_WORDS.has(normalized)) {
this.pending.status = 'cancelled';
await this.player.speak('操作を取り消しました');
return;
}
if (CONFIRM_WORDS.has(normalized)) {
await this.approveAndExecute(this.pending);
return;
}
// 訂正や別の依頼なら、古い候補を破棄して新しいターンとして扱う
this.pending.status = 'cancelled';
this.pending = null;
}
await this.createProposal(text);
}
private async createProposal(text: string): Promise<void> {
const capturedRevision = this.revision;
const requestId = randomUUID();
const candidate = await this.planner.propose(text, requestId);
// LLMの処理中に別の発話が始まっていたら、遅れて届いた結果を捨てる
if (capturedRevision !== this.revision) return;
const proposal = toProposal(
candidate,
requestId,
capturedRevision,
this.leaseMs
);
if (!proposal) {
await this.player.speak('その操作は実行できません');
return;
}
this.pending = proposal;
if (proposal.status === 'approved') {
await this.executeReadOnly(proposal);
return;
}
await this.player.speak(
`${proposal.explanation}、でよければ「実行して」と言ってください`
);
}
private async approveAndExecute(proposal: Proposal): Promise<void> {
if (Date.now() > proposal.expiresAt) {
proposal.status = 'cancelled';
await this.player.speak('確認の有効期限が切れました。もう一度お願いします');
return;
}
proposal.status = 'approved';
const permit: Permit = {
permitId: randomUUID(),
proposalId: proposal.proposalId,
revision: this.revision,
expiresAt: proposal.expiresAt,
revoked: false,
used: false
};
this.permit = permit;
// 「実行して、いや待って」の後半を受け付ける短い猶予
await sleep(this.commitWindowMs);
const canDispatch = () =>
!permit.revoked &&
!permit.used &&
permit.revision === this.revision &&
Date.now() <= permit.expiresAt;
if (!canDispatch()) {
proposal.status = 'cancelled';
await this.player.speak('操作を取り消しました');
return;
}
await this.gateway.dispatch(proposal, permit.permitId, canDispatch);
permit.used = true;
proposal.status = 'executed';
await this.player.speak('実行しました');
}
private async executeReadOnly(proposal: Proposal): Promise<void> {
await this.gateway.dispatch(
proposal,
proposal.proposalId,
() => proposal.revision === this.revision
);
proposal.status = 'executed';
}
}
ここでの400msと15秒は製品性能値ではなく、検証用のポリシー例です。実運用では会話ログから確認後の言い直し頻度や操作待ち時間を測り、操作種別ごとに調整します。
手順3:Geminiを操作候補の生成器として接続する
Gemini側には自由文ではなく、許可されたJSONだけを要求します。ただし、JSONになっていること自体は安全性を保証しません。前段のtoProposalでアクション名と引数を再検証します。
APIキーはクライアントアプリへ埋め込まず、バックエンドの環境変数から読み込みます。
// src/gemini-planner.ts
import type { Candidate, Planner } from './controller.js';
export class GeminiPlanner implements Planner {
constructor(
private readonly apiKey: string,
private readonly model: string
) {}
async propose(text: string, requestId: string): Promise<Candidate> {
const url = new URL(
`https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent`
);
url.searchParams.set('key', this.apiKey);
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-request-id': requestId
},
body: JSON.stringify({
systemInstruction: {
parts: [{
text: [
'あなたは操作候補をJSONで返すプランナーです。',
'許可されたactionはget_room_statusとset_light_powerだけです。',
'推測で部屋名や電源状態を補完しないでください。',
'JSON以外を出力しないでください。'
].join('\n')
}]
},
contents: [{ role: 'user', parts: [{ text }] }],
generationConfig: {
responseMimeType: 'application/json'
}
})
});
if (!response.ok) {
throw new Error(`Gemini request failed: ${response.status}`);
}
const data = await response.json() as {
candidates?: Array<{
content?: { parts?: Array<{ text?: string }> };
}>;
};
const raw = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!raw) throw new Error('Gemini returned no candidate');
const parsed = JSON.parse(raw) as Partial<Candidate>;
if (
typeof parsed.action !== 'string' ||
typeof parsed.explanation !== 'string' ||
!parsed.args ||
typeof parsed.args !== 'object'
) {
throw new Error('Invalid candidate shape');
}
return parsed as Candidate;
}
}
モデル名は固定せず、利用環境で確認した値を環境変数から渡します。
export GEMINI_API_KEY='...'
export GEMINI_MODEL='利用するモデル名'
LLMがJSONを返せることは、操作候補を機械処理しやすくする能力です。一方で、次の判断までLLMへ委ねる根拠にはなりません。
- 「はい」が本当に操作承認なのか
- 発話後にユーザーが考えを変えていないか
- 外部APIを再試行してよいか
- 失敗時に別の操作へ置き換えてよいか
これらは会話の自然さではなく、アプリの責任とユーザーの不安に関わる判断です。
手順4:外部操作を冪等にする
割り込み制御が正しくても、タイムアウト後の再試行で同じ操作が二度送られる可能性があります。ゲートウェイ側でも実行済みキーを記録します。
// src/mock-gateway.ts
import type { DeviceGateway, Proposal } from './controller.js';
export class MockGateway implements DeviceGateway {
readonly calls: string[] = [];
private readonly consumed = new Set<string>();
async dispatch(
proposal: Proposal,
idempotencyKey: string,
canDispatch: () => boolean
): Promise<void> {
if (!canDispatch()) throw new Error('permit revoked before dispatch');
if (this.consumed.has(idempotencyKey)) return;
this.consumed.add(idempotencyKey);
this.calls.push(`${proposal.action}:${JSON.stringify(proposal.args)}`);
}
}
実際のデバイスAPIが冪等キーを受け付けるなら、permitIdをその境界まで渡します。受け付けない場合は、アプリ側の永続ストレージで消費済みキーを管理します。プロセス内のSetだけでは、再起動後の重複を防げません。
確認方法:回答文ではなく実行回数を検証する
最低限、次のケースを自動テストします。
1. LLM処理中に新しい発話が始まる
「照明を消して」
↓ Gemini処理中
「やっぱり待って」
↓
古いGemini結果は破棄され、外部APIは0回
確認点は回答内容ではなく、revisionが変わった後の候補がpendingにならないことです。
2. 確認せずに「やめて」と言う
「照明を消して」
AI「実行して、と言ってください」
「やめて」
期待結果は次の通りです。
- 提案状態が
cancelled -
Permitが発行されない - 外部APIは0回
3. 「実行して」の直後に「待って」と割り込む
commitWindowMs内にonSpeechStart()を呼びます。
期待結果は次の通りです。
- 発行済み
Permitがrevoked: trueになる -
gateway.dispatchが呼ばれない - 操作は
cancelledになる
4. 同じ実行許可を再送する
同じpermitIdでゲートウェイを二度呼びます。
期待結果は、calls.length === 1です。
5. Geminiが未許可アクションを返す
たとえばunlock_doorを返した場合、プロンプトの指示に従っているかどうかに関係なくtoProposalがnullを返すことを確認します。
RTC統合後の確認チェックリスト
- 発話開始イベントで再生中のTTSを停止できる
- LLM処理中の割り込みで古い候補が破棄される
- STTの途中結果だけでは実行候補を作らない
- 「はい」だけで状態変更が実行されない
- 確認期限切れ後に古い提案を承認できない
- 外部API送信後のキャンセル不能状態をUIと音声で隠さない
- LLM障害時にデバイス操作へフォールバックしない
- requestId、proposalId、permitIdを秘密情報なしで追跡できる
Tencent Conversational AIへ組み込む位置
このコントローラーは、音声認識の確定結果とLLMの間ではなく、LLMの候補と外部操作の間に置きます。
Tencent Conversational AI
├─ 音声入力/音声出力
├─ 音声認識結果
└─ LLM接続
↓ Candidate
VoiceCommandController
├─ 許可リスト検査
├─ 読み上げ確認
├─ 割り込みによる失効
└─ 短命Permit発行
↓
DeviceGateway
AIコンパニオンやキャラクター対話を含むソーシャル用途の位置付けは、Social Entertainment solutionでも確認できます。ただし、会話体験が自然であることと、外部操作を自律実行してよいことは別問題です。
注意点とトレードオフ
確認を増やすほど安全だが、会話は遅くなる
すべての操作へ確認を入れると、状態参照まで冗長になります。読み取り、可逆な変更、不可逆な変更でポリシーを分ける方が実用的です。
外部API送信後は、音声割り込みだけでは取り消せない
fetchなどで要求を送信した後、ローカルのPermitを失効させても相手側の処理は止まりません。取り消しが必要な操作は、外部システム側にも予約、取消API、トランザクションなどが必要です。
LLMへ確認判定を任せると境界が曖昧になる
「お願いします」「それで」「うん」などを柔軟に理解させると便利ですが、誤承認の条件も増えます。最初は限定した確認語から始め、実際に拒否された表現を観測して拡張する方が追跡しやすくなります。
音声ログをそのまま保存しない
観測にはID、状態遷移、処理時間、失敗理由を残し、音声や文字起こしの保存は目的、同意、保持期間を別途決めます。コンパニオン用途では、利用者がマイク、履歴、外部操作を停止できるUIも必要です。
まとめ
リアルタイム音声AIで本当に難しいのは、LLMが操作候補を作れるかではありません。ユーザーが言い直した瞬間に、まだ止められる設計になっているかです。
実装上は次の順序が有効です。
- LLMから外部APIの資格情報を取り上げる
- 操作候補を許可リストで再検証する
- 状態変更だけ明示確認する
- 確認後に短命な実行許可を発行する
- 新しい発話で未使用の許可を失効させる
- 外部API境界でも重複実行を防ぐ
これにより、Geminiの言語理解能力を利用しつつ、実行の最終決定を人とアプリ側へ残せます。
関係性の開示: 筆者はTencent RTCに関係する立場で本記事を作成しています。実装上の製品確認にはTencent RTCの公式ドキュメントを参照しました。