第 6 章 · 03 四种远程沙箱执行器


第 6 章 · 03 四种远程沙箱执行器

本节摘要:本章收官,给出 6.2 节开出的药方——remote_executors.py(1076 行)的四种真沙箱。RemotePythonExecutor 基类统一抽象:init 建环境、send_tools 注入工具(自动装依赖+重建工具定义)、send_variables 经 SafeSerializer 传变量、__call__/run_code_raise_errors 执行代码、FinalAnswerTool 被打补丁成「抛异常+序列化返回值」以提取最终答案。四个实现各有生态:E2BExecutor 云沙箱(仓库里配了 e2b.toml 模板,秒级启动)、DockerExecutor 本地容器隔离(Jupyter Kernel Gateway + WebSocket)、ModalExecutor 云函数按需拉起、BlaxelExecutor 毫秒级唤醒的休眠虚拟机。CodeAgent 只改一个 executor_type 参数即可切换。结尾权衡沙箱的开销(延迟/启动/成本)与收益(隔离)。

内容来源:src/smolagents/remote_executors.pysrc/smolagents/agents.pye2b.tomlexamples/sandboxed_execution.py

⚠️ 注意:远程执行器目前不支持 managed_agents(多智能体编排)——agents.py 里 create_python_executor 对非 local 类型显式抛异常。

学习目标

  1. 读懂 RemotePythonExecutor 基类的工具注入与最终答案提取机制。
  2. 了解 E2B/Docker/Modal/Blaxel 四个实现的架构与依赖。
  3. 掌握 executor_type 切换方式与四者选型。
  4. 会做沙箱开销与安全收益的权衡。

一、RemotePythonExecutor 基类:统一的注入与提取协议

四个沙箱都长在同一副骨架上,基类定义了完整协议:

class RemotePythonExecutor(PythonExecutor): FINAL_ANSWER_EXCEPTION = "FinalAnswerException" def __init__(self, additional_imports: list[str], logger, allow_pickle: bool = False): self.additional_imports = additional_imports self.logger = logger self.allow_pickle = allow_pickle self.installed_packages = [] def run_code_raise_errors(self, code: str) -> CodeOutput: raise NotImplementedError

allow_pickle 默认 False 且 docstring 重复安全警告:只有完全信任执行环境才可开。子类只需实现 run_code_raise_errors,其余能力基类承包。

1.1 send_tools:装依赖 + 重建工具

def send_tools(self, tools: dict[str, Tool]): if "final_answer" in tools: self._patch_final_answer_with_exception(tools["final_answer"]) # Install tool packages packages_to_install = { pkg for tool in tools.values() for pkg in tool.to_dict()["requirements"] if pkg not in self.installed_packages + ["smolagents"] } if "PIL" in packages_to_install: packages_to_install.discard("PIL") packages_to_install.add("pillow") if packages_to_install: self.installed_packages += self.install_packages(list(packages_to_install)) # Get tool definitions code = get_tools_definition_code(tools) if code: code_output = self.run_code_raise_errors(code) self.logger.log(code_output.logs)

三步:给 final_answer 打补丁(见 1.2);从每个工具的 to_dict()["requirements"] 汇总依赖包,在沙箱里 pip install(注意 PIL→pillow 的名字换算);最后用 get_tools_definition_code 把所有工具还原成源码,作为一段代码送进沙箱执行——工具定义就这样在沙箱内重建。第 5.1 节 tool_validation 保证的「工具自包含」在此兑现:不自包含的代码在这里根本跑不起来。

1.2 最终答案提取:跨进程的异常协议

沙箱与主进程是两个世界,FinalAnswerException 不能原生穿越。补丁机制:

def _patch_final_answer_with_exception(self, final_answer_tool: FinalAnswerTool): ... def forward(self, *args, **kwargs) -> Any: ... def _serialize_with_fallback(obj): if not ALLOW_PICKLE: json_safe = _to_json_safe(obj) # Will raise SerializationError if fails return "safe:" + json.dumps(json_safe) ... raise FinalAnswerException(_serialize_with_fallback(self._forward(*args, **kwargs))) _FinalAnswerTool.forward = forward ... final_answer_tool.__class__ = _FinalAnswerTool

新 forward 先调原逻辑(改名 _forward 保留),把返回值序列化成 "safe:..." 前缀字符串(序列化逻辑整体内联进方法体——因为方法源码要被抽出来发往沙箱,闭包引用活不过去,连 allow_pickle_setting 都要在源码里烘焙成字面量),再抛 FinalAnswerException。沙箱侧的执行器捕获这个异常名,主进程侧 _deserialize_final_answer 按前缀解包:

@staticmethod def _deserialize_final_answer(encoded_value: str, allow_pickle: bool = False) -> Any: if encoded_value.startswith("safe:"): json_data = json.loads(encoded_value[5:]) return SafeSerializer.from_json_safe(json_data) elif encoded_value.startswith("pickle:"): if not allow_pickle: raise SerializationError("Pickle data rejected: allow_pickle=False") return pickle.loads(base64.b64decode(encoded_value[7:])) ...

