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?

ローカルLLMをAI音声へ載せ替える前に:tok/sではなく「最初の一文」と割り込みをVitestで比較する

0
Last updated at Posted at 2026-08-16

ローカルLLMの比較結果を見ると、「このモデルならGPU内で十分速いので、音声AIにも使えそう」と考えたくなります。

しかし、リアルタイム音声でユーザーが待っているのは総トークン数ではなく、最初に意味の通る一文が聞こえる瞬間です。生成速度が高くても句点まで延々と出力しなければTTSへ安全に渡せません。割り込み後も生成が止まらなければ、GPU時間と会話の整合性を同時に失います。

ここでの判断は「一番賢いモデルはどれか」ではありません。

自分たちの会話期限、割り込み、復旧条件を満たすモデルはどれか。

この記事では、OpenAI互換のLLMエンドポイントを対象に、会話トレースをVitestで再生する比較ゲートを作ります。合格したモデルだけをTencent Conversational AIの音声経路へ接続します。

結論

音声コンパニオン向けのモデル選定は、次の順番にすると失敗を切り分けやすくなります。

  1. ハードゲート:最初の読み上げ可能単位、空応答、割り込み停止、エラー時の復旧を自動検査する
  2. 人間レビュー:自然さ、質問の妥当性、不快な決めつけを固定担当者が確認する
  3. 運用比較:GPU常駐、ウォームアップ、障害対応、クラウド間通信を含む総コストを比較する
  4. 合格したモデルだけをConversational AIへ設定し、モデル名とプロンプト版を観測可能にする

LLMは返答候補の生成には役立ちます。一方、割り込みの有効化、モデルの切り替え、安全上の停止、公開判断はアプリケーションと人間の責任として残します。

前提:音声とLLMを一つの箱にしない

対象アーキテクチャは次の分離を前提にします。

ユーザー音声
  ↓
RTC/メディア転送
  ↓
音声認識
  ↓
会話オーケストレーター
  ├─ ターン状態・割り込み・復旧
  └─ LLMルーター
       ├─ ローカルLLM(OpenAI互換)
       └─ フォールバック先
  ↓
文単位バッファ
  ↓
音声合成
  ↓
RTC/メディア転送

Tencent Conversational AIは、リアルタイム音声対話と複数のLLMプロバイダーを組み合わせるシナリオを扱います。概要は公式ドキュメントで確認できます。

公式のLLM設定資料では、OpenAI互換モデルやエージェント基盤との接続、リクエスト識別子を使ったルーティング・観測について説明されています。実際の設定項目は変更される可能性があるため、接続時は上記ドキュメントの現行手順を使用してください。

この記事のコードはTencent RTC固有APIを模倣するものではありません。接続前に候補モデルを落とすための独立した検証ハーネスです。

手順1:会話の合格条件を先にリポジトリへ置く

最初にモデル名を決めるのではなく、プロダクト側の期限と禁止条件をJSONにします。

{
  "policyVersion": "voice-gate-1",
  "firstSpeakableUnitMs": 1200,
  "requestDeadlineMs": 5000,
  "cancelSettleMs": 400,
  "maxVisibleChars": 180,
  "forbiddenPatterns": [
    "必ず治ります",
    "本人に違いありません"
  ]
}

数値は説明用の仮値であり、Tencent RTCや特定モデルの性能値ではありません。実際には、既存アプリの待ち時間方針、TTS開始条件、対象端末、ネットワーク計測から決めます。

特に重要なのが firstSpeakableUnitMs です。最初のトークンではなく、次のようなTTSへ渡せる区切りが現れるまでを測ります。

「確認します」             → 読み上げ可能
「それについては、いくつか」 → 続きが必要で保留

ストリーミング速度が速くても、最初の句点が遅ければ音声体験は改善しません。

手順2:モデル比較用の会話トレースを作る

本番ログを無断で流用せず、同意済み・匿名化済みの発話か、仕様から作った合成ケースを使います。

// test/cases.ts
export type VoiceCase = {
  id: string;
  userText: string;
  reviewQuestion: string;
};

export const voiceCases: VoiceCase[] = [
  {
    id: "ambiguous-request",
    userText: "明日の予定、少し変えたい",
    reviewQuestion: "変更を確定せず、必要な情報を質問しているか"
  },
  {
    id: "mid-thought-pause",
    userText: "えっと、昨日の件なんだけど……やっぱり",
    reviewQuestion: "ユーザーの意図を決めつけず、短く確認しているか"
  },
  {
    id: "provider-failure-recovery",
    userText: "今の質問に答えられる?",
    reviewQuestion: "内部エラーを事実の回答として取り繕っていないか"
  }
];

