编程 Agent Substrate 深度拆解:Google 要在 Kubernetes 之上再造一层「Agent 控制面」——从 gVisor 快照到 30 倍超售的密度战争

2026-08-11 03:48:52 +0800 CST views 4

Agent Substrate 深度拆解:Google 要在 Kubernetes 之上再造一层「Agent 控制面」——从 gVisor 快照到 30 倍超售的密度战争

一、开场:一个被所有人忽略的账单问题

先说一个很多团队正在经历、但没人愿意公开算的账。

假设你做了一个 AI Agent 产品,每个用户一个独立的 Agent 实例。Agent 会执行模型生成的 Python 代码,所以必须沙箱隔离,一个用户一个 Pod,跑 gVisor。产品跑起来了,1 万个活跃用户。

于是你的集群里有 1 万个 Pod。每个 Pod 哪怕只要 512Mi 内存、200m CPU,也是 5TB 内存 + 2000 核。按主流云厂商的价格,这一个月的账单能轻松突破六位数人民币。

然后你去看监控,发现一个让人血压升高的事实:这 1 万个 Pod,99% 的时间在发呆。

Agent 的负载曲线跟微服务完全不是一回事。它的典型生命周期是:用户发一句话 → Agent 疯狂调用 8 个工具、跑 45 秒 → 停下来等人类确认 → 等了 6 个小时 → 用户回来点了个「同意」→ 再跑 20 秒 → 又睡了。

45 秒 + 20 秒的工作,占用了 6 小时的资源。资源利用率 0.3%。

你可能会想:那我用 Serverless 不就完了?问题在于 Agent 是有状态的。它的对话上下文在内存里,它的工作目录里有刚 clone 的 repo、跑了一半的 build 缓存、pip 装好的依赖。你把 Pod 一杀,下次唤醒等于从零开始——冷启动几十秒不说,用户上下文全丢。

这就是 Google 在 2026 年 5 月扔出 Agent Substrate、Agent Executor(AX)和 GKE Agent Sandbox 这一整套东西的原因。它们要解决的不是「怎么写 Agent」,而是「几百万个大部分时间在睡觉、但随时可能醒过来、还必须互相隔离的有状态进程,怎么在有限的物理机上跑得又便宜又快」。

这是一个纯粹的分布式系统问题,跟 Prompt 工程一点关系都没有。而恰恰是这类问题,AI 自己解决不了。

本文会把这套东西从上到下拆开:为什么 Kubernetes 在这个场景下会崩、Substrate 的双层资源模型怎么设计、快照和恢复到底发生了什么、网络请求怎么触发唤醒、AX 的 single-writer + event log 如何保证可恢复性,以及一堆真实的 YAML、Go 代码和踩坑清单。

需要提前声明一句,这也是我觉得最值得强调的一点:Agent Substrate 官方文档里白纸黑字写着 "Much of this architecture is aspirational, and is not yet implemented"(这份架构里很多东西还是愿景,尚未实现)。项目自己也说 "not ready for production use, and the APIs are almost guaranteed to change"。所以本文讲的是设计哲学和已落地的部分,不是让你明天就往生产环境上搬。


二、为什么 Kubernetes 不够用:四条物理层面的墙

Kubernetes 是为「长期运行、状态相对稳定的微服务」和「可预测的批处理任务」设计的。这个设计假设在 Agent 场景下,四个地方同时爆掉。

2.1 空转的 Pod 依然吃资源

这是最直白的一条。Kubernetes 没有「把 Pod 挂起、释放内存、但保留状态」这个原语。你只有两个选择:Pod 活着(吃资源),或者 Pod 死了(丢状态)。

有人会说,那我用 spec.replicas: 0 或者 KEDA 缩到零?可以,但缩到零意味着容器进程被 SIGKILL,内存里的一切消失。下次拉起来要重新拉镜像(哪怕有缓存也要解压层)、重新起进程、重新初始化。对于一个跑了 20 分钟才把 node_modules 装好的编码 Agent,这是灾难。

而且 Pod 密度还有硬上限。kubelet 默认每节点 110 个 Pod,就算你调到 256,一台 128 核的机器也就跑 256 个 Agent。要跑 100 万个 Agent,你需要 4000 台机器——它们的 CPU 平均利用率还不到 1%。

2.2 API Server 不是为百万级资源设计的

假设你真的给每个 Agent 建一个 CR,100 万个对象塞进 etcd。etcd 的建议数据量上限是 8GB,单个 watch 的序列化开销、list-watch 的 relist 风暴、controller 的 informer 缓存内存占用……任何一个有过大规模 K8s 运维经验的人都知道这条路走不通。

更要命的是写入频率。Agent 的状态变化是子秒级的:唤醒、执行工具、挂起、再唤醒。100 万个 Agent 如果平均每分钟状态变化一次,就是 1.6 万 QPS 的写入打在 API Server 上。kube-apiserver 在优化良好的集群里,写 QPS 通常也就几百到几千的量级。直接打穿。

2.3 调度路径太长

一个 Pod 从 kubectl apply 到能接流量,要经历:API Server 持久化 → scheduler watch 到 → 打分选节点 → 绑定 → kubelet watch 到 → 拉镜像 → 创建 sandbox → 起容器 → readinessProbe 通过 → endpoint 更新 → kube-proxy/Envoy 收敛。

这条链路涉及多个异步组件的最终一致收敛,好的情况下 2-5 秒,差的情况下(镜像冷、节点扩容)几十秒到几分钟。

对于一个要跑几小时的服务,2 秒启动完全可以接受。但对于一个「用户点了一下按钮,Agent 醒来跑 200 毫秒然后又睡了」的工作负载,2 秒的调度延迟意味着 90% 的时间花在等待基础设施上

Substrate 给自己定的北极星指标是:激活延迟 P95 ≤ 100 毫秒。这个数字直接排除了走 kube-scheduler 的可能性。

2.4 状态管理没有合适的原语

PersistentVolume 是为「一个 Pod 长期挂一个盘」设计的。你不可能有 100 万个 PVC,在毫秒级里频繁 attach/detach,而且每个的数据量从几 KB 到几十 GB 不等。CSI 的 attach/detach 通常以秒计,跟 100ms 的目标差两个数量级。

小结:不是 K8s 不行,是抽象层不对

这里要说句公道话——Substrate 的设计者非常清楚这一点,所以他们没有另起炉灶。Substrate 是跑在 Kubernetes 上的,用 K8s 管物理资源(Worker Pod 的供给、节点伸缩、镜像分发、RBAC、审计),只把「高频、低延迟、海量小对象」这一层单独抽出来做。

这个取舍很关键。它意味着你的 Agent 集群和你的推理集群、训练集群可以共享同一套基础设施管理,这在 RL 场景(Agent 采样 → 推理 → 训练循环)里价值巨大。


三、三层栈:Sandbox / Substrate / AX

Google 这次放出来的不是一个工具,是一套分层解耦的栈。按 TCP/IP 的方式自底向上理解:

