拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent高并发场景下的集群负载化设计:Google AX与Substrate实践

Agent高并发场景下的集群负载化设计:Google AX与Substrate实践

在接手 Agent 相关项目之前,我一直把 Agent 当成一个函数在调:输入一段 prompt,等一个最终输出,完事。直到流量真正起来以后才发现,这个想法错得离谱。一个 Agent 任务可能运行几十秒甚至几分钟,中间要规划、要调工具、要读记忆、要等大模型流式返回,它不是函数,它是一段有生命周期、有状态、有外部依赖的负载。如果不把 Agent 当集群负载去设计,并发一上来,整个系统会非常难看。

最近我集中研究了 Google AX 和 Substrate 这两套东西,越看越觉得它们是在回答同一个问题:当 Agent 变成高并发任务时,到底应该怎么调度、怎么排队、怎么保证不把自己拖垮。这篇文章就结合我的实际工程经验,把这三件事放一起拆开聊:为什么 Agent 适合当集群负载、Google AX 的调度设计好在哪、Substrate 的编排基座怎么配合使用,以及真正落地时会遇到哪些坑。

1. 先想清楚:Agent 是负载,不是服务

如果你只是在本地脚本里跑一两个 Agent,把 main 函数里 new 一个实例然后 run 一下,完全没有问题。但一旦你要把 Agent 能力开放给业务方、要支撑多个用户同时提问、要接多个渠道,就必须换一个视角。Agent 的并发模型和普通接口请求完全不同,这一点不先想明白,后面所有方案都会别扭。

1.1 Agent 的运行特征:长耗时、多阶段、有状态

普通 HTTP 请求一般几百毫秒内就能返回,而一次 Agent 任务,尤其是带 ReAct 循环那种,经常是几十秒起步。这个差异直接影响并发估算:假设你的服务 QPS 是 100,普通请求平均耗时 0.2 秒,那在线并发大约只有 20 个;但如果换成一个平均耗时 40 秒的 Agent 任务,同样 100 QPS,在线并发会变成 4000 个。100 个请求/秒的流量不算高,可一旦换成 Agent,瞬间就是一个中型集群的压力。

Agent 还特别能吃资源。它内部不是一次大模型调用,而是多轮调用。每一轮还需要处理中间状态:当前计划、已观察到的结果、记忆里的历史信息、工具返回的结构化数据。这些状态如果只放在进程内存里,worker 一重启,任务就断了。我见过不少项目,Agent 跑一半崩溃,重启后之前的上下文全没了,只能重新开始,用户体验非常差。

另外,Agent 的失败模式也比普通服务复杂。它要同时依赖大模型接口、工具 API、内部知识库、可能还有外部搜索。任何一个环节变慢,整个 Agent 任务都会被拖住。普通服务超时了可以快速返回错误,Agent 超时了你还要决定是重试、回退还是降级,处理逻辑复杂得多。

1.2 负载化的含义:从常驻实例变成可调度任务

把 Agent 当集群负载,核心是改变看待它的方式。不要把 Agent 想成一个常驻服务实例,而要把它想成一个“任务单”:每一个用户请求、每一个会话、每一个待执行的子任务,都生成一张任务单,放进队列。集群里的 worker 从队列里取任务,执行完后标记完成。

这一层抽象看起来简单,但收益很大。首先是并发可控。你可以精确限制任何时刻有多少个 Agent 在真正运行,而不是让用户请求直接打到 Agent 实例上,导致线程池瞬间被打爆。其次是扩缩容容易。任务都在队列里,worker 是无状态的,流量大了加 worker,流量小了减 worker,不需要迁移任何运行中的状态。最后是故障隔离。某个任务写得再烂,最多影响它所在的 worker,队列会把新任务派发给其他健康 worker,不会造成全局雪崩。

