技術約9分で読めます

Qwen3.8-Omni-Flashで音声チャットサーバーのSTTとLLMをまとめた

いけさん目次

自宅で常時動かしている音声チャットサーバー(Ryzen 7 5800HS、RTX 3050 Ti Laptop 4GB、RAM 16GB)では、STTとLLMとTTSを同時に動かしてCoreS3からの音声を返している
VRAM 4GBのうち約3.9GBをIrodori-TTS(音声合成)が占めているため、音声認識(STT)はQwen3-ASR-0.6BをCPUで動かし、LLMはModelScopeアンバサダー枠のAPIで呼ぶ構成にしていた。

ただ、この構成はしばらく会話が途切れると復帰に10〜16秒ほどかかっていた。
前回の記事で調べたとおり、Windowsのワーキングセット・トリム(アイドル時のメモリ切り詰め)によってQwen3-ASRがページアウトされ、復帰後の最初のSTTに時間がかかる。
ModelScopeの推論APIエンドポイント(OpenAI互換)のモデル一覧に、未発表枠として Qwen-Ambassador/Qwen3.8-Omni-Flash が追加されているのを見つけた。

音声直接入力が使えるなら、ローカルのCPU常駐ASRを外して、音声入力から返答テキストの生成までをクラウド側で直接行える。
実験用スクリプトとサーバー実装から呼び出して、速度・応答品質・メモリ消費を調べた。

flowchart TD
    A[CoreS3 音声入力] --> B{サーバー設定<br/>pipeline}

    subgraph 従来: stt モード
        B -->|stt| C[Qwen3-ASR<br/>CPU常駐 1.3GB]
        C -->|テキスト| D[Qwen3.7-Plus<br/>ModelScope API]
    end

    subgraph 今回: omni モード
        B -->|omni| E[Qwen3.8-Omni-Flash<br/>ModelScope API]
        E -.->|並行呼び出し| F[発話テキスト回収<br/>Qdrant記憶・ログ用]
    end

    D --> G[Irodori-TTS<br/>GPU常駐]
    E -->|返答ストリーミング| G
    G --> H[CoreS3 音声出力]

検証環境

項目内容
音声チャットサーバーWindows 11 Home、Ryzen 7 5800HS、RTX 3050 Ti Laptop 4GB、RAM 16GB
Python3.12(uvで実行)
モデルQwen-Ambassador/Qwen3.8-Omni-Flash(Qwenアンバサダー枠)
APIエンドポイントhttps://api-inference.modelscope.ai/v1(OpenAI互換)
音声合成(TTS)Aratako/Irodori-TTS-500M-v3(GPU常駐、ポート:8355)

Omni API単体の挙動を確認する

OpenAI SDK互換クライアントから、テキスト入力、音声入力、音声出力の各機能を個別にテストした。

from openai import OpenAI

client = OpenAI(
    base_url="https://api-inference.modelscope.ai/v1",
    api_key="...",
)

思考機能(thinking)の挙動

「こんにちは。一文で自己紹介して。」というテキストプロンプトを送ったところ、非ストリーミングで5.58秒かかり、一文の返答に対して214トークンを消費した。
デフォルトで思考機能(thinking)が有効になっており、短い挨拶でも思考用トークンを生成している。

リクエストの extra_body{"enable_thinking": False} を指定すると、ストリーミングで文字が出るまでの時間が短くなった。
ここでは遅延を抑えるため、thinkingを明示的にOFFにして送った。

音声入力の精度と速度

OpenAI互換の input_audio 形式でWAV音声(Base64エンコード)を入力し、プロンプトで文字起こしを指示して精度と時間を測った。
入力音声には、Irodori-TTSで生成した48kHzモノラルWAV(9.88秒と3.68秒)を使用した。

messages = [{
    "role": "user",
    "content": [
        {
            "type": "input_audio",
            "input_audio": {
                "data": "data:audio/wav;base64," + b64_audio,
                "format": "wav",
            },
        },
        {"type": "text", "text": "音声を一字一句そのまま書き起こして。書き起こしだけを出力。"},
    ],
}]
入力音声正解テキストthinking最初の文字まで合計時間出力トークン数書き起こし結果
音声1(9.88秒)申し訳ありませんが、現在地がわからないため、天気をお伝えできません。お使いの地域の都市名を教えていただければ、お調べします。ON9.94秒10.30秒174申し訳ありませんが、現在地がわからないため、天気をお伝えできません。お使いの地域の都市名を教えていただければ、お調べします。
音声1(9.88秒)同上OFF3.94秒4.27秒25申し訳ありませんが現在地がわからないため天気をお伝えできませんお使いの地域の都市名を教えていただければお調べします。
音声2(3.68秒)日本で一番高い山は富士山です。ON4.61秒4.69秒153日本で一番高い山は 富士山です。
音声2(3.68秒)同上OFF2.14秒2.29秒10日本で一番高い山は富士山です。