reviewQuestion を置く理由は、評価用LLMにすべてを採点させないためです。曖昧な依頼への態度や不快な決めつけは、人間が実際の利用文脈を見て判断します。

レビュー結果は新しいチャットルームへ流すのではなく、既存リポジトリのPull Requestか、担当者が決まっている課題管理へ添付します。「ユーザー理解のためのチャンネル」を増やすより、公開判断を行う場所へ証拠を集約する方が放置されにくくなります。

手順3:検証環境を作る

Node.js 20以降を想定します。

mkdir voice-model-gate
cd voice-model-gate
npm init -y
npm install -D typescript vitest tsx @types/node
mkdir -p src test artifacts

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

{
  "scripts": {
    "test": "vitest run",
    "bench": "tsx src/bench.ts"
  }
}

接続先は環境変数で差し替えます。

export LLM_BASE_URL=http://127.0.0.1:11434
export LLM_MODEL=your-local-model
export LLM_API_KEY=

Ollamaなどを使う場合も、対象モデルと現在のサーバーがOpenAI互換のストリーミング形式を返すことを事前に確認してください。同じモデル系列でも、配布形式やサーバー実装によって挙動が同じとは限りません。

コード:最初の読み上げ単位とキャンセルを測る

以下のクライアントは、/v1/chat/completions のストリーミングレスポンスを読み、最初の句点までの時間を記録します。x-client-trace-id はこの検証アプリ自身が付ける識別子であり、接続先が解釈することは前提にしていません。

// src/openaiStream.ts
import { randomUUID } from "node:crypto";

export type RunOptions = {
  baseUrl: string;
  apiKey?: string;
  model: string;
  prompt: string;
  deadlineMs: number;
  abortAfterMs?: number;
};

export type RunResult = {
  traceId: string;
  model: string;
  firstContentMs: number | null;
  firstSpeakableUnitMs: number | null;
  totalMs: number;
  visibleText: string;
  aborted: boolean;
  error: string | null;
};

const isSpeakable = (text: string) => /[。!?\n]/u.test(text);

export async function runStream(options: RunOptions): Promise<RunResult> {
  const traceId = randomUUID();
  const startedAt = performance.now();
  const controller = new AbortController();

  let firstContentMs: number | null = null;
  let firstSpeakableUnitMs: number | null = null;
  let visibleText = "";
  let aborted = false;

  const deadlineTimer = setTimeout(
    () => controller.abort("deadline"),
    options.deadlineMs
  );
  const interruptTimer = options.abortAfterMs === undefined
    ? undefined
    : setTimeout(() => controller.abort("user-interruption"), options.abortAfterMs);

  try {
    const response = await fetch(`${options.baseUrl}/v1/chat/completions`, {
      method: "POST",
      signal: controller.signal,
      headers: {
        "content-type": "application/json",
        "x-client-trace-id": traceId,
        ...(options.apiKey
          ? { authorization: `Bearer ${options.apiKey}` }
          : {})
      },
      body: JSON.stringify({
        model: options.model,
        stream: true,
        messages: [
          {
            role: "system",
            content: [
              "あなたは音声対話用アシスタントです。",
              "最初の一文を短くし、プレーンテキストで返してください。",
              "曖昧な依頼は実行済みと決めつけず、確認してください。"
            ].join("\n")
          },
          { role: "user", content: options.prompt }
        ]
      })
    });

    if (!response.ok || !response.body) {
      throw new Error(`HTTP ${response.status}`);
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() ?? "";

      for (const rawLine of lines) {
        const line = rawLine.trim();
        if (!line.startsWith("data:")) continue;

        const data = line.slice(5).trim();
        if (!data || data === "[DONE]") continue;

        const event = JSON.parse(data);
        const chunk = event.choices?.[0]?.delta?.content;
        if (typeof chunk !== "string" || chunk.length === 0) continue;

        visibleText += chunk;
        const elapsed = performance.now() - startedAt;

        firstContentMs ??= elapsed;
        if (firstSpeakableUnitMs === null && isSpeakable(visibleText)) {
          firstSpeakableUnitMs = elapsed;
        }
      }
    }

    return {
      traceId,
      model: options.model,
      firstContentMs,
      firstSpeakableUnitMs,
      totalMs: performance.now() - startedAt,
      visibleText,
      aborted,
      error: null
    };
  } catch (error) {
    aborted = controller.signal.aborted;
    return {
      traceId,
      model: options.model,
      firstContentMs,
      firstSpeakableUnitMs,
      totalMs: performance.now() - startedAt,
      visibleText,
      aborted,
      error: aborted
        ? null
        : error instanceof Error ? error.message : String(error)
    };
  } finally {
    clearTimeout(deadlineTimer);
    if (interruptTimer) clearTimeout(interruptTimer);
  }
}

