※本記事は個人の自己研鑽として取り組んだ内容であり、所属組織の公式見解や業務事例ではありません。
初めに
前回記事:RTX4060ローカルLLM環境で、双方向な音声会話を「疑似的」に実現するための設計検討
この記事で扱うこと
- 前回記事をもとに実装した結果と、見直した内容を報告します
- まずは、音声入力/TTS対応の前に、疑似的な双方向テキストチャットとしてハーネスを検討することで、レイテンシや、機構として成立するかを判断する必要があると考え、チャット可能な形で実装しました
- ユーザーの経路はチャットのみですが、LLMはTTSとチャットを使い分ける仕組みで実装しました。ただし今回はハーネスの検討のため、TTSテキストも出力先をチャットにいったん流しています
- RTX4060ローカルLLM環境で、llama.cpp + Gemma 4 E2Bを用いました
- ハーネスはFastAPI(Python)、UIはハーネス側でHTML + JavaScript + CSSを供給し、WebSocketでつなぎました
- 具体的な数値・厳密な条件を設定したわけではないので、ここでの見解は、私の実施環境にて観測された定性・主観寄りな情報となります
動作結果
- レイテンシの感じやチャットの感じをお伝えできるようGIFで掲示しました
- かなりレイテンシが気になる感じの現状です
事例1:細切れな順次入力と疑似双方向
USER: 東京タワー
USER: スカイツリー
LLM: 東京タワーとスカイツリー、どちらについて詳しく知りたいですか?
USER: いつできたんだろう
USER: 2つは
LLM: 東京タワーは1958年に、スカイツリーは2012年に完成しました。
- 音声会話を想定したとき、細切れ入力の文脈追従に対応できる必要があります
- 疑似な双方向に対応するため、LLMの応答タイミングに縛られずユーザーから自由に入力できる必要があります
- 今回のケースは単語の入れ替えや逐次入力へも応答ができました
事例2:しりとり
USER: しりとりしましょう
USER: あなたから
LLM: では、私が始めますね。最初の単語は「りんご」です。
USER: ごま
LLM: まめですね。では、まめから始めます。
USER: めがね
- しりとりは、誰が発言するターンか自然に認識しつつ、最後の文字を認識し別の単語を想起するという複数のタスクを行っていくため、状態保持・ターン管理を見る検証ケースの一つになりそうと思いました
- GIF画像の場面にはありませんが、LLM自身の番で、2回連続で別の単語を出す場面がありました (言い直しでもなさそうでした)
- 場の判定と、方策の決定で多少は改善したものの、未だ破綻してしまいます
実行環境
- Windows + WSL2
- Docker / Docker Compose
- GPU: Nvidia GeForce RTX 4060 8GB
- RAM: 64GB ※実施時の環境。本構成に64GBが必要という意味ではありません
前回設計ではうまくいかず試行錯誤した点
応答生成の機構
- 会話の継続感を得るために、当初、応答の生成系統を、本筋に応える系と、即時反応する相槌系の2つにすると、推論の時間を稼げると考えていました
- ただ、ローカルLLM (RTX4060)の制約上、かえってリソースを圧迫しました
- 即時反応系の出力が会話の本筋に干渉してくることで、推論を経た本回答の応答と矛盾し、会話がちぐはぐになることも見られました
会話の変遷と応答結果のずれ
- 会話はループタイマーだけではなく、イベント駆動のアプローチも必要と理解しました
- 今回、疑似な双方向を実現するため、ユーザー・LLM側ともに逐次発言します。結果、キュー処理ベースでは以下の各時点でコンテキストが古くなり得るため、出力時点での会話の文脈とずれてしまっていました
- キューした時点でのコンテキスト
- キューから取り出し、LLMに照会した時点のコンテキスト
- LLMから応答が返ってきた時点のコンテキスト
- WebSocketへ通知する直前のコンテキスト
ハーネスのコンテキスト保持・注入・推論設計の考慮不足
- 推論や会話文生成のために必要なコンテキスト、プロンプトに注入する・持ちまわす情報の形式・内容を想定していませんでした
- 結果、ただ持ちまわした情報をバックグラウンド推論のプロンプトに渡しても、結論に収束せず、状況を描写した文章が延々と持ちまわされる事象が発生しました
今回やったことの全体像
- 「トリアージして分岐→ループで回す」ではなく、初めから系統を分けループすべき処理とイベント駆動にすべき処理で分担しました
- 疑似双方向で逐次入力されることに伴うコンテキストのずれの解消を図りました
- arbiter.pyにキューやループ処理を持たせた結果肥大してしまっていたので、会話状態を持つSessionState(session_state.py)にキューや各種ステータス保持、関連処理を移動しました
- arbiter.pyでWebSocketへの通知も担っていたので、websocket_manager.pyへ分けました
- 会話の発言機会や、生成した応答が文脈的に発言に値するかのゲートをLLMで判定させるようにしました。その際、回帰的なスコア定量ではなく、選択肢から選ばせる分類にすると主観ですが安定しました
- 一方で、機械的な文字列類似度判定や、ユーザー最終入力日時による入力許容期間の導入など、ハーネス側でコードでできることも並行して行いました
- 推論で持ちまわす情報は、ユーザーgoalと、具体的なアクションで記述したtodoリスト(ステータス付き)、コンテキスト(key=valueの集合)をJSON形式で持ちまわすようにし、アクション(LLM応答)生成時などのプロンプト注入時のみ端的な文字列に成形しました
修正後のハーネス全体イメージ図 (FastAPI (Python))
- user_inputを受け取ったらWebSocketで即時エコーしつつ、会話履歴に追加します
- アクション (TTS発話 / チャット送信) 生成ループは、タイマーで定期実行し、各種情報をプロンプトに注入したうえで応答を定期的に作成します
- LLM発言出力時は、後述のゲートやturn_epoch、ユーザー最終入力日時からの経過時間で判定を行い、送信しないと判断したものや、会話の文脈が古いものは破棄します
- バックグラウンド推論ループは、タイマーで定期実行し、各種情報をプロンプトに注入したうえで、推論結果を定期的に最新化します
- ユーザー入力・LLM発言が発生するたびに、会話履歴の最新化、floor(発言の機会・場判定)、サマリ生成をイベントドリブンで行います
- SessionState内のキューは、asyncio.Lockを使い排他制御を行いました
設計変更1:ハーネスでの「トリアージ」アプローチからの変更
- 前回記事で想定していたトリアージのアプローチではなく、タスクごとに処理系統を分離するように変更しました
- 一方で、前回記事での「話す」と「考える」は、アクションとして1系統にまとめました
- 以前は、話す、考える、チャット表示、何もしないをトリアージしてキューへ割り振る構成でした
- 結果、トリアージで担う処理負荷が高まりボトルネックになりそうと認識しました
- 今回は、アクション(チャット送信、TTS発話)生成、バックグラウンド推論、サマリ生成、各種コンテキスト判定の4系統に再編しました
設計変更2:イベント駆動 + 非同期キューへの機構再編
- 生成した応答の順次出力、ユーザー送信のブラウザへのエコーや履歴追加は依然キューとループ処理が必要でした
- ただし、会話状態の更新を伴うイベントでは、コンテキストが更新されるため、既存の推論や応答生成リクエスト、その時点で残存している応答をすべてクリアする方針としました
- タイマーによるループは、アクション生成と、バックグラウンド推論のみにしました
- 一方、サマリ生成や各種コンテキスト判定は、ユーザー入力またはLLM発言が発生するたびに行うイベントドリブンにしました
- 疑似な双方向感を損なわないように、通知はキューで流し続けWebSocket配信を止めないようにしました
設計変更3:古い出力を破棄する機構の追加
- LLMへのリクエストのStream化によるリクエストの途中キャンセル対応、キュークリアの機構の追加、turn_epoch概念の導入を行うことで、キューされた時点から会話が進んだ結果、出力される頃に時期を過ぎて古い内容になってしまったものを破棄し、LLMアクションとして出さないようにしました
- Stream化前は、一度LLMへリクエストを行うとキャンセルができず処理完了を待ち応答を受け取るしかなく、古い内容でllama.cpp側が圧迫され、さらに戻ってきた応答も使えないという微妙な状況でした
- LLMへのリクエストをStream有効にし、あとからリクエストをキャンセルできるようにしたことで、ユーザーかLLMの発言が発生し会話コンテキストが更新された際に即時キャンセルを行うことで、リソースを開けることができるようになりました
- 結果、新しいコンテキストで即時再リクエストを行うことができるようにしました
turn_epoch概念の導入・一致を確認
- キュー方式を採用すると、会話のコンテキストが移り変わり古い情報をもとに生成された応答文がキューされていて、出力される頃には古くなっていることが発生しました
- そこで、turn_epochというユーザー入力のたびにインクリメントされる内部変数を設け、キューする際のturn_epochを保持させることで、送信・会話履歴への追加直前にその時点でのturn_epochと同値か判定し、不一致の場合はユーザー入力が発生し会話の文脈が古くなっているとみなし、破棄するようにしました
- 特に、Pythonにてawaitを使っている箇所は、待ち合わせをしている間に会話が進みうるので、turn_epoch判定を行い、不一致の場合は破棄しました
設計変更4:会話の場を判断する機構の追加
-
会話の履歴や各種コンテキストをプロンプトとして注入し、これらから今の会話の発言機会がどちらにあるかを、LLMが選択肢制で判定するようにしました
-
speaker_turn_state = user_holds:user保持 / llm_can_react:短反応可 / llm_can_progress:進行可 / uncertain:不明
-
LLMが黙る、軽い応答、進行、訂正の4パターンの方策をとれるようにしました
-
llm_policy = listen:無発話 / reactive:短反応 / progress:進行 / repair:訂正
設計変更5:生成した応答を発言すべきか否かのゲート(簡易的な発話抑制)の導入
- 生成された応答が、会話文脈に基づいたとき、発言すべきか黙るべきかのLLM判定と、コードベースの機械的な機構の2つのアプローチを組み合わせました
LLM判定によるゲート
- 当初は、1~5のスコア(回帰)にしていましたが、安定しませんでした
- 選択肢(分類)にしたところ、一定の水準で安定しました
- judgement = silence:送らない / send:通常送信 / force_send:強制送信
- 併せて、短く理由を出力させるようにしました
ハーネスでの機械的なゲート
- ユーザー入力のあった最終日時を内部で保持するようにしました
- 一定秒数が経過した後はLLM応答を破棄するようにハーネス側で実装しました
- ゲートが適切に判断しないことにより、だらだら話し続ける場面への対処のため、導入しました
- フラグで有効・無効を変えられるようにしました
- difflibのSequenceMatcherを使うことで機械的に過去n件のllm発言をあたり、類似度の高い発言をしている場合は破棄するようにしました
- LLM連続発話回数をハーネスで計測し、コンテキストとして添付することも考えましたが、会話履歴をすでに注入しているので見送りました
設計変更6:システムプロンプトの見直し
- システムプロンプトにて大量に指示を列挙した結果、日本語出力制約が崩れ外国語で出力される現象が発生しました
- 基本的に応答はJSON形式で受け取るようにしました。リクエスト時にJSON Schemaを渡すことで、応答時の形式の精度向上を図りました
- ただしどういう値が入るかの補足はシステムプロンプトでの補足が必要でした
- システムプロンプトではJSONPath風の記法をベースにしつつもインデントなどを使用することで重複文字列を削減しました
分かったこと
- 限られたリソース環境(RTX4060)では、LLMアクション(チャット送信、TTS発話)生成のレイヤーを重ねても現状はリソースを圧迫し、本筋の応答が遅延します
- コンテキストの引継ぎ、推論の機構の設計は重要と認識しました
- キューは必要だが、コンテキストが古くなっていないかを検証したうえで出力する機構が重要と認識しました
- ループ処理は必要ですが、トランザクションを意識した形で実施しないと、会話がちぐはぐになってしまうと認識しました
残った課題
- レイテンシは依然気になります
- ゲート・サマリー・バックグラウンド推論・LLMアクション生成を、1つのLLMモデルで担っている状況です。Gemma 4のThinkingを有効にする内容は、LLMアクション生成とバックグラウンド推論に絞ったものの、LLMの判断に依存しすぎている現状のため、llama.cpp側の負荷が高まっています
次回以降の検討テーマ
LLM照会する情報の取捨選別とレイテンシ改善
- LLMにて判定する項目を取捨選別し、統合やハーネスでのプログラム的な実装で代替できないかなどを含め検討し、LLMリクエスト量を減らしたり、工夫などをしてレイテンシをもう少し実用レベルに近づけたいです
ツールコールの実装 (文字列照会)
- 現在外部情報を一切活用できないので、mdファイルやyamlファイルに記述した情報を検索する手段をツールとしてLLMへ提供した時どういう挙動になるか検討したいです
- 会話しながら外部情報を自律的にとって回答に反映してくれると便利そうですが、どのようになるのか未知数です
会話体験の改善
- 推論に回す内容を見直しつつ、待ち時間にも何らかの会話をつなぐ応答を入れるなどで、会話が続いている感覚がえられないか検討したいです
- ただ、LLMアクションとして、つなぎの応答と本筋の回答との整合性をどのように維持するかが焦点になりそうです
むすび
- やはり、LLM応答のレイテンシは課題です
- 疑似で今回はテキストでのチャットとはいえ、双方向的に、LLMの応答を待たずにユーザー側が入力していけるのは、結構便利でした
- しりとりでの破綻からうかがえるように、状態保持や、込み入った話にはさらなるハーネスの機構検討が必要そうです
- ご意見や、誤りなどありましたら、コメント欄にて教えていただけますと幸いです
- ここまでお読みくださりありがとうございました

