1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Expoでつくった犬散歩アプリに「AIおさんぽメモ」を実装した話:さくらの生成AI APIをFirebase Functions経由で安全に呼ぶ(+トラブルシュートTips)

1
Last updated at Posted at 2026-08-22

はじめに

※本記事もさくらのAIで生成したものを少しだけいじって投稿してます。

もともとExpoで開発していた犬との散歩やドッグランのチェックインを記録する自作アプリ「DoggyGo」に、AIを使った小さな振り返り機能を追加した記事になります。
生成AI機能を自作アプリに組み込むことについて、ちいさな機能追加と最低限のセキュリティの実装でPoCしたような感じです。

  • DoggyGo
    • AppStoreで公開中です。

実装したのは、散歩ログから短いメモを生成する AIおさんぽメモ です。

たとえば、こんな文章をアプリ内で生成します。

今週は夕方のお散歩が中心で、先週より少し長めに歩けています。中央公園の近くも通ったんですね。週末は無理のない範囲で少し違うルートを選ぶと、気分転換になりそうです。

この記事では、

  • なぜFirebase Functions経由にしたのか
  • 何をAI APIへ渡しているのか
  • 実装中に起きたトラブルとその解決

までをまとめます。

作った機能

今回追加したAI機能はお散歩ログからひとことメモを生成する機能です。

自動生成ではなく、ユーザーが 生成 ボタンを押したときだけAI APIを呼びます。
自動生成にしなかった理由はシンプルです。

  • 散歩終了のたびにAI APIコストが発生する
  • 生成待ちで散歩終了体験が重くなる
  • ユーザーが必要なときだけ使える方が自然

全体構成

構成は以下です。

DoggyGo(Expoアプリ)
  ↓ Firebase Callable Functions
Firebase Functions generateWalkAiMemo
  ↓ Authorization: Bearer SAKURA_AI_ENGINE_TOKEN
さくらのAI Engine /v1/chat/completions

アプリから直接さくらAI APIを呼ばず、Firebase Functionsを挟んでいます。

なぜFunctions経由にしたのか

一番大きな理由は、さくらAI Engineのアクセストークンをスマホアプリに入れないため です。

FirebaseのAPI Keyはクライアント設定としてアプリに入る前提の値ですが、さくらAIのアクセストークンは違います。持っているだけでAPIを実行できる本物の秘密情報です。

Expo/EASの環境変数に入れて app.config.ts から読むと、最終的にアプリ側へ混入する可能性があります。スマホアプリは解析される前提で考えるべきなので、AI APIのトークンを入れるのは避けました。

Functions経由にすると、次のことができます。

  • アクセストークンをFirebase Functions Secretに閉じ込められる
  • Firebase Authでログイン済みユーザーだけに制限できる
  • UID単位でレート制限できる
  • App Checkを後から有効化しやすい
  • APIのモデル名やプロンプトをアプリ更新なしで変更できる
  • エラーをCloud Loggingで追える

今回のSecret登録は以下です。

npx firebase-tools functions:secrets:set SAKURA_AI_ENGINE_TOKEN --project doggygo-cd57a

デプロイはFunction単体で行いました。

npx firebase-tools deploy --only functions:generateWalkAiMemo --project doggygo-cd57a

AI APIに渡しているデータ

AIには散歩ログの生データをそのまま渡していません。

アプリ側で直近7日間と前週の散歩ログを集計し、要約済みデータだけをFunctionsへ送っています。

イメージは以下です。

