音声AIやチャットボットを組み込んだアプリを開発しているエンジニアに向けた話です。会話中にAIが勝手にアドバイザーぶったり、聞かれてもいない提案を始めたりして困った経験がある方の参考になれば幸いです。
AIコンパニオン系のプロダクトには共通の悩みがあります。ユーザーは毎回の発言を承認したいわけではありません。同時に、AIが勝手に「コーチ」や「批評家」に変身するのも望んでいません。この矛盾をどう解くかが設計の肝になります。
よくある対処法は、システムプロンプト(AIに与える事前指示文)を長くして、振る舞いのルールを細かく書き込むことです。これは一定の効果はありますが、制御の仕組みとしては不十分です。プロンプトはあくまで確率的な指示であり、会話履歴が長くなるとAIの振る舞いがそこから逸れていく可能性があります。さらに、ユーザーがモードを切り替えた直後に、古い指示のもとで生成された返答が届いてしまうタイミングのずれも起きます。
何が変わるのか。ユーザーが操作できる「モード」を作る
この問題への解決策として提示されているのが、ユーザーが選択できる明示的な動作モードです。具体的には次の3種類が想定されています。
- Listen(聞く): 相槌や要約のみ。解決策は出さない
- Explore(探る): 質問を重ねて選択肢を提示する
- Advise(助言する): 提案はするが決定権はユーザーに残す
ここで重要なのは、言葉を生成するのはLLM(大規模言語モデル)でも、モードの管理・応答の検証・古い処理の破棄・停止手段の提供はアプリケーション側が持つという役割分担です。つまりAIに「聞く役をやって」と指示するだけでなく、アプリのコード側で「聞く役以外の返答は弾く」という強制力を持たせる発想です。
アーキテクチャを段階的に見る
処理の流れは次のように整理できます。マイクからの音声はRTC(Real-Time Communication、リアルタイム通信)基盤を経て音声認識に渡り、文字起こし結果がアプリのセッションコントローラーに届きます。
セッションコントローラーは、ユーザーが選んだモード・プロンプトの版・会話のターン状態・タイムアウト時の復旧動作を管理します。ここがLLMにリクエストを送り、返ってきた構造化された応答案をポリシーゲート(許可されたパターンかを検証する層)でチェックしてから、音声合成に渡して発話させます。
この設計で使われているRTC基盤の例として、Tencent RTCのConversational AI機能が紹介されています。これはOpenAI互換モデルやDify、Cozeといったエージェント基盤と接続できるリアルタイム音声対話の仕組みです。ただしRTCが担うのは音声の伝送だけで、同意管理・セッション状態・プロンプト構築・モデレーション・復旧処理はあくまでアプリケーション側の責任範囲だという線引きが明確にされています。
この切り分けは日本のエンジニアにも馴染みやすい発想です。たとえばWebSocketで音声ストリームを流す構成でも、通信レイヤーとビジネスロジックのレイヤーを分けるのは基本原則です。RTC SDKに「賢い制御」まで期待せず、あくまるパイプの役割に留めるという考え方は、マイクロサービス設計での「単一責任の原則」に近いものがあります。
実装の具体例。TypeScriptで型と検証を書く
実際にNode.js環境で試すには、以下のように環境を作ります。
mkdir controlled-voice-companion
cd controlled-voice-companion
npm init -y
npm install zod
npm install --save-dev typescript tsx vitest @types/node
npx tsc --initここでzod(TypeScript用のスキーマ検証ライブラリ)を使うのがポイントです。LLMからの応答をそのまま信用せず、決められた形式かどうかを検証する層を作ります。
export const modes = ["listen", "explore", "advise"] as const;
export type Mode = (typeof modes)[number];
export const responseKinds = [
"acknowledgement", "reflection", "question", "option", "suggestion"
] as const;
export type ResponseKind = (typeof responseKinds)[number];
export const allowedKinds: Record<Mode, Set<ResponseKind>> = {
listen: new Set(["acknowledgement", "reflection"]),
explore: new Set(["acknowledgement", "reflection", "question", "option"]),
advise: new Set(["acknowledgement", "reflection", "question", "option", "suggestion"])
};このallowedKindsが実質的な検閲ルールです。モードがlistenのときに、モデルが「suggestion(提案)」種別の応答を返してきても、アプリ側のコードでそれを拒否できます。プロンプトに「助言するな」と書くだけでは守れなかった約束を、型とSet(値の集合)で機械的に強制している点が肝心です。
もう一つの重要な設計要素がmodeRevisionという番号管理です。ユーザーがモードを切り替えた瞬間、この番号が上がります。LLMへのリクエストにはその時点のリビジョン番号を刻んでおき、返答が届いた時点でリビジョンが古ければ、その応答は画面に出さずに捨てます。
これは非同期処理を扱う開発者には見慣れた課題です。ユーザー操作とAPIレスポンスの到着に時間差がある場面では、React開発でよく使われる「古いリクエストの結果を無視する」パターンと本質的に同じ発想です。
自分のプロダクトに当てはめて確認すること
自社の音声AIやチャットボットにこの設計が必要かどうかは、次の観点で判断できます。
- ユーザーがAIの「役割」や「距離感」を自分で選びたい場面があるか
- モデルの応答を検証せずそのまま画面や音声に出しているか
- モード切り替えや会話中断時に、古いリクエストの結果が紛れ込む可能性がないか
該当するなら、まず自分のプロダクトのAI応答をresponseKindsのような有限の種別に分類できるか検討してみてください。分類できるなら、そこにzodのようなスキーマ検証を挟む余地があります。
既存のプロンプトエンジニアリングだけで運用している場合は、まずモデルの出力ログを見て、指示違反がどの程度の頻度で起きているかを確認するのが第一歩になります。会話が長くなるほど逸脱が増える傾向があるなら、検証層を追加する優先度は高いと言えます。
まとめ
AIコンパニオンやチャットボットの「人格ブレ」は、プロンプトの書き方だけでは解決しきれない構造的な課題です。
今日から確認できることとして、まず自分のAI応答を有限の種別(listen/explore/adviseのような分類)に整理できるか検討してみてください。
次に、モード切り替えや会話の打ち切り時に、古いリクエストの応答が紛れ込む余地がないかコードを見直してみてください。
RTCやLLM APIといった通信・生成レイヤーはあくまで部品であり、状態管理と検証はアプリケーション側の責任として実装しておくことが、安定した動作の土台になります。