壁紙の一覧を上下にゆっくり動かしていた夜のことです。さきほど通り過ぎた絵に戻るたびに、分類用の通信が毎回走っていました。見た目は何も変わりません。変わるのは、通信の回数と、請求の数字だけです。
私は壁紙のカテゴリを判定する作業で、画像を読める AI を使っております。アプリの中でも同じことをしたい、という相談をいただくことがあり、Rork で作る画面に持ち込む場合の「答えを覚えておく層」を書き起こしました。作ってみて一番手間がかかったのは、覚えさせる部分ではなく、いつ忘れさせるか のほうでした。
最初の版は、覚えさせることばかりを考えていて、古い答えを手放す場面を思い描けておりませんでした。モデルを替える前提で書き直して、ようやく鍵の形が定まったのだと思います。
ここでは、小さなキャッシュ層の TypeScript をそのままお渡しします。あわせて、400 枚の模擬カタログを手元の Node で回した結果を載せます。端末での計測ではなく、乱数を固定した模擬であることは、先にお伝えしておきます。
何を覚えさせて、何を覚えさせないか
覚えさせるのは、同じ入力に対して毎回同じになってよい答えだけです。壁紙のカテゴリのように、画像が変わらなければ答えも変わらないものが向いています。会話の返答のように、毎回違ってよいものには向きません。
覚えさせないものも決めておきます。AI が「自信がない」と返した答えは、保存しません。自信の低い答えを覚えると、その画像は何度見ても同じ曖昧な答えのままになるからです。
キーに入れる印は4つです
キャッシュの鍵は、画像の ID だけでは足りません。次の4つを並べます。
model を入れる理由は、実際に試すと一目で分かります。画像 ID だけをキーにして 30 日の有効期限だけを付けた素朴な版で、モデルを替えた直後の 1,000 回の表示を数えると、1,000 回すべてが古いモデルの答えでした。期限は「古くなったら捨てる」仕組みであって、「替えたら捨てる」仕組みではないのです。
なお、Anthropic は Claude Sonnet 4.5(claude-sonnet-4-5-20250929)を 2026 年 9 月 30 日に非推奨とし、API の提供終了を 11 月 30 日(暫定)としています。置き換え先として案内されているのは claude-sonnet-5-5 です。アプリの中のモデル ID を洗い出す手順は Rork で作ったアプリが Sonnet 4.5 を呼んでいないか、11月30日の前に洗い出します に書きました。今回の層は、洗い出したあとに差し替えを安全に済ませるための受け皿です。
キャッシュ層の実装
保存先は、MMKV と同じ形の最小のインターフェース KV に絞りました。テストでは Map で代用できます。ここで解いたい問題は、同じ画像への二度目の呼び出しを減らすこと、同時に来た同じ要求を一つにまとめること、そして差し替えの日に古い版を掃除することの三つです。
// aiCache.ts — AI の分類結果を端末に覚えさせる小さなキャッシュ層
// 保存先は MMKV 互換の最小インターフェースに絞っています(テストでは Map で代用)。
export interface KV {
getString ( key : string ) : string | undefined ;
set ( key : string , value : string ) : void ;
delete ( key : string ) : void ;
getAllKeys () : string [];
}
export interface CacheConfig {
task : string ; // 例: "wallpaper-category"
model : string ; // 例: "claude-sonnet-5-5"(差し替えの日にここだけ変える)
promptVersion : string ; // 指示文を直したら上げる。例: "p3"
ttlMs : number ; // 例: 30 日
maxEntries : number ; // 端末に溜める上限
minConfidence : number ; // これ未満の答えは覚えない
}
export interface Answer { label : string ; confidence : number }
interface Stored extends Answer { at : number ; model : string ; promptVersion : string }
const ROOT = "ai:v1:" ;
export function createAiCache (
kv : KV ,
cfg : CacheConfig ,
ask : ( contentId : string ) => Promise < Answer >,
now : () => number = Date.now,
) {
const prefix = `${ ROOT }${ cfg . task }:${ cfg . model }:${ cfg . promptVersion }:` ;
const inflight = new Map < string , Promise < Answer >>();
const stats = { hit: 0 , miss: 0 , joined: 0 , skippedLowConfidence: 0 , evicted: 0 };
function read ( key : string ) : Stored | undefined {
const raw = kv. getString (key);
if ( ! raw) return undefined ;
try {
const v = JSON . parse (raw) as Stored ;
// キーに model / promptVersion が入っていても、値側でも照合します(手で書き換えた事故の保険)
if (v.model !== cfg.model || v.promptVersion !== cfg.promptVersion) return undefined ;
if ( now () - v.at > cfg.ttlMs) return undefined ;
if ( typeof v.label !== "string" || typeof v.confidence !== "number" ) return undefined ;
return v;
} catch {
kv. delete (key); // 壊れた JSON は残さない
return undefined ;
}
}
function trim () {
const mine = kv. getAllKeys (). filter (( k ) => k. startsWith (prefix));
if (mine. length <= cfg.maxEntries) return ;
const aged = mine
. map (( k ) => ({ k, at: ( JSON . parse (kv. getString (k) ?? "{}" ) as Stored ).at ?? 0 }))
. sort (( a , b ) => a.at - b.at);
for ( const { k } of aged. slice ( 0 , mine. length - cfg.maxEntries)) {
kv. delete (k);
stats.evicted ++ ;
}
}
async function classify ( contentId : string ) : Promise < Answer > {
const key = prefix + contentId;
const hit = read (key);
if (hit) { stats.hit ++ ; return { label: hit.label, confidence: hit.confidence }; }
const running = inflight. get (key);
if (running) { stats.joined ++ ; return running; }
stats.miss ++ ;
const p = ( async () => {
try {
const ans = await ask (contentId);
if (ans.confidence >= cfg.minConfidence) {
const rec : Stored = { ... ans, at: now (), model: cfg.model, promptVersion: cfg.promptVersion };
kv. set (key, JSON . stringify (rec));
trim ();
} else {
stats.skippedLowConfidence ++ ;
}
return ans;
} finally {
inflight. delete (key); // 失敗しても必ず外す。外さないと次の呼び出しが同じ失敗を受け取り続けます
}
})();
inflight. set (key, p);
return p;
}
// モデルや指示文を差し替えたあと、古い版の答えを端末から掃除します。
function sweepOldVersions () : number {
let n = 0 ;
for ( const k of kv. getAllKeys ()) {
if (k. startsWith ( `${ ROOT }${ cfg . task }:` ) && ! k. startsWith (prefix)) { kv. delete (k); n ++ ; }
}
return n;
}
return { classify, sweepOldVersions, stats };
}
書き方の理由を3点だけお伝えします。
値の側にも model と promptVersion を持たせています 。キーが合っていても、値が食い違っていれば読み捨てます。キー文字列の組み立てを将来誰かが変えたときの保険です。
finally で必ず進行中の記録を外しています 。次の章で、外し忘れると何が起きるかを実測しています。
確信度が基準未満の答えは保存しません 。保存しない代わりに、その画像は表示のたびに呼び出しが走ります。この費用の見積もりも後で数えます。
画面につなぎ込む
画面側は、MMKV を KV の形に包んで渡すだけです。MMKV のメソッド名はバージョンで異なります(削除が delete か remove か)。お使いの版の型に合わせて、包む部分だけ直してください。MMKV そのものの選び方と移し替えは Rork で書いた 6 つの壁紙アプリで MMKV に移し替えた 3 週間の所感 にまとめています。
// wallpaperCategory.ts — 画面側からの使い方(MMKV の包み方と hook は型を通した例で、端末では動かしていません)
import { MMKV } from "react-native-mmkv" ;
import { createAiCache, type KV } from "./aiCache" ;
const mmkv = new MMKV ({ id: "ai-answers" });
const kv : KV = {
getString : ( k ) => mmkv. getString (k),
set : ( k , v ) => mmkv. set (k, v),
delete : ( k ) => mmkv. delete (k), // 版によっては remove
getAllKeys : () => mmkv. getAllKeys (),
};
// モデルを替える日に変えるのは、この 2 行だけです
const MODEL = "claude-sonnet-5-5" ;
const PROMPT_VERSION = "p1" ;
export const categoryCache = createAiCache (
kv,
{ task: "wallpaper-category" , model: MODEL , promptVersion: PROMPT_VERSION ,
ttlMs: 30 * 24 * 3600 * 1000 , maxEntries: 2000 , minConfidence: 0.6 },
async ( id ) => {
// 呼び出し先は Edge Function などの自前のゲートウェイに置きます。鍵をアプリに入れないためです。
const res = await fetch ( `https://YOUR_PROJECT.functions.supabase.co/classify?id=${ id }` );
if ( ! res.ok) throw new Error ( `classify ${ res . status }` );
return ( await res. json ()) as { label : string ; confidence : number };
},
);
// 起動時に 1 回。モデルや指示文を替えたあとの古い答えを掃除します。
categoryCache. sweepOldVersions ();
鍵をアプリに入れずに呼び出し先を作る考え方は、プロンプトをアプリから外して Edge Function に置く に書きました。
手元で確かめた結果
条件は次のとおりです。400 枚のカタログを、人気に偏りをつけた乱数(固定の種)で 3,000 回ずつ、2 回の起動に分けて眺めます。AI の呼び出しは模擬の関数で、約 8% が確信度 0.4、残りが 0.9 を返します。Node 22 で実行しました。
意外だったのは、2 回目の起動の 4 回です。この 4 回は、すべて確信度の低い画像でした。保存しないと決めた画像は、起動をまたいでも毎回聞き直しになります。1 回目の 426 回のうち、確信度が低くて保存しなかったのは 30 件でした。この 30 件が、いつまでも呼び出しの残りを作ります。
もう一つ、モデルを替えた日の費用も正直に書いておきます。古い答えを使わないということは、見た画像から順に聞き直すということです。模擬では、次の 1,000 回の表示で 356 回の呼び出しが発生しました。差し替えの日には、この分の呼び出しが必ず一度だけ増えます。私はここを読み違えて、掃除さえすれば費用は増えないと思い込んでいました。実際には、掃除は古い答えを使わないための手当てで、費用を消すものではないと気づいたのは、数字を並べたあとでした。
保存した 400 件の大きさは約 60 KB(1 件あたり約 150 バイト)でした。上限の 2,000 件に達したあとの1件ごとの書き込みは、Node では約 0.65 ミリ秒でした。端末での値は別なので、目安としてご覧ください。
失敗の一回が居座る不具合は、次の短いテストで再現できます。外し忘れる版では、進行中の約束が失敗したまま残り、再試行は同じ失敗を受け取り続けます。
// test_inflight.ts — node --experimental-strip-types test_inflight.ts
import { createAiCache } from "./aiCache.ts" ;
import { memKV } from "./mem.ts" ;
const cfg = { task: "t" , model: "m1" , promptVersion: "p1" , ttlMs: 864e5 , maxEntries: 100 , minConfidence: 0.6 };
// 外し忘れ版(then の中でしか削除しない)
const inflight = new Map < string , Promise < number >>();
let calls = 0 ;
const flaky = async () => { calls ++ ; if (calls === 1 ) throw new Error ( "timeout" ); return 1 ; };
const buggy = ( k : string ) => {
const r = inflight. get (k); if (r) return r;
const p = flaky (). then (( v ) => { inflight. delete (k); return v; }); // 失敗時は消えません
inflight. set (k, p); return p;
};
const a : string [] = [];
for ( let i = 0 ; i < 3 ; i ++ ) { try { await buggy ( "k" ); a. push ( "成功" ); } catch { a. push ( "失敗" ); } }
console. log ( "外し忘れ:" , a. join ( "," ), "呼び出し" , calls); // 失敗,失敗,失敗 呼び出し 1
// この実装
let fail = true , n = 0 ;
const cache = createAiCache ( memKV (), cfg, async () => { n ++ ; if (fail) { fail = false ; throw new Error ( "timeout" ); } return { label: "x" , confidence: 0.9 }; });
const b : string [] = [];
for ( let i = 0 ; i < 3 ; i ++ ) { try { await cache. classify ( "1" ); b. push ( "成功" ); } catch { b. push ( "失敗" ); } }
console. log ( "この実装:" , b. join ( "," ), "呼び出し" , n); // 失敗,成功,成功 呼び出し 2
落とし穴
次の一歩
次にモデル ID を差し替えるときは、先に MODEL と PROMPT_VERSION の2行だけを変えて、起動直後の呼び出し回数を一度数えてみてください。その数が、差し替えの日にかかる追加の費用の見積もりになります。見積もりが重いと感じたら、よく見られる画像から順に聞き直す方法も取れます。
差し替えは、モデルの名前を書き換える作業に見えて、実は端末に残った過去の答えをどう扱うかを決める作業でした。答えを覚えさせるときは、いつ忘れさせるかを同じ場所に書いておく ——この一文だけは、次の仕組みでも守るようにしております。