Part 2|第 5/30 篇
今日要做的事: 替餐點照定 MealAnalysisSchema,同一張鴨腿便當打開 Structured outputs 再跑一次。
今天要解決的目的: 讓餐點 tags 能直接進 today_meals;順便確認「格式對了」是不是就等於「我敢信」。
我本來想說,打開 Structured outputs,今天應該可以早收工。
JSON 真的變整齊了。可是 Day 3 還肯寫「烤或滷」,今天直接給我
grilled,needs_confirmation還變成false。
| 項目 | 內容 |
|---|---|
| 產出 | MealAnalysisSchema、一筆模型直出的 Meal JSON、我自己的驗證結果 |
| 工具 | AI Studio(Gemini 3.1 Pro Preview、Structured outputs 開) |
| 不使用 | Firebase、重拍食物、重跑冰箱 |
| 素材 | Day 3 鴨腿便當+AI Studio 設定/輸出截圖 |
| 指標 | parsed_ok、balance_response、no_hallucination |
還是不上 Firebase。Meal 的契約還沒驗完,先存進資料庫,只會多一批長得很漂亮的錯資料。
先把便當再放一次。今天沒重拍,就是 Day 3 那張。

圖:外食鴨腿便當。主菜帶皮禽肉,旁邊炒菜、豆干、白飯。
Day 3 便當照我故意關掉 Structured outputs,想先看它會不會亂猜。它回中文條列,烹調停在「烤或滷」,禽肉也不敢說死。後來那份 today_meals,是我看完自己收的,不是模型吐出來的。

圖:左邊是模型看圖、我翻譯;右邊才是模型照 schema 直接輸出。
偶爾手填一次還好。我要連記好幾天的話,build_balance_summary() 吃的是固定 tags,不是「主菜可能是烤或滷」這種句子。
所以今天只換開關、不換照片,只想知道一件事:
餐點照能不能直接交出看得懂的 JSON?欄位被填滿之後,它還會不會承認自己不確定?
流程就這條,不推薦晚餐:
照片 → Meal JSON → 我檢查語意 → 過了才進 today_meals
模型不准在 JSON 外面再寫一段「建議少油」。那是後面 Rule/Agent 的事。
MealAnalysisSchema(v1)輸出先壓成七個欄位。完整 schema 放附錄,這裡只看形狀:
{
"slot": "lunch",
"label": "餐點名稱",
"food_source": "eat_out",
"items": [{ "name": "品項", "role": "main", "cook_method": "unknown" }],
"tags": ["poultry"],
"uncertain_items": [],
"needs_confirmation": false
}
items 描述畫面,tags 留給之後算均衡,兩份工作不要塞在同一個欄位。
四個我自己拍板的決定:
| 決定 | 我當時在想什麼 |
|---|---|
要有 food_source |
Day 3 那兩條流的閘門;外食認成自煮會誤扣冰箱 |
tags 最多 4 個、用 enum |
不然之後統計會變成自由作文 |
cook_method 可以是 unknown |
對齊 Day 3 的「烤或滷」 |
不放 confidence 小數 |
沒校準的 0.87 看起來很科學,我自己也會被唬到 |
Temperature 0、Thinking Medium、Tools 全關,Structured outputs 打開並貼 schema。
你在看一張台灣餐點照片。
依 schema 填寫。
只描述看得到的東西;沒入鏡的格子不要猜。
不確定就寫進 uncertain_items,並把 needs_confirmation 設為 true。
不要給營養建議或推薦菜單。
用繁體中文填 label 與 name。
「沒入鏡不要猜」我留著。欄位跟 enum 已經在 schema 裡,Prompt 不再抄一遍。
同一張便當,Structured outputs(下面簡稱 SO)打開:

圖:SO 開、Temperature 0。Studio 裡 JSON 常擠成一行,下面我貼展開版方便對。
它吐出來的原文(展開後)是這樣:
{
"slot": "lunch",
"label": "烤鴨便當",
"food_source": "eat_out",
"items": [
{ "name": "烤鴨腿", "role": "main", "cook_method": "grilled" },
{ "name": "炒高麗菜", "role": "side", "cook_method": "stir_fried" },
{ "name": "炒四季豆", "role": "side", "cook_method": "stir_fried" },
{ "name": "滷豆干", "role": "side", "cook_method": "braised" },
{ "name": "白飯", "role": "staple", "cook_method": "steamed" }
],
"tags": ["poultry", "protein_present", "vegetable_present", "braised"],
"uncertain_items": [],
"needs_confirmation": false
}
第一眼:哇,終於可以接到程式了。
第二眼看到兩個空的/false,心就涼了半截。
| 檢查點 | Day 3(我手填) | Day 4(模型直出) |
|---|---|---|
food_source |
eat_out |
eat_out ✅ |
| 主菜烹調 | 烤或滷 | grilled |
| 均衡 tags | sauce_heavy |
braised |
uncertain_items |
禽肉種類、烹調 | [] ⚠️ |
needs_confirmation |
true |
false ⚠️ |
格式我驗過了:JSON 解得開、enum 合法、tags 沒超過四個,也沒亂標 fried。可是同一張照片,昨天還會停一下,今天選得乾乾淨淨。
只有一張、各跑一次,我不敢寫成「SO 一定讓模型更愛裝懂」。我現在能確定的只有這句:SO 沒幫我解決裝懂;JSON 太漂亮,我自己比較容易鬆懈。
sauce_heavy 換成 braised,也不一定是看錯。一個在講醬色,一個抓到滷豆干。之後算 balance_response 要用哪一種,得我自己鎖,不能讓它每餐換心情。
這輪我只能收到這裡:
parsed_ok = 1
semantic_review = CONFIRM
write_allowed = false
冰箱那套 Day 3 的 pantry schema 繼續用。今天多的是平行的 Meal 契約,冰箱辨識沒重跑。

圖:SO 管能不能存;後面那關才管該不該信。沒驗過的 model_raw 我不直接寫進去。
我原本想把 needs_confirmation 也丟給模型。這輪看起來不穩。比較想這樣切:
model_raw 原文留著,我改過的另存規則抓得到「uncertain 有東西、needs_confirmation 卻是 false」。可是這輪 uncertain 根本是空的,規則不知道它漏報了什麼。這洞不是一行 if 補得完,明天從「怎麼問我」那裡下手。
parsed_ok=1,我還是打 CONFIRM。下一篇: 看不懂的時候怎麼問我,而不是繼續猜。
MealAnalysisSchema{
"type": "object",
"properties": {
"slot": {
"type": "string",
"enum": ["breakfast", "lunch", "dinner", "snack"]
},
"label": { "type": "string" },
"food_source": {
"type": "string",
"enum": ["home", "convenience_store", "supermarket", "eat_out"]
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"role": {
"type": "string",
"enum": ["staple", "main", "side", "other"]
},
"cook_method": {
"type": "string",
"enum": [
"fried", "braised", "stir_fried", "steamed",
"grilled", "raw", "unknown"
]
}
},
"required": ["name", "role", "cook_method"]
}
},
"tags": {
"type": "array",
"maxItems": 4,
"items": {
"type": "string",
"enum": [
"fried", "braised", "stir_fried", "poultry", "seafood",
"protein_present", "vegetable_present", "vegetable_low",
"sauce_heavy", "refined_carb_heavy"
]
}
},
"uncertain_items": {
"type": "array",
"items": { "type": "string" }
},
"needs_confirmation": { "type": "boolean" }
},
"required": [
"slot", "label", "food_source", "items", "tags",
"uncertain_items", "needs_confirmation"
]
}