跳至主要內容
技術

成本控制:省錢是一門工程藝術

成本控制:省錢是一門工程藝術
Claude API & Agent SDK 完全指南 第 13 / 15 篇

本篇是「Claude API & Agent SDK 完全指南」系列的第 13 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

我曾經有一個月收到 $2,341 的 Anthropic 帳單。

那個時候,我在做一個內部知識庫的 RAG 系統,大概有三十幾個人在用。$2,341 除以 35 人,每個人每個月大概 $67——這個成本在企業環境裡看起來還好,但我的老闆覺得太貴了。

他說:「這個系統有沒有辦法變成 $500 以內?」

我花了兩週,把費用降到了 $287。這一章我要告訴你我是怎麼做到的。

為什麼成本優化是 AI 應用的核心工程問題

很多開發者把成本優化當成「後期才需要考慮的事」。這是個錯誤。

AI 應用的成本結構跟傳統軟體完全不同。傳統 SaaS 的邊際成本接近零——多一個用戶,你的 AWS 帳單大概增加幾塊錢。但 AI 應用的邊際成本是線性的甚至是超線性的:用戶量增加一倍,API 費用大概也增加一倍;如果你的系統設計讓每次查詢消耗更多 token(例如累積越來越長的對話歷史),費用增長甚至會超過用戶量增長。

成本不只影響利潤,還影響產品設計決策。當你知道每次 API 呼叫的成本,你會更謹慎地設計什麼時候要呼叫 API、呼叫什麼等級的模型、要不要快取。不理解成本的工程師,容易設計出用起來爽但賣不起錢的系統。

Token 成本的全貌

很多人只知道「輸入 token 比輸出 token 便宜」,但 Anthropic 的計費實際上有四個維度:

Input Tokens(輸入 token):你傳給模型的所有文字——system prompt、用戶訊息、工具結果、對話歷史。這是最大宗的費用來源。

以 claude-3-5-sonnet-20241022 為例,定價是 $3 / 1M tokens(2026 年初的定價,請以 Anthropic 官方定價頁面為準)。

Output Tokens(輸出 token):模型生成的文字——回覆內容、思考鏈(如果啟用)、工具呼叫參數。輸出 token 比輸入貴,Sonnet 是 $15 / 1M tokens——整整貴了五倍。這意味著讓模型少說一點話,比讓你少說一點話更有效益。

Cache Read Tokens(快取讀取 token):Prompt Caching 命中時,快取部分的 token 計費為 $0.30 / 1M——只有標準輸入的十分之一。快取是成本優化最強力的武器。

Cache Write Tokens(快取寫入 token):建立快取時,需要支付 $3.75 / 1M(比標準輸入稍貴)。但只要快取被讀取 1 次,就已大幅回本(寫入多付的 $0.75,對比讀取省下的 $2.70)。

另外還有 Tool Use 的 token——工具的描述(tools 參數中的 JSON)也算 input tokens。如果你有很多工具,工具描述本身就可能佔用 1000-3000 tokens,每次呼叫都在計費。

用這個表格來做成本估算:

類型claude-3-5-haikuclaude-3-5-sonnetclaude-opus-4
Input$0.80/1M$3/1M$15/1M
Output$4/1M$15/1M$75/1M
Cache Read$0.08/1M$0.30/1M$1.50/1M
Cache Write$1/1M$3.75/1M$18.75/1M

