昨天用 Embedding,讓 Gemini 從「回答問題」跨到「成為程式的一部分」。
第二週走到這裡,其實已經寫了六支小腳本:
Day 08 第一次呼叫 API
Day 09 串流
Day 10 JSON
Day 11 Zod 驗證
Day 12 Function Calling
Day 13 Embedding
每一支單獨看都跑得動,但它們現在散落在不同資料夾裡。
今天不學新東西,只做一件事:
回頭把這一週收拾乾淨。
把六天放在一起看,會發現一個規律:
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 | 文字轉向量 |
安裝需要的套件(前幾天都裝過了):
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 時,可以改成其他輸出方式,不用回來改函式本體。
先把 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。