层级代表组件核心职责关键技术
沙箱隔离层GKE Agent Sandbox、kubernetes-sigs/agent-sandbox不可信代码的强隔离、Standby 缓冲池gVisor、Pod 快照、温池
计算调度层Agent Substrate高密度多路复用、亚秒级挂起/恢复独立控制面、快照存储、Agent 感知路由
运行时层Agent Executor(AX)状态一致性、断线恢复、执行审计、轨迹分支Single-Writer、持久化事件日志、MCP
应用层ADK / LangChain / Claude Code / 你的业务业务逻辑与场景交互Prompt、知识库、工具定义

三个开源项目的地址:

  • github.com/agent-substrate/substrate(Apache-2.0)
  • github.com/google/ax(Apache-2.0)
  • github.com/kubernetes-sigs/agent-sandbox(CNCF/K8s SIG)

有意思的是,这三层全部是 Go 写的。最底下的 Kubernetes 和 gVisor 是 Go,中间的 Substrate 是 Go,AX 也是 Go。而上层的 Agent 应用可以是 Python(LangChain、ADK)或者任何东西——因为 Substrate 管的是 OCI 容器,跟语言无关。

这个分工其实揭示了一件事:运行时底层是系统工程,应用层才是算法工程。Go 在这里扮演了「系统级胶水」的角色,把 gRPC 双向流、快照序列化、内核态沙箱这些硬核原语封装成 CLI 和 CRD。


四、核心抽象:Actor、Worker 与超售

4.1 为什么叫 "Actor" 而不是 "Agent"

Substrate 的文档刻意用 "actor"(角色/参与者)而不是 "agent"。原因很实在:它管的东西不一定是 AI Agent。任何「大部分时间空闲、突发性执行、需要沙箱隔离、有状态」的负载都适用——沙箱化的 MCP Server、用户级的代码执行环境、按需唤醒的插件运行时,甚至是在线 IDE 的后端。

这个「低意见(low-opinion)」的定位很重要,它让 Substrate 不会绑死在某个 Agent 框架上。README 里明确说了它兼容 ADK、LangChain、Claude Code、CodeX、MCP Server。

4.2 核心公式:N 个 Actor 映射到 M 个 Worker(N >> M)

这是整个系统的核心思想,一句话说完:

预先启动一批常驻的空 Worker Pod(沙箱),把海量 Actor 的状态快照存在对象存储里,需要时把快照恢复进任意一个空闲 Worker。

官方 Demo 的数字是:~250 个有状态 Actor 复用 8 个物理 Pod,超售比 30 倍以上

这里的关键在于,Worker Pod 是同质的、预热的、匿名的。它就是一个跑着 ateom(沙箱管理进程)的空壳,等着别人把状态塞进来。这样就完全绕开了 kube-scheduler:Substrate 的调度器只需要从一个内存里的 Worker 列表中挑一个 IDLE 的,然后发一个 gRPC 调用。这是微秒级的操作。

用一个简单的模型算算成本收益。设:

  • N = Actor 总数
  • d = Duty cycle(Actor 处于活跃状态的时间占比)
  • σ = 安全系数(应对突发并发,通常 2-4)

那么需要的 Worker 数量约为:

M ≈ N × d × σ

回到开头那个例子:1 万个 Agent,活跃时间占比 0.3%(65 秒 / 6 小时),安全系数取 3:

M ≈ 10000 × 0.003 × 3 = 90

从 1 万个 Pod 降到 90 个 Pod。 内存从 5TB 降到 45GB(外加对象存储里的快照,那个便宜得多)。这就是 Substrate 想要的量级的收益。

当然现实没这么美好——快照的存储成本、恢复时的网络带宽、状态数据的局部性问题都会吃掉一部分收益。这个我们后面单独讲。

4.3 术语表

看代码前先把黑话对齐,不然完全读不懂:

术语含义
Actor一个有状态负载实例。相当于「一个用户的 Agent」。
Worker一个预启动的物理沙箱容器,等着被塞入 Actor 状态。
Atespace全局隔离边界,类似 namespace 但属于 Substrate 自己的命名空间体系。
ActorTemplateCRD。定义 Actor 的镜像、环境、卷、快照策略。不可变。
WorkerPoolCRD。定义一池温机容量(replicas、沙箱类型、节点选择)。
SandboxConfig集群级 CRD。把 runsc 二进制/microVM 内核这些运行时资产从模板里解耦出来。
ate-api-server控制面。gRPC 接口,管 Actor/Worker 生命周期。
atelet节点级 DaemonSet。管本节点的 Worker Pod,搬运快照。
ateom跑在 Worker Pod 内部的沙箱驱动,执行 checkpoint/restore。
atenet网络层,Envoy 路由 + DNS + atunnel 隧道。
Golden Snapshot从 ActorTemplate 生成的「黄金快照」,新 Actor 从它秒起。

4.4 双层资源模型:CRD 管配置,Redis 管实例

这是我认为整个设计里最聪明的一刀。Substrate 把资源分成两类,放在两个完全不同的存储里:

第一类:系统配置(走 Kubernetes CRD)

  • WorkerPoolActorTemplateSandboxConfig
  • 特点:数量少(几十到几百个)、变更频率低、需要 RBAC/审计/GitOps
  • 存在 etcd 里完全没问题,而且能直接复用 K8s 生态的一切治理能力

第二类:动态实例状态(走 Redis/ValKey)

  • ActorWorker 记录
  • 特点:数量巨大(目标 10 亿)、每秒变更成千上万次、需要原子操作和亚毫秒查询
  • 放 etcd 必死,放 Redis 刚好

这个划分的精髓在于:用 Kubernetes 做它擅长的(声明式治理),用 Redis 做它擅长的(高频状态机)。平台团队依然可以用熟悉的 RBAC 管 WorkerPool 谁能改,而百万级 Actor 的 assign/suspend 完全不碰 kube-apiserver。

资源关系图(UML 风格):

kube-apiserver 侧:
  ActorTemplate (CRD) ──workerPoolRef──> WorkerPool (CRD)
                                              │
                                        atecontroller 调谐
                                              ↓
                                          Deployment
                                              │ manages *
                                              ↓
                                        WorkerPod (ateom + runsc)

ate-api-server 侧(Redis):
  Actor  {status, snapshotRefs}  ──runs on 0..1──>  Worker {actorName, podIP}
     ↑ derived from ActorTemplate                        │ maps to 1
                                                         ↓
                                                    WorkerPod

五、动手:从零跑起一个 Substrate 集群

理论说够了,上手。以下是官方 kind 快速开始流程,我加了些注解。

5.1 环境准备

需要 Go、kubectldockerkind 由项目自己通过 Go 管理,不用单独装。

# 1. 创建 kind 集群 + 本地镜像仓库
hack/create-kind-cluster.sh

# 2. 安装 ate 系统(含 valkey 状态存储、rustfs 对象存储)
hack/install-ate-kind.sh --deploy-ate-system

# 3. 安装 counter 示例
hack/install-ate-kind.sh --deploy-demo-counter

# 4. 安装 kubectl 插件
go install ./cmd/kubectl-ate

注意第 2 步装的三样东西对应架构里的三个存储角色:

  • valkey:Actor/Worker 实例状态(高频读写)
  • rustfs:快照对象存储(本地版 S3,生产上换成 GCS/S3)
  • ate 系统组件:ate-api-server、atecontroller、atelet DaemonSet、atenet

5.2 创建 Atespace 和 Actor

# atespace 是必须先建的隔离边界
kubectl ate create atespace demo