send_variables 同理走 SafeSerializer:变量序列化成字符串,连同一段自动生成的反序列化代码(get_deserializer_code,把 allow_pickle 设置烘焙进沙箱端代码)发进沙箱执行 locals().update(...)

二、四个沙箱实现

2.1 E2BExecutor:专为 AI 代码执行而生的云沙箱

class E2BExecutor(RemotePythonExecutor): def __init__(self, additional_imports, logger, allow_pickle=False, **kwargs): super().__init__(additional_imports, logger, allow_pickle) try: from e2b_code_interpreter import Sandbox except ModuleNotFoundError: raise ModuleNotFoundError( """Please install 'e2b' extra to use E2BExecutor: `pip install 'smolagents[e2b]'`""" ) if hasattr(Sandbox, "create"): # v2 SDK self.sandbox = Sandbox.create(**kwargs) else: # v1 SDK self.sandbox = Sandbox(**kwargs) self.installed_packages = self.install_packages(additional_imports)

E2B 是托管云沙箱服务,firecracker 微虚机秒级启动、用完即弃,SDK 兼容 v1/v2 两个构造器。执行结果的处理展示了它对富输出的原生支持:

execution = self.sandbox.run_code(code) ... for result in execution.results: if not result.is_main_result: continue for attribute_name in ["jpeg", "png"]: img_data = getattr(result, attribute_name, None) if img_data is not None: decoded_bytes = base64.b64decode(img_data.encode("utf-8")) return CodeOutput(output=PIL.Image.open(BytesIO(decoded_bytes)), ...)

沙箱里 matplotlib 画的图能以 png/jpeg 属性回传,主进程解码成 PIL.Image 塞进 CodeOutput——最终流进 ActionStep.observations_images 给多模态模型看。错误侧:异常名等于 FinalAnswerException 就提取答案,否则拼成完整报错回传给 LLM 重试。仓库根目录还有 e2b.toml 模板配置(team_id、template_id、start_cmd、dockerfile),可定制带预装依赖的自定义沙箱镜像。cleanup 调 sandbox.kill()

2.2 DockerExecutor:本地容器隔离

不想把代码送云上?Docker 在本机围出容器边界:

self.dockerfile_content = dockerfile_content or dedent( """\ FROM python:3.12-bullseye RUN pip install jupyter_kernel_gateway jupyter_client ipykernel EXPOSE 8888 CMD ["jupyter", "kernelgateway", "--KernelGatewayApp.ip=0.0.0.0", "--KernelGatewayApp.port=8888"] """ )

架构是「容器内跑 Jupyter Kernel Gateway,宿主机经 WebSocket 对话内核」:构造时按需构建镜像(python:3.12-bullseye + kernel gateway)、启动容器并映射端口、生成随机 KG_AUTH_TOKEN 鉴权、等 Jupyter 就绪后建 kernel:

token = secrets.token_urlsafe(16) env["KG_AUTH_TOKEN"] = token self.container = self.client.containers.run(self.image_name, **container_kwargs) ... self.kernel_id = _create_kernel_http(f"{self.base_url}/api/kernels?token={token}", self.logger) self.ws_url = f"ws://{host}:{port}/api/kernels/{self.kernel_id}/channels?token={token}"

执行走标准 Jupyter 消息协议——_websocket_send_execute_request 组 execute_request 报文,_websocket_run_code_raise_errors 循环收消息:stream 类型的攒日志、execute_result 存结果、error 类型若 ename 是 FinalAnswerException 就反序列化为最终答案、status idle 表示执行完毕。注意:代码在沙箱内是原生执行的(没有 AST 解释器),安全完全由容器边界提供——这正呼应 6.2 节的哲学:隔离交给 OS 层。cleanup 停容器删容器。

2.3 ModalExecutor 与 BlaxelExecutor:两种云函数形态

ModalExecutor 起的是 Modal Sandbox(云上的按需容器,闲置自动回收):

create_kwargs = { "image": modal.Image.debian_slim().uv_pip_install("jupyter_kernel_gateway", "ipykernel"), "timeout": 60 * 5, **create_kwargs, } ... entrypoint = ["jupyter", "kernelgateway", "--KernelGatewayApp.ip=0.0.0.0", f"--KernelGatewayApp.port={port}"] self.sandbox = modal.Sandbox.create(*entrypoint, **create_kwargs) tunnel = self.sandbox.tunnels()[port] kernel_id = _create_kernel_http(f"https://{tunnel.host}/api/kernels?token={token}", logger) self.ws_url = f"wss://{tunnel.host}/api/kernels/{kernel_id}/channels?token={token}"

镜像由 Modal 的 debian_slim 加 uv 快装 kernel gateway;容器经加密隧道暴露端口,执行链路与 Docker 版完全一致(同一个 WebSocket 函数复用),默认 5 分钟超时。BlaxelExecutor 卖点是「快」:文档宣称虚拟机从休眠唤醒低于 25 毫秒、缩容到零后仍保留内存状态——适合高频短任务。它同样起 jupyter kernel gateway 镜像(blaxel/jupyter-notebook),支持指定 region/ttl/memory;装包走独立的进程 API(post_process 起 pip、轮询 get_process_identifier 等退出码),cleanup 删沙箱且用 _cleaned_up 标志防重复清理。

