iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Build on Google AI

30 天玩轉 Google AI 全家桶:初學者的隨身 Coding 助理養成記系列 第 17 篇

Day 17:定義高強韌流程:用 defineFlow 封裝代碼分析邏輯

  • 分享至 

  • xImage
  •  

昨天的 reviewTraceFlow 只是為了讓 trace 有東西可看,輸入輸出都是 z.string(),出錯時只能看終端機一長串堆疊。今天正式拆解 defineFlow,把它從「能跑」變成「跑得穩」。

今天做三件事:

1. 搞懂 flow 跟普通 async 函式差在哪
2. 把輸入、輸出都鎖成 Zod schema,順便兌現昨天「一次抓出全部 Bug」的伏筆
3. 加上重試與錯誤處理,故意製造失敗,看 UI 怎麼顯示

今天沿用 genkit-day15/ 沙盒,只新增 genkit-flow.js,不動 devpulse/。


一、Flow 是什麼:給函式加上「契約」

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(() =>![https://ithelp.ithome.com.tw/upload/images/20261001/20162649DtmEKNJhkX.png](https://ithelp.ithome.com.tw/upload/images/20261001/20162649DtmEKNJhkX.png)
        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

四、在 Developer UI 測三種情況

打開 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 全綠 https://ithelp.ithome.com.tw/upload/images/20261001/201626492AXvNOQuFE.png
② 壞輸入 把 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 https://ithelp.ithome.com.tw/upload/images/20261001/20162649vuZswLZXP6.png
③ 模型端失敗 暫時把模型名稱改成 gemini-flash-lates(少一個 t),存檔後重啟 genkit start 不會重試,直接丟出包裝後的錯誤 https://ithelp.ithome.com.tw/upload/images/20261001/20162649e7izCDa85l.png

③ 要用「改錯模型名稱」,是因為 503 無法隨時重現。這類錯誤不是暫時性的,isRetryable 會回傳 false,直接丟出,不會白等 6 秒。

注意: genkit start -- node genkit-flow.js 沒加 --watch,改檔案不會自動重載。改完模型名稱要先 Ctrl+C 再重新啟動,否則 trace 裡顯示的仍是舊的 gemini-flash-latest。

意外收穫:兩次真實的 503

測試時剛好遇到 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 注入) 六筆全有
漏掉的是 — https://ithelp.ithome.com.tw/upload/images/20261001/20162649UNqURodOQj.png

限制: 只跑一次,且模型輸出本來就有隨機性,這只能當觀察,不是準確率。


今日學習踩坑小記

  • 改了檔案卻沒生效:genkit start 沒加 --watch,把模型名稱改錯後直接在 UI 按 Run,trace 顯示的還是 gemini-flash-latest。要先 Ctrl+C 重啟才會載入新檔案。
  • 驗證失敗的訊息是英文,而且很長:會把整份 JSON Schema 一起印出來,但關鍵只有一行 language: must be equal to one of the allowed values。
  • 重試只管次數,不管單次等多久:長輸入的第一次嘗試等了將近 58 秒才失敗。目前的 withRetry 沒有單次逾時,之後覺得太慢再來處理。
  • 【你自己的補充】

明日預告

Genkit 三部曲(認識、觀測、封裝)到今天告一段落。明天換個口味,不碰後端:Day 18:用嘴巴切版,Google Stitch 介面生成器上手初體驗,為 DevPulse 的儀表板打底。


參考資料


上一篇
Day 16:開發者福利:善用 Genkit Developer UI 觀察每一次 AI 呼叫
系列文
30 天玩轉 Google AI 全家桶:初學者的隨身 Coding 助理養成記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言