昨天我們把整條 RAG 流程封裝成一個工具,也幫它寫了一份讓模型看得懂的說明書,但實際跑的時候工具什麼時候該被呼叫,還是我們自己在程式裡寫死的 pipeline.answer(問題) 這一行。
而 Agent 跟一般聊天機器人最根本的差別就在工具不再由程式預先決定,而是交給模型自己判斷,該不該用工具、要用哪一個、參數要帶什麼,全部由模型看著當下的任務自己決定,這就是我們今天要實作的 Tool Use,也就是很多框架和 API 裡講的 Function Calling。
這篇的完整程式碼一樣會放在 GitHub repo:https://github.com/AUSTIN2526/30-days-ironman-agent,可以直接下載下來執行。
先講一個很多人一開始都會搞錯的地方,就是以為模型接上工具之後,它自己跑去查了資料庫,但實際上不是這樣,模型從頭到尾只做了一件事,就是輸出一段文字。

它吐出來的那段 JSON 跟它平常寫的句子完全是同一回事,都只是預測出來的 Token,它沒有連上任何東西,也沒有能力連上任何東西,真正去解析那段 JSON、去執行函式、再把結果塞回對話的,從頭到尾都是我們自己寫的程式。
這件事決定了整個系統的安全問題,因為工具要不要給、參數合不合法、能不能執行權限全部都在我們手上,說白一點就是模型講什麼不重要,我們願意做什麼才重要,等到後面要處理 Prompt Injection 的時候,這個觀念會更加重要。
既然模型只會講話那我們就需要兩份東西,一份是給模型看的說明書,一份是實際會被執行的函式,我把它們放在同一個註冊表裡:
# tools.py
TOOLS = {
"search_employee_handbook": {
"func": search_employee_handbook, # 真正會被執行的東西
"schema": { # 給模型看的說明書
"name": "search_employee_handbook",
"description": (
"查詢公司員工手冊,內容包含請假制度、報帳與差旅、遠端工作、設備報修、"
"薪資獎金、資訊安全與停車交通等規定。不包含個人的請假紀錄或薪資明細。"
),
"parameters": {"query": "要查詢的問題,用完整的句子描述"},
},
},
"calculate": {
"func": calculate,
"schema": {
"name": "calculate",
"description": "計算數學算式,只支援加減乘除。需要精確數字時使用,不要自己心算。",
"parameters": {"expression": "例如 14 - 3 或 8 * 5"},
},
},
...
}
而今天除了昨天做好的 RAG,我另外加了兩個很小的工具,一個是計算機,一個是查今天日期,這兩個是刻意加的,因為只有一個工具的時候根本測不出模型會不會選,反正它隨便叫都對,要等到有三個工具,我們才看得出來它到底有沒有在判斷。
另外計算機這個工具要特別小心,很多教學會直接寫 eval(expression),但那等於把模型輸出的字串直接當程式碼執行,只要有人想辦法讓模型吐出一段惡意的字串,你的伺服器就沒了,所以這邊我們要用 ast 自己走一遍語法樹,讓他真的只能進行加減乘除:
def _safe_eval(node):
"""只允許四則運算,不要讓模型丟進來的字串變成 eval 的後門。"""
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
...
raise ValueError("只支援 + - * / 的算式")
接下來要讓模型知道它手上有哪些工具可以用,有很多種作法,比較新的模型通常在 chat template 裡就內建了 tools 參數,可以直接把 schema 傳進去,不過由於我們的模型只有1B左右,所以我想用最土法煉鋼的方式,直接寫在 System Prompt 裡,這樣你也會更清楚它到底是怎麼運作的:
SYSTEM_PROMPT = """你是一個公司內部助理。你可以使用下列工具:
{tool_list}
如果需要用工具,就只輸出一行 JSON,格式如下,不要加任何其他文字:
{{"tool": "工具名稱", "args": {{"參數名": "參數值"}}}}
如果已經有足夠的資訊可以回答,就直接用中文回答,不要再輸出 JSON。"""
這段 Prompt 有兩個地方是重點,第一是明確規定輸出格式因為我們等一下要用程式去解析它;第二是最後那句話,要讓模型知道什麼時候該收手,不然它很可能查完了還繼續吐 JSON,在那邊鬼打牆,而這部分的最重要的地方就是取得模型回覆的 JSON,以方便進行多個模型的串接。
def parse_tool_call(text: str) -> dict | None:
"""模型吐出來的是純文字,要自己想辦法把 JSON 撈出來。"""
match = re.search(r"\{.*\}", text, re.S) # 模型常常會在 JSON 前後多寫廢話
if not match:
return None
try:
data = json.loads(match.group())
except json.JSONDecodeError:
return None
if not isinstance(data, dict) or "tool" not in data:
return None
return {"tool": data["tool"], "args": data.get("args", {})}
同時我們在程式內設計了許多 return None 這些做法都是在處理模型不照規矩來的狀況,它可能在 JSON 前面加一句好的我來幫你查,可能少打一個括號,也可能根本就是在正常講話,這些狀況都不該讓程式直接噴錯,解析不出來就當它是在回答,把那段文字原封不動給使用者就好,執行的部分也一樣,模型很常因為幻覺自己掰出一個根本不存在的工具:
def run_tool(name: str, args: dict) -> str:
"""統一的執行入口,模型給的東西一律先檢查再執行。"""
if name not in TOOLS:
return f"錯誤:沒有叫做 {name} 的工具,可用的工具有 {list(TOOLS)}"
try:
return TOOLS[name]["func"](**args)
except TypeError as e:
return f"錯誤:參數不對({e})"
同樣的這裡也是沒有丟出例外,而是回傳一段錯誤訊息,因為這段訊息等一下會被寫回對話裡,模型看到之後就有機會自己改口,換一個真的存在的工具。

