Part 4|第 20/30 篇
今日要做的事: 用一個簡單網頁把六步測試、Gemini 和 Firestore 接在同一頁。一次晚餐請求分成規則短路、exact response cache、真的打 Gemini,並把 token 和延遲寫回 Firestore。
今天要解決的目的: 同一個問題問三次,不該付三次錢、等三次;但 LifeFlow 的過敏一改,舊答案必須當場失效。
Day 16 之後,每次問晚餐都要送規則、食譜、策略、冰箱、今天吃過什麼。連按三次「今晚吃什麼」,就是同一份 context 送三次。
想省錢最直覺的做法是「同一個人問同一題,就回上次的答案」。上午這樣做沒問題;下午我發現自己對花枝過敏,晚上它還是端出花枝丸豆腐煮。

| 項目 | 內容 |
|---|---|
| 產出 | 一個本機網頁、三條請求路徑、版本化的 response key、每次請求的成本紀錄 |
| Google 服務 | Firebase Auth 登入、Firestore(LifeFlow 快照、response_cache、requests、transaction)、Gemini API generateContent 與 usageMetadata |
| 模型 | 前半實測 gemini-3.8-flash;免費方案 20 次用完後,預設改為 gemini-3.5-flash,不再跳回 3.8 |
| 沿用 | Day 7 的 Firestore rules、Day 12 validate、Day 16 compileAuthoritative、Day 17 decide |
| 不做 | 語意相似 cache、用字數估 token、把金額寫死在程式裡 |
| 指標 | input / cached / output / thought tokens、模型延遲、總延遲、response_cache_hit、model_calls |
這篇不附整包程式下載。第 3 節列出資料夾結構、Firestore 結構和每個檔案的核心段落,照著組就能重現:node test.js 鎖住 key 與短路的行為,npm run dev 開網頁,登入後照左邊六顆按鈕走一遍。
今天的 LifeFlow 快照和 Day 16 一樣,只多一包快過期的花枝丸:
過敏:crustacean(甲殼類)
冰箱:豆腐 0.5 盒(快過期)、雞蛋 4 顆、花枝丸 6 顆(快過期)
爐口:1
食譜庫:豆腐蛋炒、豆腐湯配煎蛋同時煮、花枝丸豆腐煮
這時花枝丸豆腐煮是合格的。要是 cache key 只用「uid + 今晚吃什麼」:
18:00 問晚餐 → Gemini 推薦花枝丸豆腐煮 → 存起來
18:30 在 LifeFlow 新增 mollusk(軟體動物)過敏
19:00 再問一次 → key 一樣 → 端出 18:00 的花枝丸豆腐煮
所以今天要回答的不是「能不能 cache」,而是:
哪些資料可以重用,哪些變更必須讓舊推薦當場作廢?
另一件事更簡單:規則已經能回答的請求,本來就不該送 Gemini。沒有送出的 token 最便宜。

