壁紙アプリのレビューに「もっと好みを学習してほしい」というコメントが増え始めたのは、2025 年の後半からでした。
個人開発で壁紙アプリを長く運用していると、ユーザーの期待値が変わる瞬間に気づくことがあります。以前は「きれいな画像が見られれば十分」だったリクエストが、今は「自分のことを覚えていてほしい」「文脈を理解してほしい」という言葉に変わってきました。
この変化に応えようとしたとき、最初に検討したのは従来のレコメンドエンジンでした。協調フィルタリングやコンテンツベースマッチングは試したことがあります。これらは確かに機能しますが、限界があります。「いつもの系統に似ているけど少し違うものを見せて」という曖昧な意図の空間には、ルールベースのアプローチでは対応しきれません。設計時点ですべてのバリエーションを予測する必要があり、条件分岐が際限なく増えていくのです。
そこで試したのが Gemini 3.2 Pro の Function Calling です。
通常の API 呼び出しとは根本的に発想が異なります。Function Calling では「何をするか」をモデルが判断し、「どう実行するか」をアプリ側で制御できます。ルールを書くのではなく、「できること」を宣言するだけでいい。この設計思想の転換が、個人開発のアーキテクチャを大きく変えました。
Function Calling が「命令型 API」を「意図型 API」に変えるもの
通常の API は命令型です。「style=minimal で mood=calm の画像を 10 件返せ」と明示的に指示します。パラメータはクライアントが決め、サーバーはその通りに実行します。
一方、Function Calling は意図型です。「ユーザーが落ち着く壁紙を求めている」という意図をモデルが解釈し、必要な関数を選んで呼び出します。アプリ側はどの関数を呼ぶかを決めず、「呼べる関数の一覧」を宣言するだけです。
この違いが特に重要になるのは、ユーザーの意図が曖昧なときです。「最近ちょっと疲れてる感じのやつ」「いつもと違うけど落ち着くやつ」といった表現は、ルールベースでは変換できません。Function Calling では、このような表現をモデルが文脈から解釈し、filter_wallpapers(mood='calm', style='minimal', exclude_seen=True) のような具体的な関数呼び出しに変換してくれます。
もう一つの重要な特性は、アプリを更新せずに機能を拡張できる点です。新しい関数をバックエンドに追加し、モデルへの宣言リストを更新するだけで、既存のアプリバージョンのユーザーも新機能を使えるようになります。App Store のレビューサイクルを待つ必要がありません。個人開発者にとって、このリリースの柔軟性は大きな価値があります。
Gemini 3.2 Pro では、複数の関数を連続して呼び出す「複合 Function Calling」が、以前のバージョンよりも格段に安定しています。2.5 Pro では、3 つ以上の関数を連鎖させると途中でテキスト回答に切り替わるケースが全体の 15〜20% ほど見られました。3.2 Pro ではほぼ解消されており、mode: "AUTO" でも連鎖が安定して機能します。
Function Calling の基礎については Gemini API Function Calling 完全ガイド も参照してください。
環境構築:Python バックエンドで動作確認する
iOS/Android への組み込み前に、Python でコア動作を検証する工程を必ず挟んでいます。モバイルのビルドサイクルは遅く、関数の宣言やモデルの挙動を確認するには Python の REPL が明確に速いからです。
実際の検証フロー:関数を宣言 → スタブ実装(フェイクデータを返すだけ)で各種フレーズを試す → モデルが期待通りの関数を期待通りの順序で呼ぶか確認 → OK なら Swift/Kotlin に移行、という手順をとっています。
必要なもの:
Python 3.11+
google-genai SDK (pip install google-genai)
Gemini API キー(Google AI Studio の無料枠で検証可能)
基本実装(コード例 1)
import google.generativeai as genai
genai.configure( api_key = "YOUR_GEMINI_API_KEY" )
# 壁紙アプリ向け Function 定義
tools = [
{
"function_declarations" : [
{
"name" : "get_user_preferences" ,
"description" : "ユーザーの壁紙好み履歴を取得します" ,
"parameters" : {
"type" : "object" ,
"properties" : {
"user_id" : { "type" : "string" , "description" : "ユーザーID" }
},
"required" : [ "user_id" ]
}
},
{
"name" : "filter_wallpapers" ,
"description" : "条件に合う壁紙一覧を返します" ,
"parameters" : {
"type" : "object" ,
"properties" : {
"style" : {
"type" : "string" ,
"enum" : [ "nature" , "abstract" , "minimal" , "art" ]
},
"mood" : {
"type" : "string" ,
"enum" : [ "calm" , "energetic" , "dark" , "bright" ]
},
"exclude_seen" : {
"type" : "boolean" ,
"description" : "True の場合、最近閲覧済みの壁紙を除外する"
},
"limit" : {
"type" : "integer" ,
"description" : "返す件数(デフォルト: 10)"
}
}
}
}
]
}
]
model = genai.GenerativeModel(
model_name = "gemini-3.2-pro" ,
tools = tools,
tool_config = { "function_calling_config" : { "mode" : "AUTO" }}
)
def get_user_preferences (user_id: str ) -> dict :
# 実際にはデータベースから取得
return { "preferred_styles" : [ "minimal" , "nature" ], "preferred_moods" : [ "calm" ]}
def filter_wallpapers (style: str = None , mood: str = None ,
exclude_seen: bool = False , limit: int = 10 ) -> list :
# 実際にはAPIまたはDBから取得
return [
{ "id" : "w001" , "title" : "朝の森" , "style" : "nature" , "mood" : "calm" },
{ "id" : "w002" , "title" : "シンプル白" , "style" : "minimal" , "mood" : "calm" },
][:limit]
def dispatch_function (function_call):
name = function_call.name
args = dict (function_call.args)
if name == "get_user_preferences" :
return get_user_preferences( ** args)
elif name == "filter_wallpapers" :
return filter_wallpapers( ** args)
return { "error" : f "Unknown function: { name } " }
def run_with_function_calling (user_message: str , user_id: str = "user_001" ) -> str :
chat = model.start_chat()
context = f "ユーザーID: { user_id } 。壁紙アプリのアシスタントとして応答してください。"
response = chat.send_message( f " { context }\n\n ユーザーのリクエスト: { user_message } " )
# 無限ループ防止が重要 — 本番環境では必須
max_iterations = 5
for _ in range (max_iterations):
function_calls = [
part.function_call
for part in response.candidates[ 0 ].content.parts
if hasattr (part, "function_call" ) and part.function_call
]
if not function_calls:
break # テキスト回答が来た場合はループを終了
responses = [
{
"function_response" : {
"name" : fc.name,
"response" : { "result" : dispatch_function(fc)}
}
}
for fc in function_calls
]
response = chat.send_message(responses)
else :
# max_iterations を使い切っても終わらなかった場合のフォールバック
return "処理が完了できませんでした。もう一度お試しください。"
return "" .join(
part.text for part in response.candidates[ 0 ].content.parts
if hasattr (part, "text" )
)
if __name__ == "__main__" :
# 様々な表現でモデルの挙動を確認する
tests = [
"落ち着く感じの壁紙を見せて" ,
"いつもと違うけど同じ雰囲気のやつ" ,
"最近見てないやつを見せて" ,
]
for msg in tests:
print ( f "入力: { msg } " )
print ( f "出力: { run_with_function_calling(msg) } " )
print ( "---" )
この実装で特に重要なのは max_iterations = 5 と else ブロックです。Python の for...else は、ループが break なしで完了した場合(つまり最大回数に達した場合)に else を実行します。本番で実際に発生した問題として、モデルが同じ関数を微妙に異なる引数で繰り返し呼び出し続けるケースがありました。ループガードなしではこれが課金インシデントに繋がります。
非同期パターンとの組み合わせについては Gemini API asyncio 本番実装パターン も参考にしてください。
SwiftUI から Gemini API を呼び出す(コード例 2)
iOS アプリへの統合では、本番環境では必ずバックエンド経由を使います。API キーをクライアントコードに埋め込むと、コンパイル済みバイナリからの抽出は技術的に容易であるため、セキュリティリスクになります。プロトタイプ段階では直接呼び出しで挙動確認でき、実際に私もそうしています。
以下は、SwiftUI でバックエンド経由の Function Calling を扱う実装例です。
import Foundation
import Combine
class WallpaperAIService : ObservableObject {
private let backendURL = "https://your-api.example.com/chat"
@Published var isLoading = false
@Published var recommendations: [WallpaperItem] = []
@Published var errorMessage: String ?
func requestRecommendations ( userMessage : String , userId : String ) async {
await MainActor. run { isLoading = true ; errorMessage = nil }
let requestBody: [ String : Any ] = [ "message" : userMessage, "user_id" : userId]
guard let url = URL ( string : backendURL),
let body = try? JSONSerialization. data ( withJSONObject : requestBody) else {
await MainActor. run { errorMessage = "リクエストの準備に失敗しました" ; isLoading = false }
return
}
var request = URLRequest ( url : url)
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = body
// Function Calling はモデル推論→関数実行→再度モデルへ送信のサイクルがあるため
// タイムアウトを通常より長めに設定する(30秒は最低ライン)
request.timeoutInterval = 30.0
do {
let (data, response) = try await URLSession.shared. data ( for : request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError (.badServerResponse)
}
let result = try JSONDecoder (). decode (WallpaperRecommendationResponse. self , from : data)
await MainActor. run {
self .recommendations = result.wallpapers
self .isLoading = false
}
} catch {
await MainActor. run {
// リトライ可能なエラーと不可能なエラーを UI レベルで区別する
if let urlError = error as? URLError,
urlError.code == .timedOut || urlError.code == .networkConnectionLost {
self .errorMessage = "接続が不安定です。もう一度お試しください"
} else {
self .errorMessage = "取得できませんでした"
}
self .isLoading = false
}
}
}
}
struct WallpaperAIView : View {
@StateObject private var service = WallpaperAIService ()
@State private var userInput = ""
let userId: String
var body: some View {
VStack ( spacing : 16 ) {
HStack {
TextField ( "どんな壁紙をお探しですか?" , text : $userInput)
. textFieldStyle (.roundedBorder)
Button ( "検索" ) {
Task {
await service. requestRecommendations ( userMessage : userInput, userId : userId)
}
}
. disabled (userInput. isEmpty || service.isLoading)
}
. padding (.horizontal)
if service.isLoading {
VStack ( spacing : 8 ) {
ProgressView ()
Text ( "AIが好みを分析中..." )
. font (.caption). foregroundColor (.secondary)
}
. padding ()
}
if let error = service.errorMessage {
Text (error). foregroundColor (.red). font (.caption). padding ()
}
ScrollView {
LazyVGrid ( columns : [ GridItem (. flexible ()), GridItem (. flexible ())], spacing : 12 ) {
ForEach (service.recommendations) { item in
WallpaperThumbnailView ( item : item)
}
}
. padding ()
}
}
}
}
タイムアウト設定は、通常の REST 統合以上に重要です。Function Calling を含む一連の処理は「モデル推論→関数実行→結果をモデルに返す→再度推論」というサイクルを経るため、応答時間が長くなります。私の壁紙アプリで計測したところ、2 関数の連鎖では通常の接続で平均 3〜4 秒、負荷が高い時間帯では 8 秒程度かかることがありました。30 秒のタイムアウトはこのケースをカバーしつつ、本当に失敗したリクエストは弾ける設定です。
Kotlin + Jetpack Compose での Android 実装(コード例 3)
Android では Ktor HTTP クライアントと StateFlow を組み合わせます。Function Calling の処理時間を考慮した指数バックオフつきのリトライと、UI レベルでのエラー分類がポイントです。
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client. *
import io.ktor.client.call. *
import io.ktor.client.plugins.contentnegotiation. *
import io.ktor.client.request. *
import io.ktor.serialization.kotlinx.json. *
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow. *
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
data class WallpaperRequest ( val message: String , val userId: String )
@Serializable
data class WallpaperItem (
val id: String , val title: String ,
val imageUrl: String , val style: String , val mood: String
)
@Serializable
data class RecommendationResponse ( val wallpapers: List < WallpaperItem >, val message: String )
class WallpaperAIViewModel : ViewModel () {
private val client = HttpClient {
install (ContentNegotiation) { json () }
}
private val _recommendations = MutableStateFlow < List < WallpaperItem >>( emptyList ())
val recommendations: StateFlow < List < WallpaperItem >> = _recommendations
sealed class UiState {
object Idle : UiState ()
object Loading : UiState ()
data class Success ( val message: String ) : UiState ()
data class Error ( val message: String , val isRetryable: Boolean ) : UiState ()
}
private val _uiState = MutableStateFlow < UiState >(UiState.Idle)
val uiState: StateFlow < UiState > = _uiState
fun fetchRecommendations (userMessage: String , userId: String ) {
viewModelScope. launch {
_uiState. value = UiState.Loading
var lastError: Exception ? = null
// 指数バックオフによるリトライ(0.5秒 → 1秒 → 2秒)
repeat ( 3 ) { attempt ->
try {
val response: RecommendationResponse =
client. post ( "https://your-api.example.com/chat" ) {
contentType (io.ktor.http.ContentType.Application.Json)
setBody ( WallpaperRequest (message = userMessage, userId = userId))
timeout {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
}. body ()
_recommendations. value = response.wallpapers
_uiState. value = UiState. Success (response.message)
return @launch
} catch (e: Exception ) {
lastError = e
if (attempt < 2 ) delay ( 500L * ( 1 shl attempt))
}
}
val isRetryable = lastError?.message?. let {
it. contains ( "timeout" , ignoreCase = true ) ||
it. contains ( "connection" , ignoreCase = true )
} ?: false
_uiState. value = UiState. Error (
message = if (isRetryable) "接続が不安定です" else "取得できませんでした" ,
isRetryable = isRetryable
)
}
}
override fun onCleared () { super . onCleared (); client. close () }
}
UiState.Error に isRetryable フラグを持たせているのは、ユーザー体験の観点から重要な設計判断です。最初このフラグなしで実装したとき、ユーザーはタイムアウトエラーでも「取得できませんでした」と表示されて再試行ボタンも出ない状態でした。セッション録画を確認すると、ユーザーは画面を見てアプリを閉じていました。再試行ボタンを追加したところ、エラー後の機能完了率が大幅に改善しました。一方、認証エラーや不正なリクエストに対しては再試行ボタンは不要(押しても解決しない)なので、isRetryable で制御しています。
複数の Function を連携させる:意図の連鎖を設計する
単純な 1 回の Function 呼び出しで解決できないケースが本番では多くあります。
「最近よく見てる系統と似た雰囲気で、でも少し違うものを見せて」というリクエストに応えるには、以下のステップが必要です。
get_user_preferences(user_id) — 最近の閲覧履歴を取得
filter_wallpapers(style, mood, exclude_seen=True) — 未閲覧かつ類似スタイルで絞り込み
get_wallpaper_details(ids) — 詳細情報を取得
Gemini 3.2 Pro では、この 3 ステップが 1 度の会話ターンの中で完結します。これを確実に動かすには mode: "ANY" が有効です。
# 連鎖呼び出しを強制する設定
model = genai.GenerativeModel(
model_name = "gemini-3.2-pro" ,
tools = tools,
tool_config = {
"function_calling_config" : {
"mode" : "ANY" , # テキスト回答を禁止して必ず関数を呼ばせる
"allowed_function_names" : [
"get_user_preferences" , "filter_wallpapers" , "get_wallpaper_details"
]
}
}
)
ただし mode: "ANY" には注意点があります。「この機能はどう使うの?」のような質問にもモデルが無理に関数を呼ぼうとします。本番では、入力テキストを軽量な意図分類モデル(Gemini Flash で十分)に通し、「データ操作が必要なリクエスト」と「一般的な質問」を分けてから、それぞれに適したモードで処理するミドルウェアを挟んでいます。分類の追加コストは小さく(数十 ms・数十トークン)、誤った関数呼び出しを防ぐ効果は大きいです。
本番稼働で見えてきた課題:コスト・エラー・段階ロールアウト
個人開発者にとって、コストは最も切実な問題です。
Function Calling は通常の API 呼び出しと比べてトークン消費が多くなります。関数の定義文(JSON スキーマ)がシステムプロンプトと同様に毎回課金対象になるためです。壁紙アプリで計測したところ、12 個の関数を定義した初期実装では、スキーマだけで 1 リクエストあたり約 2,000 トークンを消費していました。
コスト削減のために実施した 3 つの対策:
1. 関数の数を絞る
実際に呼ばれている関数を 1 週間モニタリングしたところ、12 個中 7 個の呼び出し頻度が 2% 未満でした。この 7 個を別の REST エンドポイントに移し、コアの 5 関数だけを Function Calling で管理するようにしました。コストが約 40% 削減されました。
2. Context Caching の活用
関数定義は頻繁には変わらないため、Context Caching の対象にできます。ただし、tool_config を含むリクエストはキャッシュ有効期間が短縮される場合があります。詳細は Context Caching コスト最適化ガイド を確認してください。
3. 段階ロールアウト(1% → 10% → 50% → 全体)
新機能の影響を最小化するため、まずユーザーの 1% にのみ機能を有効にしました。エラー率・レスポンス時間・課金額・AdMob 収益の 4 指標を 1 週間モニタリングしてから段階的に展開します。
この分析で驚いたのは、Function Calling を有効にしたユーザーのセッション時間が平均 18% 増加したことです。より適切な壁紙が提示されることで、ユーザーがアプリ内で過ごす時間が増えました。結果として広告表示機会も増え、AdMob 収益がわずかに増加しました。API コスト増加分のかなりの部分を相殺できた計算になります。
本番で起きた具体的な失敗:デバッグで学んだこと
ドキュメントには書かれていない失敗パターンがいくつかありました。
失敗 1:モデルが間違った関数を自信を持って呼ぶ
get_user_preferences を呼ぶべきところで filter_wallpapers を呼ぶ、という誤動作が発生しました。調査すると、filter_wallpapers の description が曖昧すぎて、ユーザーの好みを「取得する」という意味にも受け取れる表現になっていました。各関数の説明文を詳細に書き直し、「この関数は〇〇のためにのみ使い、△△には使わない」という否定的説明を加えたところ解消しました。
失敗 2:列挙型以外の値がパラメータに渡される
style を enum: ["nature", "abstract", "minimal", "art"] で定義していましたが、モデルが「landscape」「photography」といった enum 外の値を渡してくることがありました。サーバー側でのバリデーションを怠っていたため、そのまま DB クエリに渡されてエラーになりました。関数の実装側で必ずパラメータをバリデーションし、不正な値にはエラーを返すことが必須です。
失敗 3:並列呼び出しによるステート競合
Gemini 3.2 Pro は効率化のため、複数の関数を並列で呼ぶことがあります。私の実装では関数の一部が共有のキャッシュオブジェクトに書き込んでいたため、並列実行時にレース条件が発生しました。関数実装はステートレスにする(読み取りのみ行い、共有状態への書き込みは行わない)ことで解消しました。
宣言の退行を回帰テストで防ぐ:description を変えた途端に壊れる問題
Function Calling の運用で一番こわいのは、コードを 1 行も変えていないのに挙動が変わることです。関数の description をほんの少し書き換えただけで、モデルのルーティングが変わり、別の関数を呼ぶようになる。モデルのバージョンを上げたら連鎖が途切れるようになる。こうした「宣言の退行」は、本番で初めて気づくと深刻です。
そこで私は、固定したフレーズ集に対して「どの関数がどの順序で呼ばれるべきか」を期待値として書き、CI で回す回帰テストを用意しています。関数の説明文を編集したときや、モデルを更新したときに、ルーティングが壊れていないかを push 前に検出できます。
import google.generativeai as genai
genai.configure( api_key = "YOUR_GEMINI_API_KEY" )
# 期待されるルーティング(入力フレーズ → 呼ばれるべき関数名の集合)
ROUTING_CASES = [
{ "input" : "落ち着く壁紙を見せて" ,
"expect_any" : { "filter_wallpapers" }},
{ "input" : "いつも見てる系統で、まだ見てないやつ" ,
"expect_all" : { "get_user_preferences" , "filter_wallpapers" }},
{ "input" : "この機能の使い方を教えて" ,
"expect_none" : True }, # 一般的な質問では関数を呼ばないのが正しい
]
def called_functions (user_message: str ) -> set :
chat = model.start_chat()
response = chat.send_message(user_message)
return {
part.function_call.name
for part in response.candidates[ 0 ].content.parts
if hasattr (part, "function_call" ) and part.function_call
}
def run_routing_regression () -> int :
failures = 0
for case in ROUTING_CASES :
called = called_functions(case[ "input" ])
if case.get( "expect_none" ):
ok = len (called) == 0
elif "expect_all" in case:
ok = case[ "expect_all" ].issubset(called)
else :
ok = bool (case[ "expect_any" ] & called)
if not ok:
failures += 1
print ( f "[ { 'OK' if ok else 'FAIL' } ] { case[ 'input' ] } -> { called or '(no call)' } " )
return failures
if __name__ == "__main__" :
import sys
# 1 件でも期待と違えば CI を落とす
sys.exit( 1 if run_routing_regression() else 0 )
このテストで大切なのは、expect_none のケースを必ず入れることです。mode: "ANY" を使い始めると「一般的な質問にまで関数を呼ぶ」退行が起きやすく、ここが最初に壊れます。
運用上の注意点が 2 つあります。1 つは、モデルの応答には数 % のぶれがあるため、判定は完全一致ではなく「期待した関数が含まれているか」で見ること。厳密な順序まで固定すると、正常な範囲のゆらぎで偽陽性が増えます。もう 1 つは、このテストは実際に課金が発生するため、ケース数を 10〜20 に絞り、CI では毎 push ではなく「関数定義ファイルに変更があったときだけ」走らせる設計にしていることです。壁紙アプリでは、この回帰テストを入れてから、description の微修正で本番のルーティングが静かに壊れる事故がなくなりました。
「いつ使うべきか」の判断基準:個人開発の現場から
Function Calling が万能の解決策ではないことも、実際に使って痛感しています。
使うべきケース:
ユーザーの自然言語リクエストを複数の API 操作に変換する必要がある
「条件」が複雑で、ルールベースでは管理しきれない
ユーザーの意図を推論する必要がある(「それっぽいの」「いつもの感じ」)
アプリ更新なしで将来的な機能拡張を想定している
使わない方がいいケース:
固定の検索条件でフィルタリングするだけ(単純な REST API で十分)
1 秒以内のレスポンスが求められる(Function Calling は通常 2〜5 秒)
月間 API コストの上限が厳しい
ユーザーの意図が明示的な UI コントロールで十分に表現できる
新しい技術に触れると、つい「技術のために技術を使う」方向に引っ張られます。個人開発で AI 機能を導入するときも同じ誘惑があります。「AI を使うこと」が目的になってしまうと、実際の問題に合わない技術を選んでコストと複雑さだけが増えます。
Function Calling を試して気づいたのは、この技術が輝くのは「ユーザーの意図が複雑で曖昧な領域」だということです。壁紙の発見・パーソナライズ・会話型の操作は、まさにその領域です。一方、明確な UI 操作で表現できることに Function Calling を使うのは過剰設計です。
次に試したいのは、ユーザーの好みの変化傾向を Function Calling で検出し、ホーム画面のパーソナライズに反映する機能です。個人開発で長くアプリを運用してきて一つ言えるのは、ユーザーは自分で設定を変えないということです。アプリ側が変化に気づく必要があります。Function Calling はその「気づき」の仕組みを作る有力な手段だと感じています。同じ課題に取り組んでいる方の参考になれば幸いです。