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のプロンプト肥大化を止める:OpenAI/Geminiを差し替え可能にする「会話カーネル」設計

0
Last updated at Posted at 2026-08-17

リアルタイム音声AIを改善するたびにシステムプロンプトが長くなり、どの一文が効いているのか分からなくなる。しかもモデルをOpenAIからGeminiへ替えると、同じ指示でも応答の長さや確認質問の仕方が変わる——そんな状態に陥りがちです。

短いプロンプトを使うAgentが注目される背景には、単なるトークン節約だけでなく、人間が制御可能な範囲を取り戻したいという緊張があります。

音声AIでも解決策は「何でも短くする」ことではありません。LLMが解釈すべき指示だけを小さく保ち、割り込み、ターン管理、復旧、権限はコードへ移します。

結論

本記事では、音声コンパニオンを次の3層に分けます。

  1. 会話カーネル:全ターン共通の短い指示
  2. シナリオカード:現在の画面やユーザー操作に対応する追加指示
  3. 会話ランタイム:割り込み、失敗、再接続などを決定的に処理するコード
ユーザー音声
  ↓
RTC・音声認識
  ↓
会話ランタイム ── 現在の画面からシナリオを決定
  ↓
会話カーネル + 1枚のシナリオカード
  ↓
OpenAI または Gemini
  ↓
音声合成・RTC

重要なのは、LLMに「今は割り込まれたか」「古い応答を読んでよいか」を判断させないことです。LLMは自然な返答生成には有効ですが、リアルタイム状態の正しさまでは保証しません。

また、短いプロンプトは固定コンテキストを減らせますが、それだけで会話品質やエンドツーエンド遅延が改善するとは限りません。モデル、音声認識、音声合成、ネットワークを分けて測定する必要があります。

前提

対象

  • TypeScript / Node.js 20以降
  • OpenAIまたはGeminiをLLMとして利用
  • Tencent Conversational AIを音声経路として接続する想定
  • ユーザーがAIの発話中に話し始める「バージイン」に対応

Tencent Conversational AIは、RTCによる音声経路と複数のLLMを組み合わせるリアルタイム音声対話の構成を提供しています。全体像は公式ドキュメントで確認できます。

LLM設定の公式ドキュメントでは、OpenAI互換モデルやAgent基盤との接続、リクエスト識別子を用いたルーティング・観測について説明されています。実際に接続するときは、認証、URL、要求形式などを同ページの最新仕様に合わせてください。

最初に決める責任分界

判断 置き場所 理由
口調、回答の簡潔さ 会話カーネル 自然言語で調整しやすい
現在の利用場面 アプリ側 画面や操作から確定できる
割り込み後の停止 会話ランタイム 即時かつ決定的である必要がある
ツール実行権限 アプリ側 LLMの推測で付与しない
障害時の案内 アプリ側の固定文 LLMが失敗していても再生できる
専門情報 検索・シナリオカード 全情報を毎ターン渡さない
危険性の高い判断 人間 自動化の対象にしない

手順1:検証用プロジェクトを作る

mkdir voice-kernel
cd voice-kernel
npm init -y
npm install openai @google/genai
npm install -D typescript tsx @types/node
mkdir -p src test

package.jsonへスクリプトを追加します。

{
  "type": "module",
  "scripts": {
    "check:prompt": "tsx test/prompt-budget.ts",
    "compare": "tsx src/compare.ts"
  }
}

モデル名やAPIキーは環境変数から渡します。特定モデル名をコードへ固定しないことで、モデル変更をプロンプト変更と分離できます。

export OPENAI_API_KEY="..."
export OPENAI_MODEL="..."

export GEMINI_API_KEY="..."
export GEMINI_MODEL="..."

手順2:会話カーネルとシナリオカードを分ける

会話カーネルには、すべてのターンで本当に必要な指示だけを置きます。

コード:src/prompt.ts

export type Scene = "home" | "onboarding" | "support";