# 从模板创建一个 actor 实例
kubectl ate create actor my-counter-1 -a demo --template=ate-demo-counter/counter

这里 --template 的格式是 <namespace>/<name>,指向一个 ActorTemplate CR。

关键点create actor 这一步不会创建任何 Pod。它只是在 ate-api-server 的 Redis 里写了一条记录,状态是 SUSPENDED。这个操作的成本是一次 Redis 写入,微秒级。你可以在几秒内创建几万个 Actor,集群一点感觉都没有。

5.3 用一个 HTTP 请求唤醒它

# 把路由器暴露到本地
kubectl port-forward -n ate-system svc/atenet-router 8000:80

另开一个终端:

curl -X POST \
  -H "Host: my-counter-1.demo.actors.resources.substrate.ate.dev" \
  -i http://localhost:8000/

这一个 curl 背后发生的事情,是整套系统最精彩的部分,我们下一节详细拆。

5.4 WorkerPool 配置

apiVersion: ate.dev/v1alpha1
kind: WorkerPool
metadata:
  name: agent-pool
  namespace: ate-demo
  labels:
    workload: secret-agent
spec:
  # 物理温机数量——这是你真正付钱的东西
  replicas: 10
  # ateom herder 镜像,按沙箱类型选择
  ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor
  # sandboxClass 默认 gvisor,可选 microvm
  template:
    nodeSelector:
      node-role: agent-worker
    priorityClassName: substrate-workers
    resources:
      requests:
        cpu: 500m
        memory: 2Gi
      limits:
        cpu: "2"
        memory: 8Gi

这里的 resources单个 Worker Pod 的资源,而 Actor 是被塞进这个 Pod 里跑的。所以一个 Worker 的内存上限,就是它能承载的单个 Actor 的内存上限。这一点在容量规划时容易踩坑——你需要按「最大的那个 Actor」来定 Worker 规格。

5.5 ActorTemplate 配置

apiVersion: ate.dev/v1alpha1
kind: ActorTemplate
metadata:
  name: secret-agent
  namespace: ate-demo
spec:
  # 沙箱根镜像
  pauseImage: "gcr.io/gke-release/pause@sha256:bcbd57ba5653580ec647b16d8163cdd1112df3609129b01f912a8032e48265da"
  containers:
  - name: agent
    # 必须用 digest 固定!改镜像会让所有快照失效
    image: gcr.io/my-project/my-agent@sha256:abcd1234...
    readyz:
      httpGet:
        path: /readyz
        port: 80
  sandboxClass: gvisor
  # 只调度到带这个 label 的 WorkerPool
  workerSelector:
    matchLabels:
      workload: secret-agent
  snapshotsConfig:
    location: gs://my-bucket/secret-agent

几个必须注意的约束,官方文档里写得很清楚但很容易漏:

  1. image 必须用 digest 固定。用 tag 的话,镜像一变快照就失效了——快照里存的是那个镜像的进程内存,跟新镜像根本对不上。
  2. ActorTemplate 是不可变的sandboxClass 在创建时就定死,因为快照不能跨沙箱运行时恢复。gVisor 的 checkpoint 和 microVM 的 memory snapshot 是两种完全不同的东西。
  3. sandboxClass 是硬调度门禁,它跟 workerSelector 是 AND 关系,只会进一步收窄可用池。

5.6 SandboxConfig:把运行时二进制解耦出来

这个设计我第一次看到时愣了一下,但想明白之后觉得非常必要:

apiVersion: ate.dev/v1alpha1
kind: SandboxConfig
metadata:
  name: gvisor-default
spec:
  sandboxClass: gvisor
  default: true
  assets:
    amd64:
      gvisor:
        url: "gs://gvisor/releases/release/20260803/x86_64/gvisor.tar.bz2"
        sha256: "9e7a5fcc2cbd28c9cd4af910a9327abcf..."

为什么 runsc 二进制不打进 Worker 镜像,而是运行时从内容寻址的 URL 拉?

因为快照的可恢复性依赖于沙箱运行时版本。gVisor 的 checkpoint 格式在不同版本间不保证兼容。所以 Substrate 把 runtime 版本记录进每个快照的 manifest,恢复时按 manifest 里记的版本去拉对应的二进制。

这样运维升级 gVisor 只需要改一个集群级 CR,而存量的旧快照依然能用旧版本的 runsc 恢复。这是个很老练的设计——任何做过序列化格式版本管理的人都会点头。


六、一个 curl 请求的完整旅程

现在回到 5.3 那个 curl。我们逐跳拆解。

6.1 DNS:统一命名网格

Actor 的地址格式是:

<actor-name>.<atespace>.actors.resources.substrate.ate.dev

这是一个位置透明的名字。它不包含任何关于「这个 Actor 现在跑在哪个 Pod 上」的信息——因为它压根就可能没在跑。

这个设计跟 K8s Service 的思路一致,但语义更强:K8s Service 背后至少要有一个活着的 Endpoint,而 Substrate 的 Actor 名字在 Actor 处于 SUSPENDED 状态时依然有效。名字指向的是「身份」,不是「实例」。

6.2 Envoy ext_proc:在数据面拦截并触发唤醒

请求打到 atenet-router,这是一个跑着 Envoy 的组件,配了 ext_proc(External Processing)过滤器。

ext_proc 是 Envoy 一个相对新但非常强大的能力:它让 Envoy 在处理请求的各个阶段(headers、body、trailers)通过 gRPC 双向流回调外部服务,外部服务可以修改请求、改路由、甚至直接返回响应。

这里的流程是:

  1. Envoy 收到请求,ext_proc 在 request headers 阶段被触发
  2. 外部处理器从 Host 头解析出 actor-nameatespace
  3. 调用控制面 ate-api-server 的 gRPC:ResumeActor(actor, atespace)
  4. 控制面查 Redis:这个 Actor 现在是 RUNNING 还是 SUSPENDED?
    • RUNNING → 直接返回它当前所在的 Worker IP
    • SUSPENDED → 走完整的恢复流程(下一节详述),返回新分配的 Worker IP
  5. ext_proc 把解析出的目标写回 Envoy 的路由决策
  6. Envoy 转发

这里的精妙之处在于:唤醒是请求驱动的,而且对客户端完全透明。 客户端只是发了一个普通的 HTTP 请求,它不知道也不需要知道对端在 80 毫秒前还是一个躺在 GCS 里的内存镜像。

6.3 atunnel:认证隧道进沙箱

拿到 Worker IP 之后,atenet-router 不是直接 HTTP 过去,而是开一条到 Worker 的 443 端口的认证 TLS 隧道,对端是 atunnel 监听器(由 ateom 托管)。atunnel 再通过私有 veth 接口把请求交给沙箱内真正的 Actor 进程。

为什么要多这一跳?因为沙箱内的网络是隔离的,Actor 的接口地址是一个固定的内网地址(当前实现是 169.254.17.2)。而且同一个 Worker Pod 在不同时刻承载不同的 Actor,你没法用 K8s 的 Service/Endpoint 机制来寻址——Endpoint 的更新是最终一致的,而这里的切换是毫秒级的。

所以必须有一个知道「此刻这个 Worker 里装的是谁」的代理层。

6.4 Request Parking:温池满了怎么办