我在实际项目中见过一个特别典型的反面案例:初期没有任务队列,每个请求直接起一个线程去跑 Agent。上线第三天流量翻倍,线程数直接破千,大模型接口还没来得及限流,整个 Java 进程先被 OOM 打挂了。后来改成队列加 worker 池,同样是翻倍流量,系统稳如泰山,只是队列长度变长了一点,用户体验反而更可控。这就是“负载”和“实例”的区别。

2. 拆解 Google AX:Agent 执行引擎的并发模型

Google AX 这个名字,我理解它代表 Agent Execution 这一层抽象。它不关心你写的是什么样的 prompt,也不负责管理你的业务知识库,它只关心一件事:怎么把一个 Agent 任务高效、稳定、可观测地执行完。这套设计里最值得学习的,是它对任务阶段的拆分和并发控制。

2.1 AX 的核心抽象:把一次运行切成阶段

AX 把 Agent 的一次运行拆成了多个阶段:输入组装、规划、工具调用、状态读写、模型推理、结果输出。每个阶段都有明确的输入输出边界,有独立的超时预算,也有独立的错误统计。这个设计看起来有点“重”,但对排障非常有价值。

举个例子。你有一个 Agent 在用户问“帮我查一下天气并设置提醒”时卡住了。在没有阶段划分的架构里,你只能看到一个黑盒:整个任务超时。但用 AX 的阶段模型,你会看到工具调用阶段花了 25 秒,其中天气 API 那一环就用掉了 20 秒,而模型推理只用了 2 秒。问题定位几乎瞬间完成。

阶段拆分对负载调度的意义更大。因为每个阶段的资源消耗和耗时特征不一样,你可以针对不同阶段配置不同的并发策略。比如大模型推理阶段最容易触发上游限流,那么这里可以单独加令牌桶;工具调用阶段可能依赖外部系统,则要设置更短的超时,避免一个慢 API 拖死整个任务。

2.2 队列、最大并行数和背压机制

AX 在并发控制上采用了非常经典的 worker 拉模型:内部维护一个任务队列,一组固定数量的 worker 从这个队列里消费任务。每个 worker 同一时间只处理一个 Agent 任务,通过参数 max_inflight 控制整个执行引擎同时运行的任务数。我不确定 AX 内部是否真的叫这个名字,但这类设计几乎是 Agent 执行引擎的标配。

拉模型的好处是天然具备背压能力。如果上游把任务推给执行引擎,引擎处理不过来时只能把请求缓存起来或者直接拒绝,还得自己实现一套限流逻辑。而拉模型下,worker 处理完一个任务才会去取下一个,队列就是天然的缓冲区。队列积压只是说明系统繁忙,不会导致进程崩溃。自己在做 Agent 平台时,这个设计完全可以照搬。

不过要注意,队列长度是有限度的。如果任务堆积太多,说明消费者能力跟不上了。这时候继续往队列里塞任务并不会解决问题,只会让用户等待时间越来越长。AX 的做法是设定队列阈值,超过阈值就从入口直接返回“当前繁忙,请稍后重试”,而不是傻傻地排队。这其实是保护整个集群不回退雪崩的关键。

2.3 从 AX 学到的超时、重试与状态管理

AX 对超时的处理也很细致。它不是给整个 Agent 任务一个总超时,而是每个阶段分别设置 deadline。总超时是 60 秒,可能规划阶段 10 秒,模型推理 20 秒,工具调用 15 秒,其他操作 15 秒。阶段级超时能避免一种非常常见的“卡死”:Agent 在某个工具调用上阻塞了,但任务总超时还没到,整个系统被无效占用。

重试策略同样分阶段。工具调用因为网络抖动失败,可以快速重试两次,每次退避 1 秒;模型推理因为上游限流失败,则不再重试,直接进入降级分支;状态写入失败则要检查幂等性,不能盲目重试。这些策略看似繁琐,但都是真实运行中最值得花时间打磨的细节。我见过太多项目把 Agent 重试做成“失败就整体重来”,结果不仅浪费了大量大模型调用,还把状态写重了。

