精華筆記

· @aihub.tw

Claude API 開發實戰

綜合實戰:客服 API 服務上線

綜合實戰:客服 API 服務上線

前七課你已經逐一學會:呼叫 API、選模型、設計 System Prompt、保證拿到合法 JSON、Tool Use、Streaming、Prompt Caching 與 Batch。但每個技術點在課堂上看起來都很簡單——真正讓開發者痛苦的,是把它們組合在一起、部署到生產環境之後。

那一刻你會遇到的問題通常是:API 偶爾超時把整個 Worker 堵死;System Prompt 五千字沒加 cache_control,每次呼叫都燒一倍的 token;金鑰寫死進程式被同事推上 GitHub;客戶端等了十秒才看到第一個字,使用體驗很差。這堂課就是把以上這些問題全部提前埋好地雷、提前拆彈。

這堂課適合誰 適合:想把 Claude API 整合進自己產品的後端工程師或全端開發者(本課屬工程師專區)。需要基礎:Python 基礎(FastAPI 不熟沒關係,會看程式碼就行),或 Node.js/Express 基礎擇一即可。前置課:第 1–7 課全部,尤其是第 4 課(結構化輸出)、第 6 課(Streaming)、第 7 課(Prompt Caching)。

這堂學什麼

  • 用 FastAPI 包一個完整的客服 endpoint:System Prompt + FAQ 注入 + Prompt Caching + 結構化輸出 + Streaming
  • 重試策略:指數退避 + 最大重試次數 + 哪些狀態碼值得重試
  • 超時設定:request timeout、stream idle timeout 各自在哪層設
  • 金鑰輪替:環境變數管理、輪替邏輯、緊急換鑰流程
  • 費用估算:上線前怎麼算一個月燒多少錢
  • 部署選項:Railway、Fly.io、GCP Cloud Run 三者的取捨
  • 六個真實坑:含具體錯誤訊息與解法

整體架構

客服 API 整體架構圖

架構概念:客戶端送一個 POST 請求給你自己的 FastAPI server;server 先做身份驗證(你的 token,不是 Anthropic 的),再把 FAQ 注入 System Prompt、帶上 Prompt Cache headers、呼叫 Claude API。如果需要結構化判斷(例如判斷是退款請求還是技術問題)就先拿 JSON,再用 Streaming 把回覆推給前端。

這個設計有三個核心原則:第一,你的 API 金鑰永遠不出現在客戶端,前端只持有你自己發行的 token;第二,System Prompt 和 FAQ 只快取一次,後續每次對話都省去大量 input token 費用;第三,結構化輸出與 Streaming 分兩個請求,繞開 API 不支援兩者同時使用的限制,同時兼顧分類的準確性與使用者體驗的流暢感。

手把手實戰

Step 1 — 建立專案結構

mkdir cs-api && cd cs-api
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn anthropic pydantic python-dotenv

目錄結構:

cs-api/
├── main.py          # FastAPI 入口
├── config.py        # 金鑰與設定
├── prompts/
│   ├── system.txt   # System Prompt 主體
│   └── faq.txt      # FAQ 靜態內容
├── schemas.py       # 結構化輸出 Pydantic 模型
├── retry.py         # 重試邏輯
└── .env             # 金鑰(不進 git!)

.env 檔——這個檔案要加進 .gitignore,永遠不能推上 git:

ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_API_KEY_BACKUP=sk-ant-...   # 備用金鑰
API_SECRET_TOKEN=your-own-service-token

requirements.txt(部署時用):

fastapi==0.115.0
uvicorn[standard]==0.32.0
anthropic>=0.42.0
pydantic>=2.7.0
python-dotenv>=1.0.0

Step 2 — System Prompt 注入 FAQ + Prompt Caching

prompts/system.txt(主體,幾乎不變,適合快取):

你是「Aria」,{company} 的官方客服助理。
- 回覆語言:繁體中文
- 語氣:專業但親切,每次回覆不超過 200 字
- 無法處理的問題(退款、帳號異常)請引導客戶填寫工單,連結:{ticket_url}
- 不要捏造資料,不確定一律說「我幫你確認後回覆」
- 不回答與產品無關的問題,禮貌拒絕即可

