工具调用的安全边界
前言
Agent 可以让模型根据任务选择工具,例如查询天气或搜索景点。但模型生成的 Action 只是一段文本,可能格式错误,也可能请求调用不该使用的工具。如果程序把它直接当作代码执行,模型输出就越过了程序应当控制的边界。
本文先介绍 Agent 工具调用的基本安全原则,再以我近期在写的旅行 Agent (该项目源码在文末已贴出)为例,看看程序如何把模型生成的 Action 转换成一次受限的工具调用,以及这套实现仍有哪些局限。
1.安全边界
模型输出的 Action 是调用工具的请求,不是程序必须执行的指令 。
以旅行Agent中的天气查询为例,即使模型写出了 get_weather(city="北京"),程序也要先判断:这是不是允许的调用形式?get_weather 是否已注册?参数能否传给这个工具?只有通过检查,才进入执行环节。
这条原则适用于各种 Agent:由模型提出意图,由程序决定执行范围 。 工具的能力通常比一段聊天文本更具体,也可能产生真实影响,因此不能仅凭模型"想调用"就运行任意代码。程序需要把可调用的工具和可接受的输入限定在明确的范围内。
旅行 Agent 的 v0.1.0 版本采用文本形式的 Action。它从模型回复中提取 Action,解析工具名与参数,再交给工具注册表执行;执行结果作为 Observation 进入下一轮。这里的关键分界点是:模型只生成文本,真正选择函数并调用它的是程序。接下来沿着这条路径看 Action 如何被解析。
2. Action 解析
沿用前面的例子,模型可能输出 Action: get_weather(city="北京")。程序先提取 Action: 后的文本,再判断它是否符合约定的工具调用形式。下面是旅行 Agent v0.1.0 中负责解析调用的代码,完整保留了这段方法的检查路径:
python
def _parse_tool_call(
self,
action_str: str,
) -> tuple[str, dict[str, Any]] | None:
"""解析工具调用,只允许关键字参数。"""
try:
expression = ast.parse(
action_str,
mode="eval",
).body
except SyntaxError:
return None
if not isinstance(expression, ast.Call):
return None
if not isinstance(expression.func, ast.Name):
return None
if expression.args:
return None
# 后续参数处理省略
这段代码中的 ast.parse(..., mode="eval") 负责把文本解析成 Python 的抽象语法树,并要求输入是一个表达式。它只是在识别结构,并没有执行表达式。随后程序要求节点必须是函数调用 ast.Call,函数名必须是简单名称 ast.Name,并拒绝位置参数。
参数值再通过 ast.literal_eval 转换:
python
for keyword in expression.keywords:
if keyword.arg is None:
return None
try:
kwargs[keyword.arg] = ast.literal_eval(keyword.value)
except (ValueError, TypeError, SyntaxError):
return None
return expression.func.id, kwargs
ast.literal_eval 只接受字符串、数字、布尔值、列表等字面量,不能执行函数调用或任意表达式。当前实现因此没有使用 eval,但这仍然只是受限解析,不是完整沙箱:代码没有设置输入长度、嵌套深度等限制,也没有覆盖所有业务层面的风险。
解析完成后,程序只返回工具名称和参数字典。此时还没有调用任何函数;工具是否真的允许使用,要交给下一层的注册表判断。
3. 工具执行
解析出工具名和参数后,程序还不能仅凭这个名字调用函数。旅行 Agent 把可用工具预先注册到 ToolRegistry,执行时只从注册表中查找。下面的代码展示了注册、查找和参数绑定的关键路径;为控制篇幅,省略了描述信息和执行记录相关代码。
python
def register_function(
self,
name: str,
func: Callable,
description: str = "",
parameters: list[ToolParameter] | None = None,
) -> None:
if name in self._tools:
raise ValueError(f"工具已存在: {name}")
self._tools[name] = func
# 描述信息和参数信息的保存省略
def has_tool(self, name: str) -> bool:
return name in self._tools
def execute(self, name: str, **kwargs) -> str:
"""校验参数后执行工具。"""
start_time = time.perf_counter()
if not self.has_tool(name):
result = f"错误:未定义的工具 '{name}'"
# 执行记录相关代码省略
return result
func = self._tools[name]
tool_signature = signature(func)
try:
bound_arguments = tool_signature.bind(**kwargs)
except TypeError as exc:
result = f"错误:工具 '{name}' 参数错误: {exc}"
# 执行记录相关代码省略
return result
try:
result = str(func(**bound_arguments.arguments))
except Exception as exc:
result = f"错误:工具 '{name}' 执行失败: {exc}"
# 执行记录相关代码省略
return result
# 执行记录相关代码省略
return result
注册表在这里充当工具白名单:模型可以写出任意工具名,但程序只会查找事先注册的名称。找到函数后,signature.bind(**kwargs) 检查这些关键字参数能否绑定到函数签名,例如是否缺少必填参数、是否传入了不接受的参数。绑定成功,程序才调用这个已注册的函数,并把结果转成字符串返回给 Agent。
需要注意,参数绑定不等于参数校验完成。signature.bind() 不检查参数值是否符合类型标注,也不判断城市名等输入在业务上是否有效。这些检查如果需要,应由工具或额外的校验逻辑负责。
4. 实现局限
沿着前面的调用路径看,旅行 Agent 的 v0.1.0 已经建立了几道边界:程序解析模型生成的文本,只接受特定的调用形状和字面量参数,再从注册表中选择已注册的工具。它不使用 eval,但也不是完整沙箱。 解析器没有限制输入长度或字面量的嵌套深度;即使调用形式合法,已注册工具仍可能执行网络请求等实际操作。
参数检查也有明确范围。signature.bind() 只判断参数能否绑定到函数签名,不检查类型标注,也不判断输入是否符合业务规则。例如,一个参数在签名中标注为 str,并不意味着绑定时会拒绝其他类型的字面量。工具自身仍需按业务需要校验输入,并控制它能够产生的实际影响。
此外,当前解析代码把关键字参数依次写入字典。如果 Action 中重复使用同一个参数名,后面的值会覆盖前面的值;这段代码没有显式拒绝重复参数。因此,"通过解析并成功绑定"只能说明调用符合当前规则,不能证明输入安全或结果正确。设计其他 Agent 时,也应逐层说明每项检查实际保证了什么,再针对工具的能力补上相应约束。
后记
Agent 工具调用的关键,是把模型生成的 Action 当作请求,由程序决定能否执行 。
先明确允许哪些工具和输入,再让程序逐层检查,最后由工具承担自身的业务校验。
源码
本文项目案例源码:travel-agent-lab(v0.1.0)。文中的实现分析均以该版本为准。