4.2 Agent 编排与资源池隔离


文档摘要

4.2 Agent 编排与资源池隔离 Agent 是这两年的热点,但很多团队上线 Agent 功能后,整个对话产品的稳定性反而变差了。根因通常是:Agent 请求和普通对话请求混在一起抢资源,而 Agent 是典型的"重请求",会把普通请求挤垮。这一节讲 Agent 请求的特点、为什么必须独立资源池隔离,以及多路调用的流式聚合。 我处理过一个案例:一个团队上线了 Agent 功能(让 AI 帮用户查天气、订餐、写邮件等多步任务),上线第一天,原本稳定的普通对话服务开始间歇性卡顿。排查发现,Agent 任务每个要调用 5 到 10 次模型 + 多次外部工具,单个 Agent 任务的资源消耗是普通对话的 30 倍以上。

4.2 Agent 编排与资源池隔离

Agent 是这两年的热点,但很多团队上线 Agent 功能后,整个对话产品的稳定性反而变差了。根因通常是:Agent 请求和普通对话请求混在一起抢资源,而 Agent 是典型的"重请求",会把普通请求挤垮。这一节讲 Agent 请求的特点、为什么必须独立资源池隔离,以及多路调用的流式聚合。

我处理过一个案例:一个团队上线了 Agent 功能(让 AI 帮用户查天气、订餐、写邮件等多步任务),上线第一天,原本稳定的普通对话服务开始间歇性卡顿。排查发现,Agent 任务每个要调用 5 到 10 次模型 + 多次外部工具,单个 Agent 任务的资源消耗是普通对话的 30 倍以上。少量 Agent 请求涌入,就把推理集群的算力和连接吃光,普通对话请求排队等待,体验崩塌。把 Agent 流量和普通流量分开后,问题立刻消失。

Agent 请求为什么"重"

先说清楚 Agent 请求到底重在哪。一个 Agent 任务的生命周期包括:多轮 LLM 推理(思考链、规划、反思),每次推理都要消耗算力;多次工具调用(搜索、代码执行、外部 API),每次调用都有网络延迟和可能的失败;中间状态管理,Agent 要记住"已经做了什么、下一步做什么";结果聚合与流式输出,把多步结果合成一个连贯的流式响应返回用户。

这导致 Agent 请求的单次资源消耗是普通对话的 10 到 100 倍,而且耗时长——一个复杂 Agent 任务可能跑几分钟。如果 Agent 和普通短对话共享资源池,会发生什么?Agent 的长任务长期占用推理算力,挤压普通请求;Agent 的工具调用延迟传导到整体延迟;一个 Agent 任务卡住(比如某个工具调用超时),可能拖垮一连串依赖它的请求。普通用户成了 Agent 功能的牺牲品。

具体测过一组数据:普通对话请求平均 1.5 秒返回、消耗约 800 token;Agent 任务平均 45 秒返回、消耗约 12000 token(含 8 次模型调用 + 3 次工具调用),单任务资源消耗是普通的 15 倍、占用时长是 30 倍。意味着一个 Agent 任务相当于 ~450 个普通请求的资源占用(按"GPU 秒"算)。所以即使 Agent 流量只占总请求的 2%,它消耗的 GPU 资源可能占到 30% 以上——这是必须隔离的根本原因。

独立资源池隔离

解法是资源池隔离——把 Agent 请求和普通请求分到不同的推理资源池。普通池服务高并发短请求,优化吞吐;Agent 池服务长任务,优化单任务资源保障、容忍长耗时。两池资源配比独立,互不干扰。Agent 洪流不会压垮普通用户,普通请求高峰也不会饿死 Agent。

资源池隔离的实现有几种粒度。最粗的是物理隔离——Agent 和普通请求跑在不同的 GPU 集群上,完全不共享硬件,隔离最彻底但成本最高(硬件不能复用)。中等的是逻辑隔离——同一集群内按比例切分(比如 70% 算力给普通池,30% 给 Agent 池),通过调度器保证切分,硬件能复用但有调度开销。最细的是优先级隔离——所有请求共享资源,但通过优先级队列保证普通请求优先,Agent 请求在资源紧张时让路。

生产里通常从逻辑隔离起步(成本和隔离性的平衡),如果 Agent 流量极大且波动剧烈,再上物理隔离。优先级隔离适合 Agent 流量小、且能接受"过载时被饿死"的场景。

