打造你的第一個 Agent:工具、狀態與循環
本篇是「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- 型別提示(
str、int、bool、list[...]等)→ 參數型別 - 沒有預設值的參數 →
required
工具文件就是工具的「說明書」,Claude 讀這份說明書來決定要不要用、怎麼用這個工具。花時間寫好 docstring 非常值得。
非同步版本:如果你的工具是
async def,改用from anthropic import beta_async_tool來裝飾,搭配後面會講到的AsyncAnthropicclient。
手寫 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.create 的 system 參數)比一般的單輪問答需要更多結構。因為 Claude 要在循環裡反覆做決策,你得把「它是誰、要達成什麼、怎麼決策、什麼時候用哪個工具、最後輸出長什麼樣」都交代清楚。
一個好的 Agent system prompt 應該包含:
- 角色定義:這個 Agent 是誰,有什麼專業能力
- 目標描述:它要達成什麼
- 行為準則:它應該如何決策
- 工具使用指南:什麼情況用什麼工具(特別是工具很多時)
- 輸出格式:最終報告/回答的格式要求
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")自然停下。- 觀察用 iteration:
for 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
三個方向一起做:
- 精簡 context:工具只回傳真正有用的內容,別把整頁 HTML 丟回去。
- 截斷工具輸出:像上面那樣設
max_chars,並在 system prompt 限制「不要一次請求超過 5 個搜尋結果、查資料庫用LIMIT限制行數」。 - 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 的世界,我們才剛開始。