Day 20 我們把 JSON schema validation 做成 guardrail。
它處理的是 Agent 的輸出格式:
Agent output
-> schema guardrail
-> evaluator
如果 Agent 輸出的 JSON 不合法、缺欄位或型別錯誤,guardrail 會先擋下來。
這一篇要把 guardrail 從「輸出格式」延伸到「工具使用」。
到目前為止,我們的 Agent 已經有一個工具:
calculator
它很單純,主要用來處理計算任務。
但在真實 Agent 系統中,工具可能不只 calculator。
未來可能會有:
calculator
date_tool
web_search
database_query
send_email
create_ticket
payment_refund
這時候就會出現一個問題:
Agent 可以使用工具,但不是每個任務都應該使用每個工具。
接下來先做第一版 Tool Guardrail。
目標很直接:
在 Agent 執行工具前,檢查該工具是否在這個 test case 的允許清單中。
實作會分成幾個步驟:
allowed_tools。guardrails/tool_permissions.py。ToolGuardrailResult。validate_tool_allowed()。SimpleAgent.run(),接收 allowed_tools。_handle_tool_call() 執行工具前檢查權限。evals/runner.py,把 allowed_tools 傳給 Agent。這一版先不處理:
範圍收斂在一件事:
工具被執行前,先確認它是否被允許。
假設未來 Agent 有兩個工具:
calculator
web_search
有些任務只需要回答固定知識,不應該查網路。
有些任務只需要計算,不應該使用搜尋。
如果 Agent 可以自由使用任何工具,會出現幾種問題。
例如:
請計算 72 + 19
這題應該只需要 calculator。
如果 Agent 跑去呼叫 web_search,即使最後答案對,流程也不乾淨。
工具可能有成本。
例如 web_search、資料庫查詢、外部 API call。
如果 Agent 在不需要的任務裡亂用工具,會讓 evaluation 結果變得不穩定,也會增加成本。
有些工具不是只讀資料,而是會造成外部影響。
例如:
send_email
create_ticket
payment_refund
delete_record
這類工具一定要有 guardrail。
即使模型認為「應該使用」,系統也要先檢查它是否被授權。
所以 Tool Guardrail 的基本原則是:
LLM 可以提出 tool call。
系統負責決定是否允許執行。
Day 3 開始,我們已經讓 SimpleAgent 可以處理 tool call。
目前流程大致是:
SimpleAgent.run()
-> llm_client.chat(messages)
-> response["type"] == "tool_call"
-> _handle_tool_call(response, trace)
-> self.tools[tool_name](tool_input)
真正執行工具的地方在:
tool_output = self.tools[tool_name](tool_input)
所以 Tool Guardrail 應該放在這行之前。
不能等工具執行完才檢查。
流程要改成:
收到 tool_call
-> 檢查 tool_name 是否在 allowed_tools
-> 如果允許,才執行工具
-> 如果不允許,阻擋並記錄 tool_error
這次會新增一個 guardrail 檔案,並修改 Agent 和 runner。
agent-testing-platform/
agents/
simple_agent.py
evals/
cases.json
runner.py
guardrails/
__init__.py
json_schema.py
tool_permissions.py
tests/
test_tool_permissions_guardrail.py
新增檔案:
| 檔案 | 用途 |
|---|---|
guardrails/tool_permissions.py |
檢查工具是否被允許 |
tests/test_tool_permissions_guardrail.py |
本地測試 tool guardrail |
修改檔案:
| 檔案 | 修改內容 |
|---|---|
evals/cases.json |
加入 allowed_tools |
agents/simple_agent.py |
在執行工具前檢查權限 |
evals/runner.py |
把 allowed_tools 傳給 Agent,並記錄 tool guardrail 結果 |
先從 test case 開始。
目前計算題會使用 calculator。
例如 case_001:
{
"id": "case_001",
"input": "請計算 135 * 28",
"expected": "3780",
"grading_method": "contains",
"task_type": "calculation"
}
替它加上 allowed_tools:
{
"id": "case_001",
"input": "請計算 135 * 28",
"expected": "3780",
"grading_method": "contains",
"task_type": "calculation",
"allowed_tools": ["calculator"]
}
case_002 到 case_004 也是計算題,也加入:
"allowed_tools": ["calculator"]
非計算題可以先設定成空陣列:
"allowed_tools": []
例如:
{
"id": "case_005",
"input": "請回答台灣的首都是哪裡",
"expected": "台北",
"grading_method": "contains",
"task_type": "keyword_qa",
"allowed_tools": []
}
這題不應該使用任何工具。
如果 Agent 嘗試呼叫 calculator,guardrail 就會擋下來。
這裡有一個設計選擇:
如果 test case 沒有 allowed_tools,要視為允許全部,還是允許 none?
這裡採用較保守的策略:
沒有 allowed_tools,就視為空清單。
也就是:
allowed_tools = test_case.get("allowed_tools", [])
這叫 default deny。
安全系統通常偏向 default deny,而不是 default allow。
原因是如果忘記設定權限,系統應該保守地不允許工具,而不是讓 Agent 自由使用。
但要注意,這會影響現有計算題。
所以計算題一定要補上:
"allowed_tools": ["calculator"]
否則 calculator 會被擋下。
新增 guardrails/tool_permissions.py。
先定義 result model 和錯誤類別。
新增 guardrails/tool_permissions.py:
from dataclasses import dataclass
@dataclass
class ToolGuardrailResult:
passed: bool
guardrail_type: str
tool_name: str
failure_reason: str | None = None
class ToolGuardrailViolation(Exception):
def __init__(self, result: ToolGuardrailResult):
super().__init__(result.failure_reason)
self.result = result
這裡定義兩個東西。
第一,ToolGuardrailResult。
它會記錄:
| 欄位 | 說明 |
|---|---|
passed |
是否通過 |
guardrail_type |
今天固定是 tool_permission |
tool_name |
Agent 想呼叫的工具 |
failure_reason |
被擋下時的原因 |
第二,ToolGuardrailViolation。
這是一個 exception,當工具未授權時拋出。
為什麼要用 exception?
因為未授權工具不應該繼續執行。
它不是 evaluator 的普通 fail,而是執行流程中的阻擋事件。
接著實作檢查 function。
繼續修改 guardrails/tool_permissions.py:
def validate_tool_allowed(
tool_name: str,
allowed_tools: list[str],
) -> ToolGuardrailResult:
if tool_name in allowed_tools:
return ToolGuardrailResult(
passed=True,
guardrail_type="tool_permission",
tool_name=tool_name,
)
return ToolGuardrailResult(
passed=False,
guardrail_type="tool_permission",
tool_name=tool_name,
failure_reason=f"Tool '{tool_name}' is not allowed for this task",
)
這個 function 只負責三件事:
輸入 tool_name 和 allowed_tools
輸出是否允許使用
例如:
validate_tool_allowed("calculator", ["calculator"])
會通過。
validate_tool_allowed("calculator", [])
會失敗:
Tool 'calculator' is not allowed for this task
整理後,guardrails/tool_permissions.py 會長這樣。
新增 guardrails/tool_permissions.py:
from dataclasses import dataclass
@dataclass
class ToolGuardrailResult:
passed: bool
guardrail_type: str
tool_name: str
failure_reason: str | None = None
class ToolGuardrailViolation(Exception):
def __init__(self, result: ToolGuardrailResult):
super().__init__(result.failure_reason)
self.result = result
def validate_tool_allowed(
tool_name: str,
allowed_tools: list[str],
) -> ToolGuardrailResult:
if tool_name in allowed_tools:
return ToolGuardrailResult(
passed=True,
guardrail_type="tool_permission",
tool_name=tool_name,
)
return ToolGuardrailResult(
passed=False,
guardrail_type="tool_permission",
tool_name=tool_name,
failure_reason=f"Tool '{tool_name}' is not allowed for this task",
)
接著修改 agents/simple_agent.py。
先加入 import。
修改 agents/simple_agent.py:
from guardrails.tool_permissions import (
ToolGuardrailViolation,
validate_tool_allowed,
)
接著修改 SimpleAgent.run(),讓它可以接收 allowed_tools。
修改 agents/simple_agent.py:
class SimpleAgent:
def run(
self,
user_task: str,
allowed_tools: list[str] | None = None,
) -> AgentResult:
allowed_tools = allowed_tools or []
trace = AgentTrace()
trace.add_step(
step_type="user_input",
name="User Task",
input_data={
"content": user_task,
"allowed_tools": allowed_tools,
},
)
# 原本建立 messages、呼叫 llm_client 的程式碼維持不變
這裡把 allowed_tools 寫進 trace。
原因是未來 debug 時,我們會想知道:
這次執行允許哪些工具?
run() 裡原本應該有:
if response["type"] == "tool_call":
return self._handle_tool_call(response, trace)
改成:
if response["type"] == "tool_call":
return self._handle_tool_call(
response=response,
trace=trace,
allowed_tools=allowed_tools,
)
這樣 _handle_tool_call() 才知道這次任務允許哪些工具。
接著修改 _handle_tool_call()。
修改 agents/simple_agent.py:
def _handle_tool_call(
self,
response: dict,
trace: AgentTrace,
allowed_tools: list[str],
) -> AgentResult:
tool_name = response["tool_name"]
tool_input = response["tool_input"]
tool_guardrail = validate_tool_allowed(
tool_name=tool_name,
allowed_tools=allowed_tools,
)
if not tool_guardrail.passed:
trace.add_step(
step_type="error",
name="Tool Guardrail Violation",
input_data={
"tool_name": tool_name,
"allowed_tools": allowed_tools,
},
error=tool_guardrail.failure_reason,
)
raise ToolGuardrailViolation(tool_guardrail)
trace.add_step(
step_type="tool_call",
name="Tool Call",
input_data={
"tool_name": tool_name,
"tool_input": tool_input,
},
)
if tool_name not in self.tools:
trace.add_step(
step_type="error",
name="Unknown Tool",
input_data={"tool_name": tool_name},
error=f"Unknown tool: {tool_name}",
)
raise ValueError(f"Unknown tool: {tool_name}")
tool_output = self.tools[tool_name](tool_input)
# 後面維持原本 tool_result、ToolCallRecord、final answer 的邏輯
這段要留意執行順序:
validate_tool_allowed
-> 通過才記錄 tool_call
-> 通過才檢查 self.tools
-> 通過才執行工具
工具未授權時,直接拋出:
ToolGuardrailViolation
所以工具不會被執行。
你可能會想:
應該先檢查 tool_name 是否存在,還是先檢查 allowed_tools?
這裡先檢查 allowed tools。
原因是權限問題和工具存在問題要分開看。
例如 Agent 嘗試呼叫:
web_search
而這題的 allowed_tools 是:
["calculator"]
那更重要的訊息是:
web_search 不在這題允許的工具清單中。
至於系統有沒有實作 web_search,是另一個問題。
如果 web_search 有被允許,但系統沒實作,才會進到:
Unknown tool
這樣 failure reason 會比較清楚。
接著修改 evals/runner.py。
原本執行 Agent 可能是:
result = agent.run(test_case["input"])
今天改成:
allowed_tools = test_case.get("allowed_tools", [])
result = agent.run(
user_task=test_case["input"],
allowed_tools=allowed_tools,
)
如果 Day 19 已經有 retry,retry 時也要傳入同一組 allowed_tools。
例如:
result = agent.run(
user_task=retry_task,
allowed_tools=allowed_tools,
)
Retry 只是重新要求 Agent 修正輸出,不代表它可以突然使用更多工具,因此權限設定必須沿用。
接著在 evals/runner.py 加入 import:
from guardrails.tool_permissions import ToolGuardrailViolation
然後在 try / except 中,先捕捉 ToolGuardrailViolation。
修改 evals/runner.py:
try:
allowed_tools = test_case.get("allowed_tools", [])
result = agent.run(
user_task=test_case["input"],
allowed_tools=allowed_tools,
)
# 後面維持 guardrail、evaluate、retry、append result 的流程
except ToolGuardrailViolation as exc:
tool_guardrail = exc.result
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"output_schema": test_case.get("output_schema"),
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"allowed_tools": test_case.get("allowed_tools", []),
"status": "blocked",
"actual": None,
"passed": False,
"failure_type": "tool_error",
"failure_reason": tool_guardrail.failure_reason,
"tool_guardrail_passed": False,
"tool_guardrail_type": tool_guardrail.guardrail_type,
"blocked_tool_name": tool_guardrail.tool_name,
"trace_session_id": None,
"trace_session_ids": [],
"error": None,
}
)
這裡讓 status 變成:
blocked
代表 Agent 不是正常完成,也不是程式 crash,而是被 guardrail 擋下。
failure_type 則是:
tool_error
這和 Day 16 的分類一致。
如果工具沒有被擋下,也應該在 result 裡記錄這次的工具權限設定。
修改 evals/runner.py 的 completed result:
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"output_schema": test_case.get("output_schema"),
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"allowed_tools": allowed_tools,
"status": "completed",
"actual": result.answer,
"passed": evaluation.passed,
"failure_type": evaluation.failure_type,
"failure_reason": evaluation.failure_reason,
"tool_guardrail_passed": True,
"tool_guardrail_type": "tool_permission",
"blocked_tool_name": None,
"trace_session_id": result.trace.session_id,
"trace_session_ids": trace_session_ids,
"error": None,
}
)
如果今天也已經有 Day 19 retry 和 Day 20 schema guardrail 欄位,就保留那些欄位。
不要為了加 tool guardrail 把原本的:
retry_count
guardrail_passed
guardrail_type
guardrail_reason
刪掉。
Day 22 只是在 result 裡多加 tool 相關欄位。
和 Day 20 一樣,這項驗證不需要依賴 Gemini。
Tool permission guardrail 可以用單元測試驗證。
新增 tests/test_tool_permissions_guardrail.py:
from guardrails.tool_permissions import validate_tool_allowed
def test_allowed_tool_passes() -> None:
result = validate_tool_allowed(
tool_name="calculator",
allowed_tools=["calculator"],
)
assert result.passed is True
assert result.failure_reason is None
def test_blocked_tool_fails() -> None:
result = validate_tool_allowed(
tool_name="calculator",
allowed_tools=[],
)
assert result.passed is False
assert result.guardrail_type == "tool_permission"
assert result.tool_name == "calculator"
assert result.failure_reason == "Tool 'calculator' is not allowed for this task"
執行:
python3 -m pytest tests/test_tool_permissions_guardrail.py
預期:
2 passed
這個測試不需要 Gemini API。
它只驗證 tool guardrail 的主要邏輯。
接著用 fake provider 測完整 runner。
RETRY_ENABLED=0 LLM_PROVIDER=fake python3 -m evals.runner
如果 case_001 到 case_004 都有:
"allowed_tools": ["calculator"]
那計算題應該仍然可以通過。
例如:
[PASS] case_001 - calculation - retry=0
[PASS] case_002 - calculation - retry=0
[PASS] case_003 - calculation - retry=0
[PASS] case_004 - calculation - retry=0
如果你忘了替 calculation cases 加 allowed_tools,就可能看到:
[FAIL] case_001 - calculation
type: tool_error
reason: Tool 'calculator' is not allowed for this task
看到這個結果,表示 guardrail 已經生效。
不是 calculator 壞掉,而是 test case 沒授權工具。
如果你想確認 runner 真的會產生 status: blocked,可以暫時建立一筆本地測試 case。
例如新增一筆:
{
"id": "case_tool_blocked_demo",
"input": "請計算 10 + 5",
"expected": "15",
"grading_method": "contains",
"task_type": "calculation",
"allowed_tools": []
}
這題的 input 會讓 FakeLLMClient 產生 calculator tool call。
但 allowed_tools 是空清單。
所以預期會被擋下:
{
"case_id": "case_tool_blocked_demo",
"status": "blocked",
"passed": false,
"failure_type": "tool_error",
"failure_reason": "Tool 'calculator' is not allowed for this task",
"tool_guardrail_passed": false,
"blocked_tool_name": "calculator"
}
這筆 demo case 可以只放在本地測試,不一定要長期留在主要 cases.json。
如果要保持 eval dataset 穩定,建議用單元測試或另外的 fixture 檔案測 blocked case。
正常允許 calculator 的計算題:
{
"case_id": "case_001",
"task_type": "calculation",
"allowed_tools": ["calculator"],
"status": "completed",
"actual": "The result is 3780",
"passed": true,
"tool_guardrail_passed": true,
"tool_guardrail_type": "tool_permission",
"blocked_tool_name": null
}
未授權工具被擋下:
{
"case_id": "case_tool_blocked_demo",
"task_type": "calculation",
"allowed_tools": [],
"status": "blocked",
"actual": null,
"passed": false,
"failure_type": "tool_error",
"failure_reason": "Tool 'calculator' is not allowed for this task",
"tool_guardrail_passed": false,
"tool_guardrail_type": "tool_permission",
"blocked_tool_name": "calculator"
}
沒有使用工具的 QA 題:
{
"case_id": "case_005",
"task_type": "keyword_qa",
"allowed_tools": [],
"status": "completed",
"tool_guardrail_passed": true,
"blocked_tool_name": null
}
這樣 dashboard 或 JSON 分析時,就可以分辨:
工具被允許且正常使用
工具未授權所以被阻擋
任務沒有使用工具
Day 20 的 schema guardrail 管的是:
Agent 最後輸出的格式
Day 22 的 tool guardrail 管的是:
Agent 中途是否可以使用某個工具
兩者位置不同。
tool guardrail
-> 發生在工具執行前
schema guardrail
-> 發生在 final answer 產生後
Retry 則是 recovery 策略:
如果可修正錯誤發生,是否給 Agent 一次修正機會?
所以目前可靠性流程可以整理成:
Agent wants to call tool
-> tool guardrail
-> execute tool if allowed
-> final answer
-> schema guardrail
-> evaluator
-> retry if eligible
這比單純看 final answer 更完整。
因為 Agent 的風險不只在輸出內容,也在它中途做了哪些行為。
目前的 Tool Guardrail 還是 MVP。
目前只檢查:
tool_name 是否在 allowed_tools
還沒有檢查 tool input。
例如 calculator 的 input:
135 * 28
未來可以檢查:
這版把 tool guardrail 放在 SimpleAgent._handle_tool_call()。
這對目前架構最直接,因為工具就是在這裡被執行。
但如果未來有更複雜的 agent runtime,可能會抽成:
ToolExecutor
ToolRegistry
GuardrailPipeline
目前先不做這些抽象。
真實產品中,工具權限可能和 user role 有關。
例如:
| role | allowed tools |
|---|---|
| guest | calculator |
| support_agent | calculator, create_ticket |
| admin | calculator, create_ticket, database_query |
目前先只用 test case 裡的 allowed_tools。
等專案變複雜後,再引入 user role。
這一篇加入了第一版 Tool Guardrail。
完成內容包含:
evals/cases.json 加入 allowed_tools。guardrails/tool_permissions.py。ToolGuardrailResult。ToolGuardrailViolation。validate_tool_allowed()。SimpleAgent.run(),接收 allowed_tools。_handle_tool_call() 執行工具前檢查權限。evals/runner.py,把 allowed_tools 傳給 Agent。tool_guardrail_passed、tool_guardrail_type、blocked_tool_name。做到這一步後,guardrail 不再只檢查 final answer。
它也開始管 Agent 的中間行為。
也就是:
Agent 不只要答對,
也要用被允許的方式完成任務。
Day 23 會開始記錄成本與延遲。
前面幾天加入了:
這些策略可以提升可靠性,但也可能增加成本和延遲。
例如:
retry 會增加 LLM call 次數
工具呼叫可能增加外部 API 成本
guardrail 檢查雖然便宜,但仍然是 pipeline 的一部分
所以 Day 23 要開始記錄:
到那時候,我們就能更完整地比較:
可靠性提升了多少?
代價是什麼?