機内モードに入った瞬間、AI 機能が黙りこむアプリが嫌いです。一度生成した文章や説明文がもう一度すぐに出てくるなら、それは「キャッシュが効いている」ではなく「ユーザーが裏切られなかった」という体験です。地下鉄でも飛行機でも、前回の応答が淡々と表示される。そのために必要なのは派手な仕組みではなく、レスポンスを丁寧に永続化する小さなキャッシュ層でした。
個人開発で iOS アプリを運用していると、壁紙系・癒し系・引き寄せ系のアプリは「電波が悪い場所」で開かれる比率がとても高いことに気づきます。地下鉄のホーム、機内、山あいの実家。そういう場所でこそ、AI 機能が黙りこむと一気に安っぽく感じられます。Gemini API を本格的に組み込み始めた 2025 年以降、私が痛感したのは「API を呼ばずに前回の応答を返す」設計が、そのまま本番品質に直結するという事実でした。SwiftData をストレージ層に据えて Gemini API のレスポンスを永続化・再利用する——その小さなキャッシュ層を、実装と運用の両面から具体的に組み上げていきます。
なぜ Gemini の context caching ではなく、アプリ側でレスポンスを持つのか
Gemini API には context caching の仕組みがあり、長いシステムプロンプトや参照文書を再利用するときには非常に有効です。ただし context caching はあくまで「サーバ側で入力トークンを再利用する」機能で、ネットワーク自体が切れている iOS デバイスでは何もできません。一方、本稿で扱う「アプリ側のレスポンスキャッシュ」は次のような場面で効きます。
ユーザーが直前に生成した文章を、機内モードで何度でも開ける。スクロールを戻したセル(UICollectionView/SwiftUI の LazyVGrid 等)で、リアルタイムに再生成せず前回の応答をそのまま再表示できます。レビュー対策で同じプロンプトを月に何度も叩く社内ツールでは、トークン費用が一桁減ります。
このとき、UserDefaults や軽量 JSON ファイルでも実装は可能です。ただし数千件規模を超えると、クエリ性能・スキーマ進化・関連オブジェクト管理で破綻します。私は壁紙アプリで「説明文生成」を 1 日あたり数千件キャッシュしていますが、SwiftData 化したことで起動時の初回ロードが体感で 1/3 以下になりました。
アーキテクチャ全体像
レイヤーは 3 つに分けています。1 つ目が SwiftData の @Model レコード(保存層)、2 つ目がプロンプト→ハッシュ→レコード解決を担う ResponseCacheStore(リポジトリ層)、3 つ目が「キャッシュにヒットすれば返し、ミスすれば Gemini API を呼ぶ」フォールバックを行う GeminiResponder(ユースケース層)です。
UI(SwiftUI View)は GeminiResponder だけを知っていれば良く、SwiftData の存在も Gemini SDK の存在も意識しません。これは後でストレージを Realm や Core Data に差し替える、あるいはオンデバイスの Gemma に切り替えるときに重要な分離です。
SwiftUI View
│
▼
GeminiResponder (Usecase) ─── if hit ──→ Cached response
│ ▲
│ miss │ save
▼ │
GeminiClient (Network) ─── response ───────┘
│
└─→ ResponseCacheStore (Repository)
│
▼
SwiftData @Model
@Model スキーマ設計
最初のキモは「キーをどう作るか」です。プロンプト全文をキーにすると 1 文字でも違えば別物扱いになり、本来同一のキャッシュが永遠にヒットしません。私は次の 3 つを SHA-256 で連結ハッシュしています。正規化済みプロンプト(前後の空白除去、改行統一)、モデル名(gemini-2.5-pro などのリテラル)、responseSchema の JSON 文字列(structured output を使う場合のみ)。
import Foundation
import CryptoKit
import SwiftData
@Model
final class CachedGeminiResponse {
@Attribute(.unique) var cacheKey: String
var promptDigest: String // 検索表示用に最初の 200 文字を保存
var modelName: String
var responseText: String
var responseSchemaHash: String? // structured output 用
var inputTokens: Int
var outputTokens: Int
var finishReason: String
var createdAt: Date
var lastAccessedAt: Date
var hitCount: Int
init(
cacheKey: String,
promptDigest: String,
modelName: String,
responseText: String,
responseSchemaHash: String?,
inputTokens: Int,
outputTokens: Int,
finishReason: String
) {
self.cacheKey = cacheKey
self.promptDigest = promptDigest
self.modelName = modelName
self.responseText = responseText
self.responseSchemaHash = responseSchemaHash
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.finishReason = finishReason
self.createdAt = .now
self.lastAccessedAt = .now
self.hitCount = 0
}
}
enum CacheKeyBuilder {
static func make(prompt: String, model: String, schemaJSON: String?) -> String {
let normalized = prompt
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\r\n", with: "\n")
let raw = [normalized, model, schemaJSON ?? ""].joined(separator: "::")
let digest = SHA256.hash(data: Data(raw.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}
@Attribute(.unique) を cacheKey に付けるのが地味ながら重要です。並行書き込みで同じプロンプトが二重保存されるのを SwiftData 側で防げます。
responseText を String ではなく Data で持つべきか悩む方が多いですが、私の経験では String で良いです。Gemini API は基本的に UTF-8 テキストを返すので Data 化のオーバーヘッドが無駄になります。例外は音声・画像の base64 を含む場合のみで、その場合は別 @Model(CachedGeminiBinary)に逃がし、@Relationship で結びます。一つのレコードを肥大化させないことが、後述のサイズ管理で効いてきます。
リポジトリ層 — ヒット率を可視化しながらヒットさせる
ヒット率を計測しないキャッシュは、運用 1 ヶ月でだいたい腐ります。私は hitCount と lastAccessedAt を毎回更新し、起動時に Top 20 ヒットレコードをログに出すデバッグ機能を入れています。これがあると「同じプロンプトを 200 回も叩いている画面がある」といった設計の歪みに早く気づけます。
@MainActor
final class ResponseCacheStore {
private let context: ModelContext
init(context: ModelContext) { self.context = context }
func find(key: String) throws -> CachedGeminiResponse? {
var descriptor = FetchDescriptor<CachedGeminiResponse>(
predicate: #Predicate { $0.cacheKey == key }
)
descriptor.fetchLimit = 1
guard let hit = try context.fetch(descriptor).first else { return nil }
hit.hitCount += 1
hit.lastAccessedAt = .now
try context.save()
return hit
}
func upsert(_ record: CachedGeminiResponse) throws {
if let existing = try find(key: record.cacheKey) {
existing.responseText = record.responseText
existing.outputTokens = record.outputTokens
existing.finishReason = record.finishReason
existing.lastAccessedAt = .now
} else {
context.insert(record)
}
try context.save()
}
func evict(olderThan days: Int) throws {
let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: .now)!
let descriptor = FetchDescriptor<CachedGeminiResponse>(
predicate: #Predicate { $0.lastAccessedAt < cutoff }
)
for record in try context.fetch(descriptor) {
context.delete(record)
}
try context.save()
}
}
@MainActor を付けているのは、ModelContext がスレッドアフィニティに敏感だからです。バックグラウンドキューから触ると EXC_BAD_ACCESS か、無音のデータ破壊を引き起こします。Swift 6 の strict concurrency でこの種のバグはコンパイルエラーに変わりつつありますが、SwiftData の Actor 設計はまだ過渡期にあるため、本稿のサンプルでは MainActor 固定が安全です。
ユースケース層 — 3 段階の無効化ロジック
「いつ古いキャッシュを捨てるか」を一つの条件で決めると、必ずどこかで失敗します。私は 3 つの軸で同時に判断するようにしています。
struct CachePolicy {
var ttl: TimeInterval // 例: 7 日
var allowedModel: String // 例: "gemini-2.5-pro"
var schemaHash: String? // structured output のスキーマが変わったら無効
var forceRefresh: Bool // ユーザーが「再生成」を押したら true
}
@MainActor
final class GeminiResponder {
private let store: ResponseCacheStore
private let client: GeminiClient
init(store: ResponseCacheStore, client: GeminiClient) {
self.store = store
self.client = client
}
func respond(prompt: String, policy: CachePolicy) async throws -> String {
let key = CacheKeyBuilder.make(
prompt: prompt,
model: policy.allowedModel,
schemaJSON: policy.schemaHash
)
if !policy.forceRefresh,
let cached = try store.find(key: key),
cached.modelName == policy.allowedModel,
cached.responseSchemaHash == policy.schemaHash,
cached.createdAt.timeIntervalSinceNow > -policy.ttl {
return cached.responseText
}
let result = try await client.generate(
prompt: prompt,
model: policy.allowedModel
)
let record = CachedGeminiResponse(
cacheKey: key,
promptDigest: String(prompt.prefix(200)),
modelName: policy.allowedModel,
responseText: result.text,
responseSchemaHash: policy.schemaHash,
inputTokens: result.usage.input,
outputTokens: result.usage.output,
finishReason: result.finishReason
)
try store.upsert(record)
return result.text
}
}
ここで forceRefresh を入れているのは、UI の「再生成」ボタンが必ず必要だからです。ユーザーは AI の応答が気に入らなければ即座にもう一度押します。そのときキャッシュが返ってしまうと、まったく改善されない応答を見て二度と機能を使ってくれません。「自動で賢いキャッシュ」より「ユーザーが上書きできるキャッシュ」のほうが信頼されます。
数百万件規模の運用で見えた 4 つの実践
ここからは机上の設計ではなく、個人開発で AdMob 収益とトークン費用を見比べながら詰めてきた、実アプリでの運用知見です。
1. SwiftData ストアは「テキスト保存だけ」に留める
最初のリリースでは応答テキスト・原画像・音声を全部同じ @Model に詰めていました。3 ヶ月後に 80 MB を超えるユーザーが現れ、起動時のマイグレーションで 3 秒固まるようになりました。バイナリは別モデルに切り出し、起動時には参照のみ復元、実体は lazy で読み込むのが正解です。
2. レスポンスバージョニングを「モデル名 + プロンプトバージョン」で二重管理
gemini-2.5-pro から gemini-3-pro に上げたとき、キャッシュをそのまま使うと旧モデルの応答が混在して品質ドリフトが起きました。modelName を厳密一致条件に入れ、さらにシステムプロンプトを更新したら手動でバージョン番号 (schemaHash フィールドを流用) を上げる運用に変えてから、ユーザーからの「以前と返答の質が違う」という問い合わせが目に見えて減りました。
3. 起動時に「最終アクセス 30 日以上前」を非同期削除
起動時の最初の Run Loop が回り終わったあと、Task.detached { try await evictOldEntries() } で背景削除します。前面で削除すると初回起動が重くなり、AppStore 評価に響きます。地味な改善ですが、私のアプリでは初回起動の P95 が 1.4 秒短縮されました。
4. iCloud 同期は「明示的に無効化」する
@Model は CloudKit と組み合わせやすい設計ですが、キャッシュデータを iCloud に乗せるとユーザーの容量を食い潰し、低速回線で起動できなくなります。ModelConfiguration(cloudKitDatabase: .none) を必ず指定し、明示的にデバイスローカル運用にしてください。
オフラインフォールバックの組み込み
機内モードや圏外を検出するために、NWPathMonitor を使います。オフラインのときはキャッシュ TTL を実質無制限に拡張し、「古くてもいいから何かを返す」モードに切り替えます。
import Network
actor ConnectivityMonitor {
private var isOnline: Bool = true
private let monitor = NWPathMonitor()
init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { await self?.update(isOnline: path.status == .satisfied) }
}
monitor.start(queue: .global(qos: .background))
}
func currentlyOnline() -> Bool { isOnline }
private func update(isOnline: Bool) { self.isOnline = isOnline }
}
GeminiResponder.respond の冒頭で if await !monitor.currentlyOnline() を判定し、ヒットがあればそのまま返し、ミスなら明示的にユーザーに「オフラインです。前回の応答は次回オンライン時に更新されます」と知らせます。沈黙より誠実な通知のほうが、評価レビューでの怒りが少ないというのが私の実感です。
SwiftData ストアの肥大化を抑える運用メトリクス
実運用では以下の指標を取っています。週次で確認し、いずれかが赤になったら即座に対応します。
| 指標 | 目安 | 異常時の対応 |
| ストア合計サイズ | 50 MB 未満 | バイナリ別モデルへの切り出しを再点検 |
| キャッシュヒット率 | 30% 以上 | プロンプト正規化と TTL の見直し |
| 最大 hitCount を持つ単一レコード | 全体の 10% 未満 | プロンプトをユーザー入力依存にしすぎ |
| 削除キュー残量(30 日超過レコード) | 100 件未満 | 起動時 evict が回っていない |
ストアサイズは FileManager.default.attributesOfItem(atPath:) で取れます。私はデバッグメニューに表示しており、ベータテスターから「重い」と言われたら最初に確認する数字です。
@Model にフィールドを足すときのスキーマ移行
運用を続けると、CachedGeminiResponse に後からフィールドを足したくなります。私の場合は thinkingTokens(Gemini 2.5 以降の思考トークン数)を途中で記録したくなりました。ここで何も考えずにプロパティを増やすと、既存ユーザーの起動時に SwiftData が暗黙のマイグレーションを試み、失敗すればストアごと作り直し——つまり全キャッシュ消失が起こり得ます。
オプショナル型(var thinkingTokens: Int?)やデフォルト値付きの追加だけなら、SwiftData の軽量マイグレーションで吸収されます。逆に、非オプショナルでデフォルトのないフィールド追加・@Attribute(.unique) の付け外し・型変更は軽量マイグレーションでは吸収できません。この境界を最初に押さえておくと、リリース後の事故が確実に減ります。
破壊的な変更を入れるときは VersionedSchema と SchemaMigrationPlan を明示します。
enum CacheSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [CachedGeminiResponse.self] }
}
enum CacheSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [CachedGeminiResponse.self] }
}
enum CacheMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[CacheSchemaV1.self, CacheSchemaV2.self]
}
static var stages: [MigrationStage] {
[.lightweight(fromVersion: CacheSchemaV1.self, toVersion: CacheSchemaV2.self)]
}
}
let container = try ModelContainer(
for: CachedGeminiResponse.self,
migrationPlan: CacheMigrationPlan.self,
configurations: ModelConfiguration(cloudKitDatabase: .none)
)
一度やらかしたのは、migrationPlan を渡し忘れたままフィールドを足して TestFlight に配ったことです。新規インストールでは動くのに、既存ベータユーザーだけ起動直後にキャッシュが空になる——という再現しづらい不具合で、原因に気づくまで半日溶かしました。スキーマを触る変更は、必ず「既存ストアを持つ実機」で先に試すのが鉄則だと考えています。
移行戦略 — 既存アプリへの後付け
既にリリース済みのアプリにキャッシュ層を導入する場合、初回起動時に「既存の UserDefaults / 自前 JSON キャッシュ」から SwiftData ストアへインポートする一回限りのマイグレーションを書くのが安全です。インポート完了フラグを UserDefaults に立てておき、2 回目以降はスキップします。
注意点は、初回起動で大量データを変換するとアプリが ANR 判定を受ける可能性があることです。500 件を超える場合は Task.detached でバックグラウンド化し、変換完了までは古い API 呼び出しを温存する、いわゆる「並行運用期間」を 1 リリース挟むのが現実的です。私は壁紙アプリで 2 週間の並行運用期間を取り、その間にクラッシュゼロを確認してから旧キャッシュコードを削除しました。
次のアクション
実装の出発点として、まずは小さな機能(説明文生成・タイトル提案など)で CachedGeminiResponse モデルと GeminiResponder を導入してみてください。1 ヶ月運用し、ヒット率が 30% を超えたらキャッシュ層の設計は妥当だと判断できます。逆にヒット率が 5% 未満なら、プロンプトの正規化を疑うか、そもそもキャッシュ向きでない用途です。
オフラインで「黙らない AI 機能」は、競合との差別化として地味ながら確実に効きます。本稿が実装の一助になれば幸いです。