iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Build on Google AI

DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統系列 第 17 篇

[Day 16] 模型會自己拿資料,就不用再貼整份人生了嗎?用 Function Calling 讓 Gemini 自己讀冰箱

  • 分享至 

  • xImage
  •  

Part 4|第 17/30 篇
今日要做的事: 把 Function Calling 收成五個工具,並分開「模型拿資料」和「規則判定合格」。
今天要解決的目的: 讓 C 不必再貼 LifeFlow,同時不把「有呼叫工具」當成過敏或爐口已經過關。

昨天的門禁還停在本機。今天接上的是:Gemini 透過 Function Calling 自己讀冰箱,它能叫哪些函式、不能叫哪些。

工具一多,Agent 會變成另一種長 Prompt,只是長度藏在 trace 裡。所以今天先停在五支。


今日任務卡

項目 內容
產出 五個 tool declarations、本機七項守門測試、兩輪 Gemini Function Calling trace
產品路徑 Gemini API 自己呼叫工具讀 LifeFlow;uid 來自登入,不來自模型參數
評測路徑 同一份 fixture 可重播;node test.js 不打 API
不使用 第六支資料工具、身體資料、向量搜尋;金鑰不進文章
指標 tool_ok、tool_error、hard_ok、no_hallucination

文章只放關鍵片段,fixture 與輔助函式省略。node test.js 先鎖工具契約,不花配額。契約過了,才用 Gemini Function Calling 跑 node live.js。


1. 問題:把 JSON 藏進工具,不等於問題消失

B1 靠人掃一眼後手打,B2 靠人貼當日完整 JSON。C 的差異應該是 DishFlow 自己讀 LifeFlow,不是叫使用者貼得更勤勞。

但 Function Calling 有兩件容易混在一起的事:

工具:把授權資料取回來
規則:決定哪些方案永遠不合格

模型可能少叫工具、叫錯參數,或拿到 pantry 之後仍寫一個冰箱裡沒有的食材。所以「有呼叫 get_pantry」只代表 trace 裡出現過這支函式,不代表 hard_ok=true。

https://ithelp.ithome.com.tw/upload/images/20261001/20121052r7gEEGv58f.png

左邊是這一輪 Gemini 自己叫的工具,最後推薦豆腐蛋炒。右邊是另一輪:工具把兩爐食譜交出去之後,模型寫成煮湯的同時用平底鍋煎蛋,validate 仍是 hard_ok=false。


今天走的路

uid 只從登入 session 來。五支工具各自回答一件事,參數不對就在進資料前拒絕。伺服器自己 compile 過敏與設備,不看模型有沒有叫 get_profile。食譜再走 Day 12 的 validate;外食不扣 pantry。node test.js 先在本機鎖這些失敗案例,再接 Gemini 跑兩輪,看它實際叫了哪些工具。

https://ithelp.ithome.com.tw/upload/images/20261001/20121052hATd6rjpqC.png


2. 設計:五個工具就停

get_profile()
get_pantry()
get_today_context()
get_balance_summary(days)
get_recipe(recipe_id)
工具 回傳 不回傳
get_profile 過敏、廚房、預設策略 病歷、運動處方
get_pantry qty/unit/priority/expire_on 推薦結論
get_today_context 今天吃過的餐、來源、當日策略 30 天逐餐
get_balance_summary 3/5/7 日已編譯訊號 原始日記
get_recipe 指定 id 的食材、步驟、設備 相似食譜

策略不另開一支工具。長期預設在 get_profile,當天啟用的在 get_today_context,避免兩份真相。get_recipe 只讀已知 id,不做向量搜尋;拿回來的食譜仍要過爐口與設備。

產品之後是:登入 uid → 工具讀該使用者的 LifeFlow → Gemini → validate。評測可以餵同一份 fixture 重播。兩條路的 compile 與 validate 是同一套,不能因為評測沒有 live tool call 就把門禁拿掉。


3. 實作:宣告窄,uid 不進參數

程式拆成五個檔,後面提到檔名時對照這張表:

