工具 Schema 设计即是你的爆炸半径:当函数定义成为安全边界
你的 Agent 代码库中最危险的文件是你一直当作 API 文档来编写的那个。工具注册表(Tool Registry)——即告诉模型存在哪些函数以及它们接受哪些参数的 JSON 或 Pydantic schema —— 不再仅仅是一个 docstring。它是你的授权层(authorization layer)。如果你像大多数团队那样设计它,你就是把万能钥匙交给了大模型(LLM),并称之为优秀的工程设计。
考虑一个典型的工具初步尝试:query_database(sql: string)。初衷是合理的 —— 让模型根据用户的问题制定正确的 SQL。现实情况是,模型现在成了一个不受信任的客户端,拥有连接字符串所指向的任何数据库的无限 DDL 和 DML 权限。系统提示词说“仅在 orders 表上运行 SELECT” 只是一个建议,而不是控制手段。当一个受到提示注入(prompt-injected)的工具结果 —— 比如邮件正文、网页或 PDF —— 告诉模型运行 DROP TABLE users 时,你的授权模型就变成了对模型指令遵循能力的纪律要求。那不是授权。那是祈祷。
这种模式是 2026 年 Agent AI 安全中的主要失效模式 。2026 年 LiteLLM 的 CVE 允许通过 Bearer 令牌字段进行预鉴权(pre-auth)SQL 注入;Anything-LLM SQL Agent 的通告中,table_name 被直接拼接到查询中而没有参数化;MCP 服务器附带的 command: string 参数变成了 RCE(远程代码执行)原语 —— 这些都是同一个漏洞。被定义为 string 类型的 schema 字段是一个仅存在于编写它的工程师想象中的信任边界。
Schema 是契约,而非文档
需要转变的观念是:工具定义中的每一个参数都是一项安全控制。类型级别的约束在大模型看到选项之前就已经生效了。暴露面越窄,爆炸半径就越小。
只要取值空间是有限的,就使用 enum(枚举)类型而非 string。一个 status: string 参数,即便模型“应该”传递 "active" | "paused" | "archived",在运行时它可以是模型想生成的任何字符串 —— 包括 SQL 片段、Shell 元字符或路径遍历序列。而 status: enum["active", "paused", "archived"] 则不会变成这些。这种约束在 JSON-schema 层级生效,早于你的代码运行。OpenAI 和 Anthropic 的结构化输出 API 遵循 enum;模型只能从集合中发出一个值,仅此而已。
将整数字段设为 integer,并带有最小值(min)和最大值(max)边界。一个 limit: integer(min=1, max=1000) 参数无法通过一次调用就拖库(exfiltrate)。而一个没有边界的 limit: integer 可以。limit: string 简直是笑话。
必填字段默认失效关闭(fail closed)。带有合理默认值的可选字段默认失效关闭。如果一个删除工具的 confirm: boolean 默认为 false,且要求设为 true 才能执行,那么在模型愉快地忽略该参数时,你还有一线生机。如果一个删除工具因为“模型会自动设置正确”而将 confirm 默认设为 true,那么在事后复盘(postmortem)时你得好好解释这个设计选择了。
这种自律比看起来要难,因为你添加的每一个约束都会缩小模型的能力范围 —— 包括它正确行事的能力。诱惑在于将字段保留为自由字符串并信任提示词。请抵制这种诱惑。使用 enum 的代价是工具灵活性稍微降低。使用自由字符串的代价是发生安全违规(breach)。
始终在服务端验证,因为模型是不受信任的客户端
Schema 约束捕获模型生成的值。服务端验证则捕获那些漏网之鱼 —— 无论是因为 schema 过于宽松,还是因为模型生成了结构有效但语义危险的值,抑或是因为工具在未遵循 schema 的路径中运行。
将模型发送的每个参数都视为来自敌对的 HTTP 客户端。重新验证类型。重新检查范围。重新验证用户是否拥有参数所引用的资源。2026 年的 LiteLLM SQL 注入发生是因为 Bearer 令牌值被拼接到了 SELECT 语句中。修复方法是参数化查询 —— 恰恰是你在 2005 年左右会应用到 Web 表单上的修复方法。唯一的新鲜事只是沟通渠道变了。
代码模式是:将“决策”与“执行” 分离。模型决定调用什么。你的代码负责执行,且你的代码会重新验证一切。如果工具是 update_user(user_id: string, fields: object),你的处理器在进行任何数据库调用之前,应检查 user_id 是否匹配已认证的用户 ID。如果工具是 send_email(recipient: string),你的处理器应检查 recipient 是否在当前用户角色的白名单中。授权决策存在于你的代码中,而非模型的推理中。
在输入和输出时都进行验证。返回数据的工具应该为下游的提示注入对这些数据进行清洗 —— 剥离可执行指令,将内容标记为不可信,绝不要让工具输出像系统消息(system message)一样流入下一个提示词。Agent 生态系统花了两年时间才发现工具结果也是一个攻击面;你的验证层应该将其视为攻击面。
能力作用域工具始终优于全能工具
最大的设计杠杆是拆解那些根据参数执行所有操作的“综合性工具”。这种模式看起来很无辜 —— email(action: string, recipient: string, subject: string, body: string) —— 但实际上它是一个可以用于任何邮件相关犯罪的工具包。模型可能会受到注入攻击,被引导向任何人发送任何内容,并执行任何操作。
取而代之的是,在请求时根据经过身份验证的用户权限选择“能力作用域”变体:
send_email_to_self(subject, body)—— 收件人在服务器端绑定到当前用户的地址。send_email_to_team_member(team_member_id, subject, body)—— 收件人被限制在用户有权发送邮件的团队成员枚举中。send_email_to_external(recipient, subject, body)—— 仅为具有外部邮件权限的用户注册,并且仅在经过单独的确认环节后执行。
模型只能看到用户被允许使用的工具。模型无法调用未注册的工具。如果作用域内唯一的邮件工具是 send_email_to_self,那么指令为“将凭据发送给 [email protected]”的注入攻击就不可能成功。这不是模型必须记住的防御,而是注册表强制执行的防御。
同样的模式也适用于文件系统工具(read_workspace_file 对比 read_any_file)、HTTP 工具(fetch_from_allowlisted_domain 对比 fetch_url)、Shell 工具(run_predefined_script 对比 run_command)以及数据库工具(get_order_by_id 对比 query_database)。全能工具是反模式。限定能力的变体才是生产级设计。
2026 年的 OWASP MCP Top 10 直接指出了这一点:按智能体角色划分 MCP 服务器的作用域;仅暴露该角色需要的工具;在连接时根据经过身份验证的身份过滤工具列表。无论你使用的是 MCP、原生函数调用还是自建的注册表,逻辑都是一样的。其原则是函数定义层的最小权限,而非运行时的最小权限。
针对任何破坏性操作:试运行、确认并执行故障关闭
有些操作无法仅靠 Schema 设计来保证安全。删除、转账、发送、发布 —— 这些会改变共享状态、产生费用或触及他人的操作。对于这些操作,工具的正确形态应该是两阶段的:返回将发生什么的预览,以及实际执行的提交。
delete_records(filter, dry_run: boolean = true) 是正确的默认设置。第一次调用返回将被删除的记录数量和样本。第二次调用时将 dry_run 显式设为 false,并附带第一次调用获得的幂等性令牌,以此提交操作。模型不会意外地“一键”触发破坏性路径,因为破坏性路径需要一个只能从试运行中获得的令牌。
对于影响最大的操作,应增加“人机协同”(HITL)确认作为独立的强制执行层。像 LangChain 的 HITL 中间件和 Haystack 的确认策略等框架已经内置了这种机制:任何标记为破坏性的工具都会暂停执行,并弹出带有确切参数的确认提示。由用户 —— 而非模型、非提示词、也非工具结果 —— 点击通过。2026 年发生的智能体抹除生产数据库的事件,正是因为没有这样的关口;智能体自行决定,智能体自行执行,而回滚机制并未生效。
时间作用域规则在这里也很重要:智能体在摄取了不受信任的外部内容的同一轮对话中,不应拥有访问高风险工具的权限。如果智能体刚刚读取了一个网页,它的上下文窗口中就不应该持有 delete_user 工具。在任何紧随不受信任输入之后的轮次中,从工具集中移除破坏性工具。只有当对话回到受信任状态时,才重新添加它们。