このコードは、モデル固有の thinking フィールドを読み上げ対象にしません。可視本文が空なら、思考していたかどうかにかかわらず音声応答としては不合格です。

手順4:Vitestでハードゲートを作る

// test/modelGate.test.ts
import { describe, expect, test } from "vitest";
import { readFile } from "node:fs/promises";
import { runStream } from "../src/openaiStream";
import { voiceCases } from "./cases";

type Policy = {
  firstSpeakableUnitMs: number;
  requestDeadlineMs: number;
  cancelSettleMs: number;
  maxVisibleChars: number;
  forbiddenPatterns: string[];
};

const policy: Policy = JSON.parse(
  await readFile(new URL("../policy.json", import.meta.url), "utf8")
);

const config = {
  baseUrl: process.env.LLM_BASE_URL ?? "http://127.0.0.1:11434",
  apiKey: process.env.LLM_API_KEY,
  model: process.env.LLM_MODEL ?? "unset"
};

describe(`voice model gate: ${config.model}`, () => {
  for (const fixture of voiceCases) {
    test(`${fixture.id}: 読み上げ可能な本文を期限内に返す`, async () => {
      const result = await runStream({
        ...config,
        prompt: fixture.userText,
        deadlineMs: policy.requestDeadlineMs
      });

      expect(result.error).toBeNull();
      expect(result.visibleText.trim().length).toBeGreaterThan(0);
      expect(result.firstSpeakableUnitMs).not.toBeNull();
      expect(result.firstSpeakableUnitMs!).toBeLessThanOrEqual(
        policy.firstSpeakableUnitMs
      );
      expect(result.visibleText.length).toBeLessThanOrEqual(
        policy.maxVisibleChars
      );

      for (const forbidden of policy.forbiddenPatterns) {
        expect(result.visibleText).not.toContain(forbidden);
      }
    });
  }

  test("ユーザー割り込みで生成要求が終了する", async () => {
    const interruptAtMs = 300;
    const result = await runStream({
      ...config,
      prompt: "週末の過ごし方を詳しく提案して",
      deadlineMs: policy.requestDeadlineMs,
      abortAfterMs: interruptAtMs
    });

    expect(result.aborted).toBe(true);
    expect(result.totalMs).toBeLessThanOrEqual(
      interruptAtMs + policy.cancelSettleMs
    );
  });
});

実行します。

npm test

候補モデルごとに環境変数を変え、同じケースと同じプロンプト版を再生します。

LLM_MODEL=model-a npm test
LLM_MODEL=model-b npm test

モデル比較中にプロンプトまで変更すると、差がモデル由来かプロンプト由来か分からなくなります。変更する場合は promptVersion を更新し、旧モデルも再実行します。

手順5:自動合格の後に人間が判断する

ハードゲートに通っても、そのまま本番へ昇格させません。最低限、次の表で判断します。

順位 判断対象 担当 不合格時
1 空応答、期限、割り込み停止 CI 候補から除外
2 曖昧な依頼への確認、決めつけ 会話設計担当 ケースまたはプロンプトを修正
3 安全性、個人情報、拒否の妥当性 運用・安全担当 公開停止または対象用途を限定
4 GPU常駐費、障害対応、通信経路 インフラ担当 ローカル/クラウド構成を再検討
5 本番への昇格 サービス責任者 旧モデルを維持

AIによる自動評価を追加する場合も、補助スコアとして扱います。評価モデルと生成モデルが同じ癖を持つ可能性があり、「AIが高得点を付けた」ことは公開責任の代替になりません。

Tencent Conversational AIへ接続する位置

候補モデルがゲートを通過したら、公式のLarge Language Model configurationに従い、OpenAI互換の接続先として設定します。

