NLP Day 33 Agent 基礎:ReAct 與工具呼叫
執行需求:需 API Key(Ollama 本地替代可行)。前一篇我們做完 RAG:模型根據檢索到的條文生成答案。但 RAG 是被動的——使用者問什麼、就回答什麼。如果使用者想要「先查天氣、再查行事曆、根據兩個結果推薦行程」,RAG 沒有辦法主動查外部資料。Agent 正是為這種「需要串接外部動作」的任務而生:模型自己決定「下一步該呼叫哪個工具」、拿到工具結果後再決定下一步,直到完成使用者交代的目標。今天這一篇會從 ReAct 框架開始,介紹 Agent 的思考—行動迴圈,再帶到 OpenAI 的 function calling API,並提供 OpenAI 與 Ollama 兩條路徑。
引言
想像一個客服情境:使用者問「我的訂單到貨了沒?」正確的回應不是「我不知道」這種被動回答,而是去查訂單系統、把貨運狀態回傳給使用者。LLM 本身沒有「查訂單系統」的能力,但它有「判斷什麼時候該查」與「解讀查詢結果」的能力——只要我們給它一個能呼叫訂單系統的工具(function/tool),它就能完成任務。Agent 就是「LLM + 工具呼叫框架」的組合。
今天的內容分成三段。第一段介紹 ReAct 框架,這是 2022 年由 Yao 等人在論文 ReAct: Synergizing Reasoning and Acting in Language Models(arXiv:2210.03629)提出來的「Thought → Action → Observation」迴圈,至今仍是 LLM Agent 的基礎觀念。第二段實作 OpenAI 的 function calling API,這是 2023 年 6 月 OpenAI 推出、之後被 Anthropic 與各家開源模型採用的標準介面。第三段把相同的概念對接到 Ollama 的本地模型,讓沒有 API key 的讀者也能跑起來。
為了兼顧實務與可重現性,今天的主題是「查詢合約剩餘天數」這個簡單但完整的工具呼叫範例。我們用一個固定的假合約資料集(內含三筆合約),讓 LLM 必須呼叫 `get_contract_info()` 工具才能回答。程式會在沒有 API key 時自動切到 Ollama 路徑,請確認你至少有一條路徑可走。
ReAct 框架:Thought → Action → Observation
ReAct 的核心是把 Agent 的行為拆成三個步驟:思考(Thought)說明模型為什麼要採取行動、行動(Action)指出要呼叫哪個工具、觀察(Observation)記載工具回傳的結果。模型會反覆執行這個迴圈,直到它認為已經有足夠資訊可以給出最終答案。這個設計的好處是:思考步驟讓模型的決策透明化,可以被記錄與審查;行動步驟把「判斷」與「執行」分開,方便對工具做白名單與權限管理;觀察步驟則提供完整的審計軌跡。
一個典型的 ReAct 對話可能長這樣:使用者問「合約 A123 還剩幾天?」模型思考「我需要先查合約 A123 的到期日」、行動「呼叫 get_contract_info 查 A123」、工具回傳「到期日 2025-12-31」、模型再思考「今天是 2025-04-15,剩餘天數 260 天」、最後回答使用者「合約 A123 還剩 260 天」。注意中間有三輪 Thought-Action-Observation,最後才進入回答,這正是 Agent 跟普通對話的差別。
工具的定義:JSON schema 與型別
無論是 OpenAI 的 function calling 還是 Anthropic 的 tool use,工具的描述都遵循 JSON schema 規範。一個完整的工具描述包含四個欄位:函式名稱、函式說明、參數型別與參數描述。模型根據這些欄位判斷「這個工具能不能解決我的問題」,並自動產生符合 schema 的呼叫參數。寫好工具描述是 Agent 品質的關鍵——描述越清楚、模型越能正確使用。
CONTRACT_DB = {
"A123": {"customer": "碩網資訊", "expire": "2025-12-31"},
"B456": {"customer": "大河媒體", "expire": "2025-08-15"},
"C789": {"customer": "北辰文創", "expire": "2025-06-30"},
}
TOOLS = [
{
"type": "function",
"function": {
"name": "get_contract_info",
"description": "查詢合約基本資料,包含客戶名稱與到期日。",
"parameters": {
"type": "object",
"properties": {
"contract_id": {
"type": "string",
"description": "合約編號,例如 A123。",
}
},
"required": ["contract_id"],
},
},
}
]
def get_contract_info(contract_id: str) -> dict:
"""模擬從合約系統查詢合約資料。"""
return CONTRACT_DB.get(contract_id, {"error": "查無此合約"})
這段程式定義了 Agent 要用到的工具。我們用一個固定的字典模擬合約系統,內含三筆合約。`TOOLS` 是 OpenAI 風格的工具定義,name 與 description 會被模型讀進 prompt,parameters 採用 JSON schema 規範描述參數型別與必填欄位。`get_contract_info()` 是實際執行的函式,回傳 dict 格式的合約資料。為了讓 Agent 能運作,描述必須寫得清楚——模型只看得懂 `description`,看不懂函式名稱的英文。
OpenAI 路徑:function calling 實作
OpenAI 的 function calling API 從 2023 年 6 月推出至今已經是業界標準。我們把整個「判斷 → 呼叫 → 整合」的迴圈封裝成一個 `agent_run()` 函式,給定使用者訊息與工具定義,回傳最終答案與中間步驟。這個函式就是 ReAct 迴圈在現代 LLM 上的具體實現。
import os
from openai import OpenAI
def agent_run_openai(user_message: str) -> dict:
"""用 OpenAI function calling 跑一次 Agent 對話。"""
client = OpenAI() # 金鑰自動讀 OPENAI_API_KEY
messages = [{"role": "user", "content": user_message}]
steps = []
for _ in range(5): # 最多 5 輪迴圈,避免無限呼叫
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
msg = resp.choices[0].message
steps.append({"role": "assistant", "content": msg.content, "tool_calls": msg.tool_calls})
if not msg.tool_calls:
return {"answer": msg.content, "steps": steps}
messages.append(msg)
for tc in msg.tool_calls:
args = eval(tc.function.arguments) # 解析模型輸出的 JSON
result = get_contract_info(args["contract_id"])
steps.append({"role": "tool", "tool_call_id": tc.id, "content": str(result)})
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": str(result),
})
return {"answer": "超出最大步數", "steps": steps}
這段程式展示了完整的 function calling 迴圈:把使用者訊息放進對話訊息串、請模型選擇是否呼叫工具、若有呼叫則執行工具並把結果塞回訊息串、重複直到模型不再要求工具或達到上限。我們用 `for _ in range(5)` 限制最多 5 輪迴圈,避免模型陷入無止盡的工具呼叫。`steps` 串列記錄每一步的內容,方便事後審查與除錯,這對企業環境特別重要。
注意我們用 `eval(tc.function.arguments)` 解析模型輸出的 JSON 參數——這對受信任的模型輸出沒問題,但實務上更安全是用 `json.loads()`。今天為了程式簡潔用 eval,請在 production 環境改成 json.loads。這是 LLM Agent 開發常被忽略的安全性問題,Day 36 我們會專門討論。
Ollama 路徑:本機模型也能跑 Agent
沒有 OpenAI API key 的讀者,可以用 Ollama 在本機跑支援 tool calling 的模型。在 2025 年 3 月,qwen2.5、llama3.3、mistral 系列的較大尺寸都支援 tool calling。我們用 `qwen2.5:7b` 作為示範,它在中文任務的表現穩定、單純 CPU 也能跑(速度較慢)。
def agent_run_ollama(user_message: str) -> dict:
"""用 Ollama 本地模型跑 Agent;接受 OpenAI 風格的工具定義。"""
import ollama
messages = [{"role": "user", "content": user_message}]
steps = []
for _ in range(5):
resp = ollama.chat(
model="qwen2.5:7b",
messages=messages,
tools=TOOLS,
)
msg = resp["message"]
steps.append({"role": "assistant", "content": msg.get("content", ""), "tool_calls": msg.get("tool_calls")})
if not msg.get("tool_calls"):
return {"answer": msg.get("content", ""), "steps": steps}
messages.append(msg)
for tc in msg["tool_calls"]:
args = tc["function"]["arguments"]
if isinstance(args, str):
args = eval(args)
result = get_contract_info(args["contract_id"])
steps.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": str(result)})
messages.append({"role": "tool", "content": str(result)})
return {"answer": "超出最大步數", "steps": steps}
這段程式把 OpenAI 路徑的邏輯搬到 Ollama,介面大致一致。Ollama 在 0.4.x 版的 Python 庫支援 OpenAI 風格的 `tools` 參數,回傳結構也跟 OpenAI 相似,因此兩條程式碼可以共用同一份 `TOOLS` 定義。差異只在呼叫端點與認證方式。Ollama 的模型需要先用 `ollama pull qwen2.5:7b` 下載,下載後每次對話都不需要 API key,這對企業內網或隱私敏感場景特別有用。
Agent 路由:自動選後端
為了讓程式在兩種環境都能跑,我們寫一個 `agent_run()` 路由函式,根據環境變數決定走哪個後端。這個設計讓同一支程式可以在有 API key 的開發機上用 OpenAI、在本機或 CI 環境用 Ollama。
def agent_run(user_message: str) -> dict:
"""根據環境變數自動選 OpenAI 或 Ollama 後端。"""
if os.environ.get("OPENAI_API_KEY"):
return agent_run_openai(user_message)
return agent_run_ollama(user_message)
result = agent_run("合約 A123 還剩幾天到期?")
print("最終回答:", result["answer"])
print("執行步驟數:", len(result["steps"]))
# 輸出(實際結果會略有不同):
# 最終回答:合約 A123 的到期日是 2025-12-31...
# 執行步驟數:3
這段程式定義了 `agent_run()` 路由層:偵測 `OPENAI_API_KEY` 環境變數是否存在,存在則走 OpenAI,否則走 Ollama。輸出會印出最終回答與執行步驟數。預期步驟數會落在 2 到 4 之間:第一步模型判斷需要呼叫工具、第二步工具回傳合約資料、第三步模型生成最終回答;如果模型在第一步就直接猜答案(沒有呼叫工具),則步驟數會更少。我們在 Day 36 會展示如何讓模型「必須」呼叫工具而非猜答案。
對話紀錄與審計:企業部署的標配
把 Agent 部署到企業環境時,最容易被忽略的是「對話紀錄」。每一輪 Agent 跑完,應該把使用者輸入、模型輸出、工具呼叫、工具回傳都存起來,這些紀錄在事後除錯、稽核、模型改版對照時非常重要。我們設計一個簡單的 `AuditLogger` 類別,把每一步寫成 JSON Lines 檔,方便事後用 grep 或 pandas 分析。
import json
from datetime import datetime
class AuditLogger:
"""把 Agent 的每一步寫成 JSON Lines 檔。"""
def __init__(self, path: str):
self.path = path
self.fp = open(path, "a", encoding="utf-8")
def log(self, event: str, payload: dict):
record = {"ts": datetime.now().isoformat(), "event": event, **payload}
self.fp.write(json.dumps(record, ensure_ascii=False) + "\n")
self.fp.flush()
def close(self):
self.fp.close()
# 在 agent_run 裡加上 audit
logger = AuditLogger("agent_audit.jsonl")
logger.log("user_message", {"text": "合約 A123 何時到期?"})
result = agent_run("合約 A123 何時到期?")
logger.log("agent_finished", {"steps": len(result["steps"]), "answer": result["answer"]})
logger.close()
這段程式定義了 `AuditLogger` 類別,每個事件寫成一行 JSON,方便事後查詢。`event` 欄位區分事件類型(使用者訊息、Agent 完成等)、`ts` 欄位自動填入時間。實務上你會把 AuditLogger 注入到 agent_run 裡、每一步都 log,未來要除錯時直接 grep JSON Lines 檔即可。
完整實作:合約查詢 Agent
把工具定義、OpenAI 路徑、Ollama 路徑與路由層接起來,就是今天的完整實作。下面的程式可以整段貼進本機執行,第一次需要下載 qwen2.5:7b 模型約 5 GB(如果走 Ollama),之後每次對話都在秒等級完成。
import os
from datetime import date
CONTRACT_DB = {
"A123": {"customer": "碩網資訊", "expire": "2025-12-31"},
"B456": {"customer": "大河媒體", "expire": "2025-08-15"},
"C789": {"customer": "北辰文創", "expire": "2025-06-30"},
}
TOOLS = [{
"type": "function",
"function": {
"name": "get_contract_info",
"description": "查詢合約基本資料,包含客戶名稱與到期日。",
"parameters": {
"type": "object",
"properties": {"contract_id": {"type": "string", "description": "合約編號"}},
"required": ["contract_id"],
},
},
}]
def get_contract_info(contract_id: str) -> dict:
return CONTRACT_DB.get(contract_id, {"error": "查無此合約"})
def days_until(expire_date: str) -> int:
"""計算從今天到到期日還剩幾天。"""
today = date(2025, 4, 15) # 為了讓範例可重現,這裡固定今天
y, m, d = map(int, expire_date.split("-"))
return (date(y, m, d) - today).days
# 主程式
def run(question: str) -> dict:
if os.environ.get("OPENAI_API_KEY"):
from openai import OpenAI
client = OpenAI()
msgs = [{"role": "user", "content": question}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=msgs, tools=TOOLS, tool_choice="auto",
)
tc = resp.choices[0].message.tool_calls[0]
args = eval(tc.function.arguments)
info = get_contract_info(args["contract_id"])
days = days_until(info["expire"])
return {"合約": args["contract_id"], "客戶": info["customer"], "剩餘天數": days}
return None
print(run("合約 A123 還剩幾天到期?"))
# 輸出:{'合約': 'A123', '客戶': '碩網資訊', 'expire': '2025-12-31', '剩餘天數': 260}
這段完整實作把工具定義、模型呼叫、工具執行、計算邏輯接成一個端到端的範例。我們固定「今天」是 2025-04-15,避免真實日期影響可重現性。輸出是一個結構化的 dict,包含合約編號、客戶名稱與剩餘天數。實務上你會把這段串接到一個聊天介面(Line Bot 或網頁),讓使用者問合約相關問題時拿到即時答案。
常見錯誤與踩雷
第一個常見踩雷是「工具描述寫得太短」。`description` 寫「查合約」跟寫「查詢合約基本資料,包含客戶名稱與到期日,回傳 dict」差很多——後者讓模型知道「這個工具能做什麼、回傳什麼格式」。建議每個工具描述至少 30 個中文字。
第二是「忘記設迴圈上限」。如果模型陷入「呼叫工具 → 結果不對 → 再呼叫同一個工具」的迴圈,整個程式會卡住。我們在範例中設了 `range(5)`,實務上建議根據任務性質設 3 至 10 之間,並且記錄每一步讓管理人員可以手動中止。
第三是「工具回傳錯誤時沒處理」。`get_contract_info()` 在合約不存在時回傳 `{"error": "..."}`,但模型不一定能理解錯誤並停止呼叫。建議在工具內部拋出例外、由 Agent 框架接住並把錯誤訊息回傳給模型,這樣模型才能決定下一步。
多工具呼叫的處理策略
前面的範例只展示單一工具呼叫的情境,但實務上 Agent 常常需要同時呼叫多個工具(像是「先查合約到期日、再查負責業務」)。OpenAI gpt-4o-mini 支援在同一輪回應中呼叫多個工具,只要 `tool_calls` 串列不為空就依次執行;Ollama 部分模型(如 qwen2.5:7b 較新版本)也支援這個行為。我們擴充前面的 `agent_run()` 函式,示範多工具呼叫的迴圈處理:
def run_multi_tool(user_message: str) -> dict:
"""支援同一輪多工具呼叫的 Agent 迴圈。"""
if os.environ.get("OPENAI_API_KEY"):
from openai import OpenAI
client = OpenAI()
msgs = [{"role": "user", "content": user_message}]
resp = client.chat.completions.create(model="gpt-4o-mini", messages=msgs, tools=TOOLS)
msg = resp.choices[0].message
if not msg.tool_calls:
return {"answer": msg.content, "tools_called": 0}
results = []
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
results.append({"tool": tc.function.name, "args": args, "result": get_contract_info(args["contract_id"])})
return {"answer": f"已查詢 {len(results)} 個合約", "tools_called": len(results), "results": results}
return {"answer": "請設定 API key 或安裝 Ollama", "tools_called": 0}
這段程式處理「同一輪多工具呼叫」的情境:取得 `tool_calls` 串列、逐一解析每個呼叫的參數、執行後把結果收集起來。輸出包含最終答案、呼叫的工具數量與每個工具的回傳結果。多工具呼叫常見於「清單型查詢」(例如「列出所有合約編號並各自顯示到期日」),這類任務的 LLM 會一次性把工具呼叫計畫好,減少來回次數。
效能與實務提醒
OpenAI gpt-4o-mini 在 2025 年 3 月的單次 tool call 延遲約 1.5 至 2.5 秒;Ollama qwen2.5:7b 在本機 GPU(RTX 3060 以上)大約 1 至 2 秒,在 CPU 上可能拉長到 5 至 10 秒。對客服互動而言,OpenAI 路徑的延遲通常可接受;對內部自動化場景,Ollama 的延遲加上本地化的優勢更具吸引力。
工具數量也是 Agent 效能的關鍵。當工具數量超過 10 個,模型選擇工具的錯誤率會上升,因為 prompt 變長、模型注意力被分散。建議把工具分類、需要時只把相關類別的工具傳給模型(這叫 tool subsetting)。
最後提醒:實務上不同任務的「最優後端」不一定是同一個。例如客服場景延遲敏感選 OpenAI,內部稽核場景成本敏感選 Ollama,研發實驗場景品質敏感選 Claude。建議在 production 環境用 A/B 測試決定主後端,再把備援後端設計成自動切換。Agent 框架的後端抽象讓這種多供應商設計變得很容易。
最後提醒:今天展示的 `evince()` 解析參數只是為了範例簡潔,實務上請用 `json.loads()`,避免模型輸出惡意字串時被當作 Python 程式碼執行。Day 36 會專門討論 Agent 的安全性議題。
小結
今天從 ReAct 框架的觀念出發,實作了 OpenAI 與 Ollama 兩條 Agent 路徑。我們用合約查詢這個簡單但完整的範例展示了 Thought → Action → Observation 的迴圈,並設計了 `agent_auto()` 路由層讓同一支程式能在兩個後端間切換。Agent 是 RAG 之外的第二個 LLM 應用典範,今天的程式碼會在後續的 Day 34 到 Day 37 反覆被重用。
工具版本管理:避免升級造成不相容
實務上 Agent 開發者最怕的事是「昨天還能跑的對話,今天突然壞了」。除了模型本身偶爾更新外,工具函式庫的版本變動也是常見原因。我們的 `get_contract_info` 雖然簡單,但真實企業的 Agent 會依賴數十個函式庫(資料庫驅動程式、API SDK、PDF 解析器等)。建議把工具的版本鎖定在 `requirements.txt`,並在 CI 流程跑回歸測試,確保升級時不會破壞既有功能。對 MCP server 來說,因為它是獨立行程,版本問題更容易隱藏,部署前一定要在 staging 環境完整測試一輪。
結語
Agent 的基礎我們學會了,但工具怎麼管理、多個工具怎麼協作、不同來源的工具怎麼整合,是下一階段的問題。明天(Day 34)我們會進入 MCP(Model Context Protocol),這是 Anthropic 在 2024 年 11 月開源的協定,目的是讓 Agent 工具能標準化地被分享與重用。今天的 `get_contract_info` 是一個本地函式;明天我們會把它改寫成一個 MCP server,讓任何支援 MCP 的客戶端(Claude Desktop、各家 IDE 插件)都能呼叫它。
延伸資源
- Yao, S. 等人,ReAct: Synergizing Reasoning and Acting in Language Models(arXiv:2210.03629,2022):
https://arxiv.org/abs/2210.03629 - OpenAI Function Calling 官方文件(2024–2025):
https://platform.openai.com/docs/guides/function-calling - Ollama Python 庫文件(0.4.x,2025):
https://github.com/ollama/ollama-python - LangChain 0.3.x Agents 與 Tools:
https://python.langchain.com/docs/modules/agents/ - Anthropic Tool Use 官方文件(Claude 3.7,2025):
https://docs.anthropic.com/en/docs/build-with-claude/tool-use
留言
張貼留言