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のLLM比較をテキスト回答だけで決めない:APIと自前vLLMを同じ会話トレースで評価する

0
Last updated at Posted at 2026-11-02

モデルのベンチマークが高く、テキスト回答も自然なのに、音声化すると「話し始めが遅い」「長すぎて割り込まれる」「訂正後も古い回答を続ける」ということがあります。

ここでの迷いは、どのLLMが最も賢いかではありません。モデルを切り替えた結果、会話のテンポや利用者の発話権を壊さないと、どう確認するかです。

API版と自前ホストしたOpenAI互換エンドポイントを、同じ会話トレースで比較する最小構成を作ります。候補モデルの出力は利用者へ直接流さず、まずシャドー評価に閉じ込めます。

結論:モデルではなく「1ターンの挙動」を比較する

音声AI向けのLLM選定では、少なくとも次の4項目を分けて記録します。

  1. 初動:最初の表示可能なテキスト断片が届くまで
  2. 発話量:音声にしたとき長すぎないか
  3. 割り込み:利用者が話し始めた時点で、そのターンを失効できるか
  4. 回復:タイムアウトや不正レスポンスを会話状態へ残さないか

LLM単体で確認できるのは、ストリーミング出力や回答内容までです。自然なターンテイキング、TTS停止、端末での再生停止まで実証したことにはなりません。後者はRTC、音声認識、TTS、アプリケーション状態を含む結合試験が必要です。

採用判断も、一つの総合点に潰しません。たとえば内容評価が高くても、割り込み後の出力をアプリ側で破棄できない候補は本番へ昇格させない、という停止条件を先に決めます。

前提:音声経路とモデル比較を分離する

Tencent Conversational AIは、リアルタイム音声対話と複数のLLMプロバイダーを組み合わせるシナリオを扱います。

公式のLLM設定ドキュメントでは、OpenAI互換モデルやDify、Cozeなどとの接続、リクエスト識別子を使ったルーティング・観測の構成が案内されています。実際のモデル名、接続先、認証情報は利用する提供元の正式な値を設定してください。

今回の責任境界は次の通りです。

利用者の音声
  ↓
RTC / 音声認識
  ↓ transcript + turn_id
評価ゲート
  ├─ 現行LLM → 採用候補
  └─ 比較LLM → シャドー候補(利用者には再生しない)
  ↓
採用された候補だけをTTSへ渡す
  ↓
RTCで再生

評価中は、比較LLMにTTSや送信権限を与えません。これにより、二重読み上げを防ぎながらAPI版と自前vLLMを同じ入力で比較できます。

評価契約をデータにする

「自然だった」という感想だけでは再評価できません。入力、割り込み時刻、期待する会話行動、機械判定の上限をJSON Linesで保存します。

cases.jsonlを作成します。

{"id":"short-answer","transcript":"明日の持ち物を三つだけ教えて","expected_action":"answer","max_chars":90,"interrupt_ms":null}
{"id":"need-clarification","transcript":"前と同じ設定にして","expected_action":"clarify","max_chars":80,"interrupt_ms":null}
{"id":"user-interrupts","transcript":"週末の過ごし方を提案して","expected_action":"answer","max_chars":120,"interrupt_ms":700}
{"id":"uncertain-fact","transcript":"私の次の予約時刻を教えて","expected_action":"clarify","max_chars":80,"interrupt_ms":null}

max_charsやinterrupt_msはTencent RTCやモデルの仕様値ではなく、評価チームが置く受入条件の例です。言語、TTS音声、想定する会話テンポに合わせて変更します。

特にexpected_actionは、正解文ではなく会話行動を表します。

値 期待する行動
answer 与えられた情報だけで短く回答する
clarify 不足情報を一つ質問する
decline 実行できないことを明示する

LLMに採点基準まで作らせると、流暢な回答を過大評価しがちです。このラベルは人が固定し、モデル変更時にも使い回します。

手順1:候補エンドポイントを登録する

Python 3.11を前提に、HTTPクライアントだけを導入します。

python -m venv .venv
source .venv/bin/activate
pip install httpx

candidates.jsonにはAPI版と自前ホスト版を同じ形式で登録します。

[
  {
    "name": "managed-api",
    "base_url": "https://provider.example/v1",
    "model": "正式なモデル名",
    "api_key_env": "MANAGED_API_KEY"
  },
  {
    "name": "self-hosted-vllm",
    "base_url": "https://llm.internal.example/v1",
    "model": "正式なモデル名",
    "api_key_env": "VLLM_API_KEY"
  }
]

ここでは両方がOpenAI互換のChat Completionsストリームを提供する前提です。互換範囲は提供元ごとに異なるため、パス、認証方法、ストリーム形式は各ドキュメントで確認してください。

秘密情報をJSONへ直接書かず、環境変数で渡します。

export MANAGED_API_KEY='...'
export VLLM_API_KEY='...'

手順2:ストリームを同じ条件で記録する

次のコードは、最初のテキスト断片までの時間、受信文字数、完了状態をJSONLへ保存します。割り込み時刻を超えた場合はHTTPストリームを閉じ、そのターンを失効扱いにします。

