0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qwen3.8-27B は MoE ではなかった — ローカル音声対話AIへの採用を30回計測して見送るまで

0
Posted at

Summary
ローカル完結の音声対話AI(Ryzen AI MAX+ 395 / gfx1151)の LLM を、
Qwen3.6-35B-A3B (MoE) から新しい Qwen3.8-27B に載せ替えようとして、見送った話。
27B という数字と「27B なら 35B-A3B より軽いだろう」という素朴な期待は外れで、
Qwen3.8-27B は dense だった。名前からは分からないので GGUF のメタデータで確認した。
結果、生成速度は 11.1 tok/s(MoE は約50 tok/s)。
エンドツーエンド(発話終了 → コテコが喋り出すまで)を各30回測ると、
中央値は 735ms → 829ms と大差ないのに、1秒を超えた回数が 0/30 → 14/30 に増えた。
中央値ではなく分布が3つの山に割れるのが本質で、その原因まで追った。
結論は「このクラスで Qwen3.8 の MoE 版が出るまで Qwen3.6-35B-A3B を継続」。
ついでに llama.cpp の既定ポートが 8080 → 9931 に変わる件も先回りで対応した。

はじめに

ローカル音声対話AIのSTTをWhisperXからNeMoに載せ替えて、発話終了から56msで確定させるまで
の続きです。音声 → STT → LLM → TTS → VRM リップシンクをローカルだけで回すスタックの、
今度は LLM 部分の話。

Qwen3.8 がリリースされ、27B が出たので載せ替えようとしました。
いまは Qwen3.6-35B-A3B (MoE、アクティブ約3B) を使っていて、
「新しい世代の 27B なら普通に置き換えられるだろう」くらいの気持ちでした。

結論から言うと見送りました。 ただ、見送るまでに測ったものの方が再利用価値があると思うので、
そこを厚めに書きます。特に「モデル名から MoE かどうかは分からない」のと、
中央値だけ見ていると劣化を見逃す」の2点は、他のモデルでもそのまま効くはずです。

検証環境

項目 内容
機種 GMKtec NucBox EVO X2
CPU/GPU AMD Ryzen AI MAX+ 395 / Radeon 8060S (gfx1151, 48GB VRAM)
OS Ubuntu 26.04 LTS
ROCm 7.14.0 (/opt/rocm, gfx1151 ネイティブビルド)
llama.cpp build 10364 (153d324bc), GGML_HIP=ON / AMDGPU_TARGETS=gfx1151
比較元 LLM Qwen3.6-35B-A3B UD-Q4_K_XL (MoE)
比較先 LLM Qwen3.8-27B Q4_K_M (dense)
STT nvidia/nemotron-3.5-asr-streaming-0.6b (NeMo)
TTS VOICEVOX (docker, CPU)
検証日 2026-08-16

差分・記録一式: https://github.com/kotetsuy/AIassistant


第1部 — ダウンロードと単体動作確認

GGUF はどこから取るか

Qwen3.8-27B の GGUF はいくつも上がっていますが、llama.cpp で使うなら
llama.cpp プロジェクト自身の ggml-org/Qwen3.8-27B-GGUF が素直です。

mkdir -p ~/qwen3.8
hf download ggml-org/Qwen3.8-27B-GGUF \
  Qwen3.8-27B-Q4_K_M.gguf --local-dir ~/qwen3.8
hf download ggml-org/Qwen3.8-27B-GGUF \
  mmproj-Qwen3.8-27B-Q8_0.gguf --local-dir ~/qwen3.8
ファイル サイズ
Qwen3.8-27B-Q4_K_M.gguf 18.97 GB (17.67 GiB)
mmproj-Qwen3.8-27B-Q8_0.gguf 0.63 GB

Qwen3.8-27B は画像入力に対応しているので、vision を使うなら mmproj も要ります。

hf download--include でファイルを複数指定すると、引数の取り方によっては
Ignoring --include since filenames have been explicitly set」と警告が出て
一部しか落ちません。ファイル名は位置引数で渡すか、1つずつ実行するのが確実です。
私はこれで最初 mmproj だけ落ちていました。

llama.cpp 側の対応

