所有入門單元免費開放 · 課程內容持續更新中

從零寫出 Agent 迴圈

把第一堂的白話流程寫成真正能執行的 Python 程式:呼叫模型、執行它要求的工具、把結果交回去,直到它完成任務為止,並加上步數上限避免無限迴圈。

第 11.3 堂約 18 分鐘

這是這個單元最核心的一堂課。前兩堂你已經知道 Agent 是「模型+工具+迴圈」,也寫好了工具說明書,看到模型回傳 tool_use。現在要補上最後一塊:真的執行工具,並把結果交回去。

完成後,你會有一個不到一百行、不依賴任何框架的 Agent。程式有點長,但每一段都很單純,我們一段一段來。

先看懂一來一回的格式

還記得 LLM API 單元的多輪對話嗎?每一輪都把整份 messages 送出去。Agent 迴圈也一樣,只是多了兩種新的內容區塊:

訊息往返的順序圖:程式送出任務,模型回傳 tool_use 並附上 id,程式執行工具後送回 tool_result 並帶上相同的 id,最後模型回傳文字答案並以 end_turn 結束

工具結果是放在 user 訊息裡送回去的,並且用 id 對應到當初的請求。

送回工具結果的格式是這樣:

{
    "role": "user",
    "content": [
        {
            "type": "tool_result",
            "tool_use_id": "toolu_01...",  # 抄模型給的 block.id
            "content": "檔案內容……",
        }
    ],
}

如果模型一次要求好幾個工具,就把所有 tool_result 放在同一則 user 訊息的清單裡一起送回。

準備工作資料夾

在程式旁邊建立一個 workspace 資料夾,放一個 notes.txt,內容隨意,例如:

本週待辦
- 週三前交出季報
- 預約牙醫
- 幫媽媽訂生日蛋糕

Agent 只能讀這個資料夾裡的東西,這是我們的第一道安全措施。

完整程式

建立 agent.py:

from pathlib import Path
from dotenv import load_dotenv
import anthropic

load_dotenv()
MODEL = "claude-sonnet-5-5"  # 可以換成其他模型 ID
client = anthropic.Anthropic()

WORKSPACE = Path("workspace").resolve()
MAX_STEPS = 10  # 最多跑幾輪,避免無限迴圈

SYSTEM = "你是一個檔案助理,只能透過工具查看 workspace 資料夾。用繁體中文回答。"

TOOLS = [
    {
        "name": "list_files",
        "description": "列出 workspace 資料夾裡所有檔案的相對路徑。想知道有哪些檔案時先用這個。",
        "input_schema": {"type": "object", "properties": {}, "additionalProperties": False},
    },
    {
        "name": "read_file",
        "description": "讀取 workspace 裡一個文字檔的內容。path 是 list_files 回傳的相對路徑。",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string", "description": "相對路徑,例如 notes.txt"}},
            "required": ["path"],
            "additionalProperties": False,
        },
    },
]


def safe_path(relative: str) -> Path:
    """把相對路徑轉成真實路徑,並確認它沒有跑出 workspace。"""
    path = (WORKSPACE / relative).resolve()
    if not path.is_relative_to(WORKSPACE):
        raise ValueError("不允許存取 workspace 以外的路徑")
    return path


def run_tool(name: str, args: dict) -> str:
    if name == "list_files":
        files = [str(p.relative_to(WORKSPACE)) for p in WORKSPACE.rglob("*") if p.is_file()]
        return "\n".join(files) or "(資料夾是空的)"
    if name == "read_file":
        return safe_path(args["path"]).read_text(encoding="utf-8")
    raise ValueError(f"沒有這個工具:{name}")


def run_agent(task: str) -> str:
    messages = [{"role": "user", "content": task}]

    for step in range(1, MAX_STEPS + 1):
        response = client.messages.create(
            model=MODEL,
            max_tokens=16000,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )
        # 1. 模型的完整回應原封不動存回歷史
        messages.append({"role": "assistant", "content": response.content})

        # 2. 模型說完了:回傳最後的文字
        if response.stop_reason == "end_turn":
            return "".join(b.text for b in response.content if b.type == "text")

        # 3. 不是要用工具、也不是說完了(例如被截斷或拒絕):停下來
        if response.stop_reason != "tool_use":
            return f"(Agent 意外停止,原因:{response.stop_reason})"

        # 4. 執行模型要求的每一個工具
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            print(f"[第 {step} 步] 呼叫 {block.name},參數 {block.input}")
            try:
                result = run_tool(block.name, block.input)
                is_error = False
            except Exception as e:
                result = f"錯誤:{e}"
                is_error = True
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result,
                "is_error": is_error,
            })

        # 5. 所有結果放在同一則 user 訊息送回去,進入下一輪
        messages.append({"role": "user", "content": tool_results})

    return "(已達步數上限,任務沒有完成)"


if __name__ == "__main__":
    task = input("請輸入任務:")
    print("\n" + run_agent(task))

執行看看:

python agent.py

輸入「幫我看看資料夾裡有什麼,整理出所有待辦事項」,你會看到類似:

[第 1 步] 呼叫 list_files,參數 {}
[第 2 步] 呼叫 read_file,參數 {'path': 'notes.txt'}

你這週有三件待辦:……

模型自己決定先列檔案、再讀檔案、最後回答。你沒有寫死這個順序。這就是 Agent。

逐段拆解

run_tool:真正動手的地方。模型只給名稱和參數,由這個函式決定怎麼執行。

safe_path:把模型給的路徑轉成真實路徑後,檢查它還在 workspace 裡面。模型給的參數不能完全信任,例如 ../../重要文件,這一行會把它擋下來。

步驟 1:把 response.content 整份存回去。上一個單元說過原因,這裡更重要:模型下一輪必須看到自己剛剛提出的 tool_use,才能對上你送回的結果。

步驟 3:除了 tool_use 和 end_turn,還可能出現 max_tokens(回答被截斷)或 refusal(拒絕執行)。遇到這些情況,直接停下來比盲目繼續安全。

步驟 4 的 try:檔案不存在、路徑不合法時,不要讓整個程式崩潰。把錯誤訊息交回給模型,並標記 is_error,它通常會自己換個方法,例如先用 list_files 確認正確檔名。

MAX_STEPS:如果模型一直重複呼叫工具、遲遲不結束,迴圈最多跑十輪就停。每一輪都是一次付費的 API 呼叫,這個上限保護你的帳單。

這就是框架在做的事

你剛剛寫的,就是各種 Agent 框架的核心。Anthropic SDK 也有一個 tool runner 可以幫你跑這個迴圈,OpenAI、Google 的 SDK 也有類似功能。差別只在於它們幫你處理了更多細節。現在你知道裡面在發生什麼了,之後無論用哪一套,遇到問題都能看懂。

可以這樣問 AI

這是我手寫的 Agent 迴圈 agent.py。請幫我新增一個 search_text 工具:在 workspace 所有 .txt 檔裡搜尋關鍵字,回傳出現的檔名與那一行內容。請沿用現有的 safe_path 檢查,並更新 TOOLS 與 run_tool。

行動練習
  1. 建立 workspace 和 notes.txt,執行 agent.py,觀察模型用了哪些工具、順序為何。
  2. 在 workspace 裡多放兩個檔案,例如 shopping.txt 和 ideas.txt,問一個需要讀好幾個檔案才答得出來的問題。
  3. 請 Agent 讀一個不存在的檔案,觀察它收到錯誤後怎麼調整。
  4. 把 MAX_STEPS 改成 1 再執行,確認程式會在一輪後停下並印出提示。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。