config.py — 組裝 System Prompt 並加 cache_control:

from pathlib import Path
from dotenv import load_dotenv
import os

load_dotenv()

SYSTEM_PROMPT_TEMPLATE = Path("prompts/system.txt").read_text(encoding="utf-8")
FAQ_CONTENT            = Path("prompts/faq.txt").read_text(encoding="utf-8")

def build_system_blocks(company: str, ticket_url: str) -> list[dict]:
    """
    把 system prompt 拆成兩個 block:
    - block[0]: 主體 prompt(長、幾乎不變) → cache_control ephemeral
    - block[1]: FAQ 內容(長、幾乎不變) → cache_control ephemeral

    兩個 block 各自獨立快取。快取命中後,input token 費用只剩
    原價的 10%(Sonnet 4.6 是 $0.30/MTok,而非 $3.00/MTok)。
    """
    system_text = SYSTEM_PROMPT_TEMPLATE.format(
        company=company, ticket_url=ticket_url
    )
    return [
        {
            "type": "text",
            "text": system_text,
            "cache_control": {"type": "ephemeral"},
        },
        {
            "type": "text",
            "text": f"## 常見問題知識庫\n\n{FAQ_CONTENT}",
            "cache_control": {"type": "ephemeral"},
        },
    ]

Prompt Caching 只要 system block 超過 1,024 tokens 就划算。第一次呼叫是 cache write(費用 1.25 倍),之後每次命中只付 cache read(費用 0.10 倍)。一個 3,000 token 的 System Prompt,每天 1,000 次呼叫,快取命中後每月節省約 $243(用 Sonnet 4.6 算)。

Step 3 — 定義結構化輸出 Schema

schemas.py:

from pydantic import BaseModel, Field
from enum import Enum

class IntentCategory(str, Enum):
    refund   = "refund"
    technical = "technical"
    billing  = "billing"
    general  = "general"

class ClassifyResult(BaseModel):
    intent:   IntentCategory
    urgency:  int  = Field(description="緊急程度 1–5,5 最緊急")
    summary:  str  = Field(description="一句話摘要,給客服人員看的內部備注")

class CustomerReply(BaseModel):
    classify:     ClassifyResult
    reply_text:   str  = Field(description="給客戶看的回覆正文,繁體中文,不超過 200 字")
    needs_human:  bool = Field(description="是否需要轉人工客服")

用 Pydantic .parse() 做結構化輸出呼叫(GA 版,不需要 beta header):

import os
from anthropic import Anthropic
from schemas import CustomerReply

client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

def classify_and_draft(user_msg: str, system_blocks: list) -> CustomerReply:
    """
    用結構化輸出拿到 JSON 分類結果。
    這個函式用同步 Anthropic client(不是 Async),因為它只在
    非 streaming 路徑呼叫,結果拿到後才決定要不要開 streaming。
    """
    resp = client.messages.parse(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=system_blocks,
        messages=[{"role": "user", "content": user_msg}],
        output_format=CustomerReply,  # SDK 自動把 Pydantic 轉成 JSON Schema
    )

    # stop_reason 防禦:拒絕或截斷時都要攔截
    if resp.stop_reason == "refusal":
        raise ValueError("模型拒絕回應(可能含不當內容)")
    if resp.stop_reason == "max_tokens":
        raise ValueError("輸出被截斷,請增加 max_tokens")

    return resp.parsed_output   # 已驗證的 CustomerReply 物件

結構化輸出兩路流程圖

Step 4 — FastAPI Streaming Endpoint

main.py:

import os, json
from fastapi import FastAPI, HTTPException, Header, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from anthropic import AsyncAnthropic, APIStatusError
from dotenv import load_dotenv
from config import build_system_blocks
from schemas import classify_and_draft, CustomerReply

load_dotenv()

app = FastAPI()