(以上為撰寫時的定價,請參考官方 pricing 頁面

模型選擇策略:用最便宜能完成任務的模型

這是最直接的成本優化策略,也最容易被忽視。

我見過很多系統,不管什麼任務都用 Sonnet——分類一個客服問題?Sonnet。判斷情感是正面還是負面?Sonnet。把一段文字翻譯成英文?Sonnet。

Haiku 的定價是 Sonnet 的 1/4 左右。如果你用 Haiku 能做的事,就不要用 Sonnet。

我的模型選擇框架:

用 Haiku(最便宜)的場景:

  • 分類任務(這封信是垃圾郵件嗎?這個問題屬於哪個類別?)
  • 簡單的資料提取(從文字中提取名字、日期、金額)
  • 格式轉換(把 JSON 轉成 CSV)
  • 快速的是/否判斷

用 Sonnet(中等)的場景:

  • 一般的對話和問答
  • 中等複雜的程式碼生成
  • 文件摘要
  • 大多數的 RAG 應用

用 Opus(最貴)的場景:

  • 複雜的多步驟推理
  • 需要深度分析的長文件
  • 高風險的決策(法律文件分析、財務建議)
  • 你需要最高品質且預算不是問題

在實際應用中,我推薦分層模型策略(Model Routing)

import anthropic

client = anthropic.Anthropic()

def classify_query(user_message: str) -> str:
    """用 Haiku 分類查詢的複雜度,決定用什麼模型回答"""
    response = client.messages.create(
        model="claude-3-5-haiku-20241022",  # 用 Haiku 做分類
        max_tokens=10,
        messages=[{"role": "user", "content": f"""
判斷以下查詢的複雜度:
"{user_message}"

只回答 "simple"、"medium" 或 "complex":
- simple:直接的事實查詢、簡單的是/否問題
- medium:需要一些推理、綜合多個資訊
- complex:需要深度分析、多步驟推理、高風險決策
"""}]
    )
    return response.content[0].text.strip().lower()

def route_and_answer(user_message: str, context: str) -> str:
    """根據查詢複雜度選擇適當的模型"""
    complexity = classify_query(user_message)

    model_map = {
        "simple": "claude-3-5-haiku-20241022",
        "medium": "claude-3-5-sonnet-20241022",
        "complex": "claude-opus-4-5",
    }

    model = model_map.get(complexity, "claude-3-5-sonnet-20241022")
    print(f"使用模型: {model} (複雜度: {complexity})")

    response = client.messages.create(
        model=model,
        max_tokens=1024,
        messages=[
            {"role": "user", "content": f"Context: {context}\n\nQuestion: {user_message}"}
        ]
    )

    return response.content[0].text

這個策略讓你的系統自動把簡單問題路由到便宜的模型,複雜問題才用昂貴的模型。根據我的經驗,80% 的查詢屬於 simple 或 medium,只有 20% 需要 Sonnet 等級。光是這個策略就能把成本降低 40-60%。

Prompt Caching:最強力的成本武器

Prompt Caching 是 Anthropic 在 2024 年推出的功能,我認為它是迄今為止最重要的成本優化工具。

核心概念:如果你的 prompt 有一部分是固定的(system prompt、背景文件、工具描述),Anthropic 可以把這部分快取起來。下次請求如果包含相同的前綴,直接讀快取,費用只有標準輸入的十分之一。

快取的觸發條件:

  • 前綴必須超過 1024 tokens(小於這個值不值得快取)
  • 使用 cache_control: {"type": "ephemeral"} 標記快取斷點
  • 快取有效時間:5 分鐘(5 分鐘沒有相同前綴的請求就過期)
import anthropic

client = anthropic.Anthropic()

# 假設這是你的知識庫文件(幾千 tokens)
knowledge_base = """
[你的公司知識庫內容,可能有 10,000+ tokens...]
這裡是關於產品的說明...
這裡是常見問題解答...
這裡是退款政策...
[更多內容...]
"""

def answer_with_caching(user_question: str) -> anthropic.Message:
    """使用 Prompt Caching 的 RAG 系統"""
    return client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        system=[
            {
                "type": "text",
                "text": "你是公司的客服 AI 助手。請根據以下知識庫內容回答用戶問題。"
            },
            {
                "type": "text",
                "text": knowledge_base,
                "cache_control": {"type": "ephemeral"}  # 標記這裡是快取斷點
            }
        ],
        messages=[
            {"role": "user", "content": user_question}
        ]
    )

