跳转至

Policy:划清 Agent 的行动边界

旅游助手能查天气、搜景点、估路线了。现在你给它加了 book_ticketsend_emaildelete_order 这些工具。问题马上出来:模型想帮忙,帮着帮着就自己下单了,或者把用户护照号发给了第三方接口。

它不是故意越界。它只是没有边界。

Policy 回答的问题很简单:

这个工具调用允不允许?这些参数合不合规?

不要只在 prompt 里写"你不能做危险的事"。模型会忘,会被绕。边界必须是 Python 代码里的硬检查。


没有 Policy 会怎么坏

问题 表面看起来 实际风险
用户问"帮我订票",模型直接调 book_ticket 回复很快、体验好 用户还没确认价格就扣款了
模型把 city 参数填成海外城市 看起来是正常查询 触发了你没对接的境外 API,返回错误或产生费用
模型调了 delete_order "帮用户清理" 看起来是贴心服务 删掉了不可恢复的订单
prompt 里写了"不要调 delete" 短期有效 换个 prompt 注入就绕过了

流程

flowchart TD
  A["Agent 提出工具调用"] --> P["Policy 引擎检查工具名 + 参数"]
  P -->|通过| T["Python 执行工具"]
  T --> O["工具结果写回对话"]
  P -->|拒绝| B["返回拒绝原因"]
  B --> R["Agent 换计划或提示用户"]

完整代码

一个白名单 + 黑名单 + 参数校验的 Policy 引擎。旅游助手场景:只允许查天气和搜景点,订票只能杭州标准票且价格不超过 500,删除类工具一律禁止。

from __future__ import annotations
import json
from dataclasses import dataclass


# ========== Policy 规则定义 ==========

@dataclass
class ToolPolicy:
    """单个工具的策略规则。"""
    allowed: bool = True
    max_price: float | None = None
    allowed_cities: list[str] | None = None
    allowed_ticket_types: list[str] | None = None
    reason: str = ""


# 黑名单:这些工具无论什么情况都不许调
BLACKLIST = {"delete_order", "send_passport", "drop_database"}

# 白名单 + 参数规则
TOOL_POLICIES: dict[str, ToolPolicy] = {
    "get_weather": ToolPolicy(),
    "search_places": ToolPolicy(),
    "estimate_route": ToolPolicy(),
    "book_ticket": ToolPolicy(
        allowed=True,
        max_price=500.0,
        allowed_cities=["杭州"],
        allowed_ticket_types=["standard"],
        reason="订票仅限杭州标准票,单价不超过 500",
    ),
}


@dataclass
class PolicyResult:
    allowed: bool
    tool: str
    args: dict
    reason: str = ""


def check_policy(tool_name: str, args: dict) -> PolicyResult:
    """检查一次工具调用是否符合策略。"""

    # 1. 黑名单直接拒绝
    if tool_name in BLACKLIST:
        return PolicyResult(
            allowed=False,
            tool=tool_name,
            args=args,
            reason=f"工具 {tool_name} 在黑名单中,禁止调用",
        )

    # 2. 不在白名单也拒绝
    policy = TOOL_POLICIES.get(tool_name)
    if policy is None:
        return PolicyResult(
            allowed=False,
            tool=tool_name,
            args=args,
            reason=f"工具 {tool_name} 未注册,不在白名单中",
        )

    if not policy.allowed:
        return PolicyResult(
            allowed=False,
            tool=tool_name,
            args=args,
            reason=f"工具 {tool_name} 当前被禁用",
        )

    # 3. 参数级校验
    if policy.allowed_cities is not None:
        city = args.get("city", "")
        if city not in policy.allowed_cities:
            return PolicyResult(
                allowed=False,
                tool=tool_name,
                args=args,
                reason=f"城市 {city} 不在允许范围 {policy.allowed_cities}",
            )

    if policy.max_price is not None:
        price = args.get("price", 0)
        if price > policy.max_price:
            return PolicyResult(
                allowed=False,
                tool=tool_name,
                args=args,
                reason=f"价格 {price} 超过上限 {policy.max_price}",
            )

    if policy.allowed_ticket_types is not None:
        ticket_type = args.get("ticket_type", "")
        if ticket_type not in policy.allowed_ticket_types:
            return PolicyResult(
                allowed=False,
                tool=tool_name,
                args=args,
                reason=f"票型 {ticket_type} 不在允许范围 {policy.allowed_ticket_types}",
            )

    return PolicyResult(allowed=True, tool=tool_name, args=args, reason="通过")