Qwen3.8-27B のアーキテクチャは qwen35 です。llama.cpp では
PR #22673(MTP サポートと同時)で
入っているので、そこそこ新しい master なら追加作業は要りません。

grep -n "QWEN35" ~/llama.cpp/src/llama-arch.cpp
# 41:    { LLM_ARCH_QWEN35,           "qwen35"           },
# 42:    { LLM_ARCH_QWEN35MOE,        "qwen35moe"        },

この時点で伏線が出ています。 qwen35qwen35moe は別アーキとして定義されている。

起動と単体テスト

llama-server -m ~/qwen3.8/Qwen3.8-27B-Q4_K_M.gguf \
  --mmproj ~/qwen3.8/mmproj-Qwen3.8-27B-Q8_0.gguf \
  -ngl 99 -c 8192 --host 127.0.0.1 --port 9931

モデルロード 2.3秒、VRAM 21.2GB(48GB中)。全レイヤー GPU オフロードで余裕があります。

つまづき1: 応答が空で返ってくる

最初のテストがこれでした。

curl -s http://127.0.0.1:9931/v1/chat/completions -H "Content-Type: application/json" -d '{
  "messages":[{"role":"user","content":"日本の首都はどこですか?一文で答えてください。"}],
  "max_tokens":200
}'
content    : ''
usage      : {'completion_tokens': 200, ...}

200トークン生成しているのに content が空。推論モデルなので、思考(reasoning_content)で
max_tokens を使い切っていました。
チャットテンプレートを覗くと分かります。

{%- if enable_thinking is undefined or enable_thinking is true %}
    {%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}

既定の reasoning_effort が xhigh そりゃ200トークンでは終わりません。
max_tokens を 1024 にしたら普通に返ってきました。

reasoning_content: ユーザーは日本語で「日本の首都はどこですか?…」と尋ねている。…答えは東京。
content          : 日本の首都は東京です。

音声対話では thinking を切る

うちの ttllm ブリッジは元々 thinking を切っています(初音を1秒台に入れるため)。

"chat_template_kwargs": {"enable_thinking": False},

Qwen3.8 のテンプレートでもこれは効きました。enable_thinking が false のとき、
<think>\n\n</think>\n\n を prefill して思考を空で閉じる実装です。

{%- if enable_thinking is defined and enable_thinking is false %}
    {{- '<think>\n\n</think>\n\n' }}

実際に投げると reasoning_contentNonecontent に直接返ってきます。
SSE ストリーミング(stream: true)の delta も問題なし。

content   : 'うん、天気いいのだ。'
reasoning : None

vision も動く

赤い楕円と青い四角を描いた PNG を投げてみました。

- 左上:青い正方形
- 中央:赤い楕円

正しい。ただしログに警告が出ます。

Qwen-VL models require at minimum 1024 image tokens to function correctly on grounding tasks

座標を答えさせるような grounding をやるなら --image-min-tokens 1024 が要るようです。
単純な物体認識では問題ありませんでした。


第2部 — MoE だと思っていたら dense だった

ここからが本題です。

単体テストの時点で「生成が遅いな」と感じました。測ると 11.2 tok/s
Qwen3.6-35B-A3B (MoE) は同じマシンで tg128 ≈ 50 tok/s(以前ベンチしたときの値)なので、
総パラメータが少ない 27B の方が4倍以上遅いことになります。

モデル名では判断できない

HuggingFace のタグを見ると、Qwen/Qwen3.8-27Bqwen3_5
一方 Qwen/Qwen3.8-2.4T-A95Bqwen3_5_moe_text でした。
つまり Qwen3.8 ファミリーに MoE はあるが、27B は dense ということです。

推測で終わらせたくないので、GGUF のメタデータを直接読みました。

# ~/llama.cpp で実行
import sys; sys.path.insert(0, 'gguf-py')
from gguf import GGUFReader

r = GGUFReader('/home/araki/qwen3.8/Qwen3.8-27B-Q4_K_M.gguf')
f = r.fields['general.architecture']

