Agentic Research

This article is not yet available in English. You are reading the Traditional Chinese original. The English edition will appear here once it is translated.

Browse articles that do have an English edition

你的第一個 Agent:一個會用工具的 Hello World

2026/09/3028 min readBryan Chan閱讀中文原文
TopicsAI AgentTutorialLLMTools

這篇文章要帶你不用任何框架,手寫一個真的會做事的 Agent:它能查天氣、能算數學,會自己決定下一步、自己循環,直到任務完成才停。先說清楚這裡的「Hello World」指什麼:不是印出一行字,而是一個能自主完成多步驟任務的最小循環——麻雀雖小,五臟俱全,真實 Agent 系統的所有核心問題它都會碰到。為什麼不用 LangChain 這類框架直接開始?因為 Agent 的核心是一個不到一百行的迴圈,親手寫過一次,你之後看任何框架的文件都能一眼認出「這段在替我做循環、那段在替我做解析」,而不是在背 API。前置條件:完成 第一次呼叫 LLM API,能順利發出請求。

先對齊一下概念:AI Agent 是什麼 那篇說過,Agent = LLM + 上下文 + 工具 + 循環。這篇就是把這四個零件實際組起來。

Agent 循環的六個步驟

我們要做的事情可以拆成六步,之後的程式碼就是這六步的直接翻譯:

  1. 定義工具:寫好普通的 Python 函式,再為每個函式準備一段「給模型看的說明」。
  2. 呼叫 LLM:把任務、工具說明、之前的所有動作與結果,打包成 messages 送給模型。
  3. 解析工具呼叫:要求模型用約定的 JSON 格式回答「我要呼叫某工具、參數是某值」或「任務完成、最終答案是什麼」。
  4. 執行工具:你的程式依照解析結果去呼叫對應的 Python 函式。模型從頭到尾沒有執行任何東西,它只是「說」要呼叫什麼。
  5. 把結果餵回去:把工具回傳值追加進 messages,讓模型下一輪看得到。
  6. 重複:回到第 2 步,直到模型宣布完成,或觸發保險絲(最大輪數)。
   任務 ──→ [2] 呼叫 LLM ──→ [3] 解析輸出
                ↑                  │
                │        ┌─────────┴─────────┐
                │     要用工具            任務完成
                │        ↓                   ↓
   [5] 結果餵回 ←── [4] 執行工具          輸出答案,結束

這六步裡,真正需要「AI」的只有第 2 步,其餘五步都是普通程式:字串解析、函式呼叫、清單追加、條件判斷。記住這個比例——所謂 Agent 框架,做的事就是把第 1、3、4、5、6 步封裝成可重用的組件,再加註記憶體管理、錯誤處理與可觀測性。先手寫一遍,之後讀框架文件時你會不斷產生「啊,這就是我那百來行裡的某一段」的既視感。

動手實作:工具、協議與完整程式

準備兩個 mock 工具

工具就是普通函式。本篇用兩個 mock(模擬)工具:查天氣回傳寫死的假資料,計算機做四則運算。用 mock 的原因很單純——學習目標是循環結構,不是接真實 API;之後要換成真天氣服務,只要改函式內部實作,對外介面不動,Agent 的其他部分完全不用改。

# ── 工具實作 ─────────────────────────────────────────────
# 假資料:重點是循環結構,不是真的接天氣服務
FAKE_WEATHER = {
    "台北": "26°C,降雨機率 70%,悶熱",
    "台中": "28°C,降雨機率 20%,多雲",
    "高雄": "29°C,降雨機率 10%,晴天",
}

def get_weather(city: str) -> str:
    """查詢城市今日天氣(模擬資料)。"""
    return FAKE_WEATHER.get(city, f"查無 {city} 的天氣資料")

# 計算機不能直接 eval 任意字串——模型的輸出是不可信輸入。
# 這裡做示範級防護:只放行數字與運算符號,並清空內建函式。
# 真實專案請改用專門的安全運算式解析套件,不要沿用 eval。
ALLOWED_CHARS = set("0123456789+-*/(). ")