逻辑隔离的具体实现,在 vLLM 里可以起两套实例,分别打 agent-poolnormal-pool 的标签,编排层按请求类型路由到对应实例池。容量切分我们用的是"动态配比 + 上下限"——默认普通池 75%、Agent 池 25%,但允许在 60%-90% 之间浮动:白天 Agent 流量少时把比例往普通倾斜(普通 90%、Agent 10%),晚上 Agent 批处理任务多时往 Agent 倾斜。下限设 15% 保证任何一个池都不会被饿死(即使某个池当前没流量,也保留 15% 资源待命,避免突发请求全排队)。这个动态调整由一个简单的调度器根据两池的排队长度每 5 分钟调一次。

Agent 的调度策略

Agent 池要配套专门的调度策略。并发上限是基础——Agent 池设并发上限,避免被 Agent 任务占满,超出的 Agent 请求排队或拒绝。优先级,付费用户的 Agent 任务优先,免费的排队或限速。超时与中断,Agent 任务设最大时长(比如 5 分钟),超时主动中断,避免僵尸任务长期占用资源。状态持久化,Agent 任务中间状态持久化到外部存储,失败时可以从最近的检查点恢复,而不必从头重来——这对长任务尤其重要,重跑 10 分钟的任务是不可接受的。

给一组 Agent 池调度的实战参数。单用户并发上限:免费用户 1 个、付费 3 个、企业 10 个(防止一个人脚本刷爆 Agent 池)。全局 Agent 并发上限:设为 Agent 池 GPU 算力的 80%(留余量应对突发),用令牌桶控制,超出的进队列(队列上限 500,满了直接拒绝返回"系统繁忙")。任务级超时分层:默认 3 分钟、深度研究类 10 分钟、有明确上限标注的任务(如"批处理 100 封邮件摘要")可到 30 分钟——超时按任务类型配置,不要一刀切。优先级队列:付费权重 5、免费权重 1,加权公平调度。Checkpoint 频率:每个工具调用完成或每 30 秒(取早者)存一次 checkpoint 到 Redis,崩溃后从最近 checkpoint 恢复,重算量控制在 30 秒以内。

死循环检测也要在调度层做:记录 Agent 的工具调用序列(如 ["search","search","search"]),如果连续 3 次调用同一工具且参数相似,判定为疑似死循环,强制中断并返回"任务疑似陷入循环,已终止"。这条规则帮我们抓到过不少模型陷入"再搜一下确认"怪圈的 case。

多路调用的流式聚合

Agent 常需要把多路并行的子调用结果聚合成一个流式响应返回用户。比如一个"帮我查北京和上海的天气,对比一下"的 Agent 任务,可能并行发起两个天气查询,拿到结果后让模型生成对比分析,整个过程流式推送给用户。这是个编排难题。

难点在于:多路子调用并行发起,完成时间不同(北京天气 200ms 返回,上海天气 800ms 返回),怎么把它们按逻辑顺序合并?要边产生边推送(用户看到"正在查询北京...北京晴,25 度;正在查询上海..."),而不是等全部完成再返回(那样用户要干等 800ms 才看到第一个字)。某一路失败时要有降级(用部分结果或默认值兜底,而不是整个任务失败)。

实现上要用流式编排框架(基于 async/await 的编排引擎)管理多路调用的生命周期,定义清晰的聚合规则(顺序、优先级、降级策略),并支持中间结果边产生边推送。这种编排引擎通常需要自研,市面上的通用框架很难完全满足大模型服务的流式需求。

给一个流式聚合的核心代码思路(Python 伪代码):

async def run_agent_task(query, stream_out): # 并行发起多路子调用 tasks = { "beijing": asyncio.create_task(call_weather("北京")), "shanghai": asyncio.create_task(call_weather("上海")), } results = {} # 谁先完成先推送谁,不按固定顺序阻塞 pending = set(tasks.keys()) while pending: done, pending = await asyncio.wait( [tasks[k] for k in pending], return_when=asyncio.FIRST_COMPLETED ) for t in done: city = next(k for k, v in tasks.items() if v is t) try: results[city] = t.result() await stream_out(f"{city}天气:{results[city]}\n") # 边产生边推 except Exception as e: # 某路失败,降级用默认值,不让整个任务崩 results[city] = "查询失败" await stream_out(f"{city}天气:暂不可用\n") metrics.tool_failure.inc() # 全部完成后做聚合分析 analysis = await model.generate(f"对比:{results}") async for chunk in analysis.stream(): await stream_out(chunk)