print('arch        :', bytes(f.parts[f.data[0]]).decode())              # qwen35
print('MoE keys    :', [k for k in r.fields if 'expert' in k])          # []
print('exps tensors:', len([t.name for t in r.tensors if 'exps' in t.name]))  # 0

比較すると一目瞭然でした。

Qwen3.8-27B Qwen3.6-35B-A3B
general.architecture qwen35 qwen35moe
expert_count キー自体が存在しない 256
expert_used_count 8
*_exps テンソル 0個 120個
FFN テンソル blk.N.ffn_{down,gate,up}.weight エキスパート版

Qwen3.8-27B の構成は 64層 / hidden 5120 / FFN 17408 / GQA 24:4 heads / context 262144 の、
素直な dense です。

実測が裏付ける

llama-server のログには毎リクエストの内訳が出ます。

eval time = 4402.19 ms /  49 tokens ( 89.84 ms per token, 11.13 tokens per second)
eval time = 4494.86 ms /  50 tokens ( 89.90 ms per token, 11.12 tokens per second)
eval time = 6595.36 ms /  73 tokens ( 90.35 ms per token, 11.07 tokens per second)

1トークンあたり約90ms で安定。 19GB の重みを毎トークン全部読むので、
帯域律速のこのマシンではこうなります。MoE ならアクティブ約3B分しか読まないので、
同じ帯域でも桁が違う。この差は量子化やビルドオプションでは埋まりません。

念のため補足すると、これは Qwen3.8-27B が悪いモデルという話ではありません。
帯域が細い iGPU に dense 27B が向かないというだけで、
広帯域の dGPU なら評価は変わるはずです。


第3部 — エンドツーエンドで30回測る

単体の tok/s が落ちても、音声対話として体感が悪化するとは限りません。
うちのパイプラインは LLM の出力を文単位で切って、順次 VOICEVOX に流すので、
初音までの時間は「第1文が出るまで」で決まります。全文の生成速度ではありません。

なので実際に測りました。既存のベンチスクリプト(bench/bench_e2e.py)で、
発話終了 → three-vrm の WebSocket に最初の speak チャンクが届くまでを計測します。

# VOICEVOX / llama-server(9931) / ttllm(8001) / three-vrm(8000) を起動して warmup 済みの状態で
ttllm/.venv/bin/python bench/bench_e2e.py --runs 30 --audio human_river --label "nemo-qwen3.8-27B"

条件はベースラインと完全に揃えました(音声 human_river 3.5秒、STT は NeMo、
LLM は -fit off -ngl 99 -c 8192)。

結果

経路 モデル 中央値 平均 最小 最大 1秒超
batch Qwen3.6-35B-A3B (MoE) 735 ms 723 ms 415 ms 939 ms 0/30
batch Qwen3.8-27B (dense) 829 ms 1204 ms 799 ms 2222 ms 14/30
stream Qwen3.6-35B-A3B (MoE) 615 ms 608 ms 353 ms 903 ms 0/30
stream Qwen3.8-27B (dense) 772 ms 1171 ms 743 ms 3721 ms 12/30

中央値だけ見ると +94ms / +157ms で、大したことがないように見えます。
実際、最初にこの行だけ見たときは「意外といけるのでは」と思いました。

しかし平均が中央値を大きく上回っていて(829 vs 1204)、明らかに分布が歪んでいます。
30回分の生データを並べるとこうでした(batch, ms)。

 799  802  805  805  806  807  810  812  816  816  818  819  821  828  828  829   ← 16回
1438 1439 1439 1441 1451 1457 1468 1472 1479 1512                                 ← 10回
1995 2118 2169 2222                                                               ←  4回

3つの山にきれいに割れています。 MoE 側は 415〜939ms に全30回が連続的に収まっていたので、
性質がまったく違う。

なぜ3つの山になるのか

山の間隔は 約640ms と約655ms。90ms/token で割ると 約7トークンです。

つまりこういうことでした。

  • 初音までの時間 = STT + 第1文のトークン数 × 90ms + VOICEVOX 合成
  • LLM のサンプリングで第1文の長さが毎回変わる
  • 第1文が7トークン伸びるごとに、初音が630ms 遅れる

