精華筆記

· @aihub.tw

Claude API 開發實戰

Prompt Caching 與 Batch:成本砍半

Prompt Caching 與 Batch:成本砍半

第 6 課學完 Streaming 和多模態之後,你的系統已經能即時回應、讀圖、讀 PDF。但如果把這套東西推上生產環境,第一個月帳單拿到手可能會嚇一跳。API 費用的殺手通常不是單次呼叫,而是兩個壞習慣:一是「每次都把同一份長文件重新送進去」,二是「幾千筆離線任務全部即時跑、但其實根本不需要即時」。這堂課把這兩個坑填掉。

用真實數字來感受問題規模:假設你用 claude-sonnet-4-6 做一個 RAG 問答系統,每次問答都把同一份 10,000 token 的產品手冊貼進 system prompt,一天有 500 個使用者各問 5 個問題。光是 system prompt 的重複輸入費,一天就是 500 × 5 × 10,000 ÷ 1,000,000 × $3.00 = $75,一個月就是 $2,250——什麼功能都沒做,光貼文件就燒這麼多錢。Prompt Caching 可以把這個數字砍到 $250 以內。

這堂課適合誰 適合:已完成第 1–6 課、會 Python 或 JavaScript、想把 Claude API 用在真實系統並在意帳單的開發者。需要基礎:Python 或 Node.js 基礎、會建立 Anthropic client、懂 async/await。前置課:第 2 課(模型計價)、第 6 課(Streaming)。

這堂學什麼

  • Prompt Caching 的運作原理:前綴比對、cache 斷點、TTL 怎麼算
  • cache write / cache read 的計費差異,以及何時「寫貴讀省」划算
  • Batch API 的工作流程、50% 折扣的正確適用場景
  • Python 和 JavaScript 的完整可跑範例,含 usage 欄位解讀與成本計算
  • 兩者組合策略,以及真實帳單對比的完整計算

觀念一:Prompt Caching 怎麼運作

Claude 每次收到 request,都要把所有輸入的 token 從頭計算一遍——這叫做「context processing」。如果你的 system prompt 有 10,000 個 token、或每次都把同一份合約文件貼進去,這 10,000 個 token 的費用就會一筆一筆疊加,完全是重複付費。

Prompt Caching 的做法是讓你標記「這段內容請快取」。第一次呼叫是 cache write:Anthropic 計算並儲存這段前綴的中間狀態。之後只要你送進來的前綴完全一樣,API 就直接回傳 cache read——跳過重新計算,費用大幅下降。這個「完全一樣」很關鍵:快取比對的是位元組級別的精確比對,前綴只要差一個字元,就無法命中。

Prompt Caching 前綴比對示意圖

計費結構(以 claude-sonnet-4-6 為例)

動作 費用(每百萬 token) 說明
一般輸入 $3.00 沒有 caching 的基準
Cache write(5 分鐘 TTL) $3.75 首次寫入貴 25%
Cache write(1 小時 TTL) $6.00 首次寫入貴 100%
Cache read(命中) $0.30 只要基準的 10%,省 90%
輸出 $15.00 輸出費用不受 caching 影響

其他模型的 cache read 價格:claude-haiku-4-5 的 cache read 是 $0.10/MTok,claude-opus-4-8 是 $0.50/MTok,比例都是基準輸入的 10%。

快取有最短存活時間(TTL)。預設是 5 分鐘,每次 cache read 成功都會重置計時器,換句話說只要持續有人問,5 分鐘 TTL 的快取可以活很久。選用 1 小時 TTL 的話 cache write 費用翻倍,但適合不常呼叫、每次間隔超過 5 分鐘的場景,例如批次離線任務或低流量的夜間服務。

什麼時候划算? 計算很簡單。5 分鐘 TTL:cache write 比普通輸入貴 25%,只要同一段快取被讀取超過 1.25 次就開始省錢。1 小時 TTL:cache write 是普通輸入的 2 倍,超過 2 次才回本。對話系統的高頻呼叫幾乎瞬間就超過門檻;批次任務則要事先估算每份文件會被幾個 request 使用。

Cache write vs read 損益平衡折線圖

一個 request 最多四個 cache 斷點

