接到 Agent Platform 的维护任务时,我有心理准备,但没料到线上超时故障会来得那么快。那是一个工作日下午,监控告警先于用户反馈到达:Agent 任务成功率骤降到 78%,接口 P95 延迟从平时的 800ms 涨到 8 秒,紧接着客服群开始有人截图问“为什么机器人不回复”。这套平台在公司内部承担着统一托管 Agent 生命周期的工作,从任务编排、模型调用、工具挂载到上下文管理都跑在一条链路上,线上超时故障一旦出现,受影响的不只是一个接口,而是所有依赖 Agent 能力的业务方。这篇复盘不谈空泛的架构理念,只讲这次真实故障从告警到根因再到修复的全过程,以及我给同类 Agent 平台总结的可复用经验。
1. 项目背景:为什么需要一套 Agent Platform
1.1 团队各自造轮子带来的混乱
最开始时,公司里并没有统一的 Agent 平台。各个业务团队各干各的:推荐团队自己写脚本调大模型,客服团队自己维护一套多轮对话逻辑,内容生产团队则用定时任务批量跑生成。代码仓库零散,prompt 模板散落在各个项目中,模型 API 的 Key 也各自管理。表面上看大家都很自由,实际上问题一堆:出故障时谁都说不清自己调的模型实例状态如何,也没有统一的日志和链路追踪,更谈不上对 Token 成本做整体管控。
Agent Platform 的提出就是为了解决这些痛点。它统一接收上游业务请求,负责 Agent 实例的编排调度、调用外部模型和工具、维护会话上下文,并统一暴露 API 给各个业务方。有了这个平台,新业务接入 Agent 能力不再需要从零搭建,只需要在配置中心写一套 Agent 定义,按约定调用接口就行。作为平台开发者,我们考虑的是三个核心指标:第一,Agent 任务的吞吐;第二,端到端延迟;第三,故障时的隔离性。第三个指标在当时并没有被足够重视,这也为后来的线上超时故障埋下了伏笔。
1.2 平台核心模块与技术选型
Agent Platform 在早期版本里主要分成这几个模块:
| 模块 | 职责 | 选型 |
|---|---|---|
| API Gateway | 统一接入、鉴权、限流 | Spring Cloud Gateway |
| Agent Runtime | 执行 Agent 编排逻辑,调用模型和工具 | Java Spring Boot,固定线程池处理任务 |
| Task Scheduler | 接收异步任务,做持久化和调度 | Redis + RabbitMQ |
| Model Gateway | 统一封装大模型 API,处理密钥和协议转换 | 自研 HTTP 客户端 |
| Context Store | 保存会话和上下文 | Redis + MySQL |
选型时更多考虑的是团队技术栈的熟悉程度,Spring Boot 生态比较成熟,接入监控、链路追踪都有现成组件。任务调度使用 RabbitMQ 是因为团队已经依赖它做异步消息,Redis 则用来存储短期会话状态。整个平台是无状态设计,部署在 Kubernetes 上,每个服务都有三个以上副本。Agent Runtime 的默认线程池配置是 200 个线程,队列容量 1000,这个数字看起来不小,但后面我们会看到,如果没有合理的隔离和超时控制,再大的线程池也会被外部依赖一秒打穿。上述模块划分和参数取值不是标准答案,而是根据同类 Agent 平台项目常见实践补全的示例,实际落地需要结合团队情况调整。
1.3 故障前的部署拓扑与流量特征
故障发生前,平台的拓扑大概是这样的:业务方的请求先到 API Gateway,然后根据 Agent ID 转发到 Agent Runtime;Runtime 内部走编排流程,需要模型回答时通过 Model Gateway 调用大模型 API,需要查询业务数据时调用内部工具服务。任务有同步和异步两种,同步请求通常是聊天对话,要求实时返回;异步请求则是批量处理、文档总结等,通过 Task Scheduler 落地。
日常流量并不算高,白天平均 QPS 在 300 左右,高峰能到 600。绝大部分请求是同步的对话类任务,平均一次调用会经历一到两次模型 API 请求。模型 API 的 P95 延迟平时在 800ms 到 1.2s 之间。问题发生在一次模型服务提供方发布新版模型之后的两个小时,对方服务出现间歇性变慢,部分请求需要 10 秒以上才返回。这正是线上超时故障最经典的引信:平台侧的代码没有变化,但外部依赖的延迟特性变了,整个系统的承压能力被瞬间拉高了要求。
2. 线上超时故障现场:从告警到定位
2.1 告警第一响:别急着看代码,先看现象
告警是从监控系统推过来的,消息内容是“Agent API 成功率低于 80% 持续 5 分钟”。我当时的第一个动作不是打开 IDE,而是先看监控面板上的全局状态。那个下午的真实情况是:入口成功率的曲线从原来的 99.9% 直接掉到 78%,平均响应时间从 650ms 涨到 4.5s,P95 到 8s,P99 直接超时被网关截断。用户感知就是机器人“转圈转了很久然后报错”。
第一反应要克制。人在故障时容易直接去查代码、找最近发布,但更有效的方式是先确认影响面。我快速做了三件事:第一,看 API Gateway 的错误状态码分布,判断是服务端错误还是客户端超时;第二,看 Kubernetes 里的 Pod 状态和资源使用,判断是否有实例宕机;第三,看外部依赖的健康状态,包括模型 API、Redis、MySQL 的延迟。结果发现 Pod 都活着,CPU 和内存也没有异常,但 Agent Runtime 的线程池活跃线程数长期顶着上限,这说明问题出在内部阻塞,而不是资源耗尽或宕机。
2.2 监控数据缩小范围:外部依赖成为最大嫌疑
有了“内部阻塞”的判断后,我打开链路追踪按服务维度拆延迟。在 OpenTelemetry 的看板上,一次完整请求被拆成 API Gateway 耗时、Agent Runtime 耗时、Model Gateway 耗时、外部工具耗时。对比故障前后的数据,最明显的变化是 Model Gateway 到外部模型 API 这一段:正常时耗时在 800ms 左右,故障时大量请求的耗时超过 5 秒,部分达到 15 秒。
这里要特别强调的是,Model Gateway 本身并不是瓶颈,它的线程也在等待外部 HTTP 响应。Agent Runtime 的线程池才是真正被拖垮的地方:每个同步任务占用一个线程,这个线程在调用模型 API 期间一直处于阻塞等待状态,而等待时间从原来的 1 秒左右变成 5 到 15 秒。由于线程释放速度变慢,新的请求进不来,在队列里越积越多。队列满之后,新请求直接抛出 RejectedExecutionException,API Gateway 看到大量 500 和超时,于是成功率的下降曲线和延迟的上升曲线几乎同步出现。外部依赖变慢是“因”,线程池被占满就是“果”。
2.3 链路追踪与线程转储:找到具体阻塞点
监控数据给了方向,但要确认根因还需要更硬核的证据。我抓了几份 Agent Runtime 的线程转储,命令是 jstack,连续抓了三次,每次间隔 10 秒。从线程栈里能看到大量线程都停留在大模型 HTTP 客户端的SocketInputStream.read上,这说明线程确实是在等待网络响应,而不是在做计算。同时在线程池队列里看到大量排队中的任务,而工作线程数已经达到 maximumPoolSize。
链路追踪里的另一个细节也很有意思:同一个用户的一次对话,可能在 Agent Runtime 里触发两次模型调用,第一次是意图识别,第二次是生成回答。正常情况下两次调用并行度并不高,但在外部服务变慢时,第二次调用会等待第一次的结果返回后才发起,导致请求整体耗时变成外部依赖耗时的两倍。这个等待模型是 Agent 场景很常见的特征,也意味着外部依赖延迟对 Agent 平台的放大效应比普通 Web 服务更严重。
2.4 压测复现:把故障固定在实验室
当时我们判断问题已经比较明确,但为了避免误伤,还是决定在压测环境里复现一次。处理方式是用 GoReplay 录制线上真实流量,然后把模型 API 的响应延迟通过 Mock 服务人为抬高到 3 秒和 10 秒两种档位,再用 JMeter 以线上 QPS 的 1.5 倍去压 Agent Runtime。结果非常稳定:模型 API 延迟为 3 秒时,线程池活跃线程数在 10 分钟内从 80 涨到 200,P95 延迟从 1.2s 升到 6s;模型 API 延迟为 10 秒时,5 分钟内直接开始报队列满了。复现证明,第一次故障不是偶发,而是系统性的设计缺陷。
压测还有一个额外收获:我们发现即使模型 API 恢复正常,平台也需要大约 8 分钟才能把积压的队列消费完,期间新请求依然会超时。这解释了为什么那次故障线上持续了近 40 分钟,而不是外部服务一恢复平台就立刻跟着恢复。阻塞型线程池像水管里的一段窄口,前面存了水,后面就过不去,等外部依赖恢复后,积压的任务还要继续消耗线程资源。
3. 根因分析:不是单点问题,是三个小问题叠加
3.1 线程池容量假象:所有线程都被外部等待占满
很多团队在配置线程池时会陷入一个常见误区,认为线程数越大越好。Agent Runtime 当时配置了 200 个线程,单个实例 QPS 按 100 来算,理论上应该足够。但这里遗漏了一个关键点:线程池中的线程如果长时间被阻塞的外部调用占用,线程池的“处理能力”会趋近于零,而不是像我们直觉理解的那样还能腾出资源去处理新任务。
用一个生活中的类比来说明:银行柜台开 20 个窗口,看起来能同时服务 20 个客户,但如果每个客户都在窗口前等电话确认而迟迟不办理,那第 21 个客户就只能在候厅里等。线程池里的线程就是窗口,外部模型 API 就是那个迟迟不给确认的电话。真正有效的容量指标不是线程数的总和,而是“线程数 ÷ 平均阻塞时间”。当平均阻塞时间从 1 秒变成 15 秒,单位时间能处理的任务量直接掉到原来的 1/15,线程池再大也没有用。
3.2 重试策略:好心办坏事,把故障放大成雪崩
如果说线程池被占满是故障的直接原因,那重试策略就是雪崩的加速器。Model Gateway 的代码里有一个自己写的重试组件,外部调用失败或者超时后会自动重试 3 次,退避策略是固定等待 500ms。平时外部依赖健康时,这个重试几乎不会触发,大家也就没在意。但当外部模型 API 开始变慢时,一次模型调用可能超过等待阈值触发超时,超时后重试又发起新的请求,原本只需要发出 1 次的外部请求,因为超时被放大成 3 到 4 次。
更糟糕的是,这些重试请求还会继续占用 Agent Runtime 的线程。一个任务在重试期间,它的线程始终没有被释放,同时新的重试请求也在往模型 API 上打,导致外部依赖收到的负载不降反升。这就是典型的“重试风暴”。本来外部服务只是变慢,被重试一放大,直接变成过载,恢复时间也被拉长。事后我们看模型服务方给的监控数据,故障时段他们看到的调用量比平时高了 2.8 倍,其中大部分来自我们的重试。
3.3 缺少故障隔离:一个租户的任务拖垮所有人
第三个问题是在多租户场景下缺少隔离。Agent Platform 服务了多个业务方,推荐、客服、内容生成都在同一个 Agent Runtime 集群里,用的是同一个线程池和同一个队列。故障当天真正触发问题的,其实是一个内容团队提交的大批量文档总结任务,这些任务单个执行时间本身就长,占用的线程数量又多。外部模型 API 变慢后,长任务的线程迟迟不结束,后面进来的客服对话在队列里等不到线程,于是客服业务也一起超时。
这在架构上属于典型的“没有舱壁”。如果每个租户或每类任务都有独立的线程池和队列,那么内容团队的批量任务再慢,也只是消耗它自己的那部分资源,客服对话仍然可以走自己的线程池正常处理。缺少隔离还有一个隐藏成本:排障时需要反复沟通确认是哪个业务方在跑大批量任务,既浪费时间又容易误判。多租户系统如果不能在资源层面做隔离,稳定性就无从谈起。
4. 处理方案与工程改造
4.1 紧急止血:限流、熔断、降级三步走
定位到根因后,线上第一件事不是改代码,而是止血。我们在 API Gateway 层按租户维度加了限流,每个租户的 QPS 限制到故障前的 70%,超过部分直接返回“系统繁忙,请稍后重试”。这个操作虽然会损失一部分请求,但能保护 Agent Runtime 不再继续堆积任务。接着在 Model Gateway 里临时打开了熔断逻辑:连续 20 个调用里有 50% 超时或失败时,直接快速失败,不再发起新的外部请求,让线程立刻释放。
降级方面,我们把非核心的 Agent 类型暂时停用,比如文档总结和批量生成任务,只保留客服对话和在线问答。这样做的意义在于,把有限的线程资源优先供给最核心的实时链路。这套“限流+熔断+降级”的组合拳在 15 分钟内稳住了成功率,积压任务逐渐被消化,用户反馈也明显减少。核心思路其实很朴素:故障时先保核心链路,再谈其他。
4.2 舱壁模式:给每个租户和任务类型独立线程池
止血只能解决当下,真正要改的是架构。我们做的第一项长期改造是引入舱壁模式,把原来的共享线程池拆成多个独立线程池。具体做法是:在 Agent Runtime 中为每个租户创建一个线程池,同时为同步任务和异步任务创建不同的线程池。每个线程池的大小根据租户的历史峰值和任务类型单独配置,并设置最大队列长度,超过队列就直接拒绝而不是无限堆积。
这里给出一个配置示例,实际代码不复杂,关键在于设计时的参数计算。
@Configuration public class AgentThreadPoolConfig { @Bean("tenantThreadPoolManager") public ThreadPoolManager tenantThreadPoolManager() { // key 为租户ID,value 为对应线程池 Map<String, ExecutorService> pools = new ConcurrentHashMap<>(); // 推荐业务:核心线程 50,最大线程 50,队列 200 pools.put("recommend", createPool(50, 50, 200)); // 客服业务:核心线程 80,最大线程 80,队列 300 pools.put("customer-service", createPool(80, 80, 300)); // 内容生成:核心线程 20,最大线程 20,队列 100 pools.put("content", createPool(20, 20, 100)); return new ThreadPoolManager(pools); } private ExecutorService createPool(int core, int max, int queueSize) { return new ThreadPoolExecutor( core, max, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueSize), new NamedThreadFactory("agent-tenant-", true), new AbortPolicyWithReport() ); } }注意这里故意没有使用“核心线程数小于最大线程数”的动态扩容配置,是因为在外部依赖阻塞型场景下,动态扩容很容易变成无限创建线程,反而加剧服务过载。固定线程数加明确队列上限,行为更容易预测。同时,对模型 API 的并发调用单独加了一个 Semaphore,比如限制全局同时最多只能有 30 个模型请求在途,避免单个实例对上游造成过大压力。
4.3 超时与重试规范:把“快速失败”写进代码
线程池隔离只能解决资源争抢,如果某个外部调用的超时时间设置不合理,独立线程池同样会被拖垮。我们重新梳理了所有外部调用的超时参数。HTTP 客户端从连接建立、写入请求到读取响应,分别设置连接超时 2 秒,读取超时 8 秒。比起之前的 60 秒总超时,这几乎是雷霆手段,但实际效果很好,因为大模型 API 正常响应都在 2 秒以内,超过 8 秒的调用大概率是异常状态,继续等待只会让线程长时间挂起。
重试逻辑我们也改了。新规则是:只有接口具备幂等性才允许重试,并且最多重试 1 次;重试使用指数退避加随机抖动,第一次重试等待 200ms,第二次(如果还有)等待 500ms,整体重试预算不超过 2 秒。更关键的是引入了超时传播:API Gateway 收到请求时记录一个总超时时间(比如 10 秒),每次调用下游前,把剩余时间传给下游,下游使用的超时时间是“剩余时间”而不是自己的固定值。这一条能有效避免一个请求在多个环节各自等待,造成整体超时远超用户可接受范围。
4.4 异步化与缓存优化:降低对同步依赖的要求
Agent 场景里,并不是所有任务都需要同步等待结果。我们对任务类型做了进一步梳理,把批量总结、定时生成这类对实时性要求不高的任务全部改为异步模式。异步任务的流程是:请求先落 RabbitMQ,Task Scheduler 消费后调用模型 API,结果写入 Redis,业务方通过回调或轮询获取。异步化之后,这些长任务不再占用 Agent Runtime 的同步线程池,天然起到了削峰作用。
缓存方面,我们对一些重复性较高的通用任务加了响应缓存。比如“根据产品介绍生成一段推荐语”这类 prompt 固定、内容变化不大的请求,命中缓存后直接返回,连模型 API 都不用调用。缓存使用 Redis,TTL 设置为 10 分钟,同时加上请求参数哈希作为 key。这个优化在故障时期的价值是:即便外部模型 API 仍然抖动,只要有一部分请求能命中缓存,平台对外表现就不会全挂。缓存不是万能药,但它能显著降低对外部依赖的敏感度。
5. 影响范围与复盘总结
5.1 故障影响范围:不只是慢几分钟
这次线上超时故障持续了大约 40 分钟,表面影响是成功率下降、延迟升高,但实际影响比监控数据要大。首先,客服对话的中断直接导致部分用户重复发起提问,客服系统的消息量在故障后半小时内增加了 20%;其次,内容团队的批量任务全部失败,任务队列里积压了十几万个待处理项,恢复后需要重新调度;再次,故障期间为了解决临时限流,业务方不得不在自己的网关里加配置,这本身也带来了跨团队沟通成本。
从数据上看,故障期间全平台失败请求数约占当天总请求量的 6%,看起来不算高,但因为集中在高峰时段,用户感知非常明显。更值得关注的是信任成本:故障之后,业务方对平台稳定性的信心下降,产品团队要求我们出具详细的 SLA 承诺和故障说明,后续每次模型 API 出现波动时,都会有业务方第一时间来问“会不会又像上次一样”。稳定性问题的修复成本,往往比故障本身的损失更大。
5.2 监控体系改进:提前发现线程池排队
之前的监控主要关注入口的成功率、延迟和 CPU 使用率,但这些指标都比较滞后。线程池的活跃线程数、队列深度、被拒绝的任务数,这些内部指标才是更早的预警信号。我们现在为每个租户线程池都暴露了 Micrometer 指标,包括 active 线程数、queue size、task count,并配置了三档告警。比如队列深度超过上限 60% 时触发 Warning,超过 80% 时触发 Critical,同时把“线程池拒绝任务数大于 0”作为最高优先级告警。
告警阈值不是拍脑袋定的,我们根据压测数据做了容量模型:单实例线程池 50、队列 200,能支撑 QPS 50 的任务,如果延迟 P95 超过 2 秒,队列就开始增长。这样在线上我们就能看到“队列深度在持续上升”这个先兆,而不是等成功率掉下来才去抢救。链路追踪的覆盖范围也扩大了,所有外部调用无论成功失败都会记录耗时和状态码,并在看板里按外部依赖维度聚合。
5.3 故障演练与混沌工程:把依赖当成“不可靠变量”
经历这次故障后,我们在团队里达成了一个共识:任何外部依赖都不能假设为永远健康,包括大模型 API、内部工具服务、Redis、MySQL。为了验证熔断和隔离是否真的管用,我们每个月做一次故障演练。比较简单的方式是往 Model Gateway 里注入 2000ms 的额外延迟,持续 5 分钟,观察线程池隔离和熔断是否按照预期触发。后来干脆做了一个演练开关,可以在测试环境随机让某个外部接口返回 500,验证平台是否会自动降级。
混沌工程听起来高大上,其实核心就是“在可控范围内提前破坏系统,验证防御措施有效”。我们不做那种把线上整个区域的黑名单全部拔掉的极端动作,只做最小化的依赖故障注入。演练过程中确实发现过新的问题,比如某些租户线程池的队列设置过大,导致熔断后仍然堆积了大量请求。这些问题都在演练阶段被发现,至少没有等到下一次线上故障才暴露。
5.4 给 Agent 平台开发者的几条架构建议
总结这次踩坑,我会把最重要的几条建议列在这里,给正在做同类 Agent 平台的团队参考。
- 外部调用必须有超时、熔断、重试预算三个配置,缺一个都别上线。
- 线程池要按租户或任务类型隔离,池大小按阻塞耗时来算,而不是按 QPS 来算。
- 重试前先问一句“这个接口幂等吗”,重试次数宁可少不可多。
- Agent 任务能异步就异步,不是所有请求都需要实时返回。
- 监控一定要看到线程池队列深度和拒绝次数,只看成功率会错过最佳处理窗口。
- 每次外部依赖波动之后,都要重新做容量评估,因为依赖的延迟分布会变。
这些建议并不是我第一次接触时就懂,而是被线上超时故障教育之后才真正想明白的。技术选型阶段没人会觉得自己需要这些,但等到线上出问题时,后悔就晚了。
最后再分享一点个人体会:Agent Platform 和其他业务系统最大的不同,是它把大部分时间都花在“等待外部结果”上,而不是“计算”上。这样的系统对故障隔离和快速失败的要求,比普通接口服务高一个量级。我在处理完这次故障后,给团队定了一条规矩,任何涉及外部调用的代码评审,都必须能看到超时、熔断和隔离三个相关配置,否则一律打回。这个规矩执行到现在,新接入的 Agent 工具再没有出现过因为外部依赖抖动导致的全局超时。踩过的坑不再踩第二次,这可能就是这次线上超时故障留给我们最实在的价值。