iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

前言

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 的允許清單中。

實作會分成幾個步驟:

  1. 說明為什麼需要 tool guardrail。
  2. 在 test case 中加入 allowed_tools。
  3. 新增 guardrails/tool_permissions.py。
  4. 實作 ToolGuardrailResult。
  5. 實作 validate_tool_allowed()。
  6. 修改 SimpleAgent.run(),接收 allowed_tools。
  7. 在 _handle_tool_call() 執行工具前檢查權限。
  8. 修改 evals/runner.py,把 allowed_tools 傳給 Agent。
  9. 在 eval result 中記錄 tool guardrail metadata。
  10. 用 fake client 或 unit test 驗證,不依賴 Gemini API。

這一版先不處理:

  • 真正 Gemini function calling。
  • 多工具複雜權限系統。
  • 使用者角色權限。
  • 工具輸入 schema validation。
  • 工具執行成本統計。
  • 審核高風險工具,例如付款或寄信。

範圍收斂在一件事:

工具被執行前,先確認它是否被允許。


為什麼需要 Tool Guardrail?

假設未來 Agent 有兩個工具:

calculator
web_search

有些任務只需要回答固定知識,不應該查網路。

有些任務只需要計算,不應該使用搜尋。

如果 Agent 可以自由使用任何工具,會出現幾種問題。

1. 不必要的工具呼叫

例如:

請計算 72 + 19

這題應該只需要 calculator。

如果 Agent 跑去呼叫 web_search,即使最後答案對,流程也不乾淨。

2. 成本和延遲增加

工具可能有成本。

例如 web_search、資料庫查詢、外部 API call。

如果 Agent 在不需要的任務裡亂用工具,會讓 evaluation 結果變得不穩定,也會增加成本。

3. 安全風險

有些工具不是只讀資料,而是會造成外部影響。

例如:

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 結果

在 cases.json 加入 allowed_tools

先從 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 就會擋下來。


allowed_tools 的預設策略

這裡有一個設計選擇:

如果 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 會被擋下。


建立 tool permissions guardrail

新增 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,而是執行流程中的阻擋事件。


實作 validate_tool_allowed

接著實作檢查 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

tool_permissions.py 完整版本

整理後,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",
    )

修改 SimpleAgent:傳入 allowed_tools

接著修改 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 時,我們會想知道:

這次執行允許哪些工具?

修改 tool_call 分支

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:執行前檢查權限

接著修改 _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

所以工具不會被執行。


為什麼權限檢查放在 Unknown Tool 前?

你可能會想:

應該先檢查 tool_name 是否存在,還是先檢查 allowed_tools?

這裡先檢查 allowed tools。

原因是權限問題和工具存在問題要分開看。

例如 Agent 嘗試呼叫:

web_search

而這題的 allowed_tools 是:

["calculator"]

那更重要的訊息是:

web_search 不在這題允許的工具清單中。

至於系統有沒有實作 web_search,是另一個問題。

如果 web_search 有被允許,但系統沒實作,才會進到:

Unknown tool

這樣 failure reason 會比較清楚。


修改 runner:傳入 allowed_tools

接著修改 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 修正輸出,不代表它可以突然使用更多工具,因此權限設定必須沿用。


runner 捕捉 ToolGuardrailViolation

接著在 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 的分類一致。


completed result 也加入 tool guardrail 欄位

如果工具沒有被擋下,也應該在 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 相關欄位。


本地測試 tool guardrail

和 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 串接

接著用 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 沒授權工具。


怎麼刻意測 blocked 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 分析時,就可以分辨:

工具被允許且正常使用
工具未授權所以被阻擋
任務沒有使用工具

Tool Guardrail 和前兩天的關係

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。

1. 只檢查工具名稱

目前只檢查:

tool_name 是否在 allowed_tools

還沒有檢查 tool input。

例如 calculator 的 input:

135 * 28

未來可以檢查:

  • 是否只包含安全運算符。
  • 是否太長。
  • 是否包含不允許的語法。

2. 只在 SimpleAgent 內檢查

這版把 tool guardrail 放在 SimpleAgent._handle_tool_call()。

這對目前架構最直接,因為工具就是在這裡被執行。

但如果未來有更複雜的 agent runtime,可能會抽成:

ToolExecutor
ToolRegistry
GuardrailPipeline

目前先不做這些抽象。

3. 還沒有處理不同使用者角色

真實產品中,工具權限可能和 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。
  • 在 eval result 中記錄 tool_guardrail_passed、tool_guardrail_type、blocked_tool_name。
  • 用 unit test 和 fake provider 驗證,不依賴 Gemini API。

做到這一步後,guardrail 不再只檢查 final answer。

它也開始管 Agent 的中間行為。

也就是:

Agent 不只要答對,
也要用被允許的方式完成任務。

下一步

Day 23 會開始記錄成本與延遲。

前面幾天加入了:

  • prompt versioning
  • retry
  • schema guardrail
  • tool guardrail

這些策略可以提升可靠性,但也可能增加成本和延遲。

例如:

retry 會增加 LLM call 次數
工具呼叫可能增加外部 API 成本
guardrail 檢查雖然便宜,但仍然是 pipeline 的一部分

所以 Day 23 要開始記錄:

  • latency
  • token usage
  • estimated cost
  • retry 對成本和延遲的影響

到那時候,我們就能更完整地比較:

可靠性提升了多少?
代價是什麼?

上一篇
Day 21|第三週回顧:從真實 LLM baseline 找到改善方向
下一篇
Day 23|成本與延遲紀錄
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言