音声コンパニオンにMCPツールを接続すると、検索や予約まで一続きの会話にできます。しかし、デモで自然に会話できることと、実運用で安全にツールを実行できることは別問題です。
特に困るのは、次のような場面です。
- ユーザーが「今夜空いている場所は?」と質問する
- AIが検索ツールを呼び出す
- 待っている間にユーザーが「やっぱり明日にして」と割り込む
- 最初の検索結果が遅れて返り、AIが「今夜は3件あります」と読み上げる
MCPでツール接続を共通化しても、この古い結果を話してよいかという判断までは自動的に解決されません。予約や購入のような副作用を伴う処理では、さらに「音声認識された一言だけで確定してよいのか」という不安も残ります。
本記事では、Tencent Conversational AIをリアルタイム音声の経路として使い、MCPツールをアプリケーション側の制御層から呼び出す構成を扱います。例は「候補を検索し、ユーザー確認後に予約するAIコンパニオン」です。
結論
実装上の要点は、MCPツールをLLMへ直結しないことです。間にツール制御層を置き、次の4つをアプリケーションの責任にします。
- 読み取り系と副作用系のツールを区別する
- 各処理へ
turnIdとrequestIdを付ける - 割り込み後に返った古い結果を読み上げない
- 予約などの副作用は、明示確認されるまで実行しない
AIが得意なのは、自然発話から候補ツールと引数を提案する部分です。一方、権限判定、確認期限、重複実行防止、古い結果の破棄は、LLMのプロンプトだけに任せずコードで固定します。
先に決める判断表
MCP対応かどうかだけで採用を決めず、ツールごとに失敗時の影響を分類します。
| 種類 | 例 | 自動実行 | 割り込み時 | 必須対策 |
|---|---|---|---|---|
| 読み取り | 空き状況検索 | 可 | 中断または結果を破棄 | タイムアウト、ターン照合 |
| 低リスク更新 | お気に入り追加 | 条件付き | 実行状態を確認 | 冪等キー、取消導線 |
| 高リスク更新 | 予約、購入、送信 | 不可 | 実行済みか照会 | 明示確認、監査記録、結果表示 |
| 機密情報参照 | 個人予定、連絡先 | 原則不可 | 応答を停止 | 認可、最小開示、同意 |
「AIがツールを選べる」ことは、「AIへ実行権限を渡してよい」ことを意味しません。実行権限はこの表を基にアプリ側で決定します。
前提:音声経路とツール経路を分離する
Tencent Conversational AIは、ユーザーとLLMを使ったリアルタイム音声インタラクションを構成するためのサービスです。対応する全体像は公式ドキュメントで確認できます。
LLM設定の公式ドキュメントでは、OpenAI互換モデルやDify、Cozeなどのエージェントプラットフォームとの接続、およびリクエスト識別子を利用したルーティング・観測について説明されています。具体的な設定項目は利用時点の公式画面とドキュメントを参照してください。
本記事では責任範囲を次のように分けます。
ユーザー音声
│
▼
RTC/音声対話層
│ 確定テキスト、割り込みイベント
▼
会話オーケストレーター
├── LLM: 応答文またはツール呼び出し候補を生成
├── ポリシー: ツール権限と確認要否を判定
├── MCP Gateway: 外部ツールを呼び出す
└── 監査ログ: requestId、turnId、結果を保存
│
▼
読み上げ要求/画面上の確認UI
音声認識、LLM、MCPサーバー、音声合成を一つの処理として扱わないのが重要です。どこで待ち時間や失敗が発生したかを分離して記録できるからです。
なお、AIコンパニオンやキャラクター対話を含む利用シナリオは、Social Entertainment solutionでも確認できます。
データモデル:会話ターンと実行権限を別に持つ
最小構成では、以下の4種類を保存します。
type ToolMode = "read" | "write";
type ToolRequestStatus =
| "proposed"
| "waiting_confirmation"
| "running"
| "succeeded"
| "failed"
| "stale"
| "rejected";
interface Turn {
turnId: number;
sessionId: string;
transcript: string;
interrupted: boolean;
startedAt: string;
}
interface ToolPolicy {
toolName: string;
mode: ToolMode;
requiresConfirmation: boolean;
timeoutMs: number;
}
interface ToolRequest {
requestId: string;
sessionId: string;
originTurnId: number;
toolName: string;
arguments: Record<string, unknown>;
status: ToolRequestStatus;
idempotencyKey?: string;
expiresAt?: string;
}
originTurnIdは、ツール結果がどの発話から生まれたかを表します。現在のターンと一致しない結果は、ログや画面更新には使えても、自動読み上げには使いません。
idempotencyKeyは副作用系ツールの重複実行を防ぐための値です。MCPサーバー側が冪等性を扱えない場合は、Gateway側に実行済みキーを保存します。
手順
1. 検証用プロジェクトを作る
Node.js 20以降を前提にします。
mkdir voice-mcp-guard
cd voice-mcp-guard
npm init -y
npm install -D typescript tsx @types/node
mkdir src
package.jsonへ実行コマンドを追加します。
{
"scripts": {
"start": "tsx src/demo.ts"
}
}
このサンプルでは特定のMCP SDKやTencent RTC SDKのAPI名を固定しません。MCP Gatewayと音声出力をインターフェースとして分離し、採用するSDKの公式手順に合わせて差し替えます。
2. LLMの出力を「発話」か「ツール提案」に限定する
LLMへ任せるのは、自然言語から構造化された候補を作るところまでです。
type ModelDecision =
| {
kind: "speak";
text: string;
}
| {
kind: "tool";
toolName: string;
arguments: Record<string, unknown>;
reason: string;
};
実運用では、LLM設定のシステムプロンプトへ次の制約を入れます。
あなたは音声コンパニオンの計画担当です。
- ツールの実行可否を自分で決定しない
- 予約、購入、送信を完了したと発言しない
- 利用可能なツール名以外を出力しない
- 引数が不足している場合はツールを提案せず、確認質問を返す
- 出力は指定されたJSON形式に限定する
ただし、これは安全機構ではなく、出力品質を整える補助です。プロンプトに違反した出力が来る前提で、次のコード側でも検査します。
コード:古い検索結果と無確認予約を止める
src/demo.tsを作成します。
import { randomUUID } from "node:crypto";
interface ToolPolicy {
toolName: string;
mode: "read" | "write";
requiresConfirmation: boolean;
timeoutMs: number;
}
type ModelDecision =
| { kind: "speak"; text: string }
| {
kind: "tool";
toolName: string;
arguments: Record<string, unknown>;
reason: string;
};
interface McpGateway {
callTool(input: {
requestId: string;
toolName: string;
arguments: Record<string, unknown>;
idempotencyKey?: string;
signal: AbortSignal;
}): Promise<Record<string, unknown>>;
}
interface VoiceOutput {
speak(text: string, turnId: number): Promise<void>;
stop(): Promise<void>;
}
interface PendingAction {
actionId: string;
requestId: string;
originTurnId: number;
toolName: string;
arguments: Record<string, unknown>;
expiresAt: number;
}
const policies = new Map<string, ToolPolicy>([
[
"search_availability",
{
toolName: "search_availability",
mode: "read",
requiresConfirmation: false,
timeoutMs: 3_000,
},
],
[
"create_reservation",
{
toolName: "create_reservation",
mode: "write",
requiresConfirmation: true,
timeoutMs: 5_000,
},
],
]);
class ConversationOrchestrator {
private currentTurnId = 0;
private activeRead?: AbortController;
private pending = new Map<string, PendingAction>();
private executedKeys = new Set<string>();
constructor(
private readonly mcp: McpGateway,
private readonly voice: VoiceOutput,
) {}
async beginTurn(transcript: string): Promise<void> {
await this.interrupt();
const turnId = ++this.currentTurnId;
this.log("turn.started", { turnId, transcript });
const decision = this.plan(transcript);
if (decision.kind === "speak") {
await this.speakIfCurrent(decision.text, turnId);
return;
}
const policy = policies.get(decision.toolName);
if (!policy) {
await this.speakIfCurrent(
"その操作は現在利用できません。別の方法を案内します。",
turnId,
);
return;
}
if (policy.mode === "write" || policy.requiresConfirmation) {
const actionId = randomUUID();
const action: PendingAction = {
actionId,
requestId: randomUUID(),
originTurnId: turnId,
toolName: decision.toolName,
arguments: decision.arguments,
expiresAt: Date.now() + 60_000,
};
this.pending.set(actionId, action);
this.log("tool.waiting_confirmation", action);
await this.speakIfCurrent(
`実行前の確認が必要です。画面に表示した内容を確認してください。確認番号は${actionId.slice(0, 6)}です。`,
turnId,
);
return;
}
await this.runReadTool(turnId, decision, policy);
}
async interrupt(): Promise<void> {
this.activeRead?.abort();
this.activeRead = undefined;
await this.voice.stop();
}
async confirmAction(actionId: string): Promise<void> {
const action = this.pending.get(actionId);
if (!action) {
throw new Error("確認対象が存在しません");
}
if (Date.now() > action.expiresAt) {
this.pending.delete(actionId);
this.log("tool.confirmation_expired", { actionId });
throw new Error("確認期限が切れています");
}
const idempotencyKey = `action:${action.actionId}`;
if (this.executedKeys.has(idempotencyKey)) {
this.log("tool.duplicate_blocked", { actionId });
return;
}
this.executedKeys.add(idempotencyKey);
this.pending.delete(actionId);
const policy = policies.get(action.toolName);
if (!policy) throw new Error("ポリシーがありません");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), policy.timeoutMs);
this.log("tool.started", {
actionId,
requestId: action.requestId,
toolName: action.toolName,
});
try {
const result = await this.mcp.callTool({
requestId: action.requestId,
toolName: action.toolName,
arguments: action.arguments,
idempotencyKey,
signal: controller.signal,
});
// 副作用は会話ターンが変わっても消えないため、必ず記録する。
this.log("tool.succeeded", {
actionId,
requestId: action.requestId,
result,
});
// 結果はまず画面や履歴へ表示する。
// 現在のターンと一致する場合だけ補助的に読み上げる。
if (action.originTurnId === this.currentTurnId) {
await this.voice.speak("予約処理が完了しました。詳細は画面で確認できます。", this.currentTurnId);
}
} catch (error) {
this.log("tool.failed", {
actionId,
requestId: action.requestId,
error: String(error),
});
throw error;
} finally {
clearTimeout(timer);
}
}
private async runReadTool(
turnId: number,
decision: Extract<ModelDecision, { kind: "tool" }>,
policy: ToolPolicy,
): Promise<void> {
const requestId = randomUUID();
const controller = new AbortController();
this.activeRead = controller;
const timer = setTimeout(() => controller.abort(), policy.timeoutMs);
const startedAt = performance.now();
this.log("tool.started", {
requestId,
turnId,
toolName: decision.toolName,
});
try {
const result = await this.mcp.callTool({
requestId,
toolName: decision.toolName,
arguments: decision.arguments,
signal: controller.signal,
});
const elapsedMs = Math.round(performance.now() - startedAt);
if (turnId !== this.currentTurnId) {
this.log("tool.stale", { requestId, turnId, elapsedMs });
return;
}
this.log("tool.succeeded", { requestId, turnId, elapsedMs, result });
await this.voice.speak(`検索結果は${JSON.stringify(result)}です。`, turnId);
} catch (error) {
const aborted = controller.signal.aborted;
this.log(aborted ? "tool.aborted" : "tool.failed", {
requestId,
turnId,
error: String(error),
});
if (!aborted && turnId === this.currentTurnId) {
await this.voice.speak(
"検索を完了できませんでした。条件を変えてもう一度試せます。",
turnId,
);
}
} finally {
clearTimeout(timer);
if (this.activeRead === controller) this.activeRead = undefined;
}
}
private async speakIfCurrent(text: string, turnId: number): Promise<void> {
if (turnId !== this.currentTurnId) return;
await this.voice.speak(text, turnId);
}
private plan(transcript: string): ModelDecision {
// 再現用の固定プランナー。実運用では構造化出力するLLMへ差し替える。
if (transcript.includes("空き")) {
return {
kind: "tool",
toolName: "search_availability",
arguments: {
date: transcript.includes("明日") ? "tomorrow" : "today",
},
reason: "空き状況の検索",
};
}
if (transcript.includes("予約")) {
return {
kind: "tool",
toolName: "create_reservation",
arguments: { slotId: "slot-demo-01" },
reason: "予約候補の作成",
};
}
return { kind: "speak", text: "空き状況の検索や予約を手伝えます。" };
}
private log(event: string, data: unknown): void {
console.log(JSON.stringify({
at: new Date().toISOString(),
event,
data,
}));
}
}
class FakeMcpGateway implements McpGateway {
async callTool(input: {
requestId: string;
toolName: string;
arguments: Record<string, unknown>;
idempotencyKey?: string;
signal: AbortSignal;
}): Promise<Record<string, unknown>> {
await wait(input.toolName === "search_availability" ? 1_500 : 500, input.signal);
if (input.toolName === "search_availability") {
return {
date: input.arguments.date,
availableSlots: ["19:00", "20:30"],
};
}
if (input.toolName === "create_reservation") {
return {
reservationId: "reservation-demo-01",
status: "confirmed",
};
}
throw new Error("unknown tool");
}
}
class ConsoleVoice implements VoiceOutput {
async speak(text: string, turnId: number): Promise<void> {
console.log(`[voice turn=${turnId}] ${text}`);
}
async stop(): Promise<void> {
console.log("[voice stopped]");
}
}
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new Error("aborted"));
},
{ once: true },
);
});
}
async function main(): Promise<void> {
const app = new ConversationOrchestrator(
new FakeMcpGateway(),
new ConsoleVoice(),
);
// 最初の検索が返る前に、条件を変えて割り込む。
const first = app.beginTurn("今夜の空きを調べて");
await new Promise((resolve) => setTimeout(resolve, 200));
const second = app.beginTurn("やっぱり明日の空きを調べて");
await Promise.allSettled([first, second]);
// 予約は、この時点では実行されず確認待ちになる。
await app.beginTurn("19時で予約して");
}
main().catch(console.error);
実行します。
npm start
最初の「今夜」の検索は中断され、「明日」の結果だけが読み上げられます。また、予約ツールはwaiting_confirmationになり、confirmActionが呼ばれるまで実行されません。
Tencent Conversational AIへ接続する位置
上のサンプルから実サービスへ移す際は、次の2か所を差し替えます。
- 確定したユーザー発話を
beginTurn()へ渡す -
VoiceOutputのspeak()とstop()を、採用した音声対話経路へ接続する
LLMはplan()の代わりに利用しますが、モデル出力を直接MCP Gatewayへ通してはいけません。JSONスキーマ検証、ツール名の許可リスト、引数検証を通した後にToolPolicyへ照合します。
requestIdは、LLM要求、ツール要求、読み上げ要求のログを関連付けるために保持します。ただし、同じ識別子を外部へ無条件に転送せず、個人情報を含まない内部IDとして設計してください。
確認方法
1. 割り込み後に古い検索結果を話さない
サンプルを実行し、次を確認します。
- 最初の検索に
tool.abortedが記録される - 「今夜」の検索結果が
[voice]へ出ない - 「明日」の検索結果だけが読み上げられる
実際のMCPサーバーが中断要求を処理できない場合でも、turnId !== currentTurnIdの照合によって古い結果の読み上げを止められるようにします。
2. 予約が自動実行されない
19時で予約しての後に、次を確認します。
-
tool.waiting_confirmationが記録される -
create_reservationはまだ呼ばれていない - 確認画面に対象日時、対象名、取消可否を表示できる
音声だけで確認する場合も、「はい」という短い応答だけに依存するのは避けます。別の会話への相づちと区別できないためです。高リスク操作では、画面ボタン、再認証、具体的な内容の復唱などを組み合わせます。
3. 同じ確認操作を二度押しても重複しない
同じactionIdでconfirmAction()を二度呼び、2回目が実行されないことを確認します。
本番ではプロセスメモリ上のSetではなく、データベースの一意制約などを利用します。アプリの再起動や複数インスタンスでも重複を防げる必要があります。
4. 段階別の待ち時間を記録する
少なくとも以下を別々に計測します。
- 発話終了からLLM判断まで
- MCPツール呼び出し開始から完了まで
- 結果確定から読み上げ開始まで
- 割り込み検出から読み上げ停止まで
一つの「会話レイテンシ」だけでは、改善すべき場所を特定できません。目標値は端末、ネットワーク、接続先モデル、ツールの性質で変わるため、固定の万能値ではなく実測分布と失敗率から決めます。
失敗ケースと復旧方針
| 失敗 | ユーザーへの扱い | 内部処理 |
|---|---|---|
| LLMが未知のツール名を返す | 実行できないと案内 | 許可リストで拒否 |
| 読み取りツールがタイムアウト | 再試行または条件変更を提案 | 中断し、失敗理由を記録 |
| 割り込み後に結果が返る | 読み上げない |
staleとして保存可能 |
| 確認期限が切れる | 再確認を求める | 保留操作を破棄 |
| 更新結果が不明 | 完了と言い切らない | 状態照会または人間確認 |
| TTSだけ失敗する | 画面へ結果を残す | ツール成功を取り消さない |
特に注意すべきなのは、「音声が止まったから処理も止まった」とみなさないことです。予約要求が外部システムへ到達した後では、音声の割り込みで取り消せない場合があります。副作用系処理の結果は、会話状態とは別に保存し、ユーザーが後から確認できるようにします。
注意点
MCP導入だけでは解決しないこと
MCPはLLMアプリケーションと外部機能の接続面を整理するのに役立ちますが、次の判断はアプリケーション側に残ります。
- そのユーザーにツール実行権限があるか
- どの引数を外部システムへ送ってよいか
- 音声認識結果を本人の確定意思として扱えるか
- 副作用が途中まで進んだとき、どう復旧するか
- 会話ログやツール引数をどれだけ保存するか
したがって、MCP対応サーバーを追加するたびに、機能一覧だけでなく、認可、データ送信先、保持期間、タイムアウト、冪等性、取消方法をレビューします。
AIに任せる範囲を言語化する
この構成でAIが実証できる能力は、発話内容から検索条件やツール候補を構造化し、自然な確認文を作ることです。一方、「ユーザーが本当に予約を望んでいる」「この操作は安全」と最終判断できるわけではありません。
音声AIに対するユーザーの不安は、回答精度だけでなく、何がすでに実行され、何がまだ候補なのか分からないことから生まれます。確認待ち、実行中、完了、失敗を画面と音声の両方で区別すると、主導権をユーザーへ戻せます。
プライバシーと安全性
AIコンパニオンでは、個人予定、位置情報、連絡先などがツール引数へ入り得ます。必要なフィールドだけを送信し、ログではマスキングしてください。また、録音・文字起こし・外部ツール送信の範囲をユーザーへ明示し、停止や削除の操作を用意します。
実装チェックリスト
- RTC、音声認識、LLM、MCP、音声合成のログを分離した
- 全ツールを読み取り系と副作用系に分類した
- 許可されていないツール名をコードで拒否する
-
ツール結果へ
turnIdとrequestIdを付けた - 割り込み後の古い結果を読み上げない
- 副作用系ツールは明示確認まで実行しない
- 更新処理へ冪等キーを付けた
- タイムアウト時に成功と言い切らない
- 実行結果を音声以外でも確認できる
- 個人情報をツール引数とログで最小化した
MCPを音声AIへ組み込むときの設計単位は、「LLMがツールを呼べたか」ではありません。ユーザーが途中で考えを変えても、古い結果や未確認の操作が会話を追い越さないかを単位にすると、説得力のあるデモから信頼できる実装へ進めます。
関係性の開示:筆者はTencent RTCに関係する立場で本記事を作成しています。実装上の事実確認には、Tencent RTCの公式ドキュメントを参照しました。