# Streaming 一定要用 AsyncAnthropic,避免堵住 event loop
async_client = AsyncAnthropic(
    api_key=os.environ["ANTHROPIC_API_KEY"],
    timeout=30.0,   # 非 streaming 請求的最長等待秒數
)

SYSTEM_BLOCKS = build_system_blocks(
    company="Acme Inc.",
    ticket_url="https://support.acme.com/ticket",
)

class ChatRequest(BaseModel):
    message:    str
    session_id: str

def verify_token(x_api_token: str = Header(...)):
    """簡易 token 驗證:前端帶 X-Api-Token header。"""
    if x_api_token != os.environ["API_SECRET_TOKEN"]:
        raise HTTPException(status_code=401, detail="Invalid token")

@app.post("/chat")
async def chat(
    req: ChatRequest,
    _: None = Depends(verify_token),
):
    # 1. 先做結構化分類(同步,用一般 Anthropic client)
    try:
        classified: CustomerReply = classify_and_draft(req.message, SYSTEM_BLOCKS)
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e))

    # 2. 需要轉人工時直接回 JSON
    if classified.needs_human:
        return {
            "type":       "redirect",
            "intent":     classified.classify.intent,
            "ticket_url": "https://support.acme.com/ticket",
        }

    # 3. 不需轉人工:用 Streaming 推回覆文字給前端
    async def event_stream():
        # 先推一筆 metadata(分類結果),前端可以同步顯示
        meta = {
            "type":    "meta",
            "intent":  classified.classify.intent,
            "urgency": classified.classify.urgency,
        }
        yield f"data: {json.dumps(meta, ensure_ascii=False)}\n\n"

        # 再 stream 正文
        import asyncio
        try:
            async with asyncio.timeout(90):   # stream 最多跑 90 秒
                # 注意:不要把 classified.reply_text 當成 assistant 訊息「prefill」
                # 塞進去——Sonnet 4.6 不支援最後一則 assistant 前綴,會回 400。
                # 分類草稿只在內部使用,streaming 這一趟直接從 user 訊息重新生成。
                async with async_client.messages.stream(
                    model="claude-sonnet-4-6",
                    max_tokens=512,
                    system=SYSTEM_BLOCKS,
                    messages=[
                        {"role": "user", "content": req.message},
                    ],
                ) as stream:
                    async for text in stream.text_stream:
                        chunk = {"type": "text", "delta": text}
                        yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
        except asyncio.TimeoutError:
            yield 'data: {"type":"error","msg":"stream timeout"}\n\n'

        yield "data: [DONE]\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control":               "no-cache",
            "X-Accel-Buffering":           "no",   # 讓 Nginx 不 buffer SSE
        },
    )

一定要用 AsyncAnthropic 而不是同步的 Anthropic。同步客戶端會在 Streaming 期間堵住 uvicorn 的 event loop,造成其他請求全部排隊等待,高流量時直接服務崩潰。

Express(Node.js)版等效寫法:

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

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const app = express();
app.use(express.json());

function verifyToken(req, res, next) {
  if (req.headers["x-api-token"] !== process.env.API_SECRET_TOKEN) {
    return res.status(401).json({ error: "Invalid token" });
  }
  next();
}

app.post("/chat", verifyToken, async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("X-Accel-Buffering", "no");

  try {
    const stream = await anthropic.messages.stream({
      model: "claude-sonnet-4-6",
      max_tokens: 512,
      messages: [{ role: "user", content: req.body.message }],
    });

    for await (const chunk of stream) {
      if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
        const data = { type: "text", delta: chunk.delta.text };
        res.write(`data: ${JSON.stringify(data)}\n\n`);
      }
    }
    res.write("data: [DONE]\n\n");
  } catch (err) {
    res.write(`data: ${JSON.stringify({ type: "error", msg: err.message })}\n\n`);
  }
  res.end();
});

app.listen(3000);

Step 5 — 重試與超時處理

重試策略決策流程圖

retry.py:

import asyncio
from anthropic import APIStatusError, APIConnectionError

# 值得重試的 HTTP 狀態碼(伺服器端暫時性錯誤)
RETRYABLE_STATUS = {500, 502, 503, 529}

async def with_retry(func, max_retries: int = 3, base_delay: float = 1.0):
    """
    指數退避重試。
    - 529 Overloaded / 5xx Server Error → 重試,等待時間 1s/2s/4s
    - 429 Rate Limit → 讀 Retry-After header 決定等待時間
    - 4xx(非 429)→ 立即拋出,不重試(這類錯誤重試沒有意義)
    - APIConnectionError(網路斷) → 重試
    """
    for attempt in range(max_retries + 1):
        try:
            return await func()
        except APIStatusError as e:
            if e.status_code == 429:
                # 讀 Retry-After header,沒有就用 base_delay
                retry_after = float(
                    e.response.headers.get("retry-after", base_delay)
                )
                await asyncio.sleep(retry_after)
                continue
            if e.status_code in RETRYABLE_STATUS and attempt < max_retries:
                wait = base_delay * (2 ** attempt)
                await asyncio.sleep(wait)
                continue
            raise   # 4xx 或超過重試次數:往上拋
        except APIConnectionError:
            if attempt < max_retries:
                await asyncio.sleep(base_delay * (2 ** attempt))
                continue
            raise

# 用法範例:
# result = await with_retry(
#     lambda: async_client.messages.create(model=..., messages=...)
# )

超時設定的正確層次:

# 層次一:SDK 層設 HTTP request timeout(整個非 streaming 請求的上限)
client = AsyncAnthropic(
    api_key=os.environ["ANTHROPIC_API_KEY"],
    timeout=30.0,    # 非 streaming 請求 30 秒沒回應就拋 APITimeoutError
)

# 層次二:Streaming 層用 asyncio.timeout 包 stream 迴圈
# (SDK 的 request timeout 不管 streaming 期間的 idle,要自己設)
async def safe_stream(messages: list):
    async with asyncio.timeout(90):     # 整個 stream 最多 90 秒
        async with client.messages.stream(
            model="claude-sonnet-4-6",
            max_tokens=512,
            messages=messages,
        ) as stream:
            async for text in stream.text_stream:
                yield text

不同層次各司其職:SDK timeout 管「等第一個回應等多久」,asyncio.timeout 管「整段 stream 跑多久」。兩個都要設,缺一個都可能讓請求無限期掛著。

Step 6 — 金鑰輪替

金鑰輪替架構圖

import os, asyncio
from anthropic import AsyncAnthropic, APIStatusError

class KeyManager:
    """
    雙金鑰輪替:主金鑰失效時自動切到備用金鑰,
    同時觸發警報讓你去申請新金鑰替換 BACKUP。
    """
    def __init__(self):
        self._keys = [
            os.environ["ANTHROPIC_API_KEY"],
            os.environ.get("ANTHROPIC_API_KEY_BACKUP", ""),
        ]
        self._active = 0

    def current_key(self) -> str:
        return self._keys[self._active]

    def client(self) -> AsyncAnthropic:
        return AsyncAnthropic(api_key=self.current_key())

    def rotate(self):
        self._active = (self._active + 1) % len(self._keys)
        print(f"[ALERT] API key rotated → index {self._active}")
        # 接 Slack webhook / PagerDuty:
        # requests.post(SLACK_WEBHOOK, json={"text": "Claude API key rotated!"})

key_manager = KeyManager()

async def call_with_key_fallback(messages: list, **kwargs):
    """最多嘗試兩把金鑰,401 才輪替。"""
    for _ in range(2):
        try:
            return await key_manager.client().messages.create(
                messages=messages, **kwargs
            )
        except APIStatusError as e:
            if e.status_code == 401:
                key_manager.rotate()
                continue
            raise

緊急換鑰 SOP(零停機):

1. Anthropic Console → API Keys → 建立新金鑰,複製新 key
2. 更新部署環境(Railway/Fly.io/Cloud Run)的環境變數:
   ANTHROPIC_API_KEY        = <新金鑰>
   ANTHROPIC_API_KEY_BACKUP = <舊金鑰(暫時保留,還沒 revoke)>
3. 觀察 5 分鐘,確認新金鑰呼叫正常、日誌無錯誤
4. 回 Console revoke 舊金鑰
5. 不需要重啟 server:KeyManager.client() 每次呼叫都重新讀取環境變數

Step 7 — 上線前費用估算

先確認 2026 年 7 月的定價(每百萬 token,美元):

模型 Input Output Cache Read Cache Write
claude-haiku-4-5 $1.00 $5.00 $0.10 $1.25
claude-sonnet-4-6 $3.00 $15.00 $0.30 $3.75
claude-opus-4-8 $5.00 $25.00 $0.50 $6.25

估算函式——上線前先跑這個,心裡有底:

def estimate_monthly_cost(
    daily_requests:    int,
    system_tokens:     int,    # system prompt + FAQ 的 token 數
    avg_user_tokens:   int,    # 平均每次使用者輸入的 token 數
    avg_output_tokens: int,    # 平均 Claude 回覆的 token 數
    cache_hit_rate:    float,  # 預估快取命中率,建議保守用 0.8
    model: str = "claude-sonnet-4-6",
) -> dict:
    pricing = {
        "claude-haiku-4-5":  {"in": 1.0,  "out": 5.0,  "cr": 0.10, "cw": 1.25},
        "claude-sonnet-4-6": {"in": 3.0,  "out": 15.0, "cr": 0.30, "cw": 3.75},
        "claude-opus-4-8":   {"in": 5.0,  "out": 25.0, "cr": 0.50, "cw": 6.25},
    }
    p = pricing[model]
    monthly = daily_requests * 30

    # system token 費用:每次都要 write 一次快取(1.25x),命中後讀取(0.10x)
    cache_write_cost = (system_tokens / 1e6) * p["cw"]
    cache_read_cost  = (system_tokens / 1e6) * p["cr"] * cache_hit_rate
    uncached_in_cost = (system_tokens / 1e6) * p["in"] * (1 - cache_hit_rate)
    user_in_cost     = (avg_user_tokens / 1e6) * p["in"]
    output_cost      = (avg_output_tokens / 1e6) * p["out"]

    per_request = (
        cache_write_cost + cache_read_cost +
        uncached_in_cost + user_in_cost + output_cost
    )
    monthly_total = per_request * monthly

    return {
        "per_request_usd": round(per_request, 6),
        "monthly_usd":     round(monthly_total, 2),
        "monthly_ntd":     round(monthly_total * 32, 0),  # 約 1 USD = 32 NTD
    }


# 情境一:每天 500 通客服對話,Sonnet 4.6
print(estimate_monthly_cost(
    daily_requests=500,
    system_tokens=3000,      # system.txt + faq.txt 合計約 3K tokens
    avg_user_tokens=80,
    avg_output_tokens=200,
    cache_hit_rate=0.85,
    model="claude-sonnet-4-6",
))
# → {"per_request_usd": 0.010827, "monthly_usd": 162.4, "monthly_ntd": 5197.0}

# 情境二:每天 1,000 通
print(estimate_monthly_cost(
    daily_requests=1000,
    system_tokens=3000,
    avg_user_tokens=80,
    avg_output_tokens=200,
    cache_hit_rate=0.85,
    model="claude-sonnet-4-6",
))
# → {"per_request_usd": 0.010827, "monthly_usd": 324.8, "monthly_ntd": 10394.0}

每通對話約 $0.011 USD = 0.35 元台幣,比人工客服的每小時人力成本便宜兩個數量級。這個數字拿去說服主管或客戶時很好用。

Step 8 — 部署選項

三種部署選項對照表

Railway Fly.io GCP Cloud Run
免費額度 $5/月 credits Hobby(限制大) 請求量低時接近免費
冷啟動 有(Hobby) 可設最少 1 機(付費) 有,約 2–5 秒
SSE Streaming 原生支援 原生支援 需設 min-instances≥1
設定複雜度 極低 中(需 Docker)
最推薦情境 快速 PoC / 個人專案 全球低延遲 企業/GCP 生態