# ========== 模拟 Agent 调用 ==========

def simulate_agent_calls():
    """模拟一组工具调用,展示 Policy 引擎的拦截效果。"""

    test_calls = [
        ("get_weather", {"city": "杭州"}),
        ("search_places", {"city": "杭州", "constraint": "下午有雨"}),
        ("book_ticket", {"city": "杭州", "ticket_type": "standard", "price": 280}),
        ("book_ticket", {"city": "杭州", "ticket_type": "vip", "price": 1200}),
        ("book_ticket", {"city": "东京", "ticket_type": "standard", "price": 3500}),
        ("delete_order", {"order_id": "ORD-20250601"}),
        ("hack_system", {"cmd": "rm -rf /"}),
    ]

    print("=" * 60)
    print("Policy 引擎测试")
    print("=" * 60)

    for tool_name, args in test_calls:
        result = check_policy(tool_name, args)
        status = "允许" if result.allowed else "拒绝"
        print(f"\n调用: {tool_name}({json.dumps(args, ensure_ascii=False)})")
        print(f"  结果: {status}")
        print(f"  原因: {result.reason}")


if __name__ == "__main__":
    simulate_agent_calls()

运行输出:

============================================================
Policy 引擎测试
============================================================

调用: get_weather({"city": "杭州"})
  结果: 允许
  原因: 通过

调用: search_places({"city": "杭州", "constraint": "下午有雨"})
  结果: 允许
  原因: 通过

调用: book_ticket({"city": "杭州", "ticket_type": "standard", "price": 280})
  结果: 允许
  原因: 通过

调用: book_ticket({"city": "杭州", "ticket_type": "vip", "price": 1200})
  结果: 拒绝
  原因: 票型 vip 不在允许范围 ['standard']

调用: book_ticket({"city": "东京", "ticket_type": "standard", "price": 3500})
  结果: 拒绝
  原因: 城市 东京 不在允许范围 ['杭州']

调用: delete_order({"order_id": "ORD-20250601"})
  结果: 拒绝
  原因: 工具 delete_order 在黑名单中,禁止调用

调用: hack_system({"cmd": "rm -rf /"})
  结果: 拒绝
  原因: 工具 hack_system 未注册,不在白名单中

代码走读

三层防线

  1. 黑名单delete_ordersend_passport 这类工具,无论参数是什么,一律拒绝。这是最硬的一层。
  2. 白名单:没有在 TOOL_POLICIES 里注册的工具名,默认拒绝。模型编造一个 hack_system 也会被挡住。
  3. 参数校验:工具允许调用,但参数不对也拒绝。book_ticket 可以用,但只能订杭州的标准票,单价不超过 500。

check_policy 返回的 PolicyResult 带着 reason 字段。这个 reason 有两个用途:

  • 写进日志,方便事后审计。
  • 返回给 Agent,让模型知道为什么被拒了,好调整计划。

和 Guardrails 的区别

Policy Guardrails
检查时机 工具调用之前 运行时任意位置
检查对象 工具名 + 参数 输入、输出、中间状态
典型问题 该不该调这个工具 内容有没有注入、泄露、超长
违规后果 直接拒绝,不执行 拦截、降级、上报

Policy 管权限,Guardrails 管运行时安全。两个不是同一件事。


什么时候用

  • 工具会影响真实世界:付款、订票、发邮件、删数据。
  • 不同用户有不同权限(普通用户不能删订单,管理员可以)。
  • 工具有成本或调用频率限制。
  • 你需要审计日志来追溯"为什么这个调用被允许/拒绝了"。

什么时候不需要

如果系统只生成文本、没有工具,Policy 可以先不做。但只要有一个工具能改变外部状态,就从白名单开始。不要让模型自己给自己划边界。


常见坑

现象 修法
只在 prompt 里写"不要越界" 模型偶尔会忘、会被注入绕过 用 Python 代码检查,不依赖 prompt
只检查工具名不检查参数 工具对了但参数越界 加参数级规则
白名单开得太大 开发时方便,上线后裸奔 最小权限原则,逐步开放
拒绝了但不告诉 Agent 为什么 Agent 反复重试同样的调用 返回 reason,让模型调整计划
不记日志 出事以后查不到哪一步越界 每次检查都写日志

读完以后

Policy 解决了"该不该调这个工具"。但有些问题不是权限问题——工具返回的内容里可能藏着 prompt 注入,最终输出里可能泄露敏感信息。

这些运行时的危险,读下一节 Guardrails