檔案 負責
declarations.js 五支工具的宣告
dispatch.js 驗參數、依登入 uid 讀資料
guard.js 伺服器自己 compile、validate、幻覺檢查
test.js 七項本機測試,不打 API
live.js 真的打 Gemini,跑 Function Calling 來回

get_profile 回傳固定長這樣。過敏沿用這系列的 crustacean,不是另起一組花生:

{
  "hard_constraints": {
    "allergens": ["crustacean"],
    "forbidden_ingredients": []
  },
  "kitchen": {
    "equipment": ["induction_cooktop", "deep_wok",
      "tamagoyaki_pan", "microwave"],
    "burners": 1
  },
  "default_strategy_ids": ["leftover_first", "meal_balance_recent"]
}

get_balance_summary 的 days 只允許 3、5、7:

{
  name: "get_balance_summary",
  parameters: {
    type: "object",
    properties: { days: { type: "integer", enum: [3, 5, 7] } },
    required: ["days"],
  },
}

宣告裡寫了 enum,模型還是可能傳 4,所以 dispatch.js 在讀資料之前自己再驗一次。uid 不在任何一支工具的參數裡,模型若硬塞,也是在這裡擋掉:

function argError(name, args) {
  if (args && Object.prototype.hasOwnProperty.call(args, "uid")) {
    return fail("UID_NOT_ALLOWED", "uid 由登入 session 決定,不接受模型傳入");
  }
  const decl = toolDeclarations.find((item) => item.name === name);
  for (const key of decl?.parameters?.required ?? []) {
    if (args?.[key] == null) return fail("ARG_MISSING", `缺少 ${key}`);
  }
  if (name === "get_balance_summary") {
    const allowed = decl.parameters.properties.days.enum;
    if (!allowed.includes(args.days)) {
      return fail("ARG_ENUM", `days=${args.days} 不在 ${allowed.join(",")}`);
    }
  }
  return null;
}

export function callTool({ sessionUid, name, args = {} }) {
  if (!TOOL_NAMES.has(name)) return fail("UNKNOWN_TOOL", name);
  const rejected = argError(name, args);
  if (rejected) return rejected;
  const life = lifeByUid[sessionUid];
  // ...依 name 回傳 life.profile/life.pantry/life.today 等
}

重點在 callTool 的簽名:sessionUid 和 args 是分開的兩個參數。資料永遠用 sessionUid 去查,args 只拿來決定「查哪一份」,所以模型傳什麼 uid 都碰不到別人的 pantry。

伺服器讀的是登入者自己的 profile。模型完全沒叫 get_profile,allergy 仍在 hard_constraint_ids 裡:

export function compileAuthoritative(profile, today) {
  const compiled = compileStrategies(profile, v1Catalog, null, {
    active_strategy_ids: ["allergy", "equipment", "budget_cap", "time_cap",
      ...(today?.active_strategy_ids ?? [])],
  });
  return {
    compiled,
    context: {
      blocked_allergen_groups: profile?.hard_constraints?.allergens ?? [],
      allowed_equipment: profile?.kitchen?.equipment ?? [],
      burners: profile?.kitchen?.burners ?? 1,
    },
  };
}

4. 本機驗證:先鎖七項守門契約

接 Gemini 之前,先在本機執行:

node test.js

七項測試全過,最後一行是「這輪沒有呼叫 Gemini」:

https://ithelp.ithome.com.tw/upload/images/20261001/20121052F1vAUiOdHa.png

https://ithelp.ithome.com.tw/upload/images/20261001/20121052SttSmq4II6.jpg

測什麼 結果
工具只有五支 PASS
days=4 → ARG_ENUM PASS
沒叫 get_pantry 卻聲稱豆腐 → no_hallucination=0 PASS
arguments 帶別人的 uid → UID_NOT_ALLOWED,自己的庫存仍是豆腐與雞蛋 PASS
two_burner_soup 在單口爐 → BURNER_CONCURRENCY_EXCEEDED PASS
food_source=eat_out → pantry_deducted=false PASS
tool call 是空的,compile 仍含 allergy,過敏原是 crustacean PASS

這七項不打 API。昨天說規則還沒穩就不要燒配額,所以失敗案例先留在本機。契約過了,才把 Gemini 接上。