你可以在一個 request 的不同位置各放一個 cache_control 標記,最多 4 個。這讓你能分層快取:例如把「通用 system prompt」放一個斷點、「這個客戶的歷史對話紀錄」放第二個斷點、「本次要分析的文件」放第三個斷點。每一層都是獨立的快取,命中各自算費用。實際上大多數情境用 1–2 個斷點就夠了。

觀念二:Batch API 怎麼運作

Batch API 讓你把一大批 request 打包成一個工作送出,Anthropic 在後台非同步處理,通常 1 小時內完成。代價是你沒辦法即時拿到結果——但因此省下整整 50% 的輸入和輸出費用。這個折扣是直接打在帳單上,不需要任何特別設定,只要透過 Batch API 送就自動適用。

每個批次最多可以包含 100,000 個 request,最多攜帶 256MB 的資料。Batch 有 24 小時的處理期限,超過就會被自動取消。

適合 Batch API 的場景:

  • 離線資料分析:把 5,000 筆客戶評論一次分類,明天看結果沒問題
  • 大量文件摘要:每晚跑前一天的業務報告,隔天早上進辦公室就有摘要
  • 測試集評估:定期跑 benchmark 看模型表現
  • 資料標注:微調前的大量樣本處理

不適合 Batch API 的場景:任何需要即時回應的互動。使用者在等你的聊天機器人回答,這個時候用 Batch 就是把人晾在那裡。

Batch API 完整工作流程

觀念三:組合策略——最高省 95%

兩者可以同時使用。以 claude-sonnet-4-6 為例,如果你有一個每天跑 3,000 筆、每筆都包含同一份 20,000 token 說明文件的批次任務:

策略 輸入費用估算(每天) 較基準節省
無任何優化 3,000 × 20,000 ÷ 1M × $3.00 = $180
只用 Batch API $180 × 0.5 = $90 50%
只用 Prompt Caching 首次 write $0.075 + 2,999 次 read $18.0 ≈ $18.08 90%
Batch + Cache read ($0.075 × 0.5) + (2,999 × 20k ÷ 1M × $0.30 × 0.5) ≈ $9.04 95%

從 $180 壓到 $9:帳單縮水 95%。這不是理論值——只要任務符合「有重複前綴、不需要即時」這兩個條件,就能實現。

四種策略帳單對比橫條圖

手把手實戰

Step 1. 啟用 Prompt Caching,觀察 cache write 與 read

在需要快取的區塊最後加上 "cache_control": {"type": "ephemeral"}。以下範例用一份模擬的長合約文件:

import anthropic

client = anthropic.Anthropic()

# 模擬一份超長文件(真實情境可能是讀取 PDF 轉成的文字)
LONG_DOCUMENT = "這是合約條款全文..." + ("內容重複填充..." * 400)

def ask_about_contract(question: str):
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=[
            {
                "type": "text",
                "text": "你是一位合約審閱助理,請根據使用者問題分析以下合約內容。",
            },
            {
                "type": "text",
                "text": LONG_DOCUMENT,
                "cache_control": {"type": "ephemeral"},  # 快取斷點放在這裡
            },
        ],
        messages=[{"role": "user", "content": question}],
    )
    u = response.usage
    print(f"問題: {question[:20]}...")
    print(f"  cache_write: {u.cache_creation_input_tokens}")
    print(f"  cache_read:  {u.cache_read_input_tokens}")
    print(f"  input:       {u.input_tokens}")
    print(f"  output:      {u.output_tokens}")
    return response.content[0].text

# 第一次:cache write(寫入快取,稍貴)
ask_about_contract("這份合約的違約條款在哪裡?")

# 第二次:cache read(命中快取,省 90%)
ask_about_contract("交貨期限是多少天?")

# 第三次:仍然 cache read
ask_about_contract("付款方式是什麼?")

執行後你會看到:第一次 cache_creation_input_tokens > 0cache_read_input_tokens == 0。第二次起則反過來,cache_read_input_tokens > 0,代表省錢生效了。只要每次問問題的間隔在 5 分鐘以內,快取就會持續命中。

Step 2. 同樣功能的 JavaScript 版本

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();
const LONG_DOCUMENT = "合約條款全文...".padEnd(30000, "內容");

