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の「どこで待ったか」を追跡する:turn_id・context_hash・中断ログをTypeScriptで実装する

0
Last updated at Posted at 2026-09-09

リアルタイム音声AIのデモは、ユーザーの声に一度答えられれば成立します。しかし本番に近づけると、次の問いに答えられないことが問題になります。

  • 返答が遅かったのは、音声認識、LLM、音声合成のどこか
  • ユーザーが割り込んだ後、古い回答はどこまで処理されたか
  • 同じ設定のはずなのに、なぜ特定のデプロイだけ挙動が変わったか
  • LLMの障害時に、ユーザーへ何を返したか

モデルの管理画面、RTCのログ、アプリのログを別々に見ても、同じ会話ターンとして結び付かなければ原因を判断できません。必要なのはログを増やすことではなく、一つの発話を最後まで追跡できる識別子とイベント設計です。

この記事では、Tencent Conversational AIへ接続する前段として、turn_idrequest_idcontext_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

この検証は回答の自然さを評価していません。まず保証しているのは、以下の制御上の不変条件です。

  1. すべてのターンが確定入力から始まる
  2. 破棄されたLLM結果は読み上げ完了にならない
  3. LLMリクエストをターンへ関連付けられる
  4. 固定文脈の版を後から照合できる

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を明示的に追加します。

観測結果からの判断フレーム

ログが取れた後は、次の順番で判断すると「とりあえずモデルを交換する」を避けられます。

  1. 入力確定までが遅い:STT終端判定や無音判定を確認する
  2. LLM開始前が遅い:キュー、認可、ルーティング、ツール準備を確認する
  3. LLM処理が遅い:モデル経路、入力サイズ、固定文脈の変化を確認する
  4. LLM完了後が遅い:TTS生成、音声バッファ、再生開始を確認する
  5. 割り込み後も音声が続く:LLMではなくTTS/再生停止経路を確認する

生成AIの性能向上で回答品質は変わっても、どの回答を現在のターンとして採用するかは自動的には決まりません。人間側が決めるべきなのは、モデルの人格より先に、待機期限、割り込み条件、復旧文、保存範囲、有人移行条件です。

注意点

会話本文を標準ログへ無条件に保存しない

音声コンパニオンでは、雑談から個人情報やセンシティブな内容が入り得ます。通常ログはID、時刻、状態、所要時間を中心にし、本文が必要な調査は同意、アクセス制御、保存期限を別途設計します。

context_hashをセキュリティ機能として扱わない

ハッシュは文脈の同一性を比較する補助情報です。認証、改ざん防止、秘密情報の匿名化にはなりません。改ざん検知が必要なら、署名やアクセス制御を別に実装します。

自動再試行は会話上の重複を生む

ネットワーク上の失敗と、モデル側では完了している失敗を区別できない場合があります。ツール実行を伴うエージェントでは、再試行前に冪等性も確認してください。

しきい値は実測から決める

本記事の300msは失敗経路を短時間で再現するための値であり、本番推奨値ではありません。実際にはSTT、LLM、TTS、ネットワークを分けて計測し、会話体験と復旧方針に合わせて期限を決定します。


関係性の開示: 著者はTencent RTCと関係があり、本記事の実装上の確認にはTencent RTC公式ドキュメントを参照しました。コード中の観測・制御レイヤーは説明用の独自実装であり、公式SDKのAPI名を模したものではありません。

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?