# 第一次請求:需要寫入快取(稍貴)
response1 = answer_with_caching("退款政策是什麼?")
usage1 = response1.usage
print(f"Cache creation: {usage1.cache_creation_input_tokens} tokens")
print(f"Cache read: {usage1.cache_read_input_tokens} tokens")  # 第一次:0

# 第二次請求(5 分鐘內):命中快取
response2 = answer_with_caching("如何申請退貨?")
usage2 = response2.usage
print(f"Cache read: {usage2.cache_read_input_tokens} tokens")  # 第二次:命中快取!
# 知識庫的 tokens 現在按 $0.30/1M 計費,而不是 $3/1M

如何最大化快取命中率:

  1. 把穩定的內容放在前面,動態的內容放在後面。 快取是前綴匹配——只要前面相同,後面不同也算命中。

  2. 快取斷點要謹慎放置。 如果你有多個可快取的段落,每個段落都加 cache_control,但注意每個斷點都需要前面的整個前綴完全匹配。

  3. 對話歷史的快取策略。 在長對話中,把累積的對話歷史也標記為快取:

def chat_with_caching(
    messages: list[dict],
    new_user_message: str,
    system_prompt: str,
    knowledge_base: str
) -> str:
    """長對話中的快取策略"""

    # 建立新的訊息列表
    all_messages = messages.copy()

    # 對最後一條 assistant 訊息加快取標記(代表到目前為止的對話歷史)
    if all_messages and all_messages[-1]["role"] == "assistant":
        last_msg = all_messages[-1].copy()
        if isinstance(last_msg["content"], str):
            last_msg["content"] = [
                {
                    "type": "text",
                    "text": last_msg["content"],
                    "cache_control": {"type": "ephemeral"}
                }
            ]
        all_messages[-1] = last_msg

    # 加入新的用戶訊息
    all_messages.append({"role": "user", "content": new_user_message})

    response = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        system=[
            {"type": "text", "text": system_prompt},
            {
                "type": "text",
                "text": knowledge_base,
                "cache_control": {"type": "ephemeral"}
            }
        ],
        messages=all_messages
    )

    return response.content[0].text

我的目標是讓 cache hit rate 超過 80%——也就是說,80% 以上的請求中,大部分 input tokens 都來自快取。這個數字對一個活躍的服務(每分鐘有多個請求)是可達到的。

Output Length 控制

輸出 token 比輸入貴 5 倍,控制輸出長度是重要的成本槓桿。

設定合理的 max_tokens。 很多開發者直接設 4096 或更高,但如果你的用例只需要幾百 token 的回覆,這樣做不只浪費(模型可能輸出比需要多的內容),也讓用戶等待更長時間。

根據用例設定 max_tokens:

  • 客服問答:512-1024
  • 文件摘要:1024-2048
  • 長文章生成:2048-4096
  • 程式碼生成:視複雜度而定

在 prompt 中明確要求簡潔:

system_prompt = """
你是客服助手。回答用戶問題時:
- 直接給出答案,不要有前言和後記
- 使用條列式格式
- 回答控制在 200 字以內
- 如果問題複雜,告訴用戶可以進一步詢問,而不是一次說完所有內容
"""

一個設計良好的 prompt,讓模型知道「夠了就停」,不要追求詳細。對多數用戶來說,精簡的回覆其實比詳細的回覆更好——AI 的「詳細」往往包含大量廢話。

Batch API:節省 50% 的懶人救星

如果你的任務不需要即時回應(例如批次處理文件、離線分析),用 Batch API 可以省下 50% 的費用。

Batch API 的工作方式:你一次送出多個請求,Anthropic 在 24 小時內處理完,費用是標準 API 的一半。

import anthropic
import json

client = anthropic.Anthropic()

# 準備批次請求
requests = []
documents = ["文件 1 的內容...", "文件 2 的內容...", "文件 3 的內容..."]