{
  summary: {
    generatedFor: "2026-08-18T...",
    timezone: "Asia/Tokyo",
    thisWeek: {
      walkCount: 3,
      totalDistanceMeters: 4200,
      totalDurationMinutes: 85,
      activeDays: 3,
      averageDistanceMeters: 1400,
      averageDurationMinutes: 28
    },
    previousWeek: {
      walkCount: 2,
      totalDistanceMeters: 2600,
      totalDurationMinutes: 55,
      activeDays: 2,
      averageDistanceMeters: 1300,
      averageDurationMinutes: 28
    },
    distanceDeltaMeters: 1600,
    durationDeltaMinutes: 30,
    walkCountDelta: 1,
    mostCommonTimeOfDay: "夕方",
    dogSummaries: [
      {
        name: "ポチ",
        breed: "柴犬",
        age: 4,
        walkCount: 3
      }
    ],
    nearbySpots: [
      {
        name: "中央公園",
        category: "park",
        categoryLabel: "公園",
        distanceMeters: 45
      }
    ],
    recentWalks: [
      {
        date: "8/18(火)",
        distanceMeters: 1200,
        durationMinutes: 24,
        dogNames: ["ポチ"]
      }
    ]
  }
}

散歩終了時の「公園を通ったんですね」をどう作ったか

散歩終了時のメモでは、ルート周辺のスポット情報も使いたくなりました。

ただし、GPS点列をそのままAIへ渡すのはやりすぎなので、アプリ側で以下の処理をしています。

  1. 散歩ルートのGPS点列を持つ
  2. 地図上の施設/公園/ドッグランなどのスポット座標を持つ
  3. ルート上の点から120m以内にあるスポットを抽出
  4. 最大5件までに絞る
  5. AIにはスポット名・カテゴリ・距離だけ渡す

つまりAIには「このルートは中央公園の近くを通った」という要約だけを渡します。

これで、

中央公園の近くも通ったんですね。

のような自然な振り返りが可能になります。

Functions側の実装ポイント

Functions側ではCallable Functionを作りました。

主な役割は以下です。

  • Firebase Auth必須にする
  • Secretから SAKURA_AI_ENGINE_TOKEN を読む
  • 入力サイズを制限する
  • 30秒に1回の簡易レート制限を行う
  • さくらAI Engineの chat/completions を呼ぶ
  • 結果の本文だけをアプリへ返す

さくらAI EngineはOpenAI互換のエンドポイントを使いました。

https://api.ai.sakura.ad.jp/v1/chat/completions

リクエストは以下のような形です。

{
  model: "gpt-oss-120b",
  messages: [
    {
      role: "system",
      content: "犬の散歩アプリの記録を読み、飼い主向けに短い日本語メモを書く..."
    },
    {
      role: "user",
      content: `次の集計済み散歩データから「今週のおさんぽメモ」を作成してください。\n${summaryJson}`
    }
  ],
  temperature: 0.6,
  max_tokens: 1000,
  stream: false
}

返却はアプリに対してシンプルにしました。

{
  memo: "今週は夕方のお散歩が中心で...";
}

実装中に起きたトラブル

最初はアプリにこう出ていました。

おさんぽメモの生成に失敗しました

Firebase Functionsのリクエストログを見ると、HTTPステータスは 503 でした。

最初はSecretや認証を疑いましたが、Cloud Loggingには以下のログがありました。

{
  "message": "Callable request verification passed",
  "verifications": {
    "app": "MISSING",
    "auth": "VALID"
  }
}

auth: VALID なのでFirebase Authは通っています。app: MISSING はApp Check未導入なので、この時点では問題ではありません。

さらに、さくらクラウド側ではリクエストが受領されていました。つまり、トークン認証も通信も通っています。

本当の原因はFunctionsのstderrに出ていました。

[generateWalkAiMemo] Sakura AI returned empty content

さくらAI APIはHTTPエラーではなく成功レスポンスを返していました。しかし、こちらが期待していた choices[0].message.content が空でした。

原因

gpt-oss-120b はレスポンスに reasoning_content を含むことがあります。

さくらの公式サンプルにも、以下のようなレスポンス例があります。

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "こんにちは!今日はどんなことをお手伝いしましょうか?",
        "reasoning_content": "We need to respond in Japanese..."
      },
      "finish_reason": "stop"
    }
  ]
}

今回、初期実装では max_tokens を小さめにしていたため、推論側にトークンを使ってしまい、最終回答の content が空になるケースが起きた可能性が高いです。