def calculate(expression: str) -> str:
    """計算四則運算式,例如 '(350*2)+120'。"""
    if not set(expression) <= ALLOWED_CHARS:
        return "錯誤:運算式含有非法字元"
    try:
        return str(eval(expression, {"__builtins__": {}}, {}))
    except Exception as e:
        return f"計算失敗:{e}"

# 工具註冊表:名稱 → 實作函式。說明文字單獨放進 system prompt 給模型看
TOOLS = {
    "get_weather": get_weather,
    "calculate": calculate,
}

兩個設計重點。第一,工具回傳字串而不是結構化物件——因為結果最終要塞回 messages 給模型讀,字串最直接。第二,工具要能優雅地失敗:查無資料、算式非法時回傳錯誤說明而不是拋例外,這樣模型有機會看到錯誤、自己修正參數重試。這個特性在真實系統裡更重要:真實 API 一定會在某個時刻失敗,能把失敗轉述給模型的工具設計,讓 Agent 具備自我修復的第一步。

還有一個伏筆:TOOLS 註冊表把「函式實作」與「給模型看的名稱」綁在一起,而工具的用途說明寫在 system prompt 裡。說明寫得好壞,直接決定模型選不選對工具、參數填不填得對——這門手藝在下一篇 Function Calling 裡會是重點,這裡先埋下種子。

與模型約定文字協議

聊天模型的輸出是自由文字,你要它「呼叫工具」,本質上是要它輸出一段你的程式能解析的文字。所以第二步是定一份協議,寫進 system prompt:每一輪只允許輸出一個 JSON 物件,兩種格式之一——要用工具就輸出 {"action": "工具名", "args": {...}},做完了就輸出 {"action": "final_answer", "answer": "..."}。

SYSTEM_PROMPT = """你是一個能使用工具完成任務的 Agent。
每一輪你只能輸出一個 JSON 物件,格式限以下兩種:

1. 需要呼叫工具時:
{"action": "工具名稱", "args": {"參數名": "參數值"}}

2. 任務完成、給出最終答案時:
{"action": "final_answer", "answer": "給使用者的最終回答"}

可用工具:
- get_weather:查詢指定城市今日天氣。參數:city(城市名,例如「台北」)
- calculate:計算四則運算式。參數:expression(僅含數字與 + - * / ( ) 的字串)

規則:
- 嚴禁輸出 JSON 以外的任何文字,包括解釋與程式碼圍欄。
- 工具結果只能來自系統餵回給你的訊息,嚴禁自行編造。
- 每輪只呼叫一個工具,然後等待結果。"""

這就是「文字協議」驅動的 Agent——Function Calling 被發明之前的經典做法,優點是任何聊天模型都能跑,缺點是模型不一定守規矩(後面踩坑一節會處理)。主流 API 現在有原生的 Function Calling 機制,可靠性高得多,那是下一篇 Function Calling 入門 的主題;先寫過土砲版,你才知道原生機制幫你解決了什麼。

完整程式

把六個步驟組起來。以下程式存成 mini_agent.py,在配好環境變數的虛擬環境裡 pip install openai 後直接執行。讀程式碼時抓兩條主線:run_agent 裡的 for 迴圈就是六步循環本身;MAX_ITERATIONS 與 MAX_PARSE_FAILS 兩個常數是保險絲,下一節會專門討論為什麼它們是上線必備:

"""最小可用 Agent:會查天氣、會算數、自己循環到任務完成。

前置:pip install openai,且環境變數 DEEPSEEK_API_KEY 已設定。
"""
import json
import os

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com/v1",
)
MODEL = "deepseek-chat"

FAKE_WEATHER = {
    "台北": "26°C,降雨機率 70%,悶熱",
    "台中": "28°C,降雨機率 20%,多雲",
    "高雄": "29°C,降雨機率 10%,晴天",
}

def get_weather(city: str) -> str:
    """查詢城市今日天氣(模擬資料)。"""
    return FAKE_WEATHER.get(city, f"查無 {city} 的天氣資料")

ALLOWED_CHARS = set("0123456789+-*/(). ")

def calculate(expression: str) -> str:
    """計算四則運算式(示範級安全防護,勿直接用於生產)。"""
    if not set(expression) <= ALLOWED_CHARS:
        return "錯誤:運算式含有非法字元"
    try:
        return str(eval(expression, {"__builtins__": {}}, {}))
    except Exception as e:
        return f"計算失敗:{e}"