几个工程细节。第一,用 asyncio.wait(..., return_when=FIRST_COMPLETED) 而不是 asyncio.gather——后者要等全部完成,前者谁好谁先推,这是"边产生边推送"的关键。第二,每路子调用要单独 try/except,单点失败不影响整体——宁可推一个"暂不可用",也不要整个任务挂掉。第三,子调用本身要设超时(如 asyncio.wait_for(call_weather(city), timeout=3)),避免一个慢工具拖死整个聚合。第四,流式输出通道(stream_out)要做背压控制——如果客户端消费慢,要给上游反压,不能无脑累积。

一个常被忽视但很有效的体验技巧是:在多路调用的间隙,向用户推送"思考状态"——"正在查询北京天气..."这样的中间提示。它不增加算力成本,但让用户感知到"系统在干活",而不是干等。这种"感知到进度"的体验,比单纯的低延迟更能让用户接受长任务。

Agent 编排的坑

成本黑洞是第一个坑。Agent 单任务成本高(一次任务可能消耗普通对话几十倍的 token),恶意或异常的 Agent 调用会烧钱。必须设单任务的 token 上限和成本上限,超限自动终止。我见过有用户写了个循环触发 Agent 的脚本,一晚上烧掉了平台几万元的算力,就是因为没设单任务上限。

成本控制的配置要分几道闸。第一道是单任务 token 上限(如 30000 token),超过直接中断,这是防"单任务失控"。第二道是单用户单位时间成本上限(如免费用户每小时 Agent 调用消耗不超过 0.5 元),超过限速,这是防"恶意刷"。第三道是全局 Agent 池日预算告警,当天累计成本超过阈值(如日预算的 80%)就告警值班,超过 100% 自动收紧并发上限。这三道闸配合,把成本黑洞的风险压到可控。记账要在编排层做(每次模型/工具调用后实时累加),不能等任务结束才算——任务可能跑 5 分钟,等你算账已经烧超了。

死循环是第二个坑。Agent 可能陷入无限工具调用循环(模型反复调用同一个工具,每次都说"再确认一下")。要设最大轮次(比如一个 Agent 任务最多 20 步),超限终止。

最大轮次的设置要看任务类型——简单工具调用任务(查天气、翻译)5-8 步够了,复杂研究任务(多源检索 + 综合)可能要 30 步。我们的做法是按任务模板配 max_steps,没有模板的默认 20。轮次快到上限时(如 80%)给模型注入一条提示"剩余步数有限,请尽快给出最终答案",让它主动收尾,而不是硬截断。另外前面提到的"工具调用序列指纹"检测也要开,作为死循环的二级防护——即使轮次没到上限,检测到原地打转也终止。

工具权限是第三个坑。Agent 调用的工具(尤其是代码执行、文件读写)要严格沙箱,防止安全风险——一个能执行任意代码的 Agent 工具,如果被恶意 prompt 操纵,可能变成攻击系统的后门。这部分要和本系列的《输入输出过滤与对抗攻击防御》配合,对 Agent 的工具调用做安全约束。

代码执行沙箱我们用的是 gVisor + 资源 cgroup 限制——每个代码执行任务跑在独立的沙箱容器里,限制 CPU(1 核)、内存(512MB)、网络(仅白名单域名)、执行时长(10 秒),任务结束容器销毁。文件读写工具不允许访问宿主路径,只给一个临时工作目录。这些限制看着繁琐,但都是真实被攻击换来的教训——早期我们没限网络,有个用户通过代码执行工具从容器里 curl 内网元数据服务,差点拿到云平台的临时凭证。

第四章的收尾

至此第四章完成。Agent 是重请求,必须独立资源池隔离,否则拖垮全局。调度要控并发、设超时、持久化状态。多路结果用流式编排聚合,边产生边推送,并善用"思考状态"提示改善长任务体验。坑主要在成本黑洞、死循环、工具权限三个方面。

路由与编排层是对话引擎的大脑,决定每个请求如何被最优地服务。这一层加上第三章的缓存层,构成了百万级平台降本的核心组合拳。下一章我们用一套参考架构把全书串起来,并给容量规划与故障推演的方法。


作者与出处
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 会发光的石头的小龙虾 转发
评论区 (0)
U