最近、雨不足の時の野菜かと思うくらいRAM高いですね。GPUのメモリは後付けできないですが、こちらも引っ張られてあがるのでしょうか?
この記事はLLM・LLM活用 Advent Calendar 2025の4日目です.
ざっくり
GTX1650搭載ノートパソコンで、リアルタイム音声認識・音声合成・対話生成・画像認識ができました。この記事はその制作メモです。
デモが見たい!ほんとに動いてるの?
で出展予定です。
書いていないこと
- コードは載せていないです。すみません。
目標
- 画像認識(何が写っているかの説明)の発話開始まで10秒以内
- 通常の対話は話者発話終了から3秒以内
- 顔認識は0.1秒以内
結果
- 実行時GPU利用量が3GB程度に収められた
- 処理時間は大体達成
- 画像認識時間にばらつき
- 5秒から15秒
- 音声認識は実時間の半分程度の時間
- 音声合成は句読点で分割して非同期に生成することでほぼリアルタイム
- 顔認識は0.1秒かからない
- 画像認識時間にばらつき
課題
- 一番のネックは音声認識
- 家の静かな環境では問題ないが、イベント会場では外部音が大きいのでVAD(無音検出)の調整が難しい
- 対話生成品質
- 2B量子化だとよく錯乱する
- コンテクスト長
- GPUメモリ制限&推論時間短縮のために短くせざるを得ないので対話履歴を(単純には)入れられない
今後
- 対話履歴をなんとかする
- 1650から3060(ノート向け)にすることで、応答速度、応答品質を上げる
- ファインチューニングも1650で
- 個性を持たせるには必要
- めどはついたのでそのうちまとめるかも
シナリオ
- カメラで顔を認識し閾値以上の面積を検知したら「こんにちは」などの発声
- 話者の発話認識(STT)
- 発話内容と既存知識の類似文検索
- 定型文にはサクッと返答
- 知識を問うものなら、ナレッジを加えたプロンプトを生成
- 「あなたの名前は?」→ システムプロンプトに名前を入れて推論させる
- 画像認識、ハード操作、外部関数呼び出し(MCPもどき)
- 「今何がみえる?」→ カメラ画像取得 → 画像推論
- 「おもちゃ動かして」→ GPIO制御 → システムプロンプトに「おもちゃを動かした」を入れて推論させる
- 「今何時」→ 現在時刻取得ツールを呼び出す →システムプロンプトに現在時刻を入れて推論させる
- それ以外なら通常プロンプト
- プロンプト処理
- 結果を句読点に分けて音声を出力
- 生成・発話を同時に行うことでユーザー体験を良くする
ハード環境
- CPU AMD Ryzen 5 4600H
- メモリ 16G
- GPU GTX1650 4GB
モデル・ソフト・ライブラリなど
- OS Ubuntu 24.02
以下の組み合わせでないとGPUメモリが溢れるか、応答時間が長くなる
- GPUで動作
- モデル Qwen3 VL 2B instract Q4_K_M
- 画像推論、対話生成
- LLM実行環境 llama.cpp
- Fast whisper medium
- 音声認識
- モデル Qwen3 VL 2B instract Q4_K_M
- CPUで動作
- OpenCV + YuNet
- リアルタイム顔認識
- Style-Bert-VITS2
- 音声合成
- モデル: あみたろ、あみたろの声素材工房 (https://amitaro.net/)
- cl-nagoya/ruri-v3-310m
- 入力文検索
- OpenCV + YuNet
- おまけ
- GPIO制御
AtomS3 + notif1(ソフト)
- GPIO制御
各種パラメータなど
llama.cpp(llama-server)
LLAMA_SERVER="$SCRIPT_DIR/../llama.cpp/build/bin/llama-server"
MODEL_PATH="$SCRIPT_DIR/../models/qwen3-vl-2b/Qwen3-VL-2B-Instruct-Q4_K_M.gguf"
MMPROJ_PATH="$SCRIPT_DIR/../models/qwen3-vl-2b/mmproj-F16.gguf"
$LLAMA_SERVER \
--model "$MODEL_PATH" \
--mmproj "$MMPROJ_PATH" \
--image-min-tokens 1024 \
--host "127.0.0.1" \
--port "10000" \
--ctx-size 1024 \
-ngl 99 \
--jinja \
--top-p 0.8 \
--top-k 20 \
--temp 0.7 \
--min-p 0.0 \
--flash-attn on \
--presence-penalty 1.5 \
--threads 12
pyproject.yaml
余計なものもあるかも。
[project]
name = "nonki"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"accelerate>=1.2.1",
"bitsandbytes>=0.45.0",
"ctranslate2>=4.6.0",
"fastapi>=0.120.2",
"faster-whisper>=1.2.1",
"fastmcp>=2.13.0.2",
"jinja2>=3.1.6",
"kokoro>=0.9.4",
"llama-cpp-python>=0.3.16",
"misaki[ja]>=0.9.4",
"nvidia-cublas-cu12>=12.8.4.1",
"nvidia-cudnn-cu12>=9.10.2.21",
"ollama>=0.6.0",
"openai-whisper>=20250625",
"opencv-python>=4.12.0.88",
"pillow>=12.0.0",
"pyopenjtalk>=0.3.0",
"pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"requests>=2.32.0",
"scipy>=1.16.3",
"sentence-transformers>=3.3.0",
"sentencepiece>=0.2.1",
"sounddevice>=0.5.3",
"soundfile>=0.13.1",
"style-bert-vits2>=2.0.0",
"torch>=2.9.0",
"torchvision>=0.24.0",
"transformers>=4.57.1",
"uvicorn>=0.38.0",
"webrtcvad>=2.0.10",
]
コードスニペット
llama-serverへのリクエスト部分です。
def chat_with_image(
self,
message: str,
image_path: Union[str, Path],
system_prompt: Optional[str] = None
) -> str:
"""
画像付きチャット推論を実行(llama-server経由)
Args:
message: ユーザーメッセージ
image_path: 画像ファイルのパス
system_prompt: systemプロンプト(Noneの場合はファイルから読み込み)
Returns:
LLMからの応答テキスト
Raises:
InferenceError: 推論に失敗した場合
"""
try:
start_time = time.time()
# systemプロンプトの取得
if system_prompt is None:
system_prompt = self._load_system_prompt()
# ユーザープロフィールを毎ターン読み込んで追加
user_profile = self._load_user_profile()
if user_profile:
system_prompt = f"{system_prompt}\n\n{user_profile}"
logger.info("ユーザープロフィールをシステムプロンプトに統合しました(毎ターン読込)")
# 画像読み込み
image = cv2.imread(str(image_path))
if image is None:
raise InferenceError(f"画像の読み込みに失敗しました: {image_path}")
# 画像をBase64エンコード
image_data_uri = self._encode_image(image)
# メッセージリスト構築(systemプロンプトを分離)
messages = [
{
"role": "system",
"content": system_prompt
},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_data_uri}},
{"type": "text", "text": message},
],
}
]
# API リクエスト
payload = {
"messages": messages,
"max_tokens": config.qwen.max_tokens,
"temperature": config.qwen.temperature,
"top_p": config.qwen.top_p,
"top_k": config.qwen.top_k,
}
# nonthinkパラメータの追加
payload["chat_template_kwargs"] = {"enable_thinking": not config.qwen.nonthink}
response = requests.post(
self.full_url,
json=payload,
timeout=config.qwen.timeout
)
if response.status_code != 200:
raise InferenceError(
f"Server returned status {response.status_code}: {response.text}"
)
result = response.json()
content = result["choices"][0]["message"]["content"]
elapsed_time = time.time() - start_time
logger.info(f"画像推論完了: {elapsed_time:.2f}秒")
return content.strip()
except requests.exceptions.ConnectionError:
raise InferenceError(
f"llama-serverに接続できません。サーバーが起動しているか確認してください: {self.server_url}"
)
except requests.exceptions.Timeout:
raise InferenceError(
f"推論がタイムアウトしました({config.qwen.timeout}秒)"
)
except KeyError as e:
raise InferenceError(f"APIレスポンスの形式が不正です: {e}")
except Exception as e:
raise InferenceError(f"画像推論エラー: {str(e)}")
まとめ
- GPUメモリ量と応答速度のバランスをとるためにいろいろな組み合わせを試しまくるのが大変でした。特にGPUメモリは起動時と推論時で1GB程度増減するので、起動はできてもしばらく使うとOOMになることがあります。
- リアルタイム処理特有のめんどくささとしてデバッグが難しいというのもありました。
- Claude codeで開発しましたが、今回のように組み合わせのような課題はまだ難しいようで、できるだけそれぞれの機能をcliで動作させるコードを作成し、それを組み合わせるやり方が今のところベストプラクティスと感じています。
それではよいお年を!
