手元の iPhone アプリに Android を足してみた夜のことです。プレビューの上にある + を押して Android (Kotlin) を選ぶと、エージェントが画面と導線を順に組み直していきました。
出来上がったものを開いて、私が最初に探したのは見た目ではありませんでした。無料で見せる枚数を決めている条件文がどこにあるか 、それだけを探しておりました。
見つかった場所は、Kotlin 側の画面のすぐ近くでした。iPhone 側にも、同じ意味の条件文が Swift で書かれておりました。二つとも正しく動きます。ただ、同じ意味のものが二箇所にあるという事実だけが残りました。
ここからお伝えするのは、機能の作り方ではありません。AI に関わる判断を、二つのクライアントのどちらに残し、どこから先を共有バックエンドへ寄せるか という線の引き方です。片側しか持っていない方にも、いま引いておけば後で楽になる線がありますので、そこまで書き残します。
分かれたのは画面ではなく、条件文です
前提を先に置きます。Rork のドキュメントでは、新規アプリが iPhone は Swift、Android は Kotlin、Web は React になったこと、そして既存の Expo プロジェクトはこれまで通り動くことが明記されております(What happened to Expo? )。
同じページに、私がいちばん重く受け取った一文があります。二つのアプリは別々のコードベースであり、Swift の変更は Kotlin の変更と同じファイルではない、そして両者はずれ得る——という趣旨の記述です。一つのプロジェクトの中で共有されるのは、バックエンドとチャットと公開先であって、コードそのものではありません(iPhone, Android, or web: what's the difference )。
React Native の一枚のコードベースから来た方にとって、ここは意識の切り替えが要るところです。画面が二つになったことは、プレビューを見れば分かります。分かりにくいのは、画面と一緒に条件文まで二つになった という点のほうです。
AI 機能を持つアプリでは、この条件文が集中します。どの入力を通してどの入力を止めるか。今日あと何回呼べるか。どのモデルを使うか。応答をどこまで保存するか。いずれも画面ではなく判断であり、判断が二重になると、片側だけを直した日から答えが割れ始めます。
移行そのものを続けるか作り直すかという手前の判断については、Rork の新規作成から Expo が消えました に整理しております。本稿はその先、二つになったあとの話です。
私が自分のアプリで先に踏んだずれ
私は個人開発で壁紙アプリを長く運用しております。iOS 側と Android 側はもともと別のネイティブ実装で、配信する画像と付随する情報だけが共通のサーバから降りてくる構成です。つまり、いま Rork が新規プロジェクトで採る形を、私は先に何年も踏んでおりました。
そこで実際に割れたのは、派手な機能ではありませんでした。「この画像をこの地域で出してよいか」という一行 です。
最初のうち、私はこの判定をクライアントに書いておりました。理由は単純で、そのほうが速く、サーバの往復が減るからです。結果は芳しくありませんでした。片方の実装で除外条件を一つ足した日、もう片方には足し忘れました。クラッシュもエラーもありません。ただ、同じ画像が片方の端末だけに出続けました。
気づいたのは、問い合わせをいただいたときでした。ログを見ても異常は残っておりません。両方とも「自分の持っている条件では正しい」と答えていた からです。
この一件から引いた線は、いまも変えておりません。
壊れたときに叫ばない判断は、クライアントに置きません。 例外を作るなら、ずれても実害が出ない範囲に限ります。
AI 機能は、この性質をさらに濃く持ちます。応答は毎回違ってよいものですので、「違う」ことが異常の合図になりません。二つのクライアントが別々の方針で別々の答えを返しても、どちらももっともらしく見えます。
判断を四つに仕分けます
そこで、AI 機能に関わる判断を四つに分けて、置き場所をあらかじめ決めております。仕分けの軸は「あとから取り消せるか」と「ずれたときに気づけるか」の二つです。
判断の種類 例 置き場所 理由
表示の都合 読み込み中の見せ方、入力欄の文字数の目安、再試行ボタンの位置 各クライアント ずれても実害がなく、プラットフォームごとに違ってよい部分です
端末の能力 オンデバイス推論が使えるか、マイクの権限、低電力モードの検知 各クライアント 端末しか知らない事実で、サーバには判定材料がありません
事業の規則 無料枠の上限、地域による可否、どのモデルを使わせるか、停止スイッチ 共有バックエンド ずれたことに気づけず、取り消しに再配信が要ります
記録と学習 利用量の数え上げ、拒否理由の記録、方針の版 共有バックエンド 二箇所で数えると、どちらも正しくない数字になります
上二段はクライアントに残します。下二段は、片側だけで完結させないという意味で、共有バックエンドへ寄せます。
境界の判定に迷ったら、私は次の問いを使っております。「いま配信済みの版を触らずに、この判断を明日変えられるか」 。変えられないなら、それは共有バックエンドの仕事です。この観点は AI 機能に限った話ではなく、遠隔で動かせる範囲を数え直した Remote Config のキーと配信済みの版 でも同じ結論に行き着きました。
寄せた判断を一つの封筒で返します
寄せると決めた判断は、モデル呼び出しとは別の小さな入口にまとめております。クライアントに返すのは「許すか否か」だけでなく、なぜそう決めたか とその方針の版 までを含む一つの封筒です。
以下は Cloudflare Workers での最小の形です。実際に動かしている構造から、アプリ固有の条件を外して残しました。
// worker/src/ai-decision.ts
// 二つのネイティブアプリが「同じ答え」を受け取るための唯一の判断点。
// クライアントは決めません。受け取って従うだけにします。
export interface Env {
AI_POLICY : KVNamespace ; // 方針そのもの(版つき)
USAGE : DurableObjectNamespace ; // 利用量の数え上げ
}
type Reason =
| 'ok'
| 'quota_exceeded'
| 'region_blocked'
| 'feature_disabled'
| 'policy_unavailable' ;
interface Decision {
policy_version : string ; // 両クライアントのログに必ず載せる値
allow : boolean ;
reason : Reason ;
remaining : number ; // 今日あと何回呼べるか
model : string | null ; // 止めるときは null
ttl_seconds : number ; // 次の問い合わせまで待ってよい秒数
}
// 方針が読めないときに返す既定値。
// allow: false に倒しておくのが肝心で、ここを true にすると
// 障害のあいだだけ全端末が無制限に呼べる状態になります。
const CLOSED : Decision = {
policy_version: 'unknown' ,
allow: false ,
reason: 'policy_unavailable' ,
remaining: 0 ,
model: null ,
ttl_seconds: 30 ,
};
export default {
async fetch ( req : Request , env : Env ) : Promise < Response > {
if (req.method !== 'POST' ) {
return json ({ ... CLOSED , reason: 'feature_disabled' }, 405 );
}
let body : { user_id ?: string ; feature ?: string ; region ?: string };
try {
body = await req. json ();
} catch {
return json ({ ... CLOSED , reason: 'feature_disabled' }, 400 );
}
const userId = body.user_id;
const feature = body.feature;
if ( ! userId || ! feature) {
return json ({ ... CLOSED , reason: 'feature_disabled' }, 400 );
}
let policy : {
version : string ;
enabled : boolean ;
daily_limit : number ;
model : string ;
blocked_regions : string [];
} | null = null ;
try {
policy = await env. AI_POLICY . get ( `policy:${ feature }` , 'json' );
} catch {
policy = null ; // KV が読めない場合も閉じる側へ倒します
}
if ( ! policy) return json ( CLOSED , 200 );
if ( ! policy.enabled) {
return json (
{ ... CLOSED , policy_version: policy.version, reason: 'feature_disabled' , ttl_seconds: 300 },
200 ,
);
}
const region = (body.region ?? '' ). toUpperCase ();
if (region && policy.blocked_regions. includes (region)) {
return json (
{ ... CLOSED , policy_version: policy.version, reason: 'region_blocked' , ttl_seconds: 3600 },
200 ,
);
}
// 利用量は Durable Object で一箇所に数えます。
// クライアント側の端末ごとのカウンタは、機種変更と再インストールで簡単に巻き戻ります。
const id = env. USAGE . idFromName ( `${ feature }:${ userId }` );
const used = await env. USAGE . get (id)
. fetch ( 'https://usage/increment' , { method: 'POST' })
. then (( r ) => r. json <{ count : number }>())
. catch (() => null );
if ( ! used) return json ({ ... CLOSED , policy_version: policy.version }, 200 );
const remaining = Math. max ( 0 , policy.daily_limit - used.count);
const allow = remaining > 0 ;
return json (
{
policy_version: policy.version,
allow,
reason: allow ? 'ok' : 'quota_exceeded' ,
remaining,
model: allow ? policy.model : null ,
ttl_seconds: allow ? 60 : 900 ,
},
200 ,
);
} ,
} ;
function json ( d : Decision , status : number ) : Response {
return new Response ( JSON . stringify (d), {
status,
headers: { 'content-type' : 'application/json; charset=utf-8' , 'cache-control' : 'no-store' },
});
}
ここで大事なのは分岐の数ではありません。CLOSED を既定値に置いたこと です。方針が読めない時間帯に開いてしまうと、いちばん困る瞬間にいちばん使われます。reason を文字列で返しているのも同じ理由で、クライアントの実装者が「なぜ止まったのか」を推測せずに済みます。
ttl_seconds を返している点も、二つのクライアントを持つ構成では効きます。再問い合わせの間隔を Swift 側と Kotlin 側でそれぞれ決めると、そこが最初にずれるからです。間隔まで含めてサーバが指定してしまえば、片側だけが賢くなることもありません。
Swift と Kotlin は「決める」をやめて「受け取る」に徹します
クライアント側は、驚くほど短くなります。判断を持たないからです。
// AIDecisionClient.swift
// 判断は持ちません。受け取った封筒に従うだけにします。
import Foundation
struct AIDecision : Decodable {
let policyVersion: String
let allow: Bool
let reason: String
let remaining: Int
let model: String ?
let ttlSeconds: Int
enum CodingKeys : String , CodingKey {
case policyVersion = "policy_version"
case allow , reason , remaining , model
case ttlSeconds = "ttl_seconds"
}
// 通信できなかったときに使う値。サーバ側の CLOSED と必ず同じ意味にします。
static let closed = AIDecision (
policyVersion : "unknown" , allow : false , reason : "policy_unavailable" ,
remaining : 0 , model : nil , ttlSeconds : 30
)
}
actor AIDecisionClient {
private let endpoint: URL
private var cached: (decision: AIDecision, expiresAt: Date) ?
init ( endpoint : URL) { self .endpoint = endpoint }
func decision ( userID : String , feature : String , region : String ) async -> AIDecision {
if let c = cached, c.expiresAt > Date () { return c.decision }
var req = URLRequest ( url : endpoint)
req.httpMethod = "POST"
req.timeoutInterval = 5
req. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
req.httpBody = try? JSONSerialization. data ( withJSONObject : [
"user_id" : userID, "feature" : feature, "region" : region,
])
do {
let (data, response) = try await URLSession.shared. data ( for : req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
return .closed
}
let decision = try JSONDecoder (). decode (AIDecision. self , from : data)
cached = (decision, Date (). addingTimeInterval ( TimeInterval (decision.ttlSeconds)))
return decision
} catch {
// 落とさずに閉じます。ここで開くと、圏外の端末が無制限になります。
return .closed
}
}
}
Kotlin 側も、同じ封筒を同じ意味で読みます。
// AiDecisionClient.kt
// Swift 版と同じ契約。フィールド名も既定値も揃えます。
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3. *
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
@Serializable
data class AiDecision (
@SerialName ( "policy_version" ) val policyVersion: String ,
val allow: Boolean ,
val reason: String ,
val remaining: Int ,
val model: String ? = null ,
@SerialName ( "ttl_seconds" ) val ttlSeconds: Int ,
) {
companion object {
val CLOSED = AiDecision ( "unknown" , false , "policy_unavailable" , 0 , null , 30 )
}
}
class AiDecisionClient ( private val endpoint: String ) {
private val json = Json { ignoreUnknownKeys = true }
private val http = OkHttpClient. Builder ()
. callTimeout ( 5 , TimeUnit.SECONDS)
. build ()
@Volatile private var cached: Pair < AiDecision , Long >? = null
suspend fun decision (userId: String , feature: String , region: String ): AiDecision =
withContext (Dispatchers.IO) {
cached?. let { (d, expiresAt) ->
if (System. currentTimeMillis () < expiresAt) return @withContext d
}
val payload = """{"user_id":" $userId ","feature":" $feature ","region":" $region "}"""
val request = Request. Builder ()
. url (endpoint)
. post (payload. toRequestBody ( "application/json" . toMediaType ()))
. build ()
try {
http. newCall (request). execute (). use { res ->
if ( ! res.isSuccessful) return @withContext AiDecision.CLOSED
val body = res.body?. string () ?: return @withContext AiDecision.CLOSED
val decision = json. decodeFromString < AiDecision >(body)
cached = decision to (System. currentTimeMillis () + decision.ttlSeconds * 1000L )
decision
}
} catch (e: Exception ) {
AiDecision.CLOSED // 例外も閉じる側へ倒します
}
}
}
二つを並べて置いておく理由があります。片方を直したときに、もう片方のどこを直すべきかが目で分かる形にしておく ためです。エージェントに「両方に入れてください」と頼む場合でも、契約が文書として先にあるかどうかで結果の安定が変わります。
ignoreUnknownKeys = true と Swift 側の省略可能なフィールドは、意図して入れております。サーバが新しい項目を足した日に、古い版のアプリが落ちないようにするためです。ここを厳格にすると、封筒を育てるたびに再配信が必要になります。
移すべきだったのは、モデル呼び出しではありませんでした
ここが、私の予想と最も食い違ったところです。
二つのコードベースに分かれると聞いて、最初に考えたのは「モデルの呼び出しをサーバへ移せば重複が減る」ということでした。実際にやってみると、効果は小さいものでした。**呼び出しは、二箇所にあっても壊れません。**リクエストの組み立てが多少違っても、返ってくるものは同じ形ですし、違えば動かないので気づきます。
割れて困るのは、その手前にある方針のほうでした。誰に何回許すか、どの地域で止めるか、どのモデルを使わせるか。これらは二箇所にあっても動いてしまいます 。動いてしまうから、ずれに気づく機会がありません。
もう一つ、直感に反したことがあります。一つのチャットが両方のアプリを把握しているという構成は、ずれを見つけやすくしてくれるはずだと考えておりました。実際には逆に働く場面がありました。両側とも、それらしいコードが入る からです。片側が空のままなら気づけますが、両側に似て非なるものが入ると、差分を自分で読むまで分かりません。
ですので、私はエージェントに両方を触ってもらう前に、契約のほうを先に固めるようにしております。判断が一箇所にしかなければ、両側に入り得るのは「受け取る側」だけになりますので、比べる範囲がぐっと狭くなります。
鍵の置き場所についても同じ考え方で、API キーを環境変数に置くか Edge Function に寄せるか では「使われすぎたときに誰へ請求が来るか」を基準にしました。判断の置き場所は「ずれても気づけるか」、鍵の置き場所は「止められるか」——軸は違いますが、どちらもクライアントに預けたものは取り返しにくい、という同じ事実から来ております。
クライアントに残してよい判断の三条件
寄せるほうばかり書きましたので、残してよいものの条件も明示しておきます。次の三つをすべて 満たすときだけ、私はクライアントに判断を置いております。
ずれても、ユーザーに損も不利益も生じません。見せ方や並び順がここに入ります。
サーバでは判定できません。端末の能力・権限・電池の状態など、端末しか知らない事実に依存します。
明日変えたくなっても、次の配信まで待てます。急いで止める必要が出る性質のものではありません。
三つ目が、実務ではいちばん効きます。「急いで止めたくなるか」を先に自問しておくと、事故の夜に打てる手が残ります。逆に言えば、止めたくなる可能性が少しでもあるものは、最初から封筒に入れておいたほうが安いという判断になります。
いま片側しかない人が、先に引いておける線
Android や Web をまだ足していない段階でも、線は引けます。私が推奨する順番は、次の四手です。
いまのコードから、条件分岐だけを書き出します 。画面の実装は読まずに、if の条件だけを一覧にします。二十行ほどの表になるはずです。
一行ずつ、先ほどの四分類に当てはめます。「事業の規則」と「記録と学習」に入ったものに印を付けます。
印の付いた行の数 を数えます。ここが、二つ目のプラットフォームを足した瞬間に二重化する量です。三つや四つなら急ぐ必要はありません。十を超えるようなら、足す前に寄せたほうが確実に安く済みます。
寄せるときは、機能ごとではなく封筒ごと にまとめます。判断の入口が機能の数だけ増えると、結局クライアント側に「どれを呼ぶか」という新しい判断が生まれてしまいます。
この四手は、二つ目を足す予定がない場合でも無駄になりません。配信済みの版を触らずに変えられる範囲が増えるだけでも、運用は静かになります。
つまずきやすいところ
実際に組んでみて、私が引っかかった箇所を並べます。どれも本番運用に入ってから気づきにくい落とし穴で、回避の仕方まで添えます。
policy_version のログ漏れ 。載せていないと、問い合わせをいただいたときに「その端末がどの方針で動いていたか」を再現できません。両クライアントとも、AI 機能の成否ログに必ず添えるようにしております。
キャッシュ期限のクライアント任せ 。ここを各々の実装に任せると、片側だけが古い方針を持ち続けます。ttl_seconds をサーバから配るだけで、この種のずれは起きなくなります。
失敗時に開く既定値 。catch の中で既定値を返すとき、つい allow: true を置きたくなります。障害中こそ止めるべきですので、両側とも閉じる側へ倒します。
二箇所での数え上げ 。利用量をそれぞれの端末で数えると、再インストールで巻き戻り、機種変更で二重になります。数え上げは一箇所だけにします。
「両方に同じものを」で済ませる依頼 。生成されたコードは両方それらしく見えます。フィールド名と既定値だけは、自分の目で並べて確かめる価値があります。
まず一つだけ動かすなら
明日できることを一つだけ挙げるなら、いまのアプリの AI 機能が失敗したときに開くのか閉じるのか を確かめるところからだと思います。通信を切って起動し、その機能を叩いてみます。それだけで、いまの実装がどちら側へ倒れているかが分かります。
私はこの確認を、二つ目のプラットフォームを足す前に済ませておくようにしております。片方で直した線引きは、もう片方にも同じ形で移せるからです。
お読みいただきありがとうございました。同じ分かれ道に立っている方の、判断の材料になれば幸いです。