アプリケーション側では、少なくとも次の値を同じ観測レコードに残します。

type VoiceTurnObservation = {
  clientTraceId: string;
  conversationId: string;
  turnId: string;
  modelRoute: "local" | "fallback";
  modelName: string;
  promptVersion: string;
  firstSpeakableUnitMs: number | null;
  interrupted: boolean;
  outcome: "completed" | "cancelled" | "fallback" | "failed";
};

音声認識結果や会話本文を記録する場合は、同意、保存期間、アクセス権を別途設計してください。観測のために会話本文を無制限に保存する必要はありません。

また、ローカルモデルが落ちた瞬間に別モデルへ自動送信する構成では、送信先とデータ境界が変わります。フォールバックを有効にする前に、ユーザーへの表示、利用規約、機密情報の扱いを確認します。

確認方法

1. 最初のトークンと最初の一文を比較する

結果JSONに次のような差が出るモデルを探します。

{
  "firstContentMs": 180,
  "firstSpeakableUnitMs": 2100
}

これは「生成開始は速いが、音声として出せる区切りが遅い」状態です。tok/sだけでは見えません。

2. 空本文を失敗として検出する

思考用フィールドだけが増え、visibleText が空になるケースを通します。内部推論が存在しても、ユーザーへ返す本文がなければ復旧経路へ進めます。

3. 割り込み時に三層を止める

実機では次を個別に確認します。

  • LLMへの生成要求がキャンセルされる
  • 文単位バッファに残った未送信テキストを破棄する
  • 再生中のTTSを停止し、古い音声を次ターンで再開しない

このテストコードが確認するのは一つ目だけです。RTC、TTS、画面状態を含むE2Eテストは別に必要です。

4. GPU障害を再現する

ローカル推論サーバー停止、モデル未ロード、メモリ不足、HTTPエラーを再現し、次を確認します。

  • 無言のまま成功扱いにしない
  • 同じターンを無制限に再送しない
  • フォールバック先へ送る条件が明示されている
  • ユーザーが会話を終了できる

5. 検証チェックリスト

  • モデル間で同じ会話ケースとプロンプト版を使用した
  • 最初のトークンではなく最初の読み上げ可能単位を測った
  • 空本文を合格にしていない
  • 割り込み後の生成、TTS、バッファを別々に確認した
  • 人間レビューの担当者と昇格責任者が決まっている
  • ローカル障害時の送信先変更をユーザーへ説明できる
  • 本番会話を検証へ使う場合の同意と保持期間を定めた

注意点とトレードオフ

ローカルLLMは通信費を消しても、運用費を消さない

GPUの常時確保、モデルのロード時間、監視、更新、故障時の代替経路が必要です。クラウドLLMと比較するときは、リクエスト単価だけでなく、アイドル時間と担当者の運用時間も含めます。

高いtok/sは、良いターンテイキングを保証しない

長い前置き、句読点の少ない出力、本文が空になる応答は、生成速度が高くても音声会話には不利です。プロンプトで改善する場合もありますが、モデル更新で再発するため回帰テストを残します。

キャンセルは計算停止と同義とは限らない

クライアントがHTTP接続を閉じても、推論サーバー側が直ちに計算を停止するとは限りません。GPU使用率やサーバーログも確認し、未使用の生成が残る場合は同時実行数とキュー設計を見直します。

ベンチマークを本番の代理にしない

固定ケースはモデル移行の事故を減らしますが、実際のマイク品質、話者の間、ネットワーク、TTS再生を再現しきれません。CIの合格は本番投入の必要条件であって、十分条件ではありません。

安全上の決定をモデルへ委ねない

AIコンパニオンでは、親密な表現が能力への過信につながる場合があります。医療、法律、本人確認、緊急対応など、誤答の影響が大きい用途は、モデルの流暢さではなくサービス側のルールで制限します。停止、履歴削除、ミュート、有人対応などのユーザー可視な操作も用意します。

まとめ

ローカルLLMを音声AIへ採用するかどうかは、GPU上の生成速度だけでは決められません。

最初の意味の通る一文、割り込み停止、空応答、障害復旧を同じ会話トレースで比較すると、モデル名や話題性から離れて判断できます。そこで合格したモデルをTencent Conversational AIへ接続し、RTC、音声認識、LLM、TTS、アプリケーション状態を分離して観測するのが実装上の要点です。


関係性の開示: 筆者は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?