有參數驗證、可見工具結果及失敗上限的 agent 迴圈。
01/工具呼叫是請求,並非已執行的行動
聊天模型可提出函式名稱及參數;由你的程式決定是否執行,交回結果,再讓模型解釋。這個循環是小型本地 agent 的實用基礎。模型支援工具呼叫,不代表自動獲得檔案、瀏覽器或終端機權限。
我們會查詢虛構庫存。成功的 agent 應查找兩項貨品,並報告工具回傳的數量。這比單看一句「我已檢查庫存」更有意義,因為你能看到工具真正執行的證據。
02/執行有明確限制的例子
安裝 Python 3 及 Ollama,下載 qwen3:4b,並保持 Ollama 運行。下載下方 stock-agent.py。它只使用標準函式庫及程式內的庫存字典,不能購物、修改檔案或執行 shell。Windows 可視安裝方式使用 py stock-agent.py。
程式只允許一種工具、兩個貨品名稱、合共四次呼叫及四輪模型請求。模型會收到 JSON schema,但 Python 仍會再次驗證回傳參數。Schema 是給模型的說明,不能代替執行時檢查。
ollama pull qwen3:4b
python3 stock-agent.py03/先查執行紀錄,再查答案
測試資料是 cable: 12、adapter: 0。接受最終解釋前,先確認兩項貨品都有 verified tool-result 紀錄。文句可不同,但兩個數字不能改變。如模型完全沒有用工具便作答,程式會刻意報錯,不會把它當作已驗證查詢。
修改其中一個庫存數再重跑,之後查詢清單以外的貨品。模型可能要求澄清,或程式拒絕無效呼叫;兩者都比暗中編造結果好。達到輪數上限的模型,即使輸出了很多 token,也未算完成任務。
04/有計劃地擴大功能範圍
正式業務可把字典換成經驗證的唯讀庫存查詢,並由程式控制授權及可讀取紀錄。不要直接把模型字串當成 SQL、shell 指令或任意網址。對發送、購買或刪除等工具,加入人工確認。
網頁及文件可能包含誤導指令,應視作不可信證據,並在提示以外強制執行工具權限。記錄工具名稱、驗證結果及耗時,同時避免記下機密內容。無論模型在本地或雲端,這些控制都適用。
05/量度已完成的任務
記錄成功率、工具選擇、參數有效性及端到端時間,再測試兩個和三個 job 同時呼叫同一模型。同一對話內的並行工具請求,不等於多個獨立 job 共用伺服器。
TokFire 的本地 agent 測試資料,為支援的執行環境提供可重複量度。通過本庫存練習或該測試,都不代表模型可以安全瀏覽任何網站或營運整間公司。先擴大測試任務,再擴大權限。
完整範例
下載 Python 範例使用虛構資料的教學程式,須先安裝文中所述本地執行環境及模型;模型輸出可能不同。這些範例不是已公開的測試成績。
"""Bounded, read-only tool-calling exercise. Requires Ollama and qwen3:4b."""
import json
from urllib.request import Request, urlopen
INVENTORY = {"cable": 12, "adapter": 0} # Fictional teaching data.
TOOLS = [{"type": "function", "function": {
"name": "stock_count", "description": "Look up stock for one item.",
"parameters": {"type": "object", "properties": {
"item": {"type": "string", "enum": list(INVENTORY)}},
"required": ["item"], "additionalProperties": False}
}}]
def chat(messages):
request = Request("http://127.0.0.1:11434/api/chat", json.dumps({
"model": "qwen3:4b", "stream": False, "think": False,
"messages": messages, "tools": TOOLS,
"options": {"num_ctx": 4096, "num_predict": 256, "temperature": 0}
}).encode(), {"Content-Type": "application/json"})
with urlopen(request, timeout=120) as response:
return json.load(response)["message"]
def dispatch(function):
args = function.get("arguments", {})
if function.get("name") != "stock_count":
raise ValueError("Unknown tool")
if not isinstance(args, dict) or set(args) != {"item"}:
raise ValueError("Invalid arguments")
item = args["item"]
if not isinstance(item, str) or item not in INVENTORY:
raise ValueError("Unknown item")
return {"item": item, "count": INVENTORY[item]}
def run(question):
messages = [{"role": "system", "content": "Use stock_count for stock facts. "
"Do not invent inventory. This tool only reads data."},
{"role": "user", "content": question}]
calls_used = 0
for _ in range(4):
message = chat(messages)
messages.append(message)
calls = message.get("tool_calls", [])
if not calls:
if not calls_used:
raise RuntimeError("No tool used: the task is not verified")
print(message.get("content", ""))
return
if not isinstance(calls, list) or calls_used + len(calls) > 4:
raise RuntimeError("Tool-call limit exceeded")
for call in calls:
result = dispatch(call["function"])
calls_used += 1
print("Verified tool result:", json.dumps(result))
messages.append({"role": "tool", "tool_name": "stock_count",
"content": json.dumps(result)})
raise RuntimeError("Stopped: model did not finish within four rounds")
if __name__ == "__main__":
run("How many cables and adapters are in stock?")