一次 create_sandbox 调用背后:Agent Sandbox 的控制面、数据面与句柄机制详解

当 LLM Agent 需要执行一段自己生成的代码时,它会向隔离沙箱平台发起一个请求:“给我一个沙箱”。这个请求背后发生了什么?沙箱是怎么被分配的?后续的命令又是如何找到正确的沙箱的?

这是 Agent 基础设施中最容易被一笔带过、却最值得拆解的一环。本文以 Kubernetes SIG 的 Agent Sandbox 项目(kubernetes-sigs/agent-sandbox)为样本,把一次完整的沙箱生命周期——从 create_sandboxterminate——拆到协议层面。

核心结论先行:领养沙箱和操作沙箱走的是两条完全不同的通道。 create_sandbox 触发的是控制面流程(K8s API 上的 Claim/adopt,一次性的);后续 commands.run()files.write() 走的是数据面流程(HTTP 请求经网关/Router 打到具体的沙箱 Pod,每次操作都走一遍)。而把这两条通道缝在一起的,是 SDK 返回的那个"句柄"。

关联阅读:本文讲的是"沙箱怎么被领养、命令怎么找到沙箱"(调度/路由层);沙箱底层用什么隔离(Firecracker/gVisor/Kata 的取舍)见同日 从冯·诺依曼到 Agent 沙箱:虚拟化隔离技术栈的一次完整拆解——两篇合起来正好是 Agent 沙箱的"调度层"与"隔离层"两个面。


一、角色与对象:谁声明什么

在展开流程之前,先厘清各方的责任边界。Agent Sandbox 建立在**四个 CRD(自定义资源)**之上,每个对象归属于不同的角色:

对象 由谁创建 里面有什么
RuntimeClass(K8s 原生对象) 集群管理员,一次性 指向节点上的 runtime 二进制(runsc / kata)
SandboxTemplate 平台管理员/平台团队 镜像、资源限额、runtimeClassName、网络策略——隔离决策全在这里
SandboxWarmPool 平台管理员 引用 Template,维持预热池水位
SandboxClaim agent / 终端用户(SDK 代其创建) 只指定"我要认领哪个池",不涉及任何运行时细节

这个分工是刻意的。Red Hat 的 Agent Sandbox 发行版文档说得非常直白:

“开发者请求一个沙箱,平台交付一个。底层是 Kata Containers 还是别的运行时提供隔离,是平台管理员的选择,不是开发者的。”

也就是说,agent 在代码里调用 create_sandbox(warmpool="python-sandbox-pool") 时,它选择的只是一个池子名。这个池子背后挂着的 Template 里写着 runtimeClassName: kata 还是 gvisor,对 agent 是完全透明的——agent 最多"间接选择"(挑了哪个池就等于挑了哪类隔离),但无法越权改写。这带来两个直接收益:

  • agent 的代码对 gVisor、Kata、Firecracker 后端完全无感;
  • 平台换隔离技术、调池子水位、改网络策略,都不用通知任何一个 agent 的作者。

二、全景时序图

一次完整的沙箱生命周期,按时间顺序分为三个阶段:

sequenceDiagram
    participant A as Agent 进程(你的代码)
    participant SDK as Python/Go SDK
    participant K8s as K8s API Server
(控制面) participant C as Sandbox Controller participant P as 预热好的 Sandbox Pod
(含 runtime server) participant R as sandbox-router participant G as Gateway / LB Note over A,P: 阶段 0:池子预备(平台管理员提前做好) C->>P: 按 Template 预建 N 个就绪沙箱入池 Note over A,K8s: 阶段 1:控制面——领养(一次性) A->>SDK: client.create_sandbox(warmpool=...) SDK->>K8s: 创建 SandboxClaim(引用 WarmPool) K8s->>C: watch 到新 Claim C->>K8s: 从池中选一个就绪沙箱,
写入 claim.status.sandbox.name K8s-->>SDK: Claim Ready,返回沙箱名/ns/pod IP SDK-->>A: 返回句柄 sandbox(封装 id、ns、port、连接配置) Note over C,P: 异步:WarmPool 控制器补建一个新沙箱入池 Note over A,G: 阶段 2:数据面——每次操作都走这条路 A->>SDK: sandbox.commands.run("python3 run.py") SDK->>G: HTTP 请求 + Header
(X-Sandbox-ID / X-Sandbox-Namespace / X-Sandbox-Port) G->>R: 静态 HTTPRoute 全量转发给 router-svc R->>R: 校验 header,构造内网 DNS R->>P: 代理转发到 ..svc.cluster.local: P->>P: runtime server 执行命令
(如 FastAPI 的 /execute) P-->>R: 返回 stdout/stderr/exit_code R-->>G: 响应原路回传 G-->>SDK: HTTP 响应 SDK-->>A: result.stdout / result.exit_code Note over A,K8s: 阶段 3:销毁(控制面) A->>SDK: sandbox.terminate() SDK->>K8s: 删 Claim → 级联删 Sandbox/Pod/Service Note over C,P: 异步:WarmPool 补位回到目标水位