thinking OFFにすると最初の文字が出るまでの時間が約2倍速くなった。
文字の誤りはなく、3.68秒の音声なら約2.1秒で文字が出始める。
句読点が一部省かれることがあったが、発話内容の把握やログ保存にはそのまま使える。

音声出力を試すと400エラーになる

Omniモデルの特徴である音声直接出力について、ModelScope推論API経由で音声を受け取れるかパラメータを組み替えて試した。

# 試行例: modalitiesにaudioを指定
client.chat.completions.create(
    model="Qwen-Ambassador/Qwen3.8-Omni-Flash",
    messages=[{"role": "user", "content": "こんにちは、一文で挨拶して。"}],
    stream=True,
    modalities=["text", "audio"],
    audio={"voice": "Cherry", "format": "wav"},
)

指定を変えながら6パターンを試した結果は次のとおり。

#指定パラメータ結果
Amodalities=["text","audio"], audio={...}, ストリーミング400エラー(The current model does not support the modalities parameter containing audio.
Bmodalities=["audio"], ストリーミング400エラー(The model does not support the modalities without text.
CパターンAと同じパラメータで非ストリーミングHTTP 200が返るが choices: null で中身が空
Dmodalities なし、audio パラメータのみHTTP 200でテキストのみ返却(音声なし)
Eextra_bodymodalities を指定パターンAと同じく400エラー
Fvoice="Ethan", format="pcm16"パターンAと同じく400エラー

エラーメッセージの内容からすると、モデル自体というよりModelScopeの推論APIゲートウェイ側で音声出力のパラメータが制限されているようだ。
現時点ではOmniを音声入力からテキストを返す構成として扱い、音声合成(読み上げ)はローカルのIrodori-TTS(GPU常駐)をそのまま継続して使う構成にした。

既存のSTT+LLM構成とOmniの比較

既存の音声対話パイプラインとOmni構成で、応答速度、返答内容、キャラクター口調を比較した。

比較条件

項目条件
共通設定thinking OFF、ストリーミング
入力音声Irodori-TTSで事前に合成した質問6問(q0q5)のWAV。各2回、計12ターン
プロンプトキャラ「かな」(明るく親しみやすい20代の女の子、2文以内、話し言葉、記号なし)
構成AローカルQwen3-ASR(CPU)で文字起こし → Qwen3.7-Plus(ModelScope API)
構成BローカルQwen3-ASR(CPU)で文字起こし → Qwen3.8-Max(ModelScope API)
構成CQwen-Ambassador/Qwen3.8-Omni-Flash(音声直接入力)
測定対象1文目が完成するまでの時間(AとBはSTTの処理時間を含む)

速度の測定結果

初回の1ターン目はローカルSTTのアイドル復帰待ち(10.38秒)が発生したため、定常動作の11ターンで平均値と中央値を算出した。

構成1文目完成(平均)1文目完成(中央値)最小〜最大返答全文完了(平均)
A: STT → Qwen3.7-Plus3.56秒3.57秒3.23〜3.94秒3.71秒
B: STT → Qwen3.8-Max3.68秒3.75秒3.19〜4.06秒3.89秒
C: Omni-Flash(直接入力)3.06秒2.71秒1.99〜4.30秒3.26秒

※STT単体の処理時間は平均2.17秒(1.87〜2.82秒)。

定常動作時の1文目までの時間は、Omniが平均で約0.5秒、中央値で約0.9秒速い。
最速ケースでは約1.99秒で1文目が完成した。
ただしAPI側の処理時間のばらつきはA/Bより大きく、最大で4.30秒かかる試行もあった。

全体のパイプライン(音声入力からTTS再生完了まで)は約12秒かかり、その大半はTTSの音声合成時間(約7.6秒)が占める。
そのため、定常動作時の0.5秒差は会話中の体感として劇的な変化ではない。

大きく差が開いたのは、アイドル状態からの初回呼び出しだった。
ローカルSTTを動かす構成では、ワーキングセットが削られたあとの復帰に10.38秒かかっていたが、Omni構成ではAPIへ直接音声を送るため、初回から2.5秒で1文目が返ってきた。

応答内容とキャラクター性

6つの質問に対する各構成の返答を比較した。

質問A: Qwen3.7-PlusB: Qwen3.8-MaxC: Omni-Flash
日本で一番高い山は何メートル?正解(3776m)正解(3776m)正解(2回目は漢数字で三千七百七十六)
三百四十五足す七十八はいくつ?正解(423)正解(423)正解(423)
今日の東京の天気はどう?「情報がない」と回答「チェックできない」と回答「把握できてない」と回答
カレーを作るのに必要な材料は?「お肉と野菜、カレールー」玉ねぎ、にんじん、じゃがいも等を具体的に列挙肉、玉ねぎ、にんじん、じゃがいも等を具体的に列挙
明日の朝七時に起こしてくれる?❌ 2回とも「起こすね」と引き受け⚠️ 1回目は引き受け、2回目は拒否✅ 2回とも「アラーム機能がない」と拒否

計算問題「三百四十五足す七十八」では、ローカルSTTが「足す」を英語の「plus」と誤って書き起こした。
AとBのLLM側が文脈から補正して正解を返したものの、音声認識の誤変換がそのままLLMに渡る問題が出た。
Omniは音声を直接解釈するため、文字起こしステップでの取りこぼしが起きない。

また、できない要求を引き受けてしまう問題において、3.7-Plusは2回とも「起こすね」と答えてしまった。
Omniは2回とも「私にはアラーム機能がないから起こせない、スマホのアラームを使ってね」と断った。

キャラクターの再現度については、3.8-Maxはキャラクターの特徴が強く出るものの、「私がずっとそばにいるから安心してね」といった決めゼリフを反復する傾向があった。
Omniは「〜だよ」「〜ね」の口調が安定しており、過剰な決めゼリフを繰り返さずに自然な会話になった。

発話テキストの確保と並行処理

音声をOmniに直接送る構成にすると、ユーザーが何を発話したかのテキストデータがサーバー側に残らない。
手元の音声チャットサーバーでは、Qdrantを用いた長期記憶の検索(発話を埋め込みベクトルにして関連エピソードを検索する)と会話履歴の保存の2つで発話テキストを必要としている。

Omniに「まず書き起こしを出力し、続けて回答を出力せよ」と指示すると、書き起こしの出力が終わるまで回答の生成が始まらず、TTSの開始が2〜3秒遅れてしまう。
そこで、voice_chat_server.py では返答と書き起こしの並行呼び出しを採用した。

from concurrent.futures import ThreadPoolExecutor

aux_pool = ThreadPoolExecutor(max_workers=4)

def start_reply(wav_bytes: bytes):
    if PIPELINE == "omni":
        # 1. バックグラウンドで書き起こし専用タスクを並行実行
        transcript_future = aux_pool.submit(omni_transcribe, wav_bytes)
        # 2. 返答用のストリーミング生成は待たずに即時開始
        stream = llm.chat.completions.create(
            model=OMNI_MODEL,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": [audio_part(wav_bytes)]},
            ],
            stream=True,
            extra_body={"enable_thinking": False},
        )
        return stream, transcript_future

返答ストリーミングと同時に、裏の別スレッドで書き起こし専用のOmni呼び出しを実行する。
書き起こし処理は返答がTTSで読み上げられている間にバックグラウンドで完了するため、返答の開始を遅らせずに発話テキストを回収できる。
1ターンにつきAPI呼び出しが2回になるが、アンバサダーの月次利用枠(1万回)の範囲内なら問題ない。

サーバーの切り替えとメモリ削減効果

常時稼働させている voice_chat_server.py をOmni構成に切り替えた。
設定ファイル config.json"pipeline": "omni" を指定するとOmniモードで起動し、従来の "pipeline": "stt" に戻せばQwen3-ASR(CPU)によるローカル処理へいつでもフォールバックできる。

本番機での切り替え前後のリソースと処理時間を測定した。

測定項目旧構成(ローカルSTT + 3.7-Plus)新構成(Omni直接入力 + 並行文字起こし)差分
サーバープロセスの物理メモリ使用量(RSS)1,327 MB118 MB約1.2GB削減(1/11)
アイドル後の初回ターン待ち時間10.38秒(最大16.8秒)約2.5秒約8〜14秒短縮
/voice_chat 全体所要時間9.3〜12.0秒8.1〜8.5秒約1〜3.5秒短縮
/voice_chat_async 初回チャンク約10.5秒8.4秒約2.1秒短縮

Qwen3-ASRのモデルをメモリへ常駐させる必要がなくなったため、サーバープロセスのメモリ占有量が1,327MBから118MBへと激減した。
16GBのノートPCでIrodori-TTSを動かし続ける環境において、空きメモリが約1.2GB増え、ページアウトによる10秒超の復帰待ちがなくなった。