跳至主要內容
技術

打造你的第一個 Agent:工具、狀態與循環

打造你的第一個 Agent:工具、狀態與循環
Claude API & Agent SDK 完全指南 第 10 / 15 篇

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

上一章我們了解了「用 Claude 建 Agent」的核心概念。這一章我要帶你從頭到尾打造一個真實可用的 Agent。

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

先講一句老實話: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               │
│  == "end_turn"   == "tool_use"             │
│      │              │                      │
│      ▼              ▼                      │
│    結束          執行工具(你的程式碼)    │
│                     │                      │
│                     ▼                      │
│              把 tool_result 回灌 messages  │
│                     │                      │
│                     └──→ 回到步驟 1        │
└───────────────────────────────────────────┘


最終的 assistant 訊息(final text)

關鍵點:

  • Claude API 是 stateless 的。每次呼叫,你都要把完整的 messages 歷史(含先前的工具結果)一起送回去。
  • Loop 一直跑,直到 Claude 的回應 stop_reason == "end_turn"(也就是這一輪它沒有再要求呼叫工具)。
  • 「誰跑這個 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:

import anthropic
from anthropic import beta_tool

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


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

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

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

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

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

    return "\n---\n".join(results)

@beta_tool 會自動把:

  • 函式名稱 → tool name
  • docstring 開頭的描述 → tool description
  • Args: 區塊裡每個參數的說明 → 對應參數的 description
  • 型別提示(strintboollist[...] 等)→ 參數型別
  • 沒有預設值的參數 → required

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

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

手寫 JSON 工具定義(給手寫 loop 用)

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

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

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

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

TypeScript 工具定義

TypeScript 用 betaZodTool,搭配 Zod schema:

import Anthropic from '@anthropic-ai/sdk';
import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
import { z } from 'zod';

const client = new Anthropic();

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

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

Tool Return Value 的設計原則

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

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

# 差:只返回原始 JSON
return json.dumps(data)

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

原則 2:包含充足的上下文

# 差:只返回數字
return "42"

# 好:帶上單位和上下文
return "台灣 EV 市場 2026 年第一季銷售量:42,000 輛(YoY +28%)"

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

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

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

Agent 的 System Prompt 設計

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

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

  1. 角色定義:這個 Agent 是誰,有什麼專業能力
  2. 目標描述:它要達成什麼
  3. 行為準則:它應該如何決策
  4. 工具使用指南:什麼情況用什麼工具(特別是工具很多時)
  5. 輸出格式:最終報告/回答的格式要求
RESEARCH_AGENT_SYSTEM = """你是一位專業的市場研究分析師,專門為企業提供深度市場報告。

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

## 工作流程
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. 結論與建議

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

Python 完整 Research Agent

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

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) -> str:
    """搜尋網路上的最新資訊。

    Args:
        query: 搜尋關鍵字(英文或中文)。
        num_results: 返回結果數(1-10)。
    """
    try:
        from duckduckgo_search import DDGS

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


@beta_tool
def query_database(sql: str) -> str:
    """查詢市場資料資料庫(只讀)。

    可用的資料表:
    - 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)。
    """
    # 安全檢查:只允許 SELECT
    if not sql.strip().upper().startswith("SELECT"):
        return "安全限制:只允許 SELECT 查詢。"

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

        if not rows:
            return "查詢成功,但沒有符合條件的資料。"

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

        result = f"查詢返回 {len(rows)} 筆記錄:\n\n" + "\n".join(table_lines)
        if len(rows) > 50:
            result += f"\n\n(僅顯示前 50 筆,共 {len(rows)} 筆)"
        return result

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


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

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

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

    if metric_type == "yoy_growth":
        if len(values) < 2:
            return "錯誤:yoy_growth 需要至少 2 個值"
        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"{labels[i]} vs {labels[i - 1]}"
                    if labels and len(labels) > i
                    else f"第{i + 1}期 vs 第{i}期"
                )
                results.append(f"  {label}: {growth:+.1f}%")
        return "年增率計算:\n" + "\n".join(results)

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

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

    return f"未知的 metric_type: {metric_type}。可選:summary, yoy_growth, cagr, market_share"


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

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


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

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

## 研究流程
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. 結論與建議"""


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

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

    print(f"開始研究:{topic}")
    print("=" * 60)

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

    # 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 == "tool_use"]
        if tool_uses:
            print(f"  [工具] {', '.join(tool_uses)}")
        usage = message.usage
        print(f"  [token] in={usage.input_tokens} out={usage.output_tokens}")

    print("=" * 60)
    print(f"完成!過程中記錄了 {len(FINDINGS)} 筆發現。")

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


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

幾個重點:

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

加入記憶:Context 傳遞

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

方法一:用 messages 歷史串接(最基本、最可靠)

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

import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()

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


def chat_turn(user_input: str) -> str:
    history.append({"role": "user", "content": user_input})

    runner = client.beta.messages.tool_runner(
        model="claude-opus-4-8",
        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({"role": "assistant", "content": final_message.content})

    return "".join(b.text for b in final_message.content if b.type == "text")


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

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

方法二:memory 工具(進階選項)

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

tools = [
    {"type": "memory_20250818", "name": "memory"},
    # ... 你自己的工具
]

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

加入停止條件

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

手寫 loop 的停止條件有兩根支柱:stop_reason == "end_turn"(Claude 講完了)和自己維護的 max_iterations 計數器(防呆上限):

import anthropic

client = anthropic.Anthropic()

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


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


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

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

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

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

    # 支柱 2:跑到 max_iterations 上限還沒結束 → 硬停(防無限循環)
    return "(已達最大迭代次數,回傳目前進度)\n" + 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:
## 完成條件
完成以下所有步驟後,立即輸出報告並結束:
- 至少完成 3 次網路搜尋
- 查詢相關的歷史數據(如果有)
- 計算關鍵指標
- 輸出最終報告

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

陷阱 2:Token 爆炸

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

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

解決

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

三個方向一起做:

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

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

症狀:工具拋出異常,整個 loop 崩潰。

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

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

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

陷阱 4:Agent 沒有使用工具

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

解決:兩個層次。

system prompt 明確要求:

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

或者用 tool_choice 從 API 層級強制(手寫 loop / messages.create 都支援):

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

Debug 技巧

開啟 SDK 詳細日誌

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

ANTHROPIC_LOG=debug python research_agent.py

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

檢查完整的訊息歷史

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

import json

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

用 request id 回報問題

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

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

如何測試 Agent

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

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

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

import pytest

# 把可測的純邏輯抽成獨立函式,工具只是薄薄一層包裝
def compute_yoy_growth(values: list[float]) -> 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 "只允許 SELECT" in query_database("DELETE FROM market_data")

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

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

from types import SimpleNamespace
from unittest.mock import MagicMock


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


def make_end_response(text):
    block = SimpleNamespace(type="text", text=text)
    return SimpleNamespace(stop_reason="end_turn", 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("search_web", "toolu_1", {"query": "台灣 EV"}),
        make_end_response("# 報告\n台灣電動車市場 2026 年成長強勁。"),
    ]
    # 用我們的假 client 取代真實 client
    monkeypatch.setattr("research_agent.client", fake_client)

    report = run_research_manual("台灣電動車市場", max_iterations=10)

    # loop 應該呼叫了兩次 API(一次工具、一次收尾)
    assert fake_client.messages.create.call_count == 2
    assert "電動車" in report

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


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

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

Agent 的世界,我們才剛開始。

留言討論

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