5. 接上 Gemini:live.js 怎麼寫、實際跑出什麼

Function Calling 聽起來像模型「自己去開冰箱」,其實它碰不到冰箱。Gemini 只會回一段結構化的請求:我要叫 get_pantry,參數是 {}。真正去讀資料的是 live.js,讀完再把結果交回去。整支程式就是在跑這個來回,下面照順序拆成宣告、送出、執行三步,最後實際跑一次。

5.1 宣告:把第 3 節的五支工具交給 Gemini

declarations.js 已經寫好五支工具的 name、description、parameters。送給 Interactions API 之前,每一支補上 type: "function":

const tools = toolDeclarations.map((item) => ({
  type: "function",
  name: item.name,
  description: item.description,
  parameters: item.parameters,
}));

description 是模型決定要不要叫這支的主要依據,所以每支都寫了「不回什麼」。parameters 是 JSON Schema,模型會照著它填參數,但填錯時是第 3 節的 argError 在擋。

5.2 送出:對話紀錄和工具一起送

const response = await fetch(
  "https://generativelanguage.googleapis.com/v1beta/interactions",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-goog-api-key": process.env.GEMINI_API_KEY,
    },
    body: JSON.stringify({
      model: "gemini-3.8-flash",
      store: false,
      input: history,
      tools,
    }),
  }
);
const interaction = await response.json();

history 一開始只有一筆 user_input。之後每一輪,模型回的 steps 和我們補上的 function_result 都接在後面,下一次整串再送出去。store: false 是不讓這段對話留在 Google 端,狀態由我們自己帶。

金鑰從 .env 讀,用 x-goog-api-key 帶上,不寫進程式碼。今天 gemini-3.8-flash 回了兩次 high demand,所以實際的 live.js 外面多包一層重試,連續忙碌才改打 gemini-3.5-flash,工具宣告不變。

5.3 執行:模型提出呼叫,伺服器決定怎麼跑

回傳的 steps 裡如果有 type: "function_call",就由我們執行,再把結果用同一個 call_id 接回去:

const calls = steps.filter((step) => step.type === "function_call");

for (const step of calls) {
  const result = callTool({
    sessionUid: SESSION_UID,
    name: step.name,
    args: step.arguments ?? {},
  });
  history.push({
    type: "function_result",
    name: step.name,
    call_id: step.id,
    result: [{ type: "text", text: JSON.stringify(result.data ?? result) }],
  });
}

sessionUid 是我們傳的,args 才是模型傳的,這就是第 3 節 callTool 把兩者分開的用意。

補完結果再送一次 API。模型可能繼續叫下一支,也可能直接回文字;steps 裡沒有 function_call 就代表這一輪結束。live.js 最多來回 4 次,超過就停,免得模型一直叫工具燒配額。

模型回的文字也不是終點。只要它讀過 two_burner_soup,live.js 就把那份食譜另外送進 validate,不管模型怎麼描述:

const recipeCall = turn.called.find(
  (row) => row.name === "get_recipe" && row.arguments?.recipe_id === "two_burner_soup"
);
if (recipeCall) {
  const recipe = callTool({ sessionUid: SESSION_UID, name: "get_recipe",
    args: { recipe_id: "two_burner_soup" } });
  const verdict = validateRecipeOption(recipe.data, context);
}

5.4 實際跑一次

金鑰放進本機 .env 的 GEMINI_API_KEY 後執行:

node live.js

live.js 送兩段 prompt:第一段讓它自己讀冰箱推薦一道菜,第二段指定讀兩爐食譜。模型是文件上的 gemini-3.8-flash,它先回了兩次 high demand,第三次才把工具呼叫吐出來。

https://ithelp.ithome.com.tw/upload/images/20261001/20121052ETZBKTAy1R.jpg

第一輪它叫了 get_pantry、get_today_context、get_profile,再叫兩次 get_recipe:tofu_scramble 和 two_burner_soup。對照 5.3 的迴圈,這五次都是模型提出、callTool 執行,參數裡沒有 uid。

https://ithelp.ithome.com.tw/upload/images/20261001/20121052lreBSs80AL.jpg