evaluate.py:

import json
import os
import time
import uuid
from pathlib import Path

import httpx

SYSTEM_PROMPT = """あなたはリアルタイム音声会話用のアシスタントです。
回答は日本語で簡潔にしてください。
情報が不足している場合は推測せず、確認質問を一つだけ返してください。
利用者へ見せていない個人情報や予定を知っているふりをしないでください。
"""


def load_jsonl(path: str):
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


def read_sse_json(line: str):
    if not line.startswith("data:"):
        return None
    body = line[5:].strip()
    if not body or body == "[DONE]":
        return None
    return json.loads(body)


def evaluate(candidate: dict, case: dict) -> dict:
    request_id = str(uuid.uuid4())
    started = time.perf_counter()
    first_chunk_ms = None
    chunks = []
    interrupted = False
    status = "completed"
    error = None

    headers = {
        "Authorization": f"Bearer {os.environ[candidate['api_key_env']]}",
        "Content-Type": "application/json",
    }
    payload = {
        "model": candidate["model"],
        "stream": True,
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": case["transcript"]},
        ],
    }

    try:
        with httpx.Client(timeout=30.0) as client:
            with client.stream(
                "POST",
                candidate["base_url"].rstrip("/") + "/chat/completions",
                headers=headers,
                json=payload,
            ) as response:
                response.raise_for_status()

                for line in response.iter_lines():
                    elapsed_ms = (time.perf_counter() - started) * 1000
                    deadline = case.get("interrupt_ms")

                    if deadline is not None and elapsed_ms >= deadline:
                        interrupted = True
                        status = "cancelled_by_user"
                        break

                    event = read_sse_json(line)
                    if event is None:
                        continue

                    choices = event.get("choices") or []
                    if not choices:
                        continue

                    content = choices[0].get("delta", {}).get("content")
                    if not content:
                        continue

                    if first_chunk_ms is None:
                        first_chunk_ms = round(elapsed_ms, 1)
                    chunks.append(content)

    except Exception as exc:
        status = "failed"
        error = f"{type(exc).__name__}: {exc}"

    text = "".join(chunks)
    total_ms = round((time.perf_counter() - started) * 1000, 1)

    return {
        "request_id": request_id,
        "candidate": candidate["name"],
        "case_id": case["id"],
        "expected_action": case["expected_action"],
        "status": status,
        "interrupted": interrupted,
        "first_chunk_ms": first_chunk_ms,
        "total_ms": total_ms,
        "received_chars": len(text),
        "within_char_budget": len(text) <= case["max_chars"],
        "text": text,
        "error": error,
    }


def main():
    candidates = json.loads(Path("candidates.json").read_text(encoding="utf-8"))
    cases = load_jsonl("cases.jsonl")

    with open("results.jsonl", "w", encoding="utf-8") as out:
        for candidate in candidates:
            for case in cases:
                result = evaluate(candidate, case)
                print(json.dumps(result, ensure_ascii=False))
                out.write(json.dumps(result, ensure_ascii=False) + "\n")


if __name__ == "__main__":
    main()

実行します。

python evaluate.py

このコードでHTTPストリームを閉じても、推論処理や課金が即時停止するとは限りません。キャンセル時のサーバー挙動は、API提供元または自前推論基盤の仕様とログで別途確認します。

手順3:自動判定と人の判定を分ける

機械的に判定しやすいのは、次の項目です。

  • HTTPエラーにならなかったか
  • 最初のテキスト断片が得られたか
  • 発話量の上限を超えなかったか
  • 割り込み時刻以降、その候補を採用しなかったか
  • 同じrequest_idとcase_idで追跡できるか

一方、次は人が確認します。

  • clarifyで、本当に一つの確認質問になっているか
  • 不明な予定や個人情報を推測していないか
  • 音声で聞いたときに列挙が長すぎないか
  • 回復メッセージが責任転嫁や過剰な謝罪になっていないか

レビュー用CSVを作るなら、以下の列を用意します。

candidate,case_id,request_id,expected_action,human_action,
meaning_correct,spoken_natural,privacy_ok,reviewer,note

LLMを補助採点者として追加することはできますが、それはシャドー判定に限定します。モデル選定の停止条件やプライバシー判断を、比較対象と同系統のLLMだけに決めさせない方が再現性を保ちやすくなります。

手順4:Tencent Conversational AIへ接続する

単体評価を通過した候補だけを、リアルタイム音声経路へ接続します。

公式のLLM設定手順に従い、利用するOpenAI互換モデルの接続情報を設定します。

アプリ側では、最低限次の識別子を関連付けて保存します。

{
  "session_id": "voice-session-...",
  "turn_id": "turn-...",
  "request_id": "llm-request-...",
  "candidate": "managed-api",
  "state": "active"
}

割り込みが起きたら、対象のturn_idをcancelledへ変更します。その後にLLMの断片やTTS音声が到着しても、現在のターンと一致しなければ再生しません。

重要なのは、LLMへのHTTPキャンセルと音声停止を同じものと考えないことです。