三个阶段对应三条不同的路径:领养只在阶段 1 发生一次,且不产生任何进入沙箱的流量;阶段 2 的每一次操作都是独立的 HTTP 请求;销毁回到控制面。 下面逐段拆解。


三、阶段 1:控制面——领养,一次纯 API 层面的"改归属"

是 agent(更准确说是 agent 进程里调用的 SDK)自主调用 create_sandbox。这一步触发的是纯 Kubernetes 控制面动作,全程分五步:

第 1 步:Claim 创建。 SDK 向 K8s API 提交一个 SandboxClaim,引用目标 WarmPool:

1
sandbox = client.create_sandbox(warmpool="python-sandbox-pool")

第 2 步:控制器挑沙箱。 SandboxClaim 控制器 watch 到新 Claim,进入 reconcile:在 WarmPool 维护的就绪沙箱集合里,按选择条件(包括 Template 版本匹配等)挑出一个匹配的沙箱。

第 3 步:绑定 + 状态回写。 控制器把选中的沙箱名写进 Claim 的 status.sandbox.name,Claim 状态转为 Ready。其中绝大部分时间是 K8s API 的 reconcile 往返,而不是冷启动——沙箱早就预热好了。

第 4 步:池子补位(异步)。 WarmPool 控制器发现可用沙箱少了一个,立即按 Template 重建一个新 Pod 入池。补位发生在领养之后且是异步的,不在用户的请求路径上——agent 不用等。

第 5 步(释放时):级联清理。 SDK 删除 Claim 时,级联删除其 Sandbox、Pod 和 headless Service,WarmPool 同步把水位补回目标值。

这里有一个容易被误解的点:"领养"这个词听起来像数据面有流量动作,其实整个过程只发生在 Kubernetes 控制面,一个 HTTP 请求都不会打进沙箱。被领养的 Pod 从头到尾没有被销毁重建——它就是池子里那个预热好的实例,只是"归属权"从池子转移到了 Claim。这正是 adopt(领养)这个词的字面含义:认领现成的孩子,而不是现场生一个


四、阶段 2:数据面——句柄 + Header 路由,每次操作都是独立请求

这是整个架构最讲究的一环:海量的、短生命周期的沙箱,流量是怎么找到正确的那个的?

为什么需要一个 Router?

官方文档的逻辑很直白:沙箱是海量的、临时性的,不可能给每个沙箱都在网关上建一条路由。所以所有流量先打到同一个静态入口(Gateway 提供的单一 IP),再由一个高可用的 router deployment 按 HTTP header 分流。一条静态路由服务上千个临时沙箱。

一次 commands.run() 的完整路径

以生产 Gateway 模式为例:

  1. SDK 把命令封装成 HTTP 请求(如 POST /execute,body 是 {"command": "python3 run.py"}),并在 header 里带上三个寻址字段:
    • X-Sandbox-ID:沙箱名
    • X-Sandbox-Namespace:命名空间
    • X-Sandbox-Port:runtime server 监听的端口(默认 8888)
  2. 请求打到云负载均衡(Gateway)提供的单一静态 IP;
  3. 一条静态 HTTPRoute 规则把所有流量无差别转发给 sandbox-router-svc;
  4. Router Service 把请求负载均衡到某个 router Pod;
  5. Router Pod 读取 header 做合法性校验(X-Sandbox-ID 返回 400,namespace 含非法字符返回 400,端口非数字返回 400),然后用这三个字段拼出 Kubernetes 内部 DNS 名:
    1
    <id>.<namespace>.svc.cluster.local:<port>
  6. 把原始请求代理过去;
  7. 请求经目标沙箱的 headless Service 落到 Pod 里的 runtime server;
  8. 响应沿同一路径流式回传。

Router 的细节设定也很务实:默认代理超时 180 秒(可通过 PROXY_TIMEOUT_SECONDS 调大以支持长时间运行的命令);沙箱不可达时返回 502;原始 Host header 不转发进沙箱。

沙箱 Pod 里跑的是什么?——runtime server