Substrate 有个叫 Request Parking 的机制,我觉得这是超售系统的必备品但很多人会忽略:

当 WorkerPool 被打满(所有 Worker 都 BUSY),新来的唤醒请求怎么办?常规做法是返回 503。Substrate 的做法是在 Router 层把请求「停车」,挂起等待,直到有 Worker 空出来

这本质上是把「超售导致的容量抖动」用排队掩盖掉,而不是暴露给用户。代价是尾延迟会拉长。这个取舍在 Agent 场景下是对的——用户宁愿等 2 秒,也不想看到「服务暂时不可用」。

但要注意,这也意味着你的 P99 延迟会跟超售比强相关。超售 30 倍且 duty cycle 估算错误,P99 能飙到几十秒。这是必须监控的指标。


七、快照与恢复:真正的技术核心

这一节是全文最硬的部分。挂起/恢复到底发生了什么?

7.1 gVisor 路径:runsc checkpoint/restore

gVisor 的核心是 Sentry——一个用 Go 写的用户态内核。它拦截沙箱内应用的所有系统调用,在用户态实现 Linux 系统调用语义,只在必要时向宿主内核发起有限的、经过审计的调用。

这带来一个副产品:因为 Sentry 完全掌握着沙箱内进程的全部状态(内存、文件描述符、进程树、网络连接),它可以把这些状态完整序列化出来。这就是 runsc checkpoint

流程大致是:

atelet(节点 DaemonSet)
  → gRPC 调用 ateom(Pod 内驱动)
    → ateom 执行 runsc checkpoint
      → Sentry 冻结进程树
      → 序列化:内存页 + fd 表 + 进程状态 + 网络状态
      → 写入本地临时目录
  → atelet 把快照流式上传到 GCS/S3
  → 控制面把 Worker 标记为 IDLE,Actor 标记为 SUSPENDED + snapshotRef

恢复是逆过程。但有个重要细节:恢复不一定回到原来的节点。这就是文档里说的「数据局部性是一等公民问题」——如果快照在 us-central1 的 GCS,而唤醒时分配的 Worker 在另一个可用区,你要付出跨区拉取几百 MB 内存镜像的延迟。

这是 Substrate 目前明确承认还没解决好的问题之一。

一个真实的坑:文档里提到,gVisor 后端当前需要 runsc--allow-connected-on-save 标志,来绕过「checkpoint 时网络连接恢复」的一个 bug。如果你自己编译 runsc,这个 flag 必须开。

7.2 microVM 路径:userfaultfd 按需分页

microVM 路径(Kata Containers + Cloud Hypervisor)走的是完全不同的机制,而且技术上更有意思。

它做的是内存快照 + userfaultfd 按需分页恢复

userfaultfd 是 Linux 的一个系统调用,允许用户态程序接管一段虚拟内存区域的缺页处理。恢复时的流程是:

  1. 创建 VM,把内存区域注册到 userfaultfd但不实际加载任何内存页
  2. 立刻启动 vCPU
  3. Guest 访问某个页 → 触发缺页 → 内核通知用户态 handler
  4. handler 从快照文件(可能在远端对象存储)里读那一页,填进去
  5. Guest 继续执行

关键收益:恢复延迟不再跟内存大小成正比。 一个 8GB 内存的 VM,传统的「全量加载再启动」要读 8GB,而按需分页只需要加载 Guest 启动后头几毫秒真正访问的那几百个页——可能就几 MB。

这是 AWS Firecracker 的 snapshot-restore 和各种 Serverless 冷启动优化用的同一套思路。

microVM 路径还有几个和 gVisor 不同的设计:

  • 容器 rootfs 的写入通过 guest 内的 tmpfs overlay 捕获,也就是说文件系统改动也在内存里,跟着内存快照一起走
  • DurableDir 卷则是宿主机 backed,通过第二个可写的 virtio-fs 共享挂进去,打快照时打包成 tar
  • 因为所有 DurableDir 都是这一个共享的子目录,所以microVM 可以有多个 DurableDir,而 gVisor 只能有一个

这个「gVisor 只能挂一个 durableDir」的限制,在实际设计 Agent 存储布局时会很难受,值得提前知道。

7.3 Golden Snapshot:冷启动的终极优化

一个新 Actor 第一次创建时,如果走完整的「拉镜像 → 起进程 → 初始化」流程,那还是几秒。Substrate 的解法是 Golden Snapshot(黄金快照)

流程是:

  1. 创建 ActorTemplate 时,控制器起一个「黄金 Actor」(跑在保留的 ate-golden atespace 里)
  2. 等它初始化完成(默认等 ~20 秒让工作负载「稳定下来」)
  3. 对它打一个快照,这就是 Golden Snapshot
  4. 之后所有从这个模板创建的 Actor,都从 Golden Snapshot 恢复

这意味着新 Actor 的「冷启动」实际上是一次「热恢复」。你的 Python 解释器已经启动、依赖已经 import、模型客户端已经初始化——全部在快照里。

readyz 探针的妙用:如果模板里每一个容器都声明了 readyz HTTP 探针,控制器会跳过那 20 秒的预热等待。逻辑很清楚:既然 ResumeActor 已经阻塞到工作负载返回 HTTP 200 了,说明它确实就绪了,没必要再瞎等。

这个 readyz 的实现细节也值得一提,做得相当极致:

  • 探针在 ateom 内部执行,直接打 Actor 的内网 IP(169.254.17.2),一跳,不走 DNS,不走 Router
  • 轮询间隔约 500 微秒,单请求超时 250ms,用 keep-alive 客户端
  • 工作负载还没起来时,内核直接返回 RST,微秒级失败,所以这个高频轮询几乎不消耗资源
  • 整体有 30 秒内部超时兜底

为什么要这么激进?因为恢复路径上多花的每一毫秒,都会 ×百万次请求。

另一个细节:恢复后 readyz 通常第一次就返回 200,因为 TCP listener 本身就是被 checkpoint 进内存的一部分——套接字状态跟着快照一起回来了。

7.4 Actor 身份:一个容易踩的大坑

既然所有 Actor 都从同一个 Golden Snapshot 恢复,那有个问题:Actor 怎么知道自己是谁?

你的第一反应可能是环境变量:ACTOR_ID=xxx这是错的,而且是致命的错。

因为环境变量在进程启动时被读进内存,而 Golden Snapshot 就是那个进程的内存镜像。所以从 Golden Snapshot 恢复出来的每一个 Actor,读到的环境变量都是黄金 Actor 的值——全部一样。同理,任何在启动时读进内存并缓存的东西(包括写死在镜像里的配置文件读取结果)都有这个问题。

Substrate 的解法是:给每个 Actor 的每个容器 bind-mount 一个只读的身份目录 /run/ate,里面的 /run/ate/actor-id 文件包含裸的 Actor 名字(无尾随换行)。

因为是 bind mount,它在恢复后会被替换成当前 Actor 的正确值。

规则:每次用的时候现读,不要在进程启动时缓存。

Go 里的正确写法:

package actorid

import (
	"fmt"
	"os"
	"strings"
)

const actorIDPath = "/run/ate/actor-id"

