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 自己的命名空间体系。 |
| ActorTemplate | CRD。定义 Actor 的镜像、环境、卷、快照策略。不可变。 |
| WorkerPool | CRD。定义一池温机容量(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)
WorkerPool、ActorTemplate、SandboxConfig- 特点:数量少(几十到几百个)、变更频率低、需要 RBAC/审计/GitOps
- 存在 etcd 里完全没问题,而且能直接复用 K8s 生态的一切治理能力
第二类:动态实例状态(走 Redis/ValKey)
Actor、Worker记录- 特点:数量巨大(目标 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、kubectl、docker。kind 由项目自己通过 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
几个必须注意的约束,官方文档里写得很清楚但很容易漏:
image必须用 digest 固定。用 tag 的话,镜像一变快照就失效了——快照里存的是那个镜像的进程内存,跟新镜像根本对不上。ActorTemplate是不可变的。sandboxClass在创建时就定死,因为快照不能跨沙箱运行时恢复。gVisor 的 checkpoint 和 microVM 的 memory snapshot 是两种完全不同的东西。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 双向流回调外部服务,外部服务可以修改请求、改路由、甚至直接返回响应。
这里的流程是:
- Envoy 收到请求,
ext_proc在 request headers 阶段被触发 - 外部处理器从
Host头解析出actor-name和atespace - 调用控制面
ate-api-server的 gRPC:ResumeActor(actor, atespace) - 控制面查 Redis:这个 Actor 现在是 RUNNING 还是 SUSPENDED?
- RUNNING → 直接返回它当前所在的 Worker IP
- SUSPENDED → 走完整的恢复流程(下一节详述),返回新分配的 Worker IP
ext_proc把解析出的目标写回 Envoy 的路由决策- 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 的一个系统调用,允许用户态程序接管一段虚拟内存区域的缺页处理。恢复时的流程是:
- 创建 VM,把内存区域注册到
userfaultfd,但不实际加载任何内存页 - 立刻启动 vCPU
- Guest 访问某个页 → 触发缺页 → 内核通知用户态 handler
- handler 从快照文件(可能在远端对象存储)里读那一页,填进去
- 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(黄金快照)。
流程是:
- 创建
ActorTemplate时,控制器起一个「黄金 Actor」(跑在保留的ate-goldenatespace 里) - 等它初始化完成(默认等 ~20 秒让工作负载「稳定下来」)
- 对它打一个快照,这就是 Golden Snapshot
- 之后所有从这个模板创建的 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()
}
快照友好的编码原则总结:
- 内存状态随便用,它会被完整保留,这正是 Substrate 的价值所在
- 不要缓存身份信息,现读
/run/ate/actor-id - 不要长期持有外部连接。快照恢复后,你以为还活着的 TCP 连接、数据库连接、gRPC 流,对端可能几小时前就断了。要么短连接,要么带健康检查的连接池,要么恢复后主动重建
- 不要依赖 wall clock 的连续性。
time.Now()在恢复后会突然跳跃几小时。任何基于「上次执行时间 + 间隔」的定时逻辑都会瞬间触发一大堆积压任务 readyz端点必须极轻量,不要在里面查数据库- 谨慎使用
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 是最简洁的正确解。
持久化事件日志:执行过程的每一步都追加到事件日志。这带来三个能力:
- 断线续传:客户端断了,重连时告诉服务端「我最后看到的是第 12 步」,服务端把 13 步之后的重放给你
- 执行恢复:进程崩了,从日志重建状态继续跑
- 审计:完整的执行轨迹可回溯
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 文档列为一等公民问题。
实践建议:
- 控制 Actor 内存工作集。快照大小直接决定恢复成本,这是最有效的优化杠杆
- 优先选 microVM 路径(如果你的负载能接受),因为它的按需分页在大内存场景下优势明显
- 考虑分层存储:热 Actor 的快照放本地 NVMe / 节点本地缓存,冷的放对象存储
- 利用 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-gvisor 用 nvidia-ctk 生成 CDI spec,注入设备节点、驱动库和环境变量到 Actor 容器的 OCI spec → 用 --nvproxy 跑 runsc,让 CUDA 和 NVML 在沙箱里能工作。
几个必须知道的约束:
- 必须用 glibc 版的
ateom-gvisor。默认的 distroless 镜像 exec 不了nvidia-ctk。自己构建:KO_DEFAULTBASEIMAGE=debian:stable-slim ko build ./cmd/ateom-gvisor - 节点上必须有
nvidia-ctk。gpu-operator 会装,GKE 内置的 GPU 支持不会装 atelet必须能跑在 GPU 节点上,如果节点有 taint,要给它的 DaemonSet 加 toleration- GPU 是 Actor 级共享,不是容器级。跟 K8s Pod 的语义不同——K8s 里 GPU 只给申请它的那个容器,Substrate 里 Actor 的所有容器共享整个设备集
- 只支持 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-server 从 ActorTemplate 所在 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 大成,但也有一堆无疾而终的)。
但有几个东西我认为一定会留下来,不管最终赢的是哪个项目:
- 「Actor 与 Worker 解耦」的抽象。用快照实现身份与实例的分离,这是必然方向
- 双层资源模型:声明式配置走 K8s,高频实例状态走专用存储
- 请求驱动的按需唤醒:在数据面拦截流量触发恢复
- 按需分页恢复:让恢复延迟与内存大小解耦
- 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 文档。