路一 SHORT_CIRCUIT。 compile 先跑。晚餐改外食只是「記一筆」,寫進 today_meals 就結束,不碰 pantry,也不叫模型。缺預算或可用時間時,先問使用者,同樣不叫模型。
路二 CACHE_HIT。 要推薦時,先算 response_key,到 Firestore 的 response_cache 找。完全一樣才算命中,不做「這兩個冰箱看起來差不多」的語意相似。
路三 GEMINI。 沒命中才打 Gemini,回來先過伺服器的 decide,只有通過 validate 的方案寫回 cache。被擋下的結果不進 cache,下次不會被重用。
三條路的每一次請求,都在 Firestore 的 requests 寫一筆成本紀錄。沒打模型的那幾筆,token 欄位寫 not_applicable,不寫 0。0 是「量到了,是零」;not_applicable 是「這次根本沒有可量的東西」。
day19/
├─ index.html 六顆按鈕、成本紀錄表、推薦結果
├─ package.json firebase、vite
├─ vite.config.js port 3019,允許讀上一層沿用的模組
├─ .env Firebase 設定與 Gemini 金鑰,不進版控
├─ src/
│ ├─ firebase.js initializeApp、Auth、Firestore
│ ├─ core.js 食譜、穩定前綴、responseKey、shortCircuit
│ ├─ gemini.js generateContent、忙碌時重試、整理 usageMetadata
│ └─ main.js 三條路、transaction、寫 requests
├─ probe.js 探顯式 Context Cache 的門檻與額度
├─ implicit.js 同一份快照連打三次,看隱式快取
└─ test.js 六個本機測試
驗證邏輯不重寫,直接 import 前幾天的模組:Day 11 的策略目錄 catalog.js、Day 12 的 validate.js 和 allergen-map.js、Day 16 的 compileAuthoritative、Day 17 的 decide。這幾支都是純 JavaScript,沒有用到 Node 專屬 API,瀏覽器也跑得動。Vite 預設不讓網頁讀專案根目錄外的檔案,要打開一層:
export default defineConfig({
server: { port: 3019, fs: { allow: [".."] } },
});
.env 只放這七個變數,值不貼進文章:
VITE_FIREBASE_API_KEY
VITE_FIREBASE_AUTH_DOMAIN
VITE_FIREBASE_PROJECT_ID
VITE_FIREBASE_STORAGE_BUCKET
VITE_FIREBASE_MESSAGING_SENDER_ID
VITE_FIREBASE_APP_ID
VITE_GEMINI_API_KEY
Firestore 全部放在登入者自己的 users/{uid} 底下,Day 7 那條「只有本人能讀寫整棵子樹」的 rules 不用改:
users/{uid}/
├─ day19_state/hard_profile hard_profile_version、allergens、kitchen
├─ day19_state/pantry pantry_version、items
├─ day19_state/today today_meals、budget_twd、available_time_minutes
├─ response_cache/{rk_…} key_parts、options、blocked、naive_key
└─ requests/{自動 id} path、版本、四種 token、延遲
export async function responseKey({ model, hard, pantry, today, strategyIds }) {
const parts = {
model,
prompt_schema_version: PROMPT_SCHEMA_VERSION,
verifier_version: VERIFIER_VERSION,
hard_profile_version: hard.hard_profile_version,
pantry_version: pantry.pantry_version,
today_hash: (await sha256Hex(canonical(today))).slice(0, 12),
strategy_ids: [...strategyIds].sort(),
};
return { key: `rk_${(await sha256Hex(canonical(parts))).slice(0, 24)}`, parts };
}
uid 不在 key 裡,因為 cache 本來就放在 users/{uid}/response_cache 底下,Day 7 的 rules 只讓本人讀寫,別人的 key 撞到也讀不到。
canonical 會先把欄位排序再算 hash。同樣的 today,budget_twd 寫在前面或後面,不該變成兩份 cache。hash 用瀏覽器和 Node 都有的 Web Crypto,同一支函式網頁和測試都能用:
export function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
}
return JSON.stringify(value);
}
export async function sha256Hex(text) {
const bytes = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
verifier_version 也在裡面。哪天 Day 12 的過敏對照表多收一個同義詞,舊 cache 是用舊規則驗過的,一樣要作廢。
await runTransaction(db, async (tx) => {
const ref = userDoc("day19_state", "hard_profile");
const current = (await tx.get(ref)).data();
const allergens = [...new Set([...current.hard_constraints.allergens, "mollusk"])];
tx.update(ref, {
"hard_constraints.allergens": allergens,
hard_profile_version: current.hard_profile_version + 1,
updated_at: serverTimestamp(),
});
});
改過敏和升版本必須同時成功。如果過敏寫進去了、版本沒加,下一次請求算出來的 key 跟舊的一樣,就是開頭那個 bug。
這裡不靠 TTL。就算舊 cache 還有一小時才過期,版本不同就是 miss。舊的那份也不用去刪,它的 key 再也不會被算出來。
短路的條件只看規則已經知道的事:
export function shortCircuit({ task, today }) {
if (task === "record_meal" && today.food_source === "eat_out") {
return { code: "EAT_OUT_RECORD", message: "外食紀錄:寫 today_meals,不寫 pantry,不叫模型" };
}
if (task === "recommend" && (today.budget_twd == null || today.available_time_minutes == null)) {
return { code: "INVALID_CONTEXT", message: "缺預算或可用時間,先問使用者" };
}
return null;
}
三條路接在一起:
const stop = shortCircuit({ task, today });
if (stop) return finish(row, t0, { path: "SHORT_CIRCUIT", model_calls: 0 });
const { key, parts } = await responseKey({ model: DEFAULT_MODEL, ...life, today });
const cached = await getDoc(userDoc("response_cache", key));
if (cached.exists()) return finish(row, t0, { path: "CACHE_HIT", model_calls: 0, ...cached.data() });
const ai = await askGemini(apiKey, buildVariablePart({ ...life, today }));
const verdict = decide({ requestId, profile, today, modelOptions });
if (verdict.options.length > 0) {
await setDoc(userDoc("response_cache", key), { key_parts: parts, options: verdict.options, ... });
}
return finish(row, t0, { path: "GEMINI", model_calls: 1, ...ai.usage });
finish 把 path、key、兩個版本、四種 token、模型延遲、總延遲一起寫進 requests。總延遲從按下按鈕算到結果出來,包含 Firestore 讀寫,不只算模型那一段。沒打模型的路,欄位補成 not_applicable:
for (const k of ["input_tokens", "cached_input_tokens", "output_tokens", "thought_tokens", "model_latency_ms"]) {
if (record[k] === undefined) record[k] = "not_applicable";
}
await addDoc(userCol("requests"), record);
contents: [{ role: "user", parts: [{ text: STABLE_PREFIX }, { text: variablePart }] }],
STABLE_PREFIX 是每次都一樣的東西:規則、三道食譜、策略目錄、過敏同義詞。冰箱、過敏、今天吃過什麼放在後面。Gemini 的 Prompt Caching 只能重用「開頭一模一樣」的部分,順序放反,什麼都重用不到。
回來之後,token 一律從 usageMetadata 拿,不自己估。cachedContentTokenCount 沒出現就是 0,代表這次真的沒命中:
const u = body.usageMetadata ?? {};
return {
model: body.modelVersion ?? model,
parsed: JSON.parse(text),
usage: {
input_tokens: u.promptTokenCount ?? null,
cached_input_tokens: u.cachedContentTokenCount ?? 0,
output_tokens: u.candidatesTokenCount ?? null,
thought_tokens: u.thoughtsTokenCount ?? 0,
total_tokens: u.totalTokenCount ?? null,
},
model_latency_ms: Math.round(latency),
};
網頁直接從瀏覽器打 Gemini,金鑰放在 VITE_GEMINI_API_KEY。VITE_ 開頭的變數會被打包進前端,所以這頁只在 localhost 跑;要上線,這段得搬到伺服器端。

六個測試鎖住四件事:同一份快照 key 不變、欄位順序不影響 key、pantry 或 hard profile 換版 key 一定換、外食紀錄直接短路。最後一個是 Day 12 的安全網:加了 mollusk 之後,就算模型說花枝丸豆腐煮 hard_constraints_ok: true,decide 一樣擋下。
Day 19 原本的計畫是用 Gemini 的 Context Cache,把穩定前綴存起來,後面每次只付變動那段的錢。下面這兩次翻車,都是還在用 gemini-3.8-flash、專案還在免費方案時打的。
第一次:太短。 前綴一開始只有規則和食譜:
stable prefix tokens 844
Cached content is too small. total_token_count=844, min_total_token_count=1024
顯式快取最少要 1024 token。我回頭看 Day 19 草稿,策略目錄和過敏同義詞本來就該放在穩定前綴,只是我沒放進去。補進去之後變成 1809。
第二次:免費方案沒有額度。

TotalCachedContentStorageTokensPerModelFreeTier limit exceeded for model gemini-3.8-flash: limit=0
換成 gemini-3.5-flash 也一樣是 limit=0。顯式 Context Cache 在免費方案用不了。
退一步:隱式快取。 不用建 cache,只要前綴一樣、夠長,Gemini 會自己重用,命中時 usageMetadata.cachedContentTokenCount 會大於 0。我用同一份快照連打三次:

| 次數 | 模型 | input | cached | output | thought | 模型延遲 |
|---|---|---|---|---|---|---|
| 1 | gemini-3.8-flash | 2016 | 0 | 202 | 1407 | 7732 ms |
| 2 | gemini-3.8-flash | 2016 | 0 | 213 | 1392 | 7247 ms |
| 3 | gemini-3.8-flash | 2016 | 0 | 201 | 755 | 7105 ms |
三次都有「模型忙碌」重試,但最後都還是 gemini-3.8-flash 回來,cached 都是 0。隱式快取是「有機會」,不是保證,這次一次都沒輪到。
這張表還藏著第二件事:thought token 是 output 的 4 到 7 倍(1407、1392、755)。就算隱式快取哪天命中,折扣的也只是 input;這三段思考照付。對 DishFlow 來說,Prompt Caching 不是今天的主角。
網頁上的第一次請求 input 是 2046,比這裡多 30。主要差在 Firestore 讀回來的 hard profile 和 pantry 多了 updated_at,字串變長,token 就跟著變。cache key 只看版本,不看這個時間戳,所以不影響命中。
gemini-3.8-flash 先問了兩次。第一次 key rk_a22b07ca 走 GEMINI,input 2046、cached 0、output 278、thought 1298,模型 6594 ms,含重試的總時間 49453 ms。第二次同一個 key 走 CACHE_HIT,1285 ms,token 全是 —。第三次要把花枝丸扣 3 顆,transaction 先成功,接著 cache miss,API 拒絕:
Quota exceeded for metric:
generativelanguage.googleapis.com/generate_content_free_tier_requests,
limit: 20, model: gemini-3.8-flash
Please retry in 10h2m51s
免費方案這個模型每天 20 次 generateContent。沒有綁帳單,就會停在這個 limit。今天 probe.js、implicit.js 和網頁都打過 3.8,中間還有好幾次「模型忙碌」的重試;CACHE_HIT 不打模型,這次 miss 才撞上。配額錯誤不算忙碌,程式沒有再重試。這筆沒寫進 requests,冰箱卻已經扣過。
要繼續測,錢是在下一張畫面付出去的。預付 NT$150,餘額低於 150 會再自動加 150。月上限當時是 No limit set。文章截圖前我把帳號編號和卡號遮掉。

3.8 今天常常 high demand,每次都要等重試。綁完之後預設改成 gemini-3.5-flash,忙碌時也不再跳回 3.8。key 裡有模型名稱,所以從第 1 步把快照和 cache 清掉,六步重走。下面這張是 gemini-3.5-flash 版 走完六步後的畫面,五筆請求都寫進 requests:

| # | 情境 | 路徑 | hard v | pantry v | input | output | thought | 總延遲 |
|---|---|---|---|---|---|---|---|---|
| 1 | 第一次問 | GEMINI | 1 | 1 | 2046 | 192 | 1165 | 7561 ms |
| 2 | 再問一次 | CACHE_HIT | 1 | 1 | — | — | — | 358 ms |
| 3 | 花枝丸少 3 顆 | GEMINI | 1 | 2 | 2047 | 197 | 1046 | 7145 ms |
| 4 | 新增 mollusk 過敏 | GEMINI | 2 | 2 | 2051 | 283 | 1558 | 8594 ms |
| 5 | 晚餐外食 | SHORT_CIRCUIT | 2 | 2 | — | — | — | 482 ms |
三次 GEMINI 的 cached 都是 0。圖左下是走完之後 Firestore 上的 LifeFlow:hard v2,過敏變成 crustacean, mollusk;pantry v2,花枝丸剩 3 顆;今天已吃多了 dinner:eat_out。右下顯示的是最後一步外食的結果,model_calls = 0。
逐步看:
crustacean,花枝丸 6 顆。rk_279d3e6d,走 GEMINI。input 2046、cached 0、output 192、thought 1165。模型 6604 ms,總共 7561 ms。豆腐蛋炒、花枝丸豆腐煮上桌,兩爐的湯被 BURNER_CONCURRENCY_EXCEEDED 擋下。rk_2379137d,再走 GEMINI。input 2047、output 197、thought 1046,總共 7145 ms。冰箱變了,舊答案不能沿用。花枝丸剩下 3 顆,這時還沒加軟體動物過敏,花枝丸豆腐煮仍然上桌。rk_fc22d099,走 GEMINI。input 2051、output 283、thought 1558,總共 8594 ms。豆腐蛋炒還在;兩爐湯仍是 BURNER_CONCURRENCY_EXCEEDED;花枝丸豆腐煮被 ALLERGEN_GROUP_BLOCKED 擋下。食材名和步驟都寫了花枝丸,所以這個 code 出現兩次。按下這一步時,結果區還多一條黃色對照:如果 key 只用「uid + 今晚吃什麼」,這次會命中 hard v1 的舊結果,把豆腐蛋炒和花枝丸豆腐煮一起端出來。走到第 6 步後,結果區換成外食紀錄,所以上圖看不到這條黃色對照。today_meals 多一筆 dinner:eat_out,花枝丸仍是 3 顆,model_calls = 0。換成 gemini-3.5-flash、也綁了帳單,三次 GEMINI 仍沒有吃到 Prompt Caching 的折扣。真正把時間和 token 省下來的,是第 2 筆的 CACHE_HIT 和第 5 筆的 SHORT_CIRCUIT。
到 Firebase console 看 requests。這筆是第一次問:input_tokens 2046、cached_input_tokens 0、hard_profile_version 1,key_parts.model 是 gemini-3.5-flash。被擋下的是 two_burner_soup,model_said_ok 是 false,issue 是 BURNER_CONCURRENCY_EXCEEDED。

response_cache 的文件 id 就是 response key。裡面有三份:rk_279d3e6d、rk_2379137d、rk_fc22d099,對應第一次問、花枝丸少 3 顆、新增過敏。打開第一次那份,key_parts 寫著 hard v1、pantry v1、模型 gemini-3.5-flash,還有 today_hash 和 verifier_version。naive_key 也留著,用來對照「只用 uid 加問題」的那種 key。blocked 裡只有兩爐湯,花枝丸豆腐煮當時還在可上桌的方案裡。

requests 只存 token,不存台幣。單價會變,模型也會換;寫死在程式裡,半年後這份紀錄就會說謊。要算錢時,用 token 乘上當天官方價目:
cost = (input - cached) × input 單價
+ cached × cached 單價
+ (output + thought) × output 單價
thought 照 output 計價,這也是第 5 節那張表要看 thought 那一欄的原因。
Day 0 定義的 Cost 是錢、時間和心力。今天動的是 Cost 裡最容易被忽略的一塊:使用者為了等 AI 回答付出的時間,以及我自己為了跑 AI 付出的帳單。
但 Cost 省下來的前提,是 Eat 那一邊不能被犧牲。過敏是 Eat 裡最硬的一條線,所以 key 寧可多換幾次、多打幾次 Gemini,也不能讓舊的花枝丸豆腐煮混過去。
省多少,看第 6 節那張表:五筆請求裡,CACHE_HIT 和 SHORT_CIRCUIT 兩筆沒打模型,分別是 358 ms 和 482 ms;打 Gemini 的三筆都在 7 到 9 秒。省得對不對,看第 5 步那條黃色的對照。
model_calls = 0。limit=0;隱式快取三次都沒命中,而且每次思考 token 比 output 多出好幾倍。在這個規模,真正省錢的是 Firestore 上那份 exact response cache。gemini-3.8-flash 在花枝丸那一步用完額度。後面綁上帳單,預設模型換成 gemini-3.5-flash。下一篇: 先把 Reality Benchmark 的計時與戰報模板鎖死;Day 21 才真的開火。
allergens、forbidden_ingredients、飲食型態、equipment、burners 任一變更,升 hard_profile_version。pantry 數量或品項變更,升 pantry_version。today 和 strategies 直接進 hash,不另外記版本。
{
"path": "CACHE_HIT",
"response_key": "rk_…",
"hard_profile_version": 1,
"pantry_version": 1,
"model_calls": 0,
"input_tokens": "not_applicable",
"cached_input_tokens": "not_applicable",
"output_tokens": "not_applicable",
"thought_tokens": "not_applicable",
"model_latency_ms": "not_applicable",
"response_cache_hit": true,
"prompt_cache_hit": false
}