精華筆記

· @aihub.tw

AI SaaS 產品開發

AI 功能設計:別讓 token 吃掉利潤

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 功能、或已經有 AI 功能但成本失控的開發者。需要基礎:會寫 JavaScript/TypeScript、看得懂 async/await、上過第 3 課(或有 Supabase + Next.js 基礎)。前置課:第 3 課(登入與金流)。本課屬工程師專區,會有完整程式碼。

這堂學什麼

  • 單位經濟學:用真實公式算出每位用戶的月均 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 倍。

輕度/中度/重度用戶的 AI 成本 vs 訂閱收入對比,標出毛利率與虧損臨界點

觀念二:額度制是成本的安全閥

額度制(credit system)的核心很簡單:把你的 AI 成本換算成一個「點數」單位,每次呼叫扣相應點數,點數用完就停。這樣無論用戶再怎麼用,你的最大虧損是可以預測的。

設計時要回答三個問題:

  1. 1 點等於多少 token? 建議以「一次標準操作」為基準。例如「改寫一段 500 字文章」= 1 點,這樣用戶容易理解。
  2. 每個方案給幾點? 免費方案 10 點/月,基本方案 100 點/月,進階方案 500 點/月。
  3. 點數是否滾動? 最簡單的做法:每月 1 日重置,不累積,減少你的「負債」(未使用點數的服務義務)。

額度制系統流程圖:查詢額度、判斷、呼叫 AI、扣點,並標出各節點的 HTTP 狀態碼

觀念三: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 成本都省回來了。

模型分級:分流才是省錢正道

不是每個任務都需要最強的模型。

模型分級:Haiku 4.5 守門、Sonnet 4.6 主力、Opus 4.7 處理複雜任務,含各層定價與成本倍數

任務類型 建議模型 每 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_URLUPSTASH_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 呼叫,不能讓外部隨便觸發。

扣額度時序圖:Client → Middleware → API Route → Supabase → Anthropic,標出 429/401/402/503 各在哪個關卡發生

月成本預測:免費/基本/進階三方案各 100 用戶的月收入 vs AI 成本,對比有無 caching 的毛利差距

常見坑

坑 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 估算。

作業

  1. 算出你產品的單位經濟學試算表:填入你的 AI 功能用到的 model、預估 system prompt 大小、預估用戶每次輸入/輸出 token 數,算出「月均 AI 成本/用戶」。確認你的定價方案在重度用戶情況下仍有 ≥50% 毛利。

  2. 實作基本額度系統:按照本堂課的程式碼,在你的 Supabase 加上 credits_remaining 欄位,在你的 AI API route 加入「查詢額度 → 呼叫 AI → 扣除額度」三步驟,並在前端顯示剩餘額度。

  3. 開啟 Prompt Caching:在你的 system prompt 區塊加上 cache_control: { type: "ephemeral" },跑幾次請求後去 Anthropic 後台的 Usage 頁面確認有出現 cache_read_input_tokens——看到這個數字,代表省錢開始了。

  4. 選做:加上 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。

#AI SaaS#token 成本#Prompt Caching#Rate Limit#Supabase#Next.js

← 回所有文章