昨天的 reviewTraceFlow 只是為了讓 trace 有東西可看,輸入輸出都是 z.string(),出錯時只能看終端機一長串堆疊。今天正式拆解 defineFlow,把它從「能跑」變成「跑得穩」。
今天做三件事:
1. 搞懂 flow 跟普通 async 函式差在哪
2. 把輸入、輸出都鎖成 Zod schema,順便兌現昨天「一次抓出全部 Bug」的伏筆
3. 加上重試與錯誤處理,故意製造失敗,看 UI 怎麼顯示
今天沿用 genkit-day15/ 沙盒,只新增 genkit-flow.js,不動 devpulse/。
defineFlow 就是把一個 async 函式包起來,包完之後多了三樣東西:
| 普通 async 函式 | defineFlow |
|
|---|---|---|
| 輸入輸出檢查 | 自己寫 if 判斷 |
宣告 Zod schema,自動檢查 |
| 執行紀錄 | 自己 console.log |
自動產生 trace(Day 16) |
| 測試方式 | 寫測試腳本 | Developer UI 直接填表單執行 |
今天的重點是第一列:讓「壞輸入」和「壞輸出」在邊界就被擋下來。
輸入 ─► [inputSchema] ─► build-prompt ─► generate(含重試)─► [outputSchema] ─► 輸出
擋壞輸入 擋 503 這類暫時性錯誤 擋壞輸出
先定義三個 schema:
import "dotenv/config";
import { genkit, z, GenkitError } from "genkit";
import { googleAI } from "@genkit-ai/google-genai";
const ai = genkit({
plugins: [googleAI()],
model: googleAI.model("gemini-flash-latest"),
});
const InputSchema = z.object({
code: z.string().min(1).max(4000),
language: z.enum(["javascript", "python"]).default("javascript"),
});
const IssueSchema = z.object({
title: z.string().describe("問題的一句話標題"),
severity: z.enum(["low", "medium", "high"]),
explanation: z.string().describe("給初學者的白話原因,50 字內"),
});
const OutputSchema = z.object({ issues: z.array(IssueSchema) });
三個值得看懂的地方:
1. min(1).max(4000):空字串或超長檔案在進入 flow 前就被拒絕,不會浪費一次模型呼叫。
2. z.enum:severity 只能是三個值,之後儀表板(Day 29)可以直接依等級上色。
3. .describe():這段文字會跟著 schema 一起送給模型,等於替每個欄位附上說明。
昨天的 503 是「過一下再試就好」的暫時性錯誤,所以只對這類錯誤重試,其他錯誤直接丟出去:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const isRetryable = (err) =>
err?.status === "UNAVAILABLE" || /503|overloaded/i.test(err?.message ?? "");
async function withRetry(fn, retries = 2) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
if (i >= retries || !isRetryable(err)) throw err;
await sleep(2000 * (i + 1)); // 等 2 秒、4 秒
}
}
}
接著是 flow 本體:
export const analyzeCodeFlow = ai.defineFlow(
{ name: "analyzeCodeFlow", inputSchema: InputSchema, outputSchema: OutputSchema },
async ({ code, language }) => {
const prompt = await ai.run("build-prompt", async () =>
`請審查下面的 ${language} 程式碼,列出所有問題;沒有問題就回傳空陣列:\n\n${code}`
);
let res;
try {
res = await withRetry(() =>
ai.generate({
system: "你是耐心的 Coding 導師,用繁體中文回答。",
prompt,
config: { temperature: 0.3 },
output: { schema: OutputSchema },
})
);
} catch (err) {
throw new GenkitError({
status: isRetryable(err) ? "UNAVAILABLE" : "INTERNAL",
message: `analyzeCodeFlow 呼叫模型失敗:${err.message}`,
});
}
if (!res.output) {
throw new GenkitError({ status: "INTERNAL", message: "模型沒有回傳符合 schema 的結果" });
}
return res.output;
}
);
跟昨天相比只改了三處:prompt 從「最主要的一個問題」改成「所有問題」、generate 多了 output: { schema }、外面包了 withRetry 和 try/catch。
啟動(一樣不加 --watch):
genkit start -- node genkit-flow.js
打開 http://localhost:4000,到 Flows 選 analyzeCodeFlow。這次 Input 是物件,UI 會依 schema 帶出欄位:
{
"code": "async function sendAll(users) {\n users.forEach(async (u) => {\n await sendMail(u);\n });\n console.log(\"全部寄完\");\n}",
"language": "javascript"
}
| 測試 | 怎麼做 | 預期 | 實測結果 |
|---|---|---|---|
| ① 正常輸入 | 照上面 JSON 執行 | 回傳 issues 陣列,含 forEach + await 的問題 |
回傳 2 筆:forEach 無法等待非同步操作完成(high)、缺少錯誤捕捉機制(medium)。耗時 6.71s(83 in / 82 out / 686 reasoning tokens),trace 全綠 ![]() |
| ② 壞輸入 | 把 language 改成 "ruby" |
在進入 flow 前被 schema 拒絕 | INVALID_ARGUMENT: Schema validation failed,關鍵訊息是 language: must be equal to one of the allowed values。畫面上沒有 View trace,stack 停在 parseSchema,也就沒有 build-prompt ![]() |
| ③ 模型端失敗 | 暫時把模型名稱改成 gemini-flash-lates(少一個 t),存檔後重啟 genkit start |
不會重試,直接丟出包裝後的錯誤 | ![]() |
③ 要用「改錯模型名稱」,是因為 503 無法隨時重現。這類錯誤不是暫時性的,isRetryable 會回傳 false,直接丟出,不會白等 6 秒。
注意:
genkit start -- node genkit-flow.js沒加--watch,改檔案不會自動重載。改完模型名稱要先 Ctrl+C 再重新啟動,否則 trace 裡顯示的仍是舊的gemini-flash-latest。
測試時剛好遇到 Gemini 端的 503,反而把重試機制看得更清楚:
| 情境 | Trace 上的 generate |
結果 |
|---|---|---|
| 極短輸入,連續 503 | 3 次,耗時 11.19s → 4.45s → 2.41s,全部紅燈 | 重試 2 次後放棄,flow 總耗時 24.06s,丟出包裝後的 UNAVAILABLE:analyzeCodeFlow 呼叫模型失敗:…[503 Service Unavailable] This model is currently experiencing high demand |
| 40 行長輸入 | 第 1 次 57.96s 失敗(紅燈)、等 2 秒、第 2 次 13.37s 成功 | flow 總耗時 73.33s,回傳完整 issues,整體綠燈 |
兩列的總耗時,扣掉各次嘗試後剛好剩下等待的 2 秒、4 秒,也就是說 isRetryable 確實抓到了 503,重試不是裝飾。
用 Day 16 同一份約 40 行、含 6 個 Bug 的輸入再跑一次:
| Day 16(最主要的一個問題) | Day 17(issues 陣列) |
|
|---|---|---|
| 抓到的 Bug 數 | 1(SQL 注入) | 六筆全有 |
| 漏掉的是 | — | ![]() |
限制: 只跑一次,且模型輸出本來就有隨機性,這只能當觀察,不是準確率。
genkit start 沒加 --watch,把模型名稱改錯後直接在 UI 按 Run,trace 顯示的還是 gemini-flash-latest。要先 Ctrl+C 重啟才會載入新檔案。language: must be equal to one of the allowed values。withRetry 沒有單次逾時,之後覺得太慢再來處理。Genkit 三部曲(認識、觀測、封裝)到今天告一段落。明天換個口味,不碰後端:Day 18:用嘴巴切版,Google Stitch 介面生成器上手初體驗,為 DevPulse 的儀表板打底。
參考資料