还有一个值得关注的点:AX 的推荐做法是让状态外置。执行引擎本身不保存 Agent 的会话状态,而是通过 state_ref 指向外部的存储。每个阶段完成时,把当前上下文快照写回存储。这样即使 worker 崩溃,新的 worker 可以从最近一次快照恢复,而不是从头再来。这个思路我会在第 4 节详细展开。

3. 拆解 Substrate:Agent 编排基座的插件化思路

如果说 Google AX 是一台“能跑 Agent 的发动机”,那 Substrate(这里说的是 Agent 圈子里那套编底层,不是区块链领域同名的那个)更像是一套“接口约定”。它不直接给你一个完整调度器,而是把 Agent 运行过程中需要的公共能力:事件钩子、记忆接口、工具注册、生命周期管理,都定义成标准组件,让上层业务可以把任意 Agent 接进来。

3.1 Substrate 的核心组成:Hook、Memory、Tool Registry

Substrate 这一类 Agent 编排基座,最常见的抽象包括三块。第一是 Hook,也就是生命周期回调。Agent 在开始、结束、工具调用前、工具调用后、模型调用等关键节点,都会触发相应的钩子函数。第二是 Memory,它把记忆存储抽象成统一接口,底层可以用向量数据库、Redis、普通数据库,上层一致通过接口访问。第三是 Tool Registry,所有 Agent 可调用的工具都要注册到这里,调度层可以统一做权限校验、限流、审计。

Hook 是对负载化最重要的一个设计。因为它给了我们在不改 Agent 内部代码的前提下,把任务接入队列、设置追踪、上报指标的能力。比如我想知道每个 Agent 任务在队列里等了多久,只需要在 Agent 开始执行的钩子里记录入队时间,在结束的钩子里计算差值,不需要侵入业务代码。我之前做的一套监控系统,就是靠这类钩子把数十个不同团队开发的 Agent 全部纳管了,没有让团队成员改一行 Agent 核心代码。

Memory 接口的外置也很关键。它强制要求 Agent 不要依赖进程内的全局变量保存上下文,而是通过统一的 Memory API 读写。这一点对集群负载特别重要,因为只有把上下文从“内存”搬到“可寻址的存储”,任务才可能被调度到任意一个 worker 上执行,而不至于出现“这个任务只能由原来那台机器处理”的尴尬。

3.2 在 Substrate 上实现 worker 池和队列消费

有些朋友会误以为用了 Substrate 就不需要队列了,其实不是。Substrate 提供的是执行层和能力层,队列和 worker 池还是要在上层自己组装。做法也很直观:外部请求到来时,生成一个 AgentTask 对象,推到消息队列;worker 进程从队列里拉取任务,在 Substrate 的运行时里创建 Agent 实例,执行任务。

关键在于,worker 进程完全不感知业务逻辑,它只做四件事:从队列取任务、给任务分配一个运行时上下文、执行并等待结果、把结果写回。所有 Agent 内部的工具调用、模型调用、记忆读写,都通过 Substrate 的 Hook 上报给上层。这样你的整个 Agent 集群就成了一个标准的“任务生产者—队列—任务消费者”模型,任何一个环节都可以独立扩展。

我建议在接入 Substrate 时,把“任务入队”“任务开始”“任务结束”“任务失败”这四个 hook 点第一时间接上。入队时生成唯一的 trace_id;任务开始时记录 worker 信息和开始时间;任务结束时记录耗时和 token 消耗;任务失败时记录错误类型和堆栈。这四类数据是整个 Agent 集群可观测性的地基,后面无论做监控、做限流还是做容量规划,都从这里取数。

3.3 对比:执行引擎与编排基座的互补关系

经常有人问,Google AX 和 Substrate 是不是二选一。从我的经验看,它们根本不是同一个层次的东西,更适合组合使用。AX 偏重“单个任务怎么跑得又快又稳”,解决的是执行层的并发、超时和阶段化问题;Substrate 偏重“Agent 怎么构建和组织”,解决的是组件解耦、记忆管理和扩展性问题。