TOOLS = {"get_weather": get_weather, "calculate": calculate}

SYSTEM_PROMPT = """你是一個能使用工具完成任務的 Agent。
每一輪你只能輸出一個 JSON 物件,格式限以下兩種:
1. 需要呼叫工具時:{"action": "工具名稱", "args": {"參數名": "參數值"}}
2. 任務完成時:{"action": "final_answer", "answer": "給使用者的最終回答"}

可用工具:
- get_weather:查詢指定城市今日天氣。參數:city(城市名)
- calculate:計算四則運算式。參數:expression(僅含數字與 + - * / ( ) 的字串)

規則:
- 嚴禁輸出 JSON 以外的任何文字,包括解釋與程式碼圍欄。
- 工具結果只能來自系統餵回給你的訊息,嚴禁自行編造。
- 每輪只呼叫一個工具,然後等待結果。"""

def extract_json(text: str) -> dict:
    """從模型輸出解析 JSON,容忍偶爾混入的程式碼圍欄。"""
    cleaned = text.strip()
    if cleaned.startswith("```"):
        # 模型不守規矩、把 JSON 包進 ```json ... ``` 時,剝掉圍欄
        cleaned = cleaned.split("```")[1]
        if cleaned.startswith("json"):
            cleaned = cleaned[4:]
    return json.loads(cleaned.strip())

MAX_ITERATIONS = 10   # 保險絲:無論發生什麼事,跑滿就停
MAX_PARSE_FAILS = 3   # 連續格式錯誤達到門檻就放棄

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

    for iteration in range(1, MAX_ITERATIONS + 1):
        # ── 步驟 2:呼叫 LLM ──
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            temperature=0,  # Agent 要行為穩定可重現,隨機性調到最低
        )
        reply = resp.choices[0].message.content
        print(f"[第 {iteration} 輪] 模型輸出:{reply}")

        # ── 步驟 3:解析模型決策 ──
        try:
            decision = extract_json(reply)
            parse_fails = 0
        except (json.JSONDecodeError, IndexError):
            parse_fails += 1
            if parse_fails >= MAX_PARSE_FAILS:
                return "任務失敗:模型多次未按約定格式輸出"
            # 把錯誤餵回去,給模型自我修正的機會
            messages.append({"role": "assistant", "content": reply})
            messages.append({
                "role": "user",
                "content": f"格式錯誤:你的輸出不是合法 JSON。請嚴格按約定重新輸出(第 {parse_fails} 次違規)。",
            })
            continue

        # 模型自己的發言要以 assistant 角色進歷史,對話才完整
        messages.append({"role": "assistant", "content": reply})

        # ── 終止條件:模型宣布完成 ──
        if decision.get("action") == "final_answer":
            return decision.get("answer", "(模型未提供答案內容)")

        # ── 步驟 4:執行工具 ──
        tool_name = decision.get("action", "")
        args = decision.get("args", {})
        func = TOOLS.get(tool_name)
        if func is None:
            result = f"錯誤:找不到工具 '{tool_name}',可用的有 {list(TOOLS)}"
        else:
            try:
                result = func(**args)
            except TypeError as e:
                result = f"參數錯誤:{e}。請檢查參數名稱與數量。"

        # ── 步驟 5:結果餵回,進入下一輪 ──
        print(f"[第 {iteration} 輪] 執行 {tool_name}({args}) → {result}")
        messages.append({
            "role": "user",
            "content": f"工具 {tool_name} 的執行結果:{result}",
        })

    # ── 終止條件:保險絲燒斷 ──
    return f"任務未在 {MAX_ITERATIONS} 輪內完成,已強制終止"


if __name__ == "__main__":
    task = (
        "幫我查台北今天的天氣,判斷適不適合野餐;"
        "再算一下:兩個人每人花 350 元的話,800 元的預算夠不夠?"
    )
    print(f"任務:{task}\n")
    print("\n最終答案:", run_agent(task))

逐輪拆解:一次執行實際發生了什麼

跑起來後,終端機會印出類似這樣的過程(模型用語每次略有不同):

任務:幫我查台北今天的天氣,判斷適不適合野餐;再算一下:兩個人每人花 350 元的話,800 元的預算夠不夠?

