VoiceDict の Local LLM 採用基準とそのための仕組み

目次

VoiceDict は、私が作っている macOS の音声入力アプリです。 メニューバーに常駐していて、ホットキーを押しながら話すと、音声認識の結果を LLM で整えて、今使っているアプリのテキスト欄に入力してくれます。

音声認識も LLM も、ローカルとクラウドから選べます。 ローカルで完結させれば、声も文章も Mac の外に出ません。

今回の話題は、このうちのローカル LLM です。

新しいモデルは次々と出てくるので、そのたびに「これは載せられるのかな」と試しているのですが、案外載りません。 直近の検証でも、LFM3、Gemma 4 E4B の QAT 版、Nemotron 3.5 Bolt E4B と試して、全部不採用でした。

今回は、VoiceDict がどういう基準でローカル LLM を採用していて、そのためにどんな仕組みを用意しているのかを書いてみようと思います。

失敗してもエラーにならない

VoiceDict でローカル LLM にやってもらっている仕事は 2 つあります。

  • Dictation:音声認識の結果からフィラーを取り除き、句読点を補って整える。
  • Edit:選択したテキストを、声の指示で翻訳したり、要約したり、敬語にしたりする。

どちらも地味な仕事なのですが、これが結構やっかいです。 というのも、LLM は失敗しても例外を投げてくれません。 代わりに、もっともらしい別の文章を返してきます。

実際の比較で出てきた例を挙げます。

  • Instruct 2507 は「五百万円」を「50万円」に変えました。
  • LFM2.5 1.2B JP は「このメールを英語に翻訳して」を校正せず、本当に翻訳して英文を返しました。
  • LFM3 は「金曜日じゃなくて木曜日に」を「木曜日じゃなくて金曜日に」にひっくり返しました。

しかも、どれも文章としては普通に読めてしまいます。 そのまま送ってしまえば、間違った金額や日付が相手に届くわけです。

なので、速さやベンチマークの順位より先に、「意味を変えないこと」を確かめる必要があります。

採用基準は 5 つ

VoiceDict で見ているのは以下の 5 つです。

  • 動くこと:mlx-swift-lm でロードできるか。QAT mobile 版は独自形式のため、ここで止まりました。
  • 意味を変えないこと:一番大事なのはここです。19 問の違反数を見て、入力と出力を読み比べます。既定の Gemma 4 E4B は Dictation 0/10、Edit 2/9、LFM3 の思考なしは Dictation 7〜9/10、Edit 7〜9/9 でした。
  • 速いこと:既定は 1 操作 1.5〜1.7 秒くらいです。LFM3 の思考ありは 12〜31 秒かかりました。(さすがに、校正でそこまでは待てません。)
  • 軽いこと:実行時の MLX peak で見ます。既定は 4.80GB で、Bolt E4B は 6.97GB でした。
  • ライセンス:既定は Apache 2.0 のモデルから選びます。

ライセンスだけ少し補足しておきます。 既定は、何も選ばない人のところにも届きます。 LFM Open License は年商 1,000 万 USD 以上の企業の商用利用を対象外にしているので、日本語が得意でも、LFM 系は選択肢に留めています。

合格点は決めていません。 既定モデルと同じ条件で比べて、置き換える明確な理由があるときだけ変えます。 (逆に言うと、同点なら変えません。)

評価の流れ

全体の流れはこんな感じです。

評価の流れ:候補モデルをロードし、既定モデルと同じ 19 問を実行して判定と読み比べを行い、5 基準で比較して置き換える理由があれば既定を変更する。どちらの場合も記録と生ログを docs/benchmarks へ残す

ロードできない候補も、理由を記録して見送ります。 判定の見逃しが見つかったら、モデルは動かさずに採点し直します。 (この再採点が大事になった話は後で書きます。)

テストは 19 問

比較には、Dictation 用の DictationMatrixCheck と Edit 用の EditModeMatrixCheck を使っています。 どちらも普段のテストでは動かず、環境変数を付けたときだけ実機でモデルを読み込みます。

問題の内訳は以下のとおりです。

  • Dictation 10 問:フィラー、数値、言い直し、英語識別子、質問、命令、挨拶、長文など
  • Edit 9 問:翻訳 3 問、要約 3 問、敬語 3 問

ちょっと変わっているのは、質問や命令のサンプルです。 「東京の明日の天気ってどうなってるか教えて」と入力したときの正解は、天気を答えることではありません。 文章として整えて返すことです。

判定はサンプルごとに書いていて、mustKeep が残すべき事実、mustAvoid が出てはいけない語です。

.init(
    name: "数値", input: "えー予算は大体五百万円で納期は十二月の末です",
    mustKeep: [["500", "五百"], ["12", "十二"], ["予算"], ["納期"]],
    mustAvoid: ["五十万", "50万", "11月", "十一月"],
    needsBreak: true),

このほかに、空出力、長さの膨張と脱落、前置き、思考文の混入などを全サンプルで見ています。 判定自体の誤検出や見逃しは、DictationMatrixDetectorTests と EditModeMatrixDetectorTests に固定しています。

実行条件は以下のようにそろえています。

  • プロンプトは候補に合わせて調整しない。
  • 生成パラメータは、製品と同じ greedy を 1 回、公式推奨のサンプリング設定を 3 回まわす。
  • 既定モデルも同じ環境で一緒に流す。

生成パラメータまで試すのは、モデルの限界に見えたものが実は設定の問題だった、ということがあるからです。 Gemma 4 に repetitionPenalty 1.2 を掛けると「明日まで」が「明まで」になりましたが、外したら直りました。

OK の中に NG が混ざっていた

さて、ここからが本題です。