三、executor_type:一个参数切换五种命运

agents.py 中的工厂:

def create_python_executor(self) -> PythonExecutor: if self.executor_type not in {"local", "blaxel", "e2b", "modal", "docker"}: raise ValueError(f"Unsupported executor type: {self.executor_type}") if self.executor_type == "local": return LocalPythonExecutor( self.additional_authorized_imports, **{"max_print_outputs_length": self.max_print_outputs_length} | self.executor_kwargs, ) else: if self.managed_agents: raise Exception("Managed agents are not yet supported with remote code execution.") remote_executors = { "blaxel": BlaxelExecutor, "e2b": E2BExecutor, "docker": DockerExecutor, "modal": ModalExecutor, } return remote_executors[self.executor_type]( self.additional_authorized_imports, self.logger, **self.executor_kwargs )

examples/sandboxed_execution.py 演示四种沙箱统一用法(上下文管理器保证 cleanup):

# Docker executor example with CodeAgent(tools=[WebSearchTool()], model=model, executor_type="docker") as agent: output = agent.run( "How many seconds would it take for a leopard at full speed to run through Pont des Arts?" )

换 e2b/modal/blaxel 只改一个字符串。agent 侧完全无感——CodeAgent 调的都是 self.python_executor(code_action),接口由 PythonExecutor 抽象基类钉死(send_tools/send_variables/__call__→CodeOutput)。

3.1 选型与权衡

执行器 隔离边界 启动延迟 依赖 适合
local 无(进程内) 可信环境/原型
docker 本机容器 秒~分钟(首次建镜像) 本机 Docker 服务 数据不出内网的团队
e2b 云端微虚机 秒级 E2B 账号 最省心的默认云选项
modal 云端按需容器 秒级+隧道握手 Modal 账号 已用 Modal 的团队
blaxel 云端休眠 VM <25ms 唤醒 Blaxel 账号 高频短任务

代价清单要心里有数:网络延迟——本地执行函数调用是微秒级,沙箱要经 HTTP/WebSocket 往返,每步动作都多一跳;启动成本——首次建镜像/拉起沙箱动辄数秒,好在 send_tools 后沙箱跨步复用,均摊后可控;金钱成本——云沙箱按用量计费;能力缺口——多智能体(managed_agents)暂不支持远程执行。收益同样清晰:安全边界真实存在(OS 级隔离,代码失控也烧不到宿主)、环境干净(依赖装在一次性环境,不怕污染)、预装加速(E2B 模板镜像可预装全家桶)。工程判断:开发期 local,对外服务 e2b/docker,内网合规 docker,高频轻任务 blaxel。

💡 阶梯要点:本阶补上全书安全版图的最后一块。回顾 6.1→6.2→6.3 的完整叙事:AST 解释器提供进程内的「细粒度检查点」,黑名单与 dunder 禁止提高绕过成本但不构成边界,真隔离来自 OS 层的沙箱。RemotePythonExecutor 的协议设计(send_tools 注入、异常协议提取 final_answer、SafeSerializer 跨界传值)让「换沙箱」退化成一个字符串参数——安全选型与业务代码彻底解耦。

本节要点回顾

  • RemotePythonExecutor 协议:run_code_raise_errors 由子类实现;send_tools 三步(补丁 final_answer、装依赖(经 to_dict 的 requirements)、get_tools_definition_code 重建工具源码)。
  • 最终答案协议:沙箱内 final_answer 抛 FinalAnswerException,值序列化为 safe:/pickle: 前缀字符串(序列化代码内联烘焙进方法源码),主进程按前缀反序列化。
  • E2B:托管微虚机秒级启动,结果对象原生携带 png/jpeg/chart 等富输出,e2b.toml 可定制模板镜像。
  • Docker:容器内 Jupyter Kernel Gateway + 随机 token 鉴权,WebSocket 走 Jupyter 消息协议(stream/execute_result/error/idle);沙箱内是原生执行,安全全靠容器边界。
  • Modal:debian_slim+uv 预装、加密隧道、默认 5 分钟超时;Blaxel:休眠 VM 毫秒级唤醒、独立进程 API 装包、防重复清理。
  • executor_type∈{local,blaxel,e2b,modal,docker},工厂创建;远程执行暂不支持 managed_agents;CodeAgent 经统一 PythonExecutor 接口无感切换。
  • 权衡:沙箱代价=网络往返+启动+费用,收益=真实隔离+干净环境+可预装;开发 local、生产 e2b/docker、内网 docker、高频 blaxel。

下一节:离开装备区,进入第 7 章「多模型后端」——InferenceClientModel/TransformersModel/OpenAIServerModel 等如何把不同 LLM 服务统一成一套 generate 接口。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U