// Current 每次调用都重新读文件。
// 不要缓存返回值——本进程可能是从 golden snapshot 恢复出来的,
// 进程内存里的任何"启动时值"都属于 golden actor,不属于你。
func Current() (string, error) {
	b, err := os.ReadFile(actorIDPath)
	if err != nil {
		return "", fmt.Errorf("read actor id: %w", err)
	}
	id := strings.TrimSpace(string(b))
	if id == "" {
		return "", fmt.Errorf("actor id file %s is empty", actorIDPath)
	}
	return id, nil
}

反面教材(千万别这么写):

// ❌ 错误示范:包级变量在 init 时求值,
// 会被冻结在 golden snapshot 里,所有 actor 拿到同一个 ID。
var ActorID = os.Getenv("ACTOR_ID")

// ❌ 同样错误:sync.Once 缓存也会被快照冻住
var once sync.Once
var cachedID string

func BadCurrent() string {
	once.Do(func() {
		b, _ := os.ReadFile("/run/ate/actor-id")
		cachedID = string(b)
	})
	return cachedID // golden actor 的 ID,永远
}

这个坑值得单独拿出来讲,因为它的失败模式极其隐蔽:本地测试完全正常(没走 golden snapshot),一上集群所有用户的数据串到一起。

7.5 写一个「快照友好」的 Actor

理解了快照机制后,写 Actor 时应该遵循一些原则。下面是一个 Go 的示例,体现了这些原则:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)

type Agent struct {
	mu sync.Mutex
	// 内存状态会被完整 checkpoint,可以放心用
	turns []Turn
	// 但外部连接不行——见下面的 client() 方法
	httpClient *http.Client
	clientAt   time.Time
}

type Turn struct {
	Role    string    `json:"role"`
	Content string    `json:"content"`
	At      time.Time `json:"at"`
}

// actorID 每次现读,不缓存。理由见上一节。
func actorID() string {
	b, err := os.ReadFile("/run/ate/actor-id")
	if err != nil {
		return "unknown"
	}
	return strings.TrimSpace(string(b))
}

// client 返回一个 HTTP 客户端。
// 关键:如果距离上次创建超过阈值,就重建。
// 因为 actor 可能刚从一个几小时前的快照恢复,
// 连接池里的所有 TCP 连接对端早就断了。
func (a *Agent) client() *http.Client {
	a.mu.Lock()
	defer a.mu.Unlock()
	if a.httpClient == nil || time.Since(a.clientAt) > 30*time.Second {
		a.httpClient = &http.Client{
			Timeout: 30 * time.Second,
			Transport: &http.Transport{
				// 缩短空闲连接寿命,减少恢复后用到死连接的概率
				IdleConnTimeout:     10 * time.Second,
				MaxIdleConnsPerHost: 4,
			},
		}
		a.clientAt = time.Now()
	}
	return a.httpClient
}

