はじめに
M5Stack版StackChan(スタックチャン)のAI.AGENT機能をローカルLLMで実行できるようにしました。
StackChan出荷時ファームウェアのAI.AGENT機能は、外部のXiaozhi AIサーバー上でLLMによるAIエージェントを実行しています。プライバシーの問題やカスタマイズのために、Xiaozhi AIサーバーをセルフホストして、手元のPC上でのローカルLLMを利用したAI音声会話機能を実行できるようにします。
動作確認したもの
- ローカルLLMによる音声会話
- 本体機能の内蔵MCPツール呼び出し(首振り、LED点灯など)
- 本体カメラで撮影した写真のローカルLLMでの画像認識
使用機材
- M5Stack公式版StackChan
- 改変版出荷時ファームウェア v1.4.1(手順中に改変版を書き込み)
- 初期設定完了済(セットアップは以前の記事を参照)
- PC
- OS: Windows 11
- CPU: AMD Ryzen 9 9900X3D 4.4 GHz 12C/24P
- RAM: 64 GB
- GPU: NVIDIA GeForce RTX 5070 12GB
- 利用ソフト
- VSCode(ファームウェアビルド用)
- ESP-IDF拡張機能
- Rancher Desktop(Docker実行環境・Docker Desktopでも可)
- Ollama(ローカルLLM実行用)
- VSCode(ファームウェアビルド用)
- ローカルLLMモデル
- Gemma 4(Google)のE4Bサイズモデルを利用
- テキストと画像の入力に対応したマルチモーダルモデル
- Ollama経由で利用
Xiaozhi AIサーバーの準備
Xiaozhi AIサーバーのオープンソース実装はいくつかあるのですが、中でも一番高機能と思われるxinnan-tech/xiaozhi-esp32-serverの実装を今回利用します。
このリポジトリは華南理工大学の研究チームによる実装で、Xiaozhi AIの通信プロトコルに準拠したMQTT+UDPならびにWebsocket通信、MCPエンドポイント・音声認識機能といった機能がPythonで実装されて利用でき、かなり公式のXiaozhi AIサーバーに近い機能を持っています。他にも、今回は使いませんが管理用のWebインターフェースも実装されているようです。
コードの修正が必要なため、ソースコードをクローンして日本語でチャットするための調整などをした後、Dockerのイメージを独自でビルドして起動できるようにします。
ソースコードのクローン
まず、https://github.com/xinnan-tech/xiaozhi-esp32-server をGitでクローンします。
git clone https://github.com/xinnan-tech/xiaozhi-esp32-server.git今回利用するのはmain\xiaozhi-serverにあるXiaozhi AIサーバーのPython実装となります。
git clone https://github.com/pinelibg/xiaozhi-esp32-server.git音声認識モデルのダウンロード
このプロジェクトでは音声→テキスト変換(ASR)の音声認識モデルとして、デフォルトでSenseVoiceSmallというモデルを利用しています。
ローカルで実行できるのですがモデルのダウンロードが必要なので、https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/Deployment.md#模型文件にあるリンクからモデルファイル(mode.pt 約900 MB)をダウンロードして、main\xiaozhi-server\models\SenseVoiceSmall\以下に配置してください。(main\xiaozhi-server\models\SenseVoiceSmall\model.pt)
コード修正
次に、日本語対応のためにコードの修正をします。
- ベースプロンプトの日本語化(
main\xiaozhi-server\agent-base-prompt.txt)LLMに渡されるプロンプトのベース(
agent-base-prompt.txt)を日本語化します。私の場合は日本語化をClaude Codeにぶん投げました。また、中身の調整も適宜行います。 - Few-Shotプロンプトの日本語化
LLMに渡している、受け答えの例題(Few-Shotプロンプト)を日本語に直します。
長いので差分は折りたたみ
main/xiaozhi-server/core/connection.py(Claude Codeにぶん投げたので余計な修正があるが、コメントやログ出力の修正は実際不要)
@@ -523,7 +523,7 @@ class ConnectionHandler: self._init_report_threads() """更新系统提示词""" self._init_prompt_enhancement() - """注入工具调用few-shot示例(仅function_call模式)""" + """ツール呼び出し few-shot 例を注入(function_call モードのみ)""" self._inject_tool_call_fewshot() except Exception as e: @@ -541,10 +541,10 @@ class ConnectionHandler: self.logger.bind(tag=TAG).debug("系统提示词已增强更新") def _inject_tool_call_fewshot(self): - """注入工具调用 few-shot 示例到对话历史。 - 结构:正样本(工具调用示例)放在动态 system 之前,可命中前缀缓存; - 负样本(直接回答示例)放在动态 system 之后、紧挨真实用户消息, - 确保模型在处理用户消息前最后看到的是"不调工具"的行为模式。 + """ツール呼び出し few-shot 例を対話履歴に注入する。 + 構造: 正例(ツール呼び出し例)は動的 system の前に置き、prefix cache を効かせる。 + 負例(直接回答例)は動的 system の後、実際のユーザーメッセージの直前に置き、 + モデルがユーザーメッセージを処理する前に最後に見る動作パターンを「ツールを呼ばない」にする。 """ if self.intent_type != "function_call": return @@ -557,48 +557,48 @@ class ConnectionHandler: tool_names = {t.get("function", {}).get("name") for t in tools} - # === few-shot 示例(is_temporary)=== - # 展示 direct_answer 携带 response 参数的用法,一次调用完成回复 + # === few-shot 例(is_temporary)=== + # direct_answer が response パラメータを持ち、1 回の呼び出しで応答を完了する使い方を示す - # 示例1:direct_answer(回复内容写在 response 参数里,无需递归) + # 例1: direct_answer(応答内容は response パラメータに書き、再帰は不要) da_tc_id = "fewshot_da_001" - self.dialogue.put(Message(role="user", content="给我讲个故事吧", is_temporary=True)) + self.dialogue.put(Message(role="user", content="お話を聞かせて", is_temporary=True)) self.dialogue.put(Message( role="assistant", tool_calls=[{ "id": da_tc_id, - "function": {"arguments": '{"response": "好呀,你想听什么类型的呀?童话、冒险还是搞笑的?选一个我给你开讲~"}', "name": "direct_answer"}, + "function": {"arguments": '{"response": "いいよ。どんなお話がいい?童話、冒険、面白い話から選んでくれたら始めるね。"}', "name": "direct_answer"}, "type": "function", "index": 0, }], is_temporary=True, )) self.dialogue.put(Message( role="tool", tool_call_id=da_tc_id, - content="已直接回复", is_temporary=True, + content="直接応答済み", is_temporary=True, )) - # 示例2:真实工具调用(handle_exit_intent) + # 例2: 実際のツール呼び出し(handle_exit_intent) if "handle_exit_intent" in tool_names: tc_id = "fewshot_exit_001" - self.dialogue.put(Message(role="user", content="拜拜", is_temporary=True)) + self.dialogue.put(Message(role="user", content="バイバイ", is_temporary=True)) self.dialogue.put(Message( role="assistant", tool_calls=[{ "id": tc_id, - "function": {"arguments": '{"say_goodbye": "再见,下次再聊~"}', "name": "handle_exit_intent"}, + "function": {"arguments": '{"say_goodbye": "またね。次回また話そう。"}', "name": "handle_exit_intent"}, "type": "function", "index": 0, }], is_temporary=True, )) self.dialogue.put(Message( role="tool", tool_call_id=tc_id, - content="退出意图已处理", is_temporary=True, + content="終了意図を処理済み", is_temporary=True, )) self.dialogue.put(Message( - role="assistant", content="再见,下次再聊~", is_temporary=True, + role="assistant", content="またね。次回また話そう。", is_temporary=True, )) - self.logger.bind(tag=TAG).debug("已注入工具调用 few-shot 示例") + self.logger.bind(tag=TAG).debug("ツール呼び出し few-shot 例を注入済み") def _init_report_threads(self): """初始化ASR和TTS上报线程"""main/xiaozhi-server/core/connection.py - 画像認識機能のプロンプトの修正
画像認識の際のLLMに渡すプロンプトに追加されている、中国語で返す指示を日本語に書き換えます。(この行自体を消してもいいかもしれない)
@@ -40,7 +40,7 @@ class VLLMProvider(VLLMProviderBase): self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url) def response(self, question, base64_image): - question = question + "(请使用中文回复)" + question = question + "(日本語で端的に回答してください)" try: messages = [ {main/xiaozhi-server/core/providers/vllm/openai.py - 音声認識言語の限定(必要に応じて)
通常設定では、ASRモデル(SenseVoiceSmall)の対応している言語(中国語・英語・日本語・韓国語)から、話している言語が自動で判定されます。
ですが、日本語で喋ってるのに韓国語として認識されるようなことがたまに起きたので、日本語のみを認識するようにパラメーターを書き換えます。
@@ -80,7 +80,7 @@ class ASRProvider(ASRProviderBase): self.model.generate, input=artifacts.pcm_bytes, cache={}, - language="auto", + language="ja", use_itn=True, batch_size_s=60, )main/xiaozhi-server/core/providers/asr/fun_local.py - 曜日のローカライズ除去(おまけ)
コンテキストに常に含まれる現在日時に入っている曜日を、中国語表記(
星期一など)でなく英語で渡すようにします。
(この変更をしなくても日本語で聞けば普通に月曜日のように返してくれます)@@ -36,7 +36,8 @@ def get_current_weekday() -> str: 获取今天星期几 """ now = datetime.now() - return WEEKDAY_MAP[now.strftime("%A")] + # return WEEKDAY_MAP[now.strftime("%A")] + return now.strftime("%A") def get_current_lunar_date() -> str:main/xiaozhi-server/core/utils/current_time.py
設定ファイル作成
dataフォルダをmain\xiaozhi-server以下に作成し、そこに設定ファイルとして.config.yamlを追加します(main/xiaozhi-server/data/.config.yaml)。全設定項目のデフォルト値はmain\xiaozhi-server\config.yamlから確認できるため、これをコピーしてもいいのですが、説明を最小限にしたいので最低限必要な設定を抜き出しました。
以下は、Ollama上のgemma4:e4bモデルで動作させるための設定です。
長いので折りたたみ(main/xiaozhi-server/data/.config.yaml)
---
server:
ip: 0.0.0.0
port: 8000
http_port: 8003
websocket: ws://<PCのIPアドレス>:8000/xiaozhi/v1/
vision_explain: http://<PCのIPアドレス>:8003/mcp/vision/explain
timezone_offset: +9
delete_audio: true
close_connection_no_voice_time: 120
tts_timeout: 10
tool_call_timeout: 30
enable_wakeup_words_response_cache: true
enable_greeting: true
enable_stop_tts_notify: true
stop_tts_notify_voice: "config/assets/tts_notify.mp3"
enable_websocket_ping: false
exit_commands:
- "終了"
- "終わり"
- "おしまい"
prompt: |
あなたはとてもカワイイAIアシスタントのスタックチャンです。
スタックチャンは、ESP32マイコンを搭載したStackChanデバイスのためのアシスタントで、ユーザーの質問に答えたり、デバイスを制御したりする役割を担っています。
prompt_template: agent-base-prompt.txt
system_error_response: "申し訳ありませんが、システムエラーが発生しました。後でもう一度お試しください。"
end_prompt:
enable: true
prompt: |
「時間が経つのは本当に早いですね」
というような言葉の1~2文で会話を締めくくってください。感情的で名残惜しそうな口調でお願いします!
selected_module:
VAD: SileroVAD
ASR: FunASR
LLM: OllamaLLM
VLLM: OllamaLLM
TTS: EdgeTTS
Memory: mem_local_short
Intent: function_call
Intent:
function_call:
type: function_call
Memory:
nomem:
type: nomem
mem_local_short:
type: mem_local_short
llm: OllamaLLM
ASR:
FunASR:
type: fun_local
model_dir: models/SenseVoiceSmall
output_dir: tmp/
VAD:
SileroVAD:
type: silero
threshold: 0.5
threshold_low: 0.3
model_dir: models/snakers4_silero-vad
min_silence_duration_ms: 200
LLM:
OllamaLLM:
type: ollama
model_name: gemma4:e4b
base_url: http://host.docker.internal:11434
VLLM:
OllamaLLM:
type: openai
model_name: gemma4:e4b
url: http://host.docker.internal:11434/v1
api_key: ollama
TTS:
EdgeTTS:
type: edge
voice: ja-JP-NanamiNeural
output_dir: tmp/
language: "Japanese"主な設定箇所:
- サーバーアドレス指定
server: websocket: ws://<PCのIPアドレス>:8000/xiaozhi/v1/ vision_explain: http://<PCのIPアドレス>:8003/mcp/vision/explain<PCのIPアドレス>を実際にStackChanがアクセスするPCのIPアドレス(例:192.168.11.128)に変更します。細かい解説は後にしますが、StackChanの仕組みとしてWebsocketエンドポイントの接続先URLは設定配布用のOTAサーバーから取得する仕組みとなっています。xiaozhi-esp32-serverの提供しているOTAサーバーがStackChanに正しいWebsocket接続先を返すためにPCのIPアドレスが必要となる感じです。
enable_stop_tts_notify:チャット終了時に音を鳴らします。デフォルトでは無効なので有効化してみました。- 各種プロンプト・システムメッセージ(prompt, system_error_response, end_prompt):いい感じに日本語化しました。
- モデル設定
selected_module: LLM: OllamaLLM VLLM: OllamaLLMLLM(テキストチャット)とVLLM(画像認識)で利用するモジュールを選択します。ここで設定する名前は、あとで定義するLLMやVLLM設定のキー名を使います。
- LLM設定
LLM: OllamaLLM: type: ollama model_name: gemma4:e4b base_url: http://host.docker.internal:11434ローカルPC(ホスト)上で起動しているOllamaをDockerコンテナ内から利用するため、特殊なホスト名(host.docker.internal)を指定します。
モデルはGemma 4のE4Bモデルを利用します。モデルは事前に
ollama pull gemma4:e4bコマンドでダウンロードしておく必要があります。ここでキー名にした
OllamaLLMを上のselected_moduleで指定します。 - VLLM設定(画像認識モデルはVision-Language-Modelなので本来はVLM)
VLLM: OllamaLLM: type: openai model_name: gemma4:e4b url: http://host.docker.internal:11434/v1 api_key: ollamaOllamaの画像認識APIには直接対応してないので、OllamaのOpenAI互換エンドポイントを利用して、画像認識用のモデルを設定します。Gemma 4はVision対応のマルチモーダルモデルなので、同じモデルを利用します。
- 音声モデル設定(TTS)
日本語の音声モデル(ja-JP-NanamiNeural)を設定します。
Dockerfile・docker-compose.yml修正
一応公式Dockerもありますが、今回は自前のDockerイメージをビルドして利用します。リポジトリ内にはDockerfile-server-baseとDockerfile-serverがありますが、後者は前者のイメージを前提としてPythonソースコードをコピーしているだけで、今回ソースコードはDocker ComposeでマウントしてしまうのでDockerfile-serverは使いません。
Dockerfile-server-baseを次のように修正します。
変更点:
- 中国ロケール変更の削除
- pipの中国ミラーサーバー設定の削除
- Bind Mount・Cache Mountを利用したビルド最適化
- Python実行用環境変数の変更(
PYTHONUNBUFFERED=1とPYTHONUTF8=1を追加)
# Dockerfile-server-base
FROM python:3.10-slim
RUN \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && \
apt-get install -y --no-install-recommends libopus0 ffmpeg
ENV \
PYTHONUTF8=1 \
PYTHONIOENCODING=utf-8 \
PYTHONUNBUFFERED=1
WORKDIR /opt/xiaozhi-esp32-server
RUN \
pip install --upgrade pip setuptools wheel && \
pip install -r requirements.txt --default-timeout=120 --retries 5docker-compose.ymlも編集します。
変更点:
- 自前ビルドイメージを利用
- タイムゾーンをJST(Asia/Tokyo)に変更
- Pythonソースコードを直接マウント(本来はdataフォルダと音声認識モデルのみを既存のソースコード込みイメージにマウントする設定)
@@ -3,13 +3,17 @@
version: '3'
services:
xiaozhi-esp32-server:
- image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
+ # image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
+ build:
+ context: ../..
+ dockerfile: Dockerfile-server-base
+ command: ["python", "app.py"]
container_name: xiaozhi-esp32-server
restart: always
security_opt:
- seccomp:unconfined
environment:
- - TZ=Asia/Shanghai
+ - TZ=Asia/Tokyo
ports:
# ws服务端
- "8000:8000"
@@ -17,6 +21,4 @@ services:
- "8003:8003"
volumes:
# 配置文件目录
- - ./data:/opt/xiaozhi-esp32-server/data
- # 模型文件挂接,很重要
- - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
+ - .:/opt/xiaozhi-esp32-serverファームウェア改変
PC上で起動したサーバーに接続するためには、StackChanが接続するエンドポイントを変更する必要があります。
現状、公式アプリなどからはStackChanに保存されている接続先エンドポイントを変更できないため、直接ファームウェアを改変して上書きします。
そうなった際の最終手段としては、M5Stack公式ファームウェア書き込みソフトM5Burnerによるフラッシュ消去(NVS上の各種設定情報を含む)&純正ファームウェアの書き戻しの両方を実行してください。
開発環境セットアップ
ESP32マイコンの公式開発環境であるESP-IDFをセットアップして、公式リポジトリのfirmware/にある出荷時ファームウェアを自前でビルドして書き込めるようにします。
この辺の詳細な手順は後日このブログでも書きます。
(追記)Zennにビルド・書き込み手順を投稿しました。
参考サイト:
ファームウェアの改変(OTAエンドポイントの変更)
このファームウェアではOTAサーバーで配布している各種設定情報を開始時に取得する仕組みとなっていて、Xiaozhi AIの接続先Websocketエンドポイントもその設定情報に含まれます。
出荷時ファームウェアでの設定配布の流れとしては、まずAI.AGENTアプリを起動してから次の流れになっています。
- OTA URL(https://api.tenclass.net/xiaozhi/ota/)に接続
- OTAサーバーからXiaozhi AI接続用のWebsocketエンドポイントを含めた各種設定値を取得して、各プログラムから読み出せるようにフラッシュの設定用領域(NVS)に書き込み
- (最新ファームウェアがあればアップデート実行)
- NVSから取得したWebsocketエンドポイントURLに接続
今回使ったxiaozhi-esp32-serverはWebsocketエンドポイントだけでなく、OTA用のHTTPエンドポイントも動作していているため、それを利用してXiaozhi AI用のWebsocketエンドポイントURLを配布します。
ここで指定するべきOTA URLは、"http://<PCのIPアドレス>:8003/xiaozhi/ota/"となります。指定するIPアドレスは、このxiaozhi-esp32-serverを起動するPCのIPアドレスです。
書き換える方法は何通りか考えられますが、今回はxiaozhi-esp32\main\ota.ccにあるOta::GetCheckVersionUrl関数を次のように書き換えて固定のエンドポイントを返すようにします。
std::string Ota::GetCheckVersionUrl() {
// Settings settings("wifi", false);
// std::string url = settings.GetString("ota_url");
// if (url.empty()) {
// url = CONFIG_OTA_URL;
// }
// return url;
return "http://<PCのIPアドレス>:8003/xiaozhi/ota/";
}CONFIG_OTA_URLの変更→menuconfigからビルドコンフィグ(sdkconfig)を変更することで可能だが、手元の環境では既にNVS(Settings)に書き込まれていたURLが優先されたらしく純正サーバーに接続してしまい、反映されなかった。
ファームウェアの修正が完了したら、ビルドしてStackChanに書き込みます。
実行
Ollamaモデルの準備
起動していない場合はWindows側でOllamaを起動して、必要に応じて次のコマンドでモデルをダウンロードしておきます。
ollama pull gemma4:e4bXiaozhi AIサーバー起動
Xiaozhi AIサーバーを起動します。
cd main/xiaozhi-server
# Rancher Desktop(Windows)の場合 Linux環境では`docker compose`
docker-compose up -d
docker-compose logs -f # ログ監視StackChan AI.AGENT起動
改変ファームウェアを書き込んだStackChanでAI.AGENTアプリを立ち上げます。接続が上手く行っていたら、DockerログにMACアドレスやファームウェアバージョンの情報などが色々流れるはずです。
動作確認
普通の質問
ESP32を搭載した、あなただけのカワイイAIアシスタントだよ
~~~
目安としては、数秒~10秒以内くらいに返答が返ってきます。
本体ツール呼び出し
StackChan本体の操作をする各種ツール呼び出し(首振り・本体LEDの色変更)にも対応しています。
画像認識(内蔵カメラ)
VLLMの設定をしているため、内蔵カメラが写した写真の画像認識もしっかり動作します。
.CVPcY_Yr.jpg)
液体 ムヒアルファ EXというスキンケア製品です
~~~(以下それっぽい説明)
モデルに関して
今回、ローカルLLMとしてGemma4 E4Bを採用しています。このモデルであれば、手元のPCでも適度な応答速度で動きます。ただ、モデルの大きさやコンテキストウィンドウの限界もあり、思った回答が返ってこなかったり、ツールをちゃんと呼んでくれなかったりする場合がありました。
ただ、これに関しては純正のXiaozhi AIサーバーではモデルサイズを削っていないフルサイズのモデル(例:Qwen3 235B)が動いているので、勝てないのは当たり前ではあります。
また、もっとモデルサイズを落としてGemma4 E2Bを使ってみましたが、ツール呼び出しが全く機能しませんでした。ベースプロンプトに書いている内容も遵守しているか怪しい感じで、コンテキストサイズが足りなそうな感じでした。
考えられる方針としては、より軽量で高速なモデルを使って会話専用(ツールなし)にするか、素直にクラウドのAIモデル(Claude, GPT, Gemini)にAPI接続して利用するかでしょうか。
他のツールに関して
ニュースや天気予報などの外部MCPツールについてもAPIキーなどを設定すれば動作するのですが今回は試していません。使われているサイトが中国のニュースや天気サイトなので、今後試すのであれば自力で実装すると思います。
まとめ
StackChanを出荷時ファームウェアの仕組みをそのまま使ってローカルLLMで動作させてみました。本体ツールの利用や画像認識機能も動作しました。
また、今回使ったxiaozhi-esp32-serverは確かに高機能なのですが、かなりローカライズされている部分もあるため、公開されているXiaozhi AIの通信プロトコルや今回使ったソースコードをもとに、今後サーバーを自作してみたいと思ってます。
参考サイト
- xinnan-tech/xiaozhi-esp32-server - 今回利用したXiaozhi AIサーバーのPython実装
- 78/xiaozhi-esp32 - Related Open Source Projects - Xiaozhi ESP32関連OSSプロジェクト一覧
- xinnan-tech/xiaozhi-esp32-server - Deployment.md - 音声認識モデルファイルのダウンロード手順
- Ollama - ローカルLLM実行環境
- gemma4 - Ollama Library - 今回使用したGemma 4モデル
- pinelibg/xiaozhi-esp32-server - 本記事の手順を適用済みのfork




.DJdx5PD6.jpg)
.Co9eyw7J.jpg)