本节摘要:本章收官,给出 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.py、src/smolagents/agents.py、e2b.toml、examples/sandboxed_execution.py
⚠️ 注意:远程执行器目前不支持 managed_agents(多智能体编排)——agents.py 里 create_python_executor 对非 local 类型显式抛异常。
四个沙箱都长在同一副骨架上,基类定义了完整协议:
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,其余能力基类承包。
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 保证的「工具自包含」在此兑现:不自包含的代码在这里根本跑不起来。
沙箱与主进程是两个世界,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(...)。
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()。
不想把代码送云上?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 停容器删容器。
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 标志防重复清理。
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)。
| 执行器 | 隔离边界 | 启动延迟 | 依赖 | 适合 |
|---|---|---|---|---|
| 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 跨界传值)让「换沙箱」退化成一个字符串参数——安全选型与业务代码彻底解耦。
下一节:离开装备区,进入第 7 章「多模型后端」——InferenceClientModel/TransformersModel/OpenAIServerModel 等如何把不同 LLM 服务统一成一套 generate 接口。