TL;DR
把 action-maker 從假資料升級為 Cloudflare Workers AI 即時生成,架構拆成 Worker(純 AI)、Server(存資料)、Frontend(串接),踩了 Qwen3 thinking block 和 Workers AI response 格式兩個坑。
為什麼要做這件事
島島阿學有一個 action-maker 功能,用戶選一個分類(興趣、健康、學習…)輸入目標,系統生成三個難度的行動建議(初學/中級/進階)。問題是:這些建議是假的。前端 setTimeout(800ms) 之後回傳寫死的靜態資料,每次看到一樣的東西。
要讓它變真的,需要接 AI。但不只是接上去就好——用戶選完行動之後呢?以前是到結果頁就死路了,只能分享或重玩。真正該做的是讓用戶可以直接從結果「開始實踐」,建立一筆 practice 到 DB 裡追蹤。
同時還有一個場景:用戶選「我想自己設定」的時候,填了一個粗略的想法(「每天練吉他」),AI 應該要能幫他潤色成更具體的版本(「每天 15 分鐘練習 C、G、Am、F 四個和弦轉換,搭配節拍器從 60 BPM 開始」),但最終決定權在用戶手上。
所以這次要做三件事:AI 生成、AI 協作潤色、建立 practice 串接。
架構:誰該負責什麼
最關鍵的決策是 Worker 和 Server 的分工。三個方案擺在面前:
- Worker 直連 DB:少一跳,但 Worker 要處理 DB 邏輯,跟 Server 重複
- Worker 呼叫 Server 存資料:Worker 多一個 HTTP call,但職責單一
- Worker 只管 AI,存資料全交給前端和 Server:最簡單,但前端不一定有 user_id(生成不需登入)
選了第三個的變體:Worker 生成完後,自己背景呼叫 Server internal API 存紀錄(不阻塞回應),前端負責建立 practice 和回報用戶互動。
Frontend
├─ POST Worker /action-maker/generate → AI 生成 3 個 actions
├─ POST Worker /action-maker/refine → AI 潤色自訂 action
├─ POST Server /api/v1/practices → 建立 practice(需登入)
└─ PATCH Server /api/v1/ai-generations → 回報用戶選了什麼
Worker
├─ Workers AI (Qwen3) → 生成內容
├─ Langfuse → 追蹤每次 AI call
└─ POST Server /api/internal/ai-generations → 存紀錄(背景、5s timeout)
Server
├─ POST /api/internal/ai-generations → Worker 存紀錄(API Key 驗證)
├─ PATCH /api/v1/ai-generations/:sessionId → 前端回報互動(JWT 驗證)
└─ POST /api/v1/practices → 既有的建立 practice API
核心原則:Worker 不碰 DB,不碰認證,只管「問 AI、拿答案」。存資料的事讓 Server 做,它本來就在做這件事。
DB 設計:通用的 ai_generations
不只為 action-maker 設計,而是建了一張通用的 ai_generations table,用 feature + action_type 區分:
CREATE TABLE ai_generations (
id SERIAL PRIMARY KEY,
external_id UUID UNIQUE DEFAULT gen_random_uuid(),
feature VARCHAR(50) NOT NULL, -- 'action-maker', 未來 'checkin-encourage'
action_type VARCHAR(20) NOT NULL, -- 'generate', 'refine'
session_id VARCHAR(64), -- 前端生成的 UUID,串聯整個流程
ip_hash VARCHAR(16), -- SHA-256 前 8 bytes,不存原始 IP
user_id INT REFERENCES users(id), -- nullable,生成時可能未登入
status VARCHAR(20) DEFAULT 'success',
input JSONB NOT NULL,
output JSONB, -- nullable,AI 失敗時沒有 output
model VARCHAR(100),
latency_ms INT,
user_interaction JSONB, -- 前端後續回報:選了什麼、有沒有建立 practice
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
input/output 用 JSONB,不同 feature 結構不同但不用改 schema。session_id 讓同一用戶的 generate → refine → 建立 practice 可以串在一起分析。
之後想知道「AI 生了什麼 → 用戶選了什麼 → 有沒有真的去做」,一條 query 就搞定。
Worker 實作:Hono + Workers AI
Tech stack 很輕量:Hono 做路由、Cloudflare KV 做 rate limit、Workers AI 跑推論。
Rate Limit
用 factory pattern,generate 和 refine 共用同一個計數池:
const rateLimiter = createRateLimiter("action-maker");
actionMakerRouter.post("/generate", rateLimiter, async (c) => { ... });
actionMakerRouter.post("/refine", rateLimiter, async (c) => { ... });
每個 IP 10 分鐘 5 次(generate + refine 合計)。KV 存 { count, resetAt } 加上 TTL 自動過期。
Prompt 設計
system prompt 指定 JSON 結構、字數限制、規則。支援 zh-TW 和 en 兩種 locale。關鍵是要夠具體:
你必須只回傳合法的 JSON 物件,不得包含 markdown、解釋文字或任何其他內容。
JSON 必須符合以下結構:
{
"actions": [
{
"id": "<categoryId>-beginner-001",
"categoryId": "<categoryId>",
"level": "beginner",
"locked": false,
"title": "簡潔具體的行動標題",
...
}
]
}
固定回傳 3 個行動(beginner、intermediate、advanced 各一)
refine 的 prompt 則是「保留用戶核心意圖,根據等級調整難度」,輸入用戶的粗略想法,輸出完善版本。
踩坑一:Qwen3 的 thinking block
Qwen3 是個 thinking model,會在正式回答前先「思考」:
<think>
用戶想學吉他,我應該根據不同等級設計行動...
初學者可以從基礎和弦開始...
</think>
{
"actions": [...]
}
直接 JSON.parse() 一定炸。解法是先 strip 掉 <think> block:
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
然後先嘗試直接 parse,失敗再用 regex 提取:
let parsed;
try {
parsed = JSON.parse(cleaned);
} catch {
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("No JSON found");
parsed = JSON.parse(jsonMatch[0]);
}
踩坑二:Workers AI 的 response 格式
文件說 Workers AI 回傳 { response: "..." },實測回來的是 OpenAI-compatible chat completion 格式:
{
"id": "chatcmpl-xxx",
"choices": [{
"message": {
"role": "assistant",
"content": "<think>...</think>\n{...}"
}
}]
}
兩種格式都要處理:
let text: string;
if (typeof response === "string") {
text = response;
} else if (typeof response === "object" && response !== null) {
const r = response as Record<string, unknown>;
if ("response" in r) {
text = String(r.response);
} else if ("choices" in r && Array.isArray(r.choices)) {
const choices = r.choices as Array<{ message?: { content?: string } }>;
text = choices[0]?.message?.content ?? "";
} else {
text = JSON.stringify(response);
}
} else {
text = JSON.stringify(response);
}
不判斷的話,String(response) 會得到 [object Object],然後 JSON.parse 噴 “No JSON found”。debug 的時候還以為是 AI 沒回東西,其實回了,只是包在另一層裡。
前端:從 mock 到真實 AI
原本的 hook:
// 假裝等 0.8 秒
await new Promise((r) => setTimeout(r, 800));
const fallback = getFallbackActions(input.category);
setActions(fallback);
改成:
const response = await fetch(`${WORKER_URL}/action-maker/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category, topic, tags, locale, session_id }),
signal: controller.signal,
});
Worker 掛了就 fallback 回靜態資料,用戶不會感覺到差異。加了 isFallback flag 讓 UI 可以顯示「這是預設建議」。
AI 協作自訂
「我想自己設定」的流程從單純填表變成四步:
- 選強度 — 初學 / 中級 / 進階
- 填想法 — 標題 + 描述
- AI 潤色 — 呼叫
/action-maker/refine,或跳過直接用原本的 - 比較選擇 — 採用 AI 版 / 自己修改 / 用原本的
用戶永遠有最終決定權。AI 只是提供一個更具體的版本參考。
結果頁建立 practice
結果頁加了「開始實踐」按鈕。沒登入會彈登入框,登入後自動建立 practice:
const { data } = await createPractice({
title: result.action.title,
practiceAction: result.action.description,
otherContext: result.triggerTiming,
tags: [result.category],
startDate: new Date().toISOString().split("T")[0],
durationDays: 14,
frequencyMinDays: 1,
frequencyMaxDays: 1,
});
建完跳轉到 practice 頁面。用戶從「看到建議」到「開始追蹤」一氣呵成。
安全面
幾個注意到的點:
- Internal API Key 用 timing-safe 比較:
crypto.timingSafeEqual防 timing attack - IP 不存原文:SHA-256 取前 8 bytes,只做統計用
- PATCH 端點防越權:只允許更新
user_id為 null 或屬於自己的 row - AI 回傳的
locked強制覆寫為false:不信任 AI 的欄位值 - Input 截斷:topic max 100、title max 30、description max 200、tags max 10 items each max 20 chars
整體來說
這次的核心取捨是把 Worker 做得極薄——只管 AI 呼叫,不碰 DB、不碰認證。好處是 Worker 邏輯簡單、部署快、容易測試(12 個 vitest 跑不到 2 秒)。壞處是多了一跳 server-to-server call 存紀錄,但用 waitUntil 背景執行不影響回應速度。
Qwen3 作為免費的 Workers AI 模型表現不錯,JSON 結構遵從度高,但 thinking block 是一定要處理的。如果之後換模型,只要改一行 AI_MODEL 常數。
從產品角度,action-maker 從一個「玩完就沒了」的工具變成了「玩完可以直接開始做」的入口。AI 生成 → 選擇行動 → 建立 practice → 每天打卡追蹤,閉環了。