const kernel = [
  "あなたは音声で対話するAIコンパニオンです。",
  "ユーザーが使用している言語で答えてください。",
  "回答は音声で理解しやすい短い文にしてください。",
  "実行していない操作を、実行済みだと説明しないでください。",
  "情報が不足している場合は、一度に一つだけ確認してください。",
  "Markdown記法は使わないでください。"
];

const sceneCards: Record<Scene, readonly string[]> = {
  home: [
    "現在はホーム画面です。",
    "特定機能が選ばれるまで、操作完了を約束しないでください。"
  ],
  onboarding: [
    "現在は初回案内です。",
    "説明を一項目ずつ進め、次へ進む前にユーザーの意思を確認してください。"
  ],
  support: [
    "現在はサポート場面です。",
    "問題、発生条件、希望する対応を順番に確認してください。",
    "解決できない場合は、人間への引き継ぎを案内してください。"
  ]
};

const LOCAL_PROMPT_BUDGET_BYTES = 4096;

export function buildSystemPrompt(scene: Scene): string {
  const prompt = [
    "# 共通ルール",
    ...kernel.map((line) => `- ${line}`),
    "# 現在の場面",
    ...sceneCards[scene].map((line) => `- ${line}`)
  ].join("\n");

  const bytes = Buffer.byteLength(prompt, "utf8");
  if (bytes > LOCAL_PROMPT_BUDGET_BYTES) {
    throw new Error(
      `system prompt budget exceeded: ${bytes}/${LOCAL_PROMPT_BUDGET_BYTES} bytes`
    );
  }

  return prompt;
}

4096 bytesはTencent RTCや各モデルの制限ではなく、このリポジトリで決めるローカルな変更予算です。

モデルごとにトークン化方式が異なるため、CIではUTF-8バイト数を安定した代理指標として使い、実際の入力トークン数は各プロバイダーの利用情報から別途記録します。

手順3:シナリオをLLMに推測させない

ユーザーの発言から「これはサポート場面だろう」とLLMに分類させると、同じ発言でも読み込まれる指示が変わります。現在の画面や押されたボタンなど、アプリが持つ事実から選択します。

import type { Scene } from "./prompt.js";

type AppContext = {
  route: "/" | "/welcome" | "/help";
  supportRequested: boolean;
};

export function selectScene(context: AppContext): Scene {
  if (context.supportRequested || context.route === "/help") {
    return "support";
  }
  if (context.route === "/welcome") {
    return "onboarding";
  }
  return "home";
}

これにより、LLMは場面を決めるのではなく、人間が設計した場面の中で返答を作る役割になります。

手順4:OpenAIとGeminiを同じ契約で包む

コード:src/providers.ts

import OpenAI from "openai";
import { GoogleGenAI } from "@google/genai";

export type GenerationResult = {
  provider: "openai" | "gemini";
  text: string;
  inputTokens?: number;
  outputTokens?: number;
};

export interface VoiceModel {
  generate(input: {
    system: string;
    user: string;
  }): Promise<GenerationResult>;
}

export class OpenAIModel implements VoiceModel {
  private readonly client = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY
  });

  async generate(input: {
    system: string;
    user: string;
  }): Promise<GenerationResult> {
    const model = process.env.OPENAI_MODEL;
    if (!model) throw new Error("OPENAI_MODEL is required");

    const response = await this.client.responses.create({
      model,
      instructions: input.system,
      input: input.user
    });

    return {
      provider: "openai",
      text: response.output_text.trim(),
      inputTokens: response.usage?.input_tokens,
      outputTokens: response.usage?.output_tokens
    };
  }
}

export class GeminiModel implements VoiceModel {
  private readonly client = new GoogleGenAI({
    apiKey: process.env.GEMINI_API_KEY
  });

