🍎

iOS Foundation Modelを実際のアプリに統合してみた —良かった点、苦労した点、そして「もうボタンで解決しちゃえ」と決めた話

に公開

きっかけ

趣味で開発しているライブの記録アプリに、抽選機能がある。チケット抽選のスケジュールを登録し、申込締切や当落発表のリマインダーを受け取れる。

コンセプトには自信があったけど、使う側は入力の手間がかかることに作ってから気づいた。

ライブによく行く人はわかると思うけど、販売経路は複雑で、「チケットを買う」というのはもはや「作戦」だ。
推しのチケットを手に入れるまでの道のり:

  • ファンクラブ先行 1次
  • ファンクラブ先行 2次
  • クレジットカード会員先行
  • プレイガイドA 先行
  • プレイガイドB 先行
  • 一般発売(2秒で売り切れ)
  • リセール・マッチングシステム

受付期間も当落発表も全部バラバラ。1ツアー追うだけで5〜7件の手入力が必要になる。気軽とは言えない。

公式の告知をそのまま貼り付けるだけで、アプリが勝手に読み取ってくれたら——。


regexを諦めた理由

実際の抽選告知テキストはこんな感じだ:

会員受付 (XYZ会員対象)
受付期間:2025.01.10 18:00 - 2025.01.18 23:59
当選発表:2025.01.25
*受付期間の締切までに、XYZ会員マイページより下記のID登録情報を
ご登録ください。
(メールアドレス、氏名、生年月日、性別、住所、電話番号)

プレイガイド先行販売・一般発売

プレリザーブ先行
■申込受付期間
2月1日(月)正午 〜 2月7日(日)23:59
■抽選結果発表
2月11日(祝)18:00
本先行にお申込みできるのは、下記の方のみとなります。
「ZYXカード」に新規入会申込をした会員の方
すでにぴあXYZカードをお持ちの「プレミアム会員」の方

一般発売
■販売開始
2025年3月13日(土)10:00 〜

このバラバラさを見てほしい:

  • 日付形式:2025.01.10 vs 2月1日(月) vs 2025年3月13日(土)
  • 時刻形式:18:00 vs 正午 vs 10:00 〜
  • ラベル:当選発表 vs ■抽選結果発表
  • 無関係なテキストも混在(登録案内、応募資格など)

チケット販売サイトごと、ファンクラブごと、イベントごとに書式が違う。

まず regex で抽出できるか考えてみた。
日付だけならもちろんできる。
でも、各抽選は「会員受付」「プレリザーブ先行」「WEB抽選先行」などバリエーションがあって、どの名前がどの日付セットに対応するか、テキストの構造を理解して判断する必要がある。パターンマッチングだけでは厳しい。

しかもこれはまだ公式告知テキストの話。
ユーザーが貼り付けたいのは、Xに誰かが投稿したまとめとか、友達からのLINE、自分のメモかもしれない。
理想としては「何でも貼り付けたら、ちゃんとしたデータになって出てくる」なので、regexだとやっぱり厳しい。

汎用的で柔軟な抽出・・・じゃあやっぱりAIでは?
ということで、iOSのFoundation Modelsを試してみることにした。


なぜオンデバイス?

外部API オンデバイス
コスト リクエストごとに課金 無料
プライバシー ユーザーのテキストが外部へ 端末内で完結
安定性 モデルが予告なく変わる iOSバージョンに紐づく
端末 どこでも動く iPhone 15 Pro以降のみ
出力 JSONをパースする必要あり ネイティブSwift型

コストと出力の部分が決め手になった。


@GenerableとネイティブSwift出力

使ってみて気に入ったのは次の部分。

@Generable
struct LotteryApplicationGeneratedInfo {
    @Guide(description: "Name of the lottery round")
    var name: String

    @Guide(description: "True only for 先着順 or 一般販売")
    var isFirstComeFirstServe: Bool

    var applicationStartYear: Int
    var applicationStartMonth: Int
    var applicationStartDay: Int
    var applicationStartHour: Int
    var applicationStartMinute: Int
    // ... 終了日時、当落発表日時なども同様
}

@Generable
struct LotteryApplicationParsingResults {
    var lotteryApplicationInfos: [LotteryApplicationGeneratedInfo]
}

Swift構造体を定義してモデルに生成を頼むと、、、Swift構造体が直で返ってくる。
で、型付きの値がそのまま入っている。
JSONパース不要。

AIでよくある「今回はなぜかMarkdownで返ってきた」みたいなのがない。

しかも配列の長さが事前にわからなくても大丈夫。
抽選が1件なら1件、7件なら7件。
モデルがよしなにやってくれる。

この瞬間、「もしかしたらいけるかも」から「よし、作ろう」に変わった。


詰まったとこ①:日付処理

さっきのコードを見て「year、month、day...全部 Int でダイジョブ?」と思った方がいるかもしれないけど、なんと Date 自体サポートされていない。

// 普通に考えると
var applicationStart: Date  // ❌ コンパイルでエラー

// 回避策:とにかく Int 
var applicationStartYear: Int
var applicationStartMonth: Int
var applicationStartDay: Int
var applicationStartHour: Int
var applicationStartMinute: Int

酷いやり方だけど、一旦これで回避。
生成後に自前のコードでDateに再構築するようにした。


詰まったとこ②:年の推論問題

例えば、

  • 入力時点の年月日:2025/11
  • ユーザー入力:受付期間: 1/9 〜 1/20 結果発表: 1/25
    の場合、もちろんこの1月は、2026年1月を意味している。

が、モデルは一貫して、2025年1月と判断していた。

明示的なルール、具体例、ステップバイステップの推論指示……色々試したけど、どれも安定しなかったので、、、戦うのをやめた
とりあえずUIに年調整コントロールを追加して回避することに。

[ − ]  年  [ + ]

+1をタップすれば、すべての日付が1年先にずれる。終わり。

プロンプトを1時間いじるより、ボタンを1つ追加するほうが良いエンジニアリングのこともあるっちゅうことです。


削除せざるを得なかったフィールド

notesフィールド

手動入力には、メモ欄的な notesフィールドがあった。
AI入力でも同じ構造体を使うので、自然とこのフィールドもモデルに渡していた。

結果、モデルが notes に捏造し始めた。

抽選名を言い換えたり、すでに他で取得した日付を要約したり。
よく考えたら、告知テキストに「モデルが抜き出すべき補足情報」がそもそも存在しない。
このフィールドはユーザー自身が書くためのものであって、モデルが埋めるものではなかった。
よって削除。

urlフィールド

抽選告知には申込URLが含まれていることがあるので、それもフィールドにしていたが、

  1. 複数のURLがある場合、モデルがエントリ間で混同する
  2. URLがない場合、モデルがもっともらしい偽物を生成する
    という2つの問題が発生。
    2は特にやばい。

ということで、「入力にURLがない限り、URLを生成するな」という指示を追加。
が、モデルはガン無視で偽URLをガンガン作成する。
これは納得いかないけど、今のモデルの能力だとどうしようもないのかな・・・。

両方のフィールドをスキーマから完全に削除した。問題解決。


効果があったプロンプトパターン

具体例を提供する

指示だけでなく、@Generable型の実際のインスタンスを:

session = LanguageModelSession {
    "You are provided with ticket lottery details..."

    "Here is an example of correctly parsed output:"
    LotteryApplicationGeneratedInfo.exampleLottery  // 実際の構造体インスタンス

    "Now analyze the following..."
}

出力が目に見えて安定した。
パターンマッチの対象となる具体例を教えてあげる、いわゆる one-shot というやつ。

デフォルトの仮定を明示する

データには2種類ある:抽選と先着。
先着は稀で、普通はリストの最後にしか出てこない。
でもモデルは何でも先着に分類したがった。

対策として、プロンプトを明示的にしてみる:

YOU MUST EXPECT ALL ENTRIES ARE LOTTERIES BY DEFAULT!
ONLY CONSIDER FCFS IF EXPLICITLY STATED!

(ちなみにプロンプトは英語、ユーザー入力は日本語でも問題なく動く。)

大文字で強調したら、バランスは改善した。
でも完全にはエラーを排除できず。
入力の表現によっては、モデルが誤分類することがある。

ということで、UI設計でモデルの不完全さを補ってみることに。

この機能は抽選ごとに1枚のカードを生成するので、
ユーザーは確定前に各カードを確認し、おかしいものだけ外せるようにした。

「全部使うか、全部捨てるか」ではなくて、モデルが7件中6件を正しく解析したら、6件分の手入力は省略できる。失敗した1件だけ直せばいい。

モデルがハルシネーションする前提で、UIでカバーする。
プロンプトで完璧を目指すより現実的かなーと思います。


スキーマ設計 > プロンプトの工夫

何度も試行錯誤を重ねた結果、これが最も重要な学びになった:

明確なプロパティ名と@Guide記述を持つ@Generable構造体は、緩いスキーマに巧妙なプロンプトを組み合わせるより良い結果を出す。
モデルはプロパティ名を見て、それをガイダンスとして使う。
applicationStartYearは、長々とした指示文より確実に意図を伝える。

逆に言えば、オプショナルフィールドとか曖昧な型とか「よしなに判断して」的に投げちゃうと、モデルが予想外のことをし始める。

これはnotes/urlフィールドのとこと同じ話で、オプショナルフィールドはすべて自由度が高い。
そういう問題を起こすフィールドは、解決するより削除したほうがいい。


ストリーミングとPartiallyGenerated

Foundation Modelsに@Generable型の生成を頼むと、部分的な結果をストリーミングで受け取れる:

let stream = session.streamResponse(
    generating: LotteryApplicationParsingResults.self
) {
    "Input to analyze: \(userInput)"
}

for try await partialInfo in stream {
    // モデルが生成するにつれて、フィールドが1つずつ現れる
    lotteryInfoArray = partialInfo.content
}

APIは.PartiallyGenerated型を提供してくれる。
すべてのプロパティがオプショナルになっている。
モデルが処理を進めると、フィールドがnilから値ありに変わっていく。

これを使って、カードがリアルタイムで「組み上がっていく」UIを作った:

モデルの生成に合わせてカードが段階的に構築される様子
PartiallyGeneratedを活用したリアルタイムUI更新

ただ待たせる代わりに、ユーザーはデータが出来上がっていくのを見られる。
生成に数秒かかっても、レスポンシブに感じる。
こういう細部が、機能を「とりあえず動く」から「ちゃんと作られている」に変えるなーと改めて感じる。


プロファイリング

5件の抽選スケジュールをバッチ処理したときの、Instrumentsでの生成の様子:

InstrumentsでのFoundation Modelsプロファイリング
Instrumentsで見る生成処理の内訳(5件の抽選スケジュール)

合計生成時間は約14秒。
でもストリーミングのおかげで、UIは約2秒後には最初の結果をプレビューできる。
モデルが残りのエントリを処理している間も、ユーザーには進捗が見える。

黄色のセグメント(com.apple.fm.language.instruct_300m.safety)はprewarm()が呼ばれたタイミングを示している。
Appleの公式リファレンスでは、生成が近いとわかったら呼ぶことを推奨とのこと。

自分の実装では、入力画面が表示されたときに発火するようにしてる。
体感パフォーマンスが改善するかは測定が難しいけど、プロファイラに表示されるし、推奨プラクティスとして従う価値はありそう。


Foundation Modelsの使いどころ

向いているケース:

  • 雑然とした入力から構造化データを抽出したい
  • 出力スキーマが明確に定義できる
  • プライバシー重視(データを端末内に留めたい)
  • モデルが間違えてもUIでカバーできる

向いていないケース:

  • 複雑な多段階の推論
  • 大きめのコンテキストが必要(そこまで大きくなくても混乱し始める)
  • ツール呼び出しに依存する機能

余談:ツール呼び出し

Foundation Modelsはモデルが呼び出せるツールの定義をサポートしている。面白そうだったので試してみたが、プロンプトが少しでも複雑になると、モデルがツールを呼び出してくれなかった。

色々試したが結局うまくいかず、今回は諦めた。将来のiOSで改善されるかもしれないので、また試すつもりではいる。


おわりに

正直、開発者向けの新しいツールが次々と出てきて、追いかけるのに疲れることもある。
「これもまたすぐに廃れて次の何かが来るんだろうな・・・」と思ってしまうこともある。
この Foundation models も、制限が多いし万能な感じではない。
Dateサポートなし、年の推論がおかしい、ハルシネーション、挙げればキリがない。

でも、ツールとして適材適所で使うのはかなりアリだと思った。
Foundation Modelsは、汎用チャットボットとしては輝かない。
でも特定のタスク(構造化データ抽出、テキスト分類、雑然とした入力の解析など)では、これまで外部APIや手実装に頼るしかなかったことが、端末だけで完結する。
@GenerableとPartiallyGeneratedを使ったストリーミングは、Swiftとの統合感がかなり気持ちいい。

今のとこ、Foundation ModelsはiPhone 15 Pro以降が必要なので、インストールベースは限られている。
でも今後数年でユーザーがデバイスをアップグレードすると、オンデバイスAI機能はみんな当たり前に使うものになる。
参考になれば。


この記事はLiveSoul——ライブ記録アプリ——の開発経験に基づいています。

Discussion