今天是整個系列的轉捩點。
到目前為止,我們的程式是這樣運作的:我們決定要做什麼,AI 負責處理文字。
從今天開始會反過來:AI 決定要做什麼,我們負責執行。
很多人第一次聽到「AI 可以呼叫工具」,會以為 AI 真的有能力執行程式碼、連上網路、讀取你的檔案。
它不能。
模型還是只會做一件事:產生文字。所謂的 Tool Use,實際上是這樣的:
1. 我們告訴模型:「你有這些工具可以用」(工具定義)
2. 模型回應:「我想呼叫 get_weather,參數是 {"city": "臺北市"}」
↑ 這只是一段結構化的文字,什麼事都沒發生
3. 【我們的程式】看到這個請求,實際去執行 get_weather("臺北市")
↑ 真正做事的是我們
4. 我們把結果傳回給模型:「執行結果是:臺北市多雲,26-31度…」
5. 模型根據結果產生最終回答
**第 3 步永遠是我們的程式在做。**模型只是「說」它想呼叫什麼,執行與否、怎麼執行,完全由我們控制。
這個認知很重要,因為它直接關係到安全性:我們永遠掌握最後的否決權。
用程式碼看一遍。
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
tools = [
{
"name": "get_weather",
"description": (
"查詢台灣某個縣市未來 36 小時的天氣預報,"
"包含天氣現象、氣溫範圍、降雨機率與舒適度。"
"當使用者詢問天氣、要不要帶傘、穿什麼衣服時使用。"
),
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "台灣的縣市名稱,例如「臺北市」",
}
},
"required": ["city"],
},
}
]
messages = [{"role": "user", "content": "我明天在台北要不要帶傘?"}]
response = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
tools=tools, # ← 關鍵在這裡
messages=messages,
)
這個 tools 的格式,就是 Day 15 我們寫的 to_schema() 產生的東西。當初設計的 description 和 input_schema,現在真的派上用場了。
print(response.stop_reason)
# tool_use
for block in response.content:
print(block.type)
# text ← 有時候會先說一句「我來查一下」
# tool_use ← 這個就是工具呼叫請求
tool_use 區塊裡有三個東西:
tool_block = [b for b in response.content if b.type == "tool_use"][0]
print(tool_block.id) # toolu_01A9B... ← 這次呼叫的唯一編號
print(tool_block.name) # get_weather ← 要呼叫哪個工具
print(tool_block.input) # {'city': '臺北市'} ← 參數(已經是 dict)
注意模型自己把「台北」轉成了「臺北市」——因為我們在 description 裡寫了範例。
from tools.weather_tool import WeatherTool
weather_tool = WeatherTool()
result = weather_tool.safe_run(tool_block.input) # Day 15 寫的
print(result)
# {'ok': True, 'content': '【臺北市】未來 36 小時天氣\n...'}
**這一步完全是我們的程式在跑。**模型不知道、也不參與。
這裡有個格式要求,要小心:
# 1. 把模型剛剛的回應(包含 tool_use 區塊)加進歷史
messages.append({"role": "assistant", "content": response.content})
# 2. 把工具結果當作 user 訊息傳回去
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_block.id, # ← 必須對應到原本的 id
"content": result["content"],
"is_error": not result["ok"], # 失敗時標記
}
],
})
# 3. 再呼叫一次模型
final = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
tools=tools,
messages=messages,
)
print(final.content[0].text)
輸出:
明天台北白天降雨機率 60%,而且是短暫陣雨,建議帶把傘。
氣溫 25–30 度,中午偏悶熱,穿短袖就好,但早晚可以帶件薄外套。
AI 自己決定去查了天氣,然後根據真實資料回答。
tool_use_id 必須對應"tool_use_id": tool_block.id # ✅
"tool_use_id": "toolu_123" # ❌ 亂填會報錯
模型可能一次要求呼叫多個工具,id 是用來配對的。
user 角色傳回雖然直覺上「工具的結果」不是使用者說的話,但 API 的格式就是這樣規定。role 要填 "user"。
assistant 訊息要放整個 contentmessages.append({"role": "assistant", "content": response.content}) # ✅
messages.append({"role": "assistant", "content": "我來查一下"}) # ❌
必須把原始的 content list(包含 tool_use 區塊)整個放回去。只放文字的話,模型就不知道它剛剛要求了什麼。
這是我自己踩過最久的坑,症狀是模型一直重複呼叫同一個工具。
模型可能在一次回應裡要求多個工具:
messages = [{"role": "user", "content": "幫我看明天台北跟台中的天氣,順便告訴我現在幾點"}]
回應的 content 可能是:
[
TextBlock(text="我來查一下"),
ToolUseBlock(id="toolu_01", name="get_weather", input={"city": "臺北市"}),
ToolUseBlock(id="toolu_02", name="get_weather", input={"city": "臺中市"}),
ToolUseBlock(id="toolu_03", name="get_current_datetime", input={}),
]
處理方式:全部執行完,把所有結果放在同一則訊息裡回傳:
tool_blocks = [b for b in response.content if b.type == "tool_use"]
results = []
for block in tool_blocks:
tool = TOOLS[block.name]
outcome = tool.safe_run(block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": outcome["content"],
"is_error": not outcome["ok"],
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": results}) # ← 全部一起
⚠️ **不要拆成多則訊息傳回去。**這會讓模型以後不敢平行呼叫工具,每次都變成一個一個慢慢來。
上面的例子只跑了一輪。但模型可能在看到第一個工具的結果後,決定再呼叫另一個工具。
例如:
使用者:「幫我安排明天的行程」
第 1 輪:模型呼叫
get_current_datetime(先確認今天是幾號)
第 2 輪:模型呼叫get_weather(查明天天氣)
第 3 輪:模型呼叫list_todos(看有什麼事要做)
第 4 輪:模型綜合以上資訊,給出建議
所以我們需要一個迴圈:
def run_agent(client, tools_map, tool_schemas, user_input, max_turns=10):
"""執行 Agent 迴圈,直到模型給出最終回答。"""
messages = [{"role": "user", "content": user_input}]
for turn in range(max_turns):
response = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
tools=tool_schemas,
messages=messages,
)
# 模型講完了,沒有要呼叫工具
if response.stop_reason != "tool_use":
return extract_text(response)
# 有工具要執行
messages.append({"role": "assistant", "content": response.content})
results = []
for block in response.content:
if block.type != "tool_use":
continue
tool = tools_map.get(block.name)
if tool is None:
outcome = {"ok": False, "content": f"沒有名為 {block.name} 的工具"}
else:
outcome = tool.safe_run(block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": outcome["content"],
"is_error": not outcome["ok"],
})
messages.append({"role": "user", "content": results})
return "抱歉,這個任務太複雜了,我試了很多次還是沒辦法完成。"
def extract_text(response):
return "\n".join(b.text for b in response.content if b.type == "text").strip()
**這個迴圈就是 Agent 的核心。**整個系列講了 21 天,濃縮起來就是這三十幾行。
max_turns 為什麼一定要有沒有上限的話,可能發生:
**每一輪都是一次 API 呼叫,都在花錢。**沒有上限等於沒有煞車。
Day 17 寫的 trace 在這裡就很有用——你可以事後看到它在第幾輪卡住、重複呼叫了什麼。
把工具接上:
"""agent_v1.py — 第一個真正的 Agent。"""
import logging
import anthropic
import config
from logger_setup import setup_logging
from tools.datetime_tool import DateTimeTool
from tools.todo_tool import AddTodoTool, ListTodosTool
from tools.weather_tool import WeatherTool
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """你是「小幫」,一個台灣使用者的生活助理。
使用提供的工具取得真實資訊,絕對不要憑印象編造天氣、日期或待辦內容。
工作原則:
- 處理任何跟「今天」「明天」相關的問題前,先呼叫 get_current_datetime
- 需要資訊時主動使用工具,不要反問使用者你自己查得到的事
- 工具失敗時,用白話告訴使用者原因,不要對同一個工具重試超過兩次
- 最後的回答用自然的口語,簡潔具體,三到五句話
你使用繁體中文與台灣用語。"""
def build_tools():
tools = [
DateTimeTool(),
WeatherTool(),
AddTodoTool(),
ListTodosTool(),
]
return {t.name: t for t in tools}, [t.to_schema() for t in tools]
def main():
setup_logging()
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
tools_map, tool_schemas = build_tools()
print("=== 小幫 v1(有工具了!)===")
print(f"可用工具:{', '.join(tools_map)}\n")
while True:
user_input = input("你:").strip()
if user_input.lower() in ("quit", "exit"):
break
if not user_input:
continue
messages = [{"role": "user", "content": user_input}]
for turn in range(10):
response = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=tool_schemas,
messages=messages,
)
if response.stop_reason != "tool_use":
text = "\n".join(
b.text for b in response.content if b.type == "text"
).strip()
print(f"小幫:{text}\n")
break
messages.append({"role": "assistant", "content": response.content})
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f" 🔧 使用工具 {block.name}({block.input})")
tool = tools_map.get(block.name)
outcome = (
tool.safe_run(block.input) if tool
else {"ok": False, "content": f"沒有名為 {block.name} 的工具"}
)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": outcome["content"],
"is_error": not outcome["ok"],
})
messages.append({"role": "user", "content": results})
else:
print("小幫:這個任務太複雜了,我沒能完成。\n")
if __name__ == "__main__":
main()
實際跑起來:
=== 小幫 v1(有工具了!)===
可用工具:get_current_datetime, get_weather, add_todo, list_todos
你:我明天在台北要不要帶傘?
🔧 使用工具 get_current_datetime({})
🔧 使用工具 get_weather({'city': '臺北市'})
小幫:明天(10/6,星期一)台北白天有短暫陣雨,降雨機率 60%,建議帶傘。
氣溫 25–30 度,中午偏悶熱但早晚舒適,短袖加薄外套就可以。
你:那幫我記一下明天要帶傘
🔧 使用工具 add_todo({'title': '出門記得帶傘', 'priority': '高'})
小幫:好,已經記下來了。明天出門前記得看一下清單。
你:我現在有什麼事要做?
🔧 使用工具 list_todos({'only_pending': True})
小幫:目前有 1 筆待辦:出門記得帶傘(高優先度)。
**注意第一個問題——我們只問了一句話,它自己決定要先查日期、再查天氣。**這個決策過程完全沒有寫在我們的程式碼裡。
這就是「自主決策」的雛形。
回頭看第一輪互動,模型做了這些決定:
| 決定 | 依據 |
|---|---|
| 要用工具(而不是直接回答) | 它知道自己不知道明天的天氣 |
| 要先查日期 | system prompt 教它的 |
| 「台北」→「臺北市」 | tool description 裡的範例 |
| 查完天氣後就夠了,不用再查別的 | 它判斷資訊足夠了 |
| 回答要提到降雨機率的數字 | system prompt 要求「具體」 |
這五個決定,沒有一個是 if-else 寫死的。
但同時也要注意:這五個決定全部都受我們寫的文字影響。system prompt、tool description、input_schema 的 description——這些都是我們的「程式碼」,只是用自然語言寫的。
這是 Agent 開發跟傳統開發最大的心態差異:
你不是在寫指令,你是在設計一個環境,讓模型在裡面做出好的決定。
今天的 Agent 已經能動,但還很粗糙:
| 問題 | 什麼時候解決 |
|---|---|
| 工具是手動註冊的,加新工具要改好幾個地方 | Day 22 |
| 只有單次對話,沒有記憶前面聊過什麼 | Day 26 |
| 沒有 trace,出事不知道為什麼 | Day 22 接上 |
| 危險操作沒有確認機制 | Day 25 |
| 主迴圈的程式碼跟 UI 混在一起 | Day 27 重構 |
明天先處理第一個:建立一個乾淨的工具註冊系統。
stop_reason == "tool_use" → 執行 → 把結果傳回tool_result 要以 user 角色傳回,tool_use_id 必須對應assistant 訊息要放整個 response.content,不能只放文字max_turns
明天:把工具系統做好,讓新增工具變成一件輕鬆的事。