0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI音声コンパニオンが「考え中の沈黙」を奪わない:承認済み会話ポリシーを状態機械へコンパイルする

0
Last updated at Posted at 2026-08-15

「自分のことを理解してくれる音声AI」を作りたい一方で、メモから性格や感情を勝手に推測されるのは落ち着きません。しかも、プロフィールをLLMへ渡しただけでは、考えるための沈黙を発話終了と誤認する、長く話しすぎる、遮っても止まらないといったリアルタイム会話の問題は解決しません。

この緊張は、パーソナライズの不足ではなく、次の2つを混ぜていることから生じます。

  • LLMが参照する「何を知っているか」
  • アプリが制御する「いつ聞き、いつ話し、いつ止まるか」

本記事では、本人が承認したプロフィールを、LLM用プロンプトと決定的な会話ポリシーへ分けてコンパイルします。AIは候補整理に使えても、同意、割り込み、待ち時間の最終決定者にはしません。

結論

音声コンパニオンの「ユーザー取扱情報」は、一枚の長いプロンプトにしない方が安全に運用できます。

次の3層に分けます。

  1. 一次メモ:本人が書いた記録。実行時には直接渡さない
  2. 承認済みプロフィール:本人が現在も有効だと確認した事実と希望
  3. 実行ポリシー:待ち時間、応答量、割り込み、失敗時の挙動

LLMへ渡すのは、承認済みの会話上必要な情報だけです。一方、沈黙の猶予時間や再生停止はTypeScriptの状態機械で処理します。

Tencent Conversational AIはリアルタイム音声対話と複数のLLMプロバイダーとの接続を扱います。全体像は公式概要を参照してください。

OpenAI互換モデルやエージェント基盤の接続方法、リクエスト識別子を使ったルーティングと観測については、後者の公式ドキュメントに従います。この記事では、存在しないSDK APIを仮定しないため、RTC SDK固有部分をアダプターとして分離します。

前提:音声経路と人格設定を分ける

構成は次のようにします。

マイク
  ↓
RTC/音声認識
  ↓ 音声開始・認識確定イベント
会話コーディネーター ── 承認済み実行ポリシー
  ↓
OpenAI互換LLM ── 承認済みプロフィールのみ参照
  ↓
音声合成
  ↓
RTC/再生

ここで重要なのは、LLMが以下を決めないことです。

  • 何秒の沈黙を待つか
  • ユーザーが話し始めたら再生を止めるか
  • LLM失敗時に再試行を続けるか
  • 未承認の個人情報をプロフィールへ追加するか

LLMが得意なのは、自然な返答、メモからの候補抽出、表現の調整です。しかし、沈黙が「考え中」か「発話終了」かを常に正しく推測できるわけではありません。同意の有効性や現在の好みも、文章生成能力から自動的には保証されません。

どこをAIに任せるか

判断 担当 理由
メモから好みの候補を抽出 LLM+人間のレビュー 候補生成は速いが、誤推定がある
プロフィールを有効化 人間 同意と現在性は本人が決める
応答文を生成 LLM 文脈に応じた表現が必要
沈黙の猶予 状態機械 毎回同じ規則で再現できる
割り込み時の再生停止 状態機械 LLM応答を待つべきではない
センシティブな相談の扱い 明示ルール+人間への導線 生成品質だけで責任を移せない

手順1:プロフィールを「推測」ではなく「承認済み設定」にする

検証用プロジェクトを作ります。

mkdir voice-policy-compiler
cd voice-policy-compiler
npm init -y
npm install -D typescript tsx vitest @types/node
npx tsc --init
mkdir -p src profiles tests

profiles/approved.jsonを作成します。

{
  "userId": "user-local-001",
  "revision": 3,
  "approvedAt": "2026-08-15T00:00:00Z",
  "consent": {
    "personalization": true,
    "storeConversation": false
  },
  "conversation": {
    "responseStyle": "short",
    "maxSentences": 2,
    "silenceGraceMs": 1200,
    "allowUserInterruption": true,
    "failureMessage": "うまく返答を作れませんでした。もう一度お願いします。"
  },
  "approvedFacts": [
    {
      "key": "preferred_name",
      "value": "ミナト",
      "reviewedAt": "2026-08-15T00:00:00Z"
    },
    {
      "key": "conversation_preference",
      "value": "結論を先に聞きたい",
      "reviewedAt": "2026-08-15T00:00:00Z"
    }
  ],
  "boundaries": {
    "doNotInfer": ["健康状態", "家族関係", "政治的立場"],
    "blockedTopics": []
  }
}