async function askAboutContract(question) {
  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    system: [
      {
        type: "text",
        text: "你是一位合約審閱助理,請根據使用者問題分析以下合約內容。",
      },
      {
        type: "text",
        text: LONG_DOCUMENT,
        cache_control: { type: "ephemeral" },
      },
    ],
    messages: [{ role: "user", content: question }],
  });

  const u = response.usage;
  console.log(`write: ${u.cache_creation_input_tokens} | read: ${u.cache_read_input_tokens}`);
  return response.content[0].text;
}

await askAboutContract("違約條款在哪裡?");   // cache write
await askAboutContract("交貨期限多少天?");   // cache read ✓

Step 3. 使用 1 小時 TTL(適合慢速批次場景)

如果你的任務間隔超過 5 分鐘,例如批次任務在後台排隊等待 30 分鐘才開始處理,5 分鐘 TTL 的快取可能在批次跑完前就過期了。這時改用 1 小時 TTL:

{
    "type": "text",
    "text": LONG_DOCUMENT,
    "cache_control": {"type": "ephemeral", "ttl": "1h"},  # 1 小時 TTL
}

使用 1 小時 TTL 時,cache write 費用是 Sonnet 4.6 的 $6.00/MTok(基準的 2 倍)。只要在 1 小時內讀超過 2 次就回本。批次任務的每份文件通常對應多個分析問題,很容易超過這個門檻。

Step 4. 送出 Batch 任務

假設你要把 1,000 筆客服評論做情緒分類,不需要即時結果:

import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()

feedbacks = [
    {"id": "fb_001", "text": "商品很快到,包裝也很好,非常滿意!"},
    {"id": "fb_002", "text": "客服沒有回應,等了三天都沒消沒息。"},
    {"id": "fb_003", "text": "品質普通,跟預期差一點點。"},
    # ... 更多筆,實際情況可能有幾千筆
]

SYSTEM_PROMPT = (
    "你是客服分析師。請將以下回饋分類為「正面」、「負面」或「中性」,"
    "只回傳一個詞,不要加其他說明。"
)

# 建立批次 request 清單
requests = [
    Request(
        custom_id=fb["id"],  # 一定要設,取結果時靠它對應
        params=MessageCreateParamsNonStreaming(
            model="claude-haiku-4-5",  # 分類用 Haiku 就夠,更省錢
            max_tokens=10,
            system=SYSTEM_PROMPT,
            messages=[{"role": "user", "content": fb["text"]}],
        ),
    )
    for fb in feedbacks
]

# 送出批次(立刻拿到 batch_id,但結果還沒好)
batch = client.messages.batches.create(requests=requests)
print(f"Batch ID: {batch.id}")
print(f"狀態: {batch.processing_status}")  # in_progress
print(f"請求數量: {batch.request_counts.processing}")

# 重要:把 batch.id 存起來,程式重啟後還能查詢
with open("batch_id.txt", "w") as f:
    f.write(batch.id)

注意:custom_id 是你自己取的識別碼。批次結果的回傳順序不保證和送入時一樣,一定要靠 custom_id 對應,不能靠位置。

Step 5. 輪詢狀態並取回結果

Batch 不是即時的,送出後要等。下面示範簡單的輪詢迴圈,實際生產環境建議改用獨立的 cron job,避免程式 crash 導致結果遺失:

import time

batch_id = open("batch_id.txt").read().strip()

# 持續輪詢直到完成
while True:
    status = client.messages.batches.retrieve(batch_id)
    counts = status.request_counts
    print(
        f"狀態: {status.processing_status} | "
        f"完成: {counts.succeeded} | "
        f"錯誤: {counts.errored} | "
        f"處理中: {counts.processing}"
    )
    if status.processing_status == "ended":
        break
    time.sleep(30)  # 每 30 秒查一次

# 取回每筆結果
results = {}
for entry in client.messages.batches.results(batch_id):
    if entry.result.type == "succeeded":
        label = entry.result.message.content[0].text.strip()
        results[entry.custom_id] = label
    else:
        # 記錄失敗的 request,稍後可以重跑
        results[entry.custom_id] = f"ERROR:{entry.result.error.type}"

for fb_id, label in results.items():
    print(f"{fb_id}: {label}")

Step 6. Batch API JavaScript 版本

import Anthropic from "@anthropic-ai/sdk";
import fs from "fs";

const client = new Anthropic();

