リアルタイム音声AIのデモは、ユーザーの声に一度答えられれば成立します。しかし本番に近づけると、次の問いに答えられないことが問題になります。
- 返答が遅かったのは、音声認識、LLM、音声合成のどこか
- ユーザーが割り込んだ後、古い回答はどこまで処理されたか
- 同じ設定のはずなのに、なぜ特定のデプロイだけ挙動が変わったか
- LLMの障害時に、ユーザーへ何を返したか
モデルの管理画面、RTCのログ、アプリのログを別々に見ても、同じ会話ターンとして結び付かなければ原因を判断できません。必要なのはログを増やすことではなく、一つの発話を最後まで追跡できる識別子とイベント設計です。
この記事では、Tencent Conversational AIへ接続する前段として、turn_id、request_id、context_hashを使った最小の観測レイヤーをTypeScriptで実装します。
結論:会話履歴ではなく「ターンの因果関係」を記録する
音声AIでは、次の3種類のIDを分けます。
| 識別子 | 対象 | 用途 |
|---|---|---|
session_id |
音声セッション全体 | 同じ接続中のイベントを集約する |
turn_id |
STTで確定した一つのユーザー発話 | STT、LLM、TTSを一続きにする |
request_id |
一回の外部リクエスト | LLMの再試行やルーティングを区別する |
さらに、システムプロンプト、ツール定義、モデル経路など、毎回同じであるべき固定文脈を正規化してcontext_hashを計算します。
context_hashはプロンプトキャッシュのヒットを保証する値ではありません。次の切り分けに使う観測値です。
- 同じリリースで固定文脈が意図せず変化していないか
- ツール一覧の順番だけが毎回変わっていないか
- 遅延やコストの変化が、モデルではなく入力構造の変化に対応していないか
LLMができるのは回答候補の生成です。割り込みの確定、古い回答の破棄、タイムアウト後の案内、ログ保存範囲の決定はアプリケーション側に残します。
前提:RTC、STT、LLM、TTSを一つの処理に埋め込まない
今回の責任分界は次の通りです。
ユーザー音声
↓
RTC / メディア転送
↓
STT ── final発話 ──→ Turn Controller
├─ LLM Adapter
├─ TTS / Playback
└─ Trace Writer
Tencent Conversational AIは、リアルタイム音声対話を複数のLLMプロバイダーと組み合わせるための構成を提供しています。全体像は公式ドキュメントを確認してください。
LLM設定の公式ドキュメントでは、OpenAI互換モデルやエージェントプラットフォームとの接続、およびルーティングや観測に利用するリクエスト識別子が説明されています。実際の設定項目名は利用する接続方式に従い、本記事で独自のTencent RTC API名は定義しません。
Geminiを含む別のモデル基盤を使う場合も、モデル固有のSDK呼び出しをModelAdapterの内側へ閉じ込めます。これにより、音声の割り込み制御をモデル交換の影響から切り離せます。
先に決める失敗時の扱い
観測項目は、障害時の判断とセットで設計します。
| 状況 | 自動処理 | 人が決めておくこと |
|---|---|---|
| ユーザーが割り込む | 実行中ターンを中断し、遅れて届いた結果を破棄 | 何を割り込みとして扱うか |
| LLMが期限を超える | 固定の復旧案内へ切り替える | 待機期限と案内文 |
| LLMが失敗する | テキスト案内、再試行、有人導線のいずれかへ遷移 | 自動再試行の上限 |
context_hashが変わる |
リリース情報と照合する | 許容された設定変更か |
| ログに機微情報が含まれる | 本文を保存せず、識別子と状態だけ記録する | 保存期間と閲覧権限 |
重要なのは、タイムアウトを長くして成功扱いを増やすことではありません。ユーザーが待ち続ける状態を終わらせ、次の操作を選べるようにすることです。
手順1:検証環境を作る
Node.js環境で以下を実行します。
mkdir voice-turn-trace
cd voice-turn-trace
npm init -y
npm install -D typescript tsx @types/node
mkdir src
package.jsonへ実行コマンドを追加します。
{
"scripts": {
"demo": "tsx src/demo.ts",
"verify": "tsx src/verify.ts"
}
}
今回は実際の音声や外部モデルを使わず、遅いLLMをモックして制御経路を再現します。先にモックで因果関係を確認すると、RTC、ネットワーク、モデル品質を一度にデバッグせずに済みます。
手順2:追跡イベントのデータモデルを作る
ログへ会話本文をそのまま残さず、状態遷移を中心に記録します。
// src/demo.ts
import { appendFileSync, writeFileSync } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
const TRACE_FILE = "trace.ndjson";
writeFileSync(TRACE_FILE, "");
type Phase = "input" | "llm" | "tts" | "recovery";
type Status =
| "accepted"
| "started"
| "completed"
| "interrupt_requested"
| "discarded"
| "canceled"
| "deadline_exceeded"
| "selected";
type TraceEvent = {
at: string;
sessionId: string;
turnId: string;
phase: Phase;
status: Status;
requestId?: string;
contextHash?: string;
durationMs?: number;
reason?: string;
};
class TraceWriter {
emit(event: Omit<TraceEvent, "at">): void {
const record: TraceEvent = {
at: new Date().toISOString(),
...event,
};
appendFileSync(TRACE_FILE, JSON.stringify(record) + "\n");
console.log(record);
}
}
function canonicalize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalize).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalize(child)}`);
return `{${entries.join(",")}}`;
}
return JSON.stringify(value);
}
function sha256(value: unknown): string {
return createHash("sha256")
.update(canonicalize(value))
.digest("hex");
}
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
context_hashの対象にユーザー発話を入れていません。ユーザー本文のハッシュも、入力候補が少ない場合には推測される可能性があります。固定文脈のドリフト検出と、個別発話の追跡は分けた方が安全です。
手順3:モデル境界と期限を実装する
ModelAdapterにはrequest_idと中断シグナルを渡します。実際のプロバイダーへ接続するときは、この境界で公式設定に対応付けます。
// src/demo.ts に追記
type ModelRequest = {
text: string;
requestId: string;
signal: AbortSignal;
};
interface ModelAdapter {
reply(request: ModelRequest): Promise<string>;
}
class FakeModel implements ModelAdapter {
async reply(request: ModelRequest): Promise<string> {
// 「最初」を含むターンは、割り込み後にも遅れて完了するモデルを再現する。
// 「遅延」を含むターンは、期限超過を再現する。
const delay = request.text.includes("遅延")
? 450
: request.text.includes("最初")
? 220
: 90;
// あえてAbortSignalを無視する。
// 外部サービスがキャンセルを受理しない場合でも、
// アプリ側のgeneration guardで古い結果を捨てられるか確認するため。
await sleep(delay);
return `応答:${request.requestId}`;
}
}
async function withDeadline<T>(
task: Promise<T>,
deadlineMs: number,
controller: AbortController,
): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort("deadline");
reject(new Error("deadline_exceeded"));
}, deadlineMs);
});
try {
return await Promise.race([task, deadline]);
} finally {
if (timer) clearTimeout(timer);
}
}
外部モデルがキャンセルをサポートしていても、キャンセルだけに依存してはいけません。中断要求とモデルの完了がすれ違う可能性があるため、結果を採用する直前に「まだ現行ターンか」を確認します。
コード:割り込み、遅延結果の破棄、復旧選択を一つのターンで追う
// src/demo.ts に追記
type ActiveTurn = {
turnId: string;
controller: AbortController;
};
class VoiceTurnController {
private active?: ActiveTurn;
private readonly fixedContext = {
systemPromptVersion: "companion-v3",
tools: ["search_faq", "open_human_support"],
modelRoute: "primary",
};
constructor(
private readonly sessionId: string,
private readonly model: ModelAdapter,
private readonly trace: TraceWriter,
) {}
async acceptFinalTranscript(text: string): Promise<void> {
if (this.active) {
this.active.controller.abort("user_interruption");
this.trace.emit({
sessionId: this.sessionId,
turnId: this.active.turnId,
phase: "input",
status: "interrupt_requested",
reason: "new_final_transcript",
});
}
const turnId = randomUUID();
const requestId = `${this.sessionId}:${turnId}:1`;
const controller = new AbortController();
const contextHash = sha256(this.fixedContext);
this.active = { turnId, controller };
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "input",
status: "accepted",
contextHash,
});
const startedAt = performance.now();
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "llm",
status: "started",
requestId,
contextHash,
});
try {
const answer = await withDeadline(
this.model.reply({ text, requestId, signal: controller.signal }),
300,
controller,
);
// モデルが中断を無視して完了しても、古い結果は採用しない。
if (controller.signal.aborted || this.active?.turnId !== turnId) {
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "llm",
status: "discarded",
requestId,
durationMs: Math.round(performance.now() - startedAt),
reason: "not_current_turn",
});
return;
}
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "llm",
status: "completed",
requestId,
durationMs: Math.round(performance.now() - startedAt),
});
await this.play(answer, turnId, controller.signal);
} catch (error) {
const reason = error instanceof Error ? error.message : "unknown";
if (reason === "deadline_exceeded") {
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "llm",
status: "deadline_exceeded",
requestId,
durationMs: Math.round(performance.now() - startedAt),
});
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "recovery",
status: "selected",
reason: "fixed_retry_guidance",
});
return;
}
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "llm",
status: "canceled",
requestId,
reason,
});
}
}
private async play(
answer: string,
turnId: string,
signal: AbortSignal,
): Promise<void> {
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "tts",
status: "started",
});
// TTSのチャンク再生を簡略化したもの。
for (let index = 0; index < 6; index++) {
if (signal.aborted || this.active?.turnId !== turnId) {
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "tts",
status: "canceled",
reason: "interrupted_during_playback",
});
return;
}
await sleep(25);
}
// デモでは本文をログへ出さない。未使用警告を避けるため長さのみ参照する。
void answer.length;
this.trace.emit({
sessionId: this.sessionId,
turnId,
phase: "tts",
status: "completed",
});
}
}
async function main(): Promise<void> {
const agent = new VoiceTurnController(
randomUUID(),
new FakeModel(),
new TraceWriter(),
);
// 1ターン目のLLM処理中に、2ターン目で割り込む。
const first = agent.acceptFinalTranscript("最初の質問です");
await sleep(70);
const second = agent.acceptFinalTranscript("やっぱり質問を変えます");
await Promise.all([first, second]);
// LLM期限超過と復旧選択を再現する。
await agent.acceptFinalTranscript("遅延テスト");
}
await main();
実行します。
npm run demo
trace.ndjsonには、概ね次の因果関係が残ります。
1ターン目: input accepted
1ターン目: llm started
1ターン目: interrupt requested
2ターン目: input accepted
2ターン目: llm started
2ターン目: llm completed
2ターン目: tts started
1ターン目: llm discarded
2ターン目: tts completed
3ターン目: llm deadline exceeded
3ターン目: recovery selected
時刻順にログが並んでいても、処理の完了順はターン順になりません。だからこそ、配列上の直前イベントではなくturn_idで関連付けます。
手順4:ログの不変条件を自動確認する
目視だけでは、イベント数が増えたときに破綻します。次の検証コードを追加します。
// src/verify.ts
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
type Event = {
turnId: string;
phase: string;
status: string;
requestId?: string;
contextHash?: string;
};
const events = readFileSync("trace.ndjson", "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as Event);
const byTurn = new Map<string, Event[]>();
for (const event of events) {
const current = byTurn.get(event.turnId) ?? [];
current.push(event);
byTurn.set(event.turnId, current);
}
for (const [turnId, turnEvents] of byTurn) {
const accepted = turnEvents.some(
(event) => event.phase === "input" && event.status === "accepted",
);
assert.equal(accepted, true, `${turnId}: input acceptedがない`);
const discarded = turnEvents.some((event) => event.status === "discarded");
const ttsCompleted = turnEvents.some(
(event) => event.phase === "tts" && event.status === "completed",
);
assert.equal(
discarded && ttsCompleted,
false,
`${turnId}: 破棄した回答を読み上げている`,
);
const llmStarts = turnEvents.filter(
(event) => event.phase === "llm" && event.status === "started",
);
for (const start of llmStarts) {
assert.ok(start.requestId, `${turnId}: requestIdがない`);
assert.ok(start.contextHash, `${turnId}: contextHashがない`);
}
}
const requestIds = events
.map((event) => event.requestId)
.filter((value): value is string => Boolean(value));
assert.equal(
new Set(requestIds).size,
new Set(
events
.filter((event) => event.phase === "llm" && event.status === "started")
.map((event) => event.requestId),
).size,
"LLM開始イベント間でrequestIdが重複している",
);
console.log(`OK: ${byTurn.size} turns, ${events.length} events`);
実行します。
npm run verify
この検証は回答の自然さを評価していません。まず保証しているのは、以下の制御上の不変条件です。
- すべてのターンが確定入力から始まる
- 破棄されたLLM結果は読み上げ完了にならない
- LLMリクエストをターンへ関連付けられる
- 固定文脈の版を後から照合できる
Tencent Conversational AIへ接続する位置
実環境では、モックを次の境界で置き換えます。
| 本記事の要素 | 実環境での接続先 |
|---|---|
acceptFinalTranscript() |
STTの確定発話イベント |
ModelAdapter |
Tencent Conversational AIのLLM設定で接続するモデル/エージェント基盤 |
requestId |
公式設定に従ったルーティング・観測用識別子 |
play() |
TTS生成と音声再生制御 |
AbortController |
アプリ内のターン中断要求。実SDKの停止操作とはアダプターで接続 |
TraceWriter |
ログ基盤、トレース基盤、監査用ストレージ |
AIコンパニオンやキャラクター対話は、Tencent RTCのSocial Entertainmentソリューションで扱われるユースケースです。
ただし、ユースケースが提供されていることと、自分のアプリで安全な会話制御が完成していることは別です。特に割り込みでは、少なくとも次の時刻を分けて記録します。
interrupt_detected_at ユーザーの割り込みを検出した時刻
stop_requested_at TTSまたは再生へ停止を要求した時刻
stop_acknowledged_at 実際に停止を確認した時刻
LLM completedだけを見ても、ユーザーが古い音声を聞き続けたかは分かりません。本番では再生停止の確認イベントまで追跡対象にしてください。
確認方法:本番接続前に壊しておく5ケース
1. LLM完了直前の割り込み
期待結果:古いターンがdiscardedまたはcanceledになり、TTS完了へ進まない。
2. TTS再生中の割り込み
期待結果:interrupt_requestedからstop_acknowledgedまでの時間を計測できる。停止後に古い音声を再開しない。
3. モデルの期限超過
期待結果:無期限に待たず、決められた復旧案内へ遷移する。遅れて届いた回答を読み上げない。
4. 固定文脈の順番だけを変更
ツール定義を配列で管理している場合、順番が意味を持つかを明示します。意味を持たない一覧なら、ハッシュ計算前に安定ソートします。意味を持つ会話履歴まで並べ替えてはいけません。
5. 同じターンを再試行
同じturn_idの下に新しいrequest_idを発行します。再試行回数をrequest_idだけから推測せず、必要ならattemptを明示的に追加します。
観測結果からの判断フレーム
ログが取れた後は、次の順番で判断すると「とりあえずモデルを交換する」を避けられます。
- 入力確定までが遅い:STT終端判定や無音判定を確認する
- LLM開始前が遅い:キュー、認可、ルーティング、ツール準備を確認する
- LLM処理が遅い:モデル経路、入力サイズ、固定文脈の変化を確認する
- LLM完了後が遅い:TTS生成、音声バッファ、再生開始を確認する
- 割り込み後も音声が続く:LLMではなくTTS/再生停止経路を確認する
生成AIの性能向上で回答品質は変わっても、どの回答を現在のターンとして採用するかは自動的には決まりません。人間側が決めるべきなのは、モデルの人格より先に、待機期限、割り込み条件、復旧文、保存範囲、有人移行条件です。
注意点
会話本文を標準ログへ無条件に保存しない
音声コンパニオンでは、雑談から個人情報やセンシティブな内容が入り得ます。通常ログはID、時刻、状態、所要時間を中心にし、本文が必要な調査は同意、アクセス制御、保存期限を別途設計します。
context_hashをセキュリティ機能として扱わない
ハッシュは文脈の同一性を比較する補助情報です。認証、改ざん防止、秘密情報の匿名化にはなりません。改ざん検知が必要なら、署名やアクセス制御を別に実装します。
自動再試行は会話上の重複を生む
ネットワーク上の失敗と、モデル側では完了している失敗を区別できない場合があります。ツール実行を伴うエージェントでは、再試行前に冪等性も確認してください。
しきい値は実測から決める
本記事の300msは失敗経路を短時間で再現するための値であり、本番推奨値ではありません。実際にはSTT、LLM、TTS、ネットワークを分けて計測し、会話体験と復旧方針に合わせて期限を決定します。
関係性の開示: 著者はTencent RTCと関係があり、本記事の実装上の確認にはTencent RTC公式ドキュメントを参照しました。コード中の観測・制御レイヤーは説明用の独自実装であり、公式SDKのAPI名を模したものではありません。