回覆對得上冰箱:半盒要先用的豆腐、雞蛋、一個深鍋、約十分鐘,也寫了單口爐。它在同一輪也讀過 two_burner_soup,所以上面那段檢查被觸發,得到 hard_ok: false;這個 false 是兩爐食譜的判定,不是豆腐蛋炒的。用量 total_tokens=1118,其中思考 369、回答 139。

https://ithelp.ithome.com.tw/upload/images/20261001/20121052MfClsf23cs.jpg

第二輪指定讀 two_burner_soup。get_recipe 成功,模型接著寫:煮湯的同時用平底鍋煎蛋,再同步起鍋。講得很順,但 validate 照樣回 BURNER_CONCURRENCY_EXCEEDED,和第 4 節本機測試的結果一樣。這一輪 685 token,思考 215、回答 74。

這只是今天這一次它叫了什麼,不是「模型以後都會自己拿齊」。


6. 那 MCP 呢?

Day 10 在 Antigravity 裡接過 Stitch MCP,今天又寫 Function Calling,兩個都在講「讓模型叫工具」,很容易以為是兩個競爭方案。實際上它們管的是不同層。

https://ithelp.ithome.com.tw/upload/images/20261001/20121052WER9r9dgQk.png

Function Calling 是模型 API 的能力。 你把工具宣告交給模型,模型回一段結構化的 function_call,執行和回傳都是你的程式負責。第 5 節整段就是它,工具的實作、權限、錯誤碼全部在自己的伺服器上。

MCP(Model Context Protocol)是工具怎麼被發現、怎麼連線的協定。 工具包成一個 MCP Server,Host(Cursor、Antigravity 這類 IDE 或 Agent)裡的 MCP Client 先 tools/list 問有哪些工具,需要時再 tools/call。底層是 JSON-RPC 2.0,本機走 stdio,遠端走 Streamable HTTP。好處是同一個 Server 寫一次,不同 Host 都能接。

兩者會疊在一起用:Host 拿到工具清單後,裡面的模型多半還是透過 function calling 提出「我要叫哪支」,只是執行那一步改成透過 MCP 轉給 Server。

所以 DishFlow 的問題不是選哪個比較強,而是今天這個場景需不需要「讓別的 Host 也能接」:

場景 用什麼 原因
使用者在 DishFlow 問今天吃什麼 Function Calling 只有自己的後端會叫這五支工具;uid 從 session 拿,執行和 validate 在同一個地方
開發時在 IDE 裡讓 Agent 讀設計稿 MCP Day 10 的 Stitch,工具是別人寫好的,接上就能用
之後若想讓外部 Agent 查自己的庫存 可能再包一層 MCP Server 那時要另外處理授權,validate 一樣不能省

今天 runtime 只有一個呼叫端,多包一層 MCP 只會多一段要維護的連線和授權,換不到東西。


7. 今日結論

  1. 工具先停在五支。 多一支,trace 就多一種可以叫錯的方式。
  2. 本機先鎖失敗案例,再打一輪 API。 gemini-3.8-flash 忙了兩次才回應;兩輪的思考 token 都比回答多。
  3. 叫到工具只表示資料有回來。 過敏、爐口、外食扣不扣庫存,仍是伺服器上的規則。即使 Gemini 把兩爐食譜講得很順,validate 還是可以擋。
  4. Function Calling 和 MCP 不是二選一。 現在只有自己的後端在叫工具,所以 runtime 用 Function Calling;MCP 留在開發,等真的有外部 Agent 要接再說。

下一篇: 把 Rule → Gemini → validate 串成一條決策流,並把「採用」和「煮完扣庫存」分開。


附錄 A:五支工具的參數

get_profile() / get_pantry() / get_today_context()    無參數
get_balance_summary(days)        days ∈ 3,5,7
get_recipe(recipe_id)            已知字串 id;未知 id 回 RECIPE_NOT_FOUND

附錄 B:trace 以後要留的欄位

request_id、模型版號、tool name、arguments、錯誤碼、最終判定。uid 與整份 LifeFlow 不印進公開 log。


上一篇
[Day 15] 策略都接上了,會不會反而比貼 Prompt 更麻煩?
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言