silenceGraceMsは製品の推奨値ではなく、検証開始時の仮説です。話者、言語、音声認識、ネットワークによって適切な値は変わるため、後述のイベント計測で調整します。

一次メモからLLMで候補を作る場合も、このファイルを直接更新させません。

一次メモ → LLMが差分候補を生成 → 本人が確認 → 承認済みJSONを更新

「AIが自分を理解した」と「AIが自分について推測を保存した」は別物です。レビュー画面では、追加候補だけでなく、削除・期限切れ・根拠となった一次メモも確認できるようにします。

コード1:プロフィールを2種類の成果物へコンパイルする

src/profile.tsでは、LLM用コンテキストと実行ポリシーを分けます。

export type ApprovedProfile = {
  userId: string;
  revision: number;
  approvedAt: string;
  consent: {
    personalization: boolean;
    storeConversation: boolean;
  };
  conversation: {
    responseStyle: "short" | "balanced";
    maxSentences: number;
    silenceGraceMs: number;
    allowUserInterruption: boolean;
    failureMessage: string;
  };
  approvedFacts: Array<{
    key: string;
    value: string;
    reviewedAt: string;
  }>;
  boundaries: {
    doNotInfer: string[];
    blockedTopics: string[];
  };
};

export type RuntimePolicy = {
  profileRevision: number;
  maxSentences: number;
  silenceGraceMs: number;
  allowUserInterruption: boolean;
  failureMessage: string;
};

export type CompiledProfile = {
  runtime: RuntimePolicy;
  llmSystemContext: string;
};

export function compileProfile(profile: ApprovedProfile): CompiledProfile {
  if (!profile.consent.personalization) {
    throw new Error("personalization consent is not active");
  }

  if (!Number.isInteger(profile.revision) || profile.revision < 1) {
    throw new Error("invalid profile revision");
  }

  if (
    profile.conversation.silenceGraceMs < 0 ||
    profile.conversation.silenceGraceMs > 10_000
  ) {
    throw new Error("silenceGraceMs is outside the application limit");
  }

  if (
    profile.conversation.maxSentences < 1 ||
    profile.conversation.maxSentences > 5
  ) {
    throw new Error("maxSentences is outside the application limit");
  }

  const facts = profile.approvedFacts.map((fact) => ({
    key: fact.key,
    value: fact.value
  }));

  const llmSystemContext = [
    "あなたはリアルタイム音声コンパニオンです。",
    `返答は最大${profile.conversation.maxSentences}文にしてください。`,
    "以下は本人が承認した情報です。記載のない属性を推測しないでください。",
    JSON.stringify(facts),
    `特に推測しない項目: ${JSON.stringify(profile.boundaries.doNotInfer)}`,
    "不明な場合は、断定せず本人に確認してください。"
  ].join("\n");

  return {
    runtime: {
      profileRevision: profile.revision,
      maxSentences: profile.conversation.maxSentences,
      silenceGraceMs: profile.conversation.silenceGraceMs,
      allowUserInterruption: profile.conversation.allowUserInterruption,
      failureMessage: profile.conversation.failureMessage
    },
    llmSystemContext
  };
}

このコンパイルで防げるのは、主に設定の混線です。プロンプトだけで機密性やアクセス制御を保証できるわけではありません。blockedTopicsの強制やデータ参照権限は、LLMの外側で検査する必要があります。

手順2:「少し黙った」を即座に送信しない

音声認識から確定テキストが届いても、すぐLLMへ送らず、短い猶予状態へ移します。

LISTENING
  └─ 認識確定 → GRACE
                    ├─ 再び話し始める → LISTENING
                    └─ 猶予満了 → THINKING
                                      ├─ 応答成功 → SPEAKING
                                      └─ 失敗 → RECOVERING

猶予中にユーザーが話し始めた場合は、前半の認識結果を捨てずに後半と連結します。これにより、「ええと……来週の予定なんだけど」の途中でAIが返事を始める状況を減らせます。

コード2:待機、割り込み、復旧を状態機械にする

以下のイベント名は説明用に定義したアプリケーション内の契約であり、Tencent RTC SDKのAPI名ではありません。実際の音声開始、認識結果、再生停止イベントは、利用する公式SDKからアダプターで変換してください。

src/coordinator.tsを作ります。

import type { RuntimePolicy } from "./profile.js";

type Phase = "LISTENING" | "GRACE" | "THINKING" | "SPEAKING";

type Dependencies = {
  requestLlm: (text: string, signal: AbortSignal) => Promise<string>;
  speak: (text: string) => Promise<void>;
  stopPlayback: () => Promise<void>;
  log: (event: Record<string, unknown>) => void;
};

