iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Build on Google AI

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

Day 14:【第 2 週小結】API 串接心得:把一週的零散代碼,收成一支 gemini.js

  • 分享至 

  • xImage
  •  

昨天用 Embedding,讓 Gemini 從「回答問題」跨到「成為程式的一部分」。

第二週走到這裡,其實已經寫了六支小腳本:

Day 08  第一次呼叫 API
Day 09  串流
Day 10  JSON
Day 11  Zod 驗證
Day 12  Function Calling
Day 13  Embedding

每一支單獨看都跑得動,但它們現在散落在不同資料夾裡。

今天不學新東西,只做一件事:

回頭把這一週收拾乾淨。


一、這一週,Gemini 的「輸出」一直在變

把六天放在一起看,會發現一個規律:

Day 08–09   文字 → Gemini → 文字            (問答、串流)
Day 10–11   文字 → Gemini → 驗證過的物件     (JSON + Zod)
Day 12      文字 → Gemini → 工具呼叫         (function_call)
Day 13      文字 → Gemini → 一串數字         (向量)

輸入始終是文字,但輸出越來越不是「給人讀的文字」,而是「給程式用的東西」。

這就是第二週最大的轉變:從「跟 AI 聊天」,走向「把 AI 當成程式裡的一個零件」。

每天最值得帶走的一句話,整理成一張表:

Day 主題 帶走的一句話
08 第一次呼叫 API 用 ES Module 就要記得設 "type": "module"
09 Streaming 事件流要過濾 step.delta 且 delta.type 是 text;thinking_level 會影響等待時間
10–11 JSON + Zod response_format 是請求,不是保證;safeParse 才是程式自己的防線
12 Function Calling Gemini 提出呼叫,真正執行的是你的程式
13 Embedding 文字 → 向量 → 相似度,意思接近就是數字接近

二、問題:六個資料夾,六份一樣的開頭

回頭翻這幾天的代碼,每一支都是這樣開場:

import "dotenv/config";
import { GoogleGenAI } from "@google/genai";

const ai = new GoogleGenAI({});

然後各自寫死一個模型名稱,各自處理錯誤,各自組裝請求。

一支腳本這樣寫沒問題。但 DevPulse 之後要組裝成一個工具,總不能每次都從六份代碼裡翻找、複製、貼上。

所以今天的目標很單純:

把 Day 08–13 的五種用法
收成一支 lib/gemini.js

三、五個函式,對應五種用法

專案結構:

devpulse/
├── .env
├── .gitignore
├── package.json
├── lib/
│   └── gemini.js
├── tools/
│   └── localTime.js
└── smoke-test.js

gemini.js 只匯出五個東西:

函式 來自 做什麼
askGemini Day 08 一次問、一次答
streamGemini Day 09 邊收邊印,最後回傳完整文字
askGeminiJSON Day 10–11 結構化輸出 + Zod 驗證
askWithTools Day 12 提出呼叫 → 執行 → 交回結果
embedTexts Day 13 文字轉向量

四、gemini.js 完整程式碼

安裝需要的套件(前幾天都裝過了):

npm install @google/genai dotenv zod compute-cosine-similarity

建立 lib/gemini.js:

import "dotenv/config";
import { GoogleGenAI } from "@google/genai";
import { z } from "zod";
import cosineSimilarity from "compute-cosine-similarity";

// ---------- 全系列只在這裡設定一次 ----------
export const MODEL = "gemini-3-flash-preview";
export const EMBEDDING_MODEL = "gemini-embedding-001";

export const ai = new GoogleGenAI({}); // 自動讀取 GEMINI_API_KEY

// Day 08:基本問答
export async function askGemini(input, { thinkingLevel = "minimal" } = {}) {
  const interaction = await ai.interactions.create({
    model: MODEL,
    input,
    generation_config: { thinking_level: thinkingLevel },
  });
  return interaction.output_text;
}

// Day 09:串流,邊收邊印,最後回傳完整文字
export async function streamGemini(
  input,
  { onText = (t) => process.stdout.write(t) } = {}
) {
  const stream = await ai.interactions.create({
    model: MODEL,
    input,
    generation_config: { thinking_level: "minimal" },
    stream: true,
  });

  let full = "";
  for await (const event of stream) {
    if (event.event_type === "step.delta" && event.delta.type === "text") {
      onText(event.delta.text);
      full += event.delta.text;
    }
  }
  return full;
}

// Day 10–11:結構化輸出 + Zod 驗證
export async function askGeminiJSON(input, schema) {
  const interaction = await ai.interactions.create({
    model: MODEL,
    input,
    generation_config: { thinking_level: "minimal" },
    response_format: {
      type: "text",
      mime_type: "application/json",
      schema: z.toJSONSchema(schema),
    },
  });

  const raw = interaction.output_text;

  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new Error(`Gemini 回傳的不是合法 JSON:\n${raw}`);
  }

  const validation = schema.safeParse(parsed);
  if (!validation.success) {
    throw new Error(
      `Gemini 回傳的資料不符合 Schema:\n${raw}\n` +
        JSON.stringify(validation.error.format(), null, 2)
    );
  }
  return validation.data;
}

