1. 为什么 K8s 之上还需要一层 Agent 原语
1.1 从一个真实的困惑说起
去年我在给一个内部平台做 Agent 编排层的时候,遇到一个很别扭的问题:我们已经有了一套跑得挺稳的 K8s 集群,Pod、Deployment、Service、HPA 这些都用得很熟,按道理说,把 Agent 当成一个普通工作负载丢进去跑不就完了?结果真上手才发现,事情没那么简单。
一个 Agent 进程和传统 Web 服务在行为模式上差别太大了。Web 服务是"请求进来、处理、返回"这种短生命周期、无状态的模式,K8s 的调度、探针、扩缩容机制都是围绕这个假设设计的。但 Agent 不一样:它可能一次任务要跑几十分钟甚至几个小时,中间会调用外部工具、会等待人工确认、会持有上下文记忆、会在某个步骤失败后需要从中间状态恢复。你拿 livenessProbe 去探它,探着探着就把一个正在思考的 Agent 给杀了。
这就是"Kubernetes 之父对谈 Agent Substrate"这个话题真正戳中的痛点:K8s 是为无状态服务和无状态工作负载设计的,而 Agent 是有状态、长时运行、带记忆和工具调用能力的实体,两者之间存在一层语义鸿沟。Agent Substrate 想做的事情,就是在这层鸿沟上架一座桥——不是替换 K8s,而是在 K8s 之上定义一套专门面向 Agent 的新原语。
1.2 什么是"原语",为什么这个词很关键
"原语"(Primitive)这个词在分布式系统里分量很重。它不是指某个具体功能,而是指系统提供给上层的最小、最基础、不可再分的构建单元。K8s 的原语是什么?Pod、Service、ReplicaSet、ConfigMap、Secret、Volume。你所有的上层编排,最终都是这些原语的组合。
那 Agent 需要什么原语?这是 Agent Substrate 要回答的核心问题。我个人的理解是,它至少要提供这几类 K8s 原生没有的东西:
- Agent 实例(Agent Instance):一个有身份、有记忆、有生命周期的 Agent 实体,而不是一个无状态的 Pod。
- 会话/上下文(Session / Context):Agent 跨多次调用保持的状态,需要持久化、需要能被恢复。
- 工具绑定(Tool Binding):Agent 能调用哪些工具、以什么权限调用,这是一等公民而不是配置项。
- 任务(Task / Goal):Agent 要完成的目标,带优先级、带依赖、带超时和重试语义。
- 记忆存储(Memory Store):短期记忆、长期记忆、向量记忆,需要和 Agent 生命周期解耦。
这些原语如果硬塞进 K8s 现有的 CRD 体系里,也能做,但会做得很别扭。因为 K8s 的控制器模型假设"期望状态"是相对静态的,而 Agent 的状态是高度动态、甚至带随机性的。这就是为什么需要一层专门的 Substrate。
1.3 这层 Substrate 到底解决什么问题
我把实际踩过的坑整理了一下,Agent 直接跑在裸 K8s 上,主要卡在这么几个地方:
| 问题 | 裸 K8s 的表现 | Agent Substrate 的解法 |
|---|---|---|
| 长时任务被误杀 | livenessProbe 超时导致重启 | 用 Task 原语替代探针,按任务状态判断存活 |
| 状态丢失 | Pod 重启后上下文全没 | Session 原语持久化上下文,支持断点续跑 |
| 工具权限混乱 | 靠环境变量和 Secret 硬编码 | Tool Binding 原语声明式管理权限 |
| 扩缩容不适用 | HPA 按 CPU/内存扩,Agent 按任务队列扩 | 按 Task 队列深度和 Agent 负载扩 |
| 可观测性割裂 | 日志散在各 Pod,链路断 | 以 Agent 为中心的统一 trace |
这张表是我自己在项目里总结的,不一定全面,但基本覆盖了最痛的几个点。核心逻辑就一句话:K8s 管的是"容器活着没",Agent Substrate 管的是"Agent 在干什么、干到哪了、下一步该干嘛"。
2. Agent Substrate 的核心原语拆解
2.1 Agent 实例:从 Pod 到有身份的实体
在 K8s 里,Pod 是可以随时被替换的,名字都是随机后缀,今天叫web-7d9f8b6c4-x2k9p,明天重建就变成另一个名字。这对无状态服务没问题,但对 Agent 是灾难——你没法说"让昨天那个 Agent 继续把任务做完",因为它根本不存在了。
Agent Substrate 里的 Agent 实例,我理解应该具备这几个特征:
- 稳定身份:每个 Agent 有一个持久的 ID,跨重启不变。
- 绑定记忆:Agent ID 关联到一份记忆存储,重启后能读回。
- 声明式能力:这个 Agent 会哪些技能、能调哪些工具,写在 spec 里。
- 生命周期独立于进程:进程挂了,Agent 实体还在,可以被重新调度起来继续。
这有点像 K8s 里 StatefulSet 的思路,但比 StatefulSet 走得更远。StatefulSet 保证的是"稳定的网络标识和存储",Agent Substrate 要保证的是"稳定的认知状态"。
注意:这里有个容易踩的坑。很多人第一反应是用 StatefulSet 来跑 Agent,觉得有稳定标识就够了。但 StatefulSet 的存储模型是"一个 Pod 挂一个 PVC",而 Agent 的记忆往往是多个 Agent 共享的、需要并发读写的,PVC 的 ReadWriteOnce 模式根本扛不住。所以记忆存储必须独立于 Pod 的卷体系,走单独的 Memory Store 原语。
2.2 Session 与 Context:让 Agent 能"记得住"
Agent 和普通程序最大的区别,是它需要上下文。一次对话、一个任务、一段推理链,都是上下文。上下文丢了,Agent 就"失忆"了。
Session 原语要解决的核心问题是:上下文存在哪、存多久、怎么恢复、怎么隔离。我实际做的时候,把上下文分成了三层:
- 瞬时上下文:当前这一步推理需要的信息,放在内存里,进程重启就没了,可以接受。
- 会话上下文:一次完整任务或对话的上下文,需要持久化,任务没结束就不能丢。
- 长期记忆:跨会话的知识,通常是向量化的,存在专门的存储里。
Session 原语主要管第二层。它的关键设计点是:Session 的生命周期和 Agent 进程的生命周期解耦。Agent 进程可以重启、可以迁移、可以扩缩,但 Session 一直在那,谁拿到 Session ID 谁就能接着干。
这里有个实操细节值得说:Session 的持久化不能简单粗暴地每次操作都写数据库,那样延迟受不了。我用的方案是"检查点 + 增量日志"——关键节点打检查点,中间步骤写增量日志,恢复时从最近检查点重放日志。这个思路和数据库的 WAL 是一回事。
2.3 Tool Binding:把工具调用变成一等公民
Agent 要干活,就得调工具。查数据库、发邮件、调 API、跑代码,都是工具。在裸 K8s 里,这些工具调用通常靠环境变量传密钥、靠代码里硬编码 URL,乱得很。
Tool Binding 原语要做的是:把"这个 Agent 能用哪些工具、以什么身份用、有什么配额"变成声明式配置。类似这样:
apiVersion: agent.io/v1 kind: ToolBinding metadata: name: research-agent-tools spec: agentRef: research-agent-001 tools: - name: web-search endpoint: https://internal-search.svc/api quota: requestsPerMinute: 60 - name: database-query endpoint: postgres://readonly-db.svc:5432 permissions: - read quota: requestsPerMinute: 30这么设计的好处是,权限和配额在平台层统一管控,Agent 代码里不用关心密钥从哪来,也不用担心某个 Agent 疯狂调工具把下游打挂。这其实就是把服务网格那套"sidecar 管流量"的思路,搬到了工具调用上。
2.4 Task 原语:Agent 到底在干什么
这是我认为 Agent Substrate 里最核心、也最容易被低估的原语。K8s 里没有"任务"这个概念(Job 是一次性的批处理,语义不一样),但 Agent 的一切行为都是围绕任务展开的。
Task 原语应该包含:
- 目标描述:这个任务要达成什么。
- 状态机:pending → running → waiting → done / failed。
- 依赖关系:任务 A 完成才能跑任务 B。
- 超时与重试:多久算超时,失败重试几次。
- 优先级:紧急任务插队。
有了 Task 原语,扩缩容的逻辑就变了。不再是"CPU 高了加 Pod",而是"待处理 Task 队列长了加 Agent"。这个逻辑更贴近 Agent 的实际负载特征。
3. 在 K8s 之上落地 Agent Substrate 的实操路径
3.1 整体架构:Substrate 是控制面,K8s 是执行面
先说清楚分层。我的做法是:
- K8s 层:负责最底层的容器调度、网络、存储、资源隔离。这一层不用动,继续用。
- Substrate 控制面:一组自定义控制器 + CRD,负责 Agent、Session、Task、ToolBinding 这些原语的编排。
- Agent 运行时:真正跑 Agent 逻辑的进程,以 Pod 形式跑在 K8s 上,但由 Substrate 控制面管理。
关键点是:Substrate 不替代 K8s 的调度器,而是在它之上做二次编排。Agent 运行时最终还是 Pod,还是要被 kube-scheduler 调度,但"什么时候该起一个 Agent、起哪个 Agent、给它什么任务"这些决策,由 Substrate 控制面做。
这么分层的好处是,K8s 那些成熟的运维能力——滚动更新、资源配额、网络策略、监控——全都能复用,不用重新造轮子。
3.2 用 CRD + Controller 实现原语
落地原语最自然的方式就是 CRD + Controller。每个原语一个 CRD,配一个控制器负责 reconcile。
以 Agent 原语为例,CRD 大概长这样:
apiVersion: agent.io/v1 kind: Agent metadata: name: research-agent-001 spec: image: registry.internal/research-agent:v1.2.0 skills: - web-research - summarization toolBindings: - research-agent-tools memoryRef: research-agent-memory resources: requests: cpu: "500m" memory: "1Gi" maxConcurrentTasks: 3 status: phase: Running activeTasks: 2 lastHeartbeat: "2024-01-15T10:30:00Z"控制器要做的事情:监听 Agent CRD 的变化,确保对应的 Pod 存在且健康,把 Pod 的状态回写到 Agent 的 status 里。这跟 Deployment 控制器的逻辑很像,但多了 memoryRef、toolBindings 这些 Agent 特有的字段处理。
实操心得:写控制器的时候,reconcile 函数一定要幂等。我一开始没注意,Agent 状态更新频繁触发 reconcile,结果疯狂创建 Pod。后来加了"先查当前状态,只有期望状态和实际状态不一致才动手"的判断,才稳下来。这是 K8s 控制器开发的基本功,但真写起来很容易忘。
3.3 记忆存储的选型与接入
记忆存储是 Agent Substrate 里最需要仔细选型的部分。我的经验是分两类处理:
- 结构化记忆(任务状态、会话元数据):用 PostgreSQL 或 etcd,强一致,事务支持好。
- 向量记忆(语义检索用的 embedding):用专门的向量库,比如 Milvus、Qdrant 这类。
接入方式上,我倾向于把记忆存储做成一个独立的 Service,Agent 通过标准接口访问,而不是让 Agent 直接连数据库。这样做的理由:
- 记忆存储的扩缩容和 Agent 的扩缩容解耦。
- 可以在 Service 层做缓存、限流、审计。
- Agent 代码不用关心底层用的是哪个库,换库不影响 Agent。
接口设计上,至少要有这几个操作:get(sessionId, key)、put(sessionId, key, value)、search(sessionId, queryVector, topK)、checkpoint(sessionId)、restore(sessionId, checkpointId)。
3.4 任务调度:从队列到 Agent 的映射
Task 原语落地后,调度逻辑就清晰了。我的实现是一个简单的调度循环:
- 从 Task 队列里取优先级最高的 pending 任务。
- 找到有对应 skill 且未达 maxConcurrentTasks 的 Agent。
- 把任务分配给这个 Agent,状态改为 running。
- Agent 完成后回调,状态改为 done,触发依赖它的任务。
这个循环看起来简单,但有几个细节要注意:
- 任务亲和性:如果任务需要访问某个 Session 的上下文,最好调度到已经加载了该 Session 的 Agent 上,避免重复加载。
- 公平性:高优先级任务不能无限插队,否则低优先级任务饿死。我用的是"优先级 + 等待时间"的加权排序。
- 失败处理:任务失败后,是重试还是标记失败,要有明确策略。我的做法是区分"可重试错误"(网络超时)和"不可重试错误"(参数错误),前者重试,后者直接失败并告警。
4. 常见问题与排查技巧实录
4.1 Agent 卡死但 Pod 显示健康
这是最常见的问题。Agent 进程还在,端口还在监听,但实际已经卡在某个工具调用上不动了。K8s 的 livenessProbe 探的是端口,探不出来。
排查思路:不要依赖 K8s 探针,要在 Substrate 层做心跳。Agent 定期向控制面报告"我还在处理任务 X,当前步骤 Y",超过阈值没心跳就判定卡死,触发重启或任务重新分配。
避坑技巧:心跳间隔别设太短,Agent 有些步骤本来就慢(比如等大模型返回),设太短会误判。我的经验值是正常步骤耗时的 3 倍左右。
4.2 Session 恢复后上下文错乱
Agent 重启后从 Session 恢复,结果发现上下文对不上,推理结果乱七八糟。
根因:通常是检查点和增量日志的边界没对齐。比如检查点记的是步骤 5 的状态,但增量日志从步骤 7 开始记,中间步骤 6 丢了。
解法:检查点和日志必须原子写入。我的做法是给每个 Session 维护一个单调递增的 sequence number,检查点和日志都带这个号,恢复时严格按号重放,发现断号就报错而不是硬恢复。
4.3 工具调用把下游打挂
某个 Agent 陷入循环,疯狂调同一个工具,把下游服务打挂了。
解法:Tool Binding 里的 quota 必须强制执行,而且要在 Substrate 层做,不能只靠 Agent 自觉。我用的是令牌桶限流,每个 ToolBinding 一个桶,超了就拒绝并返回明确错误,让 Agent 知道被限流了而不是傻等。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Agent 反复重启 | 探针误判 / OOM | 看 Substrate 心跳日志、看 Pod 资源限制 |
| 任务永远 pending | 没有匹配 skill 的 Agent | 检查 Agent 的 skills 声明和 Task 的 skill 要求 |
| 记忆读写超时 | 向量库负载高 | 看向量库监控,考虑加缓存或分片 |
| 工具调用 403 | ToolBinding 权限没配 | 检查 ToolBinding 的 permissions 字段 |
| 扩缩容不触发 | 扩缩容指标配错 | 确认是按 Task 队列深度扩,不是按 CPU |
4.5 几个我踩过的坑
第一个坑是把 Agent 当无状态服务做。一开始我图省事,Agent 不存任何状态,每次任务都从头开始。结果一个需要多轮交互的任务,每轮都要重新加载全部上下文,慢得要死。后来改成 Session 持久化,性能好了十倍不止。
第二个坑是CRD 字段设计太随意。早期 Agent CRD 里塞了一堆东西,后来发现有些字段根本用不上,有些又不够用,改起来要命。教训是:CRD 是 API,要当 API 设计,考虑版本兼容,别随便加字段。
第三个坑是忽略 K8s 原生能力。我一度想自己实现一套资源隔离,后来发现 K8s 的 ResourceQuota 和 LimitRange 已经够用了,白白浪费了两周。Substrate 应该站在 K8s 肩膀上,而不是重新发明 K8s。
5. 这套方案适合谁、不适合谁
5.1 适合的场景
如果你的 Agent 满足这几个特征,Agent Substrate 这套思路值得投入:
- Agent 需要长时间运行,任务粒度是分钟到小时级。
- Agent 有状态,需要跨调用保持上下文。
- Agent 数量多,需要统一编排和调度。
- 团队已经有 K8s 基础,不想另起炉灶。
5.2 不适合的场景
反过来,如果只是跑几个简单的、无状态的 Agent 做 demo,或者 Agent 任务都是秒级的,那直接跑在 K8s 上就行,上 Substrate 是过度设计。我见过不少团队,Agent 还没几个,先花两个月搭编排层,纯属本末倒置。
5.3 一个务实的演进路径
我的建议是分三步走:
- 第一步:Agent 直接跑在 K8s 上,用 Deployment 管,先把业务跑通。
- 第二步:痛点出现后,先加 Session 持久化和 Tool Binding 这两个最刚需的原语,用 CRD 实现。
- 第三步:Agent 规模上来了,再补 Task 调度和完整的控制面。
别一上来就追求大而全的 Substrate,那是给平台团队准备的,业务团队用不上。
我个人在实际操作中的体会是,Agent Substrate 这个概念的价值不在于它提供了多少新功能,而在于它逼着你想清楚 Agent 和普通工作负载的本质区别。想清楚了这个,哪怕你不用任何现成的 Substrate 框架,自己用 CRD 拼一套,也能拼出个八九不离十。真正难的不是技术实现,是原语设计的取舍——哪些该抽象成原语,哪些该留给 Agent 自己处理,这个边界划在哪,直接决定了整套系统好不好用。我到现在也不敢说自己划对了,只能说踩过的坑多了,边界感慢慢就有了。