for i, doc in enumerate(documents):
    requests.append({
        "custom_id": f"doc-{i}",
        "params": {
            "model": "claude-3-5-sonnet-20241022",
            "max_tokens": 512,
            "messages": [
                {
                    "role": "user",
                    "content": f"請摘要以下文件(100 字以內):\n\n{doc}"
                }
            ]
        }
    })

# 送出批次請求
batch = client.messages.batches.create(requests=requests)
print(f"Batch ID: {batch.id}")
print(f"狀態: {batch.processing_status}")

# 等待完成(可以隔幾個小時再查)
import time
while True:
    batch_status = client.messages.batches.retrieve(batch.id)
    if batch_status.processing_status == "ended":
        break
    print(f"還在處理中... ({batch_status.request_counts.processing} 個請求)")
    time.sleep(60)

# 取得結果
for result in client.messages.batches.results(batch.id):
    if result.result.type == "succeeded":
        print(f"{result.custom_id}: {result.result.message.content[0].text}")
    else:
        print(f"{result.custom_id}: 失敗 - {result.result.error}")

適合 Batch API 的場景:

  • 每晚批次生成隔天的報告
  • 離線的文件分類和標記
  • 測試時跑大量的 evaluation
  • 定期更新快取的知識庫摘要

Context Window 管理:不要無限累積對話歷史

這是很多對話 AI 應用的常見問題:每輪對話都把完整的歷史塞進去,越說越長,費用越來越高。

正確的做法是有策略地管理對話歷史

from anthropic import Anthropic

client = Anthropic()

class ConversationManager:
    def __init__(self, max_history_tokens: int = 4000):
        self.messages = []
        self.max_history_tokens = max_history_tokens

    def estimate_tokens(self, messages: list) -> int:
        """粗略估算 token 數(1 token ≈ 4 個英文字元 / 2 個中文字元)"""
        total_chars = sum(
            len(m["content"]) if isinstance(m["content"], str)
            else sum(len(c.get("text", "")) for c in m["content"])
            for m in messages
        )
        return total_chars // 3  # 保守估算

    def trim_history(self):
        """保留最近的對話,確保不超過 token 限制"""
        while len(self.messages) > 2 and self.estimate_tokens(self.messages) > self.max_history_tokens:
            # 移除最早的一組對話(user + assistant)
            self.messages = self.messages[2:]

    def chat(self, user_message: str, system_prompt: str) -> str:
        self.messages.append({"role": "user", "content": user_message})
        self.trim_history()

        response = client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=1024,
            system=system_prompt,
            messages=self.messages
        )

        assistant_message = response.content[0].text
        self.messages.append({"role": "assistant", "content": assistant_message})

        return assistant_message

    def summarize_and_reset(self, system_prompt: str):
        """當對話太長,先讓 AI 做摘要,然後重置歷史"""
        if len(self.messages) < 4:
            return

        # 用 Haiku 做對話摘要(便宜)
        summary_response = client.messages.create(
            model="claude-3-5-haiku-20241022",
            max_tokens=512,
            messages=[
                {
                    "role": "user",
                    "content": f"請用 200 字摘要以下對話的重點:\n\n{json.dumps(self.messages, ensure_ascii=False)}"
                }
            ]
        )

        summary = summary_response.content[0].text

        # 重置歷史,只保留摘要
        self.messages = [
            {
                "role": "user",
                "content": f"[對話摘要:{summary}]\n\n繼續我們的對話。"
            },
            {
                "role": "assistant",
                "content": "好的,我了解之前的對話內容。請繼續。"
            }
        ]

監控成本:每個功能都應該知道它花了多少錢

你不能優化你不監控的東西。我的做法是在每次 API 呼叫後記錄 token 使用量:

import anthropic
from dataclasses import dataclass
from datetime import datetime
import logging

logger = logging.getLogger(__name__)

@dataclass
class APICallMetrics:
    feature: str
    model: str
    input_tokens: int
    output_tokens: int
    cache_read_tokens: int
    cache_creation_tokens: int
    timestamp: datetime

    def estimated_cost_usd(self) -> float:
        """估算這次呼叫的美金成本"""
        pricing = {
            "claude-3-5-haiku-20241022": {
                "input": 0.80/1_000_000,
                "output": 4.00/1_000_000,
                "cache_read": 0.08/1_000_000,
                "cache_write": 1.00/1_000_000,
            },
            "claude-3-5-sonnet-20241022": {
                "input": 3.00/1_000_000,
                "output": 15.00/1_000_000,
                "cache_read": 0.30/1_000_000,
                "cache_write": 3.75/1_000_000,
            },
        }

        p = pricing.get(self.model, pricing["claude-3-5-sonnet-20241022"])
        return (
            self.input_tokens * p["input"] +
            self.output_tokens * p["output"] +
            self.cache_read_tokens * p["cache_read"] +
            self.cache_creation_tokens * p["cache_write"]
        )

def track_api_call(feature: str, response: anthropic.Message) -> APICallMetrics:
    """從 API 回應中提取並記錄 metrics"""
    usage = response.usage
    metrics = APICallMetrics(
        feature=feature,
        model=response.model,
        input_tokens=usage.input_tokens,
        output_tokens=usage.output_tokens,
        cache_read_tokens=getattr(usage, 'cache_read_input_tokens', 0),
        cache_creation_tokens=getattr(usage, 'cache_creation_input_tokens', 0),
        timestamp=datetime.now()
    )

    logger.info(
        "api_call",
        extra={
            "feature": feature,
            "model": response.model,
            "cost_usd": metrics.estimated_cost_usd(),
            "cache_hit_rate": metrics.cache_read_tokens / (metrics.input_tokens or 1),
        }
    )

    return metrics

把這些 metrics 送到你的監控系統(Datadog、Grafana、或簡單的 Google Sheets),你就能看到:哪個功能最貴、快取命中率如何、模型選擇是否合適。

真實案例:從 $2000 降到 $287

回到這章開頭的那個 RAG 系統。這是我具體做了什麼:

問題診斷(第一週)

首先我加入了 token tracking,看清楚費用來自哪裡:

  • 70% 的費用來自 input tokens(主要是知識庫文件每次都重新傳)
  • 20% 來自 output tokens(模型的回覆比必要的長)
  • 10% 來自 Sonnet 用於所有查詢(包括只需要 Haiku 的簡單問題)

修正 1:實施 Prompt Caching(最大效益)

知識庫文件大約 15,000 tokens,每次查詢都要傳一次。加入 cache 之後,命中率達到 85%——這 15,000 tokens 中的 85% 從 $3/1M 降到 $0.30/1M。

光這一項,input tokens 費用降了 75%。

修正 2:模型路由(次大效益)

分析後發現,60% 的查詢是簡單的事實查詢(「退款期限是幾天?」「服務時間是?」),完全不需要 Sonnet。改用 Haiku 後,這 60% 的查詢費用降到原來的 20%。

修正 3:Output length 控制

修改 system prompt,明確要求簡潔回覆,平均 output tokens 從 420 降到 180。

修正 4:對話歷史截斷

原本每輪對話都傳完整歷史,改為最多保留 6 輪(避免 context 無限增長)。

最終結果:

項目修改前修改後降幅
知識庫 input tokens$1,200$220-82%
Output tokens$650$280-57%
模型費用$491包含在上面-
總計$2,341$287-88%

兩個最重要的 takeaway:

  1. Prompt Caching 是 ROI 最高的優化,如果你有重複的大型 prompt,先把這個做好。
  2. 測量再優化,不要猜——先加入 monitoring,搞清楚費用來自哪裡,再針對性地優化。

省了錢之後,下一個問題是:怎麼讓這個省錢的系統在生產環境穩定跑?錯誤處理、Rate Limit、可觀測性——這些是我們下一章要解決的問題。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →