AI 功能設計:別讓 token 吃掉利潤
很多人做 AI SaaS 的死法是這樣:產品做好了、用戶進來了、每月收 NT$299,結果一算才發現 AI API 帳單跟訂閱收入幾乎打平——扣掉主機、金流手續費,幾乎是在幫 Anthropic 賺錢。
這不是少數案例。AI SaaS 的毛利問題在 2025–2026 年已經是產業共識,The SaaS CFO 直接寫文章說「Your AI Feature Is Quietly Destroying Your Gross Margin」。傳統 SaaS 毛利 80–90%,AI SaaS 如果不管控成本,真實毛利可能跌到 40% 以下,甚至每月虧損。
這堂課就解決這件事:在你把 AI 功能推上線之前,先把錢算清楚。
這堂學什麼
- 單位經濟學:用真實公式算出每位用戶的月均 AI 成本,對照訂閱價決定毛利率
- 額度制設計:用 Supabase 資料庫追蹤每位用戶的 token 消耗,每次 AI 呼叫前先檢查、後扣除
- Prompt caching 省成本:一行設定讓系統 prompt 的讀取費用降到 10%,大 context 產品直接砍半帳單
- 模型分級策略:用 Haiku 做分流、用 Sonnet 做核心功能,成本差 3–5 倍
- Rate limit 防濫用:用 Upstash + Next.js Middleware 在請求進入 AI 呼叫之前就攔截
- 實戰:從零建一個帶額度上限的 AI 寫作功能,含前端剩餘額度顯示
觀念一:先算錢,再寫程式
在你加第一行 AI 程式碼之前,先做這張試算表。以 Sonnet 4.6(2026 年 7 月標準定價:輸入 $3/M tokens、輸出 $15/M tokens)為例,假設你做一個「AI 改寫文章段落」功能:
| 項目 | tokens 估算 | 費用試算 |
|---|---|---|
| 系統 prompt(固定角色設定) | 2,000 | $0.006 |
| 用戶輸入文字 | 500 | $0.0015 |
| AI 輸出改寫結果 | 800 | $0.012 |
| 每次呼叫合計 | 3,300 | ~$0.02 |
一個月訂閱 NT$299(約 $9 USD),一個用戶一個月用 30 次:AI 成本 30 × $0.02 = $0.60,毛利還有 $8.40,換算毛利率 93%——看起來很棒。
但如果是「每天用、每次上傳長文」的重度用戶:一個月用 300 次、每次輸入 3,000 tokens:
- 每次成本:輸入 5,000 × $0.000003 + 輸出 800 × $0.000015 = $0.015 + $0.012 = $0.027
- 月成本:300 × $0.027 = $8.10
- 訂閱收入:$9,毛利不到 10%,加上主機費幾乎虧本
結論:你的定價策略必須對應「最壞的用戶行為」,而不是平均值。 有兩個出路:一是定價高到即使重度用戶也有毛利,二是用額度制把重度用戶的消耗封頂。
每用戶月均 AI 成本 = (系統 prompt tokens × 輸入單價) + (月均用戶輸入 tokens × 輸入單價) + (月均輸出 tokens × 輸出單價)
目標:這個數字要 ≤ 你訂閱價的 30%,才有合理毛利。如果超過 50%,定價或額度策略一定要調整,因為還有伺服器、金流手續費、支援成本要分攤。
另一個常被忽略的成本陷阱是對話式 AI。每一輪對話都需要帶上完整的歷史訊息,隨著對話越長,每次 API 呼叫的 token 數就越滾越大。如果你做的是 chatbot 功能,必須設定最大對話歷史長度,例如只保留最近 6 輪——超過就截斷舊的,或做摘要後壓縮。否則一個長時間使用者的第 20 輪對話,光 input tokens 就可能是第 1 輪的 10 倍。

觀念二:額度制是成本的安全閥
額度制(credit system)的核心很簡單:把你的 AI 成本換算成一個「點數」單位,每次呼叫扣相應點數,點數用完就停。這樣無論用戶再怎麼用,你的最大虧損是可以預測的。
設計時要回答三個問題:
- 1 點等於多少 token? 建議以「一次標準操作」為基準。例如「改寫一段 500 字文章」= 1 點,這樣用戶容易理解。
- 每個方案給幾點? 免費方案 10 點/月,基本方案 100 點/月,進階方案 500 點/月。
- 點數是否滾動? 最簡單的做法:每月 1 日重置,不累積,減少你的「負債」(未使用點數的服務義務)。

觀念三:Prompt Caching 和模型分級
Prompt Caching:省最多錢的一個參數
如果你的 API 呼叫每次都帶著一大段固定的系統 prompt(角色設定、範例、背景知識),那你在浪費錢。Claude API 的 prompt caching 讓 cache 命中的 token 只收 10% 的輸入費用。
以一個含 10,000 tokens 系統 prompt 的產品為例:
- 無 caching:10,000 × $0.000003 = $0.03/次(光系統 prompt 就這麼多)
- 有 caching(命中):10,000 × $0.0000003 = $0.003/次
每次省 $0.027。如果一個用戶每月呼叫 100 次:每月省 100 × $0.027 = $2.70,相當於輕度用戶一整個月的 AI 成本都省回來了。
模型分級:分流才是省錢正道
不是每個任務都需要最強的模型。

| 任務類型 | 建議模型 | 每 M tokens 輸入費 |
|---|---|---|
| 意圖分類、格式整理、簡單問答 | Haiku 4.5 | $1 |
| 核心 AI 功能(改寫、分析、生成) | Sonnet 4.6 | $3 |
| 複雜推論、長文摘要、多步驟 | Opus 4.7 | $5 |
實務作法:先用 Haiku 判斷用戶輸入的意圖(是正常需求?還是惡意 prompt injection?),通過之後再把請求交給 Sonnet 處理。這個「Haiku 守門、Sonnet 執行」的模式,在意圖判斷這一步可以省掉 67% 的成本。
觀念四:回應品質監控,不靠感覺靠數據
AI 功能上線不是終點。「它回答得好不好」這件事你必須有辦法量測,否則用戶默默離開,你完全不知道問題出在哪。
品質監控的最低配是三件事:
1. 記錄每次 API 呼叫的元資料
把每次 AI 呼叫的輸入、輸出、token 數、延遲時間、是否有錯誤存到資料庫。這份資料有三個用途:追蹤成本趨勢(哪個功能最燒錢)、找出異常用量(誰在灌水或濫用)、事後復現用戶反映的問題。
2. 讓用戶回饋,但要簡單到一秒完成
在 AI 回應旁邊放「好」「差」兩個按鈕,點一下就送。不要設計長問卷——用戶根本不填。哪怕每個月只有 5% 的用戶回饋,累積 200 筆資料後你就能清楚看到哪類問題讓用戶不滿意,再去改 system prompt 或換模型。
3. 監控延遲時間(latency)
AI 回應慢是用戶離開的隱形原因。你要知道 p50/p95 的延遲是多少。如果 p95 延遲超過 15 秒,要考慮:是 prompt 太長、輸出太多、還是網路問題?串流輸出(streaming)能顯著改善「感受到的速度」——用戶看到文字一個個出現,比等 8 秒後一次顯示主觀感受好很多。
// 記錄 AI 呼叫元資料的最簡實作
const startTime = Date.now();
let tokensUsed = { input: 0, output: 0 };
try {
const message = await anthropic.messages.create({ /* ... */ });
tokensUsed = {
input: message.usage.input_tokens,
output: message.usage.output_tokens,
};
const latencyMs = Date.now() - startTime;
// 非同步寫入,不阻塞回應
supabase.from("ai_logs").insert({
user_id: user.id,
feature: "rewrite",
input_tokens: tokensUsed.input,
output_tokens: tokensUsed.output,
latency_ms: latencyMs,
success: true,
}).then(); // fire and forget
} catch (err) {
const latencyMs = Date.now() - startTime;
supabase.from("ai_logs").insert({
user_id: user.id,
feature: "rewrite",
latency_ms: latencyMs,
success: false,
error_message: String(err),
}).then();
throw err;
}
有了這份資料,你就能在 Supabase 後台下 SQL 查詢:「上週哪個功能的平均延遲最高?哪些用戶的 token 消耗是平均值的 5 倍以上?」——這些答案會直接告訴你下一步要優化哪裡。
手把手實戰
我們建一個「AI 改寫功能」:用戶輸入一段文字,系統改寫後回傳,每次扣 1 點額度。
Step 1:Supabase 加額度欄位
在 Supabase SQL Editor 執行:
-- 在 profiles 資料表加額度欄位(如果你在第 3 課已建 profiles)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS credits_remaining INTEGER NOT NULL DEFAULT 10,
ADD COLUMN IF NOT EXISTS credits_reset_at TIMESTAMPTZ NOT NULL DEFAULT (date_trunc('month', now()) + interval '1 month');
-- 建索引,查詢快一點
CREATE INDEX IF NOT EXISTS profiles_credits_idx ON profiles(id, credits_remaining);
-- RLS 規則:用戶只能讀自己的 credits
CREATE POLICY "users read own credits"
ON profiles FOR SELECT
USING (auth.uid() = id);
也可以把上面這段直接丟給 Claude Code:
幫我在 Supabase 的 profiles 資料表加 credits_remaining(整數,預設 10)和 credits_reset_at(時間戳)欄位,
並加上 RLS 政策讓用戶只能讀自己的 credits。給我完整 SQL。
Step 2:建立扣額度的後端 API
建立 app/api/rewrite/route.ts:
import { NextRequest, NextResponse } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
import { createClient } from "@/lib/supabase/server";
const anthropic = new Anthropic();
// 系統 prompt 獨立出來,方便後面加 cache_control
const SYSTEM_PROMPT = `你是一位專業的繁體中文文字編輯。
請改寫用戶提供的段落,讓文字更精煉、更有說服力。
保持原意,不要加入額外資訊。只回傳改寫後的文字,不要加說明。`;
export async function POST(req: NextRequest) {
const supabase = await createClient();
// 1. 確認登入
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. 查詢並扣除額度(單一 SQL 操作,避免 race condition)
const { data: profile, error: creditError } = await supabase
.from("profiles")
.select("credits_remaining")
.eq("id", user.id)
.single();
if (creditError || !profile) {
return NextResponse.json({ error: "Profile not found" }, { status: 404 });
}
if (profile.credits_remaining < 1) {
return NextResponse.json(
{ error: "額度不足,請升級方案或等下個月重置" },
{ status: 402 }
);
}
// 3. 取得輸入
const { text } = await req.json();
if (!text || text.length > 3000) {
return NextResponse.json(
{ error: "輸入文字需在 3,000 字以內" },
{ status: 400 }
);
}
try {
// 4. 呼叫 Claude API(含 prompt caching)
const message = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
system: [
{
type: "text",
text: SYSTEM_PROMPT,
cache_control: { type: "ephemeral" }, // 開啟 5 分鐘 TTL caching
},
],
messages: [{ role: "user", content: text }],
});
const result = (message.content[0] as { type: string; text: string }).text;
// 5. 扣除額度(AI 呼叫成功才扣)
await supabase
.from("profiles")
.update({ credits_remaining: profile.credits_remaining - 1 })
.eq("id", user.id);
return NextResponse.json({
result,
credits_remaining: profile.credits_remaining - 1,
});
} catch (err) {
// AI 呼叫失敗不扣額度
console.error("AI API error:", err);
return NextResponse.json(
{ error: "AI 服務暫時無法使用,請稍後再試" },
{ status: 503 }
);
}
}
注意 cache_control: { type: "ephemeral" } 這個參數:它告訴 Anthropic「把這個 system 區塊快取起來」。第一次呼叫會寫入快取(寫入費 1.25x),之後 5 分鐘內命中快取只收 10% 費用。你的系統 prompt 越長,效果越顯著。
Step 3:用 Upstash 加 Rate Limit 防濫用
先安裝套件:
npm install @upstash/ratelimit @upstash/redis
到 upstash.com 免費建一個 Redis 資料庫,把 UPSTASH_REDIS_REST_URL 和 UPSTASH_REDIS_REST_TOKEN 加進 .env.local 和 Vercel 環境變數。
然後建立 middleware.ts(放在專案根目錄,與 app/ 同層):
import { NextRequest, NextResponse } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// 每個用戶每分鐘最多 5 次 AI 呼叫(sliding window 較精準)
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"),
analytics: true, // 在 Upstash Console 看流量圖
});
export async function middleware(req: NextRequest) {
// 只對 AI API 做 rate limit
if (!req.nextUrl.pathname.startsWith("/api/rewrite")) {
return NextResponse.next();
}
// 用 Authorization header 的 token 當識別 key
// 比用 IP 更準確——同 NAT 底下的多人不會互相影響
const authHeader = req.headers.get("authorization") ?? "";
const identifier = authHeader || req.ip ?? "anonymous";
const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: "請求太頻繁,請稍後再試" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
}
);
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/rewrite/:path*"],
};
Step 4:前端顯示剩餘額度
在 React 元件裡呼叫 API 並即時更新剩餘額度:
"use client";
import { useState } from "react";
export default function RewriteEditor() {
const [text, setText] = useState("");
const [result, setResult] = useState("");
const [credits, setCredits] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleRewrite() {
if (!text.trim()) return;
setLoading(true);
setError("");
const res = await fetch("/api/rewrite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
// 額度不足:引導升級
if (res.status === 402) {
setError("額度已用完!本月剩餘 0 點。請升級方案繼續使用。");
} else if (res.status === 429) {
setError("操作太頻繁,請等 1 分鐘後再試。");
} else {
setError(data.error ?? "發生錯誤");
}
return;
}
setResult(data.result);
setCredits(data.credits_remaining);
}
return (
<div className="space-y-4">
{credits !== null && (
<p className="text-sm text-gray-500">
本月剩餘額度:{credits} 點
{credits <= 3 && (
<span className="text-orange-500 ml-2">快用完了,考慮升級</span>
)}
</p>
)}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="貼上要改寫的段落(上限 3,000 字)"
className="w-full h-40 p-3 border rounded-lg"
maxLength={3000}
/>
<button
onClick={handleRewrite}
disabled={loading || !text.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{loading ? "改寫中..." : "AI 改寫(1 點)"}
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
{result && (
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm font-medium text-gray-600 mb-2">改寫結果:</p>
<p className="whitespace-pre-wrap">{result}</p>
</div>
)}
</div>
);
}
Step 5:每月額度自動重置
用 Supabase 的 pg_cron 擴充(Pro 方案支援,免費方案要用外部 cron):
-- 啟用 pg_cron(Supabase Pro 方案在 Dashboard > Extensions 開啟)
SELECT cron.schedule(
'reset-monthly-credits',
'0 0 1 * *', -- 每月 1 日 00:00 UTC
$$
UPDATE profiles
SET
credits_remaining = CASE
WHEN subscription_plan = 'free' THEN 10
WHEN subscription_plan = 'basic' THEN 100
WHEN subscription_plan = 'pro' THEN 500
ELSE 10
END,
credits_reset_at = date_trunc('month', now()) + interval '1 month'
WHERE credits_reset_at <= now();
$$
);
如果你用 Supabase 免費方案,改用 Vercel Cron Jobs 替代:在 vercel.json 加:
{
"crons": [
{
"path": "/api/cron/reset-credits",
"schedule": "0 0 1 * *"
}
]
}
然後建立 /api/cron/reset-credits/route.ts,在裡面做同樣的 Supabase update——記得用 CRON_SECRET 環境變數驗證這個 endpoint 只能由 Vercel 呼叫,不能讓外部隨便觸發。


常見坑
坑 1:Prompt Caching 一直沒有命中(cache-write 費用持續出現)
症狀:Anthropic 帳單裡看到的都是 cache_write 費用,幾乎沒有 cache_read。
原因通常是系統 prompt 裡混入了「每次不同的內容」。最常見的兩個地方:
// ❌ 錯誤:把當下時間放進 system prompt,每次都不同,永遠無法命中快取
const SYSTEM_PROMPT = `你是文字助手。現在時間是 ${new Date().toISOString()}。`;
// ❌ 錯誤:把用戶名字放進 system prompt
const SYSTEM_PROMPT = `你是文字助手,正在服務 ${user.name}。`;
// ✅ 正確:system prompt 只放固定內容,動態內容放進 user message
const SYSTEM_PROMPT = `你是文字助手。請根據用戶請求協助改寫文字。`;
// 動態資訊放這裡:
messages: [{ role: "user", content: `用戶: ${user.name}\n請求: ${userInput}` }]
Cache 的命中條件是:system 區塊的 text 內容必須逐字元完全相同。任何一個字不同就是 cache miss。
坑 2:AI 呼叫失敗但額度已被扣除(或呼叫成功但額度忘了扣)
症狀:用戶投訴「我點數扣了但沒有結果」;或用戶反映可以無限使用。
問題在實戰程式碼裡已經處理了:先呼叫 AI、成功才扣額度(把 update 放在 try 區塊內、AI 呼叫成功之後)。但如果你想要更嚴謹的「預扣」機制(先鎖定額度,確保成功再確認),可以改成:
// 預扣:先扣除,失敗時退回
const { error: deductError } = await supabase
.from("profiles")
.update({ credits_remaining: profile.credits_remaining - 1 })
.eq("id", user.id)
.eq("credits_remaining", profile.credits_remaining); // 樂觀鎖,防 race condition
if (deductError) {
return NextResponse.json({ error: "額度扣除失敗,請重試" }, { status: 409 });
}
try {
const message = await anthropic.messages.create({ /* ... */ });
return NextResponse.json({ result: /* ... */ });
} catch (err) {
// 失敗退回額度
await supabase
.from("profiles")
.update({ credits_remaining: profile.credits_remaining }) // 還原
.eq("id", user.id);
throw err;
}
.eq("credits_remaining", profile.credits_remaining) 是樂觀鎖的關鍵:如果兩個請求同時進來,只有一個會成功更新(因為第二個到達時值已經變了),防止重複消耗同一點數。
坑 3:Rate Limit 用 IP 做 key,一下子就被繞過
症狀:同一個用戶換 VPN 或從不同裝置登入,就突破了 rate limit。
原因:IP 是個很弱的識別子。手機、WiFi、VPN 都可以換,一個惡意用戶可以輕易規避 IP-based rate limit。
正確做法是用 user ID(或 JWT token 的 sub)當 rate limit key,因為 user ID 是帳號層級的,換 IP 也沒用:
// ❌ 弱:用 IP 做 key
const identifier = req.ip ?? "anonymous";
// ✅ 強:先從 JWT 解出 user ID,用 user ID 當 key
// 在 middleware 裡可以讀 cookie 裡的 supabase token
const supabase = createClient(); // server-side supabase client
const { data: { user } } = await supabase.auth.getUser();
const identifier = user?.id ?? req.ip ?? "anonymous";
注意:在 Next.js Middleware 裡不能用需要 Node.js runtime 的 Supabase client,要改用支援 Edge runtime 的 @supabase/ssr 搭配 createServerClient。
坑 4:用戶上傳超長文本,單次呼叫成本爆炸
症狀:某些用戶把整篇 10,000 字文章貼進去,一次就消耗幾十個「標準次數」的 token,但他只被扣 1 點。
解法:在後端硬性截斷輸入長度,不要只靠前端的 maxLength(前端的限制用戶隨時可以用開發者工具繞過):
const { text } = await req.json();
// 後端強制截斷,不依賴前端
const MAX_CHARS = 3000;
const sanitizedText = text?.slice(0, MAX_CHARS) ?? "";
if (!sanitizedText.trim()) {
return NextResponse.json({ error: "請輸入文字" }, { status: 400 });
}
更進一步,可以用 token 數量而不是字元數量來計算,使用 Anthropic 的 countTokens API(實驗性功能)或第三方的 tiktoken 估算。
作業
算出你產品的單位經濟學試算表:填入你的 AI 功能用到的 model、預估 system prompt 大小、預估用戶每次輸入/輸出 token 數,算出「月均 AI 成本/用戶」。確認你的定價方案在重度用戶情況下仍有 ≥50% 毛利。
實作基本額度系統:按照本堂課的程式碼,在你的 Supabase 加上
credits_remaining欄位,在你的 AI API route 加入「查詢額度 → 呼叫 AI → 扣除額度」三步驟,並在前端顯示剩餘額度。開啟 Prompt Caching:在你的 system prompt 區塊加上
cache_control: { type: "ephemeral" },跑幾次請求後去 Anthropic 後台的 Usage 頁面確認有出現cache_read_input_tokens——看到這個數字,代表省錢開始了。選做:加上 Upstash Rate Limit,設定每位用戶每分鐘最多 5 次 AI 請求。驗收方式:用
for迴圈連續打 10 次 API,第 6 次起應該收到 429。
下一課預告
你現在有一個「能賺錢、有 AI 功能、成本可控」的產品了。問題來了:沒有人知道它存在。
第 5 課《上線與獲客:沒人知道等於不存在》會帶你走完產品上線的最後一哩路——SEO 基礎設定(Next.js metadata、sitemap、robots.txt)、讓 Google 快速收錄的做法、第一批用戶從哪來(PTT、Dcard、社群貼文策略),以及如何用 Vercel Analytics 和 Supabase 的資料追蹤「哪些功能被用、哪些被忽略」——這些數據會決定你的下一次迭代方向。做了產品、賣得出去,才叫完整的 AI SaaS。