async function runFeedbackBatch(feedbacks) {
  const requests = feedbacks.map((fb) => ({
    custom_id: fb.id,
    params: {
      model: "claude-haiku-4-5",
      max_tokens: 10,
      system: "你是客服分析師。請將以下回饋分類為「正面」、「負面」或「中性」,只回傳一個詞。",
      messages: [{ role: "user", content: fb.text }],
    },
  }));

  const batch = await client.messages.batches.create({ requests });
  console.log("Batch ID:", batch.id);
  fs.writeFileSync("batch_id.txt", batch.id);

  // 輪詢
  let current = batch;
  while (current.processing_status !== "ended") {
    await new Promise((r) => setTimeout(r, 30000));
    current = await client.messages.batches.retrieve(batch.id);
    const c = current.request_counts;
    console.log(`完成: ${c.succeeded} | 錯誤: ${c.errored} | 處理中: ${c.processing}`);
  }

  // 取結果
  const results = {};
  for await (const entry of await client.messages.batches.results(batch.id)) {
    results[entry.custom_id] =
      entry.result.type === "succeeded"
        ? entry.result.message.content[0].text.trim()
        : `ERROR:${entry.result.error.type}`;
  }
  return results;
}

Step 7. 終極組合:Batch + Prompt Caching

如果批次裡每筆 request 都包含同一份長文件,把 cache_control 標上去——Batch API 和 Prompt Caching 完全相容:

REFERENCE_DOC = "產品規格書全文..." * 100  # 模擬 20,000 token 文件

requests = [
    Request(
        custom_id=f"q_{i}",
        params=MessageCreateParamsNonStreaming(
            model="claude-sonnet-4-6",
            max_tokens=256,
            system=[
                {
                    "type": "text",
                    "text": "你是產品評測助理,根據以下規格書回答問題。",
                },
                {
                    "type": "text",
                    "text": REFERENCE_DOC,
                    # Batch 任務用 1h TTL,避免排隊等待期間快取過期
                    "cache_control": {"type": "ephemeral", "ttl": "1h"},
                },
            ],
            messages=[{"role": "user", "content": question}],
        ),
    )
    for i, question in enumerate(questions)
]

batch = client.messages.batches.create(requests=requests)

批次跑完後,查每筆結果的 usage.cache_read_input_tokens——如果大部分都大於 0,代表組合策略生效了。

帳單對比:同任務開關 caching 的計算

以下是一個具體情境:用 claude-sonnet-4-6 回答同一份 8,000 token 說明文件的問題,50 個使用者各問 1 個問題。

沒有 caching 有 caching(5分鐘 TTL)
system prompt 輸入 50 × 8,000 = 400,000 tokens
cache write 第 1 次:8,000 tokens
cache read 第 2–50 次:49 × 8,000 = 392,000 tokens
輸入費用 400,000 ÷ 1M × $3.00 = $1.200 8,000 ÷ 1M × $3.75 + 392,000 ÷ 1M × $0.30 = $0.148
節省 88%

實際上 50 個使用者在 5 分鐘內都問到的可能性很高——這是保守估算,現實中命中率往往更好。

要在程式裡追蹤這個數字,可以在每次呼叫後記錄 usage:

def log_usage_cost(response, task_name: str):
    u = response.usage
    # claude-sonnet-4-6 計費(2026 年 7 月)
    cost = (
        u.input_tokens               * 3.00 / 1_000_000
        + u.cache_creation_input_tokens * 3.75 / 1_000_000
        + u.cache_read_input_tokens  * 0.30 / 1_000_000
        + u.output_tokens            * 15.00 / 1_000_000
    )
    print(
        f"[{task_name}] "
        f"write={u.cache_creation_input_tokens:,} "
        f"read={u.cache_read_input_tokens:,} "
        f"out={u.output_tokens:,} "
        f"cost=${cost:.5f}"
    )

把這個 function 嵌進你的每個 API 呼叫,就有免費的成本監控。cache_read_input_tokens / (cache_read_input_tokens + cache_creation_input_tokens) 就是你的快取命中率,這個比例愈高、省得愈多。

usage 物件欄位解剖圖

常見坑

坑 1:cache_control 放錯位置,每次都是 write

