TL;DR
- 音声対話AIに「今は黙って待つ / 相槌を打つ / 話し始める」を文脈と沈黙の長さの両方から判断させたかった
- LLMは時間を知覚できないので、外部タイマーの特徴量を小さなMLPで変換して隠れ状態に直接足し込む(Time Adapter)
- 隠れ状態から信号が「読める」ことと、生成が「変わる」ことはまったく別問題だった
-
/W/B/Sという1トークンの制御コード+LoRAで生成経路を揃えたら、テストaccuracy 0.998まで到達 - RTX 4090で0.5秒tickのリアルタイムループが回るところまで確認
- ただし合成データでの結果なので、まだ「実験室で機構が動いた」段階です(限界も正直に書きます)
きっかけ:ChatGPTへの質問と、素人の思いつき
ことの発端は、ChatGPTにTransformerの仕組みを延々と質問していたときでした。
Attention、残差接続、hidden states……と説明を聞いているうちに、ふとこう思ったんです。
「あれ、途中の値がぜんぶ足し算で流れてるなら、外からパラメーターを直接上書きすればいいじゃん!」
モデルに何かを「伝える」のに、律儀にプロンプトへ文字を書く必要はないのでは? 数値なんだから、数値のまま中に突っ込めばいいのでは? という発想です。
実際は思っていたよりも複雑で、結局実際にやることになったのは「パラメーターの上書き」ではなく「隠れ状態への残差注入」です(パラメーター=重みは触らず、推論中に流れる活性値に足し算する)。でも方向性としては当たっていて、この素朴な発想がそのまま研究の背骨になりました。
解きたかった問題:AIは「沈黙」が読めない
作りたかったのは、人の話を聞ける音声AIです。具体的には、ユーザーが黙ったとき次の3択を正しく選べること。
| 行動 | 意味 |
|---|---|
| WAIT | まだ話の途中。黙って待つ |
| BACKCHANNEL | 「うんうん」と相槌だけ打つ |
| SUPPORT | ターンを取って短く応答する |
人間はこれを無意識にやっています。「ちょっと待って」と言われたら10秒でも待つし、言いよどんでいたら「うん」と促すし、言い終わったら話し始める。同じ2秒の沈黙でも、直前の文脈によって正解が変わるわけです。
ところが音声対話システムの多くは、これを if silence > 2.0 sec then respond みたいな固定ルールで処理しています。だから「考え中の沈黙」に割り込んでくるし、「話し終わった沈黙」で妙に待たされる。
さらに厄介なことに、LLMは経過時間を知覚できません。推論と推論の間に何秒経とうが、モデルにとっては存在しない時間です。「今、沈黙が2.5秒続いている」という情報は、誰かが外から教えてあげる必要があります。
素直な方法は、プロンプトに書くことです。
(沈黙: 2.5秒経過)
でもリアルタイム対話で定期的に(今回だと0.5秒ごとに)判断させたい場合、毎tickプロンプトを書き換えて再処理するのは筋が悪い。それに、時間は本来連続値です。文字列にして、トークナイズして、埋め込んで……と遠回りするくらいなら——
そう、ここで冒頭の思いつきが帰ってきます。数値のまま、中に足せばいい。
Time Adapter:タイマーを隠れ状態に足し算する
やったことはシンプルです。
timer_features = [silence_elapsed, delta_t, utterance_elapsed,
is_user_speaking, asr_changed] # 5次元
x = MLP(timer_features) # 5 → 64 → hidden_size の小さなアダプタ
h' = h + α * x # 指定レイヤーの隠れ状態に残差として加算
ベースモデルは Qwen2.5-Omni-3B(音声入力対応のマルチモーダルLLM)。Omni本体は凍結したまま、VAD(音声区間検出)から得たタイマー特徴量を小さなMLPで変換し、Thinkerの浅いレイヤーの隠れ状態に足し込みます。
ここで設計思想をひとつ。VADは判断しません。VADはただのセンサーです。 「沈黙が何秒続いたか」を測るだけで、「だから話す」という判断はモデル側が文脈と合わせて行う。固定閾値ルールからの脱却がこの研究の目的なので、ここは譲れないポイントでした。
実験1:そもそも時間は隠れ状態から「読める」のか
まず前提確認です。「1.5秒経過」のような時間表現をプロンプトに入れたとき、隠れ状態に時間の情報がどれくらい残っているか、線形プローブで調べました。
| モデル | log秒のR² | 相関 |
|---|---|---|
| Qwen2.5-0.5B | 0.973 | 0.987 |
| Qwen2.5-3B | 0.998 | 0.999 |
| Qwen3-4B | 0.999 | 1.000 |
ほぼ完璧に読めます。……とはいえ冷静になると、これはプロンプトに数字が書いてあるものを読み取れたというだけなので、驚きの発見ではなく「配管が詰まってないことの確認」です。でも当時は「隠れ状態、ちゃんと時間持ってるじゃん!」と一人で盛り上がっていました。
実験2:注入した時間は「効く」のか(コントロール群との戦い)
次に、Time Adapterで注入した時間信号を分類ヘッドが読めるかを検証しました。ここで大事なのはコントロール条件です。「正しい時間を注入したとき」だけ良い数字が出ても、それが偶然や副作用でないことを示さないと意味がない。
| 条件 | acc | macro F1 |
|---|---|---|
| 時間なし | 0.956 | 0.951 |
| 正しいTime Adapter | 0.990 | 0.989 |
| シャッフルした時間 | 0.790 | 0.760 |
| ランダムベクトル(ノルム一致) | 0.718 | 0.662 |
| 時間と無関係な数値 | 0.794 | 0.765 |
正しい時間だけが性能を上げ、デタラメな注入はむしろ壊す。注入ベクトルの「中身」が効いていることが確認できました。よし、勝った。あとは生成に繋ぐだけ——
挫折:「読める」のに「生成が変わらない」
ここで一回失敗しました。
分類ヘッド(プロキシ)では読めた信号を、今度は生成時にそのまま注入して、モデル自身に「WAIT / BACKCHANNEL / SUPPORT」とラベルを出力させてみたところ:
| 条件 | acc | macro F1 |
|---|---|---|
| 時間なし | 0.388 | 0.297 |
| 正しいTime Adapter | 0.578 | 0.428 |
改善はする。でもプロキシの0.99から見れば惨敗です。特にBACKCHANNELのF1がほぼ0。相槌が打てない傾聴AIって何?
原因を切り分けるため診断実験を重ねました。一番わかりやすかった証拠がこれです:
同じ隠れ状態に対して、プロキシ分類ヘッドはmacro F1 0.983、LM head(語彙への出力層)のargmaxは0.428。
つまり情報は隠れ状態に確かに存在するのに、LM headがそれを「BACKCHANNEL」というトークン列に変換してくれない。考えてみれば当然で、LM headは事前学習で「自然な続きの文章」を出すよう最適化されており、「注入された謎の残差ベクトルを読んでラベル単語を出す」なんて仕事は一度も学習していません。
表現空間に信号があることと、生成分布がそれに従うことは、別の問題。 これがこの研究で一番高くついた学びです。
他にも細かい地獄がありました:
- ラベル文字列「BACKCHANNEL」は複数トークンに割れて出力が不安定(
'WAIT. If'みたいな続きを生成し始める) - 注入レイヤーとαの調整が二律背反:layer3/α4だとBACKCHANNELが出ず、layer8/α8だとBACKCHANNELは出るがWAITが壊れる
- ちなみにGemmaの12Bで同じことを試しても解決しませんでした。スケールでは改善しない。目的関数の不一致は、パラメーター数の問題ではなかったのです
ブレイクスルー:1トークンの制御コード /W /B /S
打開策は拍子抜けするほど単純でした。
- ラベルを 1トークンで表せる制御コードに変える:
/W(WAIT)、/B(BACKCHANNEL)、/S(SUPPORT)。Qwenのトークナイザーでそれぞれ1トークンであることを確認済み - LoRAで生成目的そのものを学習させる:正しいTime Adapterを注入した状態で「最初の1トークンとして正しい制御コードを出す」ことを直接学習
要するに、「注入ベクトル → ラベルトークン」という変換をLM headに教えていなかったのだから、教えればいい。それだけの話でした。
結果(テスト500件、音声のみ入力):
| 条件 | acc | macro F1 | BACKCHANNEL F1 |
|---|---|---|---|
| 時間なし | 0.653 | 0.534 | 0.035 |
| 正しいTime Adapter | 0.998 | 0.998 | 0.996 |
| シャッフルした時間 | 0.630 | 0.619 | 0.465 |
| ランダムベクトル | 0.636 | 0.624 | 0.377 |
コントロール群は壊れたまま、正しい時間だけがほぼ完璧。外部タイマー → 隠れ状態への残差 → 生成トークンというパイプラインが、初めて端から端まで通りました。
さらに制御コードに続けて短い応答文も生成するLoRA(Control+Response)を学習:
-
/W→ 何も言わない(余計な出力 0.000) -
/B Mm-hm.→ 相槌 -
/S I'm here with you.→ 短い応答
ラベル精度0.993を保ったまま、応答文も出せるようになりました。
リアルタイムで動かす
最後に、これを疑似リアルタイムループに載せました。
マイク → VAD → タイマー特徴量 → Time Adapter → Omni Thinker(layer3に注入)
→ /W なら黙る、/B・/S なら Talker が発話 → ユーザーが話し始めたら中断
0.5秒tickでの判定レイテンシは p50 = 271ms、p95 = 309ms、500ms以内率 99.3%(RTX 4090)。ギリギリですが回ります。実際のデモログはこんな感じです:
[BACKCHANNEL] @4.55s 沈黙1.52s "Mm-hm."
[BACKCHANNEL] @10.79s 沈黙1.52s "Mm-hm."
[SUPPORT] @12.79s 沈黙3.52s "I'm here with you."
そして個人的に一番嬉しかったのがこれ。「ちょっと待ってて」と頼んだ後に長い沈黙を置いたケース:
[asked_wait_long_silence] 27 ticks イベント: なし
何も起きない。沈黙が何秒続いても、文脈が「待って」なら待つ。if silence > X のルールでは絶対にできなかった挙動です。
正直な限界(ここ大事)
浮かれた話ばかり書いたので、限界も同じ解像度で書きます。
1. ラベルは手書きルールの蒸留です。 学習データの正解ラベルは「hesitantな発話で沈黙0.5〜3秒ならBACKCHANNEL」のような人間が書いた閾値ルールから生成しています。つまりacc 0.998の意味は「モデルがルールを再現できた」であって、「人間らしいターンテイキングを獲得した」ではありません。機構の実証と能力の獲得は別物です。
2. データはほぼ合成です。 ユーザー音声はTTS(Qwen3-TTS)製で、train/testは同じ文テンプレート・同じ話者から生成。実会話への汎化は未検証です。実際、実録音では小声の言いよどみをVADが「沈黙」と誤検知して早すぎる相槌が出る、という失敗も既に観測しています。
3. 「テキストで時間を書く」との公平な比較がまだです。 silence_elapsed=2.5 とプロンプトに書いてLoRA学習する、という一番素朴なベースラインをまだ回せていません。もしそれで同等の精度が出るなら、隠れ状態注入の価値は「必須の部品」ではなく「毎tickの再処理が不要になる効率化」に絞られます。これは次にやります。
4. Omniが波形から沈黙を数えているわけではありません。 時間はあくまで外部タイマーが測り、それをモデルが使える形に変換しているだけです。ここを混同した主張はしません。
まとめと今後
- 「パラメーター直接上書きすればいいじゃん」という素人の思いつきは、「隠れ状態への残差注入」という形で実際に動いた
- ただし信号が読めることと生成が変わることの間には壁があり、越えるには生成目的の学習(1トークン制御コード+LoRA)が必要だった
- 固定閾値なしで「文脈×沈黙」からWAIT/相槌/応答を選ぶリアルタイムループが、ローカルGPU 1枚で動くところまで来た
- 0.5tickの処理が間に合っても、WAIT以外には本文の生成があり、そこで処理に時間がかかり狙っていたタイミングよりかなり遅れる
- 日本語が非常に不安定(これは元々Qwen2.5-Omni-3Bで感じていたが、日本語に対応しているものの、日本語で話しかけても英語か中国語で返事が来ることが多いため、実験中は英語でのテスト、LoRAだった。日本語でのテストは未実施)
今後は、実会話音声での評価、人手ラベルのテストセット作成、テキスト時間ベースラインとの比較をやっていきます。この方式はモデルの重みを凍結したまま後付けできるので、うまく育てば「任意の音声LLMに挿せるターンテイキング・アダプタ」になれるんじゃないかと期待しています。
黙って待てるAI、けっこう可愛いですよ。
質問・ツッコミ歓迎です。特に「そのベースライン先にやれ」系のご指摘はぐうの音も出ないので、優しくお願いします。
また、あくまで大学2年の個人が趣味で行った研究なので間違っている部分なども多くあると思います。