  async generate(input: {
    system: string;
    user: string;
  }): Promise<GenerationResult> {
    const model = process.env.GEMINI_MODEL;
    if (!model) throw new Error("GEMINI_MODEL is required");

    const response = await this.client.models.generateContent({
      model,
      contents: input.user,
      config: {
        systemInstruction: input.system
      }
    });

    return {
      provider: "gemini",
      text: (response.text ?? "").trim(),
      inputTokens: response.usageMetadata?.promptTokenCount,
      outputTokens: response.usageMetadata?.candidatesTokenCount
    };
  }
}

アプリ本体はモデル固有のレスポンス形式を知りません。モデルを自動選択するのではなく、デプロイ設定や管理画面など、人間が確認できる場所で明示的に選びます。

import type { VoiceModel } from "./providers.js";
import { GeminiModel, OpenAIModel } from "./providers.js";

export function createModel(name: "openai" | "gemini"): VoiceModel {
  return name === "openai" ? new OpenAIModel() : new GeminiModel();
}

手順5:割り込みと復旧をプロンプトから外す

「ユーザーが割り込んだら話すのをやめる」とプロンプトに書いても、生成済みの音声は止まりません。割り込みは音声再生とターンの有効性を管理するランタイムの責任です。

コード:src/turn-runtime.ts

export type Turn = {
  id: number;
  startedAt: number;
};

export class TurnRuntime {
  private generation = 0;

  begin(): Turn {
    this.generation += 1;
    return {
      id: this.generation,
      startedAt: Date.now()
    };
  }

  interrupt(stopSpeech: () => void): void {
    this.generation += 1;
    stopSpeech();
  }

  isCurrent(turn: Turn): boolean {
    return turn.id === this.generation;
  }
}

利用側では、LLMの結果が返った時点でターンがまだ有効かを確認します。

import { buildSystemPrompt, type Scene } from "./prompt.js";
import type { VoiceModel } from "./providers.js";
import { TurnRuntime } from "./turn-runtime.js";

const runtime = new TurnRuntime();

export async function answer(
  model: VoiceModel,
  scene: Scene,
  transcript: string,
  speak: (text: string) => void
): Promise<void> {
  const turn = runtime.begin();

  try {
    const result = await model.generate({
      system: buildSystemPrompt(scene),
      user: transcript
    });

    if (!runtime.isCurrent(turn)) return;

    if (!result.text) {
      speak("うまく返答を作れませんでした。もう一度お願いします。");
      return;
    }

    speak(result.text);
  } catch {
    if (!runtime.isCurrent(turn)) return;
    speak("現在返答を作れません。少し待ってから、もう一度お願いします。");
  }
}

export function onUserSpeechStarted(stopSpeech: () => void): void {
  runtime.interrupt(stopSpeech);
}

この例では、プロバイダー側の処理が物理的に停止できなかった場合でも、遅れて届いた古い結果を読み上げません。可能なら通信要求自体も中断し、利用量を抑えます。ただし中断方法は利用するSDKや接続方式に合わせて実装してください。

コード:プロンプト肥大化をCIで検出する

test/prompt-budget.ts

import assert from "node:assert/strict";
import { buildSystemPrompt, type Scene } from "../src/prompt.js";

const scenes: Scene[] = ["home", "onboarding", "support"];

for (const scene of scenes) {
  const prompt = buildSystemPrompt(scene);
  const bytes = Buffer.byteLength(prompt, "utf8");

  assert.ok(bytes <= 4096, `${scene}: prompt is too large`);
  assert.match(prompt, /実行していない操作/);
  assert.match(prompt, /現在の場面/);

  // リアルタイム状態制御をプロンプトへ戻さない
  assert.doesNotMatch(prompt, /再接続してください/);
  assert.doesNotMatch(prompt, /音声再生を停止/);

  console.log({ scene, bytes });
}

実行します。

npm run check:prompt

このテストは会話品質を保証するものではありません。「プロンプトを直す」という名目で、状態制御や復旧処理が再び自然言語へ流入するのを防ぐためのガードです。

確認方法

1. 同じ入力を両モデルへ渡す

