工具调用的安全边界
- 前言
- 1.安全边界
- 2. Action 解析
- 3. 工具执行
- 4. 实现局限
- 后记
- 源码
前言
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 中负责解析调用的代码,完整保留了这段方法的检查路径:
def_parse_tool_call(self,action_str:str,)->tuple[str,dict[str,Any]]|None:"""解析工具调用,只允许关键字参数。"""try:expression=ast.parse(action_str,mode="eval",).bodyexceptSyntaxError:returnNoneifnotisinstance(expression,ast.Call):returnNoneifnotisinstance(expression.func,ast.Name):returnNoneifexpression.args:returnNone# 后续参数处理省略这段代码中的ast.parse(..., mode="eval")负责把文本解析成 Python 的抽象语法树,并要求输入是一个表达式。它只是在识别结构,并没有执行表达式。随后程序要求节点必须是函数调用ast.Call,函数名必须是简单名称ast.Name,并拒绝位置参数。
参数值再通过ast.literal_eval转换:
forkeywordinexpression.keywords:ifkeyword.argisNone:returnNonetry:kwargs[keyword.arg]=ast.literal_eval(keyword.value)except(ValueError,TypeError,SyntaxError):returnNonereturnexpression.func.id,kwargsast.literal_eval只接受字符串、数字、布尔值、列表等字面量,不能执行函数调用或任意表达式。当前实现因此没有使用 eval,但这仍然只是受限解析,不是完整沙箱:代码没有设置输入长度、嵌套深度等限制,也没有覆盖所有业务层面的风险。
解析完成后,程序只返回工具名称和参数字典。此时还没有调用任何函数;工具是否真的允许使用,要交给下一层的注册表判断。
3. 工具执行
解析出工具名和参数后,程序还不能仅凭这个名字调用函数。旅行 Agent 把可用工具预先注册到 ToolRegistry,执行时只从注册表中查找。下面的代码展示了注册、查找和参数绑定的关键路径;为控制篇幅,省略了描述信息和执行记录相关代码。
defregister_function(self,name:str,func:Callable,description:str="",parameters:list[ToolParameter]|None=None,)->None:ifnameinself._tools:raiseValueError(f"工具已存在:{name}")self._tools[name]=func# 描述信息和参数信息的保存省略defhas_tool(self,name:str)->bool:returnnameinself._toolsdefexecute(self,name:str,**kwargs)->str:"""校验参数后执行工具。"""start_time=time.perf_counter()ifnotself.has_tool(name):result=f"错误:未定义的工具 '{name}'"# 执行记录相关代码省略returnresult func=self._tools[name]tool_signature=signature(func)try:bound_arguments=tool_signature.bind(**kwargs)exceptTypeErrorasexc:result=f"错误:工具 '{name}' 参数错误:{exc}"# 执行记录相关代码省略returnresulttry:result=str(func(**bound_arguments.arguments))exceptExceptionasexc:result=f"错误:工具 '{name}' 执行失败:{exc}"# 执行记录相关代码省略returnresult# 执行记录相关代码省略returnresult注册表在这里充当工具白名单:模型可以写出任意工具名,但程序只会查找事先注册的名称。找到函数后,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)。文中的实现分析均以该版本为准。