MoE のときは 20ms/token だったので、第1文が7トークン伸びても140ms。
誤差に埋もれて見えませんでした。dense にした瞬間、第1文の長さが支配項になったわけです。

これは「中央値で評価すると劣化を見逃す」典型例だと思います。
体感で効くのは「1秒を超えた回数」の方で、そこは 0/30 → 14/30 と明確に悪化しています。
5回くらいの計測だと、たまたま下の山ばかり引いて「問題なし」と判断していた可能性があります。


第4部 — 結論と、やらなかったこと

結論: このクラスで MoE 版が出るまで Qwen3.6-35B-A3B を継続

採用は見送り、設定を Qwen3.6-35B-A3B に戻しました。

  • Qwen/Qwen3.8-2.4T-A95B は MoE ですが、総2.4T / アクティブ95B で本機には載りません
  • 27B クラスの MoE 版が出たら、その時点で同じベンチをそのまま回して再評価します
  • 計測の生データは bench/results_e2e.jsonnemo-qwen3.8-27B ラベルで残しました
    (既存の nemo ベースラインは温存)

Qwen3.8-27B 自体はテキストも vision も正しく動いていて、品質面で落とした判断ではありません。
純粋に、このマシンの帯域と、初音1秒という要件に合わなかったという話です。

効きそうだが今回はやらなかったこと

分布が割れる原因が「第1文のトークン数」だと分かったので、対策は2方向あります。
どちらも今回は試していません(見送りが決まったので)。次に MoE 版が来たときの宿題です。

1. 第1文を短く縛る

ttllm/run.sh に、まさにこれ用の SYSTEM_PROMPT がコメントアウトされたまま残っていました。

# export SYSTEM_PROMPT="... 返答は必ず短い一文から始めること。
#   最初の一文は15文字以内の相づち・結論・呼びかけにして、すぐ「。」で言い切る。..."

有効化すれば分布が下端(約800ms)に寄るはずです。dense のままでも効きます。

2. MTP 投機デコード

ggml-org/Qwen3.8-27B-GGUF には mtp-Qwen3.8-27B-Q4_0.gguf(約1.7GB) があります。
llama.cpp の --spec-type draft-mtp で使えるので、90ms/token 自体を下げられます。
以前 Qwen3.6-27B + MTP で +31.7% を実測しているので、ここでも効くはずです。


おまけ — llama.cpp の既定ポートが 9931 になります

今回の作業中、llama-server の起動ログにこんな告知が出ていました。

NOTICE: server default port will be changed to :9931 in a future release
        ref: https://github.com/ggml-org/llama.cpp/pull/26508

8080 は他のサービスと衝突しがちなので妥当な変更です。
どうせ後で踏むので、今回まとめて 9931 に移行しました

  • start_all.sh / stop_all.sh の起動・停止対象ポート
  • ttllm の LLAMA_SERVER_URL 既定値
  • ドキュメント一式

移行後に stop_all.sh がちゃんと 9931 の llama-server を検出して止められることも確認済みです。


まとめ

やろうとしたこと 音声対話AIの LLM を Qwen3.6-35B-A3B → Qwen3.8-27B に載せ替え
分かったこと Qwen3.8-27B は dense(名前からは分からない。GGUF メタデータで確認)
生成速度 90ms/token = 11.1 tok/s(MoE は約50 tok/s)
初音までの時間 中央値 735→829ms だが、1秒超が 0/30 → 14/30
原因 初音が「第1文のトークン数 × 90ms」に支配されるようになった
結論 見送り。27B クラスの MoE 版が出るまで Qwen3.6-35B-A3B 継続

持ち帰りとしてはこの2つです。

  1. モデル名やパラメータ数から MoE か dense かは判断できない。 GGUF なら
    general.architectureexpert_count を見れば1秒で分かるので、載せ替え前に見る
  2. 中央値だけで評価しない。 分布を並べると、平均との乖離が原因究明の入口になる

「新しいモデルが出たから載せ替える」を計測なしでやっていたら、
体感が悪くなった理由が分からないまま使い続けることになっていたと思います。

差分と計測データ一式はこちらに置いてあります。
https://github.com/kotetsuy/AIassistant

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?