[第 1 輪] 模型輸出:{"action": "get_weather", "args": {"city": "台北"}}
[第 1 輪] 執行 get_weather({'city': '台北'}) → 26°C,降雨機率 70%,悶熱
[第 2 輪] 模型輸出:{"action": "calculate", "args": {"expression": "350*2"}}
[第 2 輪] 執行 calculate({'expression': '350*2'}) → 700
[第 3 輪] 模型輸出:{"action": "final_answer", "answer": "台北今天降雨機率 70%,不適合野餐。預算方面:兩人共需 700 元,800 元預算足夠,還剩 100 元。"}

最終答案: 台北今天降雨機率 70%,不適合野餐。預算方面:兩人共需 700 元,800 元預算足夠,還剩 100 元。

注意三件事:

第一,任務拆解是模型自己做的。 程式碼裡沒有一行寫「先查天氣再算錢」,是模型看到任務後自己決定呼叫順序。你給的是目標,它規劃的是路徑。

第二,「降雨機率 70% 算不算適合野餐」這個判斷也是模型做的。 工具只回傳事實,判斷標準來自你任務裡的自然語言。想改標準(例如「小雨也去」),改任務描述即可,不用改程式。

第三,每一輪模型看到的都是完整歷史。 messages 清單從 system + 任務出發,每輪追加「模型的決策」與「工具的結果」,下一輪整包重發。Agent 的「記憶」就是這個不斷變長的清單——這直接連到後面要講的成本問題。

順帶把帳算清楚,呼應 上一篇 的計費邏輯:這個任務跑了三輪,就是三次 API 呼叫。第一次的輸入只有 system prompt 加任務;第二次多了第一輪的決策與工具結果;第三次又多一輪。輸入 token 隨輪數線性成長,所以 Agent 任務的成本不是「一次呼叫的價格」,而是「歷史長度 × 輪數」的累積。任務越長越貴,而且成長速度比你想像的快——這是所有 Agent 開發者都要有的直覺。

迴圈終止條件與最大迭代次數

這個迴圈有兩種結束方式,缺一種都不能上線:

正常終止:模型宣布完成。 它輸出 final_answer,你把答案交給使用者。但注意,這依賴模型「自認為做完了」——它可能過早收工(漏做一半就宣布完成),也可能永遠不收工。所以不能只靠它。

強制終止:最大迭代次數(MAX_ITERATIONS)。 這是保險絲。沒有它,一個卡住的 Agent 會無限循環:每一輪都是一次真實的 API 呼叫、燒真實的錢、印真實的日誌,而你如果在睡覺,它會燒到信用卡額度上限為止。「Agent 一夜之間燒掉大把 API 費」的事故,根因幾乎都是沒設迭代上限或花費上限。

除了程式裡的迭代上限,還要在供應商控制台設一道花費上限或餘額告警(多數平台支援,位置各異)。兩道防線的邏輯不同:迭代上限管「單個任務不要失控」,花費上限管「你的整段程式人生不要失控」——包括你自己忘記關掉的測試腳本。兩道都要有。

保險絲的數值怎麼定?看你的任務正常需要幾輪:像上面的例子三輪就結束,設 10 輪就是三倍多的餘裕。原則上是「正常路徑的寬裕上限」,不是「越大越安全」——上限越大,失控時燒得越多。

進階一點的做法還有兩種,值得知道:

  • 連續失敗計數:本程式已實作——解析連續失敗三次就放棄,避免跟一個不守格式的模型無限拉扯。工具連續回傳同樣錯誤時同理。
  • 重複動作偵測:如果模型連續兩輪發出「相同工具+相同參數」的呼叫,代表它卡住了——同樣的輸入只會得到同樣的結果,它卻期待不一樣,這就是「瘋」的定義。偵測到就注入一句「你已重複相同呼叫且未獲得新資訊,請改變策略或直接給出結論」,給它一次掙脫的機會;再重複就直接終止。實作上只需記住上一輪的 (tool_name, args) 做比對。

