はじめに
個人で使っているタスク管理アプリに、過去の実績から自動分解する既存機能とは別ラインで、ローカルLLMによるタスク自動分解機能を追加することにした。要件はシンプルで、次の3つだった。
- 完全オフラインで動作すること(初回のモデルダウンロードのみ許容)
- 追加コストがかからないこと(サーバー代・API課金なし)
- アプリ単体で完結すること(別アプリのインストールや外部プロセス起動が不要)
対象プラットフォームはmacOS。「タスク名を入力してボタンを押すと、ローカルのLLMがサブタスクに分解してJSONで返す」という、機能自体はごくシンプルなものだったが、実際に動かすまでには複数のライブラリを乗り換え、いくつもの環境固有の壁にぶつかることになった。本稿はその記録である。
第1の選択: flutter_gemma (Google LiteRT-LM)
最初に試したのは flutter_gemma。Gemmaを含む複数モデルをサポートし、Flutter公式に近い位置づけのパッケージで、APIも高レベル(installModel().fromNetwork().install() のような builder パターン)だったためだ。しかしmacOSデスクトップでは、順番に3つの壁にぶつかった。
壁(1): fromBundled() は macOS 非対応
モデルをネイティブリソースとしてアプリに完全に同梱する、一番簡単な方法は fromBundled() API。だがflutter_gemmaのソースを追うと、getBundledResourcePath()は Android/iOS の分岐しかなく、macOSはelse句で即座に UnsupportedError('Bundled resources not supported on macos') を投げる実装だった。完全に使えない。
壁(2): fromAsset() は大きいファイルで書き出しが0バイトになる
次に試したのが、モデルをFlutterの通常のassets:として同梱し、初回起動時にアプリストレージに展開する fromAsset()。ところがAssetSourceHandlerの実装を読むと、macOSではlarge_file_handlerプラグインが存在しないためMissingPluginExceptionに落ちてrootBundle.load()で全ファイルを一度にメモリに読み込むフォールバックに落ちる。小さいモデル(SmolLM 135Mなど)では問題ないが、271MBのFunctionGemmaで実際に試したところ、
Bad state: Install reported success but no usable file (0 bytes) was found
というエラーで失敗した。590MBの別モデルではたまたま成功したが、これは以前のネットワークダウンロードのキャッシュを拾っていただけで、本質的には大きなファイルのメモリ展開が不安定だった。
壁(3): ネットワークダウンロードは成功するが engine_create が原因不明のまま失敗
残されたfromNetwork()でのダウンロード自体は614MBのモデルをバイト単位で正確に取得できた。しかしその後のLiteRtLmEngineのエンジン作成が
BackendInitException: all FFI backends failed. Last attempted cpu.
Attempts: gpu: Exception: Failed to create engine. Model may be invalid: …
cpu: Exception: Failed to create engine. Model may be invalid: …
で常に失敗する。パッケージ側はstderrリダイレクトしたログファイル(Directory.systemTemp 下)を用意しており、直接読むと詳細が見えるはずだったが、実際には自分自身のdebugPrintのログしか入っておらず、LiteRT-LMのC API自体が失敗時に追加情報を一切出さない仕様だった。小さいQwen3-0.6BとFunctionGemma-270Mの両方で同じ現象が再現し、モデル固有の問題ではなく、この環境でのLiteRT-LMエンジン自体の問題だと判断した。
macOSサンドボックスとGatedモデルの壁
flutter_gemmaを追いながらさらに2つの環境固有の問題にもぶつかった。
ネットワーク権限がなくてダウンロードすらできない
macOSアプリはデフォルトでApp Sandboxが有効になっており、com.apple.security.network.client エンタイトルメントがないと外部へのHTTPリクエスト自体がブロックされる。ログには
TaskFileSystemException: ClientException with SocketException: Connection failed
(OS Error: Operation not permitted, errno = 1)
と出るだけで、一見してネットワーク障害かサーバー側の問題に見えるのが厄介だった。DebugProfile.entitlements / Release.entitlements 両方にnetwork.clientを追加して解決した。
GemmaはHugging Face上で全てGated
ネットワーク権限を修正しても今度は 401: Access to model ... is restricted で失敗する。Googleが公開しているGemma系のモデルは例外なくHugging Face上でライセンス同意が必須な「gated」モデルであり、アクセストークンなしにはダウンロードできない。「アカウント作成不要で使えるものにしたい」という要件とは正面から衝突する。
代替として Qwen/Qwen2.5 系はApache-2.0で公開されておりアカウント不要だったが、結局この後のengine_create失敗にぶつかり、flutter_gemma自体を断念することになった。
方針転換: llama_cpp_dart + llama.cpp
flutter_gemmaを断念し、llama_cpp_dartを使うことにした。llama.cppはGoogleのLiteRT-LMよりはるかに枯れたエコシステムを持ち、GGUF形式のモデルを広くサポートしている。
だがこのパッケージにはFlutter macOSアプリへの組み込み事例が一切なかった。pub.devにあるexampleディレクトリを全て確認したが、入っていたのは全てdart run example/xxx.dartで実行するCLIスクリプトだけで、Flutterアプリ(GUI)への組み込みサンプルは存在しなかった。
まずはCLIで最速検証
このままFlutterアプリに組み込む前に、本当にこのライブラリがこのマシンで動くのかを確かめるため、pub-cache内のbin/MAC_ARM64/*.dylibを直接DynamicLibrary.open()する最小のDart CLIスクリプトを書いて検証した。このアプローチのメリットは大きかった。Flutterアプリのビルドは1回数分かかるが、CLIスクリプトなら数秒で回せる。
検証の結果、libllama.dylibはGPU(Metal)を正しく検出し、小型モデル(SmolLM2-135M)でテキスト生成まで一発で成功した。この段階でアーキテクチャの実行可能性は確信できたので、安心してFlutter統合の実装に進めた。
(ちなみにこの検証中、トークンごとのバイト列をString.fromCharCodesでそのまま文字列化して日本語が文字化けするという自分のミスもした。UTF-8はutf8.decode()でバイト列をまとめてからデコードする必要がある。)
Gatekeeperにブロックされたネイティブライブラリ配布
llama.cppのネイティブライブラリ(libllama.dylib他計7つ、合計約5MB)は小さいので、今度はFlutterのassets:として同梱し、初回起動時にアプリストレージにコピーしてからDynamicLibrary.open()する方式を取った。ファイルサイズ的には問題ないはずだった。
ところが実行すると
dlopen(...): tried: '...' (code signature ... not valid for use in process:
library load disallowed by system policy)
というエラー。コピーしたファイルをcodesign --force --sign -でad-hoc署名し直しても解決しなかった。さらに悪いことに、このエラーはmacOSのGatekeeperが「開発元を検証できません」というポップアップを出し、ユーザーが誤って「ゴミ箱に入れる」を選んでしまうと、ファイルが消えて同じエラーが繰り返されるという具合だった。
正しい解決策: ビルド時にContents/Frameworksへ組み込む
ad-hoc署名はAMFI(ベースラインの署名要件)を満たすだけで、Gatekeeperのマルウェア判定レイヤーは別だった。アプリ本体と一緒にビルド時に署名されるものは問題なく、ランタイムに後からコピーしたものは弾かれる、という差である。
これは実は、先にflutter_gemmaで実際に動いていた手法と同じだった。macos/Podfileのpost_installフックでシェルスクリプトのBuild Phaseを登録し、ビルド毎に
- ソースのdylibを
${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/にcp - 各ファイルを
codesign --force --sign -で署名
するようにした。アプリ側のコードはPlatform.resolvedExecutableからContents/Frameworks/を逆算してLlama.libraryPathに設定するだけでよくなり、ランタイムコピー・署名コードは不要になった。
モデル選定と日本語品質の問題
アーキテクチャが動くことは確認できたので、次はモデル選び。条件は「アカウント不要・ライセンス同意不要」だったので、まずはApache-2.0のQwen2.5-0.5B-Instruct(GGUF, Q8_0, 約530MB)を試した。
タスク分解のプロンプトを日本語で投げると、応答は来るしJSONもおおむね形になる。だが生成されたサブタスク名に、中国語簡体字が混じることがあった(例: 「手数料の見積」の「見積」が日本語では「見積」なのに、中国語風の単語選択が混ざる)。Qwenは中国発の多言語モデルで、日中で漢字を共有する単語で語彙選択が中国語側に引っ張られることが、特に0.5Bのような小型モデルでは目立った。
日本語特化蒸留モデルへ
代わりに採用したのが SakanaAI/TinySwallow-1.5B-Instruct。Qwen2.5-1.5B-Instructを生徒モデル、Qwen2.5-32B-Instructを教師モデルとしたTAID(Temporally Adaptive Interpolated Distillation)という手法で日本語会話に特化して追加学習されたモデルで、Apache-2.0で公開されている。GGUF(Q5_K_M, 約1.1GB)に切り替えたところ、中国語の混入はなくなり、自然な日本語でサブタスクが生成されるようになった(例: 「引っ越し日時の調整」「運送会社への連絡」)。
注: TinySwallowのモデルカードには「研究・開発目的の実験的プロトタイプとして提供されており、商用利用や重要な意思決定への使用は想定していない」という旨の記述がある。個人利用のタスク管理用途では問題ないと判断したが、利用目的に応じてライセンスを確認するべき。
JSON出力の頑健化
小型モデルを実際に使い込むと、まれにJSONの途中で生成が打ち切られる。実際にアプリ上で見えた生出力:
{"subtasks":[{"title":"新居探し","minutes":40},{"title":"屋根への移除","minutes":25},{"title":"新築物件探検","minutes":30}]
項目の配列は完全だが、外側のオブジェクトを閉じる}がない。トークン数上限(maxTokens)での切断ではなく、モデルが配列を閉じた直後にEOSを出すという、小型モデルにありがちなクセだった。
括弧の自動補完でリカバリ
JSONを{から}までの文字列として切り出す従来の実装だと、閉じ括弧がないとガードの]まで切り取ってしまい、部分的な項目も全て失われる。代わりに、文字列リテラルの中身を無視して{と[の対応を数え、不足分を末尾に追加する関数を実装した。文字列が途中で切れていれば引用符も閉じる。これで、「最後の1項目の途中まで」は完全に保存され、本当に途切れた部分だけが欠損値(0分など)として存活するようになった。
単体テストでも、本番で実際に起きた切断パターンをそのまま回帰テストとして固定した。
まとめ
「アプリにLLMを内包する」という発想はシンプルだが、macOSで実際に動かすまでには多層の壁があった。振り返ると、評価軸は大きく3つあった。
-
パッケージのプラットフォーム対応度 — 高機能な公式パッケージでも、macOSデスクトップはモバイル向けの実装の踏み場になっていることがある(
fromBundled()の非対応など)。exampleディレクトリの中身を見て、対象プラットフォームの実行例があるかを先に確認すべきだった。 - macOSのサンドボックスとコード署名 — ネットワークもファイルシステムもエンタイトルメントで明示的に許可しない限りブロックされる。さらにロードするネイティブコードは、ビルド時にアプリ本体と一緒に署名されていないとGatekeeperに弾かれる。実行時にコピーしてad-hoc署名するだけでは不十分だった。
- 小型モデルの品質・安定性 — 多言語モデルは言語が混ざることがあり、目的言語に特化した蒸留モデルの方が安定する。JSONや構造化出力を期待するなら、モデル任せにせずパーサー側で部分失敗を前提にした方が楽だった。
結果として、現在の構成は llama_cpp_dart + TinySwallow-1.5B。モデルファイルの初回ダウンロード以外は完全オフラインで動作し、アカウント作成も追加課金も不要で、アプリ単体で完結する。