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。
B1 靠人掃一眼後手打,B2 靠人貼當日完整 JSON。C 的差異應該是 DishFlow 自己讀 LifeFlow,不是叫使用者貼得更勤勞。
但 Function Calling 有兩件容易混在一起的事:
工具:把授權資料取回來
規則:決定哪些方案永遠不合格
模型可能少叫工具、叫錯參數,或拿到 pantry 之後仍寫一個冰箱裡沒有的食材。所以「有呼叫 get_pantry」只代表 trace 裡出現過這支函式,不代表 hard_ok=true。

左邊是這一輪 Gemini 自己叫的工具,最後推薦豆腐蛋炒。右邊是另一輪:工具把兩爐食譜交出去之後,模型寫成煮湯的同時用平底鍋煎蛋,validate 仍是 hard_ok=false。
uid 只從登入 session 來。五支工具各自回答一件事,參數不對就在進資料前拒絕。伺服器自己 compile 過敏與設備,不看模型有沒有叫 get_profile。食譜再走 Day 12 的 validate;外食不扣 pantry。node test.js 先在本機鎖這些失敗案例,再接 Gemini 跑兩輪,看它實際叫了哪些工具。

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 就把門禁拿掉。
程式拆成五個檔,後面提到檔名時對照這張表:
| 檔案 | 負責 |
|---|---|
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,
},
};
}
接 Gemini 之前,先在本機執行:
node test.js
七項測試全過,最後一行是「這輪沒有呼叫 Gemini」:


| 測什麼 | 結果 |
|---|---|
| 工具只有五支 | 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 接上。
Function Calling 聽起來像模型「自己去開冰箱」,其實它碰不到冰箱。Gemini 只會回一段結構化的請求:我要叫 get_pantry,參數是 {}。真正去讀資料的是 live.js,讀完再把結果交回去。整支程式就是在跑這個來回,下面照順序拆成宣告、送出、執行三步,最後實際跑一次。
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 在擋。
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,工具宣告不變。
回傳的 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);
}
金鑰放進本機 .env 的 GEMINI_API_KEY 後執行:
node live.js
live.js 送兩段 prompt:第一段讓它自己讀冰箱推薦一道菜,第二段指定讀兩爐食譜。模型是文件上的 gemini-3.8-flash,它先回了兩次 high demand,第三次才把工具呼叫吐出來。

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

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

第二輪指定讀 two_burner_soup。get_recipe 成功,模型接著寫:煮湯的同時用平底鍋煎蛋,再同步起鍋。講得很順,但 validate 照樣回 BURNER_CONCURRENCY_EXCEEDED,和第 4 節本機測試的結果一樣。這一輪 685 token,思考 215、回答 74。
這只是今天這一次它叫了什麼,不是「模型以後都會自己拿齊」。
Day 10 在 Antigravity 裡接過 Stitch MCP,今天又寫 Function Calling,兩個都在講「讓模型叫工具」,很容易以為是兩個競爭方案。實際上它們管的是不同層。

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 只會多一段要維護的連線和授權,換不到東西。
gemini-3.8-flash 忙了兩次才回應;兩輪的思考 token 都比回答多。下一篇: 把 Rule → Gemini → validate 串成一條決策流,並把「採用」和「煮完扣庫存」分開。
get_profile() / get_pantry() / get_today_context() 無參數
get_balance_summary(days) days ∈ 3,5,7
get_recipe(recipe_id) 已知字串 id;未知 id 回 RECIPE_NOT_FOUND
request_id、模型版號、tool name、arguments、錯誤碼、最終判定。uid 與整份 LifeFlow 不印進公開 log。