// Day 12:Function Calling(單輪:提出呼叫 → 執行 → 交回結果)
export async function askWithTools(input, tools, handlers) {
  const first = await ai.interactions.create({ model: MODEL, input, tools });

  const call = first.steps.find((s) => s.type === "function_call");
  if (!call) return first.output_text; // 模型判斷不需要工具,直接回答

  const handler = handlers[call.name];
  if (!handler) throw new Error(`未知的 function:${call.name}`);

  const result = await handler(call.arguments);

  const final = await ai.interactions.create({
    model: MODEL,
    previous_interaction_id: first.id,
    tools,
    input: [
      {
        type: "function_result",
        name: call.name,
        call_id: call.id,
        result: [{ type: "text", text: JSON.stringify(result) }],
      },
    ],
  });
  return final.output_text;
}

// Day 13:Embedding 與相似度
export async function embedTexts(texts) {
  const response = await ai.models.embedContent({
    model: EMBEDDING_MODEL,
    contents: texts,
    config: { taskType: "SEMANTIC_SIMILARITY" },
  });
  return response.embeddings.map((e) => e.values);
}

export { cosineSimilarity };

這份程式碼有三個刻意的設計:

1. 模型名稱只寫一次。
以後要換模型,只改最上面兩行。

2. 出錯時,把 Gemini 的原始回傳一起丟出來。
這是 Day 11 的教訓:遇到「格式看起來對、內容卻是空的」,第一件事是先看 AI 到底回了什麼。

3. thinking_level: "minimal" 預設寫進去。
這是 Day 9 那場 20 秒延遲抓兇記換來的。askWithTools 則照 Day 12 原樣搬過來,沒有加這個參數。

另外,streamGemini 多了一個 onText 參數。現在預設是印到終端機,之後做 CLI 時,可以改成其他輸出方式,不用回來改函式本體。


五、驗收:一支 smoke-test 跑完五個函式

先把 Day 12 的 getLocalTimeTool 與 getLocalTime 搬進 tools/localTime.js,前面加上 export 就好。

然後建立 smoke-test.js:

import { z } from "zod";
import {
  askGemini,
  streamGemini,
  askGeminiJSON,
  askWithTools,
  embedTexts,
  cosineSimilarity,
} from "./lib/gemini.js";
import { getLocalTimeTool, getLocalTime } from "./tools/localTime.js";

console.log("=== 1. askGemini ===");
console.log(await askGemini("用一句話跟我打招呼,說你是我的 Coding 助理。"));

console.log("\n=== 2. streamGemini ===");
await streamGemini("用 30 字解釋什麼是 Streaming。");
console.log();

console.log("\n=== 3. askGeminiJSON ===");
const Analysis = z.object({ issue: z.string(), suggestion: z.string() });
console.log(
  await askGeminiJSON(
    "分析這段程式碼,找出最主要的問題:\nconst user = {};\nconsole.log(user.profile.name);",
    Analysis
  )
);

console.log("\n=== 4. askWithTools ===");
console.log(
  await askWithTools("台灣現在幾點?", [getLocalTimeTool], {
    get_local_time: (args) => getLocalTime(args?.timezone || "Asia/Taipei"),
  })
);

console.log("\n=== 5. embedTexts ===");
const [a, b] = await embedTexts([
  "function calculateTotal(price, quantity) { return price * quantity; }",
  "function getAmount(unitPrice, count) { return unitPrice * count; }",
]);
console.log("相似度:", cosineSimilarity(a, b).toFixed(4));

執行:

node smoke-test.js

可以看到類似:

=== 1. askGemini ===
哈囉!我是你的 Coding 助理……

=== 2. streamGemini ===
(文字逐字印出)

=== 3. askGeminiJSON ===
{ issue: '……', suggestion: '……' }

=== 4. askWithTools ===
台灣目前是晚上 X 點 X 分左右。

=== 5. embedTexts ===
相似度: 0.9xxx

實際內容會依模型與執行時間而不同。重點不是內容,而是五個區塊全部跑得通,而且每一個都是從同一支 gemini.js 匯入的。

(👉 這裡貼你自己的終端機截圖)


今日學習踩坑小記

把六篇代碼搬進同一個專案時,才發現同一個系列裡,模型名稱居然寫了三種:Day 8 是 gemini-3.5-flash,Day 9 和 Day 11 是 gemini-3-flash-preview,Day 12 又是 gemini-3.8-flash。

每篇各自是獨立小專案,所以各自跑得通;一旦要合併,就得決定「到底以哪個為準」。

原來共用函式庫最實際的價值,不是省幾行程式碼,而是讓「該統一的東西只存在一個地方」。


六、第二週小結,也是第三週的入口

第二週結束了。

DevPulse 還沒有一個完整的「診斷代碼」功能,但零件已經齊了:

能問        askGemini
能串流      streamGemini
能拿到可驗證的 JSON   askGeminiJSON
能呼叫工具   askWithTools
能比較語意   embedTexts

不過今天這支 gemini.js,其實是我們手寫的版本:自己組請求、自己驗證、自己處理工具流程。

那麼問題來了:

Google 有沒有專門的框架,把這些「流程」本身也包起來,甚至還附一個看得到每次 AI 呼叫的介面?

明天開始進入第三週,來認識這個工具:

Day 15:Firebase Genkit。


上一篇
Day 13:語意搜尋入門:向量魔法!用 Gemini Embedding 比對兩段程式碼
下一篇
Day 15:後端新工具!認識 Firebase Genkit:Google 專為 AI 打造的現代化工作流框架
系列文
30 天玩轉 Google AI 全家桶:初學者的隨身 Coding 助理養成記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言