在实践中,你可以用 Substrate 的规范把 Agent 的各个零件拼起来,然后在最外层用 AX 风格的执行引擎作为 worker 运行时,控制实际并发和任务生命周期。换句话说,Substrate 定义 Agent 长什么样,AX 决定 Agent 怎么被调度和执行。两者结合,正好构成一套完整的“把 Agent 当集群负载”的落地框架。

4. 落地实战:把 Agent 变成集群负载的完整方案

聊完了理念和框架,下面给出一套可以直接参考落地的方案。这里不绑定任何具体云厂商,也不依赖特定框架,核心是用消息队列加无状态 worker,把 Agent 任务变成标准负载。整个方案分四步走。

4.1 定义统一的任务格式

所有 Agent 任务的入参,先统一成一个 Task 对象。我在项目里最常用的一种定义是这样的:

@dataclass class AgentTask: task_id: str # 全局唯一任务ID agent_type: str # 指定用哪个 Agent 处理 payload: dict # 业务传入的参数 max_duration: int = 60 # 任务总超时,秒 priority: int = 0 # 优先级,越大越先处理 idempotent_key: str = "" # 幂等键,防止重复执行 state_ref: str = "" # 状态存储引用,例如 redis key

task_id 和 idempotent_key 是两个很容易被混淆的字段。task_id 是任务的唯一标识,每次生成一个新的,用于追踪日志、关联 trace;idempotent_key 则用于幂等判断,它往往来自业务侧,比如“用户ID+会话ID”。同一个幂等键如果已经存在执行记录,新的任务会被丢弃或合并,而不是重复执行。这个字段对解“重复消费”问题特别关键。

state_ref 是状态外置的关键。它不是一个具体的上下文对象,而是一个“引用”,比如 Redis 里的一个 key,或者对象存储里的一个文件路径。worker 拿到任务后,先从 state_ref 读取已有的上下文,执行过程中定期写回,结束后再写一次。这样任务本身只携带很轻量的元数据,真正的状态都存放在集群外部。

4.2 用消息队列承载 Agent 任务

队列选型方面,不需要一上来就上重型的分布式消息平台。如果团队规模不大,Redis Stream 或者 RabbitMQ 完全够用。如果已经是微服务架构,可能会优先考虑 Kafka,但要注意 Kafka 的消费模型更适合高吞吐日志类数据,用在 Agent 任务上需要额外处理死信和回滚。就我的经验,RabbitMQ 的 basic_qos 设置非常匹配 Agent 场景。

关键配置有三个。一是消费者 prefetch 设置为 1,确保每个 worker 同一时间只处理一个 Agent 任务。Agent 任务不像普通消息那样毫秒级处理完,prefetch 太高会导致消息分派不均,一个 worker 手里囤一堆任务,其他 worker 空闲。二是为每个 agent_type 建立独立队列,避免一个慢 Agent 阻塞其他类型的 Agent。三是单独设置死信队列,凡是重试多次仍然失败的任务,统一进入死信队列,由人工或补偿脚本处理。

下面是一份简化的队列配置参考,我实际项目里基本就是这样跑的:

queue: agent_tasks: durable: true max_length: 10000 overflow_policy: reject_publish # 队列满了直接拒绝,防止无界积压 consumer: prefetch: 1 max_retries: 3 backoff: exponential # 1s, 2s, 4s dead_letter: queue: agent_tasks_dlq

注意 overflow_policy 要配成 reject_publish。这样队列满了以后,新任务会在入口被拒掉,由调用方决定是降级还是稍后重试,而不是把所有任务都堆在一个无限长的队列里,导致用户等待时间变得不可控。这跟 Google AX 的背压思路是一致的。

4.3 状态外置:把记忆和上下文搬出进程

Agent 的上下文应该放哪里,是集群负载方案里最容易被低估的问题。我先说结论:不要放在 worker 的内存里。原因很简单,worker 是随时可能被杀、被重启、被替换的,只要上下文在内存里,这个任务就“绑架”了 worker,根本没法做故障转移和弹性伸缩。

