1. 从“ax”这个标题说起:一个被低估的运行时编排命题
第一次看到“ax”这个标题,很多人会一头雾水。它不像“Kubernetes 集群搭建”那样直白,也不像“Agentic RAG 实战”那样自带场景。但把热搜词摊开来看,线索就清楚了:ax、agentic、orchestration、runtime、Kubernetes这几个词放在一起,指向的其实是一个很具体的东西——一套面向智能体(Agent)工作负载的运行时编排层。换句话说,ax 要解决的是:当你的系统里不再只有无状态的 HTTP 服务,而是一堆会思考、会调工具、会互相委派的 Agent 时,Kubernetes 这套为容器设计的编排系统,该怎么接住它们。
我之所以对这个题目感兴趣,是因为过去一年多我在几个项目里反复踩过同一个坑:把 Agent 当成普通微服务往 K8s 里塞,结果要么是 Pod 频繁 OOM,要么是长连接被 kube-proxy 掐断,要么是 Agent 之间的调用链在 Service 层面彻底丢失上下文。ax 这个命题的价值,就在于它逼着你去正视“Agent 不是容器”这件事,然后重新设计一层运行时抽象。
这篇文章适合三类人看:一是正在把 Agent 应用往生产环境推的后端或平台工程师;二是对 Kubernetes 有一定了解、但没处理过有状态长任务负载的开发者;三是想搞清楚“agentic orchestration”到底和传统服务编排差在哪里的技术负责人。我会从设计思路、核心机制、实操落地到排错经验,完整走一遍,尽量把每个“为什么这么设计”讲透。
2. 为什么 Agent 负载不能直接套用 K8s 原生编排
2.1 Agent 工作负载的三个“反 K8s”特性
Kubernetes 的设计假设是:工作负载是无状态、短生命周期、可随时替换的。一个 Pod 挂了,ReplicaSet 拉起一个新的,流量切过去,用户无感知。这套模型对 Web 服务近乎完美,但 Agent 负载恰好在这三点上全是反的。
第一,Agent 是有状态的,而且状态很重。一个正在执行多步推理的 Agent,它的上下文窗口、工具调用历史、中间产物,可能占用几百 MB 到几 GB 的内存。你把它杀掉重启,这些状态全丢,任务得从头再来。这跟“无状态服务重启无感”完全是两码事。
第二,Agent 的生命周期是长且不确定的。一个普通 API 请求可能 50ms 返回,但一个 Agent 任务可能跑 3 分钟,也可能跑 30 分钟,取决于它要调多少次工具、做多少轮推理。K8s 默认的探针超时、优雅关闭窗口(默认 30 秒)根本不够用。
第三,Agent 之间需要保持会话亲和性。Agent A 调 Agent B,B 又回调 A,这条链路如果被负载均衡打散到不同 Pod,上下文就断了。而 K8s 的 Service 默认是随机轮询,天然不保证亲和。
提示:如果你现在的 Agent 服务在 K8s 里跑得很稳,先别急着高兴,大概率是因为你的负载还不够重,或者你还没开启多副本。一旦副本数上去、任务变长,上面三个问题会同时爆发。
2.2 ax 的核心设计取舍:编排层与运行时层分离
理解了痛点,就能理解 ax 的设计哲学。它没有试图去改造 Kubernetes,而是在 K8s 之上加了一层编排层(orchestration),把 Agent 的调度逻辑从容器调度里剥出来。
具体来说,ax 把系统分成两层:
- 运行时层(runtime):负责单个 Agent 实例的执行,包括上下文管理、工具调用、模型推理。这一层是“胖”的,一个 runtime 进程可能常驻很久。
- 编排层(orchestration):负责决定哪个 Agent 在哪个 runtime 上跑、任务怎么分发、状态怎么持久化。这一层是“瘦”的,只做决策不做执行。
这个分离的好处是:编排层可以像普通无状态服务一样用 Deployment 管理,随便扩缩容;而 runtime 层用 StatefulSet 或者自定义控制器管理,保证状态不丢。两者通过一个稳定的寻址机制连接,而不是靠 K8s Service 的随机负载均衡。
我实测下来,这种分层最大的收益是故障隔离。以前 runtime 崩了,整个任务链全断;现在编排层还在,它可以把任务重新派给另一个健康的 runtime,只要状态持久化做得好,任务能续上。
2.3 与 Karmada 这类多集群方案的定位差异
热搜里出现了“Karmada 正式毕业”和“agentic cloud 底座”这样的词,这里需要澄清一下 ax 和 Karmada 的关系。Karmada 解决的是多集群调度问题——把工作负载分发到多个 K8s 集群,做容灾和地理分布。而 ax 解决的是单集群内 Agent 运行时编排问题。
两者不是竞争关系,而是可以叠加的。一个典型的组合是:ax 负责单集群内的 Agent 生命周期管理,Karmada 负责把不同租户的 Agent 集群分发到不同地域。如果你现在只有一个集群,先把 ax 这层做扎实,别急着上多集群,否则问题会指数级放大。
3. 核心机制拆解:ax 运行时到底怎么运转
3.1 Agent 注册与寻址:告别随机负载均衡
ax 的第一个核心机制是基于 Agent ID 的稳定寻址。每个 Agent 实例启动时,会向编排层注册自己的 ID、能力标签(capability tags)和当前负载。编排层维护一张路由表,记录“哪个 Agent ID 在哪个 runtime 地址上”。
当 Agent A 要调 Agent B 时,它不是直接访问 B 的 Service,而是先问编排层:“我要调一个具备code-review能力的 Agent,给我一个地址。”编排层根据负载和亲和性策略返回一个具体地址。这样做的关键收益是会话粘性:同一个任务链上的调用,可以固定路由到同一组 runtime,上下文不会丢。
这里有个细节值得说:路由表不能存在编排层的内存里,否则编排层重启就全丢了。ax 的做法是把路由表写进一个带 TTL 的分布式 KV(比如 etcd 或 Redis),编排层只是缓存。TTL 的作用是自动清理死掉的 Agent 注册,避免路由到僵尸实例。
3.2 状态持久化:上下文快照与恢复
Agent 状态怎么存,是 ax 最考验设计的地方。全量存内存,Pod 一挂就没了;每步都写数据库,延迟受不了。ax 采用的是周期性快照 + 关键节点强制持久化的混合策略。
具体来说,runtime 会每隔 N 步(比如每 5 轮推理)把上下文序列化成一个快照,写到对象存储或分布式文件系统。同时,在工具调用前后这种“不可重入”的关键节点,强制写一次。这样即使崩溃,最多回退几步,而不是从头再来。
快照的序列化格式也有讲究。直接用 JSON 存,体积大、反序列化慢;用 MessagePack 或 Protobuf,体积能压到 1/3 左右。我在一个项目里实测,一个 200 轮对话的上下文,JSON 序列化后 8MB,MessagePack 只有 2.6MB,恢复时间从 1.2 秒降到 400ms。这个差距在频繁恢复的场景下非常明显。
注意:快照不能存本地磁盘。K8s 的 Pod 随时可能被调度到别的节点,本地盘的数据带不走。必须用 PVC 或者外部存储,这是硬性要求。
3.3 资源隔离:为什么 Agent 需要独立的资源配额模型
普通容器的资源模型是 CPU/内存的 request 和 limit,但 Agent 的资源消耗模式很不一样。它的 CPU 使用是突发式的——推理时 CPU 打满,等待工具返回时几乎为零。内存则是阶梯式增长的——上下文越滚越大,不会自动释放。
ax 在 K8s 原生资源模型之上,加了一层基于任务复杂度的配额预估。编排层在派发任务时,会根据任务类型(简单问答 vs 多步工具链)预估一个资源档位,然后调度到匹配的 runtime 上。runtime 本身用较大的 limit 但较小的 request,配合 HPA 基于自定义指标(比如活跃任务数)做扩缩容。
这里有个反直觉的点:Agent 的 HPA 不能只看 CPU。因为 Agent 大量时间在等 IO(等模型返回、等工具返回),CPU 利用率很低,但内存和并发任务数很高。用 CPU 做扩缩容指标,会导致该扩容时不扩,任务堆积。我建议用“活跃任务数 / 副本数”这个比值作为主指标,CPU 作为辅助。
3.4 优雅关闭:给 Agent 留足“收尾”时间
K8s 默认的terminationGracePeriodSeconds是 30 秒,对 Agent 来说远远不够。一个正在跑的任务,可能需要几分钟才能到一个可中断的安全点。ax 的做法是两阶段关闭:
第一阶段,编排层收到缩容信号后,先把该 runtime 标记为“不再接受新任务”,但允许现有任务继续跑。第二阶段,等现有任务到达安全点(或超过最大等待时间),再真正发 SIGTERM 给容器。
这个机制需要编排层和 K8s 的控制器配合。实现上,可以用一个自定义的 controller 监听 StatefulSet 的缩容事件,先改路由表,再延迟删除 Pod。延迟时间建议设成任务 P99 时长的 1.5 倍,宁可多等,不要强杀。
4. 实操落地:从零搭一个最小可用的 ax 运行时
4.1 环境准备与依赖清单
先说清楚,这一节给的是一个最小可用的方案,不是生产级。生产级还要加监控、告警、多租户隔离,那些后面再说。你需要准备:
- 一个 K8s 集群,版本 1.26 以上(热搜里那个 v1.26.0 的 preflight 检查就是这个版本)。1.26 之后
autoscaling/v2稳定,HPA 自定义指标支持更好。 - 一个分布式 KV,etcd 或 Redis 都行,用来存路由表。
- 一个对象存储或 NFS,用来存上下文快照。
- 容器运行时,containerd 或 CRI-O 都可以。热搜里那个
container runtime is not running的报错,八成是 containerd 没起来,先systemctl status containerd看一眼。
提示:如果你在本地用 kind 或 minikube 搭,注意它们默认的存储和网络插件可能不支持 PVC 动态供给,需要额外装 local-path-provisioner 或类似组件。
4.2 编排层 Deployment 配置
编排层是无状态的,用标准 Deployment 就行。关键配置在环境变量和探针上:
apiVersion: apps/v1 kind: Deployment metadata: name: ax-orchestrator spec: replicas: 2 selector: matchLabels: app: ax-orchestrator template: metadata: labels: app: ax-orchestrator spec: containers: - name: orchestrator image: ax/orchestrator:0.1.0 env: - name: KV_ENDPOINT value: "redis://ax-kv:6379" - name: SNAPSHOT_BUCKET value: "ax-snapshots" - name: ROUTE_TTL_SECONDS value: "120" ports: - containerPort: 8080 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 20 resources: requests: cpu: "200m" memory: "256Mi" limits: cpu: "1" memory: "1Gi"ROUTE_TTL_SECONDS设 120 秒是个经验值。太短,runtime 心跳稍微一抖就被误判为死;太长,真死了的实例还挂在路由表里,请求打过去超时。120 秒配合 30 秒一次的心跳,容错窗口比较舒服。
4.3 运行时层 StatefulSet 配置
runtime 层用 StatefulSet,保证每个实例有稳定的网络标识和独立的存储:
apiVersion: apps/v1 kind: StatefulSet metadata: name: ax-runtime spec: serviceName: ax-runtime-headless replicas: 3 selector: matchLabels: app: ax-runtime template: metadata: labels: app: ax-runtime spec: terminationGracePeriodSeconds: 600 containers: - name: runtime image: ax/runtime:0.1.0 env: - name: ORCHESTRATOR_ENDPOINT value: "http://ax-orchestrator:8080" - name: SNAPSHOT_INTERVAL_STEPS value: "5" - name: AGENT_CAPABILITIES value: "code-review,doc-search" volumeMounts: - name: snapshot-cache mountPath: /var/ax/cache resources: requests: cpu: "500m" memory: "2Gi" limits: cpu: "4" memory: "8Gi" volumeClaimTemplates: - metadata: name: snapshot-cache spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 20GiterminationGracePeriodSeconds: 600是关键,给足 10 分钟收尾。SNAPSHOT_INTERVAL_STEPS: 5是快照频率,步数越小越安全但 IO 压力越大,5 是个平衡点。
4.4 路由表与心跳机制的实现
编排层和 runtime 之间的心跳,用最简单的 HTTP 长轮询或 gRPC stream 都行。runtime 每 30 秒上报一次自己的状态,编排层更新路由表的 TTL。核心逻辑用伪代码表示:
def register_agent(agent_id, capabilities, address): key = f"route:{agent_id}" value = { "capabilities": capabilities, "address": address, "last_heartbeat": time.time() } kv.setex(key, ROUTE_TTL_SECONDS, json.dumps(value)) def find_agent(capability, exclude=None): candidates = [] for key in kv.scan_iter("route:*"): info = json.loads(kv.get(key)) if capability in info["capabilities"]: if exclude and info["address"] in exclude: continue candidates.append(info) # 按负载排序,返回最闲的 return min(candidates, key=lambda x: x.get("load", 0))这里exclude参数很重要,用来避免 Agent A 回调自己造成死循环。实际实现里还要加负载字段,让编排层能感知每个 runtime 的繁忙程度。
4.5 上下文快照的序列化与恢复
快照这块,我强烈建议用 MessagePack 而不是 JSON。序列化代码大概长这样:
import msgpack import zstandard as zstd def save_snapshot(agent_id, context, step): raw = msgpack.packb(context, use_bin_type=True) compressed = zstd.compress(raw, level=3) key = f"snapshots/{agent_id}/{step}.msgpack.zst" storage.put(key, compressed) def load_latest_snapshot(agent_id): keys = sorted(storage.list(f"snapshots/{agent_id}/"), reverse=True) if not keys: return None compressed = storage.get(keys[0]) raw = zstd.decompress(compressed) return msgpack.unpackb(raw, raw=False)zstd 压缩级别 3 是速度和压缩比的平衡点。级别再高,压缩时间会明显上升,而快照是热路径,不能太慢。实测一个 2.6MB 的 MessagePack 数据,zstd level 3 压到 800KB 左右,压缩耗时 15ms,完全可接受。
5. 常见问题与排查技巧实录
5.1 容器运行时相关的报错怎么定位
热搜里那个[error CRI]: container runtime is not running是 K8s 部署阶段最常见的拦路虎。排查顺序是:
systemctl status containerd看服务是否 active。- 如果 active 但 K8s 还报错,检查
/etc/containerd/config.toml里SystemdCgroup是否为 true。K8s 1.26 之后必须开这个,否则 cgroup 驱动不匹配。 - 看
crictl info能不能正常返回,如果报连接错误,多半是 socket 路径不对。
这个问题的本质是 K8s 的 kubelet 通过 CRI 接口和容器运行时通信,中间任何一环配置不对都会报这个错。别急着重装,先按上面三步查。
5.2 Agent 任务中断后无法恢复的排查
这是 ax 场景下最头疼的问题。任务中断后恢复不了,通常有三个原因:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 恢复后上下文为空 | 快照没写成功 | 检查对象存储里有没有对应 key |
| 恢复后重复执行工具调用 | 快照点选在了工具调用之后 | 检查快照触发时机,应在工具调用前 |
| 恢复后路由到错误 Agent | 路由表 TTL 过期 | 检查心跳间隔和 TTL 配置 |
第二个原因特别隐蔽。如果你的快照是在工具调用返回之后才写,那恢复时工具已经调过了,再调一次就是重复副作用。正确做法是在工具调用发起之前写快照,记录“我即将调用工具 X,参数是 Y”,恢复时先检查这个工具是否已执行。
5.3 内存阶梯式增长导致 OOM 的处理
Agent 上下文越滚越大,内存只增不减,最后 OOM。这个问题没有银弹,但有三个缓解手段:
- 上下文裁剪:超过一定轮数后,把早期的对话摘要化,只保留关键信息。摘要本身可以用一个小模型来做。
- 分页加载:上下文不全部放内存,只放最近 N 轮,更早的从快照按需加载。
- 内存 limit 留余量:别把 limit 设得刚好够用,留 30% 余量给峰值。OOM 的代价远大于多占点内存。
我在一个项目里用摘要化把上下文从 8MB 压到 1.5MB,OOM 频率从每天 3 次降到每周 1 次。摘要的质量是关键,摘要太狠会丢信息,太松又没效果,需要根据业务调。
5.4 优雅关闭超时导致任务被强杀
terminationGracePeriodSeconds设了 600 秒,但任务还是被强杀,通常是两个原因:一是任务本身超过了 600 秒,二是编排层没及时把 runtime 标记为“不再接新任务”,导致关闭时还有新任务进来。
解决办法是给任务设一个最大执行时长,超过就主动中断并存快照,而不是等 K8s 来杀。同时编排层的“标记不再接新任务”这一步要前置,在缩容信号发出的第一时间就执行,而不是等 Pod 开始终止。
注意:K8s 的 preStop hook 可以用来做这个前置标记,但 preStop 的执行时间也算在 grace period 里,别在里面做太重的操作。
5.5 多副本下会话串扰的排查
多副本部署后,发现 Agent A 的上下文串到了 Agent B 的任务里。这几乎肯定是路由表或快照 key 设计有问题。检查两点:快照 key 里有没有包含 agent_id 和 task_id,路由表查询有没有按 task_id 做隔离。我见过一个案例,快照 key 只用了 agent_id,结果同一个 Agent 的多个并发任务互相覆盖快照,恢复时拿到的是别人的上下文。
6. 从最小可用到生产级:还需要补哪些课
6.1 可观测性:Agent 链路的追踪怎么做
普通微服务用 OpenTelemetry 做链路追踪,Agent 场景下要额外记录推理轮次、工具调用、上下文大小这些维度。ax 的编排层应该在每次任务派发时生成一个 trace_id,贯穿整个 Agent 调用链。runtime 每完成一轮推理,就上报一个 span,包含 token 消耗、耗时、工具名。
这些数据攒起来,能回答很多关键问题:哪个工具最慢、哪个 Agent 最容易 OOM、平均任务要多少轮推理。没有这些数据,调优就是盲人摸象。
6.2 多租户隔离:命名空间与配额的双重约束
生产环境一定有多个团队共用一套 ax。隔离要做两层:K8s 层面用 Namespace 隔离 runtime,配额用 ResourceQuota 限制;编排层用租户 ID 隔离路由表和快照存储。两层都要做,只做一层都有漏洞。
编排层的租户隔离尤其重要,因为路由表是全局的,如果不按租户过滤,A 租户的 Agent 可能被 B 租户的任务调用。实现上,路由表的 key 前缀加上租户 ID,查询时强制带租户过滤。
6.3 成本控制:Agent 运行时的资源浪费怎么治
Agent 最烧钱的地方是空闲等待。一个 runtime 在等模型返回时,CPU 几乎为零,但内存还占着。如果副本数按峰值配,平时就是浪费。
ax 的思路是混合部署:把 runtime 分成“热池”和“冷池”。热池常驻,处理延迟敏感的任务;冷池按需拉起,处理批量任务。冷池的 Pod 可以用 K8s 的PriorityClass设低优先级,资源紧张时被驱逐,不影响热池。
这个方案我在一个项目里落地过,整体资源成本降了约 40%。代价是冷池任务的启动延迟增加(要等 Pod 拉起),所以只适合对延迟不敏感的批量场景。
6.4 版本升级:Agent 镜像滚动更新不中断任务
Agent 镜像升级比普通服务麻烦,因为不能简单滚动重启。ax 的做法是双版本并行:新版本 runtime 先起来并注册到路由表,编排层把新任务路由到新版本,老版本继续处理存量任务,等存量任务跑完再缩容。这需要路由表支持按版本过滤,以及编排层能识别任务应该走哪个版本。
这个机制实现起来不复杂,但需要提前设计。如果一开始没考虑,后期加会很痛苦,因为要改路由协议。
7. 我踩过的几个坑和一点个人体会
第一个坑是低估了快照的 IO 压力。一开始我把快照间隔设成每步都写,结果对象存储的 QPS 直接打满,整个集群的网络都受影响。后来改成每 5 步写一次,配合关键节点强制写,才稳住。快照频率这个参数,一定要根据存储的 IO 能力反推,别拍脑袋。
第二个坑是路由表的 TTL 和心跳间隔没对齐。我一开始 TTL 设 60 秒,心跳 30 秒,理论上够,但网络抖动时心跳偶尔延迟到 40 秒,TTL 就过期了,导致 Agent 被误判为死。后来把 TTL 改成心跳间隔的 4 倍,容错窗口就舒服了。
第三个坑是优雅关闭的 preStop hook 里做了重操作。我在 preStop 里写了个“等待所有任务完成”的逻辑,结果 preStop 本身超时,Pod 被强杀,任务全丢。正确做法是 preStop 只做轻量的标记操作,真正的等待交给编排层控制。
这个方向后续还能扩展的地方很多,比如把编排层做成一个 K8s Operator,用 CRD 来声明 Agent 任务,这样就能用kubectl直接管理 Agent 生命周期。也可以把快照存储换成内容寻址的存储,做去重,进一步省空间。我现在正在试的是把 Agent 的能力标签做成可动态更新的,让 runtime 能在运行时上报自己新学会的能力,编排层实时感知。这个如果跑通,Agent 集群的自适应能力会上一个台阶。