<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Claude API &amp; Agent SDK 完全指南 - Bobo 的學思山丘</title><description>從 API 呼叫到 Agent 系統：打造你的 AI 應用</description><link>https://bobochen.dev/</link><item><title>完整案例：從 0 到 1 打造 AI 客服系統</title><link>https://bobochen.dev/blog/claude-api-guide-complete-case-study/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-complete-case-study/</guid><description>本書的綜合實戰章——一個完整的 AI 客服系統，涵蓋 Messages API、Tool Use、Prompt Caching、Streaming、Multi-Agent、成本優化的所有核心概念整合。從架構設計到 Cloud Run 部署，用真實程式碼走完全程。</description><pubDate>Fri, 05 Jun 2026 00:00:00 GMT</pubDate><content:encoded>在結束之前，讓我們把整本書學到的東西全部用上。

這一章不是玩具範例——是一個你可以真正部署的 AI 客服系統。它會用到這本書介紹過的所有核心技術：Messages API、Tool Use、Prompt Caching、Streaming、Multi-Agent 架構，以及第十三章的成本優化策略。

我把這個系統叫做 **Helios**（太陽神，意味著「照亮問題」）。

## 系統需求

Helios 需要做這幾件事：

1. **接收用戶問題**：透過 REST API 接受問題，支援 Streaming 回覆
2. **查詢知識庫（RAG）**：在公司的知識庫中搜尋相關文件，作為回覆的依據
3. **判斷是否需要轉人工**：某些問題（退款、投訴、技術問題）應該轉給真人處理
4. **生成高品質回覆**：基於知識庫內容，用自然語言回覆用戶

非功能性需求：

- P95 延遲 &lt; 10 秒（包含 streaming 開始前的等待）
- 每月 API 成本 &lt; $500（假設 1000 名日活用戶）
- 不記錄用戶的個人資訊

## 技術架構

```
用戶
 │
 ▼
FastAPI Server (Cloud Run)
 │
 ├── 分類 Agent (Haiku) ─── 判斷問題類型
 │
 ├── 知識庫查詢工具 ─────── pgvector (Cloud SQL)
 │
 ├── 回覆 Agent (Sonnet) ── 生成回覆 + Streaming
 │
 └── 升級工具 ───────────── Zendesk API (建立工單)

Redis ─── 對話歷史快取（TTL: 1 小時）
```

**技術選擇說明：**

- **FastAPI**：Python 非同步框架，原生支援 async/await，搭配 Anthropic SDK 的 streaming 最方便
- **pgvector**：PostgreSQL 的向量搜尋擴充，不需要另外建立 Pinecone/Weaviate 等獨立服務，維護成本低
- **Redis**：對話歷史的暫存，TTL 設 1 小時，超過就重置
- **Cloud Run**：無伺服器容器部署，只有請求時才消耗資源，成本低

## 資料流設計

一個用戶問題的完整旅程：

```
1. 用戶 POST /chat {&quot;session_id&quot;: &quot;xxx&quot;, &quot;message&quot;: &quot;我的訂單什麼時候到？&quot;}
   ↓
2. 從 Redis 讀取對話歷史
   ↓
3. 分類 Agent（Haiku）判斷：
   - 問題類型（訂單查詢 / 退款 / 技術問題 / 一般諮詢）
   - 是否需要轉人工（高風險問題）
   ↓
4a. 如果需要轉人工 → 建立 Zendesk 工單 → 回覆「已為您轉接專員」
4b. 如果不需要轉人工 → 繼續步驟 5
   ↓
5. 回覆 Agent（Sonnet）：
   - 呼叫知識庫搜尋工具
   - 根據搜尋結果生成回覆
   - Streaming 回覆給用戶
   ↓
6. 把這輪對話加入 Redis（對話歷史）
```

## 環境設定

```bash
# 建立 Poetry 專案
poetry new helios-support
cd helios-support

# 安裝依賴
poetry add anthropic fastapi uvicorn redis psycopg2-binary pgvector
poetry add python-dotenv pydantic

# .env
ANTHROPIC_API_KEY=sk-ant-api03-xxx
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgresql://user:pass@localhost:5432/helios
ZENDESK_SUBDOMAIN=yourcompany
ZENDESK_EMAIL=support@yourcompany.com
ZENDESK_API_TOKEN=xxx
```

## 知識庫（RAG 層）

首先建立知識庫的資料存取層：

```python
# helios/knowledge_base.py
import psycopg2
from pgvector.psycopg2 import register_vector
import anthropic
import os
from dataclasses import dataclass

client = anthropic.Anthropic()

@dataclass
class Document:
    id: str
    title: str
    content: str
    similarity: float

def embed_text(text: str) -&gt; list[float]:
    &quot;&quot;&quot;使用 Anthropic 的 embedding API 把文字轉成向量&quot;&quot;&quot;
    # 注意：Anthropic 目前沒有自己的 embedding model
    # 建議使用 OpenAI text-embedding-3-small 或 Google text-embedding-004
    # 這裡假設你已經設定好 embedding function
    # 使用你偏好的 embedding service
    raise NotImplementedError(&quot;請替換成你的 embedding service&quot;)

def search_knowledge_base(
    query: str,
    limit: int = 3,
    similarity_threshold: float = 0.7
) -&gt; list[Document]:
    &quot;&quot;&quot;在知識庫中搜尋相關文件&quot;&quot;&quot;
    query_vector = embed_text(query)

    conn = psycopg2.connect(os.environ[&quot;DATABASE_URL&quot;])
    register_vector(conn)

    try:
        with conn.cursor() as cur:
            cur.execute(&quot;&quot;&quot;
                SELECT id, title, content,
                       1 - (embedding &lt;=&gt; %s::vector) AS similarity
                FROM knowledge_base
                WHERE 1 - (embedding &lt;=&gt; %s::vector) &gt; %s
                ORDER BY similarity DESC
                LIMIT %s
            &quot;&quot;&quot;, (query_vector, query_vector, similarity_threshold, limit))

            rows = cur.fetchall()
            return [
                Document(id=row[0], title=row[1], content=row[2], similarity=row[3])
                for row in rows
            ]
    finally:
        conn.close()
```

## 工具定義

接下來定義 Agent 可以呼叫的工具：

```python
# helios/tools.py
import anthropic
import requests
import json
import os
from .knowledge_base import search_knowledge_base

# 工具定義（傳給 Anthropic API 的 tools 參數）
SUPPORT_TOOLS = [
    {
        &quot;name&quot;: &quot;search_knowledge_base&quot;,
        &quot;description&quot;: &quot;&quot;&quot;搜尋公司知識庫，找到跟用戶問題相關的文件。
        在回覆任何問題之前，應該先用這個工具搜尋相關資訊。
        如果找不到相關資訊，誠實告訴用戶你不知道，不要猜測。&quot;&quot;&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;query&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;搜尋查詢詞，應該是描述問題的關鍵詞&quot;
                }
            },
            &quot;required&quot;: [&quot;query&quot;]
        }
    },
    {
        &quot;name&quot;: &quot;escalate_to_human&quot;,
        &quot;description&quot;: &quot;&quot;&quot;把用戶的問題升級給真人客服專員處理。
        在以下情況應該使用這個工具：
        - 用戶要求退款
        - 用戶表達強烈不滿或憤怒
        - 涉及帳號安全問題
        - 問題超出你的知識範疇，用戶急需解決&quot;&quot;&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;reason&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;升級原因的簡短說明（給客服專員看的，不是給用戶看的）&quot;
                },
                &quot;urgency&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;enum&quot;: [&quot;low&quot;, &quot;medium&quot;, &quot;high&quot;],
                    &quot;description&quot;: &quot;緊急程度&quot;
                }
            },
            &quot;required&quot;: [&quot;reason&quot;, &quot;urgency&quot;]
        }
    }
]

def execute_tool(tool_name: str, tool_input: dict) -&gt; str:
    &quot;&quot;&quot;執行工具並回傳結果&quot;&quot;&quot;
    if tool_name == &quot;search_knowledge_base&quot;:
        docs = search_knowledge_base(tool_input[&quot;query&quot;])
        if not docs:
            return &quot;知識庫中找不到相關資訊。&quot;

        results = []
        for doc in docs:
            results.append(f&quot;## {doc.title}\n{doc.content[:500]}&quot;)
        return &quot;\n\n&quot;.join(results)

    elif tool_name == &quot;escalate_to_human&quot;:
        ticket_id = create_zendesk_ticket(
            reason=tool_input[&quot;reason&quot;],
            urgency=tool_input[&quot;urgency&quot;]
        )
        return json.dumps({
            &quot;escalated&quot;: True,
            &quot;ticket_id&quot;: ticket_id,
            &quot;message&quot;: f&quot;已建立工單 #{ticket_id}，專員將在 24 小時內與您聯繫&quot;
        })

    return f&quot;ERROR: 未知工具 {tool_name}&quot;

def create_zendesk_ticket(reason: str, urgency: str) -&gt; str:
    &quot;&quot;&quot;建立 Zendesk 工單（簡化版）&quot;&quot;&quot;
    url = f&quot;https://{os.environ[&apos;ZENDESK_SUBDOMAIN&apos;]}.zendesk.com/api/v2/tickets.json&quot;
    auth = (
        f&quot;{os.environ[&apos;ZENDESK_EMAIL&apos;]}/token&quot;,
        os.environ[&quot;ZENDESK_API_TOKEN&quot;]
    )

    priority_map = {&quot;low&quot;: &quot;low&quot;, &quot;medium&quot;: &quot;normal&quot;, &quot;high&quot;: &quot;urgent&quot;}

    response = requests.post(url, auth=auth, json={
        &quot;ticket&quot;: {
            &quot;subject&quot;: &quot;AI 客服升級&quot;,
            &quot;comment&quot;: {&quot;body&quot;: f&quot;升級原因：{reason}&quot;},
            &quot;priority&quot;: priority_map.get(urgency, &quot;normal&quot;),
            &quot;tags&quot;: [&quot;ai-escalated&quot;]
        }
    })

    if response.ok:
        return str(response.json()[&quot;ticket&quot;][&quot;id&quot;])
    return &quot;UNKNOWN&quot;
```

## 對話管理

```python
# helios/conversation.py
import redis
import json
import os
from datetime import timedelta

redis_client = redis.from_url(os.environ[&quot;REDIS_URL&quot;])
SESSION_TTL = timedelta(hours=1)

def get_conversation_history(session_id: str) -&gt; list[dict]:
    &quot;&quot;&quot;從 Redis 讀取對話歷史&quot;&quot;&quot;
    data = redis_client.get(f&quot;session:{session_id}&quot;)
    if data:
        return json.loads(data)
    return []

def save_conversation_history(session_id: str, messages: list[dict]):
    &quot;&quot;&quot;把對話歷史存到 Redis&quot;&quot;&quot;
    redis_client.setex(
        f&quot;session:{session_id}&quot;,
        SESSION_TTL,
        json.dumps(messages, ensure_ascii=False)
    )

def build_messages_with_cache(
    history: list[dict],
    new_message: str
) -&gt; list[dict]:
    &quot;&quot;&quot;建立帶有 Prompt Caching 標記的訊息列表&quot;&quot;&quot;
    messages = history.copy()

    # 如果歷史超過 6 輪，截斷最舊的
    if len(messages) &gt; 12:  # 6 輪 = 12 條訊息
        messages = messages[-12:]

    # 對倒數第二條訊息（最新的 assistant 訊息）加快取標記
    for i in range(len(messages) - 1, -1, -1):
        if messages[i][&quot;role&quot;] == &quot;assistant&quot;:
            msg = messages[i].copy()
            content = msg[&quot;content&quot;]
            if isinstance(content, str):
                msg[&quot;content&quot;] = [
                    {
                        &quot;type&quot;: &quot;text&quot;,
                        &quot;text&quot;: content,
                        &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
                    }
                ]
            messages[i] = msg
            break

    messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: new_message})
    return messages
```

## 核心 Agent 邏輯

```python
# helios/agent.py
import anthropic
from typing import AsyncIterator
from .tools import SUPPORT_TOOLS, execute_tool
from .conversation import (
    get_conversation_history,
    save_conversation_history,
    build_messages_with_cache
)

client = anthropic.Anthropic()

# 大型知識庫摘要（幾千 tokens，固定內容，適合快取）
COMPANY_CONTEXT = &quot;&quot;&quot;
你是 Helios 電商平台的 AI 客服助手。

關於我們的服務：
- 商品交貨時間：標準配送 3-5 個工作天，快速配送 1-2 個工作天
- 退貨政策：收到商品 7 天內可申請退貨，商品需保持原狀
- 退款時間：申請通過後 5-10 個工作天退款到原付款方式
- 客服時間：週一到週五 9:00-18:00

常見問題處理準則：
[更多公司政策內容...]
&quot;&quot;&quot;

SYSTEM_PROMPT_BASE = &quot;&quot;&quot;你是 Helios 電商平台的 AI 客服助手，名字叫 Helios。

你的職責：
1. 用友善、專業的態度回應用戶問題
2. 在回覆前，用 search_knowledge_base 工具查詢相關資訊
3. 只根據知識庫中的資訊回覆，不要猜測或編造
4. 遇到退款、投訴、帳號安全問題，主動使用 escalate_to_human 工具

回覆風格：
- 簡潔清晰，避免廢話
- 中文回覆，但保留必要的技術術語
- 如果不知道答案，誠實說明並提供升級選項
&quot;&quot;&quot;

async def stream_support_response(
    session_id: str,
    user_message: str
) -&gt; AsyncIterator[str]:
    &quot;&quot;&quot;
    處理用戶問題，Streaming 回覆。
    這個 generator 會 yield 回覆的文字片段。
    &quot;&quot;&quot;
    # 讀取對話歷史
    history = get_conversation_history(session_id)
    messages = build_messages_with_cache(history, user_message)

    # Agent 的 agentic loop
    current_messages = messages.copy()
    final_response_parts = []
    assistant_message_content = []

    while True:
        # 呼叫 API（使用 streaming）
        with client.messages.stream(
            model=&quot;claude-3-5-sonnet-20241022&quot;,
            max_tokens=1024,
            system=[
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: SYSTEM_PROMPT_BASE,
                },
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: COMPANY_CONTEXT,
                    &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}  # 快取公司資訊
                }
            ],
            tools=SUPPORT_TOOLS,
            messages=current_messages,
        ) as stream:

            current_tool_calls = []
            current_text = &quot;&quot;

            for event in stream:
                if hasattr(event, &apos;type&apos;):
                    if event.type == &apos;content_block_start&apos;:
                        if hasattr(event.content_block, &apos;type&apos;):
                            if event.content_block.type == &apos;text&apos;:
                                pass  # 準備收文字
                            elif event.content_block.type == &apos;tool_use&apos;:
                                current_tool_calls.append({
                                    &quot;id&quot;: event.content_block.id,
                                    &quot;name&quot;: event.content_block.name,
                                    &quot;input&quot;: &quot;&quot;
                                })

                    elif event.type == &apos;content_block_delta&apos;:
                        if hasattr(event.delta, &apos;text&apos;):
                            # 文字片段：直接 yield 給用戶
                            text_chunk = event.delta.text
                            current_text += text_chunk
                            final_response_parts.append(text_chunk)
                            yield text_chunk

                        elif hasattr(event.delta, &apos;partial_json&apos;):
                            # 工具呼叫的參數（累積）
                            if current_tool_calls:
                                current_tool_calls[-1][&quot;input&quot;] += event.delta.partial_json

            # 取得完整的 response
            final_response = stream.get_final_message()

        # 檢查 stop_reason
        if final_response.stop_reason == &quot;end_turn&quot;:
            # 完成！把這輪對話加入歷史
            assistant_content = []
            if current_text:
                assistant_content.append({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: current_text})

            current_messages.append({
                &quot;role&quot;: &quot;assistant&quot;,
                &quot;content&quot;: assistant_content if assistant_content else &quot;好的。&quot;
            })

            # 儲存更新後的對話歷史
            save_conversation_history(session_id, current_messages)
            return

        elif final_response.stop_reason == &quot;tool_use&quot;:
            # 需要執行工具
            tool_results = []

            for content_block in final_response.content:
                if content_block.type == &quot;tool_use&quot;:
                    tool_name = content_block.name
                    tool_input = content_block.input

                    # 執行工具
                    result = execute_tool(tool_name, tool_input)

                    tool_results.append({
                        &quot;type&quot;: &quot;tool_result&quot;,
                        &quot;tool_use_id&quot;: content_block.id,
                        &quot;content&quot;: result
                    })

                    # 如果是升級工具，向用戶發送通知
                    if tool_name == &quot;escalate_to_human&quot;:
                        import json
                        result_data = json.loads(result)
                        yield f&quot;\n\n我已經為您建立工單（#{result_data[&apos;ticket_id&apos;]}），我們的專員將盡快與您聯繫。&quot;

            # 把工具呼叫和結果加入訊息
            current_messages.append({
                &quot;role&quot;: &quot;assistant&quot;,
                &quot;content&quot;: final_response.content
            })
            current_messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: tool_results
            })
            # 繼續 loop，讓 agent 根據工具結果繼續生成回覆

        else:
            # max_tokens 或其他停止原因
            yield &quot;\n\n（回覆已截斷，請繼續詢問）&quot;
            return
```

## FastAPI Server

```python
# helios/main.py
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import uvicorn
import logging

from .agent import stream_support_response

app = FastAPI(title=&quot;Helios AI Support&quot;)
logger = logging.getLogger(__name__)

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

    class Config:
        # 限制輸入長度，防止超長輸入
        max_anystr_length = 2000

@app.post(&quot;/chat&quot;)
async def chat(request: ChatRequest):
    &quot;&quot;&quot;接受用戶問題，返回 Streaming 回覆&quot;&quot;&quot;

    # 基本輸入驗證
    if len(request.message.strip()) &lt; 2:
        raise HTTPException(status_code=400, detail=&quot;問題太短，請重新輸入&quot;)

    if len(request.session_id) &gt; 100:
        raise HTTPException(status_code=400, detail=&quot;無效的 session ID&quot;)

    async def generate():
        try:
            async for chunk in stream_support_response(
                session_id=request.session_id,
                user_message=request.message
            ):
                # SSE 格式
                yield f&quot;data: {chunk}\n\n&quot;
            yield &quot;data: [DONE]\n\n&quot;

        except Exception as e:
            logger.exception(f&quot;Error in chat stream: {e}&quot;)
            yield f&quot;data: 抱歉，發生了技術問題，請稍後再試。\n\n&quot;
            yield &quot;data: [DONE]\n\n&quot;

    return StreamingResponse(
        generate(),
        media_type=&quot;text/event-stream&quot;,
        headers={
            &quot;Cache-Control&quot;: &quot;no-cache&quot;,
            &quot;X-Accel-Buffering&quot;: &quot;no&quot;,  # 禁用 Nginx 緩衝，確保 streaming 正常
        }
    )

@app.get(&quot;/health&quot;)
async def health_check():
    &quot;&quot;&quot;健康檢查 endpoint&quot;&quot;&quot;
    return {&quot;status&quot;: &quot;ok&quot;}

if __name__ == &quot;__main__&quot;:
    uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=8080)
```

## 成本追蹤整合

把第十三章的成本追蹤整合進來：

```python
# helios/metrics.py
import time
import logging
from dataclasses import dataclass

logger = logging.getLogger(__name__)

@dataclass
class RequestMetrics:
    session_id: str
    model: str
    input_tokens: int = 0
    output_tokens: int = 0
    cache_read_tokens: int = 0
    cache_creation_tokens: int = 0
    tool_calls: int = 0
    escalated: bool = False
    latency_ms: float = 0.0

    def log(self):
        # 計算成本
        input_cost = self.input_tokens * 3.0 / 1_000_000
        output_cost = self.output_tokens * 15.0 / 1_000_000
        cache_read_cost = self.cache_read_tokens * 0.30 / 1_000_000
        total_cost = input_cost + output_cost + cache_read_cost

        cache_hit_rate = (
            self.cache_read_tokens / max(self.input_tokens, 1) * 100
        )

        logger.info(&quot;request_completed&quot;, extra={
            &quot;session_id&quot;: self.session_id[:8] + &quot;...&quot;,  # 截斷，避免洩漏
            &quot;model&quot;: self.model,
            &quot;input_tokens&quot;: self.input_tokens,
            &quot;output_tokens&quot;: self.output_tokens,
            &quot;cache_read_tokens&quot;: self.cache_read_tokens,
            &quot;cache_hit_rate_pct&quot;: round(cache_hit_rate, 1),
            &quot;tool_calls&quot;: self.tool_calls,
            &quot;escalated&quot;: self.escalated,
            &quot;latency_ms&quot;: round(self.latency_ms),
            &quot;estimated_cost_usd&quot;: round(total_cost, 6),
        })
```

## 部署到 Cloud Run

**Dockerfile：**

```dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY pyproject.toml poetry.lock ./
RUN pip install poetry &amp;&amp; poetry install --no-dev

COPY helios/ ./helios/

EXPOSE 8080
CMD [&quot;poetry&quot;, &quot;run&quot;, &quot;uvicorn&quot;, &quot;helios.main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8080&quot;]
```

**部署指令：**

```bash
# Build 和 Push 到 Google Container Registry
gcloud builds submit --tag gcr.io/YOUR_PROJECT/helios-support

# 部署到 Cloud Run
gcloud run deploy helios-support \
  --image gcr.io/YOUR_PROJECT/helios-support \
  --platform managed \
  --region asia-east1 \
  --memory 512Mi \
  --concurrency 80 \
  --set-secrets &quot;ANTHROPIC_API_KEY=anthropic-api-key:latest&quot; \
  --set-env-vars &quot;REDIS_URL=redis://10.x.x.x:6379&quot; \
  --set-env-vars &quot;DATABASE_URL=postgresql://user:pass@10.x.x.x/helios&quot; \
  --allow-unauthenticated
```

幾個 Cloud Run 的注意事項：

`--concurrency 80` 代表每個 Cloud Run instance 同時處理 80 個請求。AI streaming 請求會長時間佔用連線，這個值可以根據你的實際情況調整。

Secrets（API Key、資料庫密碼）用 Google Secret Manager 管理，透過 `--set-secrets` 注入，不要放在環境變數裡直接傳。

## 成本估算

這個系統在 1000 名日活用戶、每人平均 5 個問題的情況下：

每天請求數：5,000
每次請求的 token 估算：

- Input（含 COMPANY_CONTEXT）：~3,000 tokens，其中 ~2,500 可以快取
- Output：~300 tokens
- 快取命中後，有效輸入成本：(500 × $3 + 2500 × $0.30) / 1,000,000 = $0.00225 / 請求

每天成本：5,000 × ($0.00225 + 300 × $15/1,000,000) = $11.25 + $22.50 = **$33.75/天**

每月成本：**~$1,000/月**

這比第十三章的目標 $500/月 高一點。要進一步降低，可以：

1. 把分類用的 Haiku 做 routing，簡單問題完全用 Haiku 回答（降至 ~$600）
2. 提高快取命中率（確保 5 分鐘內有足夠的請求維持快取熱度）

## 這本書的旅程

你剛讀完了「Claude API &amp; Agent SDK 完全指南」的最後一章。

我想用幾段話回顧一下我們走過的路程。

這本書是 Claude 三部曲的第三本。第一本（Claude 使用指南）告訴你怎麼跟 Claude 對話。第二本（Claude Code 完全指南）告訴你怎麼讓 AI 幫你寫程式碼。這本書——第三本——告訴你怎麼**用 Claude 建造東西**：真正的應用、真正的系統、真正可以服務用戶的產品。

從第一章的第一個 `client.messages.create()` 呼叫，到這一章的多 agent、streaming、RAG 整合系統——中間走了很長的路。

如果你真的把這本書的範例都跑過了，你現在應該能做這些事：

- 用 Messages API 建立任何對話型應用
- 用 Tool Use 讓 AI 操控外部系統
- 用 Prompt Caching 把成本降到最低
- 用 Streaming 讓用戶體驗更流暢
- 用 Agent SDK 建立多 agent 協作系統
- 開發自己的 MCP Server
- 把這一切部署到生產環境，並且知道怎麼監控和維護

**接下來呢？**

AI 應用開發這個領域正在高速演進。Anthropic 每隔幾個月就會推出新功能——更好的模型、更低的成本、更強的 agent 能力。我的建議是：

**建立一個真實的東西**。不要停在讀書和做練習。找一個你真正有需求的問題，用你在這本書學到的技術解決它。你在解決真實問題的過程中學到的東西，遠比讀任何書都多。

**關注官方文件**。Anthropic 的 [docs.anthropic.com](https://docs.anthropic.com) 是最可靠的資訊來源，這本書的範例和定價資訊可能因版本更新而改變，但官方文件永遠是最新的。

**加入社群**。Anthropic 的 Discord、各種 AI 開發者社群——這些地方有很多正在解決和你類似問題的開發者。

最後，我想說：

我花了很多時間在這三本書裡，試著把「如何真正用好 Claude」說清楚——不是表面的功能介紹，而是讓你能真正建造東西的深度知識。寫作的過程其實也是我重新整理自己理解的過程。

如果這本書對你有幫助，最好的感謝方式，就是去建造一個真實的東西。

祝你的 AI 應用上線順利。

—— Bobo</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BBK6Nb2w.webp" medium="image"/><category>Claude API</category><category>完整案例</category><category>AI 客服</category><category>系統設計</category><enclosure url="https://bobochen.dev/_astro/cover.BBK6Nb2w.webp" length="0" type="image/png"/></item><item><title>生產環境部署：錯誤處理、限流與可觀測性</title><link>https://bobochen.dev/blog/claude-api-guide-production-deployment/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-production-deployment/</guid><description>開發環境跑得好，不代表生產環境沒問題。本章全面解析 Rate Limits、指數退避、錯誤分類、API Key 安全管理、Logging 策略，以及 OpenTelemetry 整合——附 10 項生產環境部署 checklist。</description><pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate><content:encoded>你的應用在開發環境完美運作，每次 demo 都沒出問題。

然後你把它部署到生產環境，十個用戶同時使用，五分鐘後你收到第一個錯誤報告。

這個場景幾乎每個做 AI 應用的工程師都經歷過。開發環境和生產環境之間有個巨大的鴻溝，而這個鴻溝在 AI API 應用中特別明顯。

## 開發環境 vs 生產環境的本質差異

**並發性**：開發環境通常是你一個人在用，偶爾跑幾個測試。生產環境可能有幾十甚至幾百個請求同時進來——這意味著 Rate Limit 問題、資源競爭問題會突然全部冒出來。

**錯誤頻率**：在開發環境，你的請求大部分都成功。生產環境中，網路問題、Anthropic API 的偶發故障、用戶輸入的邊緣 case——這些都會讓你看到你從來沒見過的錯誤。

**可觀測性需求**：開發環境你可以直接 print() 來 debug。生產環境你需要結構化的 log、metrics、追蹤系統，因為當出問題的時候，你不在場。

**成本壓力**：開發環境的錯誤和重試只花你一點時間。生產環境的錯誤和不必要的重試會直接換算成錢。

這一章我要告訴你，一個 AI API 應用要進生產環境，需要做哪些事。

## Rate Limits 完整解析

Anthropic 的 Rate Limit 有四個維度，分別計算：

**RPM（Requests Per Minute）**：每分鐘的請求次數限制。對多數用戶，這個限制在 50-2000 RPM 之間，視你的帳戶 tier 而定。

**TPM（Tokens Per Minute）**：每分鐘的 token 使用量限制。這是最常觸發的限制——一個複雜的請求可能用掉 10K tokens，如果很多請求同時進來，很快就會碰到 TPM 上限。

**ITPM（Input Tokens Per Minute）**：輸入 token 的專屬限制，有些 tier 對輸入 token 有獨立的限制。

**OTPM（Output Tokens Per Minute）**：輸出 token 的專屬限制。如果你的應用需要生成大量長文，OTPM 可能是你最先碰到的瓶頸。

查看你的帳戶限制：

```bash
# 透過 Anthropic Console API 查看
curl https://api.anthropic.com/v1/organizations/limits \
  -H &quot;x-api-key: $ANTHROPIC_API_KEY&quot; \
  -H &quot;anthropic-version: 2023-06-01&quot;
```

或是在 [console.anthropic.com](https://console.anthropic.com) 的 Settings → Limits 頁面查看。

如果你需要更高的限制，可以在 Console 申請提高——通常需要幾個工作天審核，並且需要說明你的使用場景。

當你碰到 Rate Limit，API 會回傳 HTTP 429 狀態碼，response 中會包含 `Retry-After` header，告訴你需要等待幾秒。

## 指數退避的正確實作

碰到 429 的時候，很多初學者的直覺是「等一秒再試」。但這是錯的。

如果你有 100 個並發請求都在等一秒，一秒後它們同時再試，又同時碰到 429，然後又同時等一秒……這個「驚群效應（Thundering Herd）」會讓情況越來越糟。

正確的做法是**指數退避（Exponential Backoff）加上隨機 Jitter**：

```python
import anthropic
import time
import random
import logging
from typing import Optional

logger = logging.getLogger(__name__)

class AnthropicClientWithRetry:
    def __init__(
        self,
        api_key: str,
        max_retries: int = 5,
        initial_delay: float = 1.0,
        max_delay: float = 60.0,
        exponential_base: float = 2.0,
    ):
        self.client = anthropic.Anthropic(api_key=api_key)
        self.max_retries = max_retries
        self.initial_delay = initial_delay
        self.max_delay = max_delay
        self.exponential_base = exponential_base

    def create_message(self, **kwargs) -&gt; anthropic.Message:
        &quot;&quot;&quot;帶有指數退避重試的 message 建立&quot;&quot;&quot;
        last_exception = None

        for attempt in range(self.max_retries + 1):
            try:
                response = self.client.messages.create(**kwargs)
                if attempt &gt; 0:
                    logger.info(f&quot;請求在第 {attempt + 1} 次嘗試成功&quot;)
                return response

            except anthropic.RateLimitError as e:
                last_exception = e
                if attempt == self.max_retries:
                    logger.error(f&quot;達到最大重試次數 ({self.max_retries})，放棄&quot;)
                    raise

                # 從 Retry-After header 取得等待時間（如果有的話）
                retry_after = e.response.headers.get(&quot;Retry-After&quot;)
                if retry_after:
                    wait_time = float(retry_after)
                else:
                    # 指數退避 + jitter
                    wait_time = min(
                        self.initial_delay * (self.exponential_base ** attempt),
                        self.max_delay
                    )
                    # 加入 ±25% 的隨機 jitter，防止驚群效應
                    wait_time *= (0.75 + random.random() * 0.5)

                logger.warning(
                    f&quot;Rate limit 觸發 (429)，等待 {wait_time:.1f} 秒後重試 &quot;
                    f&quot;(第 {attempt + 1}/{self.max_retries} 次)&quot;
                )
                time.sleep(wait_time)

            except anthropic.APIStatusError as e:
                # 5xx 錯誤：Anthropic server 問題，可以重試
                if e.status_code &gt;= 500:
                    last_exception = e
                    if attempt == self.max_retries:
                        raise

                    wait_time = min(
                        self.initial_delay * (self.exponential_base ** attempt),
                        self.max_delay
                    ) * (0.75 + random.random() * 0.5)

                    logger.warning(
                        f&quot;Server error ({e.status_code})，等待 {wait_time:.1f} 秒後重試&quot;
                    )
                    time.sleep(wait_time)
                else:
                    # 4xx 錯誤（除了 429）：客戶端錯誤，不要重試
                    raise

            except anthropic.APIConnectionError as e:
                # 網路連線問題，可以重試
                last_exception = e
                if attempt == self.max_retries:
                    raise

                wait_time = min(
                    self.initial_delay * (self.exponential_base ** attempt),
                    self.max_delay
                )
                logger.warning(f&quot;連線錯誤，等待 {wait_time:.1f} 秒後重試&quot;)
                time.sleep(wait_time)

        raise last_exception
```

這個實作處理了三種可重試的情況：Rate Limit（429）、Server Error（5xx）、連線錯誤。每次重試的等待時間是上一次的 2 倍，再加上隨機 jitter，避免驚群效應。

實際上，Anthropic 的官方 Python SDK 已經內建了類似的重試邏輯：

```python
# SDK 內建重試，最多重試 2 次
client = anthropic.Anthropic(
    api_key=&quot;...&quot;,
    max_retries=2,  # 預設就是 2
    timeout=30.0,   # 請求 timeout（秒）
)
```

但我推薦自己控制重試邏輯，因為你可以加入更細緻的 logging 和 metrics，知道重試發生的頻率——頻繁的重試是一個需要關注的信號。

## 錯誤分類與對應策略

不同類型的錯誤需要不同的處理策略。

**可重試的錯誤：**

| 錯誤類型                  | HTTP 狀態碼 | 說明                  | 策略                     |
| ------------------------- | ----------- | --------------------- | ------------------------ |
| `RateLimitError`          | 429         | 超過 Rate Limit       | 指數退避重試             |
| `InternalServerError`     | 500         | Anthropic server 問題 | 短暫等待後重試           |
| `ServiceUnavailableError` | 503         | 服務維護或過載        | 長時間等待後重試         |
| `APIConnectionError`      | N/A         | 網路問題              | 重試                     |
| `APITimeoutError`         | N/A         | 請求逾時              | 重試（考慮增加 timeout） |

**不可重試的錯誤（客戶端錯誤）：**

| 錯誤類型                | HTTP 狀態碼 | 常見原因                   | 策略                           |
| ----------------------- | ----------- | -------------------------- | ------------------------------ |
| `AuthenticationError`   | 401         | API Key 無效或已撤銷       | 檢查 API Key，告警             |
| `PermissionDeniedError` | 403         | 沒有使用某個功能的權限     | 檢查帳戶設定                   |
| `NotFoundError`         | 404         | 請求了不存在的資源         | 修復 bug                       |
| `BadRequestError`       | 400         | 請求格式錯誤               | 修復 bug，可能需要記錄 request |
| `RequestTooLargeError`  | 413         | 輸入超過 context window     | 截斷輸入，記錄                 |

```python
from anthropic import (
    Anthropic,
    AuthenticationError,
    PermissionDeniedError,
    RateLimitError,
    BadRequestError,
    APIConnectionError,
    InternalServerError,
)

def handle_anthropic_error(e: Exception, context: dict) -&gt; str:
    &quot;&quot;&quot;統一的錯誤處理，回傳用戶友好的訊息&quot;&quot;&quot;

    if isinstance(e, AuthenticationError):
        # 這是嚴重問題——API Key 有問題，要告警給工程師
        logger.critical(&quot;API Key 認證失敗，需要立即檢查&quot;, extra=context)
        # 觸發 PagerDuty/Slack 告警
        alert_on_call_engineer(&quot;API Key 認證失敗&quot;)
        return &quot;服務暫時無法使用，我們已收到通知並正在處理&quot;

    elif isinstance(e, RateLimitError):
        # 已在重試邏輯中處理，到這裡代表重試用盡了
        logger.error(&quot;Rate limit 重試用盡&quot;, extra=context)
        return &quot;目前服務繁忙，請稍後再試&quot;

    elif isinstance(e, BadRequestError):
        # 可能是用戶輸入有問題，也可能是 bug
        error_msg = str(e)
        if &quot;too many tokens&quot; in error_msg.lower():
            logger.warning(&quot;輸入過長&quot;, extra={**context, &quot;error&quot;: error_msg})
            return &quot;您的輸入太長，請縮短後再試&quot;
        else:
            logger.error(&quot;Bad request&quot;, extra={**context, &quot;error&quot;: error_msg})
            return &quot;請求格式有誤，請重試&quot;

    elif isinstance(e, InternalServerError):
        logger.error(&quot;Anthropic server error&quot;, extra=context)
        return &quot;AI 服務暫時不穩定，請稍後再試&quot;

    else:
        logger.exception(&quot;未預期的錯誤&quot;, extra=context)
        return &quot;發生未知錯誤，請重試&quot;
```

## API Key 安全管理

API Key 洩漏是 AI 應用最常見的安全事故之一。我見過有人把 API Key 直接 hardcode 在程式碼裡，commit 到 GitHub，然後一週內被人濫用了幾萬塊錢。

**永遠不要做的事：**

```python
# ❌ 錯誤：hardcode 在程式碼中
client = anthropic.Anthropic(api_key=&quot;sk-ant-api03-xxxxx&quot;)

# ❌ 錯誤：commit 到 git
# 就算你之後刪掉，git history 還是看得到
```

**正確做法：**

```python
# ✅ 從環境變數讀取
import os
import anthropic

# SDK 預設就會讀 ANTHROPIC_API_KEY 環境變數
client = anthropic.Anthropic()  # 自動讀取 os.environ[&quot;ANTHROPIC_API_KEY&quot;]

# 或明確指定
client = anthropic.Anthropic(api_key=os.environ[&quot;ANTHROPIC_API_KEY&quot;])
```

**在生產環境使用 Secret Manager：**

```python
# Google Cloud Secret Manager 範例
from google.cloud import secretmanager

def get_api_key() -&gt; str:
    client = secretmanager.SecretManagerServiceClient()
    name = f&quot;projects/{PROJECT_ID}/secrets/anthropic-api-key/versions/latest&quot;
    response = client.access_secret_version(request={&quot;name&quot;: name})
    return response.payload.data.decode(&quot;UTF-8&quot;)

# 在應用啟動時（不是每次請求時）取得 key
ANTHROPIC_API_KEY = get_api_key()
anthropic_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
```

另一個重要建議：**為不同環境使用不同的 API Key**。開發環境、staging 環境、生產環境各自一個 Key。這樣當某個環境的 Key 洩漏，其他環境不受影響，而且你可以透過 Anthropic Console 查看不同 Key 的使用量，更容易追蹤問題。

## Logging 策略

Log 要記什麼？不記什麼？這個問題比大多數人想的複雜。

**要記錄的：**

- 請求 ID（`x-request-id` header）——這是 debug 的生命線
- 使用的 model、max_tokens
- 回應的 token 使用量（input/output/cache）
- 請求延遲（從送出到收到回應的時間）
- 錯誤類型和錯誤訊息
- 用戶 ID 或 session ID（匿名的，用於追蹤使用模式）

**不要記錄的：**

- 用戶的完整 prompt（可能包含個人資訊）
- API Key
- 任何 PII（個人識別資訊）

```python
import anthropic
import time
import logging
import uuid

logger = logging.getLogger(__name__)
client = anthropic.Anthropic()

def call_api_with_logging(
    messages: list,
    system: str,
    model: str = &quot;claude-3-5-sonnet-20241022&quot;,
    user_id: str = None,
    feature: str = &quot;unknown&quot;
) -&gt; anthropic.Message:
    &quot;&quot;&quot;帶有完整 logging 的 API 呼叫&quot;&quot;&quot;
    request_id = str(uuid.uuid4())
    start_time = time.time()

    # 記錄請求（注意：不記錄 prompt 內容）
    logger.info(&quot;api_request_started&quot;, extra={
        &quot;request_id&quot;: request_id,
        &quot;feature&quot;: feature,
        &quot;model&quot;: model,
        &quot;user_id&quot;: user_id,  # 應該是匿名 ID，不是真實用戶資料
        &quot;message_count&quot;: len(messages),
        # 記錄 prompt 的 token 估算，但不記錄內容
        &quot;estimated_input_chars&quot;: sum(len(str(m.get(&quot;content&quot;, &quot;&quot;))) for m in messages),
    })

    try:
        response = client.messages.create(
            model=model,
            max_tokens=1024,
            system=system,
            messages=messages,
        )

        latency_ms = (time.time() - start_time) * 1000
        usage = response.usage

        # 從回應 headers 取得 Anthropic 的 request ID（用於聯繫 support）
        anthropic_request_id = response._request_id  # SDK 中的 request ID

        logger.info(&quot;api_request_succeeded&quot;, extra={
            &quot;request_id&quot;: request_id,
            &quot;anthropic_request_id&quot;: anthropic_request_id,
            &quot;feature&quot;: feature,
            &quot;model&quot;: model,
            &quot;latency_ms&quot;: round(latency_ms),
            &quot;input_tokens&quot;: usage.input_tokens,
            &quot;output_tokens&quot;: usage.output_tokens,
            &quot;cache_read_tokens&quot;: getattr(usage, &apos;cache_read_input_tokens&apos;, 0),
            &quot;cache_creation_tokens&quot;: getattr(usage, &apos;cache_creation_input_tokens&apos;, 0),
            &quot;stop_reason&quot;: response.stop_reason,
        })

        return response

    except Exception as e:
        latency_ms = (time.time() - start_time) * 1000
        logger.error(&quot;api_request_failed&quot;, extra={
            &quot;request_id&quot;: request_id,
            &quot;feature&quot;: feature,
            &quot;model&quot;: model,
            &quot;latency_ms&quot;: round(latency_ms),
            &quot;error_type&quot;: type(e).__name__,
            &quot;error_message&quot;: str(e)[:200],  # 截斷，避免 log 過大
        })
        raise
```

## OpenTelemetry 整合

結構化 logging 只是開始。在生產環境，你還需要**分散式追蹤（Distributed Tracing）**——尤其是當你的 AI 功能只是更大系統的一部分時。

```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
import anthropic

# 設定 OpenTelemetry
provider = TracerProvider()
otlp_exporter = OTLPSpanExporter(endpoint=&quot;http://otel-collector:4317&quot;)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)

tracer = trace.get_tracer(&quot;ai-service&quot;)
client = anthropic.Anthropic()

def answer_question(user_question: str, context: str, user_id: str) -&gt; str:
    &quot;&quot;&quot;帶有 OpenTelemetry 追蹤的 API 呼叫&quot;&quot;&quot;
    with tracer.start_as_current_span(&quot;claude_api_call&quot;) as span:
        # 設定 span 屬性（不包含個人資訊）
        span.set_attribute(&quot;ai.model&quot;, &quot;claude-3-5-sonnet-20241022&quot;)
        span.set_attribute(&quot;ai.feature&quot;, &quot;question_answering&quot;)
        span.set_attribute(&quot;user.id&quot;, user_id)

        try:
            response = client.messages.create(
                model=&quot;claude-3-5-sonnet-20241022&quot;,
                max_tokens=1024,
                system=&quot;你是一個有幫助的助手。&quot;,
                messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_question}]
            )

            # 記錄結果 metrics
            usage = response.usage
            span.set_attribute(&quot;ai.input_tokens&quot;, usage.input_tokens)
            span.set_attribute(&quot;ai.output_tokens&quot;, usage.output_tokens)
            span.set_attribute(&quot;ai.stop_reason&quot;, response.stop_reason)

            return response.content[0].text

        except Exception as e:
            span.record_exception(e)
            span.set_status(trace.StatusCode.ERROR, str(e))
            raise
```

## Latency 監控

API 延遲是用戶體驗的核心指標。claude-3-5-sonnet 的典型延遲在 1-10 秒之間，視 prompt 長度和輸出長度而定。

你應該監控 P50（中位數）、P95（第 95 百分位）、P99——不只是平均值。平均值會隱藏問題，P99 才能反映最差的用戶體驗。

```python
import time
from collections import deque
import threading
import statistics

class LatencyTracker:
    def __init__(self, window_size: int = 1000):
        self.latencies = deque(maxlen=window_size)
        self.lock = threading.Lock()

    def record(self, latency_ms: float):
        with self.lock:
            self.latencies.append(latency_ms)

    def get_percentiles(self) -&gt; dict:
        with self.lock:
            if not self.latencies:
                return {}
            sorted_latencies = sorted(self.latencies)
            n = len(sorted_latencies)
            return {
                &quot;p50&quot;: sorted_latencies[int(n * 0.50)],
                &quot;p95&quot;: sorted_latencies[int(n * 0.95)],
                &quot;p99&quot;: sorted_latencies[int(n * 0.99)],
                &quot;max&quot;: sorted_latencies[-1],
                &quot;count&quot;: n,
            }

latency_tracker = LatencyTracker()
```

如果你的 P99 延遲突然從 5 秒跳到 30 秒，這是一個嚴重的信號——可能是 Anthropic 服務有問題，或是你的 prompt 突然變長了。

## Fallback 策略：降級到更便宜的模型

當 Sonnet 不可用或響應過慢，降級到 Haiku 是一個有效的 fallback：

```python
import anthropic
from typing import Optional

client = anthropic.Anthropic()

MODELS = {
    &quot;primary&quot;: &quot;claude-3-5-sonnet-20241022&quot;,
    &quot;fallback&quot;: &quot;claude-3-5-haiku-20241022&quot;,
}

def create_message_with_fallback(
    messages: list,
    system: str,
    max_tokens: int = 1024,
    timeout: float = 30.0
) -&gt; tuple[anthropic.Message, str]:
    &quot;&quot;&quot;先嘗試主要模型，失敗則降級&quot;&quot;&quot;

    for model_tier, model in MODELS.items():
        try:
            response = client.messages.create(
                model=model,
                max_tokens=max_tokens,
                system=system,
                messages=messages,
                timeout=timeout,
            )
            if model_tier == &quot;fallback&quot;:
                logger.warning(f&quot;使用了 fallback 模型: {model}&quot;)
            return response, model_tier

        except anthropic.InternalServerError as e:
            if model_tier == &quot;primary&quot;:
                logger.warning(f&quot;主要模型 {model} 不可用，嘗試 fallback&quot;)
                continue
            else:
                raise  # fallback 也失敗了，真的出問題了

        except anthropic.APITimeoutError:
            if model_tier == &quot;primary&quot;:
                logger.warning(f&quot;主要模型 {model} 超時，嘗試 fallback&quot;)
                continue
            else:
                raise
```

## 生產環境 10 項 Checklist

在部署你的 AI 應用到生產環境之前，確認這 10 項：

**[1] API Key 安全**

- [ ] API Key 存放在環境變數或 Secret Manager，不在程式碼中
- [ ] 不同環境使用不同的 API Key
- [ ] `.gitignore` 有排除 `.env` 檔案

**[2] 錯誤處理**

- [ ] 所有 API 呼叫都有 try/except
- [ ] 429 錯誤有指數退避重試
- [ ] 4xx 客戶端錯誤不重試
- [ ] 錯誤訊息對用戶友好（不暴露技術細節）

**[3] Rate Limit 管理**

- [ ] 了解你帳戶的 RPM 和 TPM 限制
- [ ] 有機制監控目前的使用量
- [ ] 有 queue 或 semaphore 控制最大並發請求數

**[4] Timeout 設定**

- [ ] 所有請求都有設定合理的 timeout
- [ ] Streaming 請求有設定 connection timeout

**[5] Logging**

- [ ] 每次 API 呼叫都記錄延遲和 token 使用量
- [ ] 錯誤有完整的 context 資訊
- [ ] Log 中不包含 PII 或 API Key

**[6] 監控和告警**

- [ ] P95/P99 延遲有告警
- [ ] 錯誤率有告警
- [ ] API 費用有告警（設定每日/每月上限）

**[7] 輸入驗證**

- [ ] 用戶輸入有長度限制（防止 context window 爆炸）
- [ ] 有基本的輸入清理

**[8] Fallback 機制**

- [ ] 主要模型不可用時有降級方案
- [ ] 完全降級時有友好的錯誤提示

**[9] 成本控制**

- [ ] 啟用 Prompt Caching（如果適合）
- [ ] max_tokens 設定合理的上限
- [ ] 有成本監控 dashboard

**[10] 運作驗證**

- [ ] 有 health check endpoint（確認 Anthropic API 可達）
- [ ] 有 staging 環境，部署前先驗證
- [ ] 有 rollback 計劃

完成這十項，你的 AI 應用才算真正準備好面對生產環境的挑戰。

---

這本書的前十四章，我們從最基礎的 Messages API 一路走到 multi-agent 系統、MCP Server、成本優化、生產部署。現在是最後一章——把所有這些拼在一起，建立一個真實的、可以部署的 AI 客服系統。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Bn4ECRom.webp" medium="image"/><category>Claude API</category><category>生產環境</category><category>錯誤處理</category><category>可觀測性</category><category>Rate Limit</category><enclosure url="https://bobochen.dev/_astro/cover.Bn4ECRom.webp" length="0" type="image/png"/></item><item><title>成本控制：省錢是一門工程藝術</title><link>https://bobochen.dev/blog/claude-api-guide-cost-optimization/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-cost-optimization/</guid><description>從 Token 成本全貌、模型選擇策略、Prompt Caching 到 Batch API，系統性地把 AI 應用的成本降下來。一個真實 RAG 系統從每月 $2000 降到 $300 的完整過程。</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>我曾經有一個月收到 $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-haiku | claude-3-5-sonnet | claude-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 頁面](https://www.anthropic.com/pricing)）

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

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

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

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

我的模型選擇框架：

**用 Haiku（最便宜）的場景：**

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

**用 Sonnet（中等）的場景：**

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

**用 Opus（最貴）的場景：**

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

在實際應用中，我推薦**分層模型策略（Model Routing）**：

```python
import anthropic

client = anthropic.Anthropic()

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

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

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

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

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

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

    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: {&quot;type&quot;: &quot;ephemeral&quot;}` 標記快取斷點
- 快取有效時間：5 分鐘（5 分鐘沒有相同前綴的請求就過期）

```python
import anthropic

client = anthropic.Anthropic()

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

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

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

# 第二次請求（5 分鐘內）：命中快取
response2 = answer_with_caching(&quot;如何申請退貨？&quot;)
usage2 = response2.usage
print(f&quot;Cache read: {usage2.cache_read_input_tokens} tokens&quot;)  # 第二次：命中快取！
# 知識庫的 tokens 現在按 $0.30/1M 計費，而不是 $3/1M
```

**如何最大化快取命中率：**

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

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

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

```python
def chat_with_caching(
    messages: list[dict],
    new_user_message: str,
    system_prompt: str,
    knowledge_base: str
) -&gt; str:
    &quot;&quot;&quot;長對話中的快取策略&quot;&quot;&quot;

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

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

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

    response = client.messages.create(
        model=&quot;claude-3-5-sonnet-20241022&quot;,
        max_tokens=1024,
        system=[
            {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: system_prompt},
            {
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: knowledge_base,
                &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
            }
        ],
        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 中明確要求簡潔：**

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

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

## Batch API：節省 50% 的懶人救星

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

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

```python
import anthropic
import json

client = anthropic.Anthropic()

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

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

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

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

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

適合 Batch API 的場景：

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

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

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

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

```python
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) -&gt; int:
        &quot;&quot;&quot;粗略估算 token 數（1 token ≈ 4 個英文字元 / 2 個中文字元）&quot;&quot;&quot;
        total_chars = sum(
            len(m[&quot;content&quot;]) if isinstance(m[&quot;content&quot;], str)
            else sum(len(c.get(&quot;text&quot;, &quot;&quot;)) for c in m[&quot;content&quot;])
            for m in messages
        )
        return total_chars // 3  # 保守估算

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

    def chat(self, user_message: str, system_prompt: str) -&gt; str:
        self.messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_message})
        self.trim_history()

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

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

        return assistant_message

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

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

        summary = summary_response.content[0].text

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

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

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

```python
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) -&gt; float:
        &quot;&quot;&quot;估算這次呼叫的美金成本&quot;&quot;&quot;
        pricing = {
            &quot;claude-3-5-haiku-20241022&quot;: {
                &quot;input&quot;: 0.80/1_000_000,
                &quot;output&quot;: 4.00/1_000_000,
                &quot;cache_read&quot;: 0.08/1_000_000,
                &quot;cache_write&quot;: 1.00/1_000_000,
            },
            &quot;claude-3-5-sonnet-20241022&quot;: {
                &quot;input&quot;: 3.00/1_000_000,
                &quot;output&quot;: 15.00/1_000_000,
                &quot;cache_read&quot;: 0.30/1_000_000,
                &quot;cache_write&quot;: 3.75/1_000_000,
            },
        }

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

def track_api_call(feature: str, response: anthropic.Message) -&gt; APICallMetrics:
    &quot;&quot;&quot;從 API 回應中提取並記錄 metrics&quot;&quot;&quot;
    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, &apos;cache_read_input_tokens&apos;, 0),
        cache_creation_tokens=getattr(usage, &apos;cache_creation_input_tokens&apos;, 0),
        timestamp=datetime.now()
    )

    logger.info(
        &quot;api_call&quot;,
        extra={
            &quot;feature&quot;: feature,
            &quot;model&quot;: response.model,
            &quot;cost_usd&quot;: metrics.estimated_cost_usd(),
            &quot;cache_hit_rate&quot;: 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、可觀測性——這些是我們下一章要解決的問題。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.hVdJm2qF.webp" medium="image"/><category>Claude API</category><category>成本優化</category><category>Token</category><category>省錢</category><enclosure url="https://bobochen.dev/_astro/cover.hVdJm2qF.webp" length="0" type="image/png"/></item><item><title>MCP Server 開發：讓你的服務成為 AI 工具</title><link>https://bobochen.dev/blog/claude-api-guide-mcp-server-dev/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-mcp-server-dev/</guid><description>從使用者角度到開發者角度，掌握 MCP（Model Context Protocol）Server 開發。學會定義 Tools、Resources、Prompts，讓你的服務被 Claude Code、Claude.ai 以及任何 MCP 客戶端呼叫。</description><pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate><content:encoded>在你讀到這一章之前，你可能已經用過 MCP 了——作為一個使用者。

你在 Claude Code 裡連接了 GitHub MCP server，Claude Code 就能直接查 PR、建立 Issue。你連接了 Filesystem MCP，Claude Code 就能讀取你本機的任何檔案。你連接了 Postgres MCP，AI 就能直接查你的資料庫。

這就是 MCP 從使用者角度的樣子：**讓 AI 客戶端取用更多能力**。

但這一章，我們要換一個視角——**開發者視角**：如果你想讓自己開發的服務，被任何支援 MCP 的 AI 客戶端使用，你需要開發一個 MCP Server。

這個思路轉換其實很重要。MCP 不只是讓你「用更多工具」，而是讓你「把你的服務暴露給所有 AI 工具」。你開發一個 Jira MCP server，它可以被 Claude Code 用、被 Claude.ai 用、被 Cursor 用、被任何未來出現的 MCP 客戶端用。一次開發，到處可用。

## MCP Server 的三種能力

在動手寫程式碼之前，先理解 MCP Server 能提供哪三種能力。

**Tools（工具）**：讓 AI 執行操作。這是最常用的能力——AI 呼叫你的工具，你的工具做某件事，回傳結果。搜尋 Jira issue、建立 GitHub PR、查詢資料庫記錄——這些都是 Tools。

**Resources（資源）**：提供唯讀的資料。Resources 跟 Tools 的差別是：Tools 是「做某件事」，Resources 是「讀取某樣東西」。你的文件庫、設定檔、日誌記錄——這些適合暴露為 Resources。AI 客戶端可以瀏覽和讀取你的 Resources，就像在資料夾裡找檔案一樣。

**Prompts（提示範本）**：預定義的提示模板。你可以把常用的提示場景封裝成 Prompts，讓 AI 客戶端可以選擇使用。例如「程式碼審查」、「撰寫 commit message」——這些都可以封裝成可重用的 Prompt。

三者之中，**Tools 是最重要的**，你 90% 的時間會在開發 Tools。但 Resources 和 Prompts 是完整 MCP Server 的組成部分，不能忽視。

## 開發環境設定

MCP 有 Python 和 TypeScript 兩個官方 SDK。我通常選擇跟我的後端語言一致——如果已有 Python 服務，用 Python SDK；如果是 Node.js 服務，用 TypeScript SDK。

**Python SDK 安裝：**

```bash
pip install mcp
# 或用 uv（速度更快，推薦）
uv add mcp
```

**TypeScript SDK 安裝：**

```bash
npm install @modelcontextprotocol/sdk
# 或
yarn add @modelcontextprotocol/sdk
```

我這一章用 Python 示範，TypeScript 的概念完全相同，只是 API 語法不同。

## 建立 MCP Server 骨架

最簡單的 MCP server 只需要幾行：

```python
from mcp.server.fastmcp import FastMCP

# 建立 server 實例
mcp = FastMCP(&quot;my-first-mcp-server&quot;)

# 啟動 server（使用 stdio transport）
if __name__ == &quot;__main__&quot;:
    mcp.run()
```

`FastMCP` 是 MCP Python SDK 的高階 API，使用 decorator 方式定義 Tools 和 Resources，減少樣板程式碼。

現在執行這個程式，你會得到一個可運作的 MCP server——雖然它什麼也做不了，但架構已經到位。

## 定義 Tools

Tools 是 MCP Server 最核心的功能。用 `@mcp.tool()` decorator 定義：

```python
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel

mcp = FastMCP(&quot;jira-mcp-server&quot;)

@mcp.tool()
def search_issues(
    project_key: str,
    status: str = &quot;Open&quot;,
    max_results: int = 10
) -&gt; str:
    &quot;&quot;&quot;
    搜尋 Jira 中的 Issue。

    Args:
        project_key: 專案代碼，例如 &quot;BACKEND&quot; 或 &quot;FRONTEND&quot;
        status: Issue 狀態，可以是 &quot;Open&quot;, &quot;In Progress&quot;, &quot;Done&quot;
        max_results: 最多回傳幾筆，預設 10

    Returns:
        符合條件的 Issue 列表（JSON 格式）
    &quot;&quot;&quot;
    # 實際的 Jira API 呼叫
    issues = jira_client.search_issues(
        f&quot;project = {project_key} AND status = &apos;{status}&apos;&quot;,
        maxResults=max_results
    )

    return format_issues_as_json(issues)
```

幾個關鍵點：

**函數的 docstring 非常重要。** AI 客戶端用 docstring 來理解這個工具做什麼、什麼時候該用。寫清楚的 docstring 讓 AI 更精準地決定何時呼叫你的工具。

**型別標注讓 MCP SDK 自動生成 JSON Schema。** AI 客戶端用 JSON Schema 來知道每個參數的型別和格式，所以型別標注要準確。

**回傳值應該是字串或可序列化的資料。** MCP 工具的回傳值最終會被轉成字串給 AI 客戶端看。

讓我們把 Jira MCP server 繼續完善：

```python
from mcp.server.fastmcp import FastMCP
import anthropic
import requests
import json
import os
from datetime import datetime

mcp = FastMCP(&quot;jira-mcp-server&quot;)

# Jira 客戶端設定
JIRA_BASE_URL = os.environ[&quot;JIRA_BASE_URL&quot;]  # 例如 https://yourcompany.atlassian.net
JIRA_EMAIL = os.environ[&quot;JIRA_EMAIL&quot;]
JIRA_API_TOKEN = os.environ[&quot;JIRA_API_TOKEN&quot;]

def jira_request(method: str, endpoint: str, data: dict = None) -&gt; dict:
    &quot;&quot;&quot;Jira API 的底層呼叫&quot;&quot;&quot;
    url = f&quot;{JIRA_BASE_URL}/rest/api/3/{endpoint}&quot;
    auth = (JIRA_EMAIL, JIRA_API_TOKEN)
    headers = {&quot;Content-Type&quot;: &quot;application/json&quot;}

    response = requests.request(
        method,
        url,
        auth=auth,
        headers=headers,
        json=data
    )
    response.raise_for_status()
    return response.json() if response.content else {}

@mcp.tool()
def search_issues(
    project_key: str,
    status: str = None,
    assignee: str = None,
    max_results: int = 10
) -&gt; str:
    &quot;&quot;&quot;
    搜尋 Jira Project 中的 Issue。

    Args:
        project_key: 專案代碼（必填），例如 &quot;BACKEND&quot;
        status: 篩選狀態，例如 &quot;In Progress&quot;、&quot;To Do&quot;、&quot;Done&quot;（選填）
        assignee: 篩選負責人的帳號 ID（選填）
        max_results: 最多回傳筆數，預設 10，最大 50

    Returns:
        Issue 列表，包含 key、標題、狀態、負責人、建立時間
    &quot;&quot;&quot;
    jql_parts = [f&quot;project = {project_key}&quot;]
    if status:
        jql_parts.append(f&quot;status = &apos;{status}&apos;&quot;)
    if assignee:
        jql_parts.append(f&quot;assignee = &apos;{assignee}&apos;&quot;)
    jql_parts.append(&quot;ORDER BY created DESC&quot;)

    jql = &quot; AND &quot;.join(jql_parts[:len(jql_parts)-1]) + f&quot; {jql_parts[-1]}&quot;

    result = jira_request(
        &quot;GET&quot;,
        f&quot;search?jql={jql}&amp;maxResults={max_results}&amp;fields=summary,status,assignee,created,priority&quot;
    )

    issues = []
    for issue in result.get(&quot;issues&quot;, []):
        fields = issue[&quot;fields&quot;]
        issues.append({
            &quot;key&quot;: issue[&quot;key&quot;],
            &quot;summary&quot;: fields[&quot;summary&quot;],
            &quot;status&quot;: fields[&quot;status&quot;][&quot;name&quot;],
            &quot;assignee&quot;: fields[&quot;assignee&quot;][&quot;displayName&quot;] if fields.get(&quot;assignee&quot;) else &quot;未指派&quot;,
            &quot;priority&quot;: fields[&quot;priority&quot;][&quot;name&quot;] if fields.get(&quot;priority&quot;) else &quot;Medium&quot;,
            &quot;created&quot;: fields[&quot;created&quot;][:10],  # 只取日期部分
        })

    return json.dumps(issues, ensure_ascii=False, indent=2)

@mcp.tool()
def get_issue(issue_key: str) -&gt; str:
    &quot;&quot;&quot;
    取得單一 Jira Issue 的詳細資訊。

    Args:
        issue_key: Issue 代碼，例如 &quot;BACKEND-123&quot;

    Returns:
        Issue 的完整資訊，包含描述、留言、子任務
    &quot;&quot;&quot;
    result = jira_request(
        &quot;GET&quot;,
        f&quot;issue/{issue_key}?fields=summary,description,status,assignee,priority,comment,subtasks,labels&quot;
    )

    fields = result[&quot;fields&quot;]

    # 整理留言
    comments = []
    for comment in fields.get(&quot;comment&quot;, {}).get(&quot;comments&quot;, [])[-5:]:  # 最近 5 則
        comments.append({
            &quot;author&quot;: comment[&quot;author&quot;][&quot;displayName&quot;],
            &quot;body&quot;: comment[&quot;body&quot;][:200],  # 截斷過長的留言
            &quot;created&quot;: comment[&quot;created&quot;][:10],
        })

    return json.dumps({
        &quot;key&quot;: issue_key,
        &quot;summary&quot;: fields[&quot;summary&quot;],
        &quot;description&quot;: str(fields.get(&quot;description&quot;, &quot;&quot;))[:500],
        &quot;status&quot;: fields[&quot;status&quot;][&quot;name&quot;],
        &quot;assignee&quot;: fields[&quot;assignee&quot;][&quot;displayName&quot;] if fields.get(&quot;assignee&quot;) else &quot;未指派&quot;,
        &quot;labels&quot;: fields.get(&quot;labels&quot;, []),
        &quot;subtasks&quot;: [{&quot;key&quot;: s[&quot;key&quot;], &quot;summary&quot;: s[&quot;fields&quot;][&quot;summary&quot;]} for s in fields.get(&quot;subtasks&quot;, [])],
        &quot;recent_comments&quot;: comments,
    }, ensure_ascii=False, indent=2)

@mcp.tool()
def create_issue(
    project_key: str,
    summary: str,
    description: str = &quot;&quot;,
    issue_type: str = &quot;Task&quot;,
    priority: str = &quot;Medium&quot;,
    assignee_account_id: str = None,
    labels: list[str] = None
) -&gt; str:
    &quot;&quot;&quot;
    在 Jira 建立新的 Issue。

    Args:
        project_key: 要建立在哪個 Project 下，例如 &quot;BACKEND&quot;
        summary: Issue 標題（必填）
        description: 詳細描述（選填，支援 Markdown 格式）
        issue_type: Issue 類型，可以是 &quot;Task&quot;、&quot;Bug&quot;、&quot;Story&quot;、&quot;Epic&quot;
        priority: 優先級，可以是 &quot;Highest&quot;、&quot;High&quot;、&quot;Medium&quot;、&quot;Low&quot;、&quot;Lowest&quot;
        assignee_account_id: 負責人的 Jira 帳號 ID（選填）
        labels: 標籤列表（選填），例如 [&quot;backend&quot;, &quot;urgent&quot;]

    Returns:
        新建立的 Issue 代碼和連結
    &quot;&quot;&quot;
    fields = {
        &quot;project&quot;: {&quot;key&quot;: project_key},
        &quot;summary&quot;: summary,
        &quot;issuetype&quot;: {&quot;name&quot;: issue_type},
        &quot;priority&quot;: {&quot;name&quot;: priority},
    }

    if description:
        # Jira Cloud 用 Atlassian Document Format（ADF），這裡用簡化版
        fields[&quot;description&quot;] = {
            &quot;type&quot;: &quot;doc&quot;,
            &quot;version&quot;: 1,
            &quot;content&quot;: [{&quot;type&quot;: &quot;paragraph&quot;, &quot;content&quot;: [{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: description}]}]
        }

    if assignee_account_id:
        fields[&quot;assignee&quot;] = {&quot;accountId&quot;: assignee_account_id}

    if labels:
        fields[&quot;labels&quot;] = labels

    result = jira_request(&quot;POST&quot;, &quot;issue&quot;, {&quot;fields&quot;: fields})

    return json.dumps({
        &quot;key&quot;: result[&quot;key&quot;],
        &quot;url&quot;: f&quot;{JIRA_BASE_URL}/browse/{result[&apos;key&apos;]}&quot;,
        &quot;message&quot;: f&quot;Issue {result[&apos;key&apos;]} 已成功建立&quot;
    }, ensure_ascii=False)

@mcp.tool()
def update_issue_status(
    issue_key: str,
    target_status: str
) -&gt; str:
    &quot;&quot;&quot;
    更新 Jira Issue 的狀態。

    Args:
        issue_key: Issue 代碼，例如 &quot;BACKEND-123&quot;
        target_status: 目標狀態名稱，例如 &quot;In Progress&quot;、&quot;Done&quot;、&quot;To Do&quot;

    Returns:
        更新結果
    &quot;&quot;&quot;
    # 先取得可用的 transitions
    transitions_result = jira_request(&quot;GET&quot;, f&quot;issue/{issue_key}/transitions&quot;)
    transitions = transitions_result.get(&quot;transitions&quot;, [])

    # 找到符合目標狀態的 transition
    target_transition = None
    for transition in transitions:
        if transition[&quot;to&quot;][&quot;name&quot;].lower() == target_status.lower():
            target_transition = transition
            break

    if not target_transition:
        available = [t[&quot;to&quot;][&quot;name&quot;] for t in transitions]
        return json.dumps({
            &quot;error&quot;: f&quot;找不到狀態 &apos;{target_status}&apos;&quot;,
            &quot;available_statuses&quot;: available
        }, ensure_ascii=False)

    # 執行 transition
    jira_request(&quot;POST&quot;, f&quot;issue/{issue_key}/transitions&quot;, {
        &quot;transition&quot;: {&quot;id&quot;: target_transition[&quot;id&quot;]}
    })

    return json.dumps({
        &quot;message&quot;: f&quot;Issue {issue_key} 狀態已更新為 &apos;{target_status}&apos;&quot;
    }, ensure_ascii=False)
```

## 定義 Resources

Resources 讓 AI 客戶端可以瀏覽和讀取你的資料。對 Jira server 來說，可以把常用的 JQL 查詢結果暴露為 Resources：

```python
from mcp.server.fastmcp import FastMCP
from mcp.types import Resource
import json

@mcp.resource(&quot;jira://projects&quot;)
def list_projects() -&gt; str:
    &quot;&quot;&quot;列出所有可存取的 Jira Project&quot;&quot;&quot;
    result = jira_request(&quot;GET&quot;, &quot;project&quot;)
    projects = [
        {&quot;key&quot;: p[&quot;key&quot;], &quot;name&quot;: p[&quot;name&quot;], &quot;type&quot;: p[&quot;projectTypeKey&quot;]}
        for p in result
    ]
    return json.dumps(projects, ensure_ascii=False, indent=2)

@mcp.resource(&quot;jira://project/{project_key}/open-issues&quot;)
def get_open_issues(project_key: str) -&gt; str:
    &quot;&quot;&quot;取得指定 Project 的所有未完成 Issue&quot;&quot;&quot;
    result = jira_request(
        &quot;GET&quot;,
        f&quot;search?jql=project={project_key} AND status != Done ORDER BY priority DESC&amp;maxResults=50&quot;
    )
    return json.dumps(result.get(&quot;issues&quot;, []), ensure_ascii=False, indent=2)
```

Resources 使用 URI 模板——`jira://project/{project_key}/open-issues` 中的 `{project_key}` 是動態參數，AI 客戶端可以填入任何值來讀取對應的資源。

## 定義 Prompts

Prompts 讓你封裝常用的提示場景：

```python
from mcp.server.fastmcp import FastMCP
from mcp.types import PromptMessage

@mcp.prompt()
def daily_standup_prompt(project_key: str) -&gt; list[PromptMessage]:
    &quot;&quot;&quot;
    生成每日站會的摘要提示。

    Args:
        project_key: 要摘要的 Project 代碼
    &quot;&quot;&quot;
    return [
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;&quot;&quot;請幫我準備 {project_key} 專案的每日站會摘要。

            步驟：
            1. 用 search_issues 查詢昨天更新的 Issue
            2. 查詢目前 In Progress 的 Issue
            3. 查詢今天 Due 的 Issue
            4. 整理成站會格式：
               - 昨天完成了什麼
               - 今天要做什麼
               - 有什麼 Blocker

            保持簡潔，每個項目一行。
            &quot;&quot;&quot;
        }
    ]
```

## 本地測試

開發 MCP server 的時候，最常用的測試方式是直接用 MCP Inspector：

```bash
# 方式一：Python MCP CLI（需先 pip install &apos;mcp[cli]&apos;）
mcp dev server.py

# 方式二：npm 版 MCP Inspector，直接掛上你的 server
npx @modelcontextprotocol/inspector python server.py
```

MCP Inspector 會啟動一個 Web UI，讓你直接呼叫你的 Tools 和瀏覽你的 Resources，不需要透過 Claude 就能測試。

或者更簡單——直接寫測試：

```python
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def test_jira_server():
    server_params = StdioServerParameters(
        command=&quot;python&quot;,
        args=[&quot;jira_server.py&quot;],
        env={
            &quot;JIRA_BASE_URL&quot;: &quot;https://test.atlassian.net&quot;,
            &quot;JIRA_EMAIL&quot;: &quot;test@example.com&quot;,
            &quot;JIRA_API_TOKEN&quot;: &quot;test-token&quot;,
        }
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # 列出所有可用的 tools
            tools = await session.list_tools()
            print(&quot;Available tools:&quot;)
            for tool in tools.tools:
                print(f&quot;  - {tool.name}: {tool.description[:50]}...&quot;)

            # 測試 search_issues tool
            result = await session.call_tool(
                &quot;search_issues&quot;,
                {&quot;project_key&quot;: &quot;BACKEND&quot;, &quot;status&quot;: &quot;In Progress&quot;}
            )
            print(&quot;\nsearch_issues result:&quot;)
            print(result.content[0].text)

asyncio.run(test_jira_server())
```

## 連接到 Claude Code

在 `~/.claude.json`（或專案的 `.claude/settings.json`）中新增你的 MCP server：

```json
{
  &quot;mcpServers&quot;: {
    &quot;jira&quot;: {
      &quot;command&quot;: &quot;python&quot;,
      &quot;args&quot;: [&quot;/path/to/jira_server.py&quot;],
      &quot;env&quot;: {
        &quot;JIRA_BASE_URL&quot;: &quot;https://yourcompany.atlassian.net&quot;,
        &quot;JIRA_EMAIL&quot;: &quot;your-email@company.com&quot;,
        &quot;JIRA_API_TOKEN&quot;: &quot;${JIRA_API_TOKEN}&quot;
      }
    }
  }
}
```

注意 `${JIRA_API_TOKEN}` 這種語法——Claude Code 會從環境變數讀取這個值，不要把 API token 直接寫在設定檔裡（那個檔案可能被 commit 到 git）。

重啟 Claude Code 後，你應該能看到 Jira 的 Tools 出現在可用工具列表中。試試看說「搜尋 BACKEND 專案裡所有 In Progress 的 issue」——Claude Code 會自動呼叫你的 MCP server。

## 連接到 Claude.ai

Claude.ai（網頁版）也支援 MCP。在 Settings → Integrations → Add integration 中填入你的 MCP server 連接資訊。

不過有個重要差異：Claude.ai 是雲端服務，它需要能透過網路訪問你的 MCP server。如果你的 server 只在本機跑（stdio transport），Claude.ai 無法連接。你需要把 server 部署到公開可訪問的位置，並使用 SSE（Server-Sent Events）或 WebSocket transport：

```python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP(&quot;jira-mcp-server&quot;)

# ... 定義 Tools, Resources, Prompts ...

if __name__ == &quot;__main__&quot;:
    import uvicorn
    # 使用 HTTP/SSE transport 讓 Claude.ai 可以連接
    mcp.run(transport=&quot;sse&quot;, host=&quot;0.0.0.0&quot;, port=8000)
```

## 打包與發布

如果你想讓其他人也能用你的 MCP server，可以發布到 npm 或 PyPI。

**Python 打包（使用 uv）：**

```toml
# pyproject.toml
[project]
name = &quot;jira-mcp-server&quot;
version = &quot;0.1.0&quot;
description = &quot;A Jira MCP server for Claude&quot;
dependencies = [&quot;mcp&gt;=1.0.0&quot;, &quot;requests&gt;=2.31.0&quot;]

[project.scripts]
jira-mcp-server = &quot;jira_mcp_server:main&quot;
```

發布後，其他人可以這樣安裝和使用：

```json
{
  &quot;mcpServers&quot;: {
    &quot;jira&quot;: {
      &quot;command&quot;: &quot;uvx&quot;,
      &quot;args&quot;: [&quot;jira-mcp-server&quot;],
      &quot;env&quot;: {
        &quot;JIRA_BASE_URL&quot;: &quot;https://yourcompany.atlassian.net&quot;
      }
    }
  }
}
```

## 安全考量：認證、Rate Limiting、輸入驗證

MCP server 的安全問題很容易被忽視，但非常重要。

**認證：不要讓任何人都能呼叫你的 server。**

對於 stdio transport（本機用），認證相對簡單——只要本機的 Claude Code 才能呼叫。但對於 HTTP/SSE transport（雲端部署），你需要 API key 或 OAuth：

```python
from fastapi import Header, HTTPException

@app.middleware(&quot;http&quot;)
async def authenticate(request, call_next):
    api_key = request.headers.get(&quot;X-API-Key&quot;)
    if api_key != os.environ[&quot;MCP_API_KEY&quot;]:
        raise HTTPException(status_code=401, detail=&quot;Unauthorized&quot;)
    return await call_next(request)
```

**Rate Limiting：防止 AI 瘋狂呼叫你的 API。**

AI 客戶端可能在短時間內大量呼叫你的工具（尤其是 agent 在 loop 裡執行的時候）。一定要加入 rate limiting：

```python
from collections import defaultdict
import time

class RateLimiter:
    def __init__(self, max_calls: int, window_seconds: int):
        self.max_calls = max_calls
        self.window = window_seconds
        self.calls = defaultdict(list)

    def is_allowed(self, key: str) -&gt; bool:
        now = time.time()
        self.calls[key] = [t for t in self.calls[key] if now - t &lt; self.window]
        if len(self.calls[key]) &gt;= self.max_calls:
            return False
        self.calls[key].append(now)
        return True

rate_limiter = RateLimiter(max_calls=60, window_seconds=60)

@mcp.tool()
def search_issues(project_key: str, ...) -&gt; str:
    if not rate_limiter.is_allowed(&quot;search_issues&quot;):
        return json.dumps({&quot;error&quot;: &quot;Rate limit exceeded. Please try again in a minute.&quot;})
    # ... 正常邏輯
```

**輸入驗證：永遠不要信任 AI 傳來的輸入。**

```python
import re

@mcp.tool()
def search_issues(project_key: str, ...) -&gt; str:
    # 驗證 project_key 格式（只允許大寫字母和數字）
    if not re.match(r&apos;^[A-Z][A-Z0-9]{0,9}$&apos;, project_key):
        return json.dumps({&quot;error&quot;: f&quot;無效的 project key 格式：{project_key}&quot;})

    # 避免 JQL injection
    # 不要直接把用戶輸入塞進 JQL 字串
    safe_project_key = project_key.strip().upper()
    # ...
```

MCP server 開發本質上跟任何 API 開發一樣——輸入驗證、認證、限流都是基本功，不能省略。

---

下一章，我們轉向一個每個 AI 應用開發者遲早都會面對的問題：成本控制。你的 agent 系統功能再強，如果每個月要燒掉 $5000 的 API 費用，那商業模式就很難跑通。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.ChhO_APS.webp" medium="image"/><category>Claude API</category><category>MCP</category><category>工具開發</category><enclosure url="https://bobochen.dev/_astro/cover.ChhO_APS.webp" length="0" type="image/png"/></item><item><title>Multi-Agent 系統：Orchestrator 與 Subagent 設計模式</title><link>https://bobochen.dev/blog/claude-api-guide-multi-agent/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-multi-agent/</guid><description>單一 agent 有 context 限制、無法平行化、難以專業分工。本章教你用真實的 Anthropic SDK 設計 Orchestrator-Worker 多 agent 系統：在程式碼裡當 orchestrator 做 routing、用 asyncio 平行跑 subagent、設計 agent 間的資料傳遞，以及防止 agent 失控的關鍵技術。</description><pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate><content:encoded>我第一次嘗試用單一 agent 做市場分析報告，是個讓我難忘的失敗經驗。

那個 agent 需要做的事情包括：搜尋競爭對手資訊、爬取相關新聞、分析財務數據、查詢用戶評論、整合所有資料，最後寫成一份 20 頁的報告。我把所有工具都給了它，寫了一個超詳細的 system prompt，然後讓它跑。

結果呢？

大約在搜尋了 15 個競爭對手、累積了大量搜尋結果之後，agent 開始「迷路」了。它忘記自己在做什麼，開始重複搜尋已經搜尋過的東西，最後生成了一份混亂、重複、前後矛盾的報告。Context window 被塞滿了大量原始搜尋結果，根本沒有空間做真正的分析思考。

這就是單一 agent 的天花板。

&gt; **先講一件容易被誤導的事**：Anthropic **沒有** `handoff()` 這種「agent 互相交接」的原語——那是 OpenAI Agents SDK 的形狀。在 Claude 這邊，multi-agent 的真相樸素很多：**你在自己的程式碼裡當 orchestrator，依判斷呼叫各個 subagent**。每個 subagent 不是什麼神祕物件，就是一個帶**專屬 system prompt** ＋（可選）一組工具的 Claude 呼叫或 agentic loop。沒有框架幫你「自動交接」，routing 邏輯就是你寫的 `if`、或是你讓 Claude 回傳「該派誰」的一次呼叫。本章所有程式碼都只用官方 `anthropic` SDK（`pip install anthropic`），照抄就能跑。

## 為什麼單一 Agent 不夠用

要理解 multi-agent 的必要性，先要理解單一 agent 的三個根本限制：

**第一：Context Window 是有限資源。** Claude 的 context window 很大（`claude-opus-4-8` 有 1M tokens），但真實任務消耗 context 的速度驚人。搜尋結果、文件內容、對話歷史……每一樣都在佔用空間。當 context 接近上限，模型的推理品質會下降——它開始「忘記」前面說過的事情，做出不一致的決策。

**第二：無法平行化。** 一個 agent 是線性執行的——做完 A 才能做 B。但很多任務本質上可以平行：分析五個競爭對手的時候，為什麼不讓五個 subagent 同時去分析？

**第三：難以深度專業化。** 一個 agent 要同時會搜尋、分析、寫作，必然每樣都只能做到 generalist 水準。但如果一個 subagent 專注做搜尋、另一個專注做分析，每個的提示可以針對該任務深度優化。

Multi-agent 系統就是解這三個問題的架構。

## Orchestrator-Worker 模式

這是 multi-agent 系統最常見也最實用的模式。

結構很簡單：**一個 Orchestrator 負責規劃和協調，多個 Worker（subagent）負責執行具體任務**。

Orchestrator 做什麼？

- 接收最初的任務目標
- 拆解任務、決定執行順序
- 分配子任務給各個 Worker
- 收集 Worker 的結果
- 整合最終輸出

Worker 做什麼？

- 接收一個明確、具體的子任務
- 用自己的工具和能力完成它
- 把結果回傳給 Orchestrator
- 不需要知道更大的任務目標是什麼

我認為這個分工的關鍵是：**Worker 應該是「愚蠢」的——它只管把分配到的任務做好，不需要理解全局**。這樣設計讓每個 Worker 的 context window 保持乾淨，只裝著跟當前子任務相關的資訊。

這裡要把抽象落地成程式碼：在 Claude 的世界裡，「一個 agent」具體就是「一個帶專屬 system prompt（＋可選工具）的 Claude 呼叫」。Orchestrator 是你的 Python 程式本身——它持有判斷邏輯，決定何時呼叫哪個 subagent 函式。沒有任何框架在背後偷偷幫你做這件事，全部都是你看得到的程式碼。

## 在程式碼裡做 routing：orchestrator 把任務交給 subagent

先把每個 subagent 寫成一個普通的 Python 函式。每個函式內部就是一次 `client.messages.create()`，帶自己的 system prompt：

```python
import anthropic

client = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰

def search_subagent(query: str) -&gt; str:
    &quot;&quot;&quot;搜尋 subagent：只管整理事實，不做分析。&quot;&quot;&quot;
    resp = client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=2000,
        system=(
            &quot;你是一個專業的網路搜尋整理 agent。\n&quot;
            &quot;任務：根據給定的查詢詞，整理相關事實。\n&quot;
            &quot;輸出格式：\n&quot;
            &quot;- 列出找到的關鍵事實，每條一行\n&quot;
            &quot;- 注明資訊來源\n&quot;
            &quot;- 不要做分析，只整理事實\n&quot;
            &quot;- 字數控制在 500 字以內&quot;
        ),
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query}],
    )
    return next(b.text for b in resp.content if b.type == &quot;text&quot;)

def analysis_subagent(raw_data: str) -&gt; str:
    &quot;&quot;&quot;分析 subagent：根據原始資料做深度分析。&quot;&quot;&quot;
    resp = client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=4000,
        system=(
            &quot;你是一個商業分析 agent。\n&quot;
            &quot;任務：根據提供的原始資料，進行深度分析。\n&quot;
            &quot;分析框架：市場規模和趨勢 / 主要競爭者優劣勢 / 機會與威脅 / 建議行動方向&quot;
        ),
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: raw_data}],
    )
    return next(b.text for b in resp.content if b.type == &quot;text&quot;)
```

注意這裡沒有 `Agent(...)` 類別、沒有 `Runner`、更沒有 `handoff()`。**「agent」只是個帶 system prompt 的函式**，「交接」就是 orchestrator 呼叫下一個函式並把上一個的輸出傳進去。

那 orchestrator 怎麼決定要派給誰？最簡單的版本，當任務流程固定時，直接用 Python 控制流程：

```python
def orchestrator(research_question: str) -&gt; str:
    &quot;&quot;&quot;Orchestrator：規劃 → 搜尋 → 分析 → 撰寫。流程由程式碼掌控。&quot;&quot;&quot;
    # 步驟 1：先用一次 Claude 呼叫把問題拆成要搜尋的主題
    plan_resp = client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=1000,
        system=&quot;你是研究規劃 agent。把使用者的研究問題拆成 3-5 個具體的搜尋查詢詞，每行一個，不要多餘文字。&quot;,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: research_question}],
    )
    plan_text = next(b.text for b in plan_resp.content if b.type == &quot;text&quot;)
    queries = [line.strip() for line in plan_text.splitlines() if line.strip()]

    # 步驟 2：對每個主題呼叫 search subagent
    search_results = [search_subagent(q) for q in queries]
    combined = &quot;\n\n&quot;.join(search_results)

    # 步驟 3：把彙整後的資料交給 analysis subagent
    return analysis_subagent(combined)
```

這就是「routing」的本質：**orchestrator 是你寫的程式，它根據判斷呼叫對應的 subagent 函式**。上一個 subagent 的輸出（搜尋結果）變成下一個 subagent 的輸入（分析資料）——這就是 Claude 世界裡的「交接」，沒有任何魔法。

## Routing 判斷怎麼設計

上面的範例流程是寫死的（固定先搜尋、再分析）。但很多時候 orchestrator 需要**動態決定要派給哪個 subagent**——例如客服系統收到一句話，要判斷該交給「退款 agent」還是「技術支援 agent」。

這種動態 routing 有兩種真實做法。

**做法一：規則 / 關鍵字（最便宜、最可預測）**

```python
def route_by_keyword(user_message: str) -&gt; str:
    text = user_message.lower()
    if any(k in text for k in [&quot;退款&quot;, &quot;退費&quot;, &quot;refund&quot;]):
        return &quot;refund&quot;
    if any(k in text for k in [&quot;當機&quot;, &quot;錯誤&quot;, &quot;bug&quot;, &quot;壞掉&quot;]):
        return &quot;tech_support&quot;
    return &quot;general&quot;
```

能用規則就用規則。它零成本、零延遲、可單元測試。但語意一複雜（「我用不了所以想退錢」既是技術又是退款）規則就會失準。

**做法二：用 structured output 讓 Claude 回傳「該派誰」**

讓一次 Claude 呼叫只做分類這一件事，並用 `messages.parse()` 把回傳約束成你定義的 schema，拿到已驗證的物件：

```python
from pydantic import BaseModel
from typing import Literal

class RouteDecision(BaseModel):
    target: Literal[&quot;refund&quot;, &quot;tech_support&quot;, &quot;general&quot;]
    reason: str

def route_with_claude(user_message: str) -&gt; RouteDecision:
    resp = client.messages.parse(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=512,
        system=(
            &quot;你是客服路由 agent。判斷使用者訊息該交給哪個專責 agent：&quot;
            &quot;refund（退款相關）、tech_support（技術問題）、general（其他）。&quot;
        ),
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_message}],
        output_format=RouteDecision,
    )
    return resp.parsed_output  # 已驗證的 RouteDecision

# orchestrator 拿到決策後，呼叫對應的 subagent 函式
def dispatch(user_message: str) -&gt; str:
    decision = route_with_claude(user_message)
    handlers = {
        &quot;refund&quot;: handle_refund,
        &quot;tech_support&quot;: handle_tech_support,
        &quot;general&quot;: handle_general,
    }
    return handlers[decision.target](user_message)
```

`messages.parse()` 會把回應約束成 `RouteDecision` 並驗證，所以 `decision.target` 一定是那三個合法值之一——你可以放心拿它當 dict 的 key 去查表，不必擔心模型回了個你沒處理的字串。

我的經驗是：**routing 決策越明確越好**。不要寫一個模糊的 system prompt 叫 Claude「自己看著辦」，而是把可選項目（refund / tech_support / general）和判準清楚列出來，用 structured output 鎖死回傳格式。模糊的指令讓模型有太多解釋空間，容易產生不預期的行為——這點跟設計工具的 `tool_choice` 是同樣的道理。

## 平行 Agent 執行

順序執行已經很有用了，但真正的威力來自**平行執行**。

如果我要分析五個競爭對手，沒有理由讓搜尋 subagent 一個一個來——讓五個搜尋呼叫同時跑，總時間從 5x 變成 1x。

關鍵是用 `AsyncAnthropic` ＋ `asyncio.gather`。每個 subagent 是一個 `async` 函式，`gather` 讓它們並行：

```python
import asyncio
from anthropic import AsyncAnthropic

client = AsyncAnthropic()

async def research_competitor(competitor_name: str) -&gt; dict:
    &quot;&quot;&quot;對單一競爭對手做一次 subagent 呼叫。&quot;&quot;&quot;
    resp = await client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=2000,
        system=(
            f&quot;你專門研究 {competitor_name} 這家公司。收集以下資訊：\n&quot;
            &quot;- 公司規模和市場定位\n&quot;
            &quot;- 主要產品和定價\n&quot;
            &quot;- 近期動態（過去 6 個月）\n&quot;
            &quot;- 用戶評價\n&quot;
            &quot;輸出結構化的 Markdown。&quot;
        ),
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;研究 {competitor_name} 的詳細資訊&quot;}],
    )
    text = next(b.text for b in resp.content if b.type == &quot;text&quot;)
    return {&quot;competitor&quot;: competitor_name, &quot;data&quot;: text}

async def parallel_market_research(competitors: list[str]) -&gt; list[dict]:
    &quot;&quot;&quot;平行研究所有競爭對手——同時發起所有呼叫。&quot;&quot;&quot;
    tasks = [research_competitor(c) for c in competitors]
    return await asyncio.gather(*tasks)

async def main():
    competitors = [&quot;Notion&quot;, &quot;Obsidian&quot;, &quot;Roam Research&quot;, &quot;Logseq&quot;, &quot;Capacities&quot;]
    print(f&quot;開始平行研究 {len(competitors)} 個競爭對手...&quot;)
    research_data = await parallel_market_research(competitors)

    # orchestrator 把所有結果彙整，再丟一次 Claude 做市場分析
    combined = &quot;\n\n&quot;.join(f&quot;## {r[&apos;competitor&apos;]}\n{r[&apos;data&apos;]}&quot; for r in research_data)
    analysis = await client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=4000,
        system=&quot;你是市場分析 agent。根據競爭對手資料，撰寫市場競爭分析報告。&quot;,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: combined}],
    )
    print(next(b.text for b in analysis.content if b.type == &quot;text&quot;))

asyncio.run(main())
```

這個範例中，五個搜尋呼叫**同時**送出，每個都有自己獨立的 context（互不污染）。整個研究時間從原本的線性累加，變成等於最慢那個呼叫的回應時間。

一個實務提醒：平行度不要無限放大。同時送出幾十個請求會撞到 rate limit（`anthropic` SDK 預設會自動重試 429，但重試本身也要時間）。常見做法是用 `asyncio.Semaphore` 把同時在跑的呼叫數壓在合理範圍：

```python
sem = asyncio.Semaphore(5)  # 最多 5 個同時在跑

async def research_competitor(competitor_name: str) -&gt; dict:
    async with sem:
        resp = await client.messages.create(...)
        ...
```

## Agent 間的通訊設計：共用狀態 vs 把輸出當輸入

當多個 subagent 需要協作，它們之間的資訊傳遞方式直接影響系統的可靠性。在 Claude 的世界裡，這純粹是**程式碼層面**的資料傳遞，不是什麼框架機制——你有兩種風格可選。

**把上一個的輸出當下一個的輸入（直接傳遞）**是最直接的方式：orchestrator 拿到 subagent A 的回傳值（一段文字），直接當作 subagent B 的 `messages` 內容傳進去。前面 `orchestrator()` 裡 `search_results` 串接後丟給 `analysis_subagent` 就是這種。

優點：清晰、可追蹤、容易除錯——資料流就是你的函式呼叫鏈。
缺點：如果 subagent A 的輸出很大，整段塞進 subagent B 的 prompt 會佔用大量 context（和成本）。

**共用狀態（shared dict / 外部儲存）**則是讓各個 subagent 把結果寫進一個共用的資料結構，orchestrator 再從裡面挑需要的給下一個 subagent。小規模時就是一個 Python dict；跨程序或要持久化時才升級成 Redis、資料庫或檔案。

```python
import json
import redis  # 跨程序 / 要持久化時才需要

# --- 小規模：一個共用 dict 就夠 ---
shared_state: dict[str, str] = {}

def search_into_state(topic: str) -&gt; None:
    &quot;&quot;&quot;subagent 把結果寫進共用狀態，而不是直接回傳一大包。&quot;&quot;&quot;
    shared_state[topic] = search_subagent(f&quot;研究 {topic}&quot;)

# orchestrator 之後只挑需要的 key 餵給下一個 subagent
def summarize_topic(topic: str) -&gt; str:
    raw = shared_state.get(topic, &quot;&quot;)
    return analysis_subagent(raw)


# --- 跨程序 / 要持久化：外部儲存 ---
redis_client = redis.Redis(host=&quot;localhost&quot;, port=6379, decode_responses=True)

def save_research_result(key: str, data: dict) -&gt; None:
    redis_client.setex(f&quot;research:{key}&quot;, 3600, json.dumps(data, ensure_ascii=False))

def load_research_result(key: str) -&gt; dict:
    raw = redis_client.get(f&quot;research:{key}&quot;)
    return json.loads(raw) if raw else {}
```

我的建議是：**對於小資料（&lt; 1000 tokens）用直接傳遞；對於大資料（文件、長報告）用共用狀態存原文，只把摘要在函式之間傳遞**。混合使用效果最好：subagent 的摘要直接回傳給 orchestrator，完整的原始資料存在外部，需要時才用 key 撈回來。

## 防止 Agent 失控

這是我認為 multi-agent 系統設計中最被忽視的部分。因為 orchestrator 是你寫的程式，**所有的安全閥也都是你自己加的**——沒有框架會替你擋。

Agent 會失控的情況：

- 無限迴圈（某個 subagent 內部跑 agentic loop，一直呼叫工具卻不收斂）
- 超出預算（持續呼叫昂貴的工具或反覆送大 prompt）
- 發散行為（subagent 偏離原始目標，做了大量不相關的事）

**每個 subagent 的 `max_iterations` ／ token 上限 ／ 逾時**是第一道防線。如果某個 subagent 內部是手寫的 agentic loop（要用工具的那種），一定要自己加迴圈計數器跳出；單次呼叫則用 `max_tokens` 封頂、用 SDK 的 `timeout` 設逾時：

```python
import anthropic

# 單次呼叫的逾時可以掛在 client 上，或單次 with_options 覆寫
client = anthropic.Anthropic(timeout=60.0)  # 秒

def bounded_tool_subagent(user_input: str, tools, max_iterations: int = 8) -&gt; str:
    &quot;&quot;&quot;手寫 agentic loop 的 subagent，自己加上 max_iterations 安全閥。&quot;&quot;&quot;
    messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_input}]

    for _ in range(max_iterations):
        response = client.messages.create(
            model=&quot;claude-opus-4-8&quot;,
            max_tokens=4000,          # 單次回應 token 上限
            tools=tools,
            messages=messages,
        )
        if response.stop_reason == &quot;end_turn&quot;:
            return next(b.text for b in response.content if b.type == &quot;text&quot;)

        # Claude 要呼叫工具：執行後回灌結果，繼續迴圈
        tool_use_blocks = [b for b in response.content if b.type == &quot;tool_use&quot;]
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        tool_results = []
        for tool in tool_use_blocks:
            result = execute_tool(tool.name, tool.input)  # 你的實作
            tool_results.append({
                &quot;type&quot;: &quot;tool_result&quot;,
                &quot;tool_use_id&quot;: tool.id,
                &quot;content&quot;: result,
            })
        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: tool_results})

    # 撞到 max_iterations 還沒結束：明確中止，不要無限跑下去
    return &quot;ERROR: subagent 達到最大迭代次數，未能收斂&quot;
```

&gt; 小提醒：手寫 loop 一定要看 `stop_reason`。`end_turn` 代表 Claude 講完了；`tool_use` 代表它要呼叫工具（你要回灌結果再續）；`max_tokens` 代表被 `max_tokens` 截斷了。`max_iterations` 是**你的** loop 計數器，不是 API 欄位——這正是手寫 loop 防無限循環的正解。

**Orchestrator 的總預算與步數上限**是第二道防線。orchestrator 自己要記帳：總共派了幾個 subagent、累積花了多少 token，超過上限就停。我自己的做法是包一個簡單的 tracker：

```python
class BudgetTracker:
    def __init__(self, max_tokens: int = 200_000, max_steps: int = 30):
        self.max_tokens = max_tokens
        self.max_steps = max_steps
        self.used_tokens = 0
        self.steps = 0

    def charge(self, response) -&gt; None:
        &quot;&quot;&quot;每次 subagent 呼叫後，把實際用量記進去。&quot;&quot;&quot;
        self.steps += 1
        self.used_tokens += response.usage.input_tokens + response.usage.output_tokens
        if self.steps &gt; self.max_steps:
            raise RuntimeError(f&quot;orchestrator 超過步數上限 {self.max_steps}&quot;)
        if self.used_tokens &gt; self.max_tokens:
            raise RuntimeError(f&quot;orchestrator 超過 token 預算 {self.max_tokens}&quot;)
```

每個 response 物件都帶 `usage.input_tokens` 和 `usage.output_tokens`，所以你拿到的是**實際**用量，不是估算。orchestrator 在每次呼叫 subagent 之後 `budget.charge(response)`，超標就丟例外中止整個流程。

**明確的終止條件**是第三道防線。每個 subagent 的 system prompt 都應該明確定義什麼時候算「完成」：

```python
SEARCH_SYSTEM = &quot;&quot;&quot;你的任務是搜尋並整理某主題的相關資訊。

完成條件（達到任一條件即完成，立即回傳結果）：
- 已整理出 5 個以上相關事實
- 已涵蓋主題的主要面向
- 已累積約 500 字的資料

不要持續追求「完美」的資料。整理到足夠就停下來回傳。
&quot;&quot;&quot;
```

## Tracing 和可觀測性

Multi-agent 系統最難除錯的地方，就是你不知道哪個 subagent 在做什麼、為什麼做這個決定。Anthropic 沒有 `enable_tracing` 那種一行開啟的 tracing API——但要做到可觀測，其實有幾個樸實又夠用的真實手段。

**第一：用環境變數打開 SDK 的 debug log。** 設 `ANTHROPIC_LOG=debug`，SDK 就會把每個 HTTP 請求／回應印出來，你能看到實際送出去的 body 和收到的內容：

```bash
ANTHROPIC_LOG=debug python market_research.py
```

**第二：記錄每個呼叫的 `response._request_id`。** 每個 response 都帶一個 request id，回報問題給 Anthropic 支援時用得上，自己對帳哪個 subagent 的哪次呼叫出事也靠它：

```python
import logging

logging.basicConfig(level=logging.INFO)
log = logging.getLogger(&quot;orchestrator&quot;)

def traced_subagent(name: str, system: str, user: str) -&gt; str:
    resp = client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=2000,
        system=system,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user}],
    )
    log.info(
        &quot;[%s] request_id=%s stop_reason=%s in=%d out=%d&quot;,
        name,
        resp._request_id,
        resp.stop_reason,
        resp.usage.input_tokens,
        resp.usage.output_tokens,
    )
    return next(b.text for b in resp.content if b.type == &quot;text&quot;)
```

**第三：自己存結構化 log。** 我在生產環境的做法是，在 orchestrator 每次呼叫 subagent 的前後各記一筆——subagent 名稱、輸入摘要、`request_id`、`stop_reason`、token 用量、耗時——寫進 JSON log 或送進你既有的觀測系統（OpenTelemetry、Grafana 之類）。因為 orchestrator 是你寫的程式，這些埋點全在你掌控之內，不需要任何特殊 API。這讓我能事後看到「哪個 subagent 慢、哪次呼叫吃掉最多 token、哪個決策走錯」，對優化 system prompt 非常有幫助。

## 完整範例：三個 subagent 協作產出市場分析報告

把前面所有概念整合起來，這是我實際在生產環境使用的市場研究系統。三個 subagent——**市場規模 / 競品 / 風險**，各有專屬 system prompt，用 `asyncio.gather` 平行跑，最後 orchestrator 把三份結果丟給一次 Claude 彙整成報告。整段只用真實 `anthropic` SDK，照抄就能跑：

```python
import asyncio
import logging
from anthropic import AsyncAnthropic

logging.basicConfig(level=logging.INFO)
log = logging.getLogger(&quot;market-research&quot;)

client = AsyncAnthropic(timeout=60.0)

MODEL = &quot;claude-opus-4-8&quot;
sem = asyncio.Semaphore(3)  # 同時最多 3 個 subagent 在跑

# =====================
# 三個 subagent：各有專屬 system prompt
# =====================

MARKET_SIZE_SYSTEM = &quot;&quot;&quot;你是市場規模分析 subagent。
任務：估算並描述目標市場的規模與成長趨勢。
請涵蓋：TAM / SAM / SOM 概念性估算、近 3 年成長率、主要驅動因素。
輸出：結構化 Markdown，800-1000 字，只談市場規模相關，不要離題。
完成條件：涵蓋上述面向即停，不要追求完美數字。&quot;&quot;&quot;

COMPETITOR_SYSTEM = &quot;&quot;&quot;你是競品分析 subagent。
任務：分析目標市場的主要競爭對手。
請涵蓋：3-5 個主要玩家、各自定位與優劣勢、產品差異化、市場份額概況。
分析框架：Porter&apos;s Five Forces 的精簡版。
輸出：結構化 Markdown，800-1000 字，只談競品，不要離題。&quot;&quot;&quot;

RISK_SYSTEM = &quot;&quot;&quot;你是風險分析 subagent。
任務：盤點進入此市場的主要風險。
請涵蓋：法規 / 技術 / 市場 / 營運四類風險，各舉具體例子並標示嚴重度（高/中/低）。
輸出：結構化 Markdown，800-1000 字，只談風險，不要離題。&quot;&quot;&quot;

SYNTHESIS_SYSTEM = &quot;&quot;&quot;你是市場研究 orchestrator 的彙整 agent。
任務：整合「市場規模」「競品」「風險」三份分析，產出一份執行摘要。
輸出（共約 600 字）：
- 市場概況（2-3 句）
- 主要洞察（3-5 條，跨三份報告交叉得出）
- 風險提示（2-3 條）
- 建議行動（2-3 條）
不要照抄三份原文，要綜合、要有觀點。&quot;&quot;&quot;


async def run_subagent(name: str, system: str, user: str) -&gt; str:
    &quot;&quot;&quot;一個 subagent = 一次帶專屬 system prompt 的 Claude 呼叫。&quot;&quot;&quot;
    async with sem:
        resp = await client.messages.create(
            model=MODEL,
            max_tokens=4000,
            system=system,
            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user}],
        )
    log.info(
        &quot;[%s] request_id=%s stop_reason=%s in=%d out=%d&quot;,
        name, resp._request_id, resp.stop_reason,
        resp.usage.input_tokens, resp.usage.output_tokens,
    )
    if resp.stop_reason == &quot;refusal&quot;:
        return f&quot;[{name}] 安全拒答，已略過。&quot;
    return next(b.text for b in resp.content if b.type == &quot;text&quot;)


async def market_research(topic: str) -&gt; str:
    &quot;&quot;&quot;Orchestrator：平行派三個 subagent，再彙整成報告。&quot;&quot;&quot;
    # 步驟 1：三個分析 subagent 平行跑（互不依賴，所以可平行）
    market_size, competitors, risks = await asyncio.gather(
        run_subagent(&quot;market_size&quot;, MARKET_SIZE_SYSTEM, topic),
        run_subagent(&quot;competitors&quot;, COMPETITOR_SYSTEM, topic),
        run_subagent(&quot;risks&quot;, RISK_SYSTEM, topic),
    )

    # 步驟 2：orchestrator 把三份結果當輸入，丟一次 Claude 彙整
    combined = (
        f&quot;# 研究主題\n{topic}\n\n&quot;
        f&quot;# 市場規模分析\n{market_size}\n\n&quot;
        f&quot;# 競品分析\n{competitors}\n\n&quot;
        f&quot;# 風險分析\n{risks}\n&quot;
    )
    report = await run_subagent(&quot;synthesis&quot;, SYNTHESIS_SYSTEM, combined)
    return report


if __name__ == &quot;__main__&quot;:
    question = (
        &quot;我想進入台灣的「知識管理 SaaS」市場（Notion 競爭對手區間）。&quot;
        &quot;請分析市場規模、主要競爭對手定位，以及進入的主要風險。&quot;
    )
    result = asyncio.run(market_research(question))
    print(result)
```

這個版本和我最初那次失敗的單一 agent 形成鮮明對比：每個 subagent 的 context 都只裝它自己那塊（市場規模 / 競品 / 風險），互不污染；三份分析平行跑，總時間約等於最慢那份；最後彙整是一次乾淨的呼叫，輸入是三份結構化摘要而不是一堆原始搜尋雜訊。orchestrator（`market_research` 函式）從頭到尾都是你看得懂、改得動、測得了的程式碼。

如果某個 subagent 內部需要用工具（例如真的去搜尋網路），就把那個 `run_subagent` 換成前面「防止失控」那段的手寫 agentic loop 版本（帶 `max_iterations`），其餘結構不變。

## 設計原則總結

經過多個 multi-agent 系統的開發和踩坑，我整理出幾條核心原則：

**1. 每個 subagent 的職責要清晰到「用一句話說清楚」。** 如果你需要用三句話才能解釋一個 subagent 做什麼，它的職責可能太模糊了——對應到程式碼，就是它的 system prompt 該再聚焦。

**2. Routing 判斷要明確到「條件成立就派、不成立就不派」。** 別讓 orchestrator「自己看著辦」。能用規則就用規則；需要語意判斷時，用 `messages.parse()` 的 structured output 把回傳鎖死成你定義的合法選項，再拿去查表呼叫對應 subagent。

**3. Subagent 之間靠資料傳遞協作，沒有魔法交接。** 上一個的輸出就是下一個的輸入（小資料直接傳），或寫進共用狀態（大資料存外部、只傳摘要）。「handoff」在 Claude 這邊就是你的一行函式呼叫。

**4. 每個 subagent 都要有明確的「完成條件」＋安全閥。** 單次呼叫用 `max_tokens` 封頂、`timeout` 設逾時；內部跑 loop 的就自己加 `max_iterations`。orchestrator 層再加總預算（用 `response.usage` 記實際 token）和步數上限。沒有框架會替你擋，安全閥都是你自己加的。

**5. 先做順序版，再優化成平行版。** 平行執行（`AsyncAnthropic` ＋ `asyncio.gather`）更複雜、更難除錯，也更容易撞 rate limit（記得用 `Semaphore` 控制併發）。先確認順序版的邏輯正確，再改成平行。

**6. 可觀測性靠你自己埋。** `ANTHROPIC_LOG=debug` 看原始請求、記每次呼叫的 `response._request_id` 與 `usage`、把這些寫進你的結構化 log。Anthropic 沒有一鍵 tracing，但因為 orchestrator 是你的程式，埋點全在掌控之內。

---

下一章，我們來看 multi-agent 系統的另一面：如果你想讓自己的服務成為別人 agent 可以呼叫的工具，你需要開發自己的 MCP Server。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.NsqXwkhM.webp" medium="image"/><category>Claude API</category><category>Multi-Agent</category><category>Orchestrator</category><category>系統設計</category><enclosure url="https://bobochen.dev/_astro/cover.NsqXwkhM.webp" length="0" type="image/png"/></item><item><title>打造你的第一個 Agent：工具、狀態與循環</title><link>https://bobochen.dev/blog/claude-api-guide-building-agents/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-building-agents/</guid><description>Agent 生命週期完整解析（真實 agentic loop）；用 @beta_tool 與手寫 JSON 定義工具；工具回傳值設計；用 messages 歷史傳遞記憶；max_iterations 與 stop_reason 停止條件；Python 完整 Research Agent（tool_runner 可跑版）；常見陷阱（無限循環、token 爆炸）；Debug 技巧；單元測試與 mock client 策略。</description><pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate><content:encoded>上一章我們了解了「用 Claude 建 Agent」的核心概念。這一章我要帶你從頭到尾打造一個真實可用的 Agent。

不是玩具，是一個能搜尋網路、查詢資料庫、生成結構化報告的 Research Agent。我會把每個設計決策都解釋清楚，包括我踩過的坑。

&gt; 先講一句老實話：Anthropic 沒有「Agent / Runner / Handoff」那種類別。**真實的做法是用官方 `anthropic`（Python）/ `@anthropic-ai/sdk`（TS）SDK 的 tool runner，或自己手寫一個 agentic loop。**我這一章兩種都會教，全部都是可以跑的真實程式碼。口語上我還是會叫它「Agent」，但你心裡要清楚：那只是「帶 system prompt + 一組工具的 Claude 呼叫，跑在一個循環裡」。

準備好了嗎？

## Agent 的生命週期

理解 Agent 怎麼運作，是寫出好 Agent 程式碼的前提。把那層抽象拆開來看，所謂的 agentic loop 其實就是這樣的一段對話循環：

```
user 訊息
    │
    ▼
┌───────────────────────────────────────────┐
│            Agentic Loop                    │
│                                            │
│  1. client.messages.create(messages, tools)│
│            │                               │
│            ▼                               │
│  2. Claude 回應                            │
│            │                               │
│      ┌─────┴────────┐                      │
│      │              │                      │
│      ▼              ▼                      │
│  stop_reason     stop_reason               │
│  == &quot;end_turn&quot;   == &quot;tool_use&quot;             │
│      │              │                      │
│      ▼              ▼                      │
│    結束          執行工具（你的程式碼）    │
│                     │                      │
│                     ▼                      │
│              把 tool_result 回灌 messages  │
│                     │                      │
│                     └──→ 回到步驟 1        │
└───────────────────────────────────────────┘
    │
    ▼
最終的 assistant 訊息（final text）
```

關鍵點：

- Claude API 是 **stateless** 的。每次呼叫，你都要把完整的 `messages` 歷史（含先前的工具結果）一起送回去。
- Loop 一直跑，直到 Claude 的回應 `stop_reason == &quot;end_turn&quot;`（也就是這一輪它沒有再要求呼叫工具）。
- 「誰跑這個 loop」有兩個選擇：
  - **Tool runner（beta，推薦）**：SDK 幫你跑「呼叫工具 → 回灌結果 → 再問」這個循環，你只要提供工具的實作。
  - **手寫 loop**：要細控（人類審批、條件式執行、自訂 log、預算上限）時，自己跑 `while` 迴圈。

整章我會從工具定義出發，先用 tool runner 把招牌的 Research Agent 跑起來，再示範手寫 loop 怎麼接管細節。

## 定義工具的正確方式

工具定義是 Agent 開發最重要的技能之一。定義得好，Claude 能精確選用正確工具；定義得差，它要麼用錯工具，要麼不知道怎麼用。

### 使用 @beta_tool 裝飾器（Python）

官方 SDK 提供 `beta_tool` 裝飾器，會自動從你的函式簽名和 docstring 產生工具的 input schema，你不用手寫一份 JSON：

```python
import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰


@beta_tool
def search_web(query: str, num_results: int = 5) -&gt; str:
    &quot;&quot;&quot;搜尋網路並返回相關結果摘要。

    使用這個工具來查詢最新新聞和資訊、尋找特定主題的概覽、
    驗證事實或統計數據。

    Args:
        query: 搜尋查詢字串，使用具體的關鍵字效果更好。
        num_results: 要返回的結果數量（1-10），預設 5。
    &quot;&quot;&quot;
    # 實際的搜尋邏輯（這裡用 DuckDuckGo 作為範例）
    from duckduckgo_search import DDGS

    results = []
    with DDGS() as ddgs:
        for r in ddgs.text(query, max_results=num_results):
            results.append(f&quot;標題: {r[&apos;title&apos;]}\n來源: {r[&apos;href&apos;]}\n摘要: {r[&apos;body&apos;]}\n&quot;)

    if not results:
        return f&quot;沒有找到關於「{query}」的結果&quot;

    return &quot;\n---\n&quot;.join(results)
```

`@beta_tool` 會自動把：

- 函式名稱 → tool name
- docstring 開頭的描述 → tool description
- `Args:` 區塊裡每個參數的說明 → 對應參數的 description
- 型別提示（`str`、`int`、`bool`、`list[...]` 等）→ 參數型別
- 沒有預設值的參數 → `required`

**工具文件就是工具的「說明書」**，Claude 讀這份說明書來決定要不要用、怎麼用這個工具。花時間寫好 docstring 非常值得。

&gt; 非同步版本：如果你的工具是 `async def`，改用 `from anthropic import beta_async_tool` 來裝飾，搭配後面會講到的 `AsyncAnthropic` client。

### 手寫 JSON 工具定義（給手寫 loop 用）

`@beta_tool` 很方便，但如果你要走手寫 loop，或者想對 schema 有完全的控制（例如強制 `strict`、控制 `additionalProperties`），就直接寫工具的 JSON 定義丟給 `client.messages.create(tools=...)`：

```python
tools = [{
    &quot;name&quot;: &quot;search_web&quot;,
    &quot;description&quot;: &quot;搜尋網路並返回相關結果摘要。用於查詢最新資訊、驗證事實、尋找特定主題的概覽。&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
            &quot;query&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;搜尋查詢字串，使用具體的關鍵字效果更好&quot;},
            &quot;num_results&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;description&quot;: &quot;要返回的結果數量（1-10）&quot;},
        },
        &quot;required&quot;: [&quot;query&quot;],
    },
}]

# 想強制 Claude 只能用某個工具：tool_choice={&quot;type&quot;: &quot;tool&quot;, &quot;name&quot;: &quot;search_web&quot;}
# 想要嚴格的結構化輸入：在工具裡加 &quot;strict&quot;: True，
#   並讓 input_schema 加上 &quot;additionalProperties&quot;: False
```

這兩種寫法定義出的工具，對 Claude 來說是一模一樣的——`@beta_tool` 只是幫你把上面這份 JSON 自動產出來而已。

### TypeScript 工具定義

TypeScript 用 `betaZodTool`，搭配 Zod schema：

```typescript
import Anthropic from &apos;@anthropic-ai/sdk&apos;;
import { betaZodTool } from &apos;@anthropic-ai/sdk/helpers/beta/zod&apos;;
import { z } from &apos;zod&apos;;

const client = new Anthropic();

const searchWeb = betaZodTool({
  name: &apos;search_web&apos;,
  description: `搜尋網路並返回相關結果摘要。
用於查詢最新資訊、驗證事實、尋找特定主題的概覽。`,
  inputSchema: z.object({
    query: z.string().describe(&apos;搜尋查詢字串，使用具體的關鍵字效果更好&apos;),
    num_results: z.number().min(1).max(10).optional().describe(&apos;要返回的結果數量（1-10）&apos;),
  }),
  run: async (input) =&gt; {
    // 實作搜尋邏輯
    const results = await performSearch(input.query, input.num_results ?? 5);
    return results.join(&apos;\n---\n&apos;);
  },
});
```

`betaZodTool` 從 Zod schema 自動推導 input schema 和 TypeScript 型別，`run` 收到的 `input` 是完全 typed 的——這是 TS 端最大的好處。

## Tool Return Value 的設計原則

工具的返回值很重要，它直接影響 Claude 接下來能做什麼（這一段不分 SDK，是通用的好習慣）。

**原則 1：返回結構化的、人可讀的文字**

```python
# 差：只返回原始 JSON
return json.dumps(data)

# 好：返回格式化的文字，Claude 更容易理解
def format_search_result(data: dict) -&gt; str:
    return f&quot;&quot;&quot;公司：{data[&apos;company_name&apos;]}
成立年份：{data[&apos;founded&apos;]}
主要業務：{data[&apos;description&apos;]}
市值：{data[&apos;market_cap&apos;]}
最新新聞：{data[&apos;latest_news&apos;]}&quot;&quot;&quot;
```

**原則 2：包含充足的上下文**

```python
# 差：只返回數字
return &quot;42&quot;

# 好：帶上單位和上下文
return &quot;台灣 EV 市場 2026 年第一季銷售量：42,000 輛（YoY +28%）&quot;
```

**原則 3：失敗時提供有意義的錯誤信息**

```python
@beta_tool
def query_database(sql: str) -&gt; str:
    &quot;&quot;&quot;查詢公司資料庫（只讀）。&quot;&quot;&quot;
    try:
        results = db.execute(sql)
        if not results:
            return &quot;查詢執行成功，但沒有符合條件的記錄。&quot;
        return format_db_results(results)
    except DatabaseConnectionError:
        return &quot;資料庫連線失敗。請稍後重試，或聯繫技術支援。&quot;
    except InvalidSQLError as e:
        return f&quot;SQL 語法錯誤：{e}。請修正查詢語法後重試。&quot;
    except Exception as e:
        return f&quot;查詢失敗（未知錯誤）：{str(e)}&quot;
```

當工具失敗時，Claude 會看到這個錯誤信息，並嘗試修正或使用替代方案。好的錯誤信息讓 Claude 能做出更好的決策。（如果你走手寫 loop，還可以更進一步在 `tool_result` 裡加上 `&quot;is_error&quot;: True`，更明確地告訴 Claude「這次工具呼叫失敗了」——後面陷阱那一段會詳細講。）

## Agent 的 System Prompt 設計

Agent 的 system prompt（`client.messages.create` 的 `system` 參數）比一般的單輪問答需要更多結構。因為 Claude 要在循環裡反覆做決策，你得把「它是誰、要達成什麼、怎麼決策、什麼時候用哪個工具、最後輸出長什麼樣」都交代清楚。

一個好的 Agent system prompt 應該包含：

1. **角色定義**：這個 Agent 是誰，有什麼專業能力
2. **目標描述**：它要達成什麼
3. **行為準則**：它應該如何決策
4. **工具使用指南**：什麼情況用什麼工具（特別是工具很多時）
5. **輸出格式**：最終報告/回答的格式要求

```python
RESEARCH_AGENT_SYSTEM = &quot;&quot;&quot;你是一位專業的市場研究分析師，專門為企業提供深度市場報告。

## 你的能力
- 搜尋和整合最新的市場資訊
- 查詢公司內部資料庫獲取歷史數據
- 分析趨勢、競爭格局和機會

## 工作流程
1. 首先理解用戶的研究需求
2. 收集資料：用 search_web 搜尋最新資訊，用 query_database 取得歷史數據
3. 交叉驗證：用至少 2 個來源確認重要數據
4. 整合分析：找出模式、趨勢和洞察
5. 生成報告：以清晰的結構呈現發現

## 工具使用指引
- search_web：用於市場現況、最新新聞、競爭者資訊
- query_database：用於歷史銷售數據、內部指標（使用 SELECT 語句，只讀取不修改）
- calculate_metrics：用於計算百分比變化、CAGR 等指標
- save_finding：在過程中把重要發現記下來，最後彙整成報告

## 輸出格式
最終報告使用以下結構：
1. 執行摘要（3-5 個要點）
2. 市場現況
3. 主要趨勢
4. 競爭格局
5. 機會與風險
6. 結論與建議

## 重要原則
- 每個重要數據都要標注來源
- 如果找不到可靠資料，明確說明而不是猜測
- 報告要有觀點，不只是資料彙整
&quot;&quot;&quot;
```

## Python 完整 Research Agent

現在來看完整的 Research Agent 實作。這是一個能搜尋網路、查詢資料庫、計算指標、邊做邊記筆記並生成報告的 Agent。我用官方 SDK 的 **tool runner** 來跑——它會自動處理整個 agentic loop，你只要把工具用 `@beta_tool` 定義好、提供給 runner 就行：

```python
import sqlite3
from datetime import datetime
from typing import Optional

import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰

# 過程中累積的研究發現（被 save_finding 寫入，最後彙整進報告）
FINDINGS: list[str] = []


# ============================================================
# 工具定義（@beta_tool 自動產生 schema）
# ============================================================

@beta_tool
def search_web(query: str, num_results: int = 5) -&gt; str:
    &quot;&quot;&quot;搜尋網路上的最新資訊。

    Args:
        query: 搜尋關鍵字（英文或中文）。
        num_results: 返回結果數（1-10）。
    &quot;&quot;&quot;
    try:
        from duckduckgo_search import DDGS

        results = []
        with DDGS() as ddgs:
            for r in ddgs.text(query, max_results=num_results):
                results.append(
                    f&quot;【{r[&apos;title&apos;]}】\n&quot;
                    f&quot;來源: {r[&apos;href&apos;]}\n&quot;
                    f&quot;內容: {r[&apos;body&apos;]}\n&quot;
                )
        if not results:
            return f&quot;沒有找到「{query}」的相關結果。建議嘗試不同的關鍵字。&quot;
        return f&quot;找到 {len(results)} 個結果：\n\n&quot; + &quot;\n---\n&quot;.join(results)
    except Exception as e:
        return f&quot;搜尋失敗：{str(e)}。請稍後重試。&quot;


@beta_tool
def query_database(sql: str) -&gt; str:
    &quot;&quot;&quot;查詢市場資料資料庫（只讀）。

    可用的資料表：
    - market_data(year, quarter, segment, revenue_usd, units_sold, growth_rate)
    - companies(id, name, segment, market_share, founded_year)
    - trends(id, date, category, value, unit, source)

    Args:
        sql: SELECT 查詢語句（不支援 INSERT/UPDATE/DELETE）。
    &quot;&quot;&quot;
    # 安全檢查：只允許 SELECT
    if not sql.strip().upper().startswith(&quot;SELECT&quot;):
        return &quot;安全限制：只允許 SELECT 查詢。&quot;

    try:
        conn = sqlite3.connect(&quot;market_data.db&quot;)
        conn.row_factory = sqlite3.Row
        cursor = conn.execute(sql)
        rows = cursor.fetchall()
        conn.close()

        if not rows:
            return &quot;查詢成功，但沒有符合條件的資料。&quot;

        # 格式化為易讀的表格
        headers = rows[0].keys()
        table_lines = [&quot; | &quot;.join(str(h) for h in headers)]
        table_lines.append(&quot;-&quot; * len(table_lines[0]))
        for row in rows[:50]:  # 最多顯示 50 行
            table_lines.append(&quot; | &quot;.join(str(v) for v in row))

        result = f&quot;查詢返回 {len(rows)} 筆記錄：\n\n&quot; + &quot;\n&quot;.join(table_lines)
        if len(rows) &gt; 50:
            result += f&quot;\n\n（僅顯示前 50 筆，共 {len(rows)} 筆）&quot;
        return result

    except sqlite3.Error as e:
        return f&quot;資料庫查詢錯誤：{str(e)}\n請檢查 SQL 語法。&quot;


@beta_tool
def calculate_metrics(
    values: list[float],
    metric_type: str,
    labels: Optional[list[str]] = None,
) -&gt; str:
    &quot;&quot;&quot;計算常用的商業指標。

    Args:
        values: 數值列表。
        metric_type: 指標類型，可選：
                     &quot;yoy_growth&quot;（年增率，需至少 2 個值）、
                     &quot;cagr&quot;（複合年均增長率，需起始值與終止值）、
                     &quot;market_share&quot;（市佔率百分比）、
                     &quot;summary&quot;（基本統計：最大、最小、平均、總和）。
        labels: 可選的標籤列表（對應每個值）。
    &quot;&quot;&quot;
    if not values:
        return &quot;錯誤：values 不能為空&quot;

    if metric_type == &quot;summary&quot;:
        return (
            f&quot;統計摘要：\n&quot;
            f&quot;  數量：{len(values)}\n&quot;
            f&quot;  總和：{sum(values):,.2f}\n&quot;
            f&quot;  平均：{sum(values) / len(values):,.2f}\n&quot;
            f&quot;  最大：{max(values):,.2f}\n&quot;
            f&quot;  最小：{min(values):,.2f}&quot;
        )

    if metric_type == &quot;yoy_growth&quot;:
        if len(values) &lt; 2:
            return &quot;錯誤：yoy_growth 需要至少 2 個值&quot;
        results = []
        for i in range(1, len(values)):
            if values[i - 1] != 0:
                growth = (values[i] - values[i - 1]) / values[i - 1] * 100
                label = (
                    f&quot;{labels[i]} vs {labels[i - 1]}&quot;
                    if labels and len(labels) &gt; i
                    else f&quot;第{i + 1}期 vs 第{i}期&quot;
                )
                results.append(f&quot;  {label}: {growth:+.1f}%&quot;)
        return &quot;年增率計算：\n&quot; + &quot;\n&quot;.join(results)

    if metric_type == &quot;cagr&quot;:
        if len(values) &lt; 2:
            return &quot;錯誤：cagr 需要至少 2 個值（起始值和終止值）&quot;
        years = len(values) - 1
        cagr = ((values[-1] / values[0]) ** (1 / years) - 1) * 100
        return (
            f&quot;複合年均增長率（CAGR）：\n&quot;
            f&quot;  期間：{years} 年\n&quot;
            f&quot;  起始值：{values[0]:,.2f}\n&quot;
            f&quot;  終止值：{values[-1]:,.2f}\n&quot;
            f&quot;  CAGR：{cagr:.1f}%&quot;
        )

    if metric_type == &quot;market_share&quot;:
        total = sum(values)
        if total == 0:
            return &quot;錯誤：總和為零，無法計算市佔率&quot;
        results = []
        for i, v in enumerate(values):
            label = labels[i] if labels and i &lt; len(labels) else f&quot;項目{i + 1}&quot;
            results.append(f&quot;  {label}: {v / total * 100:.1f}%&quot;)
        return f&quot;市佔率（總計 {total:,.0f}）：\n&quot; + &quot;\n&quot;.join(results)

    return f&quot;未知的 metric_type: {metric_type}。可選：summary, yoy_growth, cagr, market_share&quot;


@beta_tool
def save_finding(key: str, value: str) -&gt; str:
    &quot;&quot;&quot;儲存研究過程中的重要發現，最後會彙整進報告。

    Args:
        key: 發現的簡短標題。
        value: 發現的內容（含數據與來源）。
    &quot;&quot;&quot;
    FINDINGS.append(f&quot;### {key}\n{value}&quot;)
    return f&quot;已記錄發現：{key}（目前共 {len(FINDINGS)} 筆）&quot;


# ============================================================
# System Prompt
# ============================================================

RESEARCH_AGENT_SYSTEM = &quot;&quot;&quot;你是一位專業的市場研究分析師。

## 研究流程
1. 分析用戶的研究需求，確定需要收集的資訊類型
2. 用 search_web 搜尋市場現況和最新趨勢（至少 2-3 次不同查詢）
3. 用 query_database 取得歷史數據（如果相關）
4. 用 calculate_metrics 計算重要指標
5. 用 save_finding 隨手記下每一個重要發現（含來源）
6. 全部蒐集完成後，直接用一段結構化的文字輸出完整報告，然後結束

## 搜尋策略
- 先搜尋廣泛的概覽，再針對特定面向深入
- 用英文搜尋效果通常比中文好，但可以結合兩種語言
- 如果第一次搜尋結果不理想，換不同的關鍵字重試

## 資料品質原則
- 重要數據至少要有 2 個來源確認
- 如果找到矛盾的數據，說明並給出最可能正確的版本
- 清楚標注每個數據的來源和時間

## 完成條件（很重要）
完成資料蒐集與計算後，立刻輸出最終報告並停止呼叫工具。
不要無止盡地繼續搜尋。報告寫完就結束。

## 報告結構
1. 執行摘要（3-5 個要點）
2. 市場現況
3. 主要趨勢
4. 競爭格局
5. 機會與風險
6. 結論與建議&quot;&quot;&quot;


# ============================================================
# 用 tool runner 跑 agentic loop
# ============================================================

def run_research(topic: str) -&gt; str:
    &quot;&quot;&quot;執行市場研究任務。tool runner 會自動跑 loop，
    呼叫工具、回灌結果，直到 Claude 不再呼叫工具為止。&quot;&quot;&quot;
    FINDINGS.clear()

    print(f&quot;開始研究：{topic}&quot;)
    print(&quot;=&quot; * 60)

    runner = client.beta.messages.tool_runner(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=16000,
        system=RESEARCH_AGENT_SYSTEM,
        tools=[search_web, query_database, calculate_metrics, save_finding],
        messages=[{
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;請針對以下主題生成一份完整的市場研究報告：{topic}&quot;,
        }],
    )

    # runner 是可迭代物件，每個 iteration yield 一個 BetaMessage。
    # 我們順手把每一輪用了哪些工具印出來，方便觀察。
    final_message = None
    for message in runner:
        final_message = message
        tool_uses = [b.name for b in message.content if b.type == &quot;tool_use&quot;]
        if tool_uses:
            print(f&quot;  [工具] {&apos;, &apos;.join(tool_uses)}&quot;)
        usage = message.usage
        print(f&quot;  [token] in={usage.input_tokens} out={usage.output_tokens}&quot;)

    print(&quot;=&quot; * 60)
    print(f&quot;完成！過程中記錄了 {len(FINDINGS)} 筆發現。&quot;)

    # 最終 message 裡的 text block 就是 Claude 寫出來的報告
    report = &quot;&quot;.join(b.text for b in final_message.content if b.type == &quot;text&quot;)
    return report


if __name__ == &quot;__main__&quot;:
    print(run_research(&quot;台灣 AI 應用市場 2026 年展望&quot;))
```

幾個重點：

- **`tool_runner` 自動跑 loop**：你不用自己處理「拿到 `tool_use` → 執行 → 把 `tool_result` 塞回 messages → 再呼叫一次」。runner 看到 Claude 要呼叫工具，就去執行對應的 `@beta_tool` 函式，把結果回灌，再問下一輪；直到 Claude 不再呼叫工具（`stop_reason == &quot;end_turn&quot;`）自然停下。
- **觀察用 iteration**：`for message in runner` 每一輪都會給你那一輪的 `BetaMessage`，我用它印出工具呼叫與 token 用量，相當於免費的可觀測性。
- **狀態放哪**：這裡用一個模組層級的 `FINDINGS` 串列當 Agent 的「便條紙」，`save_finding` 工具把發現寫進去。下一節我會講更乾淨的記憶傳遞方式。

## 加入記憶：Context 傳遞

Claude API 是 **stateless** 的——它不會自己記得上一次對話。所謂「記憶」，就是**你自己把歷史帶著走**。

### 方法一：用 messages 歷史串接（最基本、最可靠）

要讓 Agent 記得前面發生過什麼，就在下一次呼叫時把先前的 `messages` 一起帶進去。比方說做一個能連續對話的研究助理：

```python
import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()

# 這串 messages 就是 Agent 的「記憶」，跨多輪持續累積
history: list[dict] = []


def chat_turn(user_input: str) -&gt; str:
    history.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_input})

    runner = client.beta.messages.tool_runner(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=16000,
        system=RESEARCH_AGENT_SYSTEM,
        tools=[search_web, query_database, calculate_metrics, save_finding],
        messages=history,
    )

    final_message = None
    for message in runner:
        final_message = message

    # 把這一輪 Claude 的完整回應（含它呼叫工具的紀錄）寫回歷史，
    # 下一輪就「記得」這次研究過什麼
    history.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: final_message.content})

    return &quot;&quot;.join(b.text for b in final_message.content if b.type == &quot;text&quot;)


print(chat_turn(&quot;研究台灣 SaaS 市場規模&quot;))
print(chat_turn(&quot;那其中垂直 SaaS 占多少？&quot;))  # Claude 記得上一輪查過什麼
```

這就是記憶的本質：歷史在你的程式裡，你決定要帶多少、帶哪些。要長期保存就把 `history` 存進資料庫，下次載回來即可。

### 方法二：memory 工具（進階選項）

如果你不想自己無限堆疊 `messages`（會越來越貴），官方還有一個 server 端的 memory 工具，讓 Claude 自己決定把什麼存起來、之後再讀回。啟用方式是在 `tools` 裡加一個工具型別：

```python
tools = [
    {&quot;type&quot;: &quot;memory_20250818&quot;, &quot;name&quot;: &quot;memory&quot;},
    # ... 你自己的工具
]
```

它讓 Claude 在跨會話之間維持一份「筆記」，而不必把整段歷史都塞回 context。細節我留給之後談「長時記憶」的章節，這裡你只要知道：**記憶要嘛自己用 `messages` 帶，要嘛交給 memory 工具，沒有第三種魔法。**

## 加入停止條件

tool runner 在 Claude 不再呼叫工具時就會自然停下——它不需要你做任何事。但有時候你需要**更主動的停止條件**：跑太多輪要硬停、超過 token 預算要中止、或某個工具被呼叫後就該結束。這種細控，就是手寫 loop 登場的時候。

手寫 loop 的停止條件有兩根支柱：**`stop_reason == &quot;end_turn&quot;`**（Claude 講完了）和**自己維護的 `max_iterations` 計數器**（防呆上限）：

```python
import anthropic

client = anthropic.Anthropic()

# 工具的 JSON 定義（手寫 loop 用這種，不是 @beta_tool）
TOOLS = [
    {
        &quot;name&quot;: &quot;search_web&quot;,
        &quot;description&quot;: &quot;搜尋網路並返回相關結果摘要。&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {&quot;query&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;搜尋關鍵字&quot;}},
            &quot;required&quot;: [&quot;query&quot;],
        },
    },
    # ... query_database / calculate_metrics 等
]


def execute_tool(name: str, tool_input: dict) -&gt; str:
    &quot;&quot;&quot;把工具名稱對應到實際實作（你自己寫）。&quot;&quot;&quot;
    if name == &quot;search_web&quot;:
        return do_search(tool_input[&quot;query&quot;])
    # ... 其餘工具
    return f&quot;未知的工具：{name}&quot;


def run_research_manual(topic: str, max_iterations: int = 20) -&gt; str:
    messages = [{
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: f&quot;請針對以下主題生成市場研究報告：{topic}&quot;,
    }]

    for iteration in range(max_iterations):
        response = client.messages.create(
            model=&quot;claude-opus-4-8&quot;,
            max_tokens=16000,
            system=RESEARCH_AGENT_SYSTEM,
            tools=TOOLS,
            messages=messages,
        )

        # 支柱 1：Claude 這一輪沒有再要求呼叫工具 → 結束
        if response.stop_reason == &quot;end_turn&quot;:
            return &quot;&quot;.join(b.text for b in response.content if b.type == &quot;text&quot;)

        # 否則 stop_reason == &quot;tool_use&quot;：執行工具、回灌結果
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})
        tool_results = []
        for block in response.content:
            if block.type == &quot;tool_use&quot;:
                try:
                    result = execute_tool(block.name, block.input)
                    tool_results.append({
                        &quot;type&quot;: &quot;tool_result&quot;,
                        &quot;tool_use_id&quot;: block.id,   # 必須對應 tool_use 的 id
                        &quot;content&quot;: result,
                    })
                except Exception as e:
                    # 工具錯誤：用 is_error 讓 Claude 知道，並嘗試自我修正
                    tool_results.append({
                        &quot;type&quot;: &quot;tool_result&quot;,
                        &quot;tool_use_id&quot;: block.id,
                        &quot;content&quot;: f&quot;工具執行失敗：{e}&quot;,
                        &quot;is_error&quot;: True,
                    })
        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: tool_results})

    # 支柱 2：跑到 max_iterations 上限還沒結束 → 硬停（防無限循環）
    return &quot;（已達最大迭代次數，回傳目前進度）\n&quot; + str(messages[-1])
```

關於 `stop_reason`，你會遇到的值有：

- `end_turn`：Claude 講完了（正常結束）。
- `tool_use`：Claude 要呼叫工具，loop 該執行工具並續跑。
- `max_tokens`：被 `max_tokens` 截斷了（輸出太長），你可能要調高上限或請它精簡。
- `pause_turn`：server 端工具執行暫停，可以把回應原樣再送一次續跑。
- `refusal`：基於安全拒答，看 `response.stop_reason` 旁邊的細節欄位處理。

**什麼時候用哪種？** 純粹「呼叫工具直到完成」用 tool runner 最省事；要插入人類審批、預算上限、條件式中止、或自訂每一步的 log，就手寫 loop。兩者底層打的是同一支 `messages.create` API。

## 常見陷阱與解決方法

### 陷阱 1：無限循環

**症狀**：Agent 一直呼叫工具，沒有停止跡象。

**原因**：通常是工具一直返回讓 Claude 覺得「任務還沒完成」的信號，或 system prompt 設計得讓它認為要無限深入研究。

**解決**：

- **手寫 loop**：加 `max_iterations` 計數器當硬性上限（上一節的「支柱 2」），跑到就跳出。這是手寫 loop 防無限循環的正解。
- **tool runner**：它在 Claude 沒有 `tool_use` 時本來就會停；無限循環多半來自 prompt。把「完成條件」寫進 system prompt：

```text
## 完成條件
完成以下所有步驟後，立即輸出報告並結束：
- 至少完成 3 次網路搜尋
- 查詢相關的歷史數據（如果有）
- 計算關鍵指標
- 輸出最終報告

不要繼續收集更多資料。報告輸出後就結束，不要再呼叫任何工具。
```

### 陷阱 2：Token 爆炸

**症狀**：一次 Agent run 消耗了幾十萬 token。

**原因**：Claude API 是 stateless，每一輪都把**完整歷史**送回去。如果工具返回了一大坨文字，每次循環都會把它重複帶進 context，越滾越大。

**解決**：

```python
@beta_tool
def search_web(query: str, max_chars: int = 2000) -&gt; str:
    &quot;&quot;&quot;搜尋網路。返回結果限制在 max_chars 字元以內。&quot;&quot;&quot;
    full_text = format_results(do_search(query))
    if len(full_text) &gt; max_chars:
        return full_text[:max_chars] + f&quot;\n\n[內容過長已截斷，原始長度：{len(full_text)} 字元]&quot;
    return full_text
```

三個方向一起做：

1. **精簡 context**：工具只回傳真正有用的內容，別把整頁 HTML 丟回去。
2. **截斷工具輸出**：像上面那樣設 `max_chars`，並在 system prompt 限制「不要一次請求超過 5 個搜尋結果、查資料庫用 `LIMIT` 限制行數」。
3. **Prompt caching**：把固定不變的大段（system prompt、工具定義、長文件）標記為可快取，重複的部分就不用每輪重新計費。這對長 loop 省下來的成本相當可觀。

### 陷阱 3：工具錯誤導致 Agent 卡住

**症狀**：工具拋出異常，整個 loop 崩潰。

**解決**：永遠不要讓未捕獲的異常往外炸。把錯誤包成有意義的字串回傳，並在 `tool_result` 標 `&quot;is_error&quot;: True`，讓 Claude 知道「這次失敗了」並自我修正：

```python
# 手寫 loop 裡組 tool_result
try:
    result = execute_tool(block.name, block.input)
    tool_results.append({
        &quot;type&quot;: &quot;tool_result&quot;,
        &quot;tool_use_id&quot;: block.id,
        &quot;content&quot;: result,
    })
except RateLimitError:
    tool_results.append({
        &quot;type&quot;: &quot;tool_result&quot;,
        &quot;tool_use_id&quot;: block.id,
        &quot;content&quot;: &quot;API 速率限制達到。請等待後重試，或改用其他方法獲取這個資訊。&quot;,
        &quot;is_error&quot;: True,
    })
except Exception as e:
    tool_results.append({
        &quot;type&quot;: &quot;tool_result&quot;,
        &quot;tool_use_id&quot;: block.id,
        &quot;content&quot;: f&quot;工具執行失敗（未知錯誤）：{type(e).__name__}: {e}&quot;,
        &quot;is_error&quot;: True,
    })
```

用 `@beta_tool` 搭 tool runner 時，同樣的精神是讓工具函式內部 `try/except` 後**回傳**錯誤字串（像「Tool Return Value 設計原則 3」那樣），而不是讓它拋例外——Claude 讀到錯誤訊息後，常常會自己換個參數重試。

### 陷阱 4：Agent 沒有使用工具

**症狀**：Claude 直接從「已知知識」回答，沒有呼叫你提供的工具。

**解決**：兩個層次。

system prompt 明確要求：

```text
## 強制要求
在提供任何分析之前，你必須：
1. 至少呼叫 search_web 兩次，獲取最新的市場數據
2. 呼叫 query_database 查詢歷史數據
不要依賴你的訓練數據，市場數據變化快，必須即時查詢。
```

或者用 `tool_choice` 從 API 層級強制（手寫 loop / `messages.create` 都支援）：

```python
response = client.messages.create(
    model=&quot;claude-opus-4-8&quot;,
    max_tokens=16000,
    tools=TOOLS,
    tool_choice={&quot;type&quot;: &quot;any&quot;},  # 強制這一輪一定要呼叫某個工具
    # 或 {&quot;type&quot;: &quot;tool&quot;, &quot;name&quot;: &quot;search_web&quot;} 指定一定要用某個工具
    messages=messages,
)
```

## Debug 技巧

### 開啟 SDK 詳細日誌

最快的方法是設環境變數，SDK 就會把每次請求/回應的細節印出來：

```bash
ANTHROPIC_LOG=debug python research_agent.py
```

你會看到實際送出的 payload、HTTP 狀態、retry 等資訊——排查「為什麼工具沒被呼叫」「為什麼回應被截斷」非常有用。

### 檢查完整的訊息歷史

手寫 loop 時，`messages` 就是 Agent 的完整記憶。卡住時直接 `print(messages)` 把它攤開看，是最樸實也最有效的 debug：

```python
import json

for i, message in enumerate(messages):
    print(f&quot;\n--- 訊息 {i + 1} ({message[&apos;role&apos;]}) ---&quot;)
    content = message[&quot;content&quot;]
    if isinstance(content, str):
        print(content[:200])
    else:
        for block in content:
            # block 可能是 SDK 物件或 dict，統一取屬性
            btype = getattr(block, &quot;type&quot;, None) or block.get(&quot;type&quot;)
            if btype == &quot;tool_use&quot;:
                name = getattr(block, &quot;name&quot;, None) or block.get(&quot;name&quot;)
                print(f&quot;[工具呼叫] {name}&quot;)
            elif btype == &quot;tool_result&quot;:
                print(f&quot;[工具結果] {str(getattr(block, &apos;content&apos;, None) or block.get(&apos;content&apos;))[:200]}&quot;)
            elif btype == &quot;text&quot;:
                print(f&quot;[文字] {(getattr(block, &apos;text&apos;, None) or block.get(&apos;text&apos;))[:200]}&quot;)
```

### 用 request id 回報問題

每個回應物件都帶一個 `_request_id`。如果你遇到疑似 API 端的異常要回報給 Anthropic，附上這個 id 最快：

```python
response = client.messages.create(model=&quot;claude-opus-4-8&quot;, max_tokens=16000, messages=[...])
print(response._request_id)  # 例如 req_011CS...
```

## 如何測試 Agent

測試 Agent 的挑戰是：工具會呼叫外部 API、Claude 呼叫要花錢又不確定。好消息是——**`@beta_tool` 包的就是一個普通函式**，所以我們可以分兩層測。

### 第一層：直接對工具的底層函式做單元測試

`@beta_tool` 不會把你的函式變成不可呼叫的魔法物件，它的純邏輯可以照常測。把工具的純邏輯抽出來測，最乾淨、最快、不花一毛錢：

```python
import pytest

# 把可測的純邏輯抽成獨立函式，工具只是薄薄一層包裝
def compute_yoy_growth(values: list[float]) -&gt; list[float]:
    return [
        (values[i] - values[i - 1]) / values[i - 1] * 100
        for i in range(1, len(values))
        if values[i - 1] != 0
    ]


def test_yoy_growth_basic():
    result = compute_yoy_growth([100.0, 132.0])
    assert result == [pytest.approx(32.0)]


def test_yoy_growth_handles_empty():
    assert compute_yoy_growth([100.0]) == []


def test_query_database_rejects_non_select():
    # query_database 內含 @beta_tool 也照常可呼叫，邏輯能直接驗
    assert &quot;只允許 SELECT&quot; in query_database(&quot;DELETE FROM market_data&quot;)
```

### 第二層：整合測試——mock 掉 Anthropic client

要驗整個 loop 的串接（不真的打 API），就把 `anthropic.Anthropic` client mock 掉，讓它回傳你預先安排好的回應序列：先回一個「呼叫工具」的回應，再回一個「end_turn」的回應，驗證你的手寫 loop 有正確執行工具、回灌結果、然後停下：

```python
from types import SimpleNamespace
from unittest.mock import MagicMock


def make_tool_use_response(tool_name, tool_id, tool_input):
    block = SimpleNamespace(type=&quot;tool_use&quot;, name=tool_name, id=tool_id, input=tool_input)
    return SimpleNamespace(stop_reason=&quot;tool_use&quot;, content=[block])


def make_end_response(text):
    block = SimpleNamespace(type=&quot;text&quot;, text=text)
    return SimpleNamespace(stop_reason=&quot;end_turn&quot;, content=[block])


def test_manual_loop_executes_tool_then_stops(monkeypatch):
    fake_client = MagicMock()
    # 第一次呼叫要求用 search_web，第二次就收尾
    fake_client.messages.create.side_effect = [
        make_tool_use_response(&quot;search_web&quot;, &quot;toolu_1&quot;, {&quot;query&quot;: &quot;台灣 EV&quot;}),
        make_end_response(&quot;# 報告\n台灣電動車市場 2026 年成長強勁。&quot;),
    ]
    # 用我們的假 client 取代真實 client
    monkeypatch.setattr(&quot;research_agent.client&quot;, fake_client)

    report = run_research_manual(&quot;台灣電動車市場&quot;, max_iterations=10)

    # loop 應該呼叫了兩次 API（一次工具、一次收尾）
    assert fake_client.messages.create.call_count == 2
    assert &quot;電動車&quot; in report
```

兩層測試合起來：純邏輯走第一層（快、便宜、覆蓋面廣），loop 串接走第二層（mock client、不花錢、驗收 stop 條件）。真的要對真實 Claude 行為做 e2e 驗證時，再用便宜的模型跑少量 case 即可。

---

恭喜你讀到這裡。你已經從「送一個 HTTP 請求給 Claude」走到了「用真實的 Anthropic SDK 建立能自主完成複雜任務的 AI Agent」——而且你很清楚底層就是一個 agentic loop，不是什麼黑魔法。

這一章是 Claude API &amp; Agent SDK 完全指南的第十章，也是 Agent 開發的核心技術。接下來的章節，我們會繼續深入：多 Agent 系統的設計（orchestrator 怎麼路由到不同 subagent）、Human-in-the-loop 的實作、Agent 的監控與可觀測性，以及生產環境的部署策略。

Agent 的世界，我們才剛開始。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Co3uqRyq.webp" medium="image"/><category>Claude API</category><category>Agent SDK</category><category>AI Agent</category><category>開發工具</category><category>狀態管理</category><enclosure url="https://bobochen.dev/_astro/cover.Co3uqRyq.webp" length="0" type="image/png"/></item><item><title>Agent SDK 入門：從 API 到 Agentic 應用</title><link>https://bobochen.dev/blog/claude-api-guide-agent-sdk-intro/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-agent-sdk-intro/</guid><description>為什麼有了 Messages API 還需要工具循環封裝；Tool Use 的 agentic loop 痛點；用 Claude 建 agent 的兩條真實路徑（官方 SDK 的 beta tool runner 與 Managed Agents）；Python + TypeScript 安裝與 Hello World；何時手寫 loop、何時用 tool runner；與 LangChain 的比較。</description><pubDate>Fri, 24 Apr 2026 00:00:00 GMT</pubDate><content:encoded>這一章標誌著這本書的轉折點。

前幾章我們學的是 Messages API——你問一個問題，Claude 給一個答案，任務結束。這個模式能解決很多問題，但它有一個根本限制：**Claude 只能做一步**。

真實世界的很多任務需要多步驟：找資料、分析資料、根據分析做決定、執行動作、再根據執行結果調整。這類任務靠 Messages API 加上工具（tool use）可以實作，但你要自己寫的膠水代碼很多。

這一章，我們要把這些膠水代碼交給工具去處理，正式踏進 agentic 應用的世界。

## 先說清楚：Anthropic 沒有一個叫「Agent SDK」的套件

在開始之前，我要先誠實地破除一個常見誤會，免得你照著別的教學去 `pip install` 結果裝不到東西。

網路上你會看到很多「Agent / Runner / Handoff」風格的範例——定義一個 `Agent` 類別、用一個 `Runner` 跑它、再用 `handoff()` 把任務交接給另一個 agent。**那是 OpenAI Agents SDK 的形狀，不是 Anthropic 的。** Anthropic 並沒有發布一個獨立的、長那個樣子的「Agent SDK」套件。如果你照抄，第一行 import 就會失敗。

那麼，用 Claude 到底要怎麼建 agent？真實的路徑有兩條：

1. **Claude API + Tool Use（你或 SDK 來跑 agentic loop）** ← 這本書接下來主要走這條
   - 安裝官方 SDK：Python 是 `anthropic`、TypeScript 是 `@anthropic-ai/sdk`
   - 用官方 SDK 內建的 **tool runner（beta）**：它自動幫你跑「呼叫工具 → 回灌結果 → 再問」的循環
   - 或自己**手寫 agentic loop**：當你要細控流程（人工審批、條件式執行、自訂 log）時，自己跑一個 `while` 迴圈
2. **Managed Agents（beta）**：Anthropic 在伺服器端幫你跑 loop、還幫你託管一個執行工具的 container（透過 `client.beta.agents` / `client.beta.sessions`）。適合「server 端有狀態、需要 workspace」的場景。

這本書的後續章節以**路徑 1** 為主軸——也就是用官方 SDK 的 tool runner 跟手寫 loop。Managed Agents 我們只在「何時用什麼」跟多 agent 的段落點到，因為它細節變動快，我不想教你寫到後來不能跑的程式碼。

口語上，我還是會把「帶 system prompt + 一組工具、跑在 agentic loop 裡的 Claude 呼叫」叫做一個 **agent**——這個詞沒問題，問題只在「不要把它想成某個 `Agent(...)` 類別」。

## 先說 Messages API + Tool Use 的痛點

第四章我們講了 Tool Use（工具呼叫）。回顧一下那個循環：

```
你 → Claude（送問題 + 工具定義）
     ↓
     Claude（決定用什麼工具，回傳 tool_use block）
     ↓
你的代碼（執行工具，得到結果）
     ↓
你 → Claude（送工具結果，繼續對話）
     ↓
     Claude（可能再用另一個工具，或直接回答）
     ↓
     ...重複直到 Claude 不再呼叫工具
```

這個循環你需要自己實作。不難，但每個做 agentic 應用的人都要重新寫一遍。而且，隨著任務複雜度增加，你還需要：

- 管理對話歷史（上下文）
- 處理工具執行錯誤
- 限制最大迴圈次數（防止無限循環）
- 支援平行工具呼叫
- 在你的程式碼裡做 agent 之間的路由（routing）
- 在適當時機暫停等待人工確認（human-in-the-loop）

這些功能每個都不難，但全部加起來要寫幾百行代碼，而且容易出 bug。

**這正是 tool runner 想幫你消掉的東西**——它就是官方 SDK 幫你把那個 while 迴圈封裝好的工具。

## 真實的心智模型：工具 + tool runner + agentic loop

把前面 OpenAI 風格的「Agent / Runner / Tool / Handoff」忘掉，換成 Claude 真正的三個概念：

### 1. 工具（Tool）

工具就是你給 Claude 使用的函式。官方 SDK 提供了裝飾器，讓你把一個普通函式直接變成工具，連 JSON Schema 都不用手寫：

```python
from anthropic import beta_tool

@beta_tool
def get_weather(location: str, unit: str = &quot;celsius&quot;) -&gt; str:
    &quot;&quot;&quot;Get current weather for a location.

    Args:
        location: City and state, e.g., San Francisco, CA.
        unit: Temperature unit, either &quot;celsius&quot; or &quot;fahrenheit&quot;.
    &quot;&quot;&quot;
    return f&quot;72°F and sunny in {location}&quot;
```

`@beta_tool` 會讀你的型別提示跟 docstring 裡的 `Args:`，自動生成這個工具的 input schema。你定義的是「Claude 能做什麼」，不是某個類別。

### 2. tool runner（取代你心中的 Runner）

tool runner 是官方 SDK 內建的東西，它負責跑那個工具呼叫循環——呼叫工具、把結果回灌給 Claude、再問下一步，直到 Claude 不再呼叫任何工具為止。你不用自己管那個 while 迴圈：

```python
import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰

runner = client.beta.messages.tool_runner(
    model=&quot;claude-opus-4-8&quot;,
    max_tokens=16000,
    tools=[get_weather],
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What&apos;s the weather in Paris?&quot;}],
)
```

注意：`tool_runner` 是 `client.beta.messages` 底下的一個方法，**不是一個你要 import 的 `Runner` 類別**。它回傳的東西可以直接迭代，每跑一圈 yield 一個 `BetaMessage`。

### 3. agentic loop（那個「自動跑到底」的循環）

上面 `tool_runner` 之所以省事，就是因為它把 agentic loop 包起來了。所謂 agentic loop，就是「Claude 想用工具 → 跑工具 → 把結果還給 Claude → Claude 看了結果再決定」這個重複過程，自動跑到 Claude 講完話為止。

那「Handoff（交接給另一個 agent）」呢？**Claude 沒有這個原語。** 多 agent 系統在 Claude 的世界裡，是你**在自己的程式碼裡做路由**——orchestrator 依判斷去呼叫對應的 subagent 函式。這部分我們在第十一章（多 agent）會展開，這裡先知道「沒有 `handoff()` 這種魔法、是你自己 route」就好。

## 安裝

裝的是**官方 SDK**，不是什麼 agent 專用套件。

Python：

```bash
pip install anthropic
```

TypeScript / Node.js：

```bash
npm i @anthropic-ai/sdk
```

import 的方式：

```python
# Python
import anthropic
from anthropic import beta_tool
```

```typescript
// TypeScript
import Anthropic from &quot;@anthropic-ai/sdk&quot;;
import { betaZodTool } from &quot;@anthropic-ai/sdk/helpers/beta/zod&quot;;
```

確保你已設定 `ANTHROPIC_API_KEY` 環境變數，SDK 會自動讀取。

## 第一個 Agent：查天氣

讓我們從一個最小可跑的例子開始：一個能查天氣的 agent。這裡用 tool runner，讓 SDK 幫我們跑 loop。

```python
import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()


@beta_tool
def get_weather(location: str, unit: str = &quot;celsius&quot;) -&gt; str:
    &quot;&quot;&quot;Get current weather for a location.

    Args:
        location: City and state, e.g., San Francisco, CA.
        unit: Temperature unit, either &quot;celsius&quot; or &quot;fahrenheit&quot;.
    &quot;&quot;&quot;
    # 真實情況這裡會去打某個天氣 API；這裡先回傳假資料示範
    return f&quot;72°F and sunny in {location}&quot;


# tool runner 自動處理 agentic loop：
# 呼叫工具、回灌結果、再問，直到 Claude 不再呼叫工具
runner = client.beta.messages.tool_runner(
    model=&quot;claude-opus-4-8&quot;,
    max_tokens=16000,
    tools=[get_weather],
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What&apos;s the weather in Paris?&quot;}],
)

# 每個 iteration yield 一個 BetaMessage；Claude 講完就停
for message in runner:
    print(message)
```

執行這段代碼，背後發生的事是：

1. Claude 收到問題，判斷需要查天氣，回傳一個 tool_use（要呼叫 `get_weather`）
2. tool runner **自動**執行你的 `get_weather` 函式，把結果回灌給 Claude
3. Claude 看到天氣資料，生成一段給人看的回答，不再呼叫工具
4. 因為沒有更多 tool_use，loop 自動結束

整個工具呼叫循環你一行都沒寫——這就是 tool runner 的價值。

如果你的工具是 I/O 密集（要打外部 API），可以改用 async 版：把 import 換成 `from anthropic import beta_async_tool`，工具寫成 `async def`，其餘形狀一樣。

### 想自己掌控每一步？手寫 agentic loop

tool runner 很方便，但有時候你需要**細控**——例如某些工具要先經人工審批、要依條件決定跑不跑、要記自訂 log。這時就自己跑 loop。這也是看清楚「tool runner 到底幫你做了什麼」的最好方式：

```python
import anthropic

client = anthropic.Anthropic()
tools = [...]   # 工具的 JSON 定義（見下一段）
messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_input}]

MAX_ITERATIONS = 10   # 防無限循環：自己加計數器
for _ in range(MAX_ITERATIONS):
    response = client.messages.create(
        model=&quot;claude-opus-4-8&quot;,
        max_tokens=16000,
        tools=tools,
        messages=messages,
    )
    if response.stop_reason == &quot;end_turn&quot;:
        break  # Claude 講完了

    tool_use_blocks = [b for b in response.content if b.type == &quot;tool_use&quot;]
    messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})

    tool_results = []
    for tool in tool_use_blocks:
        result = execute_tool(tool.name, tool.input)   # 你的實作
        tool_results.append({
            &quot;type&quot;: &quot;tool_result&quot;,
            &quot;tool_use_id&quot;: tool.id,      # 必須對應 tool_use 的 id
            &quot;content&quot;: result,
        })
    messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: tool_results})

final_text = next(b.text for b in response.content if b.type == &quot;text&quot;)
```

幾個一定要知道的點：

- **`stop_reason` 是 loop 的方向盤**。可能的值：`end_turn`（講完了，跳出）、`max_tokens`（被 `max_tokens` 截斷）、`tool_use`（要呼叫工具，繼續跑）、`pause_turn`（server 端工具暫停，可續跑）、`refusal`（安全拒答，看 `stop_details`）。
- **工具錯誤**：在那筆 tool_result 裡加 `&quot;is_error&quot;: True`，把錯誤訊息塞進 `content`，讓 Claude 知道工具掛了、自己想辦法。
- **防無限循環**：手寫 loop 一定要加 `MAX_ITERATIONS` 計數器跳出。tool runner 則是「沒有 tool_use 就自動停」，不會無止盡跑。

那這個手寫 loop 用的工具 JSON 定義長怎樣？沒有裝飾器幫你時，就自己寫：

```python
tools = [{
    &quot;name&quot;: &quot;get_weather&quot;,
    &quot;description&quot;: &quot;Get current weather for a location&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
            &quot;location&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;City and state&quot;},
            &quot;unit&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;celsius&quot;, &quot;fahrenheit&quot;]},
        },
        &quot;required&quot;: [&quot;location&quot;],
    },
}]
# 強制這一回合只能用某工具：tool_choice={&quot;type&quot;: &quot;tool&quot;, &quot;name&quot;: &quot;get_weather&quot;}
# 要嚴格結構化：工具裡加 &quot;strict&quot;: True，並讓 input_schema 有 additionalProperties: False
```

## TypeScript 版本

TypeScript 這邊一樣有 tool runner，搭配 `betaZodTool` 用 Zod 定義工具的 input schema，型別安全又不用手寫 JSON Schema：

```typescript
import Anthropic from &quot;@anthropic-ai/sdk&quot;;
import { betaZodTool } from &quot;@anthropic-ai/sdk/helpers/beta/zod&quot;;
import { z } from &quot;zod&quot;;

const client = new Anthropic();

const getWeather = betaZodTool({
  name: &quot;get_weather&quot;,
  description: &quot;Get current weather for a location&quot;,
  inputSchema: z.object({
    location: z.string().describe(&quot;City and state, e.g., San Francisco, CA&quot;),
    unit: z.enum([&quot;celsius&quot;, &quot;fahrenheit&quot;]).optional(),
  }),
  run: async (input) =&gt; `72°F and sunny in ${input.location}`,
});

const finalMessage = await client.beta.messages.toolRunner({
  model: &quot;claude-opus-4-8&quot;,
  max_tokens: 16000,
  tools: [getWeather],
  messages: [{ role: &quot;user&quot;, content: &quot;What&apos;s the weather in Paris?&quot; }],
});

console.log(finalMessage.content);
```

`client.beta.messages.toolRunner(...)` 跟 Python 的 `tool_runner` 是同一件事的 TS 版——一樣自動跑 agentic loop、跑到 Claude 不再呼叫工具為止，回傳最終的 message。

如果你要細控，TS 也能手寫 loop，要點跟 Python 一樣（看 `stop_reason === &quot;end_turn&quot;` 跳出，把 tool_result 串回 `messages`）：

```typescript
import Anthropic from &quot;@anthropic-ai/sdk&quot;;
const client = new Anthropic();

let messages: Anthropic.MessageParam[] = [{ role: &quot;user&quot;, content: userInput }];
const MAX_ITERATIONS = 10;
for (let i = 0; i &lt; MAX_ITERATIONS; i++) {
  const response = await client.messages.create({
    model: &quot;claude-opus-4-8&quot;,
    max_tokens: 16000,
    tools,
    messages,
  });
  if (response.stop_reason === &quot;end_turn&quot;) break;

  const toolUseBlocks = response.content.filter(
    (b): b is Anthropic.ToolUseBlock =&gt; b.type === &quot;tool_use&quot;,
  );
  messages.push({ role: &quot;assistant&quot;, content: response.content });

  const toolResults: Anthropic.ToolResultBlockParam[] = [];
  for (const t of toolUseBlocks) {
    toolResults.push({
      type: &quot;tool_result&quot;,
      tool_use_id: t.id,
      content: await executeTool(t.name, t.input),
    });
  }
  messages.push({ role: &quot;user&quot;, content: toolResults });
}
```

## Agent vs Assistant：Agency 的概念

很多人分不清「AI 助手」和「AI Agent」的差異。我的理解是：**Agency（代理能力）= 自主決定下一步的能力**。

| 特性     | AI 助手          | AI Agent               |
| -------- | ---------------- | ---------------------- |
| 執行步驟 | 一步（問一答一） | 多步（自主決定做什麼） |
| 工具使用 | 手動觸發         | 自主選擇和調用         |
| 目標導向 | 回答問題         | 完成任務               |
| 錯誤恢復 | 需要人干預       | 可以自主嘗試不同方法   |
| 適用場景 | 問答、諮詢       | 研究、執行、自動化     |

從「建立助手」升級到「建立代理」，技術上的關鍵差別其實就一句話：**有沒有那個 agentic loop**。助手是一次 `messages.create` 拿一個答案；代理是把 `messages.create` 放進一個會根據工具結果繼續跑的循環裡。tool runner 幫你跑這個循環，你只要把目標跟工具給它，它會想辦法達成，而不是每一步都要你告訴它怎麼做。

## 何時手寫 loop、何時用 tool runner、何時上 Managed Agents？

這個問題我被問過很多次，我的判斷框架是這樣的：

**用 tool runner 當（多數情況的預設）：**

- 任務需要動態決策（不知道幾步才能完成），但你不需要在每一步插手
- 你只是想「給目標 + 一組工具，讓它自己跑到底」
- 你想少寫膠水代碼、快速做出可跑的 agent

**自己手寫 agentic loop 當：**

- 你需要在某些工具執行前插入**人工審批**
- 你要依條件決定跑哪些工具、或自訂每一步的 log / 中斷邏輯
- 你要做很客製的錯誤處理或重試策略
- 講白了：當 tool runner 的「全自動」不夠細，你需要方向盤

**考慮 Managed Agents 當：**

- 你需要 server 端有狀態的、長時間執行的 agent，還要一個託管的 workspace / container 來實際執行工具
- 你不想自己維運那個執行環境

而最基本的——**只需要一到兩步、不需要任何工具循環**的任務（純文字生成、分類、翻譯），根本不用 agent，直接一次 `messages.create` 最省事、延遲最低。

我的實踐原則：**先用最單純的 `messages.create`；當工具循環邏輯開始出現，就用 tool runner；只有當你發現需要細控每一步時，才退回手寫 loop。** 不要一開始就過度工程化。

## 與 LangChain、LlamaIndex 的比較

如果你之前用過 LangChain 或 LlamaIndex，你可能在想：既然有官方 SDK 的 tool runner，為什麼還會有人用這些框架？

我的觀點：

**直接用官方 Anthropic SDK（+ tool runner）的優勢：**

- 官方出品，對 Claude 的所有功能（extended thinking、prompt caching 等）支援最直接、最新
- 抽象層少，你看得到底層的 `messages` / `tool_use` / `tool_result`，debug 容易
- 跟 Claude 功能同步更新，不用等第三方框架跟進
- 學習曲線平：tool runner 就是把你已經懂的 Messages API 包了一層 loop

**LangChain 的優勢：**

- 生態系豐富，有大量現成 integrations（向量資料庫、各種 API）
- 支援多種 LLM（不限於 Claude），抽象層幫你抹平 provider 差異
- 有更成熟的 RAG pipeline 工具
- 社群更大

**LlamaIndex 的優勢：**

- 專注於資料索引和 RAG，這方面深度更好
- 對結構化資料的支援更豐富

我的建議：如果你的應用 100% 用 Claude，從官方 SDK + tool runner 開始，因為它最直接、抽象最少、最容易 debug。如果你需要豐富的第三方整合，或者未來可能換模型，LangChain 這類框架是合理的選擇。

## 設計時要記得的幾個原則

在繼續往下蓋之前，幾個會讓你的 agent 程式碼更乾淨、更好維護的原則：

**1. 工具盡量寫成純函式、可重複呼叫**

agentic loop 裡 Claude 可能因為前一次結果不理想而再呼叫同一個工具，所以你的工具實作最好是無副作用的（或至少冪等），能承受重複呼叫。會「動到外部世界」的工具（送錢、寄信、刪資料）要特別小心，這類最適合放在手寫 loop 裡加人工審批。

**2. 狀態在 `messages`，不在某個物件**

Claude 這條路沒有什麼神祕的「agent 記憶體物件」。整段對話的狀態——使用者問了什麼、Claude 呼叫過哪些工具、工具回傳了什麼——全部就是那串 `messages` 歷史。要做「記憶」，就是把相關歷史串進 `messages`，或用 memory 類工具把長期記憶外掛出去。這也是為什麼手寫 loop 裡我們一直在 `messages.append(...)`。

**3. async 優先（尤其 I/O 密集）**

工具大多是去打外部 API 的 I/O 操作，寫成 async 能讓多個工具呼叫平行跑。Python 用 `AsyncAnthropic` + `beta_async_tool`，TypeScript 本來就是 async。

**4. 可觀測性：用 SDK log + request id，不要幻想有 tracing 開關**

Claude 這邊沒有一個「打開就有漂亮 trace」的開關。要看 agent 每一步在幹嘛，務實的做法是：

```python
# 1) 環境變數開 SDK 詳細 log
#    ANTHROPIC_LOG=debug

# 2) 直接印出訊息歷史，看 Claude 呼叫了哪些工具、拿到什麼
print(messages)

# 3) 每個 response 都有 request id，回報問題時附上它
response = client.messages.create(model=&quot;claude-opus-4-8&quot;, max_tokens=16000,
                                  messages=messages)
print(response._request_id)
```

手寫 loop 的好處之一，就是你想在哪一步插 log、插 metric、插 breakpoint，完全自由。

**5. 結構化輸出與錯誤處理（順手補上）**

要 Claude 回一個你能直接用的物件，可以用 `parse` 搭配 Pydantic：

```python
from pydantic import BaseModel

class Insight(BaseModel):
    title: str
    detail: str

resp = client.messages.parse(
    model=&quot;claude-opus-4-8&quot;,
    max_tokens=16000,
    messages=[...],
    output_format=Insight,
)
data = resp.parsed_output   # 已驗證的 Insight
```

SDK 也有一整組例外類別可以 catch：`anthropic.BadRequestError`(400)、`AuthenticationError`、`PermissionDeniedError`、`NotFoundError`、`RateLimitError`、`RequestTooLargeError`(413)、`APIStatusError`、`APIConnectionError`、`APITimeoutError`。其中 429 跟 5xx，SDK 預設會自動重試（`max_retries` 預設 2），所以多數暫時性錯誤你不用自己處理。

---

理解了「工具 + tool runner + agentic loop」這套真實心智模型之後，是時候動手建一個真正有用的 agent 了。

下一章是這本書技術深度最高的一章：我們要從頭打造一個能搜尋網路、查詢資料庫、生成報告的 Research Agent，涵蓋工具設計、用 `messages` 管狀態、錯誤處理、測試策略的完整代碼。如果你一直想知道「真實的 agent 應用長什麼樣」，下一章就是答案。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.CkwO4WOT.webp" medium="image"/><category>Claude API</category><category>Agent SDK</category><category>Agentic Engineering</category><category>代理系統</category><enclosure url="https://bobochen.dev/_astro/cover.CkwO4WOT.webp" length="0" type="image/png"/></item><item><title>Batch API：大量任務的高效非同步處理</title><link>https://bobochen.dev/blog/claude-api-guide-batch-api/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-batch-api/</guid><description>Message Batches API 完整指南；與普通 API 的差異；JSONL 輸入格式；輪詢狀態與結果處理；Python + TypeScript 完整範例；錯誤處理；50% 折扣的成本計算；最佳實踐。</description><pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate><content:encoded>前幾章我們用的都是「即時 API」——你發一個請求，馬上等到回應。這種模式適合互動式應用，但對某些場景來說是浪費：

你有 5,000 份客服對話需要分類。你有 10,000 筆產品描述需要翻譯。你有 2,000 份合約需要提取關鍵條款。

這些任務都是非即時的。你不需要每個回應即時到達，你只需要在某個時間點「全部完成」。

對這類場景，用普通 Messages API 一個一個送，不只慢（受 rate limit 限制），還貴。Batch API 是為這個痛點設計的。

## Batch API 是什麼？

Batch API（正式名稱 Message Batches API）讓你一次提交大量請求，Claude 在後台非同步處理，最長 24 小時內完成。

與普通 Messages API 的主要差異：

| 維度 | Messages API（即時） | Batch API（非同步） |
|------|---------------------|-------------------|
| 回應方式 | 即時（同步或 streaming） | 非同步，輪詢狀態 |
| 完成時間 | 毫秒到秒 | 分鐘到 24 小時 |
| 價格 | 標準 | **50% 折扣** |
| Rate limit | 受即時 rate limit 限制 | 獨立的 batch quota |
| 適用場景 | 互動式應用 | 離線批次處理 |

50% 折扣是很吸引人的數字。但重點是：**你換掉的是即時性**。如果你的任務不需要即時回應，這筆交換非常划算。

## 適用 vs 不適用場景

在決定用 Batch API 之前，先確認你的場景是否合適。

**非常適合：**
- 大規模文字分類（客服對話、評論、文章）
- 批次翻譯（幾百到幾千份文件）
- 資料集標注（AI 訓練資料生成）
- 報告自動生成（定期跑批次）
- SEO 內容生成（批次生成商品描述）
- 文件資料提取（合約、發票、報告）

**不適合：**
- 用戶等待即時回應的互動功能
- 需要根據 Claude 回應動態調整下一步的 agentic 任務
- 任何對時間敏感的任務

我在生產環境的規則是：**如果用戶不在等你，就考慮 Batch API**。

## API 使用流程

Batch API 的使用分三步：

```
1. create_batch()  ←  提交批次任務
         ↓
2. poll_status()   ←  輪詢直到完成
         ↓
3. get_results()   ←  下載並處理結果
```

### Step 1：建立 Batch（Create Batch）

```python
import anthropic

client = anthropic.Anthropic()

# 準備你的任務列表
requests = [
    {
        &quot;custom_id&quot;: &quot;review-001&quot;,  # 你自定義的 ID，用來對應結果
        &quot;params&quot;: {
            &quot;model&quot;: &quot;claude-haiku-4-5&quot;,  # Batch API 通常用 Haiku 降成本
            &quot;max_tokens&quot;: 256,
            &quot;messages&quot;: [
                {
                    &quot;role&quot;: &quot;user&quot;,
                    &quot;content&quot;: &quot;請將以下評論分類為「正面」、「負面」或「中性」，只回答一個詞：\n\n「這款產品品質很好，但運送太慢了。」&quot;
                }
            ]
        }
    },
    {
        &quot;custom_id&quot;: &quot;review-002&quot;,
        &quot;params&quot;: {
            &quot;model&quot;: &quot;claude-haiku-4-5&quot;,
            &quot;max_tokens&quot;: 256,
            &quot;messages&quot;: [
                {
                    &quot;role&quot;: &quot;user&quot;,
                    &quot;content&quot;: &quot;請將以下評論分類為「正面」、「負面」或「中性」，只回答一個詞：\n\n「完全不值這個價格，已申請退款。」&quot;
                }
            ]
        }
    },
    # ... 最多 10,000 個請求
]

# 建立批次
batch = client.messages.batches.create(requests=requests)

print(f&quot;Batch ID: {batch.id}&quot;)
print(f&quot;狀態: {batch.processing_status}&quot;)  # 初始為 &quot;in_progress&quot;
print(f&quot;請求數量: {batch.request_counts.processing}&quot;)
```

**`custom_id` 非常重要**。Batch API 不保證回應的順序。你只能透過 `custom_id` 來對應你的請求和 Claude 的回應。建議用你資料庫的主鍵或其他唯一標識符。

### Step 2：輪詢狀態（Poll Status）

```python
import time

def wait_for_batch(client: anthropic.Anthropic, batch_id: str, poll_interval: int = 60) -&gt; anthropic.types.MessageBatch:
    &quot;&quot;&quot;輪詢直到 batch 完成，每隔 poll_interval 秒檢查一次&quot;&quot;&quot;
    while True:
        batch = client.messages.batches.retrieve(batch_id)

        print(f&quot;[{time.strftime(&apos;%H:%M:%S&apos;)}] 狀態: {batch.processing_status} | &quot;
              f&quot;處理中: {batch.request_counts.processing} | &quot;
              f&quot;成功: {batch.request_counts.succeeded} | &quot;
              f&quot;失敗: {batch.request_counts.errored}&quot;)

        if batch.processing_status == &quot;ended&quot;:
            return batch

        time.sleep(poll_interval)


# 等待完成（通常幾分鐘到幾十分鐘）
completed_batch = wait_for_batch(client, batch.id)
print(f&quot;\nBatch 完成！&quot;)
print(f&quot;成功: {completed_batch.request_counts.succeeded}&quot;)
print(f&quot;失敗: {completed_batch.request_counts.errored}&quot;)
print(f&quot;過期: {completed_batch.request_counts.expired}&quot;)
```

`processing_status` 的可能值：
- `in_progress`：處理中
- `ended`：全部完成（不管成功或失敗）

`request_counts` 的欄位：
- `processing`：還在處理的請求數
- `succeeded`：成功的請求數
- `errored`：失敗的請求數
- `canceled`：被取消的請求數
- `expired`：超時未處理的請求數

### Step 3：讀取結果（Retrieve Results）

```python
def process_batch_results(client: anthropic.Anthropic, batch_id: str) -&gt; dict[str, str | None]:
    &quot;&quot;&quot;讀取並處理 batch 結果，返回 {custom_id: 回應文字} 的映射&quot;&quot;&quot;
    results = {}

    for result in client.messages.batches.results(batch_id):
        custom_id = result.custom_id

        if result.result.type == &quot;succeeded&quot;:
            # 成功：提取回應文字
            message = result.result.message
            results[custom_id] = message.content[0].text

        elif result.result.type == &quot;errored&quot;:
            # 失敗：記錄錯誤
            error = result.result.error
            print(f&quot;[ERROR] {custom_id}: {error.type} - {error.message}&quot;)
            results[custom_id] = None

        elif result.result.type == &quot;expired&quot;:
            # 超時：這個請求沒有被處理
            print(f&quot;[EXPIRED] {custom_id}: 請求超時未處理&quot;)
            results[custom_id] = None

    return results


# 處理結果
results = process_batch_results(client, batch.id)

for custom_id, classification in results.items():
    if classification:
        print(f&quot;{custom_id}: {classification}&quot;)
    else:
        print(f&quot;{custom_id}: 處理失敗&quot;)
```

## JSONL 格式（進階）

除了用 SDK 直接建立請求陣列，Batch API 也支援 JSONL（JSON Lines）格式。每一行是一個 JSON 物件：

```jsonl
{&quot;custom_id&quot;: &quot;review-001&quot;, &quot;params&quot;: {&quot;model&quot;: &quot;claude-haiku-4-5&quot;, &quot;max_tokens&quot;: 256, &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;分類這則評論：好用&quot;}]}}
{&quot;custom_id&quot;: &quot;review-002&quot;, &quot;params&quot;: {&quot;model&quot;: &quot;claude-haiku-4-5&quot;, &quot;max_tokens&quot;: 256, &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;分類這則評論：很差&quot;}]}}
```

JSONL 格式適合：
- 事先把任務批次寫入檔案
- 用其他工具（例如 Python pandas）生成大量任務
- 跨不同服務傳遞任務佇列

## Python 完整範例：批次評論分類系統

這是一個真實可用的批次評論分類系統，包含資料準備、提交、輪詢和結果處理的完整流程：

```python
import anthropic
import time
import json
from pathlib import Path


def classify_reviews_batch(reviews: list[dict]) -&gt; dict[str, str]:
    &quot;&quot;&quot;
    批次分類評論
    reviews: [{&quot;id&quot;: &quot;001&quot;, &quot;text&quot;: &quot;評論內容&quot;}, ...]
    returns: {&quot;001&quot;: &quot;正面&quot;, &quot;002&quot;: &quot;負面&quot;, ...}
    &quot;&quot;&quot;
    client = anthropic.Anthropic()

    # --- Step 1: 建立請求列表 ---
    classification_prompt = &quot;&quot;&quot;請將以下評論分類。只能回答「正面」、「負面」或「中性」三者之一，不要任何其他說明。

評論：{review}&quot;&quot;&quot;

    requests = [
        {
            &quot;custom_id&quot;: review[&quot;id&quot;],
            &quot;params&quot;: {
                &quot;model&quot;: &quot;claude-haiku-4-5&quot;,
                &quot;max_tokens&quot;: 16,  # 分類任務只需要很少 tokens
                &quot;messages&quot;: [
                    {
                        &quot;role&quot;: &quot;user&quot;,
                        &quot;content&quot;: classification_prompt.format(review=review[&quot;text&quot;])
                    }
                ]
            }
        }
        for review in reviews
    ]

    print(f&quot;提交 {len(requests)} 個評論分類請求...&quot;)

    # --- Step 2: 建立 batch ---
    batch = client.messages.batches.create(requests=requests)
    batch_id = batch.id
    print(f&quot;Batch ID: {batch_id}&quot;)

    # 儲存 batch ID（以防程式中途崩潰，可以恢復）
    Path(&quot;batch_id.txt&quot;).write_text(batch_id)

    # --- Step 3: 輪詢狀態 ---
    print(&quot;等待 batch 完成（每 30 秒檢查一次）...&quot;)
    start_time = time.time()

    while True:
        batch_status = client.messages.batches.retrieve(batch_id)

        elapsed = int(time.time() - start_time)
        counts = batch_status.request_counts
        print(f&quot;[{elapsed}s] 處理中: {counts.processing} | 成功: {counts.succeeded} | 失敗: {counts.errored}&quot;)

        if batch_status.processing_status == &quot;ended&quot;:
            break

        time.sleep(30)

    # --- Step 4: 處理結果 ---
    results = {}
    failed_ids = []

    for result in client.messages.batches.results(batch_id):
        if result.result.type == &quot;succeeded&quot;:
            text = result.result.message.content[0].text.strip()
            # 標準化輸出（以防 Claude 回答了「正面。」或「這是正面」等格式）
            if &quot;正面&quot; in text:
                results[result.custom_id] = &quot;正面&quot;
            elif &quot;負面&quot; in text:
                results[result.custom_id] = &quot;負面&quot;
            else:
                results[result.custom_id] = &quot;中性&quot;
        else:
            failed_ids.append(result.custom_id)
            results[result.custom_id] = &quot;未知&quot;

    if failed_ids:
        print(f&quot;\n警告：{len(failed_ids)} 個請求失敗: {failed_ids[:5]}...&quot;)

    total_time = int(time.time() - start_time)
    print(f&quot;\n完成！耗時 {total_time} 秒，成功 {len(requests) - len(failed_ids)}/{len(requests)}&quot;)

    return results


if __name__ == &quot;__main__&quot;:
    # 測試資料
    sample_reviews = [
        {&quot;id&quot;: &quot;review-001&quot;, &quot;text&quot;: &quot;產品品質很好，已經回購三次了！&quot;},
        {&quot;id&quot;: &quot;review-002&quot;, &quot;text&quot;: &quot;送達時包裝破損，產品也壞了，非常失望。&quot;},
        {&quot;id&quot;: &quot;review-003&quot;, &quot;text&quot;: &quot;還可以，跟描述差不多，沒有特別驚喜。&quot;},
        {&quot;id&quot;: &quot;review-004&quot;, &quot;text&quot;: &quot;客服態度超好，解決問題很快速！&quot;},
        {&quot;id&quot;: &quot;review-005&quot;, &quot;text&quot;: &quot;等了兩週才到，但產品本身質量不錯。&quot;},
    ]

    classifications = classify_reviews_batch(sample_reviews)

    print(&quot;\n分類結果：&quot;)
    for review_id, classification in classifications.items():
        print(f&quot;  {review_id}: {classification}&quot;)
```

## TypeScript 完整範例

```typescript
import Anthropic from &quot;@anthropic-ai/sdk&quot;;

const client = new Anthropic();

interface Review {
  id: string;
  text: string;
}

async function classifyReviewsBatch(
  reviews: Review[]
): Promise&lt;Record&lt;string, string&gt;&gt; {
  // Step 1: 建立請求
  const requests: Anthropic.Messages.MessageCreateParamsNonStreaming[] =
    reviews.map((review) =&gt; ({
      custom_id: review.id,
      params: {
        model: &quot;claude-haiku-4-5&quot;,
        max_tokens: 16,
        messages: [
          {
            role: &quot;user&quot; as const,
            content: `請將以下評論分類。只能回答「正面」、「負面」或「中性」三者之一。\n\n評論：${review.text}`,
          },
        ],
      },
    }));

  console.log(`提交 ${requests.length} 個評論分類請求...`);

  // Step 2: 建立 batch
  const batch = await client.messages.batches.create({ requests });
  console.log(`Batch ID: ${batch.id}`);

  // Step 3: 輪詢狀態
  let batchStatus = batch;
  while (batchStatus.processing_status !== &quot;ended&quot;) {
    await new Promise((resolve) =&gt; setTimeout(resolve, 30_000)); // 等 30 秒
    batchStatus = await client.messages.batches.retrieve(batch.id);

    const counts = batchStatus.request_counts;
    console.log(
      `處理中: ${counts.processing} | 成功: ${counts.succeeded} | 失敗: ${counts.errored}`
    );
  }

  // Step 4: 處理結果
  const results: Record&lt;string, string&gt; = {};
  const failedIds: string[] = [];

  for await (const result of await client.messages.batches.results(batch.id)) {
    if (result.result.type === &quot;succeeded&quot;) {
      const text = (
        result.result.message.content[0] as Anthropic.Messages.TextBlock
      ).text.trim();

      if (text.includes(&quot;正面&quot;)) {
        results[result.custom_id] = &quot;正面&quot;;
      } else if (text.includes(&quot;負面&quot;)) {
        results[result.custom_id] = &quot;負面&quot;;
      } else {
        results[result.custom_id] = &quot;中性&quot;;
      }
    } else {
      failedIds.push(result.custom_id);
      results[result.custom_id] = &quot;未知&quot;;
    }
  }

  if (failedIds.length &gt; 0) {
    console.warn(`${failedIds.length} 個請求失敗`);
  }

  return results;
}

// 使用範例
(async () =&gt; {
  const reviews: Review[] = [
    { id: &quot;r001&quot;, text: &quot;產品品質很好！&quot; },
    { id: &quot;r002&quot;, text: &quot;送達時包裝破損。&quot; },
    { id: &quot;r003&quot;, text: &quot;還可以，普通。&quot; },
  ];

  const results = await classifyReviewsBatch(reviews);
  console.log(&quot;分類結果：&quot;, results);
})();
```

## 錯誤處理：部分失敗的 Batch

Batch API 的一個重要特性：**即使部分請求失敗，整個 batch 仍然正常完成**。處理完成後，你需要自行處理失敗的請求。

```python
def handle_batch_results_with_retry(
    client: anthropic.Anthropic,
    batch_id: str,
    max_retries: int = 2
) -&gt; dict[str, str | None]:
    &quot;&quot;&quot;
    處理 batch 結果，對失敗的請求自動重試（使用即時 API）
    &quot;&quot;&quot;
    results = {}
    failed_requests = []

    # 第一輪：處理所有結果
    for result in client.messages.batches.results(batch_id):
        if result.result.type == &quot;succeeded&quot;:
            results[result.custom_id] = result.result.message.content[0].text
        else:
            error_type = result.result.type  # &quot;errored&quot; or &quot;expired&quot;
            print(f&quot;[{error_type}] {result.custom_id}&quot;)
            failed_requests.append(result.custom_id)
            results[result.custom_id] = None

    # 第二輪：對失敗的請求用即時 API 重試
    if failed_requests and max_retries &gt; 0:
        print(f&quot;\n對 {len(failed_requests)} 個失敗請求進行重試...&quot;)
        # 這裡你需要保留原始請求的映射，根據 custom_id 找回原始 params
        # （在實際應用中，你會從資料庫或原始列表中查找）
        for failed_id in failed_requests:
            try:
                # 用即時 API 重試
                retry_response = client.messages.create(
                    model=&quot;claude-haiku-4-5&quot;,
                    max_tokens=16,
                    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;...重試請求內容...&quot;}]
                )
                results[failed_id] = retry_response.content[0].text
            except Exception as e:
                print(f&quot;重試失敗 {failed_id}: {e}&quot;)

    return results
```

## Limits 與最佳實踐

**Limits（截至 2026）：**
- 單次 batch：最多 10,000 個請求
- 每個請求最大：32MB（JSONL 行）
- 整個 batch 最大：200MB（未壓縮 JSONL）
- Batch 保留時間：29 天（超過後自動刪除）
- 最長處理時間：24 小時

**最佳實踐：**

1. **選對模型**：Batch 任務大多是分類、提取、翻譯等任務，Claude Haiku 4.5 夠用，比 Opus 便宜 20 倍

2. **善用 max_tokens 最小化**：批次分類任務 max_tokens 設 16-32 就夠，不要設 1024

3. **設計冪等的 custom_id**：用你的資料主鍵（例如資料庫 ID）作為 custom_id，方便重跑

4. **儲存 batch_id**：程式中途崩潰也能繼續輪詢

5. **不要阻塞等待**：Batch 可能需要幾小時，設計為非阻塞的背景工作，完成後發通知

6. **分批提交超大任務**：10,000 個請求以上的任務，拆成多個 batch

```python
def split_into_batches(items: list, batch_size: int = 5000) -&gt; list[list]:
    &quot;&quot;&quot;將大型任務拆分為多個 batch&quot;&quot;&quot;
    return [items[i:i+batch_size] for i in range(0, len(items), batch_size)]
```

## 成本計算

用 Batch API 相比普通 Messages API，**輸入和輸出 tokens 都打 5 折**。

以一個批次翻譯任務為例：
- 1,000 份文件，每份平均 2,000 tokens
- 翻譯輸出平均 2,200 tokens
- 使用 Claude Haiku 4.5（假設 $0.80/MTok 輸入，$4/MTok 輸出）

| 方式 | 輸入費用 | 輸出費用 | 總計 |
|------|---------|---------|------|
| Messages API | $1.60 | $8.80 | $10.40 |
| Batch API（5折）| $0.80 | $4.40 | $5.20 |
| 節省 | | | **50%（$5.20）** |

在大量任務上，Batch API 是個非常划算的選擇。

---

Batch API 解決了「大量 + 非即時」這個使用場景。但如果你的任務更複雜——需要動態決策、多步驟推理、呼叫外部工具——光靠 Messages API 或 Batch API 就不夠了。

下一章，我們進入這本書的重頭戲：**Agent SDK**。我們要從「呼叫 API 得到答案」升級到「讓 AI 自主完成複雜任務」。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.DZglX3ke.webp" medium="image"/><category>Claude API</category><category>Batch API</category><category>非同步</category><category>大量處理</category><enclosure url="https://bobochen.dev/_astro/cover.DZglX3ke.webp" length="0" type="image/png"/></item><item><title>Prompt Caching：降低 90% 重複成本的技術</title><link>https://bobochen.dev/blog/claude-api-guide-prompt-caching/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-prompt-caching/</guid><description>為什麼 prompt caching 是最重要的成本優化技術；cache_control 用法；快取有效期與定價；適合 vs 不適合快取的內容；Python + TypeScript 實作；快取命中率監控；RAG 系統省錢案例。</description><pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate><content:encoded>如果你已經開始用 Claude API 做應用，帳單大概已經讓你有點心痛了。

不用擔心，我也走過這段路。在我把 prompt caching 導入生產環境之前，有個 RAG 應用每個月的 API 費用大約是 $800。導入之後，降到了 $180。這不是神話，是 prompt caching 的正常效果。

這一章我要把 prompt caching 的原理、用法和我的實戰心得全部告訴你。

## 為什麼這是最重要的成本優化技術？

先說結論，再解釋為什麼。

在所有 Claude API 的成本優化技術裡，prompt caching 是**投資報酬率最高**的一個。原因很簡單：

大多數 AI 應用都有一個結構特徵——**重複的前綴，變化的後綴**。

你的 system prompt 每次請求都一樣。你用來做 RAG 的文件上下文，在相同的查詢 session 裡幾乎不變。你的 few-shot examples，每次都是同樣那幾組。你的工具定義（tool definitions），幾乎從不改變。

這些「重複的前綴」在每次 API 請求時都要重新計算，這就是浪費。Prompt caching 解決的就是這個問題。

## 快取的運作原理

Claude 的 prompt caching 基於一個很直觀的概念：**相同的輸入前綴只需要計算一次**。

當你標記一段內容為可快取，Claude 的後端會：

1. 計算這段內容的哈希值
2. 第一次請求時，計算完整的 KV cache 並存起來（這次叫「cache write」）
3. 後續相同前綴的請求，直接讀取 cache 跳過計算（這次叫「cache read」）

重點：快取是基於**完整的前綴匹配**。意思是，如果你有三段標記為快取的內容，Claude 需要找到這三段全部匹配的快取才能命中。你改了第一段，第二段和第三段的快取就都失效了。

這個特性很重要，後面設計快取架構的時候我們會用到。

## Cache Control 的用法

在 API 層面，你用 `cache_control` 字段來標記哪些內容要快取：

```python
import anthropic

client = anthropic.Anthropic()

message = client.messages.create(
    model=&quot;claude-opus-4-5&quot;,
    max_tokens=1024,
    system=[
        {
            &quot;type&quot;: &quot;text&quot;,
            &quot;text&quot;: &quot;你是一位專業的技術文件助手，專門幫助工程師理解複雜的 API 文件。&quot;,
        },
        {
            &quot;type&quot;: &quot;text&quot;,
            &quot;text&quot;: &quot;&quot;&quot;以下是完整的 API 參考文件（共 50,000 字）：

            [在這裡放你的長文件內容]
            &quot;&quot;&quot;,
            &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}  # 標記這段為可快取
        }
    ],
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: &quot;請解釋 /api/v1/orders 端點的 pagination 參數怎麼用？&quot;
        }
    ],
)
```

`cache_control` 的值目前只有一個選項：`{&quot;type&quot;: &quot;ephemeral&quot;}`。

「ephemeral」聽起來像「短暫的」，但快取的存活時間其實不短——**預設是 5 分鐘**，可以延長到 1 小時（透過特定設定，稍後說明）。

## 快取可以放在哪裡？

`cache_control` 可以加在三個地方：

**1. System prompt 區塊**

```python
system=[
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;短的指令，不快取&quot;},
    {
        &quot;type&quot;: &quot;text&quot;,
        &quot;text&quot;: &quot;超長的背景知識文件...&quot;,
        &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
    }
]
```

**2. Messages 裡的 user 內容**

```python
messages=[
    {
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: [
            {
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: &quot;這是一篇很長的合約文件，請幫我分析...\n[合約全文]&quot;,
                &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
            },
            {
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: &quot;第一個問題：違約金的條款在第幾頁？&quot;  # 動態問題，不快取
            }
        ]
    }
]
```

**3. Tool definitions（工具定義）**

```python
tools=[
    {
        &quot;name&quot;: &quot;search_database&quot;,
        &quot;description&quot;: &quot;搜尋資料庫...&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {...}
        },
        &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}  # 工具定義通常很長，適合快取
    }
]
```

## 快取定價：寫入貴，讀取便宜

這是很多人看漏的細節，必須搞清楚：

| 操作                    | 相對於標準輸入 token 的費率 |
| ----------------------- | --------------------------- |
| 標準輸入（無快取）      | 1x                          |
| Cache write（建立快取） | 1.25x（貴 25%）             |
| Cache read（讀取快取）  | 0.1x（便宜 90%）            |

以 Claude Opus 4.5 為例（2026 年的定價，$15/MTok 輸入）：

- 標準輸入：$15/百萬 tokens
- Cache write：$18.75/百萬 tokens
- Cache read：$1.50/百萬 tokens

**Cache write 比標準輸入貴 25%**，這意味著如果你的快取每次都沒有命中（每次都是 write 不是 read），你反而比不用快取還貴。

所以 prompt caching 的核心策略是：**最大化快取命中率**。

## 什麼內容適合快取？

根據快取的特性，適合快取的內容是：

**高度適合：**

- **System prompt**：幾乎每次請求都相同
- **長文件上下文**（RAG 的文件、合約、手冊）：同一個 session 內不變
- **Few-shot examples**：固定的示例集
- **Tool definitions**：工具定義幾乎不變

**中度適合：**

- **用戶的會話歷史**：在多輪對話中，前幾輪的對話可以快取

**不適合：**

- **動態的 user input**：每次都不同，快取命中率趨近於零
- **包含時間戳或隨機 ID 的內容**：這些讓每次請求的前綴都不同

## 設計高快取命中率的 Prompt 架構

這是最關鍵的部分，也是我花了最多時間摸索的地方。

**核心原則：把穩定的內容放前面，把變化的內容放後面。**

一個典型的 RAG 應用的 prompt 結構應該是這樣：

```
[System Prompt - 快取] ← 穩定
[文件 1 - 快取] ← 相對穩定（同一 session）
[文件 2 - 快取] ← 相對穩定
[對話歷史 - 可考慮快取] ← 逐漸累積
[當前用戶問題 - 不快取] ← 每次都變
```

如果你把用戶問題放在文件之前，快取就永遠不會命中。

```python
def build_rag_request(
    system_prompt: str,
    documents: list[str],
    conversation_history: list[dict],
    user_question: str
) -&gt; dict:
    &quot;&quot;&quot;建立具有最優快取結構的 RAG 請求&quot;&quot;&quot;

    # System prompt 最穩定，標記為快取
    system = [
        {
            &quot;type&quot;: &quot;text&quot;,
            &quot;text&quot;: system_prompt,
            &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
        }
    ]

    # 建立 messages 結構
    messages = []

    # 把文件放進第一個 user turn，標記為快取
    if documents:
        doc_content = &quot;\n\n---\n\n&quot;.join(
            f&quot;[文件 {i+1}]\n{doc}&quot; for i, doc in enumerate(documents)
        )
        messages.append({
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: [
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: f&quot;以下是參考文件：\n\n{doc_content}&quot;,
                    &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
                },
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: &quot;好的，我已讀取這些文件。請問有什麼問題？&quot;  # 假設這是第一個問題的佔位
                }
            ]
        })
        messages.append({
            &quot;role&quot;: &quot;assistant&quot;,
            &quot;content&quot;: &quot;好的，我已閱讀完這些參考文件，隨時可以回答你的問題。&quot;
        })

    # 加入對話歷史
    messages.extend(conversation_history)

    # 最後加入當前問題（不快取，每次都變）
    messages.append({
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: user_question
    })

    return {
        &quot;system&quot;: system,
        &quot;messages&quot;: messages
    }
```

## Python 完整實作

一個真實的應用案例：文件問答系統，帶快取監控：

```python
import anthropic
from dataclasses import dataclass

@dataclass
class CacheStats:
    cache_creation_tokens: int = 0
    cache_read_tokens: int = 0
    input_tokens: int = 0
    output_tokens: int = 0

    @property
    def cache_hit_rate(self) -&gt; float:
        total_cacheable = self.cache_creation_tokens + self.cache_read_tokens
        if total_cacheable == 0:
            return 0.0
        return self.cache_read_tokens / total_cacheable

    @property
    def estimated_savings_usd(self) -&gt; float:
        # 假設 Claude Opus 4.5 定價：$15/MTok 輸入
        price_per_token = 15 / 1_000_000
        # 如果沒有快取，這些 cache_read 的 token 就是標準費率
        saved = self.cache_read_tokens * price_per_token * 0.9  # 節省 90%
        return saved


class DocumentQASystem:
    def __init__(self, documents: list[str], system_prompt: str):
        self.client = anthropic.Anthropic()
        self.documents = documents
        self.system_prompt = system_prompt
        self.conversation_history = []
        self.stats = CacheStats()

    def _build_system(self) -&gt; list[dict]:
        return [
            {
                &quot;type&quot;: &quot;text&quot;,
                &quot;text&quot;: self.system_prompt,
                &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
            }
        ]

    def _build_document_block(self) -&gt; dict:
        doc_text = &quot;\n\n---\n\n&quot;.join(
            f&quot;[文件 {i+1}]\n{doc}&quot; for i, doc in enumerate(self.documents)
        )
        return {
            &quot;type&quot;: &quot;text&quot;,
            &quot;text&quot;: f&quot;參考文件庫：\n\n{doc_text}&quot;,
            &quot;cache_control&quot;: {&quot;type&quot;: &quot;ephemeral&quot;}
        }

    def ask(self, question: str) -&gt; str:
        # 建立 messages 列表
        messages = []

        # 第一輪加入文件（帶快取標記）
        if not self.conversation_history:
            messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: [
                    self._build_document_block(),
                    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: question}
                ]
            })
        else:
            # 已有對話歷史：文件放在最前面的 user 訊息
            # conversation_history 已經包含了第一輪（有文件），直接繼續
            messages = self.conversation_history.copy()
            messages.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: question
            })

        response = self.client.messages.create(
            model=&quot;claude-opus-4-5&quot;,
            max_tokens=1024,
            system=self._build_system(),
            messages=messages,
        )

        # 更新統計數據
        usage = response.usage
        self.stats.cache_creation_tokens += getattr(usage, &apos;cache_creation_input_tokens&apos;, 0)
        self.stats.cache_read_tokens += getattr(usage, &apos;cache_read_input_tokens&apos;, 0)
        self.stats.input_tokens += usage.input_tokens
        self.stats.output_tokens += usage.output_tokens

        answer = response.content[0].text

        # 更新對話歷史
        if not self.conversation_history:
            self.conversation_history.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: [
                    self._build_document_block(),
                    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: question}
                ]
            })
        else:
            self.conversation_history.append({
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: question
            })
        self.conversation_history.append({
            &quot;role&quot;: &quot;assistant&quot;,
            &quot;content&quot;: answer
        })

        return answer

    def print_stats(self):
        print(f&quot;快取命中率: {self.stats.cache_hit_rate:.1%}&quot;)
        print(f&quot;快取寫入 tokens: {self.stats.cache_creation_tokens:,}&quot;)
        print(f&quot;快取讀取 tokens: {self.stats.cache_read_tokens:,}&quot;)
        print(f&quot;預估節省費用: ${self.stats.estimated_savings_usd:.4f}&quot;)


# 使用範例
if __name__ == &quot;__main__&quot;:
    # 模擬一個有大量文件的 RAG 系統
    documents = [
        &quot;產品規格文件 v2.3...\n[5000 字的文件內容]&quot;,
        &quot;API 參考手冊...\n[8000 字的文件內容]&quot;,
        &quot;常見問題集...\n[3000 字的文件內容]&quot;,
    ]

    system_prompt = &quot;&quot;&quot;你是一位專業的技術支援人員，熟悉公司的所有產品和 API。
請根據提供的文件回答用戶問題。
回答要準確、簡潔，必要時引用文件的具體內容。&quot;&quot;&quot;

    qa_system = DocumentQASystem(documents, system_prompt)

    # 第一次問：cache write
    answer1 = qa_system.ask(&quot;API 的 rate limit 是多少？&quot;)
    print(f&quot;問題 1: {answer1}\n&quot;)

    # 第二次問：cache read（節省 90% 費用）
    answer2 = qa_system.ask(&quot;如何處理 429 Too Many Requests 錯誤？&quot;)
    print(f&quot;問題 2: {answer2}\n&quot;)

    # 第三次問：cache read
    answer3 = qa_system.ask(&quot;SDK 支援哪些程式語言？&quot;)
    print(f&quot;問題 3: {answer3}\n&quot;)

    qa_system.print_stats()
```

## TypeScript 實作

```typescript
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const client = new Anthropic();

interface ConversationTurn {
  role: &apos;user&apos; | &apos;assistant&apos;;
  content: string | Anthropic.ContentBlockParam[];
}

async function buildRagRequest(
  systemPrompt: string,
  documentContext: string,
  conversationHistory: ConversationTurn[],
  userQuestion: string
): Promise&lt;Anthropic.Messages.MessageCreateParamsNonStreaming&gt; {
  const system: Anthropic.Messages.TextBlockParam[] = [
    {
      type: &apos;text&apos;,
      text: systemPrompt,
      cache_control: { type: &apos;ephemeral&apos; },
    },
  ];

  const messages: ConversationTurn[] = [];

  if (conversationHistory.length === 0) {
    // 第一次請求：文件 + 問題合在第一個 user turn
    messages.push({
      role: &apos;user&apos;,
      content: [
        {
          type: &apos;text&apos;,
          text: documentContext,
          cache_control: { type: &apos;ephemeral&apos; },
        } as Anthropic.Messages.TextBlockParam,
        {
          type: &apos;text&apos;,
          text: userQuestion,
        },
      ],
    });
  } else {
    // 後續請求：帶入歷史，新問題放最後
    messages.push(...conversationHistory);
    messages.push({
      role: &apos;user&apos;,
      content: userQuestion,
    });
  }

  return {
    model: &apos;claude-opus-4-5&apos;,
    max_tokens: 1024,
    system,
    messages: messages as Anthropic.Messages.MessageParam[],
  };
}

async function main() {
  const systemPrompt = &apos;你是一位專業的技術支援人員...&apos;;
  const documentContext = &apos;參考文件庫：\n\n[這裡是大量文件內容]&apos;;

  const history: ConversationTurn[] = [];

  // 第一次請求（cache write）
  const request1 = await buildRagRequest(
    systemPrompt,
    documentContext,
    history,
    &apos;API 的 rate limit 是多少？&apos;
  );

  const response1 = await client.messages.create(request1);
  const answer1 = (response1.content[0] as Anthropic.Messages.TextBlock).text;

  console.log(&apos;回答 1:&apos;, answer1);
  console.log(&apos;Cache 統計:&apos;, {
    cacheWrite: (response1.usage as any).cache_creation_input_tokens ?? 0,
    cacheRead: (response1.usage as any).cache_read_input_tokens ?? 0,
    inputTokens: response1.usage.input_tokens,
  });

  // 更新歷史
  history.push({
    role: &apos;user&apos;,
    content: [
      {
        type: &apos;text&apos;,
        text: documentContext,
        cache_control: { type: &apos;ephemeral&apos; },
      } as Anthropic.Messages.TextBlockParam,
      { type: &apos;text&apos;, text: &apos;API 的 rate limit 是多少？&apos; },
    ],
  });
  history.push({ role: &apos;assistant&apos;, content: answer1 });

  // 第二次請求（cache read）
  const request2 = await buildRagRequest(
    systemPrompt,
    documentContext,
    history,
    &apos;如何處理 429 錯誤？&apos;
  );

  const response2 = await client.messages.create(request2);
  const answer2 = (response2.content[0] as Anthropic.Messages.TextBlock).text;

  console.log(&apos;\n回答 2:&apos;, answer2);
  console.log(&apos;Cache 統計:&apos;, {
    cacheWrite: (response2.usage as any).cache_creation_input_tokens ?? 0,
    cacheRead: (response2.usage as any).cache_read_input_tokens ?? 0,
    inputTokens: response2.usage.input_tokens,
  });
}

main();
```

## 快取命中率的計算與監控

在 API 回應裡，`usage` 物件包含以下欄位：

```python
usage = response.usage

# 標準輸入 tokens（未命中快取的部分）
input_tokens = usage.input_tokens

# 快取寫入 tokens（這次建立快取消耗的 tokens）
cache_creation_input_tokens = usage.cache_creation_input_tokens  # 可能為 None 或 0

# 快取讀取 tokens（命中快取節省的 tokens）
cache_read_input_tokens = usage.cache_read_input_tokens  # 可能為 None 或 0
```

快取命中率計算：

```python
def calculate_cache_efficiency(usage) -&gt; dict:
    cache_write = getattr(usage, &apos;cache_creation_input_tokens&apos;, 0) or 0
    cache_read = getattr(usage, &apos;cache_read_input_tokens&apos;, 0) or 0
    input_tokens = usage.input_tokens

    total_processed = input_tokens + cache_write + cache_read
    hit_rate = cache_read / (cache_write + cache_read) if (cache_write + cache_read) &gt; 0 else 0

    return {
        &quot;hit_rate&quot;: hit_rate,
        &quot;cache_write_tokens&quot;: cache_write,
        &quot;cache_read_tokens&quot;: cache_read,
        &quot;standard_input_tokens&quot;: input_tokens,
        &quot;total_tokens_processed&quot;: total_processed,
    }
```

理想的快取命中率因應用而異，但我的基準是：

- 文件問答系統：&gt;70%
- 多輪對話：&gt;50%（第 2 輪以後應該都命中）
- 批次處理：&gt;90%（相同 system prompt，大量不同問題）

## 真實案例：RAG 系統的省錢計算

我把一個內部知識庫問答系統的費用做了計算：

**不用快取（每月）：**

- 每次查詢 tokens：system prompt 500 + 文件上下文 15,000 + 問題 200 = 15,700 tokens
- 每天 1,000 次查詢 = 每月 30,000 次
- 總輸入 tokens：30,000 × 15,700 = 471M tokens
- 費用（Claude Opus 4.5 $15/MTok）：$7,065/月

**使用快取後（每月）：**

- 系統 prompt + 文件上下文 = 15,500 tokens → 快取後只在第一次請求付 1.25x
- 後續請求：問題 200 tokens（標準費率）+ 15,500 tokens（0.1x 費率）
- 假設每個 session 平均 5 次對話，快取存活 5 分鐘內完成
- 有效快取命中率約 80%
- 費用：大幅降低，約 $1,500/月

**節省：約 80%**

這還是保守估計。如果你的系統有很長的文件，節省比例可以更高。

---

Prompt caching 是我見過 ROI 最高的 Claude API 優化。設置時間大概 2-4 小時，但可以立刻看到帳單下降。

下一章我們換個話題，聊聊另一種降低成本和提升吞吐量的方法：**Batch API**。如果你需要一次處理幾千份文件，Batch API 能讓你用 50% 的價格完成任務。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.CNcPr5bG.webp" medium="image"/><category>Claude API</category><category>Prompt Caching</category><category>成本優化</category><category>cache_control</category><enclosure url="https://bobochen.dev/_astro/cover.CNcPr5bG.webp" length="0" type="image/png"/></item><item><title>多模態輸入：圖片、PDF 與文件處理</title><link>https://bobochen.dev/blog/claude-api-guide-multimodal/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-multimodal/</guid><description>支援格式（JPEG/PNG/GIF/WebP/PDF）；base64 vs URL 兩種圖片輸入方式；PDF 文件上傳與分析；token 計算與成本；截圖分析、OCR、圖表解讀實戰範例；Python + TypeScript 完整代碼。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>前幾章我們把 Claude 當純文字模型來用：送進去的是文字，出來的也是文字。但 Claude 的能力遠不止於此。

從 Claude 3 Sonnet 開始，Claude 就是一個真正的多模態模型（multimodal model）。你可以送圖片進去，Claude 看得懂。你可以丟 PDF，Claude 讀得了。你可以同時傳五張截圖，Claude 可以跨圖分析。

這一章我要帶你搞清楚多模態輸入的完整用法，包括技術細節、成本考量，以及我在生產環境中真正使用的做法。

## 什麼是多模態（Multimodal）？

所謂「多模態」，就是模型能處理不同種類的輸入，而不僅限於文字。

對 Claude 來說，目前支援的輸入模態是：

- **文字**（一直支援）
- **圖片**（JPEG、PNG、GIF、WebP）
- **PDF 文件**

值得注意的是，截至 2026 年，Claude **不支援影片輸入**。你沒辦法把 mp4 傳給 Claude 分析。如果你的使用場景需要影片分析，目前的做法是把影片截成一系列關鍵幀圖片，再批次傳入——這個方法雖然繁瑣，但可行。

## 圖片輸入的兩種方式

Claude API 接受圖片的方式有兩種：**base64 編碼**和**URL 直接引用**。兩種方式各有適用場景。

### 方式一：Base64 編碼

把圖片轉成 base64 字串，直接嵌入 API 請求裡。

適合：

- 圖片存在本地（不需要先上傳到某個地方）
- 圖片是動態生成的（例如截圖）
- 你希望請求完全自包含，不依賴外部 URL

缺點：

- base64 會讓 payload 體積增大約 33%
- 長圖片會讓請求 JSON 變得很龐大

```python
import anthropic
import base64
from pathlib import Path

client = anthropic.Anthropic()

# 讀取圖片並轉換為 base64
image_path = Path(&quot;screenshot.png&quot;)
image_data = base64.standard_b64encode(image_path.read_bytes()).decode(&quot;utf-8&quot;)

message = client.messages.create(
    model=&quot;claude-opus-4-5&quot;,
    max_tokens=1024,
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: [
                {
                    &quot;type&quot;: &quot;image&quot;,
                    &quot;source&quot;: {
                        &quot;type&quot;: &quot;base64&quot;,
                        &quot;media_type&quot;: &quot;image/png&quot;,  # 必須正確指定
                        &quot;data&quot;: image_data,
                    },
                },
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: &quot;這張截圖裡有什麼錯誤訊息？請詳細說明問題所在。&quot;
                }
            ],
        }
    ],
)

print(message.content[0].text)
```

`media_type` 的值必須與實際圖片格式對應：

- `image/jpeg`
- `image/png`
- `image/gif`
- `image/webp`

### 方式二：URL 直接引用

如果圖片已經有一個公開可訪問的 URL，可以直接傳 URL，讓 Claude 自己去抓。

```python
message = client.messages.create(
    model=&quot;claude-opus-4-5&quot;,
    max_tokens=1024,
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: [
                {
                    &quot;type&quot;: &quot;image&quot;,
                    &quot;source&quot;: {
                        &quot;type&quot;: &quot;url&quot;,
                        &quot;url&quot;: &quot;https://example.com/chart.png&quot;,
                    },
                },
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: &quot;這張圖表顯示了什麼趨勢？&quot;
                }
            ],
        }
    ],
)
```

URL 方式的**限制**：

- URL 必須是公開可訪問的（Claude 伺服器要能下載到）
- 不支援需要認證的 URL（例如需要登入的 S3 bucket）
- 不支援本地網路 URL

我在生產環境的習慣是：如果圖片已經在公開 CDN 上，用 URL；如果是用戶上傳的動態圖片或本地生成的，用 base64。

## 圖片大小與 Token 計算

這裡有個很多人忽略的重點：**圖片會消耗 token，而且消耗量與圖片大小成正比**。

Claude 在處理圖片時，內部會把圖片切分成 tiles（磁磚），每個 tile 大約消耗 1500-1600 個 token。

計算規則：

1. 圖片先會縮放到最長邊不超過 1568px
2. 縮放後，每個 512x512 的 tile 消耗約 1600 tokens
3. 還有一個固定的基礎成本（base cost）約 2500 tokens

舉個例子：一張 1000x1000 的圖片，大約消耗 4000-5000 tokens。

**實際影響**：如果你的應用需要同時傳多張圖片，token 成本會快速累積。以 Claude Opus 4.5 為例，1000 tokens 輸入大約 $0.015。一張中等大小的圖片就可能消耗 $0.05-0.10。

我的建議：**在傳圖片前先對圖片做壓縮**。對截圖類的分析任務，把圖片壓到 800x600 以下通常不影響分析品質，但可以省下 50% 以上的 token。

```python
from PIL import Image
import io

def compress_image_for_api(image_path: str, max_size: int = 1000) -&gt; tuple[bytes, str]:
    &quot;&quot;&quot;壓縮圖片以降低 token 消耗，同時保留分析品質&quot;&quot;&quot;
    img = Image.open(image_path)

    # 計算縮放比例，長邊不超過 max_size
    ratio = min(max_size / img.width, max_size / img.height, 1.0)
    if ratio &lt; 1.0:
        new_size = (int(img.width * ratio), int(img.height * ratio))
        img = img.resize(new_size, Image.LANCZOS)

    # 轉換為 JPEG（通常比 PNG 小很多）
    output = io.BytesIO()
    img.convert(&quot;RGB&quot;).save(output, format=&quot;JPEG&quot;, quality=85)

    return output.getvalue(), &quot;image/jpeg&quot;
```

## PDF 文件上傳與分析

PDF 支援是 Claude 相當強大的功能之一。你可以直接把 PDF 丟給 Claude，它能讀懂裡面的文字、表格，甚至是掃描版 PDF（帶有圖片的頁面）。

PDF 的傳入方式和圖片一樣，支援 base64 和 URL 兩種：

```python
import anthropic
import base64
from pathlib import Path

client = anthropic.Anthropic()

# 讀取 PDF 並轉換為 base64
pdf_path = Path(&quot;contract.pdf&quot;)
pdf_data = base64.standard_b64encode(pdf_path.read_bytes()).decode(&quot;utf-8&quot;)

message = client.messages.create(
    model=&quot;claude-opus-4-5&quot;,
    max_tokens=2048,
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: [
                {
                    &quot;type&quot;: &quot;document&quot;,
                    &quot;source&quot;: {
                        &quot;type&quot;: &quot;base64&quot;,
                        &quot;media_type&quot;: &quot;application/pdf&quot;,
                        &quot;data&quot;: pdf_data,
                    },
                },
                {
                    &quot;type&quot;: &quot;text&quot;,
                    &quot;text&quot;: &quot;請摘要這份合約的主要條款，特別是付款條件和違約責任。&quot;
                }
            ],
        }
    ],
)

print(message.content[0].text)
```

注意幾個細節：

- PDF 用的是 `type: &quot;document&quot;` 而不是 `type: &quot;image&quot;`
- `media_type` 是 `application/pdf`
- PDF 的 token 消耗基本上跟文字提取後的文字量成正比，掃描版 PDF（全圖片）會比文字版 PDF 貴很多

### PDF 分析的限制

Claude 在處理 PDF 時有幾個需要知道的限制：

1. **頁數限制**：目前 Claude 能處理的 PDF 上限大約是 100 頁，超過的頁面會被截斷
2. **檔案大小**：單個 PDF 不超過 32MB（base64 之前的原始大小）
3. **掃描版 PDF**：能讀，但品質取決於掃描品質；低解析度的掃描文件可能識別率不佳
4. **加密 PDF**：無法處理密碼保護的 PDF

## TypeScript 範例

前面的例子都是 Python，但我知道很多人用 TypeScript 開發應用。這裡給一個完整的 TypeScript 範例：

```typescript
import Anthropic from &apos;@anthropic-ai/sdk&apos;;
import * as fs from &apos;fs&apos;;
import * as path from &apos;path&apos;;

const client = new Anthropic();

async function analyzeImageWithClaude(imagePath: string): Promise&lt;string&gt; {
  const imageBuffer = fs.readFileSync(imagePath);
  const base64Image = imageBuffer.toString(&apos;base64&apos;);

  // 根據副檔名決定 media_type
  const ext = path.extname(imagePath).toLowerCase();
  const mediaTypeMap: Record&lt;string, string&gt; = {
    &apos;.jpg&apos;: &apos;image/jpeg&apos;,
    &apos;.jpeg&apos;: &apos;image/jpeg&apos;,
    &apos;.png&apos;: &apos;image/png&apos;,
    &apos;.gif&apos;: &apos;image/gif&apos;,
    &apos;.webp&apos;: &apos;image/webp&apos;,
  };

  const mediaType = mediaTypeMap[ext] ?? &apos;image/jpeg&apos;;

  const message = await client.messages.create({
    model: &apos;claude-opus-4-5&apos;,
    max_tokens: 1024,
    messages: [
      {
        role: &apos;user&apos;,
        content: [
          {
            type: &apos;image&apos;,
            source: {
              type: &apos;base64&apos;,
              media_type: mediaType as &apos;image/jpeg&apos; | &apos;image/png&apos; | &apos;image/gif&apos; | &apos;image/webp&apos;,
              data: base64Image,
            },
          },
          {
            type: &apos;text&apos;,
            text: &apos;請描述這張圖片的內容，並指出任何值得注意的細節。&apos;,
          },
        ],
      },
    ],
  });

  return (message.content[0] as { type: &apos;text&apos;; text: string }).text;
}

async function analyzePdfWithClaude(pdfPath: string): Promise&lt;string&gt; {
  const pdfBuffer = fs.readFileSync(pdfPath);
  const base64Pdf = pdfBuffer.toString(&apos;base64&apos;);

  const message = await client.messages.create({
    model: &apos;claude-opus-4-5&apos;,
    max_tokens: 2048,
    messages: [
      {
        role: &apos;user&apos;,
        content: [
          {
            type: &apos;document&apos;,
            source: {
              type: &apos;base64&apos;,
              media_type: &apos;application/pdf&apos;,
              data: base64Pdf,
            },
          },
          {
            type: &apos;text&apos;,
            text: &apos;請摘要這份文件的主要內容。&apos;,
          },
        ],
      },
    ],
  });

  return (message.content[0] as { type: &apos;text&apos;; text: string }).text;
}

// 使用範例
(async () =&gt; {
  const imageAnalysis = await analyzeImageWithClaude(&apos;screenshot.png&apos;);
  console.log(&apos;圖片分析結果：&apos;, imageAnalysis);

  const pdfAnalysis = await analyzePdfWithClaude(&apos;report.pdf&apos;);
  console.log(&apos;PDF 分析結果：&apos;, pdfAnalysis);
})();
```

## 多張圖片同時分析

Claude 支援在單次請求裡傳入多張圖片。這個功能在某些場景非常有用，例如：

- 比較兩個設計稿的差異
- 分析一系列截圖找出 bug
- 從多張商品圖片生成描述

```python
import anthropic
import base64
from pathlib import Path

client = anthropic.Anthropic()

def load_image_as_base64(path: str) -&gt; dict:
    &quot;&quot;&quot;輔助函式：載入圖片並轉換為 API 格式&quot;&quot;&quot;
    img_path = Path(path)
    img_data = base64.standard_b64encode(img_path.read_bytes()).decode(&quot;utf-8&quot;)

    ext_to_media_type = {
        &quot;.jpg&quot;: &quot;image/jpeg&quot;,
        &quot;.jpeg&quot;: &quot;image/jpeg&quot;,
        &quot;.png&quot;: &quot;image/png&quot;,
        &quot;.gif&quot;: &quot;image/gif&quot;,
        &quot;.webp&quot;: &quot;image/webp&quot;,
    }
    media_type = ext_to_media_type.get(img_path.suffix.lower(), &quot;image/jpeg&quot;)

    return {
        &quot;type&quot;: &quot;image&quot;,
        &quot;source&quot;: {
            &quot;type&quot;: &quot;base64&quot;,
            &quot;media_type&quot;: media_type,
            &quot;data&quot;: img_data,
        }
    }

# 同時分析三張截圖
screenshots = [&quot;before.png&quot;, &quot;after.png&quot;, &quot;error.png&quot;]

content = []
for i, screenshot in enumerate(screenshots):
    content.append(load_image_as_base64(screenshot))
    content.append({
        &quot;type&quot;: &quot;text&quot;,
        &quot;text&quot;: f&quot;[圖片 {i+1}: {screenshot}]&quot;
    })

content.append({
    &quot;type&quot;: &quot;text&quot;,
    &quot;text&quot;: &quot;以上三張截圖分別是：before（操作前）、after（操作後）、error（出現的錯誤）。請分析這個問題，找出 before 和 after 的差異，解釋錯誤可能的原因。&quot;
})

message = client.messages.create(
    model=&quot;claude-opus-4-5&quot;,
    max_tokens=2048,
    messages=[
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: content}
    ],
)

print(message.content[0].text)
```

## 實際應用場景

讓我分享幾個我在實際專案中用過的多模態應用場景：

### 截圖分析（Error Analysis）

這是我用得最多的場景。當使用者遇到錯誤時，讓他們截圖上傳，比手打錯誤訊息準確得多：

```python
def analyze_error_screenshot(screenshot_base64: str) -&gt; dict:
    &quot;&quot;&quot;分析錯誤截圖，返回診斷結果和解決建議&quot;&quot;&quot;
    message = client.messages.create(
        model=&quot;claude-opus-4-5&quot;,
        max_tokens=1024,
        messages=[
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: [
                    {
                        &quot;type&quot;: &quot;image&quot;,
                        &quot;source&quot;: {
                            &quot;type&quot;: &quot;base64&quot;,
                            &quot;media_type&quot;: &quot;image/png&quot;,
                            &quot;data&quot;: screenshot_base64,
                        },
                    },
                    {
                        &quot;type&quot;: &quot;text&quot;,
                        &quot;text&quot;: &quot;&quot;&quot;你是一位技術支援工程師。請分析這張錯誤截圖：

1. 描述錯誤訊息的確切內容
2. 判斷錯誤的可能原因（列出 2-3 個）
3. 提供解決步驟（按優先順序排列）

請用繁體中文回答，格式清晰。&quot;&quot;&quot;
                    }
                ],
            }
        ],
    )

    return {&quot;analysis&quot;: message.content[0].text}
```

### 文件 OCR 與結構提取

掃描版合約或表單的結構化資料提取：

```python
def extract_invoice_data(invoice_image_base64: str) -&gt; dict:
    &quot;&quot;&quot;從發票圖片提取結構化資料&quot;&quot;&quot;
    message = client.messages.create(
        model=&quot;claude-opus-4-5&quot;,
        max_tokens=1024,
        messages=[
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: [
                    {
                        &quot;type&quot;: &quot;image&quot;,
                        &quot;source&quot;: {
                            &quot;type&quot;: &quot;base64&quot;,
                            &quot;media_type&quot;: &quot;image/jpeg&quot;,
                            &quot;data&quot;: invoice_image_base64,
                        },
                    },
                    {
                        &quot;type&quot;: &quot;text&quot;,
                        &quot;text&quot;: &quot;&quot;&quot;請從這張發票圖片中提取以下資訊，以 JSON 格式回答：

{
  &quot;invoice_number&quot;: &quot;發票號碼&quot;,
  &quot;date&quot;: &quot;日期（YYYY-MM-DD）&quot;,
  &quot;vendor&quot;: &quot;廠商名稱&quot;,
  &quot;total_amount&quot;: &quot;總金額（數字）&quot;,
  &quot;currency&quot;: &quot;幣別&quot;,
  &quot;items&quot;: [
    {&quot;description&quot;: &quot;品項說明&quot;, &quot;quantity&quot;: 數量, &quot;unit_price&quot;: 單價, &quot;amount&quot;: 金額}
  ]
}

只回答 JSON，不要其他文字。&quot;&quot;&quot;
                    }
                ],
            }
        ],
    )

    import json
    return json.loads(message.content[0].text)
```

### 設計評審

我在開發流程裡加入 Claude 做自動化設計審查：

```python
def review_design_mockup(design_image_base64: str, design_brief: str) -&gt; str:
    &quot;&quot;&quot;根據設計簡報評審 UI mockup&quot;&quot;&quot;
    message = client.messages.create(
        model=&quot;claude-opus-4-5&quot;,
        max_tokens=2048,
        messages=[
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: [
                    {
                        &quot;type&quot;: &quot;image&quot;,
                        &quot;source&quot;: {
                            &quot;type&quot;: &quot;base64&quot;,
                            &quot;media_type&quot;: &quot;image/png&quot;,
                            &quot;data&quot;: design_image_base64,
                        },
                    },
                    {
                        &quot;type&quot;: &quot;text&quot;,
                        &quot;text&quot;: f&quot;&quot;&quot;你是一位資深 UX 設計師。

設計簡報：
{design_brief}

請針對這個 UI mockup 提供專業意見：
1. 是否符合設計簡報的需求？
2. 可用性問題（如果有）
3. 視覺層次是否清晰？
4. 改進建議（最多三點，按重要性排序）&quot;&quot;&quot;
                    }
                ],
            }
        ],
    )

    return message.content[0].text
```

## Prompt 技巧：讓圖片分析更準確

幾個我實測有效的技巧：

**1. 明確說明圖片的上下文**

不要只說「分析這張圖片」，要告訴 Claude 這是什麼類型的圖片、你想知道什麼：

```
❌ 差：「請分析這張圖片。」
✅ 好：「這是一個 React 應用程式的截圖，頁面上出現了一個錯誤訊息。請識別錯誤訊息的完整內容，並推測可能的原因。」
```

**2. 對多圖片請求，為每張圖片加標籤**

在圖片之後加一個文字說明，讓 Claude 知道每張圖的角色：

```python
content = [
    image_1_block,
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;[圖1: 設計稿 - 手機版]&quot;},
    image_2_block,
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;[圖2: 設計稿 - 桌機版]&quot;},
    {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;請比較這兩個版本的設計一致性...&quot;}
]
```

**3. 要求結構化輸出**

對需要從圖片提取資訊的任務，要求 JSON 格式輸出，方便後續處理：

```python
&quot;請從這張報表截圖中提取所有數據，以 JSON 格式回答，不要其他說明文字。&quot;
```

**4. 分步驟引導複雜分析**

對複雜的圖片分析任務，分步驟引導比一次性提問效果更好：

```python
# Step 1: 描述
message1 = &quot;首先，請描述你在這張架構圖中看到的所有元件和連接關係。&quot;

# Step 2: 分析（帶入 step 1 的結果）
message2 = f&quot;根據你的描述：{description}\n現在請分析這個架構有哪些潛在的單點故障（SPOF）。&quot;
```

## 影像品質 vs Token 成本的 Tradeoff

我來給你一個實際的對比數據，讓你在專案中做出有依據的決策：

| 圖片尺寸  | 大約 Token 消耗 | Claude Opus 4.5 成本 |
| --------- | --------------- | -------------------- |
| 300x300   | ~1,000 tokens   | ~$0.015              |
| 800x600   | ~3,000 tokens   | ~$0.045              |
| 1200x900  | ~5,500 tokens   | ~$0.083              |
| 1920x1080 | ~8,000 tokens   | ~$0.120              |

對大多數截圖分析任務，800x600 的解析度已經足夠。把圖片壓縮到這個尺寸，可以在不影響分析品質的前提下，省下 60% 以上的圖片相關 token 費用。

我的實踐原則：

- **文字識別（OCR 類任務）**：至少 150 DPI，文字要清晰可辨
- **圖表分析**：800px 寬度通常足夠
- **UI 截圖分析**：直接原始尺寸，UI 細節很重要
- **設計評審**：直接原始尺寸，保留細節

---

多模態輸入讓 Claude 從「聊天機器人」變成真正能處理真實世界資料的助手。圖片和 PDF 的支援打開了大量原本無法自動化的使用場景。

不過，多模態請求的 token 消耗也比純文字請求高出不少。在下一章，我們來聊一個能大幅降低 API 成本的技術：**Prompt Caching**。如果你的應用有固定的 system prompt 或長文件，一個好的快取策略可以讓成本直接砍半。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.DaeiAmip.webp" medium="image"/><category>Claude API</category><category>多模態</category><category>Vision</category><category>PDF</category><category>圖片</category><enclosure url="https://bobochen.dev/_astro/cover.DaeiAmip.webp" length="0" type="image/png"/></item><item><title>Extended Thinking：複雜推理任務的殺手鐧</title><link>https://bobochen.dev/blog/claude-api-guide-extended-thinking/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-extended-thinking/</guid><description>深入解析 Claude Extended Thinking：適用與不適用場景、budget_tokens 設定策略、thinking blocks 的回應格式、與 Streaming 結合、成本計算，以及 with/without thinking 的實際效果對比。</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>讓我問你一個問題。

假設你要做一個很困難的決策——例如評估一個複雜的技術方案，或者分析一份合約的風險。你會怎麼做？

你不會在被問到的瞬間就立刻說出答案。你會先想一想：「這個方案的優點是什麼？缺點是什麼？有沒有遺漏什麼面向？」

這就是 Extended Thinking 給 Claude 的能力：**在回答你之前，先花時間真正地思考**。

## Extended Thinking 是什麼

一般情況下，Claude 生成回應是一個「前向傳遞」的過程——它一邊思考一邊輸出文字，無法「回頭修改」。這對大多數任務來說沒問題，但對於需要多步驟推理的困難問題，這種方式有根本的限制。

Extended Thinking 給 Claude 一個**專用的「思考空間」（thinking tokens）**。在這個空間裡，Claude 可以自由地探索問題、嘗試不同的推理路徑、驗證中間結果，而不需要立刻給出答案。思考完成後，它才把最終結論作為回答輸出。

你在 API 回應裡看到的是「thinking blocks」——Claude 的完整思考過程（Anthropic 選擇讓開發者可以看到這些思考過程，增加透明度）。

這個機制在 Anthropic 的研究中顯示，在困難的數學、邏輯、程式設計問題上，Extended Thinking 可以大幅提升準確率。在某些基準測試（例如 AIME 數學競賽題）上，提升幅度超過 20 個百分點。

## 適用場景

Extended Thinking 不是萬能藥。它最有價值的場景：

### 1. 數學與量化推理

```
&quot;以下是一個投資組合，請計算夏普比率，並分析是否需要再平衡...&quot;
&quot;這個演算法的時間複雜度是多少？能優化到 O(n log n) 嗎？&quot;
&quot;解這個微分方程：dy/dx = 2xy，初始條件 y(0) = 1&quot;
```

Claude 在沒有 thinking 的情況下做複雜計算容易出錯——它必須在「思考計算過程」和「輸出文字」之間切換。Thinking 讓它把計算做完再輸出。

### 2. 邏輯推理和謎題

```
&quot;五個人各自住在不同顏色的房子裡，按照以下線索，誰養了魚？...&quot;
&quot;這段程式碼有個 bug，在並發場景下會出現 race condition，請找出來並修復&quot;
```

### 3. 多步驟計畫和分析

```
&quot;我想建立一個 SaaS 產品，目標市場是台灣中小企業的 HR 部門，請幫我做競爭分析，
 包括主要競爭對手、市場定位、定價策略，以及我應該優先實作的 MVP 功能&quot;
```

### 4. 複雜的程式碼設計

```
&quot;請設計一個分散式任務隊列系統，需要支援優先級、重試機制、dead letter queue，
 以及水平擴展。請給出完整的架構設計和主要元件的介面定義&quot;
```

### 5. 需要仔細評估多個選項的決策

```
&quot;我有三個 API 設計方案，各有優缺點，請幫我分析哪個最適合我們的使用場景...&quot;
```

## 不適用場景

同樣重要的是知道什麼時候**不要用** Extended Thinking：

**簡單問答**：「台灣的首都是哪裡？」「Python 的 list comprehension 語法怎麼寫？」——這些問題 Claude 早就知道答案，讓它思考 5000 tokens 是浪費錢。

**創意寫作**：寫詩、寫故事、頭腦風暴創意——這些任務的「好答案」不是靠邏輯推導出來的，thinking 對品質提升幫助有限。

**速度敏感的場景**：即時客服、使用者打字時的即時回應——thinking 需要額外時間，會增加等待時間。

**翻譯和格式轉換**：把英文翻成中文、把 CSV 轉 JSON——直接執行比先思考效率更高。

我的經驗法則：**如果你自己解這個問題需要打草稿、分步驟計算、或者仔細想才能確認答案，那就值得用 Extended Thinking**。

## API 使用方式

在 API 呼叫中啟用 Extended Thinking：

```python
import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,  # Extended Thinking 需要特定模型版本
    max_tokens=16000,
    thinking={
        &quot;type&quot;: &quot;enabled&quot;,
        &quot;budget_tokens&quot;: 10000  # Claude 最多可以用多少 tokens 來思考
    },
    messages=[{
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: &quot;在 1 到 100 之間有多少個質數？請一一列出並計算。&quot;
    }]
)

# 回應會包含 thinking blocks 和 text blocks
for block in response.content:
    if block.type == &quot;thinking&quot;:
        print(&quot;=== Claude 的思考過程 ===&quot;)
        print(block.thinking)
        print()
    elif block.type == &quot;text&quot;:
        print(&quot;=== Claude 的回答 ===&quot;)
        print(block.text)
```

**注意**：`max_tokens` 必須大於 `budget_tokens`，因為模型還需要 tokens 來生成最終的回答。

## budget_tokens 的設定策略

`budget_tokens` 是 Extended Thinking 最難掌握的參數。

它的範圍是 1024（最小）到模型支援的最大值（視模型而定，通常是 32768 甚至更高）。你設定的是**上限**，Claude 可能用不完你給的預算。

幾個我測試出來的設定策略：

### 按任務難度分層

```python
THINKING_BUDGETS = {
    &quot;simple_math&quot;: 1024,        # 簡單計算
    &quot;complex_analysis&quot;: 8000,   # 複雜分析
    &quot;hard_reasoning&quot;: 16000,    # 困難推理
    &quot;research_synthesis&quot;: 32000, # 大量資訊整合
}

def smart_thinking_budget(task_type: str) -&gt; dict:
    budget = THINKING_BUDGETS.get(task_type, 4000)
    return {&quot;type&quot;: &quot;enabled&quot;, &quot;budget_tokens&quot;: budget}
```

### 動態設定（根據問題長度）

```python
def estimate_thinking_budget(user_message: str) -&gt; int:
    &quot;&quot;&quot;根據問題的長度和複雜度估算需要的 thinking budget&quot;&quot;&quot;
    word_count = len(user_message)

    # 關鍵字偵測
    complexity_keywords = [
        &quot;分析&quot;, &quot;比較&quot;, &quot;設計&quot;, &quot;架構&quot;, &quot;優化&quot;, &quot;評估&quot;,
        &quot;計算&quot;, &quot;證明&quot;, &quot;推導&quot;, &quot;解釋&quot;, &quot;規劃&quot;
    ]
    complexity_score = sum(1 for k in complexity_keywords if k in user_message)

    base_budget = 2048
    length_bonus = min(word_count * 2, 4096)
    complexity_bonus = complexity_score * 1000

    return min(base_budget + length_bonus + complexity_bonus, 16000)
```

### 我的實戰建議

- **不確定時，從 4000-8000 開始**。這個範圍對大多數「中等複雜」的問題足夠，而且不會太貴
- **數學和程式碼問題，給 8000-16000**。這些問題通常需要多次驗算
- **不要設太低**：我試過給 1024 做複雜問題，thinking 被切斷了，反而影響品質
- **測試再決定**：不同問題類型需要不同預算，最好用你的真實 use case 測試

## 回應格式：Thinking Blocks vs Text Blocks

啟用 Extended Thinking 後，`response.content` 會是一個混合陣列：

```python
response.content = [
    ThinkingBlock(
        type=&quot;thinking&quot;,
        thinking=&quot;讓我系統性地思考這個問題...\n\n首先，我需要確認...\n...&quot;,
    ),
    TextBlock(
        type=&quot;text&quot;,
        text=&quot;根據分析，答案是...&quot;
    )
]
```

Thinking blocks 的內容是 Claude 的**原始思考流**，包含：

- 自我問答（「等等，這裡我算錯了...」）
- 多種方案的比較
- 中間計算步驟
- 反思和修正

這些思考過程對你來說通常是有價值的透明度——你可以理解 Claude 是怎麼得出結論的。但在面向使用者的產品中，你可能不想把完整的思考過程顯示出來。

```python
def get_final_answer(response) -&gt; str:
    &quot;&quot;&quot;只取最終的文字回答，不包含思考過程&quot;&quot;&quot;
    return &quot; &quot;.join(
        block.text
        for block in response.content
        if block.type == &quot;text&quot;
    )

def get_thinking_and_answer(response) -&gt; tuple[str, str]:
    &quot;&quot;&quot;分別取出思考過程和最終答案&quot;&quot;&quot;
    thinking = &quot; &quot;.join(
        block.thinking
        for block in response.content
        if block.type == &quot;thinking&quot;
    )
    answer = &quot; &quot;.join(
        block.text
        for block in response.content
        if block.type == &quot;text&quot;
    )
    return thinking, answer
```

## Streaming + Extended Thinking

Streaming 和 Extended Thinking 可以同時使用，但有一點要注意：思考過程在串流時會以 `thinking_delta` 的形式出現：

```python
with client.messages.stream(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=16000,
    thinking={&quot;type&quot;: &quot;enabled&quot;, &quot;budget_tokens&quot;: 8000},
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;請分析...&quot;}]
) as stream:
    in_thinking = False

    for event in stream:
        if event.type == &quot;content_block_start&quot;:
            if event.content_block.type == &quot;thinking&quot;:
                in_thinking = True
                print(&quot;\n[Claude 開始思考...]\n&quot;, end=&quot;&quot;, flush=True)
            elif event.content_block.type == &quot;text&quot;:
                in_thinking = False
                print(&quot;\n[Claude 的回答：]\n&quot;, end=&quot;&quot;, flush=True)

        elif event.type == &quot;content_block_delta&quot;:
            if event.delta.type == &quot;thinking_delta&quot;:
                # 顯示思考過程（可選）
                print(event.delta.thinking, end=&quot;&quot;, flush=True)
            elif event.delta.type == &quot;text_delta&quot;:
                # 顯示最終回答
                print(event.delta.text, end=&quot;&quot;, flush=True)
```

在面向使用者的產品中，一個好的 UX 做法是：

1. 顯示「AI 正在思考中...」的動畫，同時接收 thinking blocks（不顯示詳細內容）
2. 當 text blocks 開始時，顯示「AI 開始回答：」並串流文字

這讓使用者知道系統沒有當機，同時不被原始思考過程的混亂內容搞混。

## 成本計算

這是很多人忽略的部分：**thinking tokens 是需要付費的**，而且算在 output tokens 裡。

假設你設定 `budget_tokens=8000`，Claude 用了 6000 tokens 思考，輸出了 500 tokens 的回答：

```
Input tokens:  1000 (你的 prompt)
Output tokens: 6500 (6000 thinking + 500 answer)

使用 claude-sonnet-4-6：
Input cost:  1000 / 1,000,000 * $3  = $0.003
Output cost: 6500 / 1,000,000 * $15 = $0.0975
Total: 約 $0.10（台幣約 3 元）
```

一次呼叫 $0.10 聽起來不多，但如果你的應用有很多使用者，每天跑幾千次，成本就很可觀了。

**成本優化建議：**

1. **只在真正需要的場景用 Extended Thinking**，不要無差別地開啟
2. **設定合理的 budget_tokens 上限**，不要給 32000 tokens 的預算去解一個 8000 tokens 就夠的問題
3. **監控實際使用的 thinking tokens**：`response.usage` 裡有 `cache_creation_input_tokens` 等欄位，你也可以從 content blocks 的長度估算

```python
# 監控 thinking 使用量
def analyze_thinking_usage(response):
    thinking_tokens = 0
    text_tokens = 0

    for block in response.content:
        if block.type == &quot;thinking&quot;:
            # 粗略估算：每個字符約 1 token
            thinking_tokens += len(block.thinking)
        elif block.type == &quot;text&quot;:
            text_tokens += len(block.text)

    print(f&quot;Thinking usage: ~{thinking_tokens} chars&quot;)
    print(f&quot;Answer length: ~{text_tokens} chars&quot;)
    print(f&quot;Total output tokens: {response.usage.output_tokens}&quot;)
```

## 實際效果對比

讓我用一個具體例子展示 with vs without Extended Thinking 的差異。

### 問題：找出以下程式碼的 Race Condition

```python
# 測試問題：這段 Python 程式碼在並發環境有什麼問題？
&quot;&quot;&quot;
class Counter:
    def __init__(self):
        self.count = 0

    def increment(self):
        current = self.count
        time.sleep(0.001)  # 模擬處理時間
        self.count = current + 1

# 在 10 個線程同時呼叫 increment()，最終 count 應該是 10
# 但實際上通常是 1-3
&quot;&quot;&quot;
```

**不使用 Extended Thinking 的回答（縮短版）：**

&gt; 這段程式碼有 race condition 問題。`increment()` 方法不是原子操作：讀取 `current`、等待、然後寫入 `self.count` 這三步之間可能被其他線程介入...

（回答是對的，但直接說出結論，沒有深度分析）

**使用 Extended Thinking 後（縮短的 thinking 過程）：**

&gt; _[Thinking 開始]_
&gt; 讓我仔細分析這個問題。
&gt;
&gt; 首先，有個 `time.sleep(0.001)`，這明顯是在模擬 I/O 操作或計算。在這個等待期間，GIL 會被釋放（因為 `time.sleep` 會讓出 GIL）...
&gt;
&gt; 等等，讓我想想 Python GIL 的行為。Python 的 GIL 確保一次只有一個線程執行 Python bytecode，但 `time.sleep` 會釋放 GIL，讓其他線程有機會執行。
&gt;
&gt; 所以時間線是：
&gt; 線程 A: current = 0
&gt; 線程 A: sleep（GIL 釋放）
&gt; 線程 B: current = 0（因為 count 還是 0）
&gt; 線程 B: sleep
&gt; 線程 A: self.count = 0 + 1 = 1
&gt; 線程 B: self.count = 0 + 1 = 1（覆蓋了！）
&gt;
&gt; 修復方案有幾種：
&gt;
&gt; 1. threading.Lock()
&gt; 2. threading.Event()
&gt; 3. 用 queue.Queue 做工作隊列
&gt; 4. 改用 multiprocessing
&gt;
&gt; 哪個最合適？需要考慮使用場景...
&gt; _[Thinking 結束]_

**最終回答（帶 thinking 的版本）：**

&gt; 這段程式碼存在一個典型的 Check-Then-Act（讀取-修改-寫回）Race Condition。根本原因是 `increment()` 方法不是原子操作，而 `time.sleep()` 會釋放 Python GIL，導致多個線程可能同時讀到舊的值，然後各自寫入，造成「更新丟失」...
&gt; （接著給出三種修復方案和各自的適用場景）

差異很明顯：帶 thinking 的回答不只是說「有問題」，而是展示了完整的推理路徑，並且主動考慮了多種解決方案的取捨。

## 完整的 Python 範例

```python
import anthropic
from typing import Optional

client = anthropic.Anthropic()


def analyze_with_thinking(
    question: str,
    budget_tokens: int = 8000,
    show_thinking: bool = False
) -&gt; dict:
    &quot;&quot;&quot;
    使用 Extended Thinking 分析複雜問題

    Args:
        question: 要分析的問題
        budget_tokens: 思考預算（tokens）
        show_thinking: 是否在回傳中包含思考過程

    Returns:
        包含 answer、thinking（可選）、usage 的字典
    &quot;&quot;&quot;
    response = client.messages.create(
        model=&quot;claude-sonnet-4-6&quot;,
        max_tokens=budget_tokens + 4096,  # 思考 + 回答的總預算
        thinking={
            &quot;type&quot;: &quot;enabled&quot;,
            &quot;budget_tokens&quot;: budget_tokens
        },
        messages=[
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: question}
        ]
    )

    result = {
        &quot;answer&quot;: &quot;&quot;,
        &quot;usage&quot;: {
            &quot;input_tokens&quot;: response.usage.input_tokens,
            &quot;output_tokens&quot;: response.usage.output_tokens,
        }
    }

    thinking_parts = []
    answer_parts = []

    for block in response.content:
        if block.type == &quot;thinking&quot;:
            thinking_parts.append(block.thinking)
        elif block.type == &quot;text&quot;:
            answer_parts.append(block.text)

    result[&quot;answer&quot;] = &quot;\n&quot;.join(answer_parts)

    if show_thinking:
        result[&quot;thinking&quot;] = &quot;\n&quot;.join(thinking_parts)

    return result


def compare_with_without_thinking(question: str) -&gt; None:
    &quot;&quot;&quot;比較有無 Extended Thinking 的差異&quot;&quot;&quot;
    print(f&quot;問題：{question}\n&quot;)

    # 不使用 thinking
    print(&quot;=== 不使用 Extended Thinking ===&quot;)
    standard_response = client.messages.create(
        model=&quot;claude-sonnet-4-6&quot;,
        max_tokens=2048,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: question}]
    )
    standard_answer = standard_response.content[0].text
    print(f&quot;回答：{standard_answer[:300]}...&quot;)
    print(f&quot;Output tokens: {standard_response.usage.output_tokens}\n&quot;)

    # 使用 thinking
    print(&quot;=== 使用 Extended Thinking (budget: 6000) ===&quot;)
    thinking_result = analyze_with_thinking(
        question,
        budget_tokens=6000,
        show_thinking=True
    )
    print(f&quot;思考過程（前 200 字）：{thinking_result.get(&apos;thinking&apos;, &apos;&apos;)[:200]}...&quot;)
    print(f&quot;回答：{thinking_result[&apos;answer&apos;][:300]}...&quot;)
    print(f&quot;Output tokens: {thinking_result[&apos;usage&apos;][&apos;output_tokens&apos;]}&quot;)


# 實際使用範例
if __name__ == &quot;__main__&quot;:
    # 範例 1：數學推理
    result = analyze_with_thinking(
        &quot;&quot;&quot;
        一個公司有 3 個部門：A、B、C。
        - 部門 A 有 20 人，平均薪資 60000 元
        - 部門 B 有 15 人，平均薪資 80000 元
        - 部門 C 有 10 人，平均薪資 100000 元

        請計算：
        1. 整個公司的平均薪資
        2. 如果公司要全員加薪 10%，月薪資總支出增加多少？
        3. 如果只有薪資低於公司平均的員工加薪，使他們達到公司平均水準，
           月薪資總支出增加多少？
        &quot;&quot;&quot;,
        budget_tokens=4000
    )
    print(&quot;薪資計算結果：&quot;)
    print(result[&quot;answer&quot;])
    print(f&quot;\n使用的 tokens - Input: {result[&apos;usage&apos;][&apos;input_tokens&apos;]}, Output: {result[&apos;usage&apos;][&apos;output_tokens&apos;]}&quot;)
```

## TypeScript 完整範例

```typescript
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const client = new Anthropic();

interface ThinkingResult {
  thinking?: string;
  answer: string;
  inputTokens: number;
  outputTokens: number;
}

async function analyzeWithThinking(
  question: string,
  budgetTokens: number = 8000,
  includeThinking: boolean = false
): Promise&lt;ThinkingResult&gt; {
  const response = await client.messages.create({
    model: &apos;claude-sonnet-4-6&apos;,
    max_tokens: budgetTokens + 4096,
    thinking: {
      type: &apos;enabled&apos;,
      budget_tokens: budgetTokens,
    },
    messages: [{ role: &apos;user&apos;, content: question }],
  });

  const thinkingParts: string[] = [];
  const answerParts: string[] = [];

  for (const block of response.content) {
    if (block.type === &apos;thinking&apos;) {
      thinkingParts.push(block.thinking);
    } else if (block.type === &apos;text&apos;) {
      answerParts.push(block.text);
    }
  }

  return {
    thinking: includeThinking ? thinkingParts.join(&apos;\n&apos;) : undefined,
    answer: answerParts.join(&apos;\n&apos;),
    inputTokens: response.usage.input_tokens,
    outputTokens: response.usage.output_tokens,
  };
}

// 使用範例
async function main() {
  const result = await analyzeWithThinking(
    `請分析以下技術決策：
    我們正在建立一個需要處理每秒 10,000 個請求的 API 服務。
    現在需要在以下三個方案中選擇一個：
    1. Node.js + Express + PostgreSQL
    2. Go + Gin + PostgreSQL
    3. Python + FastAPI + PostgreSQL

    考慮因素：團隊主要是 TypeScript 開發者，但有 3 位有 Python 經驗，
    無人有 Go 經驗。預算允許 6 個月的開發時間。`,
    12000, // 這個問題需要較多思考
    true // 顯示思考過程
  );

  if (result.thinking) {
    console.log(&apos;思考過程：&apos;);
    console.log(result.thinking.substring(0, 500) + &apos;...\n&apos;);
  }

  console.log(&apos;最終建議：&apos;);
  console.log(result.answer);
  console.log(`\nToken 使用：Input ${result.inputTokens}, Output ${result.outputTokens}`);
}

main();
```

## 生產環境使用注意事項

**1. 快取策略**：Extended Thinking 的輸出（thinking blocks）**不能快取**，但你可以快取包含 thinking 的對話歷史，讓後續輪次的 Claude 看到之前的思考過程。

**2. 多輪對話中的 Thinking**：如果你要把包含 thinking blocks 的回應放進對話歷史，需要把完整的 `response.content`（包含 thinking blocks）加入 messages，而不只是 text blocks。

```python
# 正確：保留 thinking blocks 在對話歷史
messages.append({
    &quot;role&quot;: &quot;assistant&quot;,
    &quot;content&quot;: response.content  # 包含 thinking blocks
})

# 錯誤：只保留文字，下一輪 Claude 會失去上下文
messages.append({
    &quot;role&quot;: &quot;assistant&quot;,
    &quot;content&quot;: final_text_only  # 這樣會失去 thinking blocks
})
```

**3. 目前的限制**：截至本書寫作時，Extended Thinking 不支援以下功能的組合使用：

- Extended Thinking + streaming 的某些使用模式（確認最新文件）
- 極長的 budget_tokens 需要對應更大的 max_tokens

永遠查閱 [Anthropic 官方文件](https://docs.anthropic.com) 確認最新的支援狀況，因為這個功能還在積極發展中。

## 小結

Extended Thinking 是 Claude API 裡最有差異化的功能之一。它不是讓所有事情都變得更好，而是讓特定一類問題的回答品質大幅提升：那些需要仔細推理、多步驟計算、或者評估多種可能性的困難問題。

我的建議是：**建立一個問題難度分類器**，自動決定是否啟用 Extended Thinking 以及給多少 budget_tokens。對於明顯簡單的問題跳過 thinking，對於困難問題才開啟，這樣你可以在品質和成本之間找到最佳平衡。

---

這五章涵蓋了 Claude API 的核心基礎：從第一個 API 呼叫，到多輪對話管理，到 Streaming 的即時體驗，到 Tool Use 的 Agent 能力，最後到 Extended Thinking 的深度推理。

接下來的章節，我們會繼續往上構建：Prompt Caching（節省費用）、Multimodal（圖片輸入）、Embeddings、以及最終的 Agent SDK——把這些能力組合起來，建立真正複雜的 AI 系統。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BgPSxRAl.webp" medium="image"/><category>Claude API</category><category>Extended Thinking</category><category>推理</category><category>thinking</category><enclosure url="https://bobochen.dev/_astro/cover.BgPSxRAl.webp" length="0" type="image/png"/></item><item><title>Tool Use：讓 AI 成為你應用的大腦</title><link>https://bobochen.dev/blog/claude-api-guide-tool-use/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-tool-use/</guid><description>完整的 Claude Tool Use（Function Calling）指南：tool 定義格式、如何設計好的 tool description、parallel tool use、tool_choice 參數、structured output，以及完整的 Python 執行循環範例。</description><pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate><content:encoded>前三章，Claude 一直是個「說話的機器」。

你問它問題，它用文字回答。功能強大，但有一個根本的限制：**Claude 只知道訓練資料裡有的東西**。它不知道今天的股價，不知道你的資料庫裡有什麼，不知道你的訂單系統的狀態。

Tool Use（Anthropic 的官方名稱，其他 LLM 廠商通常叫 Function Calling）改變了這一切。

有了 Tool Use，Claude 可以「請求」呼叫你定義的函數，取得即時的外部資訊，然後根據這些資訊給出有意義的回答。更重要的是，Claude 可以決定**什麼時候呼叫工具**，以及**如何解讀工具的結果**。

這讓 Claude 從「問答機器」升級為「AI Agent 的大腦」。

## 核心概念：Tool Use 的流程

先理解整個流程，再看細節：

```
你 → [請求 + 工具定義] → Claude
Claude → [決定呼叫哪個工具，帶什麼參數] → 你
你 → [執行工具，取得結果] → Claude
Claude → [根據工具結果，給出最終回答] → 你
```

關鍵在第二步：**Claude 不會幫你執行工具**。它只是告訴你「我想呼叫 X 工具，參數是 Y」。你負責實際執行，把結果傳回去。這個設計讓你完全掌控安全性和執行邏輯。

整個流程可能要好幾輪 API 呼叫（呼叫工具 → 回傳結果 → 再呼叫工具 → ...），直到 Claude 認為它有足夠資訊給出最終答案。

## 定義工具

工具的定義格式如下：

```python
tools = [
    {
        &quot;name&quot;: &quot;get_weather&quot;,
        &quot;description&quot;: &quot;取得指定城市目前的天氣狀況。當使用者詢問天氣或計劃需要考慮天氣的活動時使用。不要用於歷史天氣資料或天氣預報超過 7 天的查詢。&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;city&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;城市名稱，例如 &apos;台北&apos;, &apos;Tokyo&apos;, &apos;New York&apos;&quot;
                },
                &quot;unit&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;enum&quot;: [&quot;celsius&quot;, &quot;fahrenheit&quot;],
                    &quot;description&quot;: &quot;溫度單位，預設使用 celsius&quot;
                }
            },
            &quot;required&quot;: [&quot;city&quot;]
        }
    }
]
```

每個工具有三個必填欄位：

- **`name`**：工具的唯一識別名稱，只能包含字母、數字和底線
- **`description`**：告訴 Claude 這個工具是什麼、什麼時候應該用（非常重要！）
- **`input_schema`**：JSON Schema 格式的參數定義

### 如何設計好的 Tool Description

這是 Tool Use 最容易被忽略、但最關鍵的部分。

我見過太多開發者把 description 寫成「This tool gets weather data」然後抱怨 Claude 呼叫工具的時機不準確。

**一個好的 tool description 應該告訴 Claude：**

1. **這個工具做什麼**（簡短說明）
2. **什麼時候應該用**（觸發條件）
3. **什麼時候不應該用**（避免誤用）
4. **輸入的限制**（城市名稱要用哪種格式？日期要怎麼傳？）
5. **工具的局限性**（只有台灣的資料？只能查今天？）

差的 description：

```
&quot;description&quot;: &quot;Gets weather information.&quot;
```

好的 description：

```
&quot;description&quot;: &quot;取得指定城市的即時天氣資訊，包含溫度、濕度和天氣狀況。
當使用者詢問某地的當前天氣、或需要根據天氣做決策時使用。
注意：只支援當前天氣，不支援天氣預報或歷史天氣。
城市名稱請用常見的英文或中文名稱，例如 &apos;Taipei&apos; 或 &apos;台北&apos;。&quot;
```

同樣的道理也適用於每個 property 的 `description`。Claude 是用 description 來理解要傳什麼值，而不是靠變數名稱。

## 完整的 Python 執行循環

這是 Tool Use 最重要的部分：你需要實作一個「工具執行循環」，讓 Claude 可以多次呼叫工具。

```python
import anthropic
import json

client = anthropic.Anthropic()

# 定義工具的具體實作
def get_weather(city: str, unit: str = &quot;celsius&quot;) -&gt; dict:
    &quot;&quot;&quot;模擬天氣 API（實際應用中這裡會呼叫真正的 API）&quot;&quot;&quot;
    weather_data = {
        &quot;台北&quot;: {&quot;temp&quot;: 28, &quot;humidity&quot;: 78, &quot;condition&quot;: &quot;多雲&quot;},
        &quot;Tokyo&quot;: {&quot;temp&quot;: 22, &quot;humidity&quot;: 65, &quot;condition&quot;: &quot;晴天&quot;},
        &quot;New York&quot;: {&quot;temp&quot;: 15, &quot;humidity&quot;: 55, &quot;condition&quot;: &quot;陰天&quot;},
    }
    data = weather_data.get(city, {&quot;temp&quot;: 20, &quot;humidity&quot;: 60, &quot;condition&quot;: &quot;未知&quot;})
    temp = data[&quot;temp&quot;] if unit == &quot;celsius&quot; else data[&quot;temp&quot;] * 9/5 + 32
    return {
        &quot;city&quot;: city,
        &quot;temperature&quot;: f&quot;{temp}°{&apos;C&apos; if unit == &apos;celsius&apos; else &apos;F&apos;}&quot;,
        &quot;humidity&quot;: f&quot;{data[&apos;humidity&apos;]}%&quot;,
        &quot;condition&quot;: data[&quot;condition&quot;]
    }

def search_database(query: str, limit: int = 5) -&gt; list:
    &quot;&quot;&quot;模擬資料庫查詢&quot;&quot;&quot;
    # 實際應用中這裡會查詢真實的資料庫
    return [{&quot;id&quot;: i, &quot;title&quot;: f&quot;結果 {i}: {query}&quot;, &quot;score&quot;: 1.0 - i * 0.1}
            for i in range(1, min(limit + 1, 6))]

# 工具映射：名稱 → 函數
TOOLS = {
    &quot;get_weather&quot;: get_weather,
    &quot;search_database&quot;: search_database,
}

# 工具定義
TOOL_DEFINITIONS = [
    {
        &quot;name&quot;: &quot;get_weather&quot;,
        &quot;description&quot;: &quot;取得指定城市目前的天氣。當使用者詢問天氣時使用。&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;city&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;城市名稱&quot;},
                &quot;unit&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;enum&quot;: [&quot;celsius&quot;, &quot;fahrenheit&quot;],
                    &quot;description&quot;: &quot;溫度單位&quot;
                }
            },
            &quot;required&quot;: [&quot;city&quot;]
        }
    },
    {
        &quot;name&quot;: &quot;search_database&quot;,
        &quot;description&quot;: &quot;在知識庫中搜尋相關文章和資訊。當需要查找特定主題的資料時使用。&quot;,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;query&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;搜尋關鍵字&quot;},
                &quot;limit&quot;: {
                    &quot;type&quot;: &quot;integer&quot;,
                    &quot;description&quot;: &quot;最多回傳幾筆結果，預設 5&quot;,
                    &quot;default&quot;: 5
                }
            },
            &quot;required&quot;: [&quot;query&quot;]
        }
    }
]


def run_agent(user_message: str) -&gt; str:
    &quot;&quot;&quot;執行一個完整的 agent 循環&quot;&quot;&quot;
    messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_message}]

    while True:
        # 呼叫 Claude
        response = client.messages.create(
            model=&quot;claude-sonnet-4-6&quot;,
            max_tokens=4096,
            tools=TOOL_DEFINITIONS,
            messages=messages
        )

        # 把 Claude 的回應加入對話歷史
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: response.content})

        # 檢查停止原因
        if response.stop_reason == &quot;end_turn&quot;:
            # Claude 認為任務完成，取出文字回應
            for block in response.content:
                if block.type == &quot;text&quot;:
                    return block.text
            return &quot;&quot;

        elif response.stop_reason == &quot;tool_use&quot;:
            # Claude 要呼叫工具
            tool_results = []

            for block in response.content:
                if block.type == &quot;tool_use&quot;:
                    tool_name = block.name
                    tool_input = block.input
                    tool_use_id = block.id

                    print(f&quot;  [工具呼叫] {tool_name}({tool_input})&quot;)

                    # 執行工具
                    try:
                        tool_fn = TOOLS[tool_name]
                        result = tool_fn(**tool_input)
                        tool_results.append({
                            &quot;type&quot;: &quot;tool_result&quot;,
                            &quot;tool_use_id&quot;: tool_use_id,
                            &quot;content&quot;: json.dumps(result, ensure_ascii=False)
                        })
                    except Exception as e:
                        # 工具執行失敗，傳回錯誤資訊
                        tool_results.append({
                            &quot;type&quot;: &quot;tool_result&quot;,
                            &quot;tool_use_id&quot;: tool_use_id,
                            &quot;content&quot;: f&quot;工具執行失敗：{str(e)}&quot;,
                            &quot;is_error&quot;: True
                        })

            # 把工具結果加入對話歷史
            messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: tool_results})

        else:
            # 其他停止原因（max_tokens 等）
            break

    return &quot;Agent 執行未正常完成&quot;


# 使用範例
if __name__ == &quot;__main__&quot;:
    print(&quot;問題：台北現在的天氣如何？適合出門騎腳踏車嗎？&quot;)
    result = run_agent(&quot;台北現在的天氣如何？適合出門騎腳踏車嗎？&quot;)
    print(f&quot;\nClaude 的回答：{result}&quot;)

    print(&quot;\n&quot; + &quot;=&quot;*50 + &quot;\n&quot;)

    print(&quot;問題：幫我查一下關於 Astro.js 的資料，然後告訴我台北的天氣&quot;)
    result = run_agent(&quot;幫我查一下關於 Astro.js 的資料，然後告訴我台北的天氣&quot;)
    print(f&quot;\nClaude 的回答：{result}&quot;)
```

## Parallel Tool Use（同時呼叫多個工具）

當 Claude 判斷需要呼叫多個不相關的工具時，它可以在同一個回應裡要求同時呼叫它們，而不是一個一個等待。

```python
# Claude 可能回傳這樣的 content（包含多個 tool_use blocks）
response.content = [
    ToolUseBlock(id=&quot;tu_001&quot;, type=&quot;tool_use&quot;, name=&quot;get_weather&quot;,
                 input={&quot;city&quot;: &quot;台北&quot;}),
    ToolUseBlock(id=&quot;tu_002&quot;, type=&quot;tool_use&quot;, name=&quot;get_weather&quot;,
                 input={&quot;city&quot;: &quot;Tokyo&quot;}),
    ToolUseBlock(id=&quot;tu_003&quot;, type=&quot;tool_use&quot;, name=&quot;search_database&quot;,
                 input={&quot;query&quot;: &quot;最佳旅遊季節&quot;})
]
```

你的工具執行循環應該能處理這種情況，並且**真正平行地執行**這些工具（如果可能的話）：

```python
import asyncio

async def execute_tools_parallel(tool_blocks: list) -&gt; list:
    &quot;&quot;&quot;平行執行多個工具&quot;&quot;&quot;
    async def execute_one(block):
        tool_fn = TOOLS[block.name]
        # 如果工具是 async 的就 await，否則用 run_in_executor
        if asyncio.iscoroutinefunction(tool_fn):
            result = await tool_fn(**block.input)
        else:
            loop = asyncio.get_event_loop()
            result = await loop.run_in_executor(None, lambda: tool_fn(**block.input))
        return {
            &quot;type&quot;: &quot;tool_result&quot;,
            &quot;tool_use_id&quot;: block.id,
            &quot;content&quot;: json.dumps(result, ensure_ascii=False)
        }

    return await asyncio.gather(*[execute_one(b) for b in tool_blocks])
```

## tool_choice 參數

`tool_choice` 讓你控制 Claude 是否必須呼叫工具：

```python
# 預設：Claude 自己決定要不要用工具
tool_choice = {&quot;type&quot;: &quot;auto&quot;}

# 強制 Claude 一定要呼叫某個工具
tool_choice = {&quot;type&quot;: &quot;tool&quot;, &quot;name&quot;: &quot;get_weather&quot;}

# 強制 Claude 一定要呼叫（任何）工具
tool_choice = {&quot;type&quot;: &quot;any&quot;}
```

**何時使用 `type: &quot;any&quot;` 或 `type: &quot;tool&quot;`？**

當你的應用流程要求 Claude 一定要呼叫工具時。例如：你建了一個「結構化資料提取器」，每次呼叫都必須回傳 JSON 格式的結構化資料，你就可以定義一個 `extract_data` 工具並設定 `tool_choice: {type: &quot;tool&quot;, name: &quot;extract_data&quot;}`。

這是實現**結構化輸出（Structured Output）**的最可靠方法——比在 system prompt 裡叫 Claude「請用 JSON 回應」可靠得多。

## 用 Tool Use 實現 Structured Output

這是我在生產環境最常用的技巧之一。

假設你要提取使用者訊息裡的聯絡資訊：

```python
# 定義一個「假工具」，實際上是用來強制結構化輸出
extract_contact_tool = {
    &quot;name&quot;: &quot;save_contact&quot;,
    &quot;description&quot;: &quot;儲存提取到的聯絡人資訊&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
            &quot;name&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;姓名&quot;},
            &quot;email&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;電子郵件&quot;},
            &quot;phone&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;電話號碼&quot;},
            &quot;company&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;公司名稱&quot;},
        },
        &quot;required&quot;: [&quot;name&quot;]
    }
}

def extract_contact_info(text: str) -&gt; dict:
    response = client.messages.create(
        model=&quot;claude-sonnet-4-6&quot;,
        max_tokens=1024,
        tools=[extract_contact_tool],
        tool_choice={&quot;type&quot;: &quot;tool&quot;, &quot;name&quot;: &quot;save_contact&quot;},  # 強制呼叫
        messages=[{
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;請從以下文字中提取聯絡資訊：\n\n{text}&quot;
        }]
    )

    # 取得 tool_use block 的 input（就是我們要的結構化資料）
    for block in response.content:
        if block.type == &quot;tool_use&quot; and block.name == &quot;save_contact&quot;:
            return block.input  # 這是一個 dict，型別已驗證

    raise ValueError(&quot;未能提取聯絡資訊&quot;)

# 使用
contact = extract_contact_info(
    &quot;嗨，我是王小明，可以聯絡我：ming@example.com 或 0912-345-678，我在 Acme 公司工作。&quot;
)
print(contact)
# {&apos;name&apos;: &apos;王小明&apos;, &apos;email&apos;: &apos;ming@example.com&apos;, &apos;phone&apos;: &apos;0912-345-678&apos;, &apos;company&apos;: &apos;Acme&apos;}
```

這個方法的優點：

- 回應格式**保證符合你的 schema**（Anthropic 驗證過）
- 比解析自由格式文字可靠
- 比在 prompt 裡要求 JSON 更穩定（沒有 markdown code block 的問題）

## 常見 Tool Use 場景

### 資料庫查詢

```python
{
    &quot;name&quot;: &quot;query_orders&quot;,
    &quot;description&quot;: &quot;查詢訂單資訊。當使用者詢問自己的訂單狀態、出貨進度時使用。只能查詢已登入使用者自己的訂單。&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
            &quot;order_id&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;訂單編號（格式：ORD-XXXXXX）&quot;},
            &quot;status_filter&quot;: {
                &quot;type&quot;: &quot;string&quot;,
                &quot;enum&quot;: [&quot;all&quot;, &quot;pending&quot;, &quot;shipped&quot;, &quot;delivered&quot;, &quot;cancelled&quot;],
                &quot;description&quot;: &quot;篩選訂單狀態&quot;
            }
        }
    }
}
```

### 外部 API 呼叫

```python
{
    &quot;name&quot;: &quot;search_products&quot;,
    &quot;description&quot;: &quot;搜尋商品目錄。當使用者想找特定商品時使用。支援關鍵字搜尋和分類篩選。&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
            &quot;query&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;搜尋關鍵字&quot;},
            &quot;category&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;商品分類&quot;},
            &quot;max_price&quot;: {&quot;type&quot;: &quot;number&quot;, &quot;description&quot;: &quot;最高價格（台幣）&quot;},
            &quot;in_stock_only&quot;: {&quot;type&quot;: &quot;boolean&quot;, &quot;description&quot;: &quot;是否只顯示有庫存的商品&quot;}
        },
        &quot;required&quot;: [&quot;query&quot;]
    }
}
```

### 計算工具

```python
{
    &quot;name&quot;: &quot;calculate&quot;,
    &quot;description&quot;: &quot;執行數學計算。當使用者詢問需要精確計算的數學問題時使用，例如利率計算、折扣計算、單位換算等。不要用於簡單的心算問題。&quot;,
    &quot;input_schema&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
            &quot;expression&quot;: {
                &quot;type&quot;: &quot;string&quot;,
                &quot;description&quot;: &quot;數學表達式，例如 &apos;(100000 * 0.03) / 12&apos; 或 &apos;25 * 4 + 10&apos;&quot;
            }
        },
        &quot;required&quot;: [&quot;expression&quot;]
    }
}
```

## tool_result 的錯誤處理

當工具執行失敗時，你應該傳回一個帶有 `is_error: true` 的 tool_result：

```python
# 正常結果
{
    &quot;type&quot;: &quot;tool_result&quot;,
    &quot;tool_use_id&quot;: &quot;tu_001&quot;,
    &quot;content&quot;: json.dumps({&quot;weather&quot;: &quot;晴天&quot;, &quot;temp&quot;: &quot;28°C&quot;})
}

# 錯誤結果
{
    &quot;type&quot;: &quot;tool_result&quot;,
    &quot;tool_use_id&quot;: &quot;tu_001&quot;,
    &quot;content&quot;: &quot;查詢失敗：城市 &apos;Xanadu&apos; 不在支援的城市列表中&quot;,
    &quot;is_error&quot;: True
}
```

Claude 看到 `is_error: true` 後，通常會這樣回應使用者：「很抱歉，我嘗試查詢 Xanadu 的天氣，但系統回報該城市不在支援範圍內。請問您是指其他城市嗎？」

這比直接讓工具例外（exception）崩潰整個 agent 循環優雅得多。

## Token 成本計算

Tool Use 有一些額外的 token 成本，需要注意：

1. **工具定義本身消耗 input tokens**：你的 tool definition JSON 越詳細，消耗越多。每次 API 呼叫都要傳工具定義，所以這個成本是固定的
2. **tool_use block 消耗 output tokens**：Claude 生成的工具呼叫請求（包含工具名稱和參數）算在 output tokens
3. **tool_result 消耗 input tokens**：你傳回的工具結果算在下一輪的 input tokens

實際測試中，一個包含 3 個工具定義的請求，光是工具定義就大約消耗 300-500 input tokens。如果你有很多工具（10+），工具定義的 token 成本可能相當可觀。

**優化策略：**

- 只在這個對話確實需要某個工具時才傳入該工具定義
- 保持工具定義的 description 簡潔但充分（不要超長）
- 對於不太常用的工具，考慮動態載入而不是每次都傳

## 下一步

Tool Use 讓你的 AI 應用可以存取外部世界的資訊，解決了「Claude 只知道訓練資料」的根本限制。搭配工具執行循環，你可以建立真正能做事的 AI Agent。

但有時候，使用者提出的問題非常複雜——需要多步驟推理、涉及數學計算、或者需要仔細分析各種可能性。預設的 Claude 很聰明，但它的「思考」是在生成回應的同時進行的，沒有機會「想清楚再說」。

**下一章**，我們來看 Extended Thinking——讓 Claude 在回答之前先花時間深度思考，解決那些最困難的推理任務。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Bxdj-NV8.webp" medium="image"/><category>Claude API</category><category>Tool Use</category><category>Function Calling</category><category>開發工具</category><enclosure url="https://bobochen.dev/_astro/cover.Bxdj-NV8.webp" length="0" type="image/png"/></item><item><title>Streaming：打造即時回應的用戶體驗</title><link>https://bobochen.dev/blog/claude-api-guide-streaming/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-streaming/</guid><description>深入理解 Claude API Streaming 的 SSE 事件類型、Python 與 TypeScript 實作、在 Next.js/Express 建立 streaming endpoint，以及 streaming 搭配 Tool Use 的特殊處理。</description><pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate><content:encoded>我要先說一個反直覺的事實：**使用 streaming 並不會讓 Claude 回答得更快**。

Claude 生成每個 token 的速度是一樣的，不管你用不用 streaming。

那為什麼 streaming 很重要？

因為**感知等待時間（perceived latency）和實際等待時間是兩件事**。

沒有 streaming：使用者看著空白螢幕等了 5 秒，然後一大段文字突然全部出現。他的感受是「等了 5 秒」。

有了 streaming：使用者在你按下送出後 200 毫秒就看到第一個字，然後文字一個接一個地流出來。即使整體等待的總秒數一樣，他的感受是「回應很快」。

這就是為什麼所有主流的 AI 產品（ChatGPT、Claude.ai、Gemini）都預設使用 streaming。如果你在建一個使用者會直接互動的 AI 應用，streaming 幾乎是必要的。

## SSE（Server-Sent Events）原理

在深入 Claude 的 streaming 實作之前，先快速了解底層的技術：SSE。

HTTP 請求通常是「請求-回應」的模式：客戶端發送請求，伺服器處理完，送回完整的回應，連線結束。

SSE 是一個例外：客戶端發送請求後，伺服器保持連線開放，**持續地推送資料**，直到它主動關閉連線。每個推送的資料片段格式如下：

```
data: {&quot;type&quot;:&quot;content_block_delta&quot;,&quot;delta&quot;:{&quot;type&quot;:&quot;text_delta&quot;,&quot;text&quot;:&quot;你好&quot;}}

data: {&quot;type&quot;:&quot;content_block_delta&quot;,&quot;delta&quot;:{&quot;type&quot;:&quot;text_delta&quot;,&quot;text&quot;:&quot;！&quot;}}

data: [DONE]
```

每個 `data: ` 行是一個事件，事件之間用空行分隔，`[DONE]` 表示串流結束。

Claude API 的 streaming 就是建立在 SSE 上的。

## Claude Streaming 的事件類型

Claude 的 streaming 不只是「把文字一個字一個字傳過來」，它有一套完整的事件體系，讓你知道現在正在發生什麼。

理解這些事件類型對於正確處理 streaming 回應非常重要：

```
message_start          → 串流開始，包含 message ID 和初始 usage 資訊
content_block_start    → 一個新的 content block 開始（例如開始產生文字）
content_block_delta    → content block 的一段增量（你的文字就在這裡）
content_block_stop     → 一個 content block 結束
message_delta          → message 層級的更新（包含 stop_reason 和最終 usage）
message_stop           → 整個 message 串流結束
```

完整的事件流大致是這樣的：

```json
{&quot;type&quot;: &quot;message_start&quot;, &quot;message&quot;: {&quot;id&quot;: &quot;msg_01...&quot;, &quot;type&quot;: &quot;message&quot;, &quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: [], &quot;model&quot;: &quot;claude-sonnet-4-6-20251101&quot;, &quot;usage&quot;: {&quot;input_tokens&quot;: 25, &quot;output_tokens&quot;: 1}}}

{&quot;type&quot;: &quot;content_block_start&quot;, &quot;index&quot;: 0, &quot;content_block&quot;: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;&quot;}}

{&quot;type&quot;: &quot;content_block_delta&quot;, &quot;index&quot;: 0, &quot;delta&quot;: {&quot;type&quot;: &quot;text_delta&quot;, &quot;text&quot;: &quot;你好&quot;}}
{&quot;type&quot;: &quot;content_block_delta&quot;, &quot;index&quot;: 0, &quot;delta&quot;: {&quot;type&quot;: &quot;text_delta&quot;, &quot;text&quot;: &quot;！我是&quot;}}
{&quot;type&quot;: &quot;content_block_delta&quot;, &quot;index&quot;: 0, &quot;delta&quot;: {&quot;type&quot;: &quot;text_delta&quot;, &quot;text&quot;: &quot; Claude&quot;}}

{&quot;type&quot;: &quot;content_block_stop&quot;, &quot;index&quot;: 0}

{&quot;type&quot;: &quot;message_delta&quot;, &quot;delta&quot;: {&quot;stop_reason&quot;: &quot;end_turn&quot;, &quot;stop_sequence&quot;: null}, &quot;usage&quot;: {&quot;output_tokens&quot;: 42}}

{&quot;type&quot;: &quot;message_stop&quot;}
```

在大多數情況下，你只需要關心 `content_block_delta` 事件裡的 `delta.text`——那就是要顯示給使用者的文字。但 `message_delta` 裡的 `stop_reason` 也很重要，你需要知道為什麼 Claude 停止了。

## Python SDK Streaming 實作

Python SDK 提供了非常優雅的 streaming 介面，使用 context manager 語法：

```python
import anthropic

client = anthropic.Anthropic()

# 使用 stream() context manager
with client.messages.stream(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    messages=[
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;請寫一首關於台灣的短詩，大約 100 字。&quot;}
    ]
) as stream:
    # 最簡單的方式：直接迭代文字
    for text in stream.text_stream:
        print(text, end=&quot;&quot;, flush=True)

print()  # 換行

# 串流結束後，你可以取得完整的 message 物件
final_message = stream.get_final_message()
print(f&quot;\nStop reason: {final_message.stop_reason}&quot;)
print(f&quot;Total tokens: {final_message.usage.input_tokens + final_message.usage.output_tokens}&quot;)
```

`stream.text_stream` 是一個 generator，每次 yield 一個文字片段。`flush=True` 確保每個片段立即輸出到 terminal，而不是等緩衝區滿了再印出。

如果你需要更細緻的控制，可以迭代原始的事件流：

```python
with client.messages.stream(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;解釋一下量子纏繞&quot;}]
) as stream:
    for event in stream:
        if event.type == &quot;content_block_delta&quot;:
            if event.delta.type == &quot;text_delta&quot;:
                # 即時處理每個文字片段
                process_text_chunk(event.delta.text)
        elif event.type == &quot;message_delta&quot;:
            # 串流結束，取得最終狀態
            print(f&quot;Done! Stop reason: {event.delta.stop_reason}&quot;)
```

### 累積完整回應

有時候你需要同時串流給使用者，同時保存完整的回應文字（例如要存進資料庫）：

```python
full_response = []

with client.messages.stream(...) as stream:
    for text in stream.text_stream:
        full_response.append(text)
        yield text  # 串流給前端

complete_text = &quot;&quot;.join(full_response)
save_to_db(complete_text)
```

## TypeScript / Node.js Streaming

TypeScript SDK 的 streaming API 設計得很對稱：

```typescript
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const client = new Anthropic();

async function streamResponse() {
  const stream = client.messages.stream({
    model: &apos;claude-sonnet-4-6&apos;,
    max_tokens: 1024,
    messages: [
      {
        role: &apos;user&apos;,
        content: &apos;請寫一首關於台灣的短詩，大約 100 字。&apos;,
      },
    ],
  });

  // 方法一：監聽 text 事件
  stream.on(&apos;text&apos;, (text) =&gt; {
    process.stdout.write(text);
  });

  // 等待串流完成
  const finalMessage = await stream.finalMessage();
  console.log(&apos;\nStop reason:&apos;, finalMessage.stop_reason);
  console.log(&apos;Total tokens:&apos;, finalMessage.usage.input_tokens + finalMessage.usage.output_tokens);
}

streamResponse();
```

或者使用 async iterator 風格：

```typescript
async function streamWithAsyncIterator() {
  const stream = await client.messages.create({
    model: &apos;claude-sonnet-4-6&apos;,
    max_tokens: 1024,
    stream: true,  // 關鍵：設定 stream: true
    messages: [
      { role: &apos;user&apos;, content: &apos;你好！&apos; },
    ],
  });

  for await (const event of stream) {
    if (event.type === &apos;content_block_delta&apos; &amp;&amp; event.delta.type === &apos;text_delta&apos;) {
      process.stdout.write(event.delta.text);
    }
  }
}
```

## 在 Express.js 中實作 Streaming Endpoint

把 streaming 整合進你的後端 API，讓前端可以接收：

```typescript
import express from &apos;express&apos;;
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const app = express();
app.use(express.json());

const client = new Anthropic();

app.post(&apos;/api/chat/stream&apos;, async (req, res) =&gt; {
  const { messages, systemPrompt } = req.body;

  // 設定 SSE 必要的 headers
  res.setHeader(&apos;Content-Type&apos;, &apos;text/event-stream&apos;);
  res.setHeader(&apos;Cache-Control&apos;, &apos;no-cache&apos;);
  res.setHeader(&apos;Connection&apos;, &apos;keep-alive&apos;);
  res.setHeader(&apos;X-Accel-Buffering&apos;, &apos;no&apos;);  // 給 Nginx 用的，禁用緩衝

  try {
    const stream = client.messages.stream({
      model: &apos;claude-sonnet-4-6&apos;,
      max_tokens: 2048,
      system: systemPrompt,
      messages,
    });

    // 把每個文字片段轉換成 SSE 格式傳給前端
    stream.on(&apos;text&apos;, (text) =&gt; {
      // SSE 格式：data: {...}\n\n
      res.write(`data: ${JSON.stringify({ type: &apos;text&apos;, text })}\n\n`);
    });

    // 等待串流完成
    const finalMessage = await stream.finalMessage();

    // 傳送結束事件
    res.write(`data: ${JSON.stringify({
      type: &apos;done&apos;,
      stop_reason: finalMessage.stop_reason,
      usage: finalMessage.usage,
    })}\n\n`);

    res.end();

  } catch (error) {
    // 錯誤時通知前端
    res.write(`data: ${JSON.stringify({ type: &apos;error&apos;, message: String(error) })}\n\n`);
    res.end();
  }
});

app.listen(3000);
```

### 前端（瀏覽器）接收 SSE

```typescript
// 前端使用 EventSource 或 fetch 接收 SSE

async function fetchStreamingResponse(userMessage: string) {
  const response = await fetch(&apos;/api/chat/stream&apos;, {
    method: &apos;POST&apos;,
    headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
    body: JSON.stringify({
      messages: [{ role: &apos;user&apos;, content: userMessage }],
    }),
  });

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = &apos;&apos;;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // 解析 SSE 事件（以 \n\n 分隔）
    const lines = buffer.split(&apos;\n\n&apos;);
    buffer = lines.pop() || &apos;&apos;;  // 最後一個可能不完整，留到下次

    for (const line of lines) {
      if (line.startsWith(&apos;data: &apos;)) {
        const data = JSON.parse(line.slice(6));
        if (data.type === &apos;text&apos;) {
          // 更新 UI：把文字附加到顯示區域
          appendToDisplay(data.text);
        } else if (data.type === &apos;done&apos;) {
          console.log(&apos;Stream complete:&apos;, data.stop_reason);
        } else if (data.type === &apos;error&apos;) {
          console.error(&apos;Stream error:&apos;, data.message);
        }
      }
    }
  }
}
```

## 在 Next.js 中實作 Streaming

Next.js App Router 原生支援 streaming response，搭配 Claude API 特別好用：

```typescript
// app/api/chat/route.ts
import { NextRequest } from &apos;next/server&apos;;
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const client = new Anthropic();

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  // 建立一個 TransformStream 來轉換 Claude 的 streaming 輸出
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const claudeStream = client.messages.stream({
          model: &apos;claude-sonnet-4-6&apos;,
          max_tokens: 2048,
          messages,
        });

        claudeStream.on(&apos;text&apos;, (text) =&gt; {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
        });

        await claudeStream.finalMessage();
        controller.enqueue(encoder.encode(&apos;data: [DONE]\n\n&apos;));
        controller.close();

      } catch (error) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ error: String(error) })}\n\n`)
        );
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      &apos;Content-Type&apos;: &apos;text/event-stream&apos;,
      &apos;Cache-Control&apos;: &apos;no-cache&apos;,
      &apos;Connection&apos;: &apos;keep-alive&apos;,
    },
  });
}
```

前端使用 Vercel AI SDK 的 `useChat` hook 可以讓這個流程更簡單。但如果你想自己實作，上面的範例已經足夠了。

## Streaming 的錯誤處理與重連策略

Streaming 相比一般的 HTTP 請求有更多需要處理的邊際情況。

### 常見的失敗模式

1. **網路中斷**：串流到一半連線斷了
2. **伺服器過載**：Anthropic 在串流過程中回傳 529
3. **逾時**：長時間的 streaming 可能觸發某些代理伺服器的逾時設定

### 簡單的重連實作

```python
import anthropic
import time

def stream_with_retry(client, max_retries=3, **kwargs):
    &quot;&quot;&quot;帶重試機制的 streaming 呼叫&quot;&quot;&quot;
    for attempt in range(max_retries):
        try:
            with client.messages.stream(**kwargs) as stream:
                for text in stream.text_stream:
                    yield text
                return  # 成功完成，退出

        except anthropic.APIConnectionError as e:
            if attempt == max_retries - 1:
                raise
            wait = 2 ** attempt
            print(f&quot;Connection error, retrying in {wait}s... (attempt {attempt + 1})&quot;)
            time.sleep(wait)

        except anthropic.APIStatusError as e:
            if e.status_code in (429, 529) and attempt &lt; max_retries - 1:
                wait = 2 ** attempt
                time.sleep(wait)
            else:
                raise
```

### 前端的重連邏輯

```typescript
async function streamWithReconnect(
  messages: Anthropic.Messages.MessageParam[],
  maxRetries = 3
) {
  for (let attempt = 0; attempt &lt; maxRetries; attempt++) {
    try {
      await fetchStreamingResponse(messages);
      return;  // 成功
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
      const waitMs = Math.pow(2, attempt) * 1000;
      console.warn(`Stream failed, retrying in ${waitMs}ms...`);
      await new Promise(resolve =&gt; setTimeout(resolve, waitMs));
    }
  }
}
```

## Streaming with Tool Use：特殊處理

當你的應用使用 Tool Use（下一章介紹），streaming 會更複雜。

除了文字的 `text_delta`，你還會收到 tool use 相關的事件：

```python
with client.messages.stream(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=2048,
    tools=[weather_tool],
    messages=messages
) as stream:
    for event in stream:
        if event.type == &quot;content_block_start&quot;:
            if event.content_block.type == &quot;tool_use&quot;:
                # Claude 正在呼叫工具
                tool_name = event.content_block.name
                tool_id = event.content_block.id
                print(f&quot;Calling tool: {tool_name}&quot;)

        elif event.type == &quot;content_block_delta&quot;:
            if event.delta.type == &quot;text_delta&quot;:
                # 普通文字
                print(event.delta.text, end=&quot;&quot;, flush=True)
            elif event.delta.type == &quot;input_json_delta&quot;:
                # Tool 的參數（JSON 格式，逐步串流）
                # 不建議在這裡解析，等 message_stop 後再解析完整的
                pass

        elif event.type == &quot;message_stop&quot;:
            # 取得完整的 message，包含所有 tool_use blocks
            final = stream.get_final_message()
            # 處理 tool calls...
```

我的建議：**在 streaming 模式下，不要試圖即時解析 tool input**。`input_json_delta` 是 JSON 的片段，很難即時解析。等到 `message_stop` 事件後，用 `stream.get_final_message()` 取得完整的 message，再處理 tool calls。

## stop_reason 的意義

每個 streaming 回應最終都會有一個 `stop_reason`，在 `message_delta` 事件裡：

```python
with client.messages.stream(...) as stream:
    for text in stream.text_stream:
        pass  # 消費所有文字

    final = stream.get_final_message()
    match final.stop_reason:
        case &quot;end_turn&quot;:
            # 正常結束：Claude 認為它說完了
            pass
        case &quot;max_tokens&quot;:
            # 被截斷：需要告知使用者，或增大 max_tokens 繼續
            handle_truncation(final)
        case &quot;stop_sequence&quot;:
            # 遇到你定義的 stop_sequence 而停止
            pass
        case &quot;tool_use&quot;:
            # Claude 想要呼叫工具，等待你執行工具後繼續
            handle_tool_use(final)
```

## 效能優化提示

**使用更小的模型**：Haiku 比 Sonnet 快 3-5 倍，如果你的應用對延遲很敏感，Haiku 可能是更好的選擇。

**減少 system prompt 長度**：更長的 system prompt 會增加 time-to-first-token（第一個 token 出現的時間）。

**平行請求**：如果你有多個不相關的 Claude 呼叫，可以平行發送而不是序列等待：

```python
import asyncio
import anthropic

# 使用 AsyncAnthropic 客戶端
client = anthropic.AsyncAnthropic()

async def parallel_streams(prompts: list[str]):
    async def single_stream(prompt):
        result = []
        async with client.messages.stream(
            model=&quot;claude-haiku-4-5&quot;,
            max_tokens=512,
            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}]
        ) as stream:
            async for text in stream.text_stream:
                result.append(text)
        return &quot;&quot;.join(result)

    return await asyncio.gather(*[single_stream(p) for p in prompts])
```

## 下一步

Streaming 讓你的 AI 應用在使用者體驗上更接近 Claude.ai 這樣的成熟產品。

但到目前為止，我們的 Claude 都是「說話的機器」——只能輸出文字，沒辦法真正地做事。

**下一章**，我們來解鎖 Claude 最強大的功能之一：Tool Use（工具使用）。讓 Claude 不只是說話，而是能夠呼叫你的 API、查詢資料庫、執行計算——真正地成為你應用的大腦。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BN5-53WJ.webp" medium="image"/><category>Claude API</category><category>Streaming</category><category>SSE</category><category>UX</category><enclosure url="https://bobochen.dev/_astro/cover.BN5-53WJ.webp" length="0" type="image/png"/></item><item><title>Messages API 深度解析：對話的基本單位</title><link>https://bobochen.dev/blog/claude-api-guide-messages-api/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-messages-api/</guid><description>深入理解 Claude Messages API 的核心設計：system/user/assistant roles、多輪對話管理、temperature 與 top_p 調校、stop_sequences，以及生產環境的 system prompt 最佳實踐。</description><pubDate>Fri, 06 Mar 2026 00:00:00 GMT</pubDate><content:encoded>你已經發出了第一個 API 呼叫。

但那只是「打個招呼」——傳一句話，收一句回答，連線結束。真正的 AI 應用比這複雜得多：使用者跟 Claude 來回對話好幾輪；你需要給 Claude 一個「角色設定」；你需要控制回應的風格和長度。

這一章，我們來把 Messages API 徹底搞清楚。

## 對話即陣列：Messages API 的核心設計哲學

Messages API 的設計非常直白：**對話是一個 messages 陣列，每個元素代表一輪對話**。

```json
{
  &quot;model&quot;: &quot;claude-sonnet-4-6&quot;,
  &quot;max_tokens&quot;: 1024,
  &quot;messages&quot;: [
    { &quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;台北有什麼好吃的？&quot; },
    { &quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;台北的美食非常豐富...&quot; },
    { &quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;你剛說的那個，有推薦的店嗎？&quot; }
  ]
}
```

注意這裡最關鍵的設計決策：**Claude API 是無狀態的（stateless）**。

每次你呼叫 API，你需要把完整的對話歷史傳過去。API 不會幫你「記住」之前的對話。這跟 Claude.ai 的使用體驗不同——Claude.ai 的 Projects 功能會幫你保存記憶，但那是前端應用自己做的，底層的 API 每次都是全新的。

這個設計的優點是簡單、可預測，而且讓你完全掌控對話狀態。缺點是你需要自己管理對話歷史，而且隨著對話越來越長，每次呼叫的成本也越來越高（因為 input tokens 包含了所有歷史）。

## 三種 Roles：system、user、assistant

Messages API 有三種 role，每種有不同的用途和限制。

### system（系統指示）

`system` 不是 messages 陣列的一部分，而是獨立的頂層參數。它用來給 Claude 設定「背景」：角色、能力範圍、回應格式、行為準則。

````python
client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=2048,
    system=&quot;&quot;&quot;你是一位專業的 TypeScript 程式碼審查員。

你的職責：
- 找出潛在的 bug 和型別錯誤
- 指出效能問題
- 建議更符合 TypeScript 慣例的寫法

你不應該：
- 重寫整段程式碼（除非被要求）
- 討論與程式碼無關的話題

回應格式：
1. 問題摘要（條列式）
2. 具體建議（帶程式碼範例）
3. 嚴重程度評估（高/中/低）&quot;&quot;&quot;,
    messages=[
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;請幫我審查這段程式碼：\n```typescript\n...\n```&quot;}
    ]
)
````

我有一個強烈的觀點：**system prompt 是你的 AI 應用最重要的工程工件，值得你花很多時間打磨它。**

一個好的 system prompt 應該：

1. **明確說明 Claude 是誰**，而不是「你是一個 AI 助理」這種廢話
2. **清楚界定能做什麼、不能做什麼**（比只說「能做什麼」更重要）
3. **定義輸出格式**：如果你要解析 Claude 的回應，請在 system prompt 裡明確說
4. **提供必要的背景知識**：你的產品是什麼、使用者是誰、常見的問題類型

一個我常犯的錯誤是 system prompt 寫太短。「你是一個客服機器人，回答用戶問題。」這種 prompt 在開發階段看起來 work，但在生產環境會有各種奇怪的邊際情況。

### user（使用者輸入）

`user` role 代表你的使用者（或你的應用）發出的訊息。messages 陣列**必須從 user 開始**，而且 user 和 assistant 要交替出現。

```python
messages=[
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;第一個問題&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;第一個回答&quot;},
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;第二個問題&quot;},
    # 下一個一定要是 assistant，然後才能再 user
]
```

`content` 可以是字串，也可以是陣列（用於多媒體訊息，例如上傳圖片）。目前我們先處理純文字的情況：

```python
# 簡單字串
{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;你好&quot;}

# 或者明確的 content array（兩種寫法等效）
{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;你好&quot;}]}
```

### assistant（模型回應）

`assistant` role 代表 Claude 的回應。在多輪對話中，你會把 Claude 之前的回應加入 messages 陣列，讓它在下一輪有上下文。

&gt; ⚠️ **注意（2026 更新）**：assistant prefill（在 `messages` 結尾放一個 `assistant` 訊息讓 Claude 接續）在 Claude 4.6 之後的模型（Opus 4.6/4.7/4.8、Sonnet 4.6、Fable 5）已**不支援**，會回傳 400 錯誤。要強制結構化輸出，請改用 `output_config.format`（structured outputs）或用 system prompt 指示格式。以下 Prefilling 技巧僅適用於舊版模型（3.x）：

有一個進階技巧叫做 **Prefilling**：你可以在最後一個 messages 元素放一個 `assistant` role（只包含部分文字），讓 Claude 從那個地方繼續往下說：

```python
messages=[
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;請用 JSON 格式回傳一個使用者物件&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;{&quot;}  # Prefilling：強制 Claude 從 { 開始
]
```

這個技巧對於強制結構化輸出很有用，但要小心：如果你 prefill 了一個 `{`，Claude 幾乎一定會繼續輸出 JSON，但不保證它一定是合法的 JSON。

## 關鍵參數詳解

### max_tokens（必填）

```python
max_tokens=1024  # 最多回傳 1024 tokens
```

`max_tokens` 是必填的，而且它決定了**這次呼叫可能產生的最大 output tokens 數**。

幾個常見的設定策略：

- **聊天機器人**：512-2048，視你的應用允許多長的回答
- **文件摘要**：2048-4096
- **程式碼生成**：2048-8192（程式碼可能很長）
- **分析報告**：4096+

注意：`max_tokens` 不是「我希望它說這麼長」，而是「最多不要超過這麼長」。Claude 可能更早結束（`stop_reason: &quot;end_turn&quot;`）。

如果 Claude 的回應因為達到 `max_tokens` 而被截斷，`stop_reason` 會是 `&quot;max_tokens&quot;` 而不是 `&quot;end_turn&quot;`。生產環境一定要處理這種情況。

### temperature（創意度控制）

```python
temperature=0.7  # 範例值（API 預設為 1.0），範圍 0.0 到 1.0
```

`temperature` 控制模型回應的「隨機性」：

- `0.0`：非常確定性，每次相同的輸入幾乎會得到相同的輸出。適合需要一致性的任務（程式碼生成、資料提取、分類）
- `0.7`：適度的多樣性，適合大多數對話場景
- `1.0`（API 預設）：最大隨機性，適合創意寫作、頭腦風暴

我的個人規則：API 預設是 1.0（偏隨機）。多數對話場景我會設成 0.7 左右；需要確定性輸出時設 0.0 或 0.1。

### top_p 和 top_k

```python
top_p=0.9   # nucleus sampling
top_k=50    # top-k sampling
```

這兩個參數跟 temperature 一樣是控制「採樣策略」的。**在實際使用時，Anthropic 建議你只調整 temperature 或 top_p 其中一個，不要同時調整兩個。**

坦白說，在大多數應用場景，你根本不需要動這兩個參數。只有在你有非常特殊的需求，而且你理解採樣策略的數學原理時，才值得去調整。

### stop_sequences

```python
stop_sequences=[&quot;&lt;/answer&gt;&quot;, &quot;Human:&quot;, &quot;---&quot;]
```

`stop_sequences` 讓你定義「遇到這些字串就停止生成」。這在結構化輸出時很有用：

```python
# 讓 Claude 生成 XML 風格的分析，但遇到 &lt;/analysis&gt; 就停止
client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=2048,
    system=&quot;請將你的分析包在 &lt;analysis&gt; 和 &lt;/analysis&gt; 之間。&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;分析這篇文章...&quot;}],
    stop_sequences=[&quot;&lt;/analysis&gt;&quot;]
)
```

另一個常用場景是在 few-shot prompting 時，避免 Claude 繼續生成下一個「示例」：

```python
stop_sequences=[&quot;\nHuman:&quot;, &quot;\nAssistant:&quot;]
```

## 多輪對話管理

這是大多數 AI 應用最重要的工程問題之一。

### 基本的多輪對話實作

```python
import anthropic

client = anthropic.Anthropic()
conversation_history = []

def chat(user_message: str) -&gt; str:
    # 把使用者訊息加入歷史
    conversation_history.append({
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: user_message
    })

    # 呼叫 API（傳入完整歷史）
    response = client.messages.create(
        model=&quot;claude-sonnet-4-6&quot;,
        max_tokens=2048,
        system=&quot;你是一位友善的繁體中文助理。&quot;,
        messages=conversation_history
    )

    # 取出回應文字
    assistant_message = response.content[0].text

    # 把 Claude 的回應也加入歷史
    conversation_history.append({
        &quot;role&quot;: &quot;assistant&quot;,
        &quot;content&quot;: assistant_message
    })

    return assistant_message

# 使用範例
print(chat(&quot;我叫小明，是一個 Python 開發者&quot;))
print(chat(&quot;你知道我叫什麼名字嗎？&quot;))  # Claude 會記住「小明」
print(chat(&quot;我剛說我是做什麼的？&quot;))     # Claude 會記住「Python 開發者」
```

### Context Window 管理：截斷策略

隨著對話進行，`conversation_history` 越來越長，成本也越來越高，最終可能超過 context window 上限（200K tokens）。你需要截斷策略。

**策略一：固定視窗（最簡單）**

```python
MAX_HISTORY_TURNS = 20  # 保留最近 20 輪

def chat(user_message: str) -&gt; str:
    conversation_history.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_message})

    # 只取最近 N 輪，但確保從 user 開始
    recent_history = conversation_history[-MAX_HISTORY_TURNS * 2:]
    # 確保第一個是 user（API 要求）
    while recent_history and recent_history[0][&quot;role&quot;] != &quot;user&quot;:
        recent_history = recent_history[1:]

    response = client.messages.create(
        model=&quot;claude-sonnet-4-6&quot;,
        max_tokens=2048,
        messages=recent_history
    )

    assistant_message = response.content[0].text
    conversation_history.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: assistant_message})

    return assistant_message
```

**策略二：Token 預算**

```python
import anthropic

def count_tokens_estimate(messages: list) -&gt; int:
    &quot;&quot;&quot;粗略估算 token 數：每個字符約 1.5 tokens&quot;&quot;&quot;
    total_chars = sum(len(str(m.get(&quot;content&quot;, &quot;&quot;))) for m in messages)
    return int(total_chars * 1.5)

MAX_INPUT_TOKENS = 150_000  # 留 50K 給輸出

def trim_history(history: list) -&gt; list:
    while count_tokens_estimate(history) &gt; MAX_INPUT_TOKENS and len(history) &gt; 2:
        # 刪掉最舊的一輪（user + assistant 各一條）
        history = history[2:]
        # 確保從 user 開始
        while history and history[0][&quot;role&quot;] != &quot;user&quot;:
            history = history[1:]
    return history
```

**策略三：摘要壓縮（最聰明但最複雜）**

對於真正的長對話，你可以讓 Claude 定期把舊的對話內容摘要成一段文字，然後把那段摘要放在 system prompt 裡，同時清空 messages 歷史：

```python
async def compress_conversation(history: list) -&gt; str:
    &quot;&quot;&quot;把一段對話歷史壓縮成摘要&quot;&quot;&quot;
    summary_request = client.messages.create(
        model=&quot;claude-haiku-4-5&quot;,  # 用便宜的模型做摘要
        max_tokens=1024,
        system=&quot;請將以下對話摘要成一段簡潔的重點，保留重要資訊。&quot;,
        messages=[{
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;對話歷史：\n{format_history(history)}&quot;
        }]
    )
    return summary_request.content[0].text
```

## 回應物件的完整結構

理解 API 回應物件的結構很重要，特別是你需要處理各種邊際情況時。

```python
response = client.messages.create(...)

# 基本屬性
response.id            # 唯一的請求 ID，例如 &quot;msg_01XFDUDYJgAACzvnptvVoYEL&quot;
response.type          # 永遠是 &quot;message&quot;
response.role          # 永遠是 &quot;assistant&quot;
response.model         # 實際使用的模型，例如 &quot;claude-sonnet-4-6-20251101&quot;

# 回應內容
response.content       # List[ContentBlock]
response.content[0].type  # &quot;text&quot; 或 &quot;tool_use&quot;
response.content[0].text  # 如果 type == &quot;text&quot;

# 停止原因（非常重要）
response.stop_reason   # &quot;end_turn&quot; | &quot;max_tokens&quot; | &quot;stop_sequence&quot; | &quot;tool_use&quot;

# Token 使用量（付費依據）
response.usage.input_tokens   # 這次請求消耗的 input tokens
response.usage.output_tokens  # 這次請求產生的 output tokens

# 如果有啟用 Prompt Caching
response.usage.cache_creation_input_tokens  # 新建快取消耗的 tokens
response.usage.cache_read_input_tokens      # 從快取讀取的 tokens（便宜很多）
```

**一定要檢查 `stop_reason`**。如果是 `&quot;max_tokens&quot;`，代表回應被截斷了——你的使用者會看到一個不完整的回答。常見的處理方式：

```python
if response.stop_reason == &quot;max_tokens&quot;:
    # 方案一：繼續生成（continuation）
    # 方案二：通知使用者回答被截斷
    # 方案三：增大 max_tokens 重試
    raise ValueError(&quot;Response was truncated. Consider increasing max_tokens.&quot;)
```

## 錯誤處理

生產環境一定會遇到這些錯誤，提前準備好：

```python
import anthropic
import time
from anthropic import APIStatusError, APIConnectionError, RateLimitError

def call_with_retry(client, max_retries=3, **kwargs):
    for attempt in range(max_retries):
        try:
            return client.messages.create(**kwargs)

        except RateLimitError as e:
            if attempt == max_retries - 1:
                raise
            # 指數退避
            wait_time = (2 ** attempt) * 1 + 0.1
            print(f&quot;Rate limited. Waiting {wait_time:.1f}s...&quot;)
            time.sleep(wait_time)

        except APIStatusError as e:
            if e.status_code == 401:
                raise ValueError(&quot;Invalid API key&quot;) from e
            elif e.status_code == 400:
                raise ValueError(f&quot;Bad request: {e.message}&quot;) from e
            elif e.status_code &gt;= 500:
                # 伺服器錯誤，可以重試
                if attempt == max_retries - 1:
                    raise
                time.sleep(2 ** attempt)
            else:
                raise

        except APIConnectionError:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)
```

常見的錯誤狀態碼：

- **400 Bad Request**：通常是你的 request 格式有問題，例如 messages 不是以 user 開始、`max_tokens` 超過模型上限
- **401 Unauthorized**：API Key 無效或已被 revoke
- **403 Forbidden**：帳號問題（可能欠費停用）
- **429 Too Many Requests**：超過 Rate Limit，需要等待
- **500 Internal Server Error**：Anthropic 伺服器問題，可以重試
- **529 Overloaded**：Anthropic 系統過載（高峰期可能發生），可以重試

## System Prompt 設計的實戰心得

讓我分享幾個我在生產環境學到的 system prompt 技巧。

### 技巧一：先說「不要做什麼」

大多數人的 system prompt 都在說「請做 X、Y、Z」，但更有效的做法是同時說清楚「不要做 A、B、C」。

```
你是一個 XX 公司的客服助理。

你應該：
- 回答關於我們產品的問題
- 協助用戶排解常見問題
- 提供退換貨流程說明

你不應該：
- 透露公司的定價策略或利潤資訊
- 對還未正式宣布的功能做出承諾
- 在沒有確認身份的情況下修改用戶帳號設定
- 討論競爭對手的產品
```

### 技巧二：定義「不知道」的處理方式

```
如果你不確定某個問題的答案，請明確說「我不確定這個問題的答案，建議您聯繫我們的客服團隊（service@example.com）。」不要猜測或提供可能不準確的資訊。
```

### 技巧三：指定輸出格式（尤其是需要解析的場景）

```
每次回應，請使用以下格式：

&lt;answer&gt;
[你的主要回答]
&lt;/answer&gt;

&lt;confidence&gt;
[high/medium/low]
&lt;/confidence&gt;

&lt;sources&gt;
[如果有參考特定資訊，列出來源]
&lt;/sources&gt;
```

### 技巧四：提供「人格」範例而不只是描述

```
你是 Aria，一個親切、有點幽默但很專業的 AI 助理。

你的說話風格範例：
- 「好問題！讓我想想...」（輕鬆但不浮誇）
- 「這個需要多說幾句，因為背後有個有趣的原因」
- 不用「當然！很高興為您服務！」這種過於熱情的開場
```

## 下一步

你現在對 Messages API 有了深入的了解：messages 的結構、三種 roles、各種參數的用途，以及如何在生產環境管理對話狀態。

但我們的範例有一個問題：你發出請求，然後等 Claude 把整個回應生成完再傳回來。對短回應來說還好，但如果 Claude 需要生成 2000 字的文章，使用者就要等好幾秒什麼都看不到。

**下一章**，我們來解決這個問題：Streaming。讓使用者看到 Claude「即時打字」的體驗，把感知等待時間從幾秒降到幾乎為零。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BP25I3V3.webp" medium="image"/><category>Claude API</category><category>Messages API</category><category>system prompt</category><category>conversation</category><enclosure url="https://bobochen.dev/_astro/cover.BP25I3V3.webp" length="0" type="image/png"/></item><item><title>Claude API 入門：帳號、費用與第一個 API 呼叫</title><link>https://bobochen.dev/blog/claude-api-guide-getting-started/</link><guid isPermaLink="true">https://bobochen.dev/blog/claude-api-guide-getting-started/</guid><description>從零開始使用 Claude API：API Key 申請、費用模型詳解（claude-opus-4-5、sonnet、haiku 定價比較）、Rate Limits，以及 curl、Python、TypeScript 三版本的第一個 API 呼叫。</description><pubDate>Fri, 27 Feb 2026 00:00:00 GMT</pubDate><content:encoded>如果你用過 Claude.ai，你知道它有多好用。

但「好用」跟「能寫進你的產品」是兩回事。

Claude.ai 是給人用的介面。Claude API 是給程式用的介面。這本書講的是後者——如何把 Claude 的能力嵌入你正在建造的東西。

這一章是起點。我們會從最基礎的問題開始：Claude API 是什麼？跟 Claude.ai、Claude Code 有什麼差別？費用怎麼算？然後一步一步帶你發出第一個真正的 API 呼叫。

## Claude API vs Claude.ai vs Claude Code：搞清楚你在用什麼

在開始之前，我想先解決一個常見的混淆。

**Claude.ai** 是 Anthropic 的消費者產品。你用瀏覽器開啟，跟 Claude 對話，有 Projects、Artifacts 等功能。費用是訂閱制（Free、Pro、Max）。你沒辦法「程式化」地呼叫它，也沒辦法把它整合進你的 app。

**Claude Code** 是給開發者用的 CLI 工具（`@anthropic-ai/claude-code`）。它運行在你的本機，可以讀取你的程式碼、執行指令、幫你寫程式。費用走的是你個人的 API Key，或者 Claude Pro/Max 訂閱（所謂的 Max 模式）。

**Claude API** 是 Anthropic 提供的 REST API，讓你用程式碼直接呼叫 Claude 的語言模型能力。你可以用它建立自己的聊天機器人、文件摘要工具、程式碼審查系統、客服 agent——任何需要 LLM 能力的應用。費用是 pay-per-use，按照你傳送和接收的 token 數量計費。

這本書講的是 Claude API，以及建立在它之上的 Agent SDK（後面章節會介紹）。

目標讀者是你：一個正在或準備建造 AI 應用的開發者。

## 建立帳號與取得 API Key

### 在 console.anthropic.com 建立帳號

前往 [console.anthropic.com](https://console.anthropic.com)，用你的 Google 帳號或 email 註冊。這個「Console」是 Anthropic 的開發者平台，跟 Claude.ai 是分開的系統（雖然可以用同一個帳號登入）。

註冊後你需要：

1. **驗證你的 email**（如果用 email 註冊的話）
2. **新增付款方式**：沒有信用卡就沒有辦法使用 API，這是必要步驟
3. **設定使用量上限**（建議設定，避免意外高額帳單）

Anthropic 不提供「永久免費層」的 API 使用量。你第一次加入信用卡後，帳號通常會有一些試用額度（金額不定，可能幾美金到十幾美金），但用完就要開始付費。

### 產生 API Key

進入 Console 後，在左側選單找到 **API Keys**。

點擊「+ Create Key」，給它一個有意義的名字（例如：`my-chatbot-prod`、`local-dev`）。建議你**為不同的應用和環境建立不同的 Key**，這樣萬一哪個 Key 洩漏，你可以精確地 revoke 它，而不影響其他服務。

Key 只會顯示一次。請立刻複製並存到安全的地方（密碼管理器、環境變數，**絕對不要 commit 到 git**）。

### API Key 的安全守則

這個很重要，我要單獨說：

**永遠不要把 API Key 放進程式碼**。不管是 hardcode 在程式裡，還是放在公開的 GitHub repo 裡。Anthropic 的安全系統會自動掃描公開的 GitHub，一旦偵測到洩漏的 Key，會自動 revoke 並通知你。但這個流程走完之前，任何人都可以用你的 Key 燒你的錢。

正確的做法是：

```bash
# 在你的開發環境
export ANTHROPIC_API_KEY=&quot;sk-ant-api03-...&quot;

# 或者放在 .env 檔案（記得加到 .gitignore）
ANTHROPIC_API_KEY=sk-ant-api03-...
```

## 費用模型詳解：你真正需要知道的

Claude API 的費用計算方式是「token 計費」。你傳送給 API 的文字（input tokens）和 API 回傳給你的文字（output tokens）分開計費，單位是每百萬 token（per million tokens，縮寫 MTok）。

### 模型與定價（2026 年初）

Anthropic 目前有三個主要的模型家族：

| 模型                | Input        | Output     | 適用場景             |
| ------------------- | ------------ | ---------- | -------------------- |
| `claude-opus-4-5`   | $15 / MTok   | $75 / MTok | 複雜推理、高品質需求 |
| `claude-sonnet-4-6` | $3 / MTok    | $15 / MTok | 日常應用、最佳 CP 值 |
| `claude-haiku-4-5`  | $0.80 / MTok | $4 / MTok  | 高頻低延遲場景       |

我自己的生產環境幾乎都用 `claude-sonnet-4-6`。它在品質和費用之間找到了很好的平衡。`claude-haiku-4-5` 我用在需要大量平行處理、或者對延遲非常敏感的場景（例如即時分析使用者輸入）。`claude-opus-4-5` 我保留給真的需要最高品質推理的任務，例如複雜的程式架構分析。

### Token 是什麼、大概多少字？

一個 token 大約是 0.75 個英文單字，或者 0.5 個中文字符。所以：

- 1000 個英文字 ≈ 1,300 tokens
- 1000 個中文字 ≈ 2,000 tokens（中文每個字通常是 1-2 tokens）

以 `claude-sonnet-4-6` 為例，用繁體中文寫一篇 2000 字的文章大約是 4000 output tokens，費用約 $0.06（不到台幣 2 元）。很便宜，但如果你的應用有大量使用者，這些費用會累積。

### Context Window 與費用的關係

每個模型都有 context window 的上限（目前 Claude 模型通常是 200K tokens）。

**重要提醒：你傳送給 API 的每個請求，都要附上整段對話歷史。** 也就是說，一個 10 輪的對話，第 10 輪請求的 input tokens 包含了前 9 輪的所有對話內容。這在費用計算上有重要影響：**長對話會越來越貴**。

Prompt Caching 是 Anthropic 提供的功能，可以讓你快取常用的 system prompt 或文件內容，重複使用時只收少量快取讀取費用而不是全額。後面章節會詳細介紹。

### Rate Limits

除了費用，你還需要了解 Rate Limits（速率限制）。

Anthropic 對 API 使用有兩種限制：

1. **RPM（Requests Per Minute）**：每分鐘可以發出的請求數
2. **TPM（Tokens Per Minute）**：每分鐘可以消耗的 token 數

Rate limits 會隨著你的帳號使用量和消費金額自動提升（Anthropic 稱為 Usage Tiers）。新帳號通常從 Tier 1 開始，限制較低。如果你預計有大量使用，可以在 Console 申請提升。

遇到 Rate Limit 錯誤時，API 會回傳 `429 Too Many Requests`。正確的處理方式是使用指數退避（exponential backoff）重試，而不是立刻再試。

## 第一個 API 呼叫

理論說夠了，來動手。

### 用 curl 測試（最快）

這是最快驗證你的 API Key 是否有效的方法：

```bash
curl https://api.anthropic.com/v1/messages \
  --header &quot;x-api-key: $ANTHROPIC_API_KEY&quot; \
  --header &quot;anthropic-version: 2023-06-01&quot; \
  --header &quot;content-type: application/json&quot; \
  --data &apos;{
    &quot;model&quot;: &quot;claude-sonnet-4-6&quot;,
    &quot;max_tokens&quot;: 1024,
    &quot;messages&quot;: [
      {
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: &quot;你好！請用繁體中文介紹你自己，不超過 50 字。&quot;
      }
    ]
  }&apos;
```

如果你的 Key 正確，你會看到類似這樣的回應：

```json
{
  &quot;id&quot;: &quot;msg_01XFDUDYJgAACzvnptvVoYEL&quot;,
  &quot;type&quot;: &quot;message&quot;,
  &quot;role&quot;: &quot;assistant&quot;,
  &quot;content&quot;: [
    {
      &quot;type&quot;: &quot;text&quot;,
      &quot;text&quot;: &quot;你好！我是 Claude，由 Anthropic 開發的 AI 助理。我能協助回答問題、分析資料、撰寫文章，以及進行各種語言任務。很高興認識你！&quot;
    }
  ],
  &quot;model&quot;: &quot;claude-sonnet-4-6-20251101&quot;,
  &quot;stop_reason&quot;: &quot;end_turn&quot;,
  &quot;usage&quot;: {
    &quot;input_tokens&quot;: 28,
    &quot;output_tokens&quot;: 42
  }
}
```

注意 `usage` 欄位——這就是這次呼叫消耗的 token 數。

### Python SDK

Anthropic 提供官方的 Python SDK，強烈建議使用它，而不是自己呼叫 REST API。

```bash
pip install anthropic
```

```python
import anthropic

# 如果環境變數 ANTHROPIC_API_KEY 已設定，不需要傳入 api_key
client = anthropic.Anthropic()

message = client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: &quot;你好！請用繁體中文介紹你自己，不超過 50 字。&quot;
        }
    ]
)

# 取出文字回應
print(message.content[0].text)

# 查看 token 使用量
print(f&quot;Input tokens: {message.usage.input_tokens}&quot;)
print(f&quot;Output tokens: {message.usage.output_tokens}&quot;)
```

SDK 會自動從環境變數 `ANTHROPIC_API_KEY` 讀取 Key，不需要你手動傳入（除非你想要明確指定）。

如果你想加上 system prompt：

```python
message = client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    system=&quot;你是一位熟悉台灣軟體業的 AI 助理，回答時請使用繁體中文，語氣專業但平易近人。&quot;,
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: &quot;2026 年台灣開發者最應該學習的技術是什麼？&quot;
        }
    ]
)

print(message.content[0].text)
```

### TypeScript / Node.js SDK

```bash
npm install @anthropic-ai/sdk
```

```typescript
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const client = new Anthropic();
// API Key 同樣從環境變數 ANTHROPIC_API_KEY 自動讀取

async function main() {
  const message = await client.messages.create({
    model: &apos;claude-sonnet-4-6&apos;,
    max_tokens: 1024,
    messages: [
      {
        role: &apos;user&apos;,
        content: &apos;你好！請用繁體中文介紹你自己，不超過 50 字。&apos;,
      },
    ],
  });

  // TypeScript 版本有完整的型別支援
  const textContent = message.content[0];
  if (textContent.type === &apos;text&apos;) {
    console.log(textContent.text);
  }

  console.log(`Input tokens: ${message.usage.input_tokens}`);
  console.log(`Output tokens: ${message.usage.output_tokens}`);
}

main();
```

TypeScript SDK 的一個優點是有完整的型別定義，你的 IDE 會給你自動補全和型別檢查。`message.content` 是一個 array，每個元素可能是 `text` block 或 `tool_use` block（工具呼叫，後面章節會介紹），所以你需要用 `type` 欄位來判斷。

## 環境設定建議

在你開始正式開發之前，我推薦這樣設定你的開發環境：

### 使用 .env 檔案（搭配 python-dotenv 或 dotenv）

**Python：**

```bash
pip install python-dotenv
```

```python
# .env 檔案內容
ANTHROPIC_API_KEY=sk-ant-api03-...

# 在你的 Python 程式開頭
from dotenv import load_dotenv
load_dotenv()

import anthropic
client = anthropic.Anthropic()  # 自動從 .env 讀取
```

**TypeScript / Node.js：**

```bash
npm install dotenv
```

```typescript
// .env 檔案內容
// ANTHROPIC_API_KEY=sk-ant-api03-...

import &apos;dotenv/config&apos;;
import Anthropic from &apos;@anthropic-ai/sdk&apos;;

const client = new Anthropic();
```

**.gitignore 設定（非常重要！）：**

```bash
# .gitignore
.env
.env.local
.env.*.local
```

### 設定費用警示

在 Console 的 **Billing** 頁面，你可以設定：

- **Monthly Spend Limit**：每月消費上限，超過就自動停止 API 服務
- **Email Alerts**：當消費達到某個門檻時發 email 通知

我建議新手一定要設定 Monthly Spend Limit。我見過有人因為程式碼 bug（例如無限迴圈一直呼叫 API）而在幾個小時內燒掉幾十美金。有上限保護，最糟糕的情況就是 API 暫停服務，而不是無底洞的帳單。

## 常見入門問題

**Q: 我要選哪個模型？**

從 `claude-sonnet-4-6` 開始。它的品質夠好、速度夠快、費用合理，是目前大多數應用的最佳起點。等你的應用上線、了解自己的使用模式之後，再考慮是否要優化成 Haiku（降低成本）或升級成 Opus（提升品質）。

**Q: API 回應太慢怎麼辦？**

有幾個方向：

1. 用更小的模型（Haiku 比 Sonnet 快很多）
2. 用 Streaming（下一章會介紹），讓使用者更快看到第一個字
3. 減少 context 長度（更短的 system prompt、更少的對話歷史）

**Q: 為什麼 `max_tokens` 是必填的？**

這是設計上的選擇。Anthropic 希望你明確指定你預期的最大回應長度，而不是讓模型無限制地回應。這有助於你控制成本，也避免你的應用等待過久。通常設 1024 到 4096 是合理的範圍，視你的應用需求而定。

**Q: `anthropic-version: 2023-06-01` 這個 header 是什麼？**

這是 API 版本 header，確保你的程式碼在 Anthropic 更新 API 時不會意外 breaking。使用 SDK 時，SDK 會自動幫你加上正確的版本，你不需要手動設定。

## 下一步

你現在已經完成了最基礎的部分：有一個能用的 API Key，理解費用的運作方式，也發出了第一個 API 呼叫。

但你可能注意到，我們的範例都是一來一往的單輪對話。實際的應用幾乎都需要多輪對話——使用者問問題，AI 回答，使用者追問，AI 再回答。

**下一章**，我們會深入了解 Messages API 的核心設計：什麼是 roles、如何管理多輪對話、system prompt 的最佳實踐，以及各種參數（temperature、top_p、stop_sequences）的實際用法。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Oi8K9qZe.webp" medium="image"/><category>Claude API</category><category>API Key</category><category>入門</category><category>費用</category><enclosure url="https://bobochen.dev/_astro/cover.Oi8K9qZe.webp" length="0" type="image/png"/></item></channel></rss>