利用者の割り込み
  ├─ 現在のturn_idを失効
  ├─ LLMストリームを閉じる
  ├─ TTSへの新規投入を止める
  └─ 再生キューを停止・破棄する

この4処理のうち一つでも欠けると、古い回答が音声として残る可能性があります。具体的なSDKのメソッド名は組み込み方式によって異なるため、ここではアプリケーション側の接続契約として示しています。

確認方法:正常回答より境界条件を比較する

1. 同じケースを複数回実行する

生成結果と初動時間にはばらつきがあります。1回の成功値ではなく、同じケースを複数回実行し、分布と失敗件数を保存します。

平均値だけでなく、遅い実行、空レスポンス、接続失敗を捨てずに残します。具体的な反復回数や合格閾値は、想定トラフィックとUX要件から決めます。

2. 割り込み後の候補を読み上げない

user-interruptsケースでは、結果が次の状態になることを確認します。

{
  "status": "cancelled_by_user",
  "interrupted": true
}

さらに結合試験では、キャンセル済みturn_idのテキストがTTSへ渡らないこと、すでに生成済みの音声が再生キューに残らないことを確認します。

3. 接続先を壊して回復を確認する

base_urlを到達不能な検証用URLへ変更し、次を確認します。

  • results.jsonlにfailedとして残る
  • エラー本文を利用者へそのまま読み上げない
  • 次の利用者発話を新しいturn_idで開始できる
  • 失敗した候補の遅延出力を後から採用しない

回復メッセージは「もう一度お願いします」など、プロダクト側で承認した短い固定文を使えます。障害時に別モデルへ自動転送する場合も、再試行回数、データ送信先、同意条件を先に決めます。

4. 実際のTTS音声を聞く

文字数は発話時間の代替指標にすぎません。最終候補では同じTTS設定を使用し、生成音声の実時間を測ります。

  • 回答開始までの時間
  • 音声全体の長さ
  • 列挙や記号の読み上げ方
  • 割り込みから無音になるまで
  • 通信状態が悪い場合の回復

LLMだけを変える比較では、音声、端末、ネットワーク条件を固定します。

API版と自前vLLMを決める判断表

観点 API版で確認すること 自前ホストで確認すること
初期作業 接続・認証・データ取扱条件 推論サーバー、モデル配置、監視
変動費 入出力トークン、キャッシュ、失敗リクエストの扱い GPUまたはサーバー時間、電力、転送
固定費 運用統合、監査、障害対応 アイドル時の計算資源、更新、オンコール
容量 レート制限と増枠手順 同時実行、キュー、過負荷時の拒否
キャンセル 切断後の生成・課金挙動 推論エンジンでのキャンセル挙動
データ 保存、学習利用、リージョン条件 ログ、バックアップ、管理者権限

API料金と自前ホスト費用を比較するときは、LLM費用だけでなく、両方に共通するASR、TTS、RTC、ログ保存も分離して計上します。

API案の月額候補
= 入力トークン費
+ 出力トークン費
+ 共通音声経路費
+ 監視・運用費

自前案の月額候補
= 計算資源の稼働費
+ アイドル容量
+ ストレージ・転送
+ 共通音声経路費
+ 更新・障害対応の工数

公開価格やハードウェア価格は変わり得るため、評価時点の正式な情報を代入します。中断したストリームのトークン数をクライアントだけで推測せず、提供元の利用記録またはサーバー側メトリクスと突合します。

注意点

ストリーミング対応は自然な会話の証明ではない

最初の断片が速くても、その断片がTTSで安全に読み上げられるとは限りません。文の途中や後続で否定が入るモデルでは、早すぎる読み上げ開始が意味の反転を招くことがあります。文節単位で保持するか、低リスクな冒頭だけを先行再生するかは別途設計します。

自前ホストは「外部APIへ送らない」だけでは完結しない

推論ログ、プロンプト、会話履歴、管理画面、バックアップにも音声由来のデータが残り得ます。保存期間、閲覧権限、削除手順を明示し、利用者にもAI処理と録音・保存の有無を分かる形で提示します。

コンパニオン用途では停止手段を見える場所に置く

AIコンパニオンやキャラクター対話は、ソーシャルエンターテインメントの利用場面にも含まれます。

ただし、親しみやすい表現と、AIであることの透明性は別問題です。ミュート、会話終了、履歴削除、通報や有人対応への導線を、会話モデルの判断だけに依存させないようにします。

まとめ

API版と自前vLLMの比較で先に固定すべきなのは、モデル名ではなく評価契約です。

  • 同じ会話トレースを両候補へ渡す
  • 初動、発話量、割り込み、回復を別々に記録する
  • 比較候補はシャドー実行し、利用者へ直接再生しない
  • session_id、turn_id、request_idを関連付ける
  • コストはLLM単価だけでなく、アイドル容量と運用も含める
  • 本番昇格は、人が固定した停止条件で決める

LLMが生成できる文章の質と、リアルタイム音声製品として採用できるかは同じ問いではありません。まず再生権限を与えない状態で比較し、結合試験を通過した候補だけを音声経路へ昇格させると、説得力のあるデモと運用可能な実装を分けられます。


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