export class ConversationCoordinator {
  private phase: Phase = "LISTENING";
  private segments: string[] = [];
  private graceTimer?: ReturnType<typeof setTimeout>;
  private activeRequest?: AbortController;
  private turnNumber = 0;

  constructor(
    private readonly policy: RuntimePolicy,
    private readonly deps: Dependencies
  ) {}

  async onUserSpeechStarted(): Promise<void> {
    this.clearGraceTimer();

    if (this.phase === "THINKING") {
      this.activeRequest?.abort();
    }

    if (
      this.phase === "SPEAKING" &&
      this.policy.allowUserInterruption
    ) {
      await this.deps.stopPlayback();
      this.deps.log({
        type: "playback_stopped_by_user",
        turnNumber: this.turnNumber,
        at: Date.now()
      });
    }

    this.phase = "LISTENING";
  }

  onAsrFinal(text: string): void {
    const normalized = text.trim();
    if (!normalized) return;

    this.segments.push(normalized);
    this.phase = "GRACE";
    this.clearGraceTimer();

    this.graceTimer = setTimeout(() => {
      void this.commitTurn();
    }, this.policy.silenceGraceMs);

    this.deps.log({
      type: "grace_started",
      graceMs: this.policy.silenceGraceMs,
      segmentCount: this.segments.length,
      at: Date.now()
    });
  }

  private async commitTurn(): Promise<void> {
    const input = this.segments.join(" ");
    this.segments = [];

    if (!input) {
      this.phase = "LISTENING";
      return;
    }

    const currentTurn = ++this.turnNumber;
    const controller = new AbortController();
    this.activeRequest = controller;
    this.phase = "THINKING";

    this.deps.log({
      type: "llm_requested",
      turnNumber: currentTurn,
      at: Date.now()
    });

    try {
      const response = await this.deps.requestLlm(
        input,
        controller.signal
      );

      if (controller.signal.aborted || currentTurn !== this.turnNumber) {
        return;
      }

      this.phase = "SPEAKING";
      this.deps.log({
        type: "playback_requested",
        turnNumber: currentTurn,
        at: Date.now()
      });

      await this.deps.speak(response);
    } catch (error) {
      if (!controller.signal.aborted) {
        this.deps.log({
          type: "llm_failed",
          turnNumber: currentTurn,
          error: error instanceof Error ? error.message : "unknown",
          at: Date.now()
        });

        this.phase = "SPEAKING";
        await this.deps.speak(this.policy.failureMessage);
      }
    } finally {
      if (currentTurn === this.turnNumber) {
        this.phase = "LISTENING";
        this.activeRequest = undefined;
      }
    }
  }

  private clearGraceTimer(): void {
    if (this.graceTimer) {
      clearTimeout(this.graceTimer);
      this.graceTimer = undefined;
    }
  }
}

割り込み時に何を残すか

割り込みでは、次の3つを別々に扱います。

  • 音声再生:直ちに停止する
  • 未完了のLLMリクエスト:可能ならキャンセルする
  • 会話記録:AIが最後まで話したことにしない

特に3点目を忘れると、次のターンでLLMが「先ほど説明したとおり」と、ユーザーが聞いていない内容を前提にすることがあります。会話履歴へ保存する際は、生成全文ではなく、実際に再生完了したか、途中停止したかを記録してください。

手順3:Tencent Conversational AIへ接続する

接続時は、責任範囲を次のように保ちます。

Tencent Conversational AI/RTC側
- リアルタイム音声の入出力
- 音声対話経路
- 利用する構成に応じた音声認識・音声合成との接続

アプリケーション側
- 承認済みプロフィール
- 沈黙猶予の状態機械
- 割り込み時の履歴処理
- 同意、保存方針、モデレーション
- イベント計測

LLM側
- 承認済み文脈に基づく応答文生成

OpenAI互換LLMの設定は、公式のLarge Language Model configurationに沿って行います。管理画面や設定項目は更新される可能性があるため、この記事ではフィールド名やエンドポイントを推測して記載しません。

アプリ側では、LLMリクエストを次のインターフェースへ閉じ込めます。

export type LlmAdapter = {
  generate(args: {
    systemContext: string;
    userText: string;
    applicationTurnId: string;
    signal: AbortSignal;
  }): Promise<string>;
};

applicationTurnIdはアプリ内のログ相関に使います。Tencent側でリクエスト識別子を設定する場合は、公式ドキュメントに示された方法へ対応付けます。認証情報はクライアントへ埋め込まず、バックエンドまたは公式に案内された安全な構成から扱ってください。