我的做法是,每个任务在执行前,从 state_ref 指向的存储里加载上下文。执行过程中,每完成一个关键阶段(比如一次模型调用结束、一次工具调用结束),向上回写一次上下文。执行完成后,写入最终结果,并把任务状态置为 done。存储可以用 Redis,数据结构上直接存 JSON 字符串,简单可靠;如果上下文很大,可以把这部分放到对象存储,Redis 里只存一个指向对象存储的引用。

这个设计带来的最大收益,是 worker 可以随时被替换。比如某台机器的内存报警,运维可以直接杀掉这个 worker 进程,把 queue 里的消息重新投递给其他 worker。新的 worker 从 state_ref 加载最近的上下文,继续执行,用户只会感觉稍微慢了一点,但不会从零开始。这在单体 Agent 架构里是做不到的。

4.4 扩缩容、优雅下线与健康检查

当所有 worker 都是无状态消费队列时,扩缩容就变成了一个很纯粹的容量问题。流量高峰期,在 worker 池里增加节点,每个新节点启动后自动订阅队列开始消费;流量降下来,销毁多余节点。这里要注意一个细节:销毁 worker 不能直接 kill 进程。一个 Agent 任务可能正在调用外部工具,直接被 kill 会导致任务处于不确定状态。

我建议的优雅下线流程是这样的:先向 worker 发送一个停止信号,worker 收到后把自己的消费状态标记为“不再拉取新任务”,然后等待当前正在执行的任务完成。为了不让等待时间无限长,我们通常会设置一个宽限期,比如 30 秒。宽限期内任务还没结束,worker 就把任务标记为可重投递,让其他 worker 继续处理。注意“可重投递”和“重试失败”是两件事,前者是因为 worker 要下线了,任务本身没有错,所以投递后要正常执行,而不是进死信。

健康检查同样重要。每个 worker 要定期上报心跳,报告自己正在处理的任务数、内存占用、最近一次模型调用的延迟。调度中心看到某个 worker 心跳超时,会把它从消费组里踢掉,并把它的未完成任务重新投递。这一套做完,Agent 集群才真正具备了基本的自愈能力。

5. 常见问题与排查技巧实录

方案说完了,接下来这部分是我踩过最多的坑。说实话,Agent 集群负载方案,最大的难点从来不是“把 Agent 塞进队列”,而是跑起来之后遇到的各种诡异问题。我把常遇到的几类问题列一个速查清单,希望对你有参考价值。

5.1 Agent 静默卡死,没有任何报错日志

这是 Agent 项目最让人头疼的问题。任务既不失败也不结束,日志里最后一条记录停在“正在调用天气接口”,然后就再也没有然后了。排查的时候你去看天气接口,发现它其实早就返回了,只是 Agent 的 HTTP 客户端一直挂在连接等待上。

根因大多是客户端超时没设置。Python 里 requests 默认没有超时,OpenAI SDK 如果没配 timeout 参数,某些网络环境下也会长时间阻塞。解决方法是给所有外部调用统一设置连接超时和读超时,更激进一点,可以在 Hook 层用一个统一的装饰器包裹所有工具调用,强制加 context deadline。别忘了大模型调用也要设置超时,不要以为 SDK 内部已经处理好了。我后来做了一道硬性规定:任何工具调用必须有 timeout,否则代码评审不通过。这个规则很粗暴,但是有效。

5.2 并发一高,上游限流报警一片

明明队列和 worker 都做了,大模型接口还是狂报 429。原因很简单:你虽然限制了 worker 数量,但每个 Agent 内部有多轮模型调用,一个 worker 可能在跑一个 Agent 的间隙,其他 Agent 的模型调用并发仍然会叠加。也就是说,worker 池限制的是 Agent 任务数,不是大模型 API 调用数。

