Flow を1本デプロイして気づいた「フレームワークの薄さ」の価値
最初に Firebase Genkit を触ったとき、正直なところ「また新しい抽象化層か」という警戒がありました。個人開発で Dolice Labs を回している私にとって、増えるのは覚えることばかりで、得られるものが見合わない道具はむしろ負債になります。
その警戒が解けたのは、挨拶を返すだけの小さな Flow を1本、ローカルの Dev UI で動かし、そのまま Cloud Functions にデプロイできたときでした。入出力のスキーマ、ローカル検証、トレース、デプロイ。これらが同じ書き味の中に収まっている。フレームワークが薄いからこそ、Gemini の呼び出しそのものに集中できる。
本稿は、その最初の一本から RAG とエージェントまでを、私が実際に書いて確かめたコードでたどる実装ノートです。公式ドキュメントの逐条訳ではなく、個人開発で運用するときに詰まる箇所を中心にまとめています。なお Genkit は API の変遷が速い領域です。ここでは 1.x 系の genkit() コンストラクタと zod スキーマを前提にしています。
インストールと初期化 — まず1箇所に集約する
Genkit 本体と Google AI プラグインを入れます。TypeScript で書くのが素直です。
npm install genkit @genkit-ai/googleai
npm install -D typescript tsx @types/node
初期化は1ファイルに集約し、他のモジュールはここから ai を取り込む形にしておくと、モデルの差し替えが一箇所で済みます。
// src/genkit.ts
import { genkit } from "genkit" ;
import { googleAI } from "@genkit-ai/googleai" ;
// モデルIDは利用したいものに置き換えてください(現行の Flash / Pro 系)
export const ai = genkit ({
plugins: [ googleAI ({ apiKey: process.env. GOOGLE_API_KEY })],
model: googleAI. model ( "gemini-2.5-flash" ),
});
apiKey はコードに直書きせず、必ず環境変数から渡します。ローカルでは .env、本番では後述の Secret Manager 経由が安全です。
最初の Flow — zod スキーマで入出力を固める
Genkit の Flow は「入力スキーマ・処理・出力スキーマ」を1つにまとめた関数です。スキーマを zod で書いておくと、Dev UI がそのまま入力フォームを生成し、実行トレースも型付きで残ります。
// src/flows/greeting.ts
import { z } from "genkit" ;
import { ai } from "../genkit" ;
export const greetingFlow = ai. defineFlow (
{
name: "greeting" ,
inputSchema: z. object ({
name: z. string (),
language: z. enum ([ "ja" , "en" ]),
}),
outputSchema: z. string (),
},
async ({ name , language }) => {
const prompt =
language === "ja"
? `${ name }さんへの、短くて親切な挨拶を日本語で書いてください。`
: `Write a short, friendly greeting for ${ name }.` ;
const { text } = await ai. generate ({ prompt });
return text;
},
);
ローカルでの確認は Dev UI から行います。
# Dev UI を起動(http://localhost:4000 でフローを試せます)
npx genkit start -- npx tsx --watch src/index.ts
ここで大切なのは、ai.generate() の戻り値から text を分割代入で受け取っている点です。旧来のサンプルで見かける response.text() のようなメソッド呼び出しではありません。Genkit 1.x の戻り値はプロパティであり、この差はコピー&ペーストで最初につまずく箇所でした。
マルチステップ Flow — ドキュメント解析を1関数に畳む
複数の前処理と生成を1つの Flow にまとめると、呼び出し側は入力を渡すだけで済みます。ここでは文書を受け取り、要約・感情・キーワードのいずれかを返す例です。
// src/flows/documentAnalysis.ts
import { z } from "genkit" ;
import { ai } from "../genkit" ;
export const documentAnalysisFlow = ai. defineFlow (
{
name: "documentAnalysis" ,
inputSchema: z. object ({
documentText: z. string (),
analysisType: z. enum ([ "summary" , "sentiment" , "keywords" ]),
}),
outputSchema: z. object ({
analysisType: z. string (),
result: z. string (),
}),
},
async ({ documentText , analysisType }) => {
// ステップ1: 入力を安全な長さに切り詰める
const cleaned = documentText. trim (). slice ( 0 , 5000 );
// ステップ2: 分析種別ごとにプロンプトを用意
const prompts : Record < string , string > = {
summary: `次の文書を3文で要約してください: \n ${ cleaned }` ,
sentiment: `次の文書の感情を ネガティブ / ニュートラル / ポジティブ で判定してください: \n ${ cleaned }` ,
keywords: `次の文書から主要キーワードを5つ挙げてください: \n ${ cleaned }` ,
};
// ステップ3: Gemini で生成し、構造化して返す
const { text } = await ai. generate ({ prompt: prompts[analysisType] });
return { analysisType, result: text };
},
);
入力を 5,000 文字で切り詰めているのは、想定外に長い本文が渡ってトークン費用が跳ねるのを防ぐためです。上限は扱う文書に合わせて決めますが、外側に置いておくと事故の芽を一つ潰せます。
Tool Calling とエージェント — ツールをモデルに委ねる
Genkit では、ツールを ai.defineTool で定義し、generate に渡すだけで、モデルが必要に応じて呼び出しを判断します。エージェントのために特別なクラスを構築する必要はありません。
// src/tools/weather.ts
import { z } from "genkit" ;
import { ai } from "../genkit" ;
export const getWeather = ai. defineTool (
{
name: "getWeather" ,
description: "指定した都市の現在の天気を返します" ,
inputSchema: z. object ({ city: z. string () }),
outputSchema: z. object ({
city: z. string (),
temperature: z. number (),
condition: z. string (),
}),
},
async ({ city }) => {
// 実運用では外部の天気APIを呼び出します
return { city, temperature: 25 , condition: "晴れ" };
},
);
// src/flows/assistant.ts
import { z } from "genkit" ;
import { ai } from "../genkit" ;
import { getWeather } from "../tools/weather" ;
export const assistantFlow = ai. defineFlow (
{
name: "assistant" ,
inputSchema: z. object ({ question: z. string () }),
outputSchema: z. string (),
},
async ({ question }) => {
const { text } = await ai. generate ({
prompt: question,
tools: [getWeather],
system: "あなたは有能なアシスタントです。必要な場合のみツールを使い、簡潔に答えてください。" ,
});
return text;
},
);
ツールを増やすほど、モデルが「どのツールを・いつ呼ぶか」を誤る余地も増えます。ツールの説明文(description)は、モデルにとっての唯一の判断材料です。曖昧だと誤選択が増えます。この計測と対策については、エージェントのツール誤選択を計測して直す実践記録 に切り分けの手順をまとめています。
RAG — Firestore Vector Search と接続する
RAG では、クエリを埋め込みに変換し、近い文書を取り出して、その文脈だけを根拠に答えさせます。Genkit は埋め込みを ai.embed で扱えます。
// src/flows/rag.ts
import { z } from "genkit" ;
import { ai } from "../genkit" ;
import { googleAI } from "@genkit-ai/googleai" ;
export const ragFlow = ai. defineFlow (
{
name: "documentRAG" ,
inputSchema: z. object ({ query: z. string (), topK: z. number (). default ( 3 ) }),
outputSchema: z. string (),
},
async ({ query , topK }) => {
// 1. クエリを埋め込みベクトルに変換
const [ embedding ] = await ai. embed ({
embedder: googleAI. embedder ( "text-embedding-004" ),
content: query,
});
// 2. Firestore Vector Search で近傍文書を取得
const docs = await searchSimilar (embedding.embedding, topK);
// 3. 取得した文脈だけを根拠にプロンプトを組む
const context = docs. map (( d ) => `- ${ d . content }` ). join ( " \n " );
const { text } = await ai. generate ({
prompt: `次の参考資料だけを根拠に、日本語で簡潔に答えてください。根拠がなければ その旨を述べてください。 \n\n 参考資料: \n ${ context } \n\n 質問: ${ query }` ,
});
return text;
},
);
// Firestore の近傍検索は別記事に実装を分けています
async function searchSimilar (
_embedding : number [],
_topK : number ,
) : Promise < Array <{ content : string }>> {
return [];
}
近傍検索の実体と、再インデックス時のドリフト対策はFirestore × Gemini 埋め込みの再埋め込みドリフト対策 に、マルチモーダルへの拡張はGemini マルチモーダル RAG システムの構築 に分けています。埋め込みの次元とベクトルDBのコストの関係はMatryoshka 次元削減でベクトルDBコストを抑える が参考になります。
Cloud Functions へのデプロイ — onCallGenkit で薄く包む
Flow はそのまま Firebase Functions に載せられます。onCallGenkit を使うと、認証・ストリーミング・App Check の配線を短く書けます。
// functions/src/index.ts
import { onCallGenkit } from "firebase-functions/https" ;
import { defineSecret } from "firebase-functions/params" ;
import { greetingFlow } from "./flows/greeting" ;
import { assistantFlow } from "./flows/assistant" ;
// APIキーは Secret Manager から注入する
const apiKey = defineSecret ( "GOOGLE_API_KEY" );
export const greeting = onCallGenkit ({ secrets: [apiKey] }, greetingFlow);
export const assistant = onCallGenkit ({ secrets: [apiKey] }, assistantFlow);
デプロイ手順は次の通りです。
# 1. Firebase CLI を用意
npm install -g firebase-tools
# 2. APIキーを Secret Manager に登録(コードには残さない)
firebase functions:secrets:set GOOGLE_API_KEY
# 3. Functions のみをデプロイ
firebase deploy --only functions
# 4. 一覧で確認
firebase functions:list
APIキーを defineSecret で受けている点が肝心です。環境変数のベタ書きは、リポジトリやログへの漏洩経路になります。Secret Manager に寄せておけば、鍵のローテーションもコード変更なしで回せます。より大規模な本番運用はVertex AI Agent Engine への本番デプロイ も選択肢になります。
コスト最適化 — モデルルーティングと費用の見える化
サーバーレスの料金は、油断すると一部のフローだけで膨らみます。私が最初に入れるのは、難易度でモデルを振り分けるルーティングです。
// src/flows/routed.ts
import { z } from "genkit" ;
import { ai } from "../genkit" ;
import { googleAI } from "@genkit-ai/googleai" ;
const FLASH = googleAI. model ( "gemini-2.5-flash" );
const PRO = googleAI. model ( "gemini-2.5-pro" );
export const routedFlow = ai. defineFlow (
{
name: "routed" ,
inputSchema: z. object ({ prompt: z. string (), hard: z. boolean () }),
outputSchema: z. string (),
},
async ({ prompt , hard }) => {
// 難しいタスクだけ Pro に回し、それ以外は Flash で捌く
const { text } = await ai. generate ({ model: hard ? PRO : FLASH , prompt });
return text;
},
);
あわせて、フローごとに呼び出し回数と実行時間を記録します。全体の合計だけを見ていると、どのフローが費用を押し上げているのかが分かりません。私の手元では、単純な分類タスクを Pro で回していた時期があり、Flash に落とすだけで当該フローのモデル費用が体感で半分以下になりました。段階的なコスト制御の考え方は個人開発のコストガードレール にもまとめています。
費用の内訳を推測しない — usage をフロー単位で記録して明細にする
ルーティングを入れたあとに困ったのは、「効いているのかどうかが分からない」ことでした。請求はプロジェクト単位でまとまってくるので、Flow ごとの内訳は出てきません。合計が下がっても、ルーティングが効いたのか、単に呼び出しが減った週だったのかを切り分けられない。
そこで ai.generate() の戻り値に含まれる usage を、フロー名と一緒に1行ずつ書き出すようにしました。
// src/usage.ts
import { appendFileSync } from "node:fs" ;
import { ai } from "./genkit" ;
type GenArgs = Parameters < typeof ai.generate>[ 0 ];
// フロー名を添えて generate を包み、usage を1行 JSON で残す
export async function generateWithUsage ( flow : string , args : GenArgs ) {
const startedAt = Date. now ();
const res = await ai. generate (args);
const u = res.usage ?? {};
const record = {
ts: new Date (). toISOString (),
flow,
model:
typeof args.model === "string" ? args.model : args.model?.name ?? "default" ,
inputTokens: u.inputTokens ?? 0 ,
outputTokens: u.outputTokens ?? 0 ,
ms: Date. now () - startedAt,
};
if (process.env. USAGE_LOG ) {
appendFileSync (process.env. USAGE_LOG , JSON . stringify (record) + " \n " );
} else {
// Cloud Functions ではファイルは残らないので構造化ログに落とす
console. log ( JSON . stringify ({ severity: "INFO" , genkitUsage: record }));
}
return res;
}
ここで一度つまずいたのが、ローカルと同じ感覚でファイルに追記していた点です。Cloud Functions のファイルシステムはインスタンスが消えれば一緒に消えるため、ログとしては当てになりません。本番側は console.log で JSON を出し、Cloud Logging のログベース指標として拾う形に切り替えました。ローカルでは USAGE_LOG を指定して jsonl に貯め、次のスクリプトで集計します。
// scripts/usage-report.mjs
import { readFileSync } from "node:fs" ;
// 単価は改定されるので、自分の請求画面の値に置き換えてください(1Mトークンあたり USD)
const PRICE = {
"gemini-2.5-flash" : { in: 0.3 , out: 2.5 },
"gemini-2.5-pro" : { in: 1.25 , out: 10.0 },
};
const JPY = 150 ;
const rows = readFileSync (process.argv[ 2 ], "utf8" )
. trim ()
. split ( " \n " )
. map (( line ) => JSON . parse (line));
const agg = new Map ();
for ( const r of rows) {
const key = `${ r . flow } \t ${ r . model }` ;
const a = agg. get (key) ?? { calls: 0 , in: 0 , out: 0 , ms: [] };
a.calls += 1 ;
a.in += r.inputTokens;
a.out += r.outputTokens;
a.ms. push (r.ms);
agg. set (key, a);
}
const pct = ( xs , p ) => [ ... xs]. sort (( x , y ) => x - y)[Math. floor (xs. length * p)] ?? 0 ;
for ( const [ key , a ] of agg) {
const [ flow , model ] = key. split ( " \t " );
const price = PRICE [model] ?? PRICE [ "gemini-2.5-flash" ];
const usd = (a.in / 1e6 ) * price.in + (a.out / 1e6 ) * price.out;
const per1k = (usd / a.calls) * 1000 * JPY ;
console. log (
[
flow. padEnd ( 18 ),
`calls=${ a . calls }` ,
`in/回=${ Math . round ( a . in / a . calls ) }` ,
`out/回=${ Math . round ( a . out / a . calls ) }` ,
`${ per1k . toFixed ( 0 ) }円 / 1,000呼び出し` ,
`p90 ${ pct ( a . ms , 0.9 ) }ms` ,
`p90入力 ${ pct ( rows . filter (( r ) => r . flow === flow ). map (( r ) => r . inputTokens ), 0.9 ) }tok` ,
]. join ( " " ),
);
}
手元の4フローを Flash に揃えて回したときの出力は、次のようになりました。単価は上のコードの値で計算しています。
フロー
平均入力トークン
平均出力トークン
1,000呼び出しあたり
greeting 比
greeting
120
90
約39円
1.0倍
assistant(ツール込み)
640
210
約108円
2.7倍
documentAnalysis
1,850
320
約203円
5.2倍
documentRAG
2,600
280
約222円
5.7倍
この表を出して初めて分かったのは、平均ではなく入力トークンの裾が効いているということでした。documentAnalysis の入力は平均1,850トークンですが、p90 は約4,900トークン。平均の約2.6倍です。上位1割の呼び出しが、そのフローの月額のおよそ3割を作っていました。
5,000文字で切り詰めていたはずなのに、なぜまだ裾が伸びるのか。原因は、上限を「渡された文書」にだけ掛けていたことでした。プロンプトの定型部と、RAG 側で差し込む参照文脈は数えていない。私はいま、上限を組み上がったプロンプト全体に対して掛け、超えた分は参照文脈の末尾から落とすようにしています。
単価表はコードの中に定数として置くことをお勧めします。スプレッドシートに分けると更新が止まり、半年前の単価で判断し続けることになります。
コールドスタートと運用 — 個人開発で効いた地味な調整
サーバーレスで最初にぶつかる壁はコールドスタートでした。たまにしか呼ばれないフローほど初回が遅く、ユーザーには「重いアプリ」に映ってしまいます。私の手元での計測は次の通りです。
フロー
ウォーム時の平均
コールドスタート初回
最小インスタンス設定後の初回
greeting
約0.9秒
約4.2秒
約1.1秒
documentAnalysis
約1.6秒
約5.0秒
約1.7秒
documentRAG
約2.3秒
約6.1秒
約2.4秒
対策は素朴です。呼び出し頻度の高い経路は minInstances を 1 に設定してウォームに保ち、重い初期化はモジュール読み込み時ではなくリクエスト経路の外へ寄せる。派手さはありませんが、体感速度は結局こうした調整で決まると感じています。最小インスタンスは常時課金につながるため、本当に温めるべき経路だけに絞るのが、個人開発での落としどころです。
最小インスタンスを置くかどうかは、次の3点で決めています。
その経路がユーザーの最初の操作に含まれるか。含まれないなら、初回に4〜6秒かかっても実害は小さい
呼び出しの間隔。ログを見て15分以上空くのが常態なら、ウォームはまず維持されません。つまり最小インスタンスを置かない限り、体感は改善しません
常時1インスタンスを維持する月額が、そのフローのモデル費用と釣り合うか
3番で判断が変わった例があります。greeting 相当の軽い経路は、1,000呼び出しで約39円。月に数千回程度なら、モデル費用より最小インスタンスの常時課金のほうが大きくなる計算でした。結局この経路の最小インスタンスは外し、代わりに documentRAG 側にだけ 1 を置いています。速くしたい気持ちだけで全経路を温めると、削ったはずのモデル費用が別の名前で戻ってきます。
Dev UI では通ったのに本番で落ちた4か所
ローカルの Dev UI は親切です。.env を読み、スキーマの既定値をフォームに埋め、リージョンを気にする必要もない。その親切さが、デプロイ後にまとめて牙をむきました。私が実際に踏んだのは次の4つです。
症状
実際の原因
最初に打つ手
デプロイ直後の呼び出しが500。ログに認証系のエラー
onCallGenkit の secrets に渡し忘れ。Dev UI は .env を読むので気づけない
Secret Manager 側に値があるかを確認し、export している全ての Flow の secrets 配列を見る
クライアントから呼ぶと404、またはCORSに見えるエラー
Functions のリージョンと、クライアントSDKの既定リージョンがずれている
クライアント側でもリージョンを明示する(getFunctions(app, "asia-northeast1"))
入力の一部が undefined で zod に弾かれる
Dev UI のフォームが .default() を埋めていた。実クライアントは省略して送っていた
既定値をスキーマ任せにせず、Flow 本体の先頭でも確定させる
最初の1回だけ deadline-exceeded
モジュールのトップレベルで重い初期化を走らせている
初期化を遅延させてリクエスト経路の外へ。温めるべき経路なら最小インスタンス
切り分けの順番は、いつも同じ3手で足りています。
デプロイした関数のログを絞って取り、失敗が初回だけか毎回かを見る。ここで配線の問題とコールドスタートの問題が分かれます
毎回失敗するなら配線側。シークレット、リージョン、スキーマの順に潰す。この3つで私の遭遇例はすべて説明がつきました
初回だけなら初期化側。モジュールのトップレベルで走っている処理を数え、リクエストを受けてから走らせて差が出るかを確かめる
2番のリージョン不一致には、私自身が半日を溶かしました。エラーがCORSの顔をして出てくるので、フロントエンド側を疑い続けてしまう。Functions の一覧でリージョンを確認するほうが、はるかに早い道でした。
まとめ — 小さく動かし、費用の内訳を持って育てる
Firebase Genkit の価値は、フレームワークが薄く、Gemini の呼び出しに集中できる点にあります。まずは挨拶を返すだけの Flow を1本、ローカルで動かしてデプロイしてみてください。その一本が通れば、Tool Calling も RAG も同じ書き味の延長です。
次の一歩として、あなたのフローに minInstances の要否を判断する材料(フローごとのレイテンシと呼び出し頻度)を、最初から記録しておくことをおすすめします。あとから足すより、ずっと楽に育てられます。
実装の足がかりになれば幸いです。お読みいただきありがとうございました。
さらに深掘りするには、Firebase Genkit 公式ドキュメント とGemini API 公式ドキュメント もあわせてご覧ください。