需要澄清:Router 转发到的不是"一个裸容器",而是沙箱 Pod 里运行的一个 runtime server 进程。以官方 Python Runtime Sandbox 模板为例,它是一个 FastAPI 服务,暴露 /execute 端点:接收 {"command": ...},返回 {stdout, stderr, exit_code},命令执行有超时控制(默认 300 秒)。文件读写走类似的文件 API 端点。其他模板(Jupyter、computer-use runtime 等)换的是镜像里的 server,模式不变。

SDK 的 commands.run() 之于 /execute,就像 ORM 之于 SQL——句柄是端点的客户端封装


五、句柄:横跨两个平面的唯一纽带

现在可以精确定义"句柄"了。create_sandbox() 返回的对象封装了三样东西:

  • 沙箱身份:领养得到的 sandbox ID 和 namespace——控制面领养的结果,也是后续路由寻址的原料;
  • 连接配置:router 地址或 Gateway URL,加上端口(默认 8888);
  • 操作方法:commands.run()files.read()/write()terminate() 等。

关键在于:agent 并不自己拼 header,句柄内部封装了这件事——每个沙箱对象持有自己的 id/ns/port,SDK 在每次调用时自动把它们塞进 header 并发往配置好的 router 地址。对写 agent 的人来说,这一切就是一行方法调用。

一个典型的工作流:

1
2
3
4
5
6
7
8
9
from k8s_agent_sandbox import SandboxClient

client = SandboxClient()
sandbox = client.create_sandbox(warmpool="python-sandbox-pool", namespace="default")
# 下面每一行都是一次独立的、经 router 的 HTTP 数据面请求
sandbox.files.write("/home/user/run.py", generated_code)
result = sandbox.commands.run("python3 /home/user/run.py")
print(result.stdout, result.exit_code)
sandbox.terminate()

如果拿数据库连接池做类比:Claim 领养相当于从连接池里拿到一条空闲连接,句柄就是那个 connection 对象——你不会拿它去碰底层 socket 细节,你只调用它上面的方法。


六、四种连接模式:同一套 API,四条数据通路

句柄的连接配置按部署形态有四种选择:

模式 数据路径 适用场景
Gateway Mode(生产) Client → 云 LB(Gateway)→ 静态 HTTPRoute → router-svc → Router Pod → 沙箱 Pod 大规模生产部署,沙箱量大
Tunnel Mode(开发) localhost → kubectl port-forward → router-svc → Router Pod → 沙箱 Pod 本地开发、CI、Kind/Minikube,无需公网 IP
In-Cluster Mode 集群内 Client → 直连沙箱 Pod IP,不可用时回退到稳定 DNS http://{sandbox_id}.{namespace}.svc.cluster.local:{port} agent 本身跑在同一集群里,绕过 router 少一跳
Advanced / Internal Mode Client → 自定义 URL(如 https://sandbox.example.com 或 router Service DNS) 自定义域名/HTTPS,或内部 agent 直连 router Service

关键观察:无论哪种模式,create_sandbox / terminate 的控制面行为完全一致——变的只是句柄里存的连接配置和后续请求的入口地址。这就是"句柄 = 身份 + 连接配置 + 操作方法"这个设计的价值:控制面与数据面被正交化,切换模式不改业务代码


七、总结:两个平面,一个句柄

把整篇文章压缩成两句话:

  • 控制面(create_sandbox / terminate):agent 自主发起,SDK ↔ K8s API ↔ 控制器,通过 Claim 从暖池领养沙箱——决定"你拥有哪个沙箱",全程不碰数据面;
  • 数据面(commands.run / files.write):每一次操作都是独立的 HTTP 请求,句柄携带的沙箱 ID 以 X-Sandbox-ID 等 header 的形式,经 Gateway → Router → 内部 DNS → 沙箱 Pod 的 runtime server 完成寻址与转发——决定"这次操作打到哪"。

句柄是横跨两个平面的唯一纽带:领养结果写进它的身份字段,后续操作消费这些字段。Router 的存在让数据面做到了"一个静态 IP 服务上千个临时沙箱"——不为每个沙箱建路由,一次 header 校验加 DNS 拼接就完成寻址。

而这套设计里最值得借鉴的抽象是责任分层:

  • 平台管理员用 Template + RuntimeClass 圈定"沙箱长什么样、用什么隔离"(静态决策,一次声明);
  • agent 运行时用 Claim + 句柄表达"我要一个沙箱、并且这样用它"(动态请求,按次发生);
  • 领养机制把两者在控制面缝合,数据面由 Router 统一收敛

对写 agent 的人来说,你永远只跟句柄的方法打交道——网关、Router、RuntimeClass、暖池水位这些基础设施细节,全部被压在了句柄背后。


参考资料