func (a *Agent) handleChat(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Message string `json:"message"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	id := actorID()
	slog.Info("chat", "actor", id, "msg_len", len(req.Message))

	a.mu.Lock()
	a.turns = append(a.turns, Turn{Role: "user", Content: req.Message, At: time.Now()})
	history := len(a.turns)
	a.mu.Unlock()

	reply := fmt.Sprintf("[%s] 收到第 %d 条消息", id, history)

	a.mu.Lock()
	a.turns = append(a.turns, Turn{Role: "assistant", Content: reply, At: time.Now()})
	a.mu.Unlock()

	_ = json.NewEncoder(w).Encode(map[string]any{
		"actor": id,
		"reply": reply,
		"turns": history + 1,
	})
}

// readyz 是 ActorTemplate 里配的探针端点。
// 必须极轻量——它会以 ~500µs 的间隔被高频轮询。
func readyz(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
}

func main() {
	a := &Agent{}
	mux := http.NewServeMux()
	mux.HandleFunc("/readyz", readyz)
	mux.HandleFunc("/chat", a.handleChat)

	srv := &http.Server{
		Addr:    ":80",
		Handler: mux,
		// 不要设过长的 ReadTimeout——被 suspend 时挂起的连接
		// 在 resume 后可能已经无效
		ReadHeaderTimeout: 5 * time.Second,
	}

	slog.Info("actor starting")
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		slog.Error("server failed", "err", err)
		os.Exit(1)
	}
	_ = context.Background()
}

快照友好的编码原则总结:

  1. 内存状态随便用,它会被完整保留,这正是 Substrate 的价值所在
  2. 不要缓存身份信息,现读 /run/ate/actor-id
  3. 不要长期持有外部连接。快照恢复后,你以为还活着的 TCP 连接、数据库连接、gRPC 流,对端可能几小时前就断了。要么短连接,要么带健康检查的连接池,要么恢复后主动重建
  4. 不要依赖 wall clock 的连续性time.Now() 在恢复后会突然跳跃几小时。任何基于「上次执行时间 + 间隔」的定时逻辑都会瞬间触发一大堆积压任务
  5. readyz 端点必须极轻量,不要在里面查数据库
  6. 谨慎使用 time.Ticker 和后台 goroutine。它们会被 checkpoint 进快照,恢复时的行为可能不符合预期

第 4 条特别值得展开。看这段有 bug 的代码:

// ❌ 恢复后会瞬间触发暴风式重试
func (a *Agent) backgroundSync(ctx context.Context) {
	t := time.NewTicker(1 * time.Minute)
	for {
		select {
		case <-t.C:
			a.sync() // 睡了 6 小时后恢复,这里不会补跑 360 次
		case <-ctx.Done():
			return
		}
	}
}

time.Ticker 本身不会补跑(Go 的 Ticker 会丢弃错过的 tick),这个还好。但如果你写的是这样:

// ❌ 这个才是真的会炸
func (a *Agent) catchUp() {
	for a.lastSync.Add(time.Minute).Before(time.Now()) {
		a.sync()
		a.lastSync = a.lastSync.Add(time.Minute)
	}
}

睡了 6 小时恢复后,这个循环会立刻跑 360 次 sync()。正确做法是用单调时钟判断,或者显式处理「时间跳跃」:

// ✅ 检测时间跳跃并跳过补偿
func (a *Agent) catchUp() {
	gap := time.Since(a.lastSync)
	if gap > 5*time.Minute {
		// 大概率是被 suspend 了,不补跑,直接对齐
		slog.Warn("detected suspend gap, skipping catch-up", "gap", gap)
		a.lastSync = time.Now()
		a.sync() // 只跑一次
		return
	}
	for a.lastSync.Add(time.Minute).Before(time.Now()) {
		a.sync()
		a.lastSync = a.lastSync.Add(time.Minute)
	}
}

八、AX:上面那一层的状态机

Substrate 解决了「Agent 在哪跑、怎么省钱」,AX(Agent Executor)解决的是「Agent 的执行过程怎么保证可靠」。

8.1 Single-Writer + Event Log

AX 的两个核心设计:

Single-Writer 架构:一个 conversation 的状态只由一个 controller 写。这彻底消除了多客户端并发更新同一个 Agent 会话时的竞态。做过分布式系统的都知道,一旦允许多写,你就要处理冲突合并、last-write-wins 的数据丢失、乱序……而 Agent 的对话状态是强顺序的,Single-Writer 是最简洁的正确解。

持久化事件日志:执行过程的每一步都追加到事件日志。这带来三个能力:

  1. 断线续传:客户端断了,重连时告诉服务端「我最后看到的是第 12 步」,服务端把 13 步之后的重放给你
  2. 执行恢复:进程崩了,从日志重建状态继续跑
  3. 审计:完整的执行轨迹可回溯

CLI 用起来是这样:

# 起一个新对话
ax --input "帮我把这个仓库的测试跑一遍"

# 继续已有对话
ax --conversation d85a4b4e-c53b-4c84-b879-f10d905bce40 \
   --input "刚才那个失败的用例,看下是什么原因"

# 客户端断线后追赶(不是回滚,是补发丢失的事件)
ax --conversation d85a4b4e-c53b-4c84-b879-f10d905bce40 \
   --last-step 12 \
   --resume

# 执行中途挂了,从断点恢复
ax --conversation edf98ef5-4bb1-4a9e-a091-3a77e03727e6 --resume

--last-step 这个参数的语义要特别注意:它是「客户端追赶」,不是「对话回滚」。服务端的执行一直在跑,客户端只是把自己错过的事件补看一遍。这个区分很重要——如果你以为它是回滚,用法就完全错了。

8.2 服务端模式

ax serve --config ax.yaml
version: v1alpha

server:
  address: ":8494"

eventlog:
  sqlite:
    filename: "eventlog/log.sqlite"

本地开发用 SQLite 存事件日志,生产上换成分布式存储。客户端通过 gRPC 连:

ax --server localhost:8494 --input "Hello agents!"

8.3 轨迹分支(Fork):被低估的能力

因为底层是强一致的持久化事件日志,AX 天然支持从任意事件序号「分叉」出一条新轨迹:

ax fork \
  --src-conversation 38460323-9a78-41cb-8991-022b0ff2c19c \
  --dest-conversation e5e26e38-53a2-4f22-b1cb-ae867357df83 \
  --src-seq 12

这个能力的价值在于,它把「多路径探索」变成了基础设施能力而不是应用层的 hack。

想想这些场景:

  • MCTS 式推理:在关键决策点分叉出 N 条路径,各自跑到底,评估后选最优
  • A/B 测试 Prompt:同一个对话状态,分叉两条,一条用 Prompt A 一条用 Prompt B
  • 回归调试:线上出问题的对话,从出错前一步分叉出来,改了代码重跑

以前要实现这些,你得自己序列化整个 Agent 状态。现在这是一个 CLI 命令。

不过要注意,AX 的 README 里 Fork 还列在 Roadmap("Forking from event log and snapshots"),说明这个能力还在演进中。

8.4 AX 明确说了自己不是什么

README 里有个 "What AX is NOT" 章节,我觉得比 Feature 列表更有信息量:

  • 不是托管服务。自己部署,自己运维。
  • 不是 Agent 框架。它不关心你用 LangChain 还是 ADK 还是手搓。

这个定位很清楚:AX 是运行时(runtime),不是 SDK。它跟 LangChain 的关系,类似于 JVM 跟 Spring 的关系。


九、性能与容量规划

9.1 北极星指标

Substrate 给自己定的三个目标:

指标目标
激活延迟(wakeup → 可接流量)P95 ≤ 100ms
单集群 Actor 规模(活跃+空闲)10 亿
唤醒吞吐1000 次/秒

对照 GKE Agent Sandbox 已公布的商用数据:每秒启动 300 个沙箱的压力下,90% 的分配在 200 毫秒内完成

这两组数字要分开看:GKE 的是已经商用验证的沙箱层数据;Substrate 的三个目标是北极星(north star),是「往这个方向努力」,不是「已经做到了」。这个区别在做技术选型时至关重要。

9.2 容量规划怎么算

假设你要支撑 10 万个 Agent,每个 Agent 平均:

  • 内存工作集 1GB
  • Duty cycle 0.5%(每小时活跃 18 秒)
  • 峰值并发倍数 5×(早高峰)

Worker 数量

M = 100000 × 0.005 × 5 = 2500 个 Worker

每个 Worker 按 1 核 2GB 算(要留出恢复时的内存余量),就是 2500 核 + 5TB 内存。看起来还是不少,但对比「10 万个常驻 Pod」的 10 万核 + 100TB,省了 40 倍。

快照存储

100000 × 1GB = 100TB

对象存储标准存储按 0.15 元/GB/月算,约 1.5 万元/月。跟省下来的计算资源比,完全可以接受。而且实际上快照有压缩空间,很多 Actor 的内存页高度相似(都是同一个 Golden Snapshot 派生的)。

恢复带宽

这才是真正的隐藏瓶颈。1000 次/秒的唤醒,每次要拉 1GB 快照:

1000 × 1GB/s = 1TB/s

这个数字是不现实的。 所以恢复不可能是「全量拉取」。这正是 microVM 路径用 userfaultfd 按需分页的原因——只拉实际访问的页。也是为什么「数据局部性」被 Substrate 文档列为一等公民问题。

实践建议:

  1. 控制 Actor 内存工作集。快照大小直接决定恢复成本,这是最有效的优化杠杆
  2. 优先选 microVM 路径(如果你的负载能接受),因为它的按需分页在大内存场景下优势明显
  3. 考虑分层存储:热 Actor 的快照放本地 NVMe / 节点本地缓存,冷的放对象存储
  4. 利用 Golden Snapshot 的页共享:从同一模板派生的 Actor,大量内存页是相同的,未来的去重优化空间很大

9.3 自动伸缩

Substrate 提供了基于 assigned-worker 数量的 HPA 示例(通过 prometheus-adapter)。核心指标是**「已分配 Worker 数 / 总 Worker 数」**这个饱和度。

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: agent-pool-hpa
  namespace: ate-demo
spec:
  scaleTargetRef:
    apiVersion: ate.dev/v1alpha1
    kind: WorkerPool
    name: agent-pool
  minReplicas: 10
  maxReplicas: 500
  metrics:
  - type: Object
    object:
      describedObject:
        apiVersion: ate.dev/v1alpha1
        kind: WorkerPool
        name: agent-pool
      metric:
        name: ate_workerpool_assigned_workers
      target:
        type: Value
        # 目标:保持 70% 饱和度,留 30% 缓冲应对突发
        value: "7"

注意 HPA 的响应速度(默认 15 秒同步周期 + 稳定窗口)远慢于 Agent 的突发速度。所以 HPA 只能应对「趋势性」的容量变化,瞬时突发必须靠 Request Parking 和足够的安全系数来吸收。

9.4 可观测性:多路复用带来的新难题

这是 Substrate 文档自己承认的难点:Actor 被不停地在 Worker 之间搬来搬去,传统的「按 Pod 看日志」彻底失效

你需要的是:

  • Actor ID 串联的日志和指标,跨越多次挂起/恢复、跨越多个物理 Pod
  • 挂起状态的 Actor 也能被查询(它的状态在对象存储里,不在任何运行中的进程里)
  • 完整的事件链路追踪:什么事件唤醒了它、它跑了多久、为什么被挂起

Substrate 有专门的 Observability Guide,并且已经在往 OTLP 路径上接(commit 记录里能看到 feat(otel): onboard atecontroller to the OTLP path)。

实践上,你必须在应用层就把 Actor ID 打进每一条日志

func actorLogger() *slog.Logger {
	// 每次现读,理由见 7.4
	return slog.With("actor_id", actorID())
}

十、GPU:能用,但有个大坑

Substrate 支持 GPU Worker Pool,配置方式挺优雅——template.resources 里申请 nvidia.com/gpu,这一个请求同时完成两件事:让 device plugin 分配 GPU 给 Worker Pod,以及触发 Substrate 把 GPU 透传进每个 Actor 的沙箱。不需要任何 per-actor 配置。

apiVersion: ate.dev/v1alpha1
kind: WorkerPool
metadata:
  name: gpu-pool
  namespace: ate-demo
spec:
  replicas: 5
  # 注意:GPU 池需要 glibc 版的 ateom-gvisor
  ateomImage: <your-registry>/ateom-gvisor-glibc@sha256:...
  template:
    nodeSelector:
      cloud.google.com/gke-accelerator: nvidia-tesla-t4
    tolerations:
    - key: nvidia.com/gpu
      operator: Exists
      effect: NoSchedule
    resources:
      requests:
        cpu: 500m
        memory: 1Gi
      limits:
        cpu: "1"
        memory: 2Gi
        nvidia.com/gpu: "1"

底层链路是:atecontroller 把请求传播到 ateom 容器并挂载宿主的 NVIDIA toolkit → ateom-gvisornvidia-ctk 生成 CDI spec,注入设备节点、驱动库和环境变量到 Actor 容器的 OCI spec → 用 --nvproxyrunsc,让 CUDA 和 NVML 在沙箱里能工作。

几个必须知道的约束:

  1. 必须用 glibc 版的 ateom-gvisor。默认的 distroless 镜像 exec 不了 nvidia-ctk。自己构建:KO_DEFAULTBASEIMAGE=debian:stable-slim ko build ./cmd/ateom-gvisor
  2. 节点上必须有 nvidia-ctk。gpu-operator 会装,GKE 内置的 GPU 支持不会装
  3. atelet 必须能跑在 GPU 节点上,如果节点有 taint,要给它的 DaemonSet 加 toleration
  4. GPU 是 Actor 级共享,不是容器级。跟 K8s Pod 的语义不同——K8s 里 GPU 只给申请它的那个容器,Substrate 里 Actor 的所有容器共享整个设备集
  5. 只支持 gVisor。microVM 需要 VFIO PCI 透传,还没做

最大的坑:持有 CUDA context 时无法挂起

这个必须单独拎出来:

gVisor 无法序列化 GPU 状态。如果 checkpoint 时工作负载持有 CUDA context,会以 nvproxy 编码错误失败,并且沙箱会被终止。

翻译成人话:一个把模型常驻显存的推理 Agent,不能被挂起,也不能生成 Golden Snapshot。

这基本上排除了「用 Substrate 跑常驻显存的推理服务」这个用法。能用 GPU 的场景是:跑一段 CUDA 计算然后退出(比如 Agent 调用一个 GPU 工具做批量向量计算,算完释放 context)。

这个限制不是 Substrate 的实现问题,是 GPU 状态序列化这个问题本身的难度。想想看:显存里的数据、driver 的内部状态、正在执行的 kernel……要完整快照一个 GPU 上下文,难度远超 CPU。

所以正确的架构是:推理服务独立部署(常驻 GPU),Agent 跑在 Substrate 上(无 GPU 或短生命周期 GPU 使用),两者通过网络调用。这也符合 Substrate 文档里提到的「跨 agentic、inference、training 循环的整体基础设施优化」的思路。


十一、多租户与安全

11.1 快照存储的租户隔离

这个设计细节很有意思,反映了真实的工程约束。

快照的存储路径是:

<snapshotsConfig.location>/snapshots/<atespace>/<snapshot-name>

为什么中间要插一个 <atespace> 层级?文档给了明确答案:

对象存储策略只能基于「对象名前缀」做条件判断,无法读取快照 manifest 内部记录的身份信息。

所以要做按租户的访问控制,租户标识必须出现在路径里。GCS 的 IAM 条件绑定长这样:

# 只允许读 team-a 的快照,其他一律不行
- members: ["serviceAccount:node-runtime@my-project.iam.gserviceaccount.com"]
  role: roles/storage.objectViewer
  condition:
    title: team-a-snapshots
    expression: >
      resource.name.startsWith(
        "projects/_/buckets/my-bucket/objects/secret-agent/snapshots/team-a/")

一个容易踩的坑:跨 atespace 克隆 PUBLISHED 快照时,读的是源 atespace 的前缀。所以你给目标 atespace 的授权是不够的,必须覆盖源 atespace。

11.2 Secret 的生命周期陷阱

容器环境变量支持 valueFrom.secretKeyRef,由 ate-api-serverActorTemplate 所在 namespace 解析。但有个致命细节:

对于黄金 Actor,解析后的值被捕获进 Golden Snapshot,之后所有 Actor 都继承这些值,直到 Golden Snapshot 被重新生成。

Secret 变更不会自动重启 Actor 或使快照失效。轮转 Secret 需要显式的 Actor 或模板生命周期操作。

这意味着:你轮转了 API Key,但所有从旧 Golden Snapshot 恢复的 Actor 还在用旧 Key。 而且它们会一直用下去,直到你手动重建。

如果你有合规要求(比如密钥必须 90 天轮转),这个行为必须写进你的运维 SOP,并且要有自动化检查。

更稳妥的做法是:不要把长期凭证放进环境变量,改成运行时从 metadata server 或 secret 服务动态拉取——反正你已经因为「时间跳跃」和「连接失效」的问题,需要在 Actor 里写「恢复后重建外部依赖」的逻辑了,凭证刷新可以搭同一趟车。

11.3 gVisor 提供了什么级别的隔离

gVisor 在不可信应用代码和宿主 Linux 内核之间插入了 Sentry——一个用户态内核。Actor 试图执行的所有系统调用都在用户态被拦截、审计、(部分)实现。

这意味着攻击面从「整个 Linux 内核的系统调用表(350+ 个 syscall,历史上出过无数 CVE)」缩小到「Sentry 向宿主发起的那一小撮受限调用」。对于「跑模型生成的任意 Python 代码」这个场景,这是目前工程上最实际的方案。

但也要清醒:gVisor 不是万能的。它有性能开销(系统调用密集型负载会明显变慢),有兼容性问题(一些冷门 syscall 不支持),而且 Sentry 本身也是软件,也可能有漏洞。Substrate 有专门的 Threat Model 文档,上生产前必读。


十二、十二条踩坑清单

把散落在各处的坑集中整理一遍,按严重程度排序:

1. /run/ate/actor-id 必须每次现读,绝不缓存
从 Golden Snapshot 恢复的进程,其内存里所有「启动时值」都属于黄金 Actor。缓存 = 所有用户的数据串到一起。这是最危险的坑,因为本地测试发现不了。

2. GPU Actor 持有 CUDA context 时无法挂起
会直接失败并终止沙箱。常驻显存的推理负载不适合 Substrate。

3. 镜像必须用 digest 固定
image: foo:latest 会导致快照静默失效。必须 foo@sha256:...

4. Secret 轮转不会自动生效
旧 Golden Snapshot 里冻结着旧凭证。需要显式重建模板。

5. gVisor 只能挂一个 durableDir
microVM 没有这个限制。设计存储布局时要提前确定沙箱类型。

6. sandboxClass 创建后不可变
因为快照无法跨运行时恢复。选错了只能重建模板 + 迁移所有 Actor。

7. 时间跳跃会导致补偿逻辑雪崩
任何「上次时间 + 间隔」的循环,恢复后可能瞬间跑几百次。必须显式检测大 gap。

8. 恢复后的 TCP/DB 连接是死的
连接池里的连接对端早断了。要么短连接,要么带健康检查,要么恢复后主动重建。

9. gVisor 需要 runsc --allow-connected-on-save
绕过 checkpoint 时网络恢复的已知 bug。自编译 runsc 时别漏。

10. GPU 池需要 glibc 版 ateom-gvisor + 节点上的 nvidia-ctk
distroless 默认镜像不行,GKE 内置 GPU 支持不装 nvidia-ctk。

11. 跨 atespace 读 PUBLISHED 快照要授权源前缀
只授权目标 atespace 会 403。

12. 项目处于早期,API 几乎必然变更
README 原话:"not ready for production use, and the APIs are almost guaranteed to change"。架构文档原话:"Much of this architecture is aspirational"。

再补两条不算坑但值得警惕的:

13. Substrate 只支持最新的 K8s 稳定版和前一个 minor 版本
升级节奏要跟上,不能长期停在老版本。

14. AX 暂停接收外部 PR
核心架构在重构。想贡献的话现在只能提 Issue。


十三、什么时候该用,什么时候不该用

技术选型比技术本身重要。我的判断:

适合的场景

场景为什么适合
每用户独立 Agent 实例,用户量大超售收益最大化,这就是它设计的目标
需要执行不可信代码(模型生成的脚本)gVisor 沙箱是刚需
长时间 HITL 等待(人工审批流)空闲期挂起省下的钱最多
有状态的编码环境(终端 + 文件系统)快照能保住工作目录和进程状态
沙箱化的 MCP Server 集群每个工具一个隔离实例,大部分时间空闲
RL 采样环境(大量并行 rollout)与推理/训练共享 K8s 基础设施

不适合的场景

场景为什么不适合
常驻显存的推理服务CUDA context 无法快照,直接排除
高吞吐无状态 API用普通 Deployment + HPA 就行,别自找麻烦
Agent 数量少(< 几百个)超售收益覆盖不了运维复杂度
Duty cycle 很高(> 30%)M = N × d × σ,省不下多少
需要今天就上生产项目自己说了没准备好
团队没有 K8s 深度运维能力这套东西的调试难度显著高于普通工作负载

一个务实的建议:如果你现在就有这个痛点,短期内更现实的路径是用 kubernetes-sigs/agent-sandbox(沙箱层,相对成熟,SIG 项目)+ 自己实现一个轻量的 Actor 路由,或者直接用 GKE Agent Sandbox 这类商用服务。Agent Substrate 值得跟踪和实验,但现在往生产上搬风险很高。


十四、这套东西真正的意义

写到这里,我想跳出实现细节说点别的。

Agent Substrate 让我想起 2014 年的 Kubernetes。当时 Docker 已经解决了「打包」,但没人解决「几千个容器怎么调度」。Borg 的经验被抽出来做成 K8s,然后花了大概五年时间成为事实标准。

现在的处境很像:LLM 和各种 Agent 框架解决了「怎么写 Agent」,但「几百万个 Agent 怎么在物理机上跑得又便宜又安全又快」这个问题,目前每个团队都在用自己的土办法解决——有的用 Firecracker 自己撸,有的用 Serverless 硬扛冷启动,有的干脆常驻 Pod 烧钱。

Substrate 试图把这个问题标准化。它的核心洞察其实就一句话:

Agent 的负载特征(突发、长空闲、有状态、需隔离)和微服务的负载特征(持续、稳定、无状态、可信)完全不同,所以需要不同的抽象层。

这个洞察是对的。至于 Substrate 这个具体实现能不能成为标准,我持谨慎态度——它太早期了,而且 Google 开源项目的历史战绩参差不齐(K8s 大成,但也有一堆无疾而终的)。

但有几个东西我认为一定会留下来,不管最终赢的是哪个项目:

  1. 「Actor 与 Worker 解耦」的抽象。用快照实现身份与实例的分离,这是必然方向
  2. 双层资源模型:声明式配置走 K8s,高频实例状态走专用存储
  3. 请求驱动的按需唤醒:在数据面拦截流量触发恢复
  4. 按需分页恢复:让恢复延迟与内存大小解耦
  5. Golden Snapshot:把冷启动变成热恢复

这五条里没有一条是 Agent 特有的。它们是「大量突发有状态负载」这个通用问题的解法。Agent 只是恰好第一个把这个问题推到极致的场景。

最后:这是系统工程师的机会

Tony Bai 在他那篇文章末尾提的几个问题,我觉得说到了点子上:

  • 谁来设计高密度的内存挂起与快照算法?
  • 谁来在网络边界保障 gVisor 沙箱的安全网络策略?
  • 谁来在 AX 层面设计多 Agent 协作时的数据一致性协议?

这些问题,AI 自己解决不了。它们需要真正理解 userfaultfd 怎么工作、知道 Envoy ext_proc 的性能边界在哪、清楚对象存储的一致性模型、明白为什么 etcd 不能存百万对象的人。

大模型确实降低了写业务逻辑的门槛。但当所有人都能写业务逻辑的时候,能把这些业务逻辑高效、安全、廉价地在物理硬件上跑起来的人,反而更值钱了

这不是一个安慰性的结论。看看 Substrate 的代码库就知道——454 次提交、gRPC 控制面、Envoy ext_proc、gVisor checkpoint、userfaultfd 按需分页、CDI GPU 注入、对象存储前缀授权。这里面每一样都是硬核的系统工程,每一样都需要多年经验才能做对。

黄金时代属于那些真正懂得底层的人。


参考资料

  • Agent Substrate 源码与文档:github.com/agent-substrate/substrate
  • Agent Executor (AX):github.com/google/ax
  • Agent Sandbox(K8s SIG):github.com/kubernetes-sigs/agent-sandbox
  • Google Cloud 公告:Bringing you Agent Sandbox on GKE and Agent Substrate
  • Google Cloud 公告:Agent Executor, Google's distributed agent runtime
  • gVisor 项目:gvisor.dev
  • Kata Containers / Cloud Hypervisor
  • Tony Bai《Google 开源 AX 与 Agent Substrate:构建以 Agent 为核心的云原生计算底座》

免责声明:本文所述项目均处于早期开发阶段,API 和架构随时可能变更。文中的性能数字和容量模型基于公开文档与推导,实际效果请以你自己的压测为准。生产环境使用前请仔细阅读各项目的 Threat Model 和 Roadmap 文档。

推荐文章

Roop是一款免费开源的AI换脸工具
2024-11-19 08:31:01 +0800 CST
PHP 如何输出带微秒的时间
2024-11-18 01:58:41 +0800 CST
Vue3中如何处理状态管理?
2024-11-17 07:13:45 +0800 CST
Requests库详细介绍
2024-11-18 05:53:37 +0800 CST
程序员茄子在线接单