AIコンパニオンやキャラクター対話を含む利用場面は、Social Entertainment solutionでも確認できます。ただし、利用場面が紹介されていることと、同意・安全設計が自動的に満たされることは別です。

確認方法:文章ではなくイベント列を再生する

自然に一度会話できただけでは、実装の信頼性は確認できません。最低限、次のイベント列を固定して再生します。

ケースA:考え中の沈黙

0ms     user_speech_started
500ms   asr_final("来週の")
1100ms  user_speech_started
1600ms  asr_final("予定を整理して")
2800ms  grace_elapsed

期待結果:

  • 最初のasr_finalだけではLLMを呼ばない
  • 2つの認識結果を連結する
  • 最後の発話後、設定した猶予を経て1回だけLLMを呼ぶ

ケースB:AIの読み上げ中に割り込む

playback_requested
user_speech_started
playback_stopped_by_user
asr_final("もっと短く")

期待結果:

  • 再生停止がLLMの次の応答を待たずに始まる
  • 途中までのAI発話を「完了済み」として履歴へ保存しない
  • 新しい入力が独立したターンになる

ケースC:LLMが失敗する

期待結果:

  • 無制限に自動再試行しない
  • 承認済みの短い復旧メッセージだけを再生する
  • 失敗した入力、プロフィール改訂番号、アプリ側ターンIDを関連付けられる
  • 認証情報や不要な音声本文をログへ出さない

ケースD:同意を取り消す

期待結果:

  • personalization: falseでコンパイルが失敗する
  • 過去のコンパイル成果物をキャッシュから再利用しない
  • 一般設定へ切り替わったことをUIで確認できる

計測する時刻

固定の「理想レイテンシ」を先に宣言するのではなく、少なくとも次を記録します。

speech_started_at
asr_final_at
grace_started_at
llm_requested_at
llm_response_at
playback_requested_at
playback_started_at
interrupt_detected_at
playback_stopped_at

ここから、次を個別に見ます。

  • 発話終了からLLMリクエストまでの猶予
  • LLMリクエストから応答までの時間
  • 応答から再生開始までの時間
  • 割り込み検出から再生停止までの時間
  • 猶予中に発話が再開された割合

猶予を長くすると考え中の割り込みは減りますが、応答開始は遅くなります。短くするとテンポは上がりますが、ゆっくり話すユーザーを遮りやすくなります。平均値だけでなく、ユーザーが「待ってほしかった」「遅すぎた」と明示したフィードバックとイベント列を対応付けて調整します。

注意点

1. プロフィールを会話ログから自動更新しない

一度の「今日は短く答えて」を恒久的な性格設定へ変換すると、状況依存の希望を固定化してしまいます。一時設定と永続設定を分け、永続化には本人の確認を要求します。

2. 沈黙時間だけでターンを完全には判定できない

相づち、言い直し、雑音、複数話者があるため、固定時間だけで正解にはなりません。音声認識結果、音声活動、UIの「話し終わり」操作を組み合わせる選択肢も残します。

3. 「推測しないで」はセキュリティ境界ではない

プロンプト上の禁止だけでは不十分です。LLMへ送らない、参照権限を分ける、センシティブ情報をプロフィール候補から除外する、といったアプリ側の制御が必要です。

4. 割り込みを常に許可するとは限らない

緊急案内や安全上必要な短い告知では、途中停止させない設計判断もあり得ます。その場合も、ユーザーへ理由と状態を表示し、通常会話へ戻る操作を用意します。

5. ログの詳しさとプライバシーはトレードオフ

観測性のために全文を保存する必要はありません。まずイベント種別、時刻、ターンID、プロフィール改訂番号、成功・失敗だけで検証できるかを確認します。音声や認識本文を保存する場合は、目的、保存期間、削除方法、同意を明示します。

まとめ

リアルタイム音声コンパニオンで価値があるのは、プロフィールを大量に覚えさせることだけではありません。本人が「今もそうしてほしい」と承認した範囲を守り、考えている間は待ち、遮られたら止まり、失敗したらごまかさず戻れることが重要です。

AIには自然な表現と候補整理を任せ、人間は同意と境界を決める。アプリは、その決定を状態機械として毎回同じように実行する。この分担なら、説得力のあるデモを、検証可能な音声体験へ進められます。


関係開示:筆者はTencent RTCに関係する立場で本記事を執筆しています。実装上の事実確認には、Tencent RTCの公式ドキュメントを参照しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?