次の固定ケースをOpenAIとGeminiへ渡し、応答を保存します。

ケース 場面 入力 人間が確認する点
通常会話 home 「今日は少し疲れた」 過剰に長くないか
曖昧な操作 home 「それをやって」 実行済みと偽らず確認するか
初回案内 onboarding 「次は何をすればいい?」 一項目ずつ説明するか
問い合わせ support 「音が聞こえない」 症状を一度に詰問しないか
解決不能 support 「何度試しても直らない」 人間への導線を提示するか

比較時は最低限、次を記録します。

type EvalRecord = {
  provider: "openai" | "gemini";
  scene: string;
  caseId: string;
  promptBytes: number;
  inputTokens?: number;
  outputTokens?: number;
  elapsedMs: number;
  text: string;
  reviewerDecision?: "pass" | "revise" | "reject";
  reviewerNote?: string;
};

elapsedMsはLLM呼び出し部分の比較値です。音声認識開始から読み上げ開始までの時間とは分けてください。

2. 割り込みを確認する

  1. AIに長めの質問をする
  2. 読み上げ開始後にユーザーが話す
  3. 音声再生が停止することを確認する
  4. 古いLLM応答が後から再生されないことを確認する
  5. 新しいユーザー発話だけで次のターンが始まることを確認する

3. 障害復旧を確認する

  • APIキーを無効にする
  • タイムアウトを意図的に発生させる
  • 空文字を返す偽モデルを注入する
  • 応答待ちの途中で接続を切る

いずれも、LLMが作ったもっともらしい説明ではなく、アプリが管理する固定の復旧文へ進むことを確認します。

4. Tencent Conversational AIへ接続する

比較で採用するモデルとプロンプトが決まったら、公式のLarge Language Model configurationに沿ってLLM接続を設定します。

観測時は、RTCセッション、音声認識、LLM要求、音声合成を同じ追跡単位で関連付けます。ただし、識別子の具体的なフィールド名や設定項目は固定で推測せず、公式ドキュメントの現行仕様に合わせてください。

最終確認では次を別々に記録します。

  • ユーザー発話の終了時刻
  • 音声認識結果の確定時刻
  • LLM要求開始・終了時刻
  • 音声合成開始時刻
  • 最初の音声が再生された時刻
  • 割り込みを検出した時刻
  • 再生停止が完了した時刻

採用判断の目安

モデル比較を「どちらが賢いか」だけで終わらせないため、次の順で判断します。

  1. 禁止事項を破らないか:未実行操作を完了したと言わない
  2. 割り込み後に古い結果を使わないか:これは主にランタイムの評価
  3. 会話目的を満たすか:案内、サポートなど場面別に確認
  4. 応答量が音声向きか:画面で読む文章より短くする
  5. 実測した待ち時間と利用量が許容範囲か
  6. モデル変更時に同じケースを再生できるか

小さなシステムプロンプトは設計を見通しやすくしますが、品質評価を不要にはしません。むしろ指示を減らした分、「どの判断をコードと人間が引き受けるか」が明確になります。

注意点

短さを目的にしない

必要な安全指示まで削ると、単に制御不能になります。削除候補は、重複した説明、場面と無関係な例、コードで確定できる状態制御です。

OpenAIとGeminiのトークン数を直接比較しない

トークン化や利用情報の定義はプロバイダーごとに異なります。プロンプトのバイト数、各プロバイダーが返す利用情報、実際の費用を分けて扱ってください。

自動ルーターを最初から入れない

内容に応じてモデルを自動選択すると、障害原因がプロンプト、ルーター、モデルのどこにあるのか判別しにくくなります。まずは環境単位で明示的に固定し、同じ評価ケースを再生できる状態を作ります。

AIコンパニオンであることを隠さない

ユーザーにはAIとの会話であること、終了・ミュート・履歴削除など利用可能な操作、音声や文字起こしの保存方針を分かる形で示します。センシティブな相談、危険性の高い判断、緊急対応を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?