這篇文章要帶你不用任何框架,手寫一個真的會做事的 Agent:它能查天氣、能算數學,會自己決定下一步、自己循環,直到任務完成才停。先說清楚這裡的「Hello World」指什麼:不是印出一行字,而是一個能自主完成多步驟任務的最小循環——麻雀雖小,五臟俱全,真實 Agent 系統的所有核心問題它都會碰到。為什麼不用 LangChain 這類框架直接開始?因為 Agent 的核心是一個不到一百行的迴圈,親手寫過一次,你之後看任何框架的文件都能一眼認出「這段在替我做循環、那段在替我做解析」,而不是在背 API。前置條件:完成 第一次呼叫 LLM API,能順利發出請求。
先對齊一下概念:AI Agent 是什麼 那篇說過,Agent = LLM + 上下文 + 工具 + 循環。這篇就是把這四個零件實際組起來。
Agent 循環的六個步驟
我們要做的事情可以拆成六步,之後的程式碼就是這六步的直接翻譯:
- 定義工具:寫好普通的 Python 函式,再為每個函式準備一段「給模型看的說明」。
- 呼叫 LLM:把任務、工具說明、之前的所有動作與結果,打包成 messages 送給模型。
- 解析工具呼叫:要求模型用約定的 JSON 格式回答「我要呼叫某工具、參數是某值」或「任務完成、最終答案是什麼」。
- 執行工具:你的程式依照解析結果去呼叫對應的 Python 函式。模型從頭到尾沒有執行任何東西,它只是「說」要呼叫什麼。
- 把結果餵回去:把工具回傳值追加進 messages,讓模型下一輪看得到。
- 重複:回到第 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 框架文件裡最重要的那一頁。
下一步
- 必讀續篇:Function Calling 入門:讓 LLM 真正使用工具——把本篇的文字協議升級成 API 原生機制,並學會控制模型的呼叫行為。
- 想比較真實框架的循環設計差異:Agent 循環架構比較。
- 想讓工具來自外部標準協議而不是寫死在程式裡:MCP 協議指南。