結論先講
AI 應用比傳統 CRUD 應用多出三種資料需求,而多數專案在第一版都只做了第一種:
| 需求 | 回答什麼問題 | 沒做的後果 |
|---|---|---|
| 生成結果 | 使用者產出了什麼 | —(這個大家都會做) |
| 生成脈絡 | 這筆內容當初是用什麼條件產生的 | 無法分析哪組條件成效好,也無法重現 |
| 稽核軌跡 | 誰、在什麼時候、用了多少 token、花了多久 | 帳單來了才發現超支,且查不出是誰 |
具體落到資料結構,就是三張表:使用者(含額度)、生成結果(含脈絡)、生成日誌(含用量)。以下是每張表的實際欄位與理由。
表一:users —— 比一般會員表多一塊「額度」
// Collection: users
// Document ID: {uid}(來自 Auth 服務)
{
"uid": "7a8b9c...",
"email": "editor@example.com",
"display_name": "Pro Marketer",
"created_at": "Timestamp",
// AI 專屬:限流與計費。一般會員表沒有這塊
"credits": {
"total_generated": 150,
"remaining_balance": 500,
"subscription_tier": "pro"
},
// 使用者偏好,用來預填生成條件
"preferences": {
"default_industry": "美妝",
"default_platform": "Instagram"
}
}
credits 這塊是關鍵。 AI 應用的邊際成本不是零——每一次生成都在花錢。如果沒有一個地方記錄「這個人還剩多少」,你只有兩個選擇:完全不限制(然後被單一使用者打爆帳單),或是每次都去查日誌加總(然後每次生成都多一次昂貴查詢)。
total_generated 與 remaining_balance 要分開存:前者是累計統計,後者是可扣減的餘額。合成一個欄位,退款或補償時就會算不清楚。
表二:saved_ads —— 生成結果要跟生成脈絡綁在一起
// Collection: saved_ads
// Document ID: {auto-generated-uuid}
{
"id": "ad_12345...",
"user_id": "7a8b9c...",
// 核心內容:AI 生成的結果
"content": {
"headline": "通勤 40 分鐘,你的早餐只有便利商店?",
"body": "五分鐘備好的舒肥雞胸,冷藏七天不走味。",
"rationale": "以通勤族的時間痛點開場,再給出具體可執行的替代方案。"
},
// 數據指標
"metrics": {
"predicted_ctr": 4.2,
"user_rating": 5
},
// 生成上下文:這一整塊才是真正的差異所在
"generation_context": {
"keyword": "舒肥雞胸",
"industry": "食品",
"emotion": "痛點",
"platform": "Instagram",
"length": "短文案"
},
"tags": ["痛點", "Instagram", "食品"],
"created_at": "Timestamp",
"is_archived": false
}
為什麼 generation_context 值得獨立一塊
沒有它,你的資料庫裡只有一堆孤立的產出。有了它,你可以回答:
- 哪一種情感設定的平均評分最高?
- 同一組關鍵字換平台,產出的差異在哪?
- 使用者最常用哪些條件組合?(這直接告訴你該把哪些選項做成預設值)
這些問題無法事後補。 你不可能回頭猜三個月前那筆文案是用什麼條件生的。
為什麼要有 rationale
讓模型在輸出時一併解釋「為什麼這樣寫」,有兩個實際好處:一是使用者更容易判斷要不要採用,二是當產出品質下滑時,你能從理由看出模型是哪一步想歪了。這個欄位設為選填,成本很低。
表三:generation_logs —— 前端永遠不該能寫入的那張表
// Collection: generation_logs
// Document ID: {auto-generated-uuid}
{
"user_id": "7a8b9c...",
"timestamp": "Timestamp",
"prompt_snapshot": "...", // 當時實際送出的完整提示詞
"model_used": "...", // 模型與版本
"token_usage": {
"prompt_tokens": 120,
"completion_tokens": 350,
"total_tokens": 470
},
"latency_ms": 1450, // 對應非功能性需求的監控
"status": "success" // 或 error,例如觸發內容過濾
}
這張表回答四個問題,每一個都會在上線後三個月內找上門:
| 欄位 | 回答的問題 |
|---|---|
token_usage | 這個月的帳單是誰貢獻的?哪個功能最燒錢? |
latency_ms | 使用者抱怨「很慢」時,是真的慢還是體感問題? |
model_used | 換模型之後,品質與成本各變了多少? |
prompt_snapshot | 這次產出很怪,當時到底送了什麼給模型? |
三條寫死的規則
- 前端不得直接寫入。 這張表若能被前端寫,用量統計與計費就沒有可信度。權限規則寫成完全禁止前端讀寫,只由後端服務寫入。
- 失敗也要記。 只記成功會讓錯誤率永遠是 0%。
status欄位存在的意義就是記錄失敗,包括被內容過濾擋下的那些。 - 一開始就定保留期限。 這張表的成長速度是其他表的數倍。決定好熱資料保留多久、之後彙總成什麼,寫進文件。
一組可以直接抄的權限規則
以 Firestore 為例,對應上述三張表的安全規則:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isAuthenticated() {
return request.auth != null;
}
function isOwner(userId) {
return request.auth.uid == userId;
}
// 使用者只能讀寫自己的資料
match /users/{userId} {
allow read, write: if isOwner(userId);
}
// 生成結果:只能操作自己的
match /saved_ads/{adId} {
allow create: if isAuthenticated()
&& request.resource.data.user_id == request.auth.uid;
allow read, update, delete: if isAuthenticated()
&& resource.data.user_id == request.auth.uid;
}
// 日誌:前端一律禁止,只由後端寫入
match /generation_logs/{logId} {
allow read, write: if false;
}
}
}
最後一條 allow read, write: if false 是刻意的。很多專案在開發階段把整個資料庫開成測試模式,然後忘了關。 測試模式的規則通常會在 30 天後自動改為全部拒絕,所以你不會馬上發現——但在那 30 天內,任何人都能讀走全部資料。
常見問題
為什麼不能只存 AI 生成的結果就好?
因為生成結果無法回答三類問題:成本(這個月花了多少、誰花的)、重現(同樣條件再跑一次會得到什麼)、歸因(這筆內容當初是基於什麼條件產生的)。這三類問題在上線第一個月不會出現,第三個月一定會出現,而那時已經沒有歷史資料可補。
generation_logs 會不會長太快、成本太高?
會長很快,所以要一開始就決定保留策略。常見做法是熱資料保留 30 至 90 天供除錯與用量分析,之後彙總成每日或每月的統計列再刪除明細。真正佔空間的是 prompt_snapshot 欄位,若成本敏感可只存雜湊值與提示詞版本編號,不存全文。
prompt_snapshot 存完整提示詞,會不會有個資風險?
會,而且這是最容易被忽略的一處。使用者輸入可能夾帶個資,一旦存進日誌就等於把個資複製了一份。若應用會接觸個資,應在寫入前做遮蔽,或改存提示詞範本編號加上參數,而非使用者原文。這件事要在設計 schema 時就決定,事後補很痛苦。
這套結構只適用 Firestore 嗎?換成 PostgreSQL 可以嗎?
可以,三張表的切法與資料庫無關。users、生成結果、生成日誌這個分法在關聯式資料庫同樣成立,差別只在巢狀欄位要拆成 JSON 欄位或另建關聯表。重點是「把生成脈絡與生成結果分開存、把日誌與業務資料分開存」這兩個決定。
generation_context 為什麼要獨立成一個物件,不直接攤平?
因為它會變。生成條件的欄位組合會隨產品迭代增加或調整,獨立成物件時新增欄位不會污染主結構,也方便日後整包比對「哪一組條件的成效最好」。攤平的話,三個月後你會分不清哪些欄位是業務資料、哪些是當初的生成參數。