把上面這些串起來之後,Agent 的本體其實很短,基本上就是一個迴圈的事情:
for step in range(self.max_steps):
reply = self.llm.chat(messages)
call = parse_tool_call(reply)
if call is None: # 沒有要呼叫工具,那就是最終答案
return reply
name, args = call["tool"], call["args"]
result = run_tool(name, args) # 真正動手的是我們,不是模型
# 把模型說過的話和工具結果都寫回對話,它下一輪才看得到
messages.append({"role": "assistant", "content": reply})
messages.append({"role": "user", "content": f"工具 {name} 的執行結果:\n{result}"})
但要注意一點這裡最重要的是 max_steps,當模型鬼打牆的時候是非常執著的,它可能連續五次都呼叫同一個工具、帶一模一樣的參數,如果沒有上限,這個迴圈就會一直跑下去,要是你接的是 API,那就是一直在燒錢,所以寧可回答失敗,也不要讓它無限跑。
記得最後我沒要把每一輪都要把工具結果寫回 messages,因為模型沒有記憶,它下一輪能看到的東西就只有我們給它的這串對話,這也代表對話會越滾越長 Context Window 的問題就會發生,之後做 Multi-Agent 時會更明顯。
python demo.py
我準備了四個問題,分別對應不同的狀況:
QUESTIONS = [
"我年資三年可以請幾天特休?", # 應該要查手冊
"我已經休了 5 天特休,年資三年還剩幾天?", # 要先查手冊,再算一次減法
"今天幾號?", # 完全不需要查手冊
"幫我算 128 乘以 34", # 該用計算機,不要自己心算
]
問題:我年資三年可以請幾天特休?
第 1 步:呼叫 search_employee_handbook({'query': '年資三年可以請幾天特休'})
結果:[1] 【請假制度 > 特休】員工到職滿半年可請 3 天特休。年資滿一年可請 7 天特休…
回答:根據公司的請假制度,年資三年的員工可以請 14 天的特休。
問題:我已經休了 5 天特休,年資三年還剩幾天?
第 1 步:呼叫 calculate({'expression': '365*3-5'})
結果:1090
回答:您還有 1090 天的年資。
問題:今天幾號?
第 1 步:呼叫 get_today({})
結果:2026-09-29
回答:今天是2026年09月29日。
問題:幫我算 128 乘以 34
第 1 步:呼叫 calculate({'expression': '128 * 34'})
結果:4352
回答:好的,128 乘以 34 等於 4352。
先講好的部分四題它都乖乖照著格式輸出 JSON,一次都沒有跑掉,這其實有點出乎我意料,畢竟 1.5B 的模型已經算很小了,且工具也有正確的選用問特休就去查手冊、問日期就叫 get_today、要算乘法就丟給計算機,沒有自己心算,但是問題出在需要兩個步驟的那一題。
它沒有先去查手冊,而是直接把年資三年當成 365 乘以 3 天,算出 1090 之後還很有自信地跟我說您還有 1090 天的年資,這個答案荒謬到不用查手冊也知道不對,但它就是講得理直氣壯而這甚至是工具調用後產生的幻覺。
所以這次錯的原因是任務拆解,計算機本來就是它需要的工具之一,問題在於它跳過了查資料那一步,把一個要兩步驟才能完成的任務,硬是用一步解決掉。對 1.5B 來說,先查規定拿到 14、再把 14 減掉 5 這種規劃,顯然還是太難了。
這也點出 Agent 真正困難的地方,接工具其實是最簡單的部分,我們今天寫的迴圈也才二十幾行,真正難的是模型有沒有能力把一個任務拆成正確的步驟,而這件事跟模型大小、跟有沒有針對這種任務訓練過,關係非常大。
今天我們讓模型自己決定要不要用工具,也親眼看到 1.5B 在單步驟任務上其實表現得不錯,但一碰到需要規劃的題目就直接露餡,所以明天要來處理這件事,看看在不換模型的前提下,能不能靠著改寫 Prompt 跟調整流程,讓它把任務拆得像樣一點,那我們明天再見!