M5Stack の卓上ロボット「スタックちゃん」の頭脳を、さくらのAI Engine の gpt-oss-120b に載せ替えました。
声で話しかけると、天気や為替や電車の遅延を調べて、声で返してきます。LLM 部分だけがさくらで、音声認識も読み上げも Raspberry Pi 5 の上のローカル処理です。全部無料の範囲で動いています。
作ったものより、作りながら測って分かったことのほうが役に立つと思うので、そちらを中心に書きます。
- 空の引数として何が飛んでくるか(
nullだけではありませんでした) - ツール選択の精度を 1 回の実行で判断してはいけない話
- ストリーミングで体感がどれだけ変わるか(口を開くまで実測 0.7 秒 対 2.0 秒)
- 生成された文字列の書き方が、そのまま読み上げを壊す話
このロボットは気象庁の警報・地震・台風なども読み上げますが、防災の判断は必ず気象庁など公式の発表を直接ご確認ください。個人が趣味で作ったものです。
構成
[スタックちゃん (M5Stack CoreS3)]
│ WebSocket(音声は Opus・制御は JSON)
▼
[Raspberry Pi 5 の自前サーバー]
├─ 聞き取り : sherpa-onnx + ReazonSpeech k2 v2(ローカル)
├─ 考える : さくらのAI Engine gpt-oss-120b ← ここだけ外
├─ 読み上げ : Open JTalk(ローカル)
└─ 道具 : 19 個(天気・為替・電車・プロ野球 …)
本体の出荷時ファームは中国のクラウド(XiaoZhi)に喋りかける作りなので、接続先を自前サーバーに向けています。その経緯は別の記事に書きました。
さくらのAI Engine を選んだ理由
OpenAI 互換であることが決め手でした。
もともと Raspberry Pi の上で Ollama の qwen2.5:3b を動かしていたのですが、3B では道具の選び方が怪しく、応答も遅い。かといって 7B 以上は、共有機である Pi に載せると他の作業が止まります。
さくらに切り替えるのに書き換えたのは、実質この 2 つだけでした。
- SAKURA_BASE=http://127.0.0.1:11434
- SAKURA_MODEL=qwen2.5:3b
+ SAKURA_BASE=https://api.ai.sakura.ad.jp
+ SAKURA_MODEL=gpt-oss-120b
POST /v1/chat/completions に tools と tool_choice を渡す形がそのまま通ります。接続先を切り替えるスクリプトを 1 本用意しておけば、別のバックエンドへ戻すのもコマンド 1 つで済みます。外が落ちたときの逃げ道を残せるのは、OpenAI 互換であることのおまけです。
そして月 3,000 リクエストの無償枠があります。公式の記載によると、基盤モデル無償プランと従量課金プランは別のプランで、自動的に従量課金プランへ移行することはなく、無償枠を超過した場合は課金ではなくレート制御がかかるとのことでした。個人の実験でいちばん怖いのは「寝ている間に課金が積み上がること」なので、この性質は精神衛生に効きます。
道具を 19 個持たせた
このロボットが呼べるサーバー側の道具は、いま 19 個です。
get_weather get_usdjpy get_stock_index get_llm_quota get_crypto
get_news get_quake get_warning get_typhoon get_heat
get_onthisday get_sky get_train get_fuel_surcharge get_travel_advisory
get_baseball get_roster_move sing_cheer_song get_cheer_song
ぜんぶキー不要・無料の公開データです(気象庁・環境省・外務省のオープンデータ・NHK・ODPT・NPB 公式など)。これに本体側の道具(画面の明るさ・サーボの角度・LED の色・カメラ撮影・リマインダーなど)が足された一覧を、毎回のリクエストに乗せています。
ツール選択の実測
本番と同じシステム文・同じ道具一覧で、34 通りの言い方を投げて測りました。
33/34 正解 model=gpt-oss-120b base=https://api.ai.sakura.ad.jp
抜粋します(応答時間は API を叩いてから返るまで)。
| 言い方 | 選ばれた道具と引数 | 時間 |
|---|---|---|
| あしたの大阪の天気は? | get_weather {"place":"大阪","when":"tomorrow"} |
0.5s |
| 円安どうなってるか教えて。 | get_usdjpy {} |
0.5s |
| 外に出ても平気なくらいの暑さ? | get_heat {} |
0.7s |
| 京急ちゃんと動いてる? | get_train {"line":"Keikyu"} |
0.7s |
| フランス行っても大丈夫かな。 | get_travel_advisory {"country":"フランス"} |
0.5s |
| ありがとう、またね。 | (道具を呼ばず「またね!気をつけてね」) | 0.5s |
0.5〜1.1 秒で返ってきます。「外に出ても平気なくらいの暑さ?」で熱中症の道具を選ぶくらいの言い換えには、素直に付いてきてくれました。
実測して分かったこと
1. 空の引数は null だけではない
いちばん最初に踏んだのがこれです。「引数なし」のつもりの呼び出しで、実際に飛んできたものを並べます。すべて実機ログの実物です。
{"index": null} // 値が null
{"place": ""} // 空文字
{"none": {}} // 存在しないキーに空オブジェクト
{"": {}} // キー名が空文字
{"date": {}} // 文字列を期待した所に空オブジェクト
なので str(args.get("place", "")) と書くと、None が文字列 "None" になって「None の天気」を調べに行きます。
# ❌ 値が null のとき "None" という文字列になる
place = str(args.get("place", ""))
# ⭕️ null も空オブジェクトも空文字に倒す
place = args.get("place") or ""
さらに {"none": {}} や {"": {}} のようにスキーマに無いキーが来るので、受け取り側は「知らないキーは黙って捨てる」作りにしておく必要があります。JSON Schema を厳密に書いても、この手のゆらぎは残ります。
2. ツール選択の精度を、1 回の実行で判断してはいけない
これが今回いちばん書きたかったことです。
以前わたしは「システム文に口調の指示を 1 文足したら、ツール選択の正解数が落ちた」という観測をして、プロンプトで口調を直すのをやめ、後処理で直すという設計判断をしました。
今回この記事を書くにあたって、同じ比較を今の 34 ケースで測り直しました。結果はこうです。
| 条件 | 正解数 |
|---|---|
| 本番のシステム文のまま | 33 / 34 |
| +「接客の決まり文句や絵文字は使いません。」 | 33 / 34 |
差は出ませんでした。それどころか、同じ条件で回しても落ちるケースが毎回入れ替わります。今回まわした 4 回ぶんを並べます。
| 実行 | 落ちたケース | 返ってきたもの |
|---|---|---|
| 1 | 日経平均いくら? |
tool_calls も本文も空 |
| 2 | 今日は何の日? |
tool_calls も本文も空 |
| 3(本番のまま) | 台風来てる? |
tool_calls も本文も空 |
| 4(+口調の指示) | 日経平均いくら? | 道具は正しいが引数が {"index": null}
|
落ち方も一様ではなく、まるごと黙るものと、道具は当てたのに引数を埋めそこねるものがありました。temperature は本番と同じ 0.7 です。1 回ずつ測っている以上、34 分の 1 の差はゆらぎと区別が付きません。つまり以前わたしが見た「落ちた」も、ゆらぎだった可能性が高いということです。
反省として、LLM の設定を変えた効果を主張するなら、同じ条件を複数回まわして、ばらつきの幅と比べる必要があります。1 回の実行で出た差を根拠に設計を決めると、後から自分が困ります。
(なお「口調は後処理で直す」という設計自体は、副作用が読みにくいシステム文をいじらずに済むので、そのまま残しています。理由が変わっただけです。)
3. ストリーミングで体感が変わる
さくらは stream: true に対応しています(text/event-stream)。1 文できた時点で読み上げを始めるようにしたところ、実測でこうなりました。
| 1 回目 | 2 回目 | 3 回目 | |
|---|---|---|---|
| 最初の欠片が届くまで | 0.60s | 0.62s | 0.88s |
| 1 文目が揃うまで | 0.67s | 0.69s | 0.88s |
| 全部書き終わるまで | 1.84s | 2.10s | 2.28s |
約 1.2〜1.4 秒、口を開くのが早くなります。会話ロボットでこの差は大きいです。
実装で注意が要ったのは tool_calls の受け取り方でした。本文と違い、index ごとに継ぎ足す必要があります。
-
idとfunction.nameは最初の欠片に 1 回だけ来る -
function.argumentsは文字列として分割されて届く({"pla/ce":"大/阪"}のように割れる)
なので、届くたびに json.loads を試すのではなく、index ごとに文字列を連結しきってから一度だけパースします。
4. 生成された文字列の書き方が、そのまま読み上げを壊す
画面に出すなら何でもない差が、音声にすると壊れます。Open JTalk の形態素解析の結果を実際に見たものを並べます(括弧内が読み)。
| 生成された文字列 | 解析された読み |
|---|---|
3 000 回 |
三(サン) (、) 0(ゼロ) 0(ゼロ) 0(ゼロ) (、) 回(カイ) |
1 か月 |
一(イチ) (、) か月(カゲツ) |
AI Engine |
AI(エーアイ) (、) Engine(イーエヌジーアイエヌイー) |
ナスダック(NASDAQ) |
ナスダック(ナスダック) ((、) NASDAQ(ナスダック) |
28℃ |
二(ニ) 十(ジュウ) 八(ハチ) ℃(ドシー) |
犯人がはっきり見えます。空白が 記号,空白 として解析され、読みが「、」になっているのです。だから「3000」が桁として読まれず、ゼロが 3 回並びます。gpt-oss は数字や英単語の周りに空白を入れる癖があるので、これを踏み続けます。
NASDAQ は合成器がちゃんと「ナスダック」と読めるので、括弧書きが親切心から二度読みになります。℃ は 1 文字で「ドシー」でした。
空白を詰めると直ります。
3 000 回 -> 三(サン) (、) 0(ゼロ) 0(ゼロ) 0(ゼロ) (、) 回(カイ)
3000回 -> 三(サン) 千(セン) 回(カイ) ← 直った
対策はプロンプトではなく後処理にしました(2 の理由と同じで、システム文をいじる副作用が読めないため)。
- 数字・英字の直後の空白だけ詰める(日本語の直後の空白は残す。ここを一律にやると箇条書きが繋がります)
- 日本語を含まない括弧書きは落とす(
(7月31日の終値)のような日本語入りは残す) -
℃→度のような記号の読み替え表を持つ(28度は「ニジュウハチ・ド」になります)
ただし空白を詰めても直らないものもあります。1か月 は詰めても「イチ・カゲツ」で、「イッカゲツ」にはなりませんでした。辞書がそう読む以上、こういうものは読み仮名の指定表(AI Engine → エーアイエンジン など)を別に持つしかありません。
5. モデルが知らない固有名詞は、道具の説明文に書く
「度会の歌うたって」に対して、モデルが道具を呼ばずに「歌えないんだ」と即答することがありました。度会選手は最近このチームに入った選手なので、名前が学習データに無く、「歌」と「応援歌」が結び付かなかったのだと見ています。
これは道具の description に書いて解決しました。
人名らしき言葉と「歌って」が来たら、心当たりが無くても必ず呼ぶこと。歌えるかどうかは道具の側が判定する。知らない名前は新しく入った選手かもしれない。
判断はモデルではなく道具の側でやるから、まず呼べ——と書くのがコツでした。これで 5 通りの言い方すべてで呼ばれるようになりました。
無料枠の使い方
コントロールパネルに利用量は出ますが、API では取れません。そこでサーバー側で自分で数え、それを道具にしました。ロボットに「無料枠あとどれくらい残ってる?」と聞くと答えます。
今月このサーバーから使った LLM リクエストは1602回で、無料枠3000回の残りは
およそ1398回です。サーバーの外で使った分(検証など)は数えられないので、
正確な値はコントロールパネルの利用量が正です。
自分で数える以上、数え漏れがあることも一緒に答えさせるのが大事だと思っています。実際、この記事のために回した検証スクリプトは別プロセスなので、この 1,602 回には入っていません(この記事の測定だけで 140 回ほど使っています)。ロボットが自信満々に嘘の残量を言うより、ずっとましです。
会話 1 往復で 1〜2 リクエスト(道具を呼ぶと結果を渡してもう 1 往復)なので、3,000 リクエストは日常の話し相手には十分でした。
おまけ:応援歌を歌います
道具の 1 つに「応援歌を歌う」があります。歌詞は球団公式のページから実行時に取り、旋律は公開されている音源や譜面から自前で採譜しました(歌う部分はさくらではなく、ローカルの歌声合成です)。
「京田陽太さんの応援歌を歌いますね」と言ってから歌い始めます(18 秒・音あり)。
ただし正直に書いておくと、歌える 16 曲のうち、人が聴いて確かめたのは動画の 1 曲だけです。残りは合成した音を機械で測った(声の割合・渡した楽譜とのずれ・最長の無音)だけで、耳では確認していません。しかもその数値は「合成器が楽譜どおりに鳴らしたか」であって、応援歌として良いかではありません。今後聴いて直すかもしれませんし、直さないかもしれません。
まとめ
- さくらのAI Engine は OpenAI 互換なので、ローカル LLM からの移行は接続先とモデル名の 2 行で済みました
- サーバー側 19 個(+本体側の道具)を渡した状態で、34 ケース中 33 正解・0.5〜1.1 秒
-
空の引数は
null以外にも色々な形で来るので、受け側を緩く作る - ツール選択の精度は 1 回の実行では測れない。ゆらぎと比べる
- プロンプトで直したくなる問題ほど、後処理で直したほうが安全だった
- 無料枠は自分で数える。数え漏れも含めて正直に答えさせる
実装は公開しています。
- yasumorishima/stackchan-lab — 自前サーバーの実装、WebSocket プロトコルの解析、ファーム書き込み手順
データの出典: 気象庁 / 環境省 / 外務省 海外安全情報オープンデータ / 公共交通オープンデータセンター (ODPT) / NHK / 日本野球機構 (NPB)