リアルタイム音声AIを改善するたびにシステムプロンプトが長くなり、どの一文が効いているのか分からなくなる。しかもモデルをOpenAIからGeminiへ替えると、同じ指示でも応答の長さや確認質問の仕方が変わる——そんな状態に陥りがちです。
短いプロンプトを使うAgentが注目される背景には、単なるトークン節約だけでなく、人間が制御可能な範囲を取り戻したいという緊張があります。
音声AIでも解決策は「何でも短くする」ことではありません。LLMが解釈すべき指示だけを小さく保ち、割り込み、ターン管理、復旧、権限はコードへ移します。
結論
本記事では、音声コンパニオンを次の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. 割り込みを確認する
- AIに長めの質問をする
- 読み上げ開始後にユーザーが話す
- 音声再生が停止することを確認する
- 古いLLM応答が後から再生されないことを確認する
- 新しいユーザー発話だけで次のターンが始まることを確認する
3. 障害復旧を確認する
- APIキーを無効にする
- タイムアウトを意図的に発生させる
- 空文字を返す偽モデルを注入する
- 応答待ちの途中で接続を切る
いずれも、LLMが作ったもっともらしい説明ではなく、アプリが管理する固定の復旧文へ進むことを確認します。
4. Tencent Conversational AIへ接続する
比較で採用するモデルとプロンプトが決まったら、公式のLarge Language Model configurationに沿ってLLM接続を設定します。
観測時は、RTCセッション、音声認識、LLM要求、音声合成を同じ追跡単位で関連付けます。ただし、識別子の具体的なフィールド名や設定項目は固定で推測せず、公式ドキュメントの現行仕様に合わせてください。
最終確認では次を別々に記録します。
- ユーザー発話の終了時刻
- 音声認識結果の確定時刻
- LLM要求開始・終了時刻
- 音声合成開始時刻
- 最初の音声が再生された時刻
- 割り込みを検出した時刻
- 再生停止が完了した時刻
採用判断の目安
モデル比較を「どちらが賢いか」だけで終わらせないため、次の順で判断します。
- 禁止事項を破らないか:未実行操作を完了したと言わない
- 割り込み後に古い結果を使わないか:これは主にランタイムの評価
- 会話目的を満たすか:案内、サポートなど場面別に確認
- 応答量が音声向きか:画面で読む文章より短くする
- 実測した待ち時間と利用量が許容範囲か
- モデル変更時に同じケースを再生できるか
小さなシステムプロンプトは設計を見通しやすくしますが、品質評価を不要にはしません。むしろ指示を減らした分、「どの判断をコードと人間が引き受けるか」が明確になります。
注意点
短さを目的にしない
必要な安全指示まで削ると、単に制御不能になります。削除候補は、重複した説明、場面と無関係な例、コードで確定できる状態制御です。
OpenAIとGeminiのトークン数を直接比較しない
トークン化や利用情報の定義はプロバイダーごとに異なります。プロンプトのバイト数、各プロバイダーが返す利用情報、実際の費用を分けて扱ってください。
自動ルーターを最初から入れない
内容に応じてモデルを自動選択すると、障害原因がプロンプト、ルーター、モデルのどこにあるのか判別しにくくなります。まずは環境単位で明示的に固定し、同じ評価ケースを再生できる状態を作ります。
AIコンパニオンであることを隠さない
ユーザーにはAIとの会話であること、終了・ミュート・履歴削除など利用可能な操作、音声や文字起こしの保存方針を分かる形で示します。センシティブな相談、危険性の高い判断、緊急対応をAIだけで完結させない設計も必要です。
プロンプト変更にもレビュー担当者を置く
一文の追加でも、応答量、拒否、確認質問の頻度が変わります。プロンプトはコピーライティングではなく、本番挙動を変更する設定としてコードレビューと再生テストの対象にします。
関係性の開示:筆者はTencent RTCに関係する立場で本記事を作成しており、実装上の参照資料としてTencent RTC公式ドキュメントを使用しています。