这时候要把限流点放在队列层,而不是 Agent 内部。我常用的办法是,在 worker 里设置一个全局令牌桶,预取任务时先检查令牌,令牌不够就稍等一下再取。令牌桶的速率按大模型接口的配额来定,比如每分钟 600 次,那令牌就是每分钟 600 个。另外一个技巧是按模型维度分流,不同模型走不同的队列和 worker,别让一个重型模型的限流把轻量 Agent 的任务也堵死。

5.3 Agent 任务被重复执行,数据写了两遍

队列系统的“at least once”投递语义是出了名的,也就是说,正常情况下也存在消息被重复消费的可能。worker 在处理任务时崩溃,消息可能被重新投递给另一个 worker,另一个 worker 又把工具调用执行了一遍。对于可重复的工具还好,如果工具是“创建订单”“发送短信”,这就是事故。

解决思路就靠幂等。核心实现是在执行真实动作前,先检查 idempotent_key 是否已经处于 running 或 done 状态。如果已经 running,直接放弃本次执行;如果已经 done,直接返回之前的执行结果。状态检查要和“写入状态”做成原子操作,这样才能避免两个 worker 同时拿到同一个任务,同时认为自己是第一次执行。实际项目里用 Redis 的 SETNX 或数据库唯一约束都能解决,关键是不要漏掉这个设计。

5.4 一个任务把整个 worker 拖死

有些 Agent 任务特别能吃内存,比如它往上下文里塞了大量搜索结果,或者模型返回了很长的中间推理过程。如果不做限制,一个异常任务很可能把进程内存撑爆。传统线程池根本没太关注这个,但在 Agent 集群里,一个 worker 挂了还算小事,如果它是一个进程里跑多个 worker 的模式,就可能影响其他任务。

最简单的控制手段是给 worker 设置资源上限。容器环境里,直接限制内存和 CPU;进程模式里,可以用每个 worker 一个进程的方式隔离。不要在一个进程里开几十个线程跑 Agent 任务,我个人的经验是,线程模式排查问题时非常痛苦:一个死循环线程导致 CPU 高居不下,但你不知道是谁。进程隔离虽然重一点,但问题定位路径清晰很多。

5.5 怎么快速定位 Agent 集群瓶颈

如果整个集群性能下降,不用猜,直接用数据说话。我通常会把一次 Agent 任务的耗时拆成四段:排队耗时、模型调用耗时、工具调用耗时、其他处理耗时。这四段分别有对应的埋点。

耗时阶段来源对应排查方向
排队耗时入队时间到 worker 取到任务队列积压、worker 数量
模型调用耗时每次 LLM 请求的开始/结束API 限流、模型选择、上下文长度
工具调用耗时每次工具请求的开始/结束外部 API 稳定性、超时配置
其他耗时任务执行 Hook 间隔序列化、内存读写、业务代码

只要这四段数据齐了,大部分瓶颈一眼就能看出来。排队耗时高就扩 worker;模型调用耗时长就查上游配额和 prompt 大小;工具调用耗时长就重点治理外部依赖。没有这些埋点,排查 Agent 问题基本就是靠猜,效率非常低。

6. 写到最后的一点体会

这套“把 Agent 当集群负载”的方案,我前后大概迭代了三个版本。第一版只是简单加了队列;第二版补上了状态外置和优雅下线;第三版才真正把阶段耗时、幂等、背压这些细节补齐。回过头看,早期我最大的认知误区就是以为 Agent 平台的核心是模型选型和 prompt 工程,实际上,当业务量上来以后,真正决定系统能不能活下去的,是调度和负载治理的基本功。

如果你的 Agent 项目现在还很初级,我建议先别急着追求特别复杂的框架,按 4.3 节的状态外置、一个消息队列、一组无状态 worker,这三件事做起来,就已经能解决 80% 的并发稳定性问题。等真的需要更强调度能力时,再往谷歌 AX 那种阶段化执行器、Substrate 那种完整编排基座靠拢也不迟。毕竟,方案是为业务服务的,不是业务为方案服务。

返回列表