はじめに
最近スタックチャンと Fitbit Air を買いました。Databricks も合わせて、自分の健康データをいろいろ収集・集計して遊んでいます。
いま実装しているのは、以下の機能です。
- 朝・昼・晩に、健康データをもとにスタックチャンから報告させる
- 本体を軽く叩くか、「スタックチャン」と呼びかけると会話モードに入る
- 健康(睡眠・歩数・心拍など)や天気の質問に答える
- 返事の内容に合わせて表情が変わる
実際に動かした様子がこちらです。
今回の構成
今回使っているアーキテクチャの紹介です。
スタックチャン(M5Stack CoreS3)
発話と表情の担当で、マイクもここに乗っています。喋ってもらうための音も表情も、あとは全部クラウド側から渡します。
Cloudflare Workers / Workers AI / Workers KV
会話の中継役が Workers で、文字起こし(STT)と返事の生成(LLM)が Workers AI、健康データの要約と定刻の報告の音声を置いておくのが Workers KV です。
普段は AWS を使うことが多いのですが、今回は Cloudflare にしました。決め手はコールドスタートです。
声をかけてから返事が鳴るまでを短くしたいので、Lambda + API Gateway で組むとコールドスタートが気になります。Workers は動いている環境の中に isolate を作る仕組みで、関数ごとに VM を立てません。VM 方式のコールドスタートがそもそも無い、と書かれています。
あとは無償枠です。Cloud Run や Fly.io、Render も並べて比べたのですが、コールドスタートと無償枠の両方で折り合ったのが Workers でした。要約や定刻の音声を置いておく Workers KV も無償枠にそのまま入っているので、S3 にあたる置き場を別に有効化しなくて済みます。
Jev(TypeSafe AI)
Jev は TypeSafe AI が 2026 年 9 月に公開したモデルで、文章ではなく「型の付いた判断」だけを返します。System One Model という新しい区分を名乗っていて、スキーマの外の答えは出さない・応答は 70〜500 ms・出力トークンは無料、と尖った特徴が並んでいます。出たばかりですが、あちこちで取り上げられているのを見かけます。
今回はここに表情とツールの判断を任せています。
Databricks Free Edition
Fitbit のデータは Google Health API 経由で取ってきて、Bronze / Silver / Gold に整えています。Gold まで来たら、直近 14 日ぶんの日次の要約を Workers KV に push します。会話中に Worker が読むのはこの KV だけで、Databricks には触りません。定刻の報告は逆向きで、Databricks 側で作った文言を Worker が音声にして KV に置いておき、スタックチャンがそれを取りに行きます。
会話の流れ
本体を軽く叩くか「スタックチャン」と呼びかけると、会話モードに入ります。実際にスタックチャンに声をかけたときの処理の流れは、こういう形です。
Jev が健康の質問だと判断すれば、Workers 上で KV の要約を読みに行きます。天気の質問だと判断すれば、Open-Meteo を叩きに行きます。取れたデータを添えて、返事を書くことだけを LLM に任せます。
あとは文単位で音声合成して、できた音から順に表情と一緒に再生します。元の顔に戻るのは会話が終わったときです。
なお、Jev の判定を待っているあいだも LLM の往復は裏で走らせていて、判定が微妙だった回はそちらの結果をそのまま使っています。
小さいモデルでも、Jev がいればツールを使い分けられる
今回、会話のモデルは Workers AI の qwen3-30b-a3b-fp8 を使っています。ローカルで動かせるクラスのモデルとしては十分賢いのですが、エージェントとしてツールを使い分けさせるにはまったく足りませんでした。
ツールの説明文を書いて渡しても、実際には呼んでくれないことがそこそこあります。昨日の睡眠時間を聞いているのに、全然適当なことを返されてしまう、ということが何度もありました。
今後、天気予報の API のようにツールを増やしていきたいと考えていたので、ここが大きなボトルネックになっていました。
そこで、ツールの使い分けは Jev に判断してもらい、会話文の生成だけを LLM に任せる、という構成を試しに取ってみました。
Jev に判断だけ任せてみる
Jev が返すのは構造化された判断だけで、文章は書きません。問いの型は 3 つあります。
| 型 | 何を返すか |
|---|---|
| Choice | 選択肢から 1 つ。どれくらい確からしいかも一緒に返る |
| Score | 基準に沿った点数。同じく確からしさが付く |
| Noul | その主張が本当かどうかを判定して、その信頼度が返る |
判断を専用のステップに切り出すのは、Anthropic が routing と呼んでいるパターンそのものです。今回やったことはほぼこれになります。
1 ターンぶんの判断を 1 リクエストにまとめる
ありがたいことに、複数の問いを 1 回のリクエストに混ぜて投げられます。なので会話 1 ターンぶんの判断は、全部まとめて 1 往復で聞いています。送っているのは文字起こしの文だけで、健康の数値も天気の結果も送りません。
実際に並べている問いがこちらです。
| 問い | 型 | 何を聞いているか |
|---|---|---|
face |
Choice | どの表情で返すのが自然か(6 種から 1 つ) |
needs_health_data |
Noul | 健康の記録を見ないと答えられない発話か |
period |
Choice | いつのことを聞いているか(今日・昨日・今週…) |
metric_*(8 問) |
Noul | 睡眠時間・歩数・安静時心拍…のどれを聞いているか |
needs_weather |
Noul | 天気予報を見ないと答えられない発話か |
weather_day |
Choice | いつの天気を聞いているか(今日・明日・あさって) |
ツールを呼ぶかどうかだけでなく、その引数(期間と指標)も同じ答えから読んでいます。指標は「今週よく眠れてた? 歩けてる?」のように複数が同時に選ばれるので、1 問ずつ聞く形です。
表情の問いが混ざっているのは、表情を選ぶのもツールを選ぶのも、こちらから見れば同じ「文字起こしの内容に対する判断」だからです。区別せず同じリクエストに並べています。
決められなかった回は LLM に戻す
Jev の答えには信頼度が付いてきます。今回は信頼度に一定の基準を決めておき、それを超えなかった回は「決められなかった」として扱っています。表情の判定も同じ考え方です。
決められなかった回は、ツールを渡して走らせてある LLM の往復がそのまま答えになります。つまり Jev を挟んでダメでも、元の「LLM 任せ」に戻るだけで、返事が止まることはありません。
速さと値段
実機のログで見ると、Jev の応答は 172〜436 ms でした。これなら間に挟んでも実際の応答にはほとんど影響が出ないので、十分採用できるレベルでした。
もう一つの狙いは値段でした。「呼んでくれないならモデルを上げる」の代わりに Jev を挟んだわけですが、単価の差はこれくらいあります。
| モデル | 入力 $/M tokens | 出力 $/M tokens |
|---|---|---|
Workers AI qwen3-30b-a3b-fp8(いま使っている) |
0.051 | 0.335 |
Workers AI llama-3.3-70b-instruct-fp8-fast
|
0.293 | 2.253 |
Workers AI gpt-oss-120b
|
0.350 | 0.750 |
| Claude Haiku 4.5 | 1 | 5 |
| Jev | 0.042 | 0 |
(Cloudflare と Claude は公式の料金ページ、Jev は TypeSafe AI の公開価格。2026-09-19 時点)
モデルを一段上げると、入力側で 6〜20 倍になります。判断だけを Jev に出す形なら、LLM は安いままでいられます。しかも Jev は出力側が無料("too cheap to meter")なので、判断を足したぶんで効いてくるのは入力側だけです。
まとめ
いまの形を一言でいうと、判断は Jev、文章は LLM にしました。役割を分けてみると、LLM に求めることが「渡されたデータで返事を書く」だけになって、ずいぶんシンプルな構成になりました。LLMのモデルを替えても判断の部分のロジックが変わらなくて気に入ってます。
ただ判断部分の性能が Jev 頼りになってしまうのが、気になるところですね。上位モデルとか出るのかな?
実際にJev入れたバージョンがこちら。