修正内容

最終的に以下の修正を入れました。

  • max_tokens220 から 1000 に増加
    • 結局3000リクエストまでは無料なので、もっと大きくしてもいいかも。
  • message.content が文字列だけでなく配列で返るケースにも対応
  • 1回目の content が空なら、短いプロンプトで1回だけリトライ
  • それでも空なら finishReason / messageKeys / hasReasoningContent をログに出す

空だった場合のリトライでは、プロンプトをかなり短くしました。

const buildWalkAiMemoMessages = (summaryJson, compact = false) => [
  {
    role: "system",
    content: compact
      ? "あなたは犬の散歩アプリの短い日本語メモを書くアシスタントです。回答は本文のみ。120字以内。医療判断はしません。"
      : "あなたは犬の散歩アプリの記録を読み、飼い主向けに短い日本語メモを書くアシスタントです。...",
  },
  {
    role: "user",
    content: compact
      ? `次の散歩集計から、飼い主向けの自然な一言メモだけを書いてください。\n${summaryJson}`
      : `次の集計済み散歩データから「今週のおさんぽメモ」を作成してください。\n${summaryJson}`,
  },
];

この修正後、無事にAIメモ生成が通るようになりました。

ログ調査で役に立ったCloud Loggingクエリ

Cloud Functions v2はCloud Run revisionとして動くため、Cloud Loggingでは以下のように検索しました。

resource.type="cloud_run_revision"
resource.labels.service_name="generatewalkaimemo"
severity>=ERROR

さくらAI呼び出しの失敗だけ見たい場合は以下です。

resource.type="cloud_run_revision"
resource.labels.service_name="generatewalkaimemo"
"Sakura AI request failed"

空レスポンスを探す場合はこちらです。

resource.type="cloud_run_revision"
resource.labels.service_name="generatewalkaimemo"
"Sakura AI returned empty content"

functions:log コマンドが失敗することもあったので、Cloud Loggingの画面で直接見る方が確実でした。

コスト面

この機能はFirebase無料枠にすぐ抵触する設計ではありません。

AIメモ1回につき、Functions呼び出しは1回です。Firestoreもレート制限用に少量の読み書きをする程度です。

ただし、注意するべきはFirebaseよりもAI API側です。

さくらAI Engineの無料プランでは、チャット補完は毎月3,000リクエストまで無料とされています。今回のような任意生成なら、初期リリースでは十分現実的です。

今後ユーザーが増えるなら、以下を追加したいです。

  • 1ユーザー1日数回までの上限
  • App Checkの有効化
  • さくらAI側の利用量監視
  • Firebase/Google Cloudの請求アラート

今後やりたい改善

今回の実装で、まずは「使えるAIメモ」になりました。

今後やるなら以下です。

  • 犬の名前を匿名化してAIへ渡す
  • App Checkを有効化して正規アプリからの呼び出しに絞る
  • 日次/月次の利用回数制限を追加する
  • 週次メモと散歩終了メモでプロンプトを分ける
  • 生成結果をFirestoreに保存して端末をまたいで表示する
  • ユーザーが「短め」「やさしめ」など文体を選べるようにする

まとめ

今回の実装で学んだことは、AI API連携では「呼べること」と「期待した本文が返ること」は別問題だということです。

認証が通っていて、リクエストも受領されていて、HTTP的には成功していても、message.content が空ならアプリとしては失敗です。

そのため、

  • APIキーはサーバー側に閉じ込める
  • アプリからは集計済みデータだけ送る
  • Functions側でレート制限する
  • AIレスポンスの揺れに備える
  • Cloud Loggingで原因を見えるようにする

という設計にしておくと、トラブルシュートしやすくなります。

犬の散歩アプリにAIを入れるなら、いきなり大きな推薦エンジンを作るより、今回のような「記録を自然な言葉で振り返る」機能は相性がいいと感じました。

AIは散歩を決める存在ではなく、飼い主があとから少し楽しく振り返るための相棒くらいがちょうどよさそうです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?