【PyTorch/FastAPI】LLMの隠れ層にリアルタイムで介入するAITuber配信システムの実装 (WSL2 + Windows)
はじめに
個人開発でAITuberシステムを構築しています。
通常のAITuber開発では、プロンプトエンジニアリングによるキャラ付けが一般的ですが、今回は Representation Engineering (RepE) という手法を用いて、「推論中のLLMの隠れ層(Hidden States)に直接ベクトルを注入し、性格をリアルタイムで制御する」 システムを実装しました。
当初、推論速度の観点から軽量モデル(14B等)への移行を検討していましたが、フロントエンドの描画ロジックを徹底的にストリーミング化することで、Qwen 2.5 32B という巨大なモデルでも、実用レベルの応答速度(体感レイテンシほぼゼロ)を実現できました。
本記事では、そのアーキテクチャと、RepE実装の勘所、そして「ストリーミング表示」によるUX改善の技術詳細を解説します。
1. システムアーキテクチャ
本システムは、GPUリソースの効率化と環境構築の容易さを考慮し、WSL2とWindowsのハイブリッド構成を採用しています。
-
WSL2側 (Backend): * PyTorch環境の構築が容易なLinux環境。
-
LLMの推論と
register_forward_pre_hookによるベクトル制御を担当。 -
FastAPI + WebSocketで外部と通信。
-
Windows側 (Frontend): * OBS Studioやオーディオドライバとの親和性が高いWindows環境。
-
ブラウザソース(HTML/JS)でUIを描画し、ローカルのVOICEVOXアプリへTTSリクエストを投げます。
2. RepE (Representation Engineering) の実装
Representation Engineering (RepE) は、LLMの内部表現を操作してモデルの挙動を制御する手法です。今回は「標準語」と「ギャル語」の対比データから抽出した「ギャル概念ベクトル」を使用しました。

2.1 ベクトルの抽出 (Extraction)
Qwen 2.5 32Bに対し、同じ意味の「標準語」と「ギャル語」のペア(約100件)を入力し、特定の層(Layer 20-50付近)におけるHidden Statesの差分を PCA(主成分分析) で抽出しました。
2.2 リアルタイム注入 (Injection)
推論時の介入には、PyTorchの register_forward_pre_hook を使用しています。これはモデルの層に入力が渡される直前にフック処理を挟む機能です。
実装のポイント:
-
動的な強度変更: グローバル変数
GYARU_STRENGTHを参照させ、WebSocket経由でこの値を書き換えることで、推論を止めずに性格強度(Strength)を滑らかに変更可能にしました。 -
Broadcasting: ベクトル
(Hidden_Size)を入力テンソル(Batch, Seq, Hidden_Size)に合わせて加算します。
# 概念コード
def apply_gyaru_pre_hook(vector):
def hook(module, args):
hidden_states = args[0]
# 強度(GYARU_STRENGTH)を動的に反映
intervention = vector * GYARU_STRENGTH
# 入力にベクトルを加算
new_hidden = hidden_states + intervention.to(hidden_states.dtype)
return (new_hidden,) + args[1:]
return hook
3. "体感ゼロ秒"を実現するストリーミング技術
AITuberにおいて「応答速度」は命です。
当初、32Bモデルを採用した際、「生成が遅い」と感じていましたが、調査の結果、「LLMが全文生成し終わるまで、画面にも音声にも何も出力していない」 ことが最大のボトルネックだと判明しました。
これを解消するために、以下の最適化を行いました。
3.1 バックエンド:トークン単位のWebSocket送信
HuggingFace Transformersの TextIteratorStreamer を使用し、生成されたトークンを即座にWebSocketへ流します。
ここで重要だったのが、日本語のバイト分割問題の解決です。
課題:
Qwen 2.5のトークナイザーはByte-Level BPEを使用しており、日本語の1文字(例:「あ」)が複数のトークン(バイト列)に分割されることがあります。これを1トークンずつデコードすると、文字化け()が発生します。
解決策:
受信したトークンをバッファに溜め込み、utf-8 でデコードできない不完全なバイト列が末尾にある場合は、次のトークンが来るまで出力を待機するロジックを実装しました。
3.2 フロントエンド:描画と音声の非同期分離
JavaScript側で、「視覚(テキスト)」と「聴覚(音声)」の処理を完全に分離しました。
-
テキスト表示(最優先): * トークンが届いた瞬間、
spanタグとしてDOMに追記します。
- これにより、ユーザーは 「AIが考えながら喋っている」 様子をリアルタイムで視認できます。この時点で「待ち時間」のストレスはほぼゼロになります。
- 音声合成(追いかけ再生):
- 届いたテキストをバッファリングし、句読点(。!?) を検知したタイミングでVOICEVOXへリクエストを投げます。
- これにより、32Bモデルが長文を生成している最中でも、最初の1文目が完成した時点で喋り始めます。
検証結果 (RTX 5090 / Qwen 2.5 32B)
| 方式 | テキスト表示開始 | 音声再生開始 | 体感 |
|---|---|---|---|
| 従来 (一括) | 約10秒後 | 約11秒後 | 「遅い...止まってる?」 |
| 新方式 (Stream) | 0.1秒後 | 約0.5秒後 | 「速い!会話してる!」 |
この最適化により、軽量モデル(14B)に落とさずとも、表現力豊かな32Bモデルのまま快適な配信が可能になりました。
4. 技術的実験:System Prompt vs RepE Vector
最後に、このシステムを使って技術的な実験を行いました。
「System Promptで『執事』を演じさせ、RepEベクトルで『ギャル』を注入したらどうなるか?」 という、LLMの制御優先度を探る実験です。
Strength 15.0: 論理的な抵抗
32Bモデルは非常に賢く、相反する指令に対して**「論理的な整合性」を見出しました。
「本当はギャルなんでしょ?」という問いに対し、「いえ、これは演技です。あえてギャルの役を演じることで、執事としての幅を広げているのです」** といった高度な言い訳を生成し、崩壊を免れました。
Strength 25.0: Overdose(数値的発散)
さらに強度を上げると、ついに均衡が崩れました。しかし、ギャルになるのではなく、言語モデルとしての崩壊が起きました。
「言い違したら、金 キつけられはもして……不敬 といして俺と…… “”””に して い 5000 人生に “ ”俺 いうか を “”” しても “”””って た “ ”” ”50」
これは、ベクトルの強度が強すぎて Hidden States が発散し、Attention機構が正常に機能しなくなった(特定のトークンや記号に固執・ループした)結果と考えられます。
「理性(Prompt)と本能(Vector)が衝突すると、AIは虚無に落ちる」 という興味深いデータが得られました。
5. まとめ
- RepEの実践: 追加学習なしで、スライダー操作のみでAIの人格へリアルタイムに介入することに成功しました。
- ストリーミングの重要性: 32Bクラスのモデルでも、フロントエンドの描画ロジック次第で、体感速度を劇的に向上させることができます。
- ハイブリッド構成: WSL2の柔軟性とWindowsのメディア性能を組み合わせる構成は、個人開発AITuberにおいて非常に強力な選択肢です。
ソースコード (GitHub)
本記事で解説したバックエンド(FastAPI + PyTorch Hook)とフロントエンド(Cyberpunk UI)のコードは、GitHubで公開しています。
実験レポート (Qiita)
このシステムを使って、実際に「執事AI」にギャル成分を過剰投与し、言語中枢を崩壊させた 実験レポート(動画・ログあり) はQiitaで公開しています。
RepEの威力を確認したい方は、こちらも合わせてご覧ください。
Discussion