結論から言うと、Apple Foundation Models(約3Bのオンデバイスモデル)は「文章を書かせる」と壊れます。
自分は最終的に、判断のほとんどを普通のSwiftコードで解いて、FMには二値の質問を1回だけ投げる形に落ち着きました。そこまで仕事を小さくしたら、ようやく安定しました。
題材は、個人で作っている「たね」というメモアプリです。Siriに話しかけるとメモが残るアプリで、保存する前に音声認識の誤変換をオンデバイスLLMで直しています。この記事では、その誤変換訂正をどう作って、どう失敗したかを書きます。
うまくいった設計だけ見せられても再現できないと思うので、失敗した順番どおりに、何をやって何が壊れたかから並べます。
なぜオンデバイスLLMなのか
LLMを使った個人アプリ、作りたいじゃないですか。
でもAPI課金は自分持ちです。無料で配ると、使われれば使われるほど赤字になります。ヒットしたら破産する構造のアプリを、平日の夜に作りたい人はいません。
ところが今、iPhoneには最初からLLMが載っています。iOS 26 の Foundation Models framework です。
- 約 3B パラメータのオンデバイスモデル
- 推論は端末内で完結するので API費用ゼロ・オフライン動作・データが外に出ない
- Swiftから
LanguageModelSession().respond(to:)で呼べる。認証もセットアップも不要 -
@Generableを付けると、出力をSwiftの型に強制できる(constrained decoding)
呼ぶところは本当にこれだけです。
import FoundationModels
let session = LanguageModelSession()
let response = try await session.respond(to: "明日までに請求書を出す、を短い見出しにして")
print(response.content) // String
APIキーも、エンドポイントも、リトライ処理もありません。ローカルで動くので通信エラーの分岐も要りません。
出力の型を決めたいときは @Generable を付けた構造体を渡します。実際に「たね」でメモを構造化している部分がこれです。
@Generable
private struct StructuredNoteDraft {
@Guide(.anyOf(["memo", "todo", "idea"]))
var kind: String
@Guide(description: "本文の要点を表す短い見出し(15文字以内目安)。句点は付けない")
var title: String
@Guide(description: "内容を表すタグを0〜3個。本文に実際に出てくる具体的な語を名詞で")
var tags: [String]
}
let draft = try await session.respond(
to: prompt,
generating: StructuredNoteDraft.self,
options: GenerationOptions(temperature: 0.0)
).content
draft.kind // "memo" / "todo" / "idea" のいずれかしか入らない
.anyOf で列挙を指定すると、その3値以外は返ってきません。JSONを文字列で受け取ってパースして、失敗したらリトライして……という処理が最初から存在しない。ここはクラウドのLLMを叩いていたときより明確に楽でした。
つまり、呼ぶこと自体は簡単です。大変だったのは、このあと書くとおりモデルの小ささに合わせて設計する側でした。
3Bというサイズ感について。GPT-4は推定1.8兆パラメータと言われているので、ざっくり600分の1くらいです。あくまで「そのくらい違う」という目安として。
「たね」がやっていること
冒頭で触れたアプリの中身です。処理は3段階になっています。
- Siriに話す(App Intents経由。ロック中でも捕捉できる)
- 音声認識の誤変換をFMで訂正する ← この記事の本題
- FMが種別・タイトル・タグを付けて保存する
2が問題でした。音声で入力する以上、誤変換は必ず混ざります。しかも、集めてみると難易度がはっきり3段階に分かれていました。
① 表記が違うだけ(読みが一意)
発話 : Notionにまとめる
認識結果: ノーションにまとめる
固有名詞やサービス名がカタカナのまま残ります。メモとしては読めますが、あとで「Notion」で検索しても引っかかりません。読みから正解が一意に決まるので、これは簡単な部類です。
② 同音で意味が変わる
議事録を性格にまとめる → 議事録を正確にまとめる
以外と早く着いたのでカフェ寄る → 意外と早く着いたのでカフェ寄る
「性格に」「以外と」は日本語としては存在する表記なので、字面を見ただけでは誤変換だと判断できません。文の意味を見ないと決まりません。
③ 文脈によって、正解が反転する
これが一番厄介でした。自分がよく喋る「ヤクルト1000」は、読みが「やくるとせん」なので、こう化けます。
コンビニでヤクルト戦を買っておいて → コンビニでヤクルト1000を買っておいて(直すべき)
今日のヤクルト戦を神宮で観戦してきた → 今日のヤクルト戦を神宮で観戦してきた(直したら壊れる)
入力の文字列がまったく同じで、正解が逆になります。 上を直せるようにすると、下を壊す危険が同時に生まれる。「誤変換を直す」という機能が、単なる置換ではなく文脈判断の問題だと分かったのがこの例でした。
そして、下のケースを壊すのが一番まずい。ちゃんと認識できていた文をアプリが勝手に書き換えたら、もうそのメモは信用できません。ここが後々の設計の軸になります。
試行1:文章をまるごと校正させる → 全滅
最初に考えたのは、当然これでした。
以下の文章の音声認識の誤変換を修正してください。
入力: ノーションにまとめる
クラウドのLLMなら通る発想です。実機で試したら全滅しました。
- 誤変換が直らない。 「ノーション」は「ノーション」のまま返ってくる
- 書いていない内容が生える。 元の文になかった一文が足されたり、言い回しを勝手に丁寧にされたりする
つまり、直してほしいところは直らず、直してほしくないところが変わるという最悪の組み合わせでした。メモアプリで本文が書き換わるのは、機能として不便なのではなく、単に信頼を失います。
原因はモデルの小ささだと思っています。「文脈を読んで、直すべきところだけ直して、それ以外は一字も変えるな」という指示は、言葉にすると一行ですが、中身は複数の判断の組み合わせです。クラウドのLLMはこれを一発でこなしてくれるので、自分はそれを当たり前だと思っていました。3Bにその余力はありませんでした。
試行2:二択から正しい表記を選ばせる → 不安定
自由に書かせるのが悪いなら、選ばせればいい。次はこう変えました。
この文の「ノーション」は、次のどちらが正しいですか?
A: ノーション
B: Notion
生成をやめて選択にしたので、少なくとも文章が生えることはなくなりました。ただ今度は、プロンプトの言い回しを少し変えるだけで「全部A」と「全部B」に振れるようになりました。
選択肢の順番を入れ替える、説明文を一行足す、その程度で結果がひっくり返ります。手元で調整すると一時的に良くなるのですが、別のケースを足すとまた崩れる。いわゆるチューニングの沼です。
ここで引っかかったのが、「全部A」「全部B」という壊れ方をしたことでした。文脈をちゃんと読んだ上で間違えているなら、当たりと外れが混ざるはずです。全部同じ答えになるということは、そもそも文を読んでいないということになります。
自分の解釈はこうです。この聞き方は、モデルから見ると2つの解き方があります。
- 文の意味を理解して、AとBのどちらが合うか判断する(難しい)
- 目の前に並んでいる文字列のうち、それっぽい方をそのまま出力する(簡単)
3Bは2に流れているように見えました。選択肢として表記そのものを見せた時点で、正解の文字列を目の前に置いてしまっているわけです。モデルは意味を読まなくても、見えている文字列をコピーすれば「形式としては正しい答え」を返せてしまいます。そして、どちらをコピーするかはプロンプトの些細な形で決まる。だから全部Aか全部Bに倒れる、と考えると挙動の説明がつきました。
そう考えると、選択肢を見せる形式そのものが罠だったことになります。
ここは実測から立てた自分の仮説で、モデルの内部を確認したわけではありません。ただ、後述するとおり「語形を見せない」形に変えたら安定したので、方向としては合っていたと思っています。
たどり着いた形:ほとんどコードで解いて、FMは最後だけ
最終的に、さっきの難易度3段階に対応する3層になりました。FMが登場するのは一番下だけです。
1層目:読みが一意なものは、コードで確定する
本文の「読み」を取って、辞書と照合します。「ノーション」のように読みから正解が一意に決まるものは、この時点で置換して終わりです。FMの出番はありません。
- 読み取得はOS標準の
CFStringTokenizerで語分割+読み取得(外部通信なし) - 辞書とは編集距離1まで許して照合。ただし距離1を許すのは5文字以上の語だけ、かつ語頭・語尾2文字一致必須
- トークン境界の整合や、すでに正しい表記になっているものの除外など、ガードを何重かに入れる
2層目:手がかりの語があるものも、コードで決める
「ヤクルト戦」のように文脈で反転するものは、辞書エントリ側に反証キーワードを持たせました。ヤクルト1000のエントリなら、こういう語を登録してあります。
{
"word": "ヤクルト1000",
"reading": "やくるとせん",
"kind": "guarded",
"sense": "乳酸菌飲料の商品名(飲む・買う・持って帰る・冷やすなど、物として扱う対象)",
"rivalKeywords": ["神宮", "試合", "観戦", "応援", "球場", "先発", "打線", "スワローズ", ...]
}
文中にこれらが出てきたら野球の話なので、訂正しません。これも単なる文字列照合なので、実機とシミュレータで結果が変わらないし、レイテンシもゼロです。LLMに聞かなくて済むものを、わざわざLLMに聞かない。
3層目:手がかりが無いものだけ、FMに「はい/いいえ」で聞く
残るのが「意外と」「正確に」のような、キーワードで機械的に切り分けられないものです。ここで初めてFMを呼びます。実際に投げているプロンプトがこれです。
文: 議事録を性格にまとめる
質問: この文は「『正しく間違いなく』の意味」の話ですか?
質問文に「正確に」という表記そのものを出していません。 出しているのは意味の説明だけです。試行2で踏んだ「見えている文字列をコピーして答える」逃げ道を、入力から消しています。
出力は二値に固定しました。
let choiceSchema = DynamicGenerationSchema(
name: "answer",
description: "文の内容がその意味に当てはまるかどうか",
anyOf: ["はい", "いいえ"]
)
constrained decodingなので、はい/いいえ以外は返せません。プロンプトでのお願いではなく構造的な強制なので、形式ミスは原理的に起きません。ここはFMで一番ありがたかったところです。分類なので temperature は0にしています。
そして、迷ったら直さない
最後に全体のガードとして、「はい」が返ってきたときだけ訂正するようにしました。「いいえ」も、判定エラーも、FMが使えない端末も、全部まとめて「原文のまま」に倒れます。
let choice = (answer == "はい") ? suspicion.candidate : suspicion.surface
誤変換が残るのは、まだ許してもらえます。でも正しく認識できていた文を勝手に書き換えられたら、そのアプリはもう使われません。失敗の方向を片側に寄せるというのが、この機能の設計の芯になりました。
数字
敵対的テスト(誤訂正をわざと誘発する意地悪ケース込み)をTDDで7ラウンド回して、実機のiPhoneで計測しました。
| 項目 | 結果 |
|---|---|
| テストケース | 376 |
| 通過 | 345 |
| 正しい文を壊した件数 | 0 |
未通過の31件は、すべて「直すべきものを直せなかった」側です。壊した側はゼロでした。辞書は現在70語ほどで、アプリ内の報告機能から追加して育てる運用にしています。
辞書にない誤変換は直せません。これはこの方式の限界で、割り切りです。「網羅」より「壊さない」を優先しました。
現時点での学び
先に書いておくと、今のアルゴリズムが最適だとは自分では思っていません。
辞書を人力で育てる方式は素朴ですし、反証キーワードを手で並べているところは、もっとうまいやり方があるはずです。3層に分けたのも、失敗を潰していった結果そうなっただけで、最初から設計したわけではありません。
その前提で、今の時点で学びになったことを挙げておきます。
LLMに聞かなくて済むことを、LLMに聞かない。
検出・候補選び・ガードは、決定的なコードで書けるなら全部そっちに寄せる。結果として「たね」では、誤変換訂正の大半がFMを一度も呼ばずに終わっています。速いし、実機とシミュレータで結果が変わらないし、テストも書きやすい。
モデルへの入力から、逃げ道を消す。
正解の文字列を選択肢として見せると、意味を読まずにそれをコピーする方に倒れました。「何を聞くか」だけでなく「何を見せないか」を考えるようになったのは、この件の一番大きい収穫かもしれません。
失敗の方向を片側に固定する。
確信が持てないなら何もしない、を既定にしておくと、非対応端末のフォールバックも、エラー処理も、迷ったケースも、全部同じ挙動に落ちます。分岐を増やさずに安全側に倒せました。
まとめると、3Bのモデルは「賢い担当者」ではなく「文脈をちょっとだけ見てくれる関数」くらいに考えて、渡す仕事を削れるだけ削るとうまくいく、というのが今のところの感触です。
FMを触る前に知っておきたい制約
自分がハマる前に知りたかったことを並べておきます。
- 対応端末: A17 Pro以上(iPhone 15 Pro以降)+ Apple Intelligence がON。M1以降のiPad / Macでも動きます
- 非対応端末: 当然使えないので、フォールバックは必須です。自分は「訂正なしで素通し」にしました
- コンテキスト長: 約4096トークンと小さめ。長文の要約より、短いテキストの分類・抽出・構造化が得意ゾーンです
- 料金・回数制限: ありません。代償は電池と速度(1回あたり数百ms〜)
- ハルシネーション対策: 本文をFMに書かせないのが一番効きました。「たね」でFMが作るのはタイトル・タグ・分類だけで、本文の整形はフィラー除去だけの純関数です
Androidにも対応物として Gemini Nano(AICore / ML Kit GenAI)があります。自分は触っていないので比較は語れません。
まとめ
- iPhoneには無料のオンデバイスLLM(約3B)が載っていて、Swiftから数行で呼べる
- ただしクラウドLLMの感覚で「お任せ」すると壊れる。文章を書かせると、直らないうえに内容が生えた
- 選択肢に正解の表記を並べる方式も不安定だった。意味を読まずに、見えている文字列をコピーする方に倒れた
- 落とし所は「コードで解けるものは全部コードで解いて、FMには二値判定だけ聞く」(大半はFMを呼ばずに終わる)
- 出力形式は
@Generableの constrained decoding で構造的に固定できる。形式ミスは起きない - 失敗は常に「何もしない」側に倒す。壊さない設計にすると、非対応端末のフォールバックも自然に決まる
2026年6月のWWDC26で、第3世代(AFM 3)が発表されました。オンデバイスは2モデル構成です。
- AFM 3 Core: 従来どおり3Bのdenseモデル。その次世代版という位置づけ
- AFM 3 Core Advanced: 総パラメータ20B。ただしsparse構成で、1回のリクエストで実際に動くのは1〜4B
面白いのは20Bの動かし方で、モデル全体はフラッシュメモリ(NAND)に置いたまま、軽量なdenseブロックがプロンプトごとにルーティングを決めて、必要な"expert"だけDRAMに入れ替える、という設計だそうです。これは Instruction-Following Pruning(IFP)という手法がベースになっています。
ただ、この20BモデルはApple公式には「最も高性能なApple siliconで解禁される」としか書かれていません。対応チップの具体名は報道ベースの情報です。古い端末には引き続き3Bの方が配られるので、この記事で書いた「3Bとの付き合い方」は、当分そのまま必要だと思っています。
それに、20Bになっても「判断をコードに寄せて、モデルの仕事を小さくする」という考え方自体は効くはずです。オンデバイスAIは、これからが本番という感じがします。
この記事で使ったアプリは「Tane - 声でメモ」という名前でApp Storeに出しています。触ってもらえると嬉しいです。
辞書にない誤変換はまだ直せませんし、書いたとおり今の方式が最適だとも思っていないので、こうした方がいいという話があればコメントで教えてもらえると助かります。
参考
- Foundation Models framework | Apple Developer Documentation
- Introducing the Third Generation of Apple's Foundation Models | Apple Machine Learning Research(AFM 3 / 20Bモデルの一次ソース)
- Apple's third-generation Foundation Models explained | 9to5Mac
- CFStringTokenizer | Apple Developer Documentation
- Gemini Nano | Android Developers