Railway 最快上線做法:

# 1. 在根目錄建 Procfile
echo "web: uvicorn main:app --host 0.0.0.0 --port \$PORT" > Procfile

# 2. 安裝 Railway CLI
npm install -g @railway/cli

# 3. 登入、建立專案、部署
railway login
railway init
railway up

# 4. 在 Railway 後台 → Variables 設定環境變數:
# ANTHROPIC_API_KEY / ANTHROPIC_API_KEY_BACKUP / API_SECRET_TOKEN

GCP Cloud Run(有 Docker 環境時):

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
gcloud run deploy cs-api \
  --source . \
  --region asia-east1 \
  --allow-unauthenticated \
  --min-instances 1 \
  --set-env-vars ANTHROPIC_API_KEY=sk-ant-...

注意 Cloud Run 一定要加 --min-instances 1,否則冷啟動會讓 SSE 連線在首次建立時出現 2–5 秒延遲,SSE 客戶端可能超時重連。

常見坑

坑 1:Streaming 和結構化輸出不能同時用在同一個請求

anthropic.BadRequestError: 400
{"error":{"type":"invalid_request_error","message":"structured output is not supported with streaming"}}

這是 API 的已知限制:同一個請求裡不能同時帶 output_config/output_format 和啟用 streaming。解法就是本課的架構——先發一個非 streaming 請求做分類(messages.parse),再發第二個 streaming 請求回覆正文。兩個請求都帶 cache_control,system token 只付一次 cache write,不會多花錢。

坑 2:Prompt Caching 沒有命中,費用沒省到

症狀:Anthropic Console 的 Usage 頁顯示 cache_read_input_tokens 一直是 0,或 Console API 呼叫的回應裡 usage.cache_read_input_tokens = 0

原因通常是:system block 的 text 內容每次呼叫都有細微差異——最常見的是把動態資訊(時間戳、session_id、當前用戶名)混進了 system prompt 的靜態文字裡。快取的邏輯是「完全相同的 text 才命中」,只要內容有一個字元不同就是 cache miss。

解法:system block 只放完全靜態的文字;所有「動態」資訊(用戶名、訂單號、當前時間)移到 messages 裡的 user 訊息。

坑 3:金鑰寫死在程式碼裡進了 git

# 危險!不要這樣做:
client = Anthropic(api_key="sk-ant-api03-xxxxxx")

git 的歷史記錄不會因為你後來刪掉那行就消失——git log -p 還是找得到。一旦 push 上 GitHub,不管 repo 是 public 還是 private,都等同於金鑰外洩(私有 repo 照樣有 bot 在掃)。正確做法:全部用 os.environ["ANTHROPIC_API_KEY"];金鑰只放 .env(加進 .gitignore)或部署平台的環境變數管理介面。已經推上去的話:第一步立刻去 Anthropic Console revoke 舊金鑰、換新的;第二步才是清理 git 歷史。

坑 4:沒設超時,慢請求堵死所有 Worker

症狀:服務在高流量時突然全部請求都卡住、回傳 502,或 uvicorn 日誌顯示請求堆積。原因是 uvicorn 的 async worker 雖然能並發,但如果某個 coroutine 在 await 時卡住(例如 Claude API 遲遲沒回應),它雖然不會「堵」其他 coroutine,但連線數累積多了,前面的 LB/proxy 就開始超時。再加上你沒設 SDK timeout,一個掛住的請求可能等到十分鐘都不斷開。

# SDK 層:非 streaming 最多等 30 秒
client = AsyncAnthropic(timeout=30.0)

# Streaming 層:整段 stream 最多跑 90 秒
async with asyncio.timeout(90):
    async with client.messages.stream(...) as s:
        ...

同時在 Nginx 設 proxy_read_timeout 95s(比 app 層的 90 秒稍長,讓 app 先超時、給前端一個明確的錯誤事件,而不是被 Nginx 直接截斷連線)。

坑 5:重試時重複執行了有副作用的操作

重試邏輯是好的,但如果你的 Tool Use 呼叫會觸發副作用(例如扣款、寄信、送出通知),重試時一樣的操作可能被執行兩次。Claude API 本身目前沒有原生的 idempotency key,需要你在應用層自己實作:

import hashlib, json

def make_idempotency_key(session_id: str, message: str) -> str:
    """同一個 session + 同一條訊息 → 同一把 key。"""
    payload = f"{session_id}:{message}"
    return hashlib.sha256(payload.encode()).hexdigest()[:16]

# 呼叫前先查 Redis:
# key = make_idempotency_key(session_id, user_msg)
# cached = redis.get(key)
# if cached:
#     return json.loads(cached)   # 直接回快取結果,不重發請求
# result = await call_claude(...)
# redis.setex(key, 300, json.dumps(result))   # 快取 5 分鐘

坑 6:模型 ID 用點號或舊格式寫錯

anthropic.NotFoundError: 404
{"error":{"type":"not_found_error","message":"model: claude-sonnet-4.6 not found"}}

四代之後的模型 ID 格式是「連字號分隔」:claude-haiku-4-5claude-sonnet-4-6claude-opus-4-8。常見錯誤寫法包括用點號(claude-sonnet-4.6)、用三代的命名格式(claude-3-5-sonnet-20241022)、或用已退役的版本號。強烈建議把模型 ID 集中在一個常數檔維護:

# constants.py — 2026-07 查證版本,需要升版時只改這一檔
MODEL_FAST     = "claude-haiku-4-5"    # $1/$5 per MTok,快速分類
MODEL_BALANCED = "claude-sonnet-4-6"   # $3/$15,主要回覆
MODEL_POWER    = "claude-opus-4-8"     # $5/$25,複雜推理

作業

  1. 基本版:把本課的 main.py 在本機跑起來(uvicorn main:app --reload),用 curl 打一個 POST /chat,確認 Streaming 回覆與結構化分類都正常,並在終端機印出 usage.cache_creation_input_tokensusage.cache_read_input_tokens——第二次呼叫應該能看到 cache read 的數字。

  2. 進階版:把你自己產品的 FAQ 內容貼進 prompts/faq.txt,調整 system.txt 的角色設定(換成自己的品牌名稱和工單連結),用費用估算函式算出你的月費預測——然後把這個數字換算成「每一通對話成本多少元台幣」。

  3. 部署版:把整個專案部署到 Railway,拿到公開 URL,用手機實際測試 Streaming——注意手機行動網路的中介 proxy 有時候會把 SSE 訊息 buffer 住,表現為「等很久才一次收到所有文字」。如果遇到這個問題,在 Response header 加上 X-Accel-Buffering: noConnection: keep-alive 通常能解決。

  4. 費用驗證:部署後跑一天,到 Anthropic Console → Usage 確認 cache_read_input_tokens 有在累積,代表 Prompt Caching 確實命中。如果數字是 0,回頭對照坑 2 的排查步驟。

下一課預告

這堂課把客服 API 上線了——但你一定很快會遇到下一個瓶頸:FAQ 靜態檔案撐不住。客戶問的問題千變萬化,光靠 system prompt 裡的幾百行 FAQ,一旦遇到沒有涵蓋到的問題,模型可能幻覺或一直說「我不確定」。

解法是 RAG(Retrieval-Augmented Generation):把你的知識庫向量化,每次有問題進來先搜出最相關的段落塞進 context,讓 Claude 只看到對的資料再回答。下一個課程《Claude API 進階實戰:RAG 與 Agent 開發》就從這裡開始——我們會用 Qdrant 向量資料庫搭配 Anthropic Embeddings API,建一個能處理數萬筆文件的客服知識庫,接著再讓 Claude 透過 Tool Use 自主呼叫多個工具、串接多步驟 Agent 工作流。今天學的這套架構,就是那門課的地基。

#Claude API#FastAPI#客服 Bot#Streaming#Structured Outputs#生產環境

← 回所有文章