做Agent Platform那阵子,我满脑子想的都是Agent的规划能力、工具调用、上下文管理这些偏“算法”的东西,代码怎么写、Prompt怎么调、模型怎么选,几乎占满了整个迭代周期。结果真正让我半夜爬起来开电脑的,不是Agent理解错了用户意图,而是上线后第四周的一次线上超时故障。这个平台说白了,就是把一堆外部AI模型能力和内部工具编排起来,对外提供一个统一的Agent调用入口,内部要管任务分发、并发调度和结果聚合。这种架构天然和“慢”绑定在一起,因为一次Agent任务可能要串行调用多个模型接口,单次响应轻则几秒,重则几十秒。也正是这种“慢”,让一次本不起眼的偶发超时,最终演变成全站不可用的级联故障。那次故障之后我才徹底想明白一件事:超时不是一个配置项,而是一整套需要提前设计的系统工程。
这篇文章不打算讲Agent的算法细节,就讲这次真实踩坑的来龙去脉,包括平台的架构背景、故障现场、排查路径、根因分析,以及最后我们做了哪些修复。如果你也在做类似的AI平台、异步任务系统,或者正在维护任何一条重度依赖外部接口的链路,这篇文章应该能帮你躲过几个坑。
1. Agent Platform的架构背景与核心链路设计
1.1 平台定位与整体模块划分
我们做的这个Agent Platform,本质上是把用户请求转成Agent任务,再交给一组执行器去调度模型、调用工具、收集结果,最后统一返回。整体上分成接入层、调度层、执行层、依赖层四块。
| 层级 | 核心模块 | 主要职责 |
|---|---|---|
| 接入层 | 网关服务 | 接收HTTP请求、鉴权、限流、参数校验 |
| 调度层 | 任务队列 + 调度中心 | 拆分任务、维护状态机、分发到执行器 |
| 执行层 | Worker执行器集群 | 驱动Agent循环、调用模型、触发工具、聚合结果 |
| 依赖层 | 模型API、内部RPC、工具服务 | 提供实际能力,是整个链路的下游 |
我印象最深的一版核心链路是这样:用户请求先到网关,网关把请求转成一条任务消息丢进消息队列,调度中心消费后根据任务类型匹配执行器,执行器开始跑Agent循环。Agent循环里会做意图识别、上下文组装、模型调用、工具结果合并,甚至可能递归二次规划。一次完整的任务,往往要经历“主模型调用→工具调用→结果整理→主模型汇总”这样好几轮。
这个设计本身没有大问题,问题出在我对“慢”的预估严重不足。做传统后端的时候,一个RPC超过3秒就觉得不对劲了,但到了Agent场景,一次模型调用等30秒都不稀奇。外部模型接口的响应时间呈现典型的长尾分布,P95可能只有5秒,P99却可能飙到30秒以上。这种慢接口一旦出现在同步调用链路上,线程池一定会被打穿。
1.2 技术选型里埋下的隐患
技术选型上,我们当时用的是Java生态,网关和调度层是Spring Boot,任务队列用了RabbitMQ,执行器是自研的Worker进程,内部通信走gRPC。模型调用这块,因为要接多个厂商的模型API,所以封装了一层统一的Model Client,底层是OKHttp + 重试拦截器。
这里有个很坑的细节。我们在封装Model Client时,给连接超时设了10秒,读超时设了45秒,重试次数设了3次。表面看每个参数都“有讲究”,但后来计算极端情况才发现,一次模型调用理论最差耗时是:连接超时10秒 + 读超时45秒,然后重试3次,也就是(10 + 45)乘以3,等于165秒。而当时Agent链路整体的超时阈值是60秒,调度层超时是30秒。这几组数字互相打架,整个系统根本没有一个统一的超时预算体系。
2. 线上超时故障现场与排查实录
2.1 故障时间线还原
故障发生在某个工作日下午,大概两点四十分左右。当时运营同学正在做一个面向外部用户的活动,流量比平时翻了差不多三倍。我们的监控系统先是在两点四十一分发出一条告警,说网关服务P99延迟超过了8秒,紧接着调度中心的活跃线程数告警也响了。我打开监控面板的时候,Executors的活跃线程显示已经到了上限,RabbitMQ的消息堆积量从几百条一路涨到了十几万条。
从用户体验来看,表现就是请求一直转圈,超过一分钟才返回一个超时错误,部分请求甚至直接连接被重置。更麻烦的是,因为消息队列积压越来越多,调度中心还在持续往Worker里投递新任务,Worker的线程池开始拒绝执行,抛出一堆RejectedExecutionException。我后来看日志,整个故障期大概持续了四十分钟,期间线上成功率跌到35%以下,几乎所有涉及Agent的任务都在报错。
2.2 排查路径:从网关到执行器的逐步定位
那天的排查路径,现在回想起来还是挺典型的,值得记一笔。
第一步是看网关。网关的日志显示,大量请求在等待下游返回,超时集中在调用调度中心同步接口的那一段。也就是说,不是网关本身被压垮,而是它等在等待下游。这就很说明问题了——查了下游。
第二步是看RabbitMQ和调度中心。队列长度暴涨,调度中心消费速度明显跟不上生产速度,但奇怪的是,数据库的负载并不高,Redis也正常。CPU、内存这些基础指标都没到瓶颈,但线程池却全部卡在等待状态。
第三步是看Worker执行器日志。这时候真相已经浮现了——大量Worker线程Block在Model Client的调用上,也就是在等待外部模型API返回。我翻了某个执行器的线程栈,几十个线程全部停在OKHttp的读取响应阶段,没有超时,没有报错,就这么干等。
顺着这个线索去查上游模型API的提供方,才确认是他们的服务出现了一段时间的响应变慢,部分请求甚至要120秒才能返回。我们的Model Client因为有45秒读超时和3次重试的存在,等于把所有慢请求又放大了好几倍。每个任务进入Agent循环后需要调用模型,模型响应慢,任务就卡住,任务卡住,调度层线程池就被占满,消息队列开始积压,网关也开始连锁超时。
2.3 这次故障的关键监控指标
故障期间有几个数据值得记录,我到现在都还在用这几个指标来评判系统健康度:
| 指标 | 正常时段 | 故障时段 |
|---|---|---|
| 网关P99延迟 | 300ms ~ 800ms | 超过20s |
| Worker线程池活跃度 | 30% ~ 50% | 100%持续打满 |
| RabbitMQ消息堆积量 | 0 ~ 100 | 12万+ |
| 外部模型API P95 | 2s | 35s |
| 成功率 | 99.9%以上 | 35%以下 |
我当时盯着这几条数据,心里只有一个想法:外部依赖一旦变慢,我们的整个容错机制几乎等于零。所有线程都在等待,所有重试都在放大,所有队列都在积压,最终把一个小问题滚成了一个大雪崩。
3. 根因分析:为什么一次慢响应就能放倒整个平台
3.1 超时参数互相打架的放大效应
我先不扯理论,直接把这笔账算给你看。假设上游模型API响应变慢,单次响应需要120秒才能返回,而我们的Model Client配置是:
连接超时10秒,读超时45秒,最大重试3次。
一次模型调用的最差耗时就会这样计算:第一次调用,连接成功,但读取等了45秒触发读超时;然后重试第二次,同样等45秒;再重试第三次,还是45秒。这三轮加在一起,耗时就是45乘以3等于135秒,这还没算连接和重试之间的间隔。一个Agent任务如果串行调用两三次模型,那总耗时就是135乘以2或者乘以3,完全超过任何链路超时。
这里面最致命的设定是重试。重试在传统接口场景下确实是保障可靠性的常用手段,因为它假设“重试一次可能就成功了”。但在一个已经超时的外部依赖面前,重试完全是反向操作——你不知道对方是暂时抖动还是已经濒临崩溃,盲目重试只会把雪球越滚越大。我们当时的代码里甚至没有区分“连接失败重试”和“读取超时重试”,只要抛异常就重试,效果就是自己的线程池活活被拖死。
3.2 线程池排队导致的假死状态
很多人以为线程池打满了系统就会立刻报错,其实不是。线程池的拒绝策略我们当时用的是CallerRunsPolicy,意思是线程池满了,任务不会被丢弃,而是回到调用方线程执行。这个策略的本意是缓解压力,但在那种场景下它起到了反向作用:调度中心把任务丢给Worker时发现Worker线程池满了,任务又回到调度中心自己的线程里跑,调度中心的线程也被拖住,然后整个调度链路就变成了一个互相等待的网格。
从外部看,数据库没瓶颈,内存没瓶颈,CPU也不高,但服务就是“假死”了。系统不是没有资源,而是资源全部被阻塞在等待外部响应的状态上,这种状态比资源耗尽更难排查,因为基础监控看起来一切正常。
3.3 链路超时缺失引发的连锁反应
还要提一个链路层面的问题。Agent任务的执行链路比较长,我们当时只在网关做了60秒的总体超时,但内部每个环节都没有设置独立的超时预算。消息队列消费环节没设,调度环节没设,Worker执行环节没设。这就导致一个现象:网关虽然60秒就断开了连接,但任务在后台其实还在执行,Worker线程还在傻等模型响应,队列还在不断投递消息。
断开连接不意味着逻辑停止,这可能是整次故障里最容易被忽略的一环。用户那边已经放弃等待,但我们的系统资源被这些“幽灵任务”继续占用,又拖慢了正常任务的处理。一个超时配置的缺失,直接浪费了后面所有环节的线程和队列资源。
4. 修复方案与落地实施细节
4.1 重新设计统一的超时预算体系
那次故障后,我们做的第一件事,不是改某一行代码,而是重新梳理整条链路的超时预算。核心原则很简单:从用户可接受的最长等待时间出发,从前往后设置每一层超时,且后一层的超时时间不能大于前一层的剩余时间。
具体策略上,我们定了三层预算:
| 环节 | 原配置 | 修复后 | 说明 |
|---|---|---|---|
| 网关总超时 | 60s | 25s | 用户侧等待是硬指标 |
| 调度中心 | 30s | 15s | 必须小于网关总超时 |
| Worker执行 | 无 | 10s | 每轮Agent执行严格卡时间 |
| 单次模型调用 | 连接10s + 读45s | 连接3s + 读8s | 重试次数从3降到1 |
模型调用的读超时从45秒直接砍到8秒,可能有人会觉得太激进,担心正常请求会被误杀。这里要说一下,经我们压测确认,模型服务的P95响应时间在5秒左右,8秒已经足够覆盖绝大多数请求。我们要防的是长尾请求,而不是正常请求。超时设置不是取平均值,也不是取最大值,而是取“你能容忍的最慢响应”,一旦超过就直接失败,快速放出线程。
4.2 重试策略的收敛与熔断降级
重试策略我们也做了大改。原来的3次重试直接砍到1次,而且只允许在连接失败时重试,读取超时一律不重试。这背后的逻辑是:连接失败可能是个别网络节点抖动,重试成功率较高;但读取超时说明服务端已经在处理请求且响应慢,重试只会加重对方压力。
另外,我们引入了一个简易的熔断器。基于本地滑动窗口统计最近100次模型调用的错误率,如果错误率超过50%,熔断器直接打开,后续请求快速失败,不再调用外部模型,而是返回一个降级提示。熔断器每10秒尝试放行一个试探请求,如果试探成功就逐渐恢复。这个机制在后续一次上游抖动中起了大作用,系统P99只涨到了2秒左右,没有再次雪崩。
4.3 线程池隔离与容量计算
调度中心原来只有一个公共线程池处理所有任务,模型调用慢的时候,普通管理操作也被拖累。修复后我们按任务类型拆分了线程池,一个池子只跑Agent任务,一个池子跑轻量管理任务,两者互不干扰。同时把CallerRunsPolicy改成了丢弃策略加告警,避免任务回流拖死调用方。
线程池容量我们也重新算了。参考模型接口的QPS上限和P99延迟,用公式:线程数 = QPS × 响应时间(秒)来估算。如果压测得出模型接口能支撑的并发上限是20,那Worker线程池核心线程数就设置在10,最大线程20,队列长度卡在200,超过就直接拒绝并丢弃。这样做的代价是峰值流量下会丢弃部分任务,但换来的是整个系统不崩溃,任务可以被重新投递或人工介入处理。
4.4 快速失败与结果缓存
还有一个小优化,效果很明显:在调度层加了同参数请求的结果缓存。Agent平台里很多请求其实是相似的,比如同一个工具调用场景,只是入参有一些微调。我们在Redis里给模型返回结果做了一层短期缓存,TTL设了几十秒。高峰期间,同样参数的调用直接命中缓存,跳过模型调用,大大降低了对下游的压力。
那块代码现在看起来很简单:
public ModelResponse callWithCache(Request request, Duration cacheTtl) { String key = buildCacheKey(request); ModelResponse cached = redis.get(key); if (cached != null) { return cached; } ModelResponse response = modelClient.invoke(request); redis.set(key, response, cacheTtl); return response; }这个改动不复杂,但对同类流量高的场景非常有效。活动期间,缓存命中率一度达到40%左右,相当于给模型接口减了快一半的流量压力。
5. 故障复盘清单与实战避坑建议
5.1 超时设计自查清单
这次故障沉淀出的一份自查清单,我后来在团队里反复推广。每次有人要接新的外部依赖,我都会让他按这个清单过一遍:
| 检查项 | 具体内容 | 是否满足 |
|---|---|---|
| 每跳超时 | 所有RPC、HTTP调用都设置了明确的连接和读超时 | 每一项都必须覆盖 |
| 重试上限 | 重试次数是否有明确上限,是否区分了连接失败和读超时 | 读超时尽量不重试 |
| 剩余预算 | 内层超时时间是否小于外层剩余时间 | 遵循超时预算体系 |
| 资源隔离 | 慢依赖是否使用了独立线程池 | 至少隔离核心链路 |
| 熔断降级 | 是否有快速失败机制,失败后是否返回降级结果 | 必须有兜底方案 |
| 幽灵任务 | 用户断连后任务是否还在后台继续执行 | 需要可取消机制 |
5.2 依赖拓扑与慢依赖治理
另一点体会是要把外部依赖当成“有生命的东西”来对待,而不是一个稳定的黑盒。我们在日常开发中很习惯把外部API当成一个可靠的组件来调用,但现实是,所有网络调用都可能失败,所有外部服务的响应时间都可能出现长尾。每次上游服务发布新版本或者做流量调度,我们都应该复盘其对下游的影响。
这次故障其实就是上游模型服务做了一次内部变更导致的响应变慢,他们自己可能都没意识到影响到了下游。所以在Agent平台这种重度依赖外部模型的系统中,我建议把外部依赖的SLA监控做得更细一点,不只是看错误率,还要看P95和P99响应时间的变化趋势。一旦发现P99连续多分钟超过我们设置的读超时阈值,就要立刻考虑降级或切流。
5.3 压测要覆盖慢依赖场景
最后一定建议大家做“慢依赖压测”。常规压测往往只测正常情况下的吞吐和延迟,但真正能检验系统鲁棒性的,是模拟某个外部依赖突然变慢甚至不可用的场景。我们后来专门做了一个故障注入工具,可以动态给指定外部接口增加延迟或者返回错误,然后观察系统整体表现。
这个压测做下来,你会发现很多平时注意不到的坑:某个融断参数太灵敏导致正常流量也被断掉,或者某个线程池的拒绝策略不合理导致雪上加霜。慢依赖压测不是锦上添花,而是Agent平台这类系统的标配测试项目。
那次故障之后,我在团队里立了一个规矩:任何新接入的外部依赖,上线前必须先过一遍超时预算评审、熔断降级评审和慢依赖压测。看起来繁琐,但对比一次线上事故造成的业务损失,这点流程成本真的不算什么。做Agent平台,算法和模型固然重要,但真正决定平台稳定性天花板的,往往是你怎么设计那些看起来不起眼的超时参数。我现在看代码,第一眼一定是找超时和重试配置,这两个东西写得好不好,基本能判断一个系统的稳定性底线。