還有一個隱性的終止條件容易被忽略:上下文長度。 每輪都重發全部歷史,messages 越來越長,某一輪就會撞上上一篇講過的 context length exceeded。玩具任務撞不到,但幾十輪的長任務一定會。真實系統的解法是裁剪舊歷史或把舊歷史摘要壓縮,這屬於 Agent 記憶管理的範疇,可先看 記憶中樞架構 建立概念。

常見錯誤與踩坑

以下每一條都是文字協議 Agent 的真實高頻故障,按「遇到機率高、危害大」排序。建議先掃過一遍再開始除錯,能省掉大量盲猜的時間。

一、模型把 JSON 包進程式碼圍欄。 你叫它「只輸出 JSON」,它輸出 ```json {...} ```——因為它在訓練資料裡見過太多「JSON 都放在程式碼區塊裡」的範例。json.loads 直接爆掉。解法就是程式裡的 extract_json:先剝圍欄再解析。用原生 Function Calling 機制可以大幅減少這類問題。

二、模型幻覺工具結果。 最陰險的一種失敗:模型不等你餵結果,自己編一個「工具回傳:晴天」繼續往下走,最終答案建在空氣上。防法:system prompt 明令「工具結果只能來自系統餵回的訊息」(本程式已寫);更重要的是在程式側驗證——只有你的程式真的執行過工具,才把結果寫進歷史,模型的「自說自話」永遠標記為 assistant 發言而不是工具結果。

三、歷史順序殘缺。 把工具結果塞回 messages 時,漏掉先追加模型自己的 assistant 發言,歷史就變成「user、user、user……」的怪異序列,模型看不懂哪句話是自己說的,行為開始混亂。規矩:每一輪,先記模型的發言(assistant),再記工具結果。

四、eval 執行模型輸出。 本篇的 calculate 用了 eval,雖然做了字元白名單與清空 builtins,仍然只算示範級防護。原則要記死:模型的輸出是不可信輸入,等同於「陌生使用者在你的伺服器上打字」。真實專案用專門的安全運算式解析套件,或 dry-run 模式的計算引擎。

五、工具函式直接拋例外。 工具因為參數錯誤炸出 Python traceback,整個 Agent 程式跟著崩潰。正確做法是 catch 住例外,把錯誤訊息轉成字串餵回模型——錯誤對 Agent 來說不是終點,是資訊,模型看到「參數錯誤:缺少 city」往往能自己改對重試。

六、金鑰出現在 log 裡。 除錯時把整個請求物件印出來,API key 跟著進日誌檔、進 issue 截圖。印 messages 就好,別印 client 設定。

七、用小模型硬撐文字協議。 文字協議對模型的指令遵循能力有真實門檻:它要同時做到「理解任務、選對工具、填對參數、嚴格守住輸出格式」。能力不足的模型會反覆輸出解釋性廢話、半截 JSON、或自創欄位名,讓你的 MAX_PARSE_FAILS 保險絲天天燒斷。症狀辨識很簡單:同一個 prompt,換一個較強的模型就穩定守規矩,那就是模型能力問題,不是你程式的問題。解法要嘛換模型,要嘛改用下一篇的原生 Function Calling——後者把「輸出合法結構」的責任從 prompt 約束轉移到 API 與模型訓練層,對模型的要求低得多。

從玩具到現實,還缺什麼

你現在有一個能跑、會用工具、有保險絲的 Agent 了。在慶祝之前,誠實清點一下它與生產系統的差距——每一項差距同時也是你後續學習地圖上的一站:

  • 工具是真的:真實 API 會逾時、會限流、會改版,需要重試策略與錯誤分類 → 這是工程問題,不是模型問題。
  • 呼叫機制是原生的:文字協議換成 API 內建的 Function Calling,解析可靠性大幅提升 → 下一篇就講。
  • 記憶是管理過的:長任務需要裁剪、摘要、外部記憶 → 記憶中樞架構。
  • 品質是可測量的:改了 prompt 之後怎麼知道變好還是變壞 → LLM 評估指南。
  • 循環是被框架接管的:LangChain、LangGraph 這類框架做的事,本質上就是把你剛寫的迴圈產品化,加上狀態管理與生態整合 → 三層 Agent 框架 給你全景,LangChain 完全指南 是單一框架的深入。

框架不是必需品,理解才是。你現在已經能看懂任何 Agent 框架文件裡最重要的那一頁。

下一步