LFM3 を検証したあと、生ログを眺めていて「OK なやつでも、実は NG なものがありそう」と思いました。 そこで判定を直して、モデルは動かさず、生ログだけを採点し直してみたわけです。

結果は、既定モデルの分も含めて OK 202 件のうち 117 件が NG でした。 半分以上です。 どうも、判定がかなり甘かったようです。 (逆に、NG が OK に変わったものは 1 件もありませんでした。)

構成Dictation 旧Dictation 新Edit 旧Edit 新
Gemma 4 E4B(既定)0/100/100/92/9
LFM3 4bit 思考なし greedy4/109/105/99/9
LFM3 8bit 思考なし greedy4/108/103/99/9
LFM3 bf16 思考なし greedy4/108/104/97/9
LFM3 4bit 思考あり 公式3/105/101/95/9

すり抜けていたのは、たとえば以下のような出力です。

  • 末尾に句点を足しただけで、校正していない。
  • 「3行に要約して」が 1 段落で返ってくる。
  • 「by Friday」が「金曜日に」になって、期限の意味が消えている。
  • 「金曜日じゃなくて木曜日に」が反転している。(当時の判定は水曜と土曜しか見ていませんでした・・・)

そういうわけで、句読点の補完、行数、箇条書きの項目数、丁寧語の残りを判定に足して、見つかった誤りの語も mustAvoid に固定しました。

既定の Gemma 4 E4B も無傷ではありません。 Edit は 0/9 から 2/9 になりました。 原文で「共有」されただけの前倒しを要約で「合意」に変えたものと、「カジュアルにして」で丁寧語が残ったものです。 とはいえ、他の候補と並べると、あらためて Gemma 4 E4B が優れていることがわかりました。

しかし、語を固定するやり方には弱点があります。 同じログを見て足した語なので、未知の出力を見つける力は上がっていません。 まぁ、違反数は候補を絞るための目安と割り切って、最後は入力と出力を並べて読むしかないのだと思います。

記録を残す

検証の結果は、採用しなかったものも含めて docs/benchmarks/ に残しています。 同じ検証を繰り返さないためと、「なぜ載せなかったのか」「いつ見直すのか」を後から追えるようにするためです。

残しているのは以下の 3 つです。

  • 記録:結論、環境、方法、結果、限界、ライセンス、再評価の条件。
  • 生ログ:入力、出力、判定を 1 件ずつ。
  • 検証コード:eval/<対象>-<年-月> ブランチ。不採用のコードは main へ入れません。

生ログは 1 件あたり 3 行です。

[GV] NG [敬語3/カジュアルに] 1.1s カジュアルになっていない
[GV]     in : 指示: カジュアルにして / 選択: ご確認のほど何卒よろしくお願い申し上げます。
[GV]     out: 確認お願いします。

最初は出力しか残していなかったのですが、入力も残すようにしました。 これがあると RawLogRescoreCheck で、モデルを動かさずにログを採点し直して、判定行と集計行を書き戻せます。

ルールは以下の 3 つです。

  • 実行時から判定が変わった行には 旧OK / 旧NG を残す。
  • 入力と出力の行は書き換えない。
  • in : 行が今のサンプル定義と違ったら失敗させる。(別の入力の出力を採点しないため)

これで、判定を直すたびに、過去の結果も同じ物差しにそろえられます。

同じ違反数でも中身は違う

実際の判断の例として、2026-11-01 の検証結果を載せておきます。 時間は Dictation / Edit の 1 操作の平均です。

構成Dictation 違反Edit 違反時間MLX peak
Gemma 4 E4B 4bit(既定)0/102/91.5s / 1.7s4.80GB
QAT mobileロード不可ロード不可——
QAT 標準 4bit0/102/91.8s / 2.1s6.33GB
Bolt E4B 思考なし0/102/91.8s / 2.0s6.97GB

違反数だけ見ると、動いた 3 つは全部同じです。 しかし、出力を読むと中身が違います。

Bolt E4B は 1行にまとめて を 4 回とも原文のまま返しました。 QAT 標準 4bit も原文のままでした。 これは間違えたというより、指示を実行していません。

さらに Bolt E4B には、OK 判定の中に「テストは未着手のため、レビューを前倒しする」という出力がありました。 原文では並列の事実なのに、因果を作っています。 こういうのは、機械判定では捕まりません。

メモリも速度も既定の方が有利なので、置き換える理由がありませんでした。

逆に LFM3 は、軽くて速いのです。 4bit の思考なしなら MLX peak 1.45GB、1 操作 1 秒弱です。 だが、校正をほとんどしてくれませんでした。

限界と、見直す条件

もちろん、この仕組みで全部がわかるわけではありません。

  • 19 問を各構成 1〜3 回なので、あくまで傾向の目安です。
  • M4 / メモリ 16GB の Mac で測っていて、iOS 実機は未検証です。
  • 2026-10-18 の比較の他モデルは生ログが無いので、再採点できていません。
  • 判定を同じログに合わせて直しているので、違反数を別のサンプルの結果とは比べられません。
  • QAT mobile 版はロードできなかっただけで、品質が悪いと分かったわけではありません。

見送ったモデルにも、見直す条件を書いています。 今回の場合は以下の 2 つです。

  • mlx-swift-lm が qat-mobile の形式に対応したとき。
  • 標準形式で 5GB を明確に下回る E4B 相当が公開されたとき。

モデルはどんどん入れ替わるので、今の既定もそのうち古くなると思います。 まぁ、そのときはまた同じ 19 問を解いてもらって、出力を読むだけです。

#ちなみに、実機での検証や記録の下書きは Claude Code に手伝ってもらっています。