症狀:每次呼叫都看到 cache_creation_input_tokens > 0cache_read_input_tokens 永遠是 0。費用沒有下降,甚至稍微變貴了。

原因幾乎都是快取前綴每次不完全一樣:

  • cache_control 標在 messages 裡的 user message,而 user message 每次都不同,當然無法命中
  • system 的靜態文字前面插入了動態內容,例如當下的日期、使用者 ID、session 流水號——這讓前綴每次都不一樣

解法:所有靜態、不變的內容放進 system 並加 cache_control。動態的、每次都不同的部分一律放進 messages。用 cache_read_input_tokens > 0 當作快取命中的健康指標。


坑 2:Batch 結果有 errored 但沒被察覺

批次整體狀態顯示 ended,但 request_counts.errored > 0——代表有些 request 失敗了。如果你只看整體狀態就跳過,這些失敗的 request 和它們的資料就無聲無息地消失了。

# 你以為全部成功,實際上有 37 筆失敗
request_counts.succeeded = 963
request_counts.errored   = 37

解法:取結果時一定要逐筆檢查 entry.result.type,遇到 "errored" 就記錄到失敗清單,之後重跑:

failed_ids = []
for entry in client.messages.batches.results(batch_id):
    if entry.result.type == "errored":
        print(f"失敗: {entry.custom_id} | {entry.result.error.type}")
        failed_ids.append(entry.custom_id)

常見的 error.type:invalid_request(你的 request 格式有問題,要修再重送)、overloaded(伺服器繁忙,可以直接重試)。


坑 3:Batch 任務的 batch_id 搞丟了

送出批次後,如果你沒有把 batch.id 存下來,程式重啟就找不回來了——因為批次是非同步的,你需要拿著 ID 去查結果。最糟糕的情況是批次其實跑完了,但你不知道要去哪裡取。

解法:送出後立刻把 batch.id 持久化儲存(資料庫、檔案都行)。萬一真的找不到 ID,可以用 batches.list() 列出最近的批次:

# 找出所有還在跑或已完成的批次
for b in client.messages.batches.list():
    print(f"ID: {b.id} | 狀態: {b.processing_status} | 建立時間: {b.created_at}")

坑 4:把即時任務改成 Batch,使用者在等結果

有人為了省 50%,把所有 API 呼叫都換成 Batch。但聊天機器人、即時分析這些場景,使用者點送出就要等你回覆——Batch 可能要幾十分鐘才給結果,這樣的使用者體驗是災難。

判斷標準很清楚:「使用者或系統在這一次操作的當下需要等待這個結果嗎?」是的話就用普通 Messages API;不需要即時才用 Batch。離線批次分析、隔天早上才看的報告、測試集評估——這些都適合。

Batch vs 即時 API 使用決策樹

作業

  1. 把第 3 課的多輪對話系統加上 caching:把 system prompt 標上 cache_control,連問三個問題,印出每次的 cache_read_input_tokens——確認第二、三次都有命中。

  2. 自己模擬一個批次情境:準備 10 句話,用 Batch API 送出分類任務,等結果,用 custom_id 對應輸出。刻意在其中一筆的 request 把 max_tokens 設成 -1(非法值,會觸發 invalid_request 錯誤),觀察 result.type == "errored" 時的錯誤訊息長什麼樣。

  3. 進階作業:把作業 1 和作業 2 組合——帶長文件、同時使用 Prompt Caching(1h TTL)的批次任務,確認結果裡有 cache_read_input_tokens > 0

  4. 在你的 API 呼叫流程裡加上 log_usage_cost 函式,跑 20 次問答,計算你的快取命中率。

下一課預告

成本控制做好了,整個 Claude API 的核心功能你都學完了:呼叫、選模型、對話管理、結構化輸出、Tool Use、Streaming、省錢優化。第 8 課是本課程的壓軸:綜合實戰:客服 API 服務上線。我們會從零開始,把這七堂課學過的技術組裝成一個真實的客服後端:多輪對話加上 Tool Use 查訂單狀態、Streaming 即時輸出、Prompt Caching 讓 FAQ 文件只讀入一次、最後部署到能接真實流量的環境。讀者買的是整合能力,最後一課就是把這個能力完整走一遍。

#Claude API#Prompt Caching#Batch API#成本優化#工程師

← 回所有文章