朝、自分の壁紙アプリを開いて「この画像で詩を生成」ボタンを押したとき、画面の下半分がうっすら灰色になったまま 0.9 秒ほど固まりました。詩の中身は綺麗だったのですが、ボタンを押した指がまだ画面に触れているうちに反応してほしいタイプの体験です。Gemini 2.5 Flash の API レイテンシを責めるのは少し違って、そもそも「6行の俳句のためにクラウドへ往復するのか」という設計の問題でした。
アーティスト・クリエイターの廣川政樹です。2014年から個人で iOS と Android のアプリを開発・運営しており、累計5,000万ダウンロード超の壁紙アプリ群の改善を続けています。iOS 26 で正式公開された Apple の FoundationModels フレームワークが手元の iPhone 15 Pro でも安定して動くようになったので、Gemini API と組み合わせて「先にオンデバイス、足りなければクラウド」という二段構えに切り替えました。1週間分の運用ログが揃ってきたので、設計判断と Swift 実装を書き残しておきます。
なぜハイブリッド設計が必要になったか — 個人開発の3つの制約
最初は迷わず Gemini 2.5 Flash 単体で組んでいました。多言語の詩を破綻なく出力でき、コストも 100万トークン換算で見れば許容範囲です。それでも以下の3点で行き詰まりました。
ひとつめは、ボタンを押してから初行が見えるまでのレイテンシです。Cloudflare Workers 経由で API を叩く構成では、TTFB が 600〜900ms。日本国内の昼帯で測ると平均 920ms 前後でした。壁紙アプリのユーザーは「画像を眺めながら短い詩を読む」という連続動作に入るため、1秒近い待ち時間は心理的に長い。
ふたつめは、コストです。月間アクティブの DAU が 8,000 前後、1ユーザーあたり 1.4 回ほど「詩を生成」を押します。月で約 33万リクエスト。プロンプトと応答を合わせて 1リクエスト 400 トークンとして、入力 1.32億トークン・出力 0.66億トークン。2.5 Flash の単価で計算すると月 ¥18,400 ほどに落ち着いていました。広告収益の改善ぶんを丸ごと差し引く規模ではないものの、無料アプリの軸として持続的ではありません。
みっつめは、オフライン挙動です。地下鉄や機内モードで開いたユーザーが「読み込み中」のまま止まると、レビューで「最近重くなった」と書かれます。AdMob のインタースティシャル広告とのタイミングがずれる副作用もあり、機内モードでも詩だけは出る状態を作りたかった。
このあたりの判断を整理していくうち、Apple FoundationModels が短文生成にちょうどよさそうだと気付きました。3B 級の蒸留モデルですが、6〜12行の詩や俳句のような短く構造化された生成タスクなら十分に扱えます。
Apple FoundationModels(iOS 26)に期待してよいことと、してはいけないこと
最初に明確にしておくと、Apple FoundationModels は「クラウドモデルの代替」ではありません。私の現場で実用に耐えると感じたのは、次のような範囲です。
期待してよい範囲としては、まず英語と主要欧州言語での短文生成(200〜400 トークン程度)が安定しています。@Generable で定義した構造体への JSON 風応答は、Gemini の structured output と同様にスキーマ違反がほぼ出ません。タグ付け・キーワード抽出・短い説明文生成・短歌や俳句のような定型詩は、得意分野と言ってよいです。
期待してはいけないこととしては、画像入力は扱えません(Vision フレームワーク側でキャプション化してから渡す必要があります)。長文生成は途中で打ち切られやすい。日本語の出力は iOS 26.0 時点ではまだ揺らぎがあり、漢字選択が直訳寄りになります。RAG のような大きな埋め込みベクトル検索とつないだ推論は、コンテキスト上限の関係で素直には載りません。
利用可能性の確認は最初に必ず入れます。SystemLanguageModel.default.availability が .available 以外の値(.unavailable(.deviceNotEligible) や .unavailable(.appleIntelligenceNotEnabled))を返すケースがそれなりにあるため、本番では分岐コードを書く前提です。
import FoundationModels
enum LocalModelStatus {
case ready
case notEligible
case appleIntelligenceOff
case downloading
case other ( String )
}
func detectLocalModelStatus () -> LocalModelStatus {
let model = SystemLanguageModel.default
switch model.availability {
case .available :
return .ready
case . unavailable (.deviceNotEligible) :
return .notEligible
case . unavailable (.appleIntelligenceNotEnabled) :
return .appleIntelligenceOff
case . unavailable (.modelNotReady) :
return .downloading
case . unavailable ( let reason) :
return . other ( String ( describing : reason))
@unknown default:
return . other ( "unknown" )
}
}
iPhone 15 Pro 以降・iPad の M シリーズ以降が .ready になり、それ以前のデバイスは .notEligible で固定です。サポート率は私のアプリの DAU 構成で 38%ほど(壁紙アプリは比較的旧端末ユーザーが多い)でした。
「先に Apple」「足りなければ Gemini」のルーティング判定ツリー
設計の核は、毎リクエストでどちらに流すかを宣言的に決められるようにすることです。Swift の enum と switch で書き切れる粒度に保つと、後からの調整が楽になります。
私の場合、判定軸は以下の4つに整理しました。
ローカルモデルが利用可能か(SystemLanguageModel.availability)
リクエストの言語タグが Apple のサポート対象に含まれるか
想定される出力長と入力長の合計がオンデバイス上限を超えないか
ユーザーが「高品質モード」を明示的に選んでいないか(設定画面のトグル)
判定をひとつの関数に閉じ込めて、上位レイヤから呼びやすくします。
enum InferenceTarget {
case appleFoundationModels
case geminiFlash
case geminiPro
}
struct RoutingInput {
let languageCode: String // "ja" / "en" / "fr" など
let estimatedInputTokens: Int
let estimatedOutputTokens: Int
let userPrefersQuality: Bool
let modelStatus: LocalModelStatus
}
func decideTarget ( _ input: RoutingInput) -> InferenceTarget {
// 高品質モードは常にクラウド
if input.userPrefersQuality {
return input.estimatedInputTokens > 32_000
? .geminiPro
: .geminiFlash
}
// ローカルが使えない / 言語非対応 / 入出力が長すぎる場合はクラウド
let totalTokens = input.estimatedInputTokens + input.estimatedOutputTokens
let isAppleLangSupported = [ "en" , "fr" , "de" , "es" , "it" , "pt" ]. contains (input.languageCode)
let isWithinLocalLimit = totalTokens <= 1_500
switch input.modelStatus {
case .ready where isAppleLangSupported && isWithinLocalLimit :
return .appleFoundationModels
default:
return input.estimatedInputTokens > 32_000
? .geminiPro
: .geminiFlash
}
}
isAppleLangSupported の言語リストは iOS 26.0 時点の体感です。日本語は除外しました。出力品質を試したところ、俳句で漢字の取り違えが目立った(「秋の鴨」が「秋の鶴」になるなど)ためで、しばらくは Gemini 側に寄せます。半年後の iOS 26.x で評価し直す前提で、コード内のリストは設定ファイルから差し替えられるようにしてあります。
Swift 実装: Apple FoundationModels で詩を組み立てる
詩のような構造化テキストは、@Generable でスキーマを定義して受け取るのが安定します。
import FoundationModels
@Generable
struct ShortPoem {
@Guide (description : "Title in three to five words." )
var title: String
@Guide (description : "Body lines, six to ten lines total, each fewer than 12 words." )
var lines: [ String ]
@Guide (description : "Mood tag: calm, melancholic, hopeful, energetic." )
var mood: String
}
actor LocalPoemGenerator {
private var session: LanguageModelSession ?
func prepare () async throws {
guard SystemLanguageModel.default.availability == .available else {
throw PoemError.localUnavailable
}
if session == nil {
session = LanguageModelSession ( model : .default,
instructions : """
You are a quiet, observant poet. Use plain English.
Avoid metaphors that depend on Japanese cultural cues.
""" )
}
}
func generate ( promptDescription : String ) async throws -> ShortPoem {
try await prepare ()
guard let session else { throw PoemError.localUnavailable }
let response = try await session. respond (
to : """
Write a short poem inspired by the following scene.
Scene: \( promptDescription )
Keep the language sober and concrete.
""" ,
generating : ShortPoem. self ,
options : GenerationOptions ( temperature : 0.7 , maximumResponseTokens : 320 )
)
return response.content
}
}
enum PoemError : Error {
case localUnavailable
case schemaMismatch
case timedOut
}
actor でラップしている理由は、LanguageModelSession がスレッドセーフではない・かつ初期化が比較的重い(端末で 150〜400ms ほど)ためです。1セッションを再利用すれば 2 回目以降は 30〜80ms で済みます。アプリ起動時に prepare() を一度走らせておくことで、ユーザーがボタンを押したときの初回遅延を消せました。
Swift 実装: Gemini API へ切り替える
クラウド側は Gemini 2.5 Flash を主に使い、入力が長文に振れたケースだけ 2.5 Pro を選びます。Swift には公式 SDK がないので、URLSession で generateContent を直接叩きます。
struct GeminiResponse : Decodable {
struct Candidate : Decodable {
struct Content : Decodable {
struct Part : Decodable { let text: String }
let parts: [Part]
}
let content: Content
}
let candidates: [Candidate]
}
actor GeminiPoemGenerator {
private let apiKey: String
private let session: URLSession
init ( apiKey : String , session : URLSession = .shared) {
self .apiKey = apiKey
self .session = session
}
func generate ( promptDescription : String ,
model : String = "gemini-2.5-flash" ) async throws -> ShortPoem {
let endpoint = URL ( string :
"https://generativelanguage.googleapis.com/v1beta/models/ \( model ) :generateContent?key= \( apiKey ) " ) !
let schema: [ String : Any ] = [
"type" : "OBJECT" ,
"properties" : [
"title" : [ "type" : "STRING" ],
"lines" : [ "type" : "ARRAY" , "items" : [ "type" : "STRING" ]],
"mood" : [ "type" : "STRING" , "enum" : [ "calm" , "melancholic" , "hopeful" , "energetic" ]]
],
"required" : [ "title" , "lines" , "mood" ]
]
let body: [ String : Any ] = [
"contents" : [[
"parts" : [[ "text" : """
Write a short poem inspired by the scene below.
Output JSON that matches the schema. Six to ten lines.
Scene: \( promptDescription )
""" ]]
]],
"generationConfig" : [
"responseMimeType" : "application/json" ,
"responseSchema" : schema,
"temperature" : 0.8 ,
"maxOutputTokens" : 512
]
]
var request = URLRequest ( url : endpoint)
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try JSONSerialization. data ( withJSONObject : body)
request.timeoutInterval = 8.0
let (data, response) = try await session. data ( for : request)
guard let http = response as? HTTPURLResponse, ( 200 ..< 300 ). contains (http.statusCode) else {
throw PoemError.timedOut
}
let decoded = try JSONDecoder (). decode (GeminiResponse. self , from : data)
guard let jsonText = decoded.candidates. first ? .content.parts. first ? . text ,
let jsonData = jsonText. data ( using : . utf8 ) else {
throw PoemError.schemaMismatch
}
return try JSONDecoder (). decode (ShortPoem. self , from : jsonData)
}
}
ここでも ShortPoem を共通の型として再利用しています。Apple 側でも Gemini 側でも、上位レイヤから見ると「ShortPoem を返す関数」一本に揃うので、ルーティング先を切り替えても UI コードを変えずに済みます。
単一インターフェースに集約するファサード
判定とふたつの実装をまとめるファサードを置きます。アプリ本体からはここだけを見ます。
final class PoemEngine {
private let local: LocalPoemGenerator
private let cloudFlash: GeminiPoemGenerator
private let cloudPro: GeminiPoemGenerator
init ( geminiApiKey : String ) {
self .local = LocalPoemGenerator ()
self .cloudFlash = GeminiPoemGenerator ( apiKey : geminiApiKey)
self .cloudPro = GeminiPoemGenerator ( apiKey : geminiApiKey)
}
func generate ( scene : String ,
languageCode : String ,
estimatedInputTokens : Int = 220 ,
estimatedOutputTokens : Int = 320 ,
userPrefersQuality : Bool = false ) async throws -> (ShortPoem, InferenceTarget) {
let status = detectLocalModelStatus ()
let target = decideTarget (. init (
languageCode : languageCode,
estimatedInputTokens : estimatedInputTokens,
estimatedOutputTokens : estimatedOutputTokens,
userPrefersQuality : userPrefersQuality,
modelStatus : status
))
do {
switch target {
case .appleFoundationModels :
return ( try await local. generate ( promptDescription : scene), target)
case .geminiFlash :
return ( try await cloudFlash. generate ( promptDescription : scene,
model : "gemini-2.5-flash" ), target)
case .geminiPro :
return ( try await cloudPro. generate ( promptDescription : scene,
model : "gemini-2.5-pro" ), target)
}
} catch PoemError.localUnavailable, PoemError. schemaMismatch {
// ローカルが失敗した場合はクラウドへ静かにフォールバック
return ( try await cloudFlash. generate ( promptDescription : scene), .geminiFlash)
}
}
}
呼び出し側はこれだけです。返り値に InferenceTarget を含めておくと、計測やデバッグログで「どちらの経路を通ったか」を追えるようになります。私は最初これを忘れていて、レイテンシ計測の解釈が混乱したのが反省点でした。
レイテンシ・コスト・成功率 — 1週間運用したログ
iOS 26 ベータの自社内テストフライトと、本番リリース直後の数日を合算して 1週間分のログを集計しました。サンプルは 11.4万リクエスト。経路別の数字は次の通りです。
ローカル経路(38% のユーザーが該当・実利用比 46%)は、平均レイテンシ 90ms、p95 で 180ms、失敗率 0.4%。失敗の内訳はほぼ schemaMismatch で、maximumResponseTokens を 320 → 384 に上げてからは 0.1% 台に落ち着きました。コストはゼロです。
Gemini 2.5 Flash 経路(実利用比 51%)は、平均 720ms、p95 で 1.4s、失敗率 0.7%。タイムアウトは 8秒設定で、500番台が大半でした。Workers 経由から直接 API 呼び出しに切り替えたら p95 が 1.8s → 1.4s に縮みました。
Gemini 2.5 Pro 経路(実利用比 3%、ユーザーが高品質モードを ON にしたケース)は、平均 1.2s、失敗率 0.2%。
月次コスト換算で見ると、Flash 経由のリクエストが 33万から 16.8万に減ったことで、Gemini 課金が ¥18,400 → ¥4,720 に縮みました。割合で言うと約 74% の削減です。
AdMob の eCPM への影響は、計測誤差の範囲内でした(前週比 -0.6% / 信頼区間内)。むしろ、ローカル経路でレイテンシが縮んだことで「詩を読んだあと別画像へスワイプ」の動線が滑らかになり、セッション中の閲覧画像数が 1.07 倍に伸びました。インタースティシャルの表示回数も連動して 1.05 倍となり、収益はわずかに増えています。
本番に入れて分かった4つの落とし穴
最初のうまくいかなかった部分を、自分への戒めとして残しておきます。
ひとつめ: 初回ロードの遅延を見落とした 。 LanguageModelSession の初期化は端末によっては 400ms 近くかかります。私は最初これを generate() 呼び出しの中でやっていたため、最初の1回だけが極端に遅く、その後は速いという挙動になりました。アプリ起動直後に actor.prepare() を一度走らせるパターンに変えてから症状が消えました。
ふたつめ: 言語判定をクライアントの Locale に頼り過ぎた 。 日本のユーザーが英語インターフェースに切り替えて使うケースが意外と多く、Locale.current.language.languageCode?.identifier だけだとミスマッチが起きました。プロンプトの description テキスト自体を NLLanguageRecognizer(NaturalLanguage フレームワーク)で軽く判定して上書きする処理を入れて解決しました。
みっつめ: 温度パラメータの体感差が大きい 。 同じ temperature: 0.7 でも、Apple FoundationModels と Gemini 2.5 Flash では出力のばらつき方が違います。Apple は語彙の振れ幅が小さく、Gemini は構文の振れ幅が大きい印象です。私は Apple 側を 0.7、Gemini 側を 0.8 で揃えると体感品質が近づくことに気づき、それぞれの呼び出し箇所で異なる値を渡しています。
よっつめ: スキーマ違反のフォールバック設計 。 try で失敗したときに、UI に「生成できませんでした」とだけ出すのは不親切でした。フォールバックでクラウドに再試行し、それも失敗したら「次の画像で試す」ボタンを出すように変えると、ユーザーからのクレームレビューがゼロになりました。本番で動かす AI 機能は、必ず「失敗時の体験」を別途設計する必要があります。
どこから手を付けると効くか — 私が個人開発者に推奨する順序
まずは、いま使っている Gemini API のリクエストログを 1日分でいいので眺めて、「実は短いタスクだった」リクエストを数えてみることをお勧めします。私の場合、全体の 46% が「6〜10行・主に英語・入出力合計 1,500 トークン以下」に収まっており、ここをローカルに移すだけで効果が出ると先に分かったので、設計に踏み切れました。
次に、SystemLanguageModel.availability を本番アプリで観測するコードだけ先に入れて、自分のユーザー基盤のどれくらいが対象端末かを把握します。Firebase Remote Config 経由で計測フラグを ON/OFF できるようにしておくと、慎重に段階公開できます。私は最初の3日は計測のみ、4日目から 10% に絞ってルーティングを有効化、1週間で 100% に開きました。
ここで紹介したコードは、私が実運用している壁紙アプリで動いているものを、ドメイン特有の部分を削って一般化したものです。Apple FoundationModels はまだ進化途中で、私の判断軸も来期にはまた書き直すつもりです。同じように個人で AI 機